feat: add Civilization II-lite game engine and UI
Implement a complete turn-based strategy game with: - Deterministic headless engine: cities, units, combat, research, governments - Seeded world generation with noise-based terrain, continents, quality scoring - Isometric map renderer with procedural fallbacks and optional sprite sheets - AI rivals with strategy phases, diplomacy, trade routes, and unit control - Full UI: setup, city screen, tech tree, diplomacy, spaceship tracker, victory - 80 techs, 51 units, 26 buildings, 11 terrains, 6 governments, 5 difficulties - Comprehensive headless verification suite (rules integrity, worldgen, combat, AI self-play)
This commit is contained in:
parent
bd693e0191
commit
67a7ac857e
Binary file not shown.
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 315 KiB |
Binary file not shown.
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"_readme": [
|
||||||
|
"Drop-in art for Civilization. Full spec with frame maps: src/games/civilization/sprites.md.",
|
||||||
|
"Every sheet is optional: path null = not painted yet, the game renders that layer procedurally.",
|
||||||
|
"To ship a sheet: put the PNG at the documented path under public/assets/images/civilization/",
|
||||||
|
"and set `path` here (paths are relative to the web root, no leading slash).",
|
||||||
|
"Frame order is row-major, 0-based. Frame indexes are pinned in data/civilization-rules.json",
|
||||||
|
"(units[].frame, terrains[].frame, specials[].frame) — append-only, never renumber.",
|
||||||
|
"citySheets is keyed by theme. Adding e.g. an `asian` entry makes it selectable with no code change."
|
||||||
|
],
|
||||||
|
"terrainSheet": { "key": "civilization-terrain", "path": null, "frameWidth": 128, "frameHeight": 96 },
|
||||||
|
"resourceSheet": { "key": "civilization-resources", "path": null, "frameWidth": 64, "frameHeight": 64 },
|
||||||
|
"improvementSheet": { "key": "civilization-improvements", "path": null, "frameWidth": 64, "frameHeight": 64 },
|
||||||
|
"unitSheet": { "key": "civilization-units", "path": null, "frameWidth": 64, "frameHeight": 64 },
|
||||||
|
"iconSheet": { "key": "civilization-icons", "path": null, "frameWidth": 48, "frameHeight": 48 },
|
||||||
|
"citySheets": {
|
||||||
|
"classic": { "key": "civilization-cities-classic", "path": null, "frameWidth": 128, "frameHeight": 96 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
{
|
||||||
|
"_readme": [
|
||||||
|
"Civilization (Civ II-lite) rules data. All techs/units/buildings/terrain are data-driven;",
|
||||||
|
"src/games/civilization/CivilizationRules.js compiles and validates this file, and",
|
||||||
|
"tools/verifyCivilization.js enforces integrity (acyclic tech DAG, all prereqs resolve, etc.).",
|
||||||
|
"Simplifications vs Civ II (by design): no happiness/tax sliders, no senate, no wonders,",
|
||||||
|
"no pollution, no espionage units, no rivers. Cut techs: Theology, Espionage, Fundamentalism,",
|
||||||
|
"Environmentalism, Genetic Engineering, Recycling. Cut buildings: Temple, Colosseum, Cathedral,",
|
||||||
|
"Police Station, Mass Transit, Recycling Center, Solar Plant (happiness/pollution systems removed).",
|
||||||
|
"Frame indexes reference the sheets documented in src/games/civilization/sprites.md;",
|
||||||
|
"all sheets are optional (procedural fallback), see data/civilization-artwork.json."
|
||||||
|
],
|
||||||
|
"version": 1,
|
||||||
|
|
||||||
|
"techs": [
|
||||||
|
{ "id": "alphabet", "name": "Alphabet", "era": "ancient", "prereqs": [] },
|
||||||
|
{ "id": "bronzeworking", "name": "Bronze Working", "era": "ancient", "prereqs": [] },
|
||||||
|
{ "id": "ceremonialburial", "name": "Ceremonial Burial", "era": "ancient", "prereqs": [] },
|
||||||
|
{ "id": "horsebackriding", "name": "Horseback Riding", "era": "ancient", "prereqs": [] },
|
||||||
|
{ "id": "masonry", "name": "Masonry", "era": "ancient", "prereqs": [] },
|
||||||
|
{ "id": "pottery", "name": "Pottery", "era": "ancient", "prereqs": [] },
|
||||||
|
{ "id": "thewheel", "name": "The Wheel", "era": "ancient", "prereqs": [] },
|
||||||
|
{ "id": "warriorcode", "name": "Warrior Code", "era": "ancient", "prereqs": [] },
|
||||||
|
{ "id": "codeoflaws", "name": "Code of Laws", "era": "ancient", "prereqs": ["alphabet"] },
|
||||||
|
{ "id": "writing", "name": "Writing", "era": "ancient", "prereqs": ["alphabet"] },
|
||||||
|
{ "id": "mapmaking", "name": "Map Making", "era": "ancient", "prereqs": ["alphabet"] },
|
||||||
|
{ "id": "mathematics", "name": "Mathematics", "era": "ancient", "prereqs": ["alphabet", "masonry"] },
|
||||||
|
{ "id": "currency", "name": "Currency", "era": "ancient", "prereqs": ["bronzeworking"] },
|
||||||
|
{ "id": "ironworking", "name": "Iron Working", "era": "ancient", "prereqs": ["bronzeworking", "warriorcode"] },
|
||||||
|
{ "id": "monarchy", "name": "Monarchy", "era": "ancient", "prereqs": ["ceremonialburial", "codeoflaws"] },
|
||||||
|
{ "id": "polytheism", "name": "Polytheism", "era": "ancient", "prereqs": ["horsebackriding", "ceremonialburial"] },
|
||||||
|
{ "id": "literacy", "name": "Literacy", "era": "ancient", "prereqs": ["writing", "codeoflaws"] },
|
||||||
|
{ "id": "therepublic", "name": "The Republic", "era": "ancient", "prereqs": ["codeoflaws", "literacy"] },
|
||||||
|
{ "id": "philosophy", "name": "Philosophy", "era": "ancient", "prereqs": ["codeoflaws", "literacy"] },
|
||||||
|
{ "id": "mysticism", "name": "Mysticism", "era": "ancient", "prereqs": ["ceremonialburial", "philosophy"] },
|
||||||
|
{ "id": "astronomy", "name": "Astronomy", "era": "ancient", "prereqs": ["mysticism", "mathematics"] },
|
||||||
|
{ "id": "construction", "name": "Construction", "era": "ancient", "prereqs": ["masonry", "currency"] },
|
||||||
|
{ "id": "trade", "name": "Trade", "era": "ancient", "prereqs": ["currency", "codeoflaws"] },
|
||||||
|
{ "id": "seafaring", "name": "Seafaring", "era": "ancient", "prereqs": ["pottery", "mapmaking"] },
|
||||||
|
|
||||||
|
{ "id": "feudalism", "name": "Feudalism", "era": "medieval", "prereqs": ["warriorcode", "monarchy"] },
|
||||||
|
{ "id": "chivalry", "name": "Chivalry", "era": "medieval", "prereqs": ["feudalism", "horsebackriding"] },
|
||||||
|
{ "id": "monotheism", "name": "Monotheism", "era": "medieval", "prereqs": ["philosophy", "polytheism"] },
|
||||||
|
{ "id": "banking", "name": "Banking", "era": "medieval", "prereqs": ["trade", "therepublic"] },
|
||||||
|
{ "id": "university", "name": "University", "era": "medieval", "prereqs": ["astronomy", "philosophy"] },
|
||||||
|
{ "id": "navigation", "name": "Navigation", "era": "medieval", "prereqs": ["seafaring", "astronomy"] },
|
||||||
|
{ "id": "physics", "name": "Physics", "era": "medieval", "prereqs": ["literacy", "navigation"] },
|
||||||
|
{ "id": "engineering", "name": "Engineering", "era": "medieval", "prereqs": ["thewheel", "construction"] },
|
||||||
|
{ "id": "invention", "name": "Invention", "era": "medieval", "prereqs": ["writing", "engineering"] },
|
||||||
|
{ "id": "gunpowder", "name": "Gunpowder", "era": "medieval", "prereqs": ["invention", "ironworking"] },
|
||||||
|
{ "id": "medicine", "name": "Medicine", "era": "medieval", "prereqs": ["philosophy", "trade"] },
|
||||||
|
{ "id": "bridgebuilding", "name": "Bridge Building", "era": "medieval", "prereqs": ["ironworking", "construction"] },
|
||||||
|
{ "id": "sanitation", "name": "Sanitation", "era": "medieval", "prereqs": ["engineering", "medicine"] },
|
||||||
|
{ "id": "economics", "name": "Economics", "era": "medieval", "prereqs": ["banking", "university"] },
|
||||||
|
{ "id": "chemistry", "name": "Chemistry", "era": "medieval", "prereqs": ["university", "medicine"] },
|
||||||
|
{ "id": "theoryofgravity", "name": "Theory of Gravity", "era": "medieval", "prereqs": ["astronomy", "physics"] },
|
||||||
|
{ "id": "magnetism", "name": "Magnetism", "era": "medieval", "prereqs": ["physics", "ironworking"] },
|
||||||
|
{ "id": "metallurgy", "name": "Metallurgy", "era": "medieval", "prereqs": ["gunpowder", "university"] },
|
||||||
|
{ "id": "leadership", "name": "Leadership", "era": "medieval", "prereqs": ["chivalry", "gunpowder"] },
|
||||||
|
|
||||||
|
{ "id": "democracy", "name": "Democracy", "era": "industrial", "prereqs": ["banking", "invention"] },
|
||||||
|
{ "id": "conscription", "name": "Conscription", "era": "industrial", "prereqs": ["democracy", "metallurgy"] },
|
||||||
|
{ "id": "explosives", "name": "Explosives", "era": "industrial", "prereqs": ["gunpowder", "chemistry"] },
|
||||||
|
{ "id": "steamengine", "name": "Steam Engine", "era": "industrial", "prereqs": ["physics", "invention"] },
|
||||||
|
{ "id": "railroad", "name": "Railroad", "era": "industrial", "prereqs": ["steamengine", "bridgebuilding"] },
|
||||||
|
{ "id": "industrialization", "name": "Industrialization", "era": "industrial", "prereqs": ["railroad", "banking"] },
|
||||||
|
{ "id": "thecorporation", "name": "The Corporation", "era": "industrial", "prereqs": ["economics", "industrialization"] },
|
||||||
|
{ "id": "refining", "name": "Refining", "era": "industrial", "prereqs": ["chemistry", "thecorporation"] },
|
||||||
|
{ "id": "electricity", "name": "Electricity", "era": "industrial", "prereqs": ["metallurgy", "magnetism"] },
|
||||||
|
{ "id": "steel", "name": "Steel", "era": "industrial", "prereqs": ["electricity", "industrialization"] },
|
||||||
|
{ "id": "combustion", "name": "Combustion", "era": "industrial", "prereqs": ["refining", "explosives"] },
|
||||||
|
{ "id": "automobile", "name": "Automobile", "era": "industrial", "prereqs": ["combustion", "steel"] },
|
||||||
|
{ "id": "tactics", "name": "Tactics", "era": "industrial", "prereqs": ["conscription", "leadership"] },
|
||||||
|
{ "id": "communism", "name": "Communism", "era": "industrial", "prereqs": ["philosophy", "industrialization"] },
|
||||||
|
{ "id": "guerrillawarfare", "name": "Guerrilla Warfare", "era": "industrial", "prereqs": ["communism", "tactics"] },
|
||||||
|
{ "id": "atomictheory", "name": "Atomic Theory", "era": "industrial", "prereqs": ["theoryofgravity", "physics"] },
|
||||||
|
{ "id": "electronics", "name": "Electronics", "era": "industrial", "prereqs": ["electricity", "engineering"] },
|
||||||
|
{ "id": "refrigeration", "name": "Refrigeration", "era": "industrial", "prereqs": ["electricity", "sanitation"] },
|
||||||
|
{ "id": "machinetools", "name": "Machine Tools", "era": "industrial", "prereqs": ["steel", "tactics"] },
|
||||||
|
{ "id": "flight", "name": "Flight", "era": "industrial", "prereqs": ["combustion", "theoryofgravity"] },
|
||||||
|
{ "id": "radio", "name": "Radio", "era": "industrial", "prereqs": ["flight", "electricity"] },
|
||||||
|
{ "id": "amphibiouswarfare", "name": "Amphibious Warfare", "era": "industrial", "prereqs": ["navigation", "tactics"] },
|
||||||
|
|
||||||
|
{ "id": "advancedflight", "name": "Advanced Flight", "era": "modern", "prereqs": ["radio", "machinetools"] },
|
||||||
|
{ "id": "rocketry", "name": "Rocketry", "era": "modern", "prereqs": ["advancedflight", "electronics"] },
|
||||||
|
{ "id": "mobilewarfare", "name": "Mobile Warfare", "era": "modern", "prereqs": ["automobile", "tactics"] },
|
||||||
|
{ "id": "combinedarms", "name": "Combined Arms", "era": "modern", "prereqs": ["mobilewarfare", "advancedflight"] },
|
||||||
|
{ "id": "massproduction", "name": "Mass Production", "era": "modern", "prereqs": ["automobile", "thecorporation"] },
|
||||||
|
{ "id": "laborunion", "name": "Labor Union", "era": "modern", "prereqs": ["massproduction", "guerrillawarfare"] },
|
||||||
|
{ "id": "nuclearfission", "name": "Nuclear Fission", "era": "modern", "prereqs": ["massproduction", "atomictheory"] },
|
||||||
|
{ "id": "nuclearpower", "name": "Nuclear Power", "era": "modern", "prereqs": ["nuclearfission", "electronics"] },
|
||||||
|
{ "id": "laser", "name": "Laser", "era": "modern", "prereqs": ["massproduction", "nuclearpower"] },
|
||||||
|
{ "id": "miniaturization", "name": "Miniaturization", "era": "modern", "prereqs": ["machinetools", "electronics"] },
|
||||||
|
{ "id": "computers", "name": "Computers", "era": "modern", "prereqs": ["massproduction", "miniaturization"] },
|
||||||
|
{ "id": "plastics", "name": "Plastics", "era": "modern", "prereqs": ["refining", "spaceflight"] },
|
||||||
|
{ "id": "spaceflight", "name": "Space Flight", "era": "modern", "prereqs": ["computers", "rocketry"] },
|
||||||
|
{ "id": "superconductor", "name": "Superconductor", "era": "modern", "prereqs": ["nuclearpower", "laser"] },
|
||||||
|
{ "id": "robotics", "name": "Robotics", "era": "modern", "prereqs": ["plastics", "mobilewarfare"] },
|
||||||
|
{ "id": "stealth", "name": "Stealth", "era": "modern", "prereqs": ["superconductor", "advancedflight"] },
|
||||||
|
{ "id": "fusionpower", "name": "Fusion Power", "era": "modern", "prereqs": ["nuclearpower", "superconductor"] },
|
||||||
|
{ "id": "futuretech", "name": "Future Tech", "era": "modern", "prereqs": ["fusionpower"], "repeatable": true }
|
||||||
|
],
|
||||||
|
|
||||||
|
"units": [
|
||||||
|
{ "id": "settlers", "name": "Settlers", "domain": "land", "attack": 0, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 40, "prereq": null, "obsoletedBy": "engineers", "flags": ["settler", "noncombat"], "abbr": "ST", "frame": 0 },
|
||||||
|
{ "id": "engineers", "name": "Engineers", "domain": "land", "attack": 0, "defense": 2, "move": 2, "hp": 20, "fp": 1, "cost": 40, "prereq": "explosives", "obsoletedBy": null, "flags": ["settler", "engineer", "noncombat"], "abbr": "EN", "frame": 1 },
|
||||||
|
{ "id": "explorer", "name": "Explorer", "domain": "land", "attack": 0, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 30, "prereq": "seafaring", "obsoletedBy": null, "flags": ["ignoreterrain", "noncombat"], "abbr": "EX", "frame": 2 },
|
||||||
|
{ "id": "caravan", "name": "Caravan", "domain": "land", "attack": 0, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 50, "prereq": "trade", "obsoletedBy": "freight", "flags": ["caravan", "noncombat"], "abbr": "CV", "frame": 3 },
|
||||||
|
{ "id": "freight", "name": "Freight", "domain": "land", "attack": 0, "defense": 1, "move": 2, "hp": 10, "fp": 1, "cost": 50, "prereq": "thecorporation", "obsoletedBy": null, "flags": ["caravan", "noncombat"], "abbr": "FR", "frame": 4 },
|
||||||
|
|
||||||
|
{ "id": "warriors", "name": "Warriors", "domain": "land", "attack": 1, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 10, "prereq": null, "obsoletedBy": "pikemen", "flags": [], "abbr": "WA", "frame": 5 },
|
||||||
|
{ "id": "phalanx", "name": "Phalanx", "domain": "land", "attack": 1, "defense": 2, "move": 1, "hp": 10, "fp": 1, "cost": 20, "prereq": "bronzeworking", "obsoletedBy": "pikemen", "flags": [], "abbr": "PH", "frame": 6 },
|
||||||
|
{ "id": "archers", "name": "Archers", "domain": "land", "attack": 3, "defense": 2, "move": 1, "hp": 10, "fp": 1, "cost": 30, "prereq": "warriorcode", "obsoletedBy": "musketeers", "flags": [], "abbr": "AR", "frame": 7 },
|
||||||
|
{ "id": "legion", "name": "Legion", "domain": "land", "attack": 4, "defense": 2, "move": 1, "hp": 10, "fp": 1, "cost": 40, "prereq": "ironworking", "obsoletedBy": "musketeers", "flags": [], "abbr": "LG", "frame": 8 },
|
||||||
|
{ "id": "pikemen", "name": "Pikemen", "domain": "land", "attack": 1, "defense": 2, "move": 1, "hp": 10, "fp": 1, "cost": 20, "prereq": "feudalism", "obsoletedBy": "musketeers", "flags": ["antimounted"], "abbr": "PK", "frame": 9 },
|
||||||
|
{ "id": "musketeers", "name": "Musketeers", "domain": "land", "attack": 3, "defense": 3, "move": 1, "hp": 20, "fp": 1, "cost": 30, "prereq": "gunpowder", "obsoletedBy": "riflemen", "flags": [], "abbr": "MK", "frame": 10 },
|
||||||
|
{ "id": "riflemen", "name": "Riflemen", "domain": "land", "attack": 5, "defense": 4, "move": 1, "hp": 20, "fp": 1, "cost": 40, "prereq": "conscription", "obsoletedBy": null, "flags": [], "abbr": "RF", "frame": 11 },
|
||||||
|
{ "id": "alpinetroops", "name": "Alpine Troops", "domain": "land", "attack": 5, "defense": 5, "move": 1, "hp": 20, "fp": 1, "cost": 50, "prereq": "tactics", "obsoletedBy": null, "flags": ["ignoreterrain"], "abbr": "AL", "frame": 12 },
|
||||||
|
{ "id": "partisans", "name": "Partisans", "domain": "land", "attack": 4, "defense": 4, "move": 1, "hp": 20, "fp": 1, "cost": 50, "prereq": "guerrillawarfare", "obsoletedBy": null, "flags": ["ignoreterrain"], "abbr": "PS", "frame": 13 },
|
||||||
|
{ "id": "marines", "name": "Marines", "domain": "land", "attack": 8, "defense": 5, "move": 1, "hp": 20, "fp": 1, "cost": 60, "prereq": "amphibiouswarfare", "obsoletedBy": null, "flags": ["amphibious"], "abbr": "MN", "frame": 14 },
|
||||||
|
{ "id": "paratroopers", "name": "Paratroopers", "domain": "land", "attack": 6, "defense": 4, "move": 1, "hp": 20, "fp": 1, "cost": 60, "prereq": "combinedarms", "obsoletedBy": null, "flags": ["paradrop"], "abbr": "PT", "frame": 15 },
|
||||||
|
{ "id": "mechinf", "name": "Mech. Inf.", "domain": "land", "attack": 6, "defense": 6, "move": 3, "hp": 30, "fp": 1, "cost": 50, "prereq": "laborunion", "obsoletedBy": null, "flags": [], "abbr": "MI", "frame": 16 },
|
||||||
|
|
||||||
|
{ "id": "horsemen", "name": "Horsemen", "domain": "land", "attack": 2, "defense": 1, "move": 2, "hp": 10, "fp": 1, "cost": 20, "prereq": "horsebackriding", "obsoletedBy": "knights", "flags": ["mounted"], "abbr": "HS", "frame": 17 },
|
||||||
|
{ "id": "chariot", "name": "Chariot", "domain": "land", "attack": 3, "defense": 1, "move": 2, "hp": 10, "fp": 1, "cost": 30, "prereq": "thewheel", "obsoletedBy": "knights", "flags": ["mounted"], "abbr": "CH", "frame": 18 },
|
||||||
|
{ "id": "elephant", "name": "Elephant", "domain": "land", "attack": 4, "defense": 1, "move": 2, "hp": 10, "fp": 1, "cost": 40, "prereq": "polytheism", "obsoletedBy": "crusaders", "flags": ["mounted"], "abbr": "EL", "frame": 19 },
|
||||||
|
{ "id": "knights", "name": "Knights", "domain": "land", "attack": 4, "defense": 2, "move": 2, "hp": 10, "fp": 1, "cost": 40, "prereq": "chivalry", "obsoletedBy": "dragoons", "flags": ["mounted"], "abbr": "KN", "frame": 20 },
|
||||||
|
{ "id": "crusaders", "name": "Crusaders", "domain": "land", "attack": 5, "defense": 1, "move": 2, "hp": 10, "fp": 1, "cost": 40, "prereq": "monotheism", "obsoletedBy": "dragoons", "flags": ["mounted"], "abbr": "CS", "frame": 21 },
|
||||||
|
{ "id": "dragoons", "name": "Dragoons", "domain": "land", "attack": 5, "defense": 2, "move": 2, "hp": 20, "fp": 1, "cost": 50, "prereq": "leadership", "obsoletedBy": "cavalry", "flags": ["mounted"], "abbr": "DG", "frame": 22 },
|
||||||
|
{ "id": "cavalry", "name": "Cavalry", "domain": "land", "attack": 8, "defense": 3, "move": 2, "hp": 20, "fp": 1, "cost": 60, "prereq": "tactics", "obsoletedBy": "armor", "flags": ["mounted"], "abbr": "CY", "frame": 23 },
|
||||||
|
{ "id": "armor", "name": "Armor", "domain": "land", "attack": 10, "defense": 5, "move": 3, "hp": 30, "fp": 1, "cost": 80, "prereq": "mobilewarfare", "obsoletedBy": null, "flags": ["mounted"], "abbr": "AM", "frame": 24 },
|
||||||
|
|
||||||
|
{ "id": "catapult", "name": "Catapult", "domain": "land", "attack": 6, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 40, "prereq": "mathematics", "obsoletedBy": "cannon", "flags": [], "abbr": "CP", "frame": 25 },
|
||||||
|
{ "id": "cannon", "name": "Cannon", "domain": "land", "attack": 8, "defense": 1, "move": 1, "hp": 20, "fp": 1, "cost": 40, "prereq": "metallurgy", "obsoletedBy": "artillery", "flags": [], "abbr": "CN", "frame": 26 },
|
||||||
|
{ "id": "artillery", "name": "Artillery", "domain": "land", "attack": 10, "defense": 1, "move": 1, "hp": 20, "fp": 2, "cost": 50, "prereq": "machinetools", "obsoletedBy": "howitzer", "flags": [], "abbr": "AT", "frame": 27 },
|
||||||
|
{ "id": "howitzer", "name": "Howitzer", "domain": "land", "attack": 12, "defense": 2, "move": 2, "hp": 30, "fp": 2, "cost": 70, "prereq": "robotics", "obsoletedBy": null, "flags": ["ignorewalls"], "abbr": "HW", "frame": 28 },
|
||||||
|
|
||||||
|
{ "id": "fighter", "name": "Fighter", "domain": "air", "attack": 4, "defense": 3, "move": 10, "hp": 20, "fp": 2, "cost": 60, "prereq": "flight", "obsoletedBy": "stealthfighter", "flags": ["fighter"], "abbr": "FT", "frame": 29 },
|
||||||
|
{ "id": "bomber", "name": "Bomber", "domain": "air", "attack": 12, "defense": 1, "move": 8, "hp": 20, "fp": 2, "cost": 120, "prereq": "advancedflight", "obsoletedBy": "stealthbomber", "flags": [], "abbr": "BM", "frame": 30 },
|
||||||
|
{ "id": "helicopter", "name": "Helicopter", "domain": "air", "attack": 10, "defense": 3, "move": 6, "hp": 20, "fp": 2, "cost": 100, "prereq": "combinedarms", "obsoletedBy": null, "flags": [], "abbr": "HC", "frame": 31 },
|
||||||
|
{ "id": "stealthfighter", "name": "Stealth Ftr.", "domain": "air", "attack": 8, "defense": 4, "move": 14, "hp": 20, "fp": 2, "cost": 80, "prereq": "stealth", "obsoletedBy": null, "flags": ["fighter"], "abbr": "SF", "frame": 32 },
|
||||||
|
{ "id": "stealthbomber", "name": "Stealth Bmb.", "domain": "air", "attack": 14, "defense": 5, "move": 12, "hp": 20, "fp": 2, "cost": 160, "prereq": "stealth", "obsoletedBy": null, "flags": [], "abbr": "SB", "frame": 33 },
|
||||||
|
{ "id": "cruisemsl", "name": "Cruise Msl.", "domain": "air", "attack": 18, "defense": 0, "move": 12, "hp": 10, "fp": 3, "cost": 60, "prereq": "rocketry", "obsoletedBy": null, "flags": ["missile"], "abbr": "CM", "frame": 34 },
|
||||||
|
{ "id": "nuclearmsl", "name": "Nuclear Msl.", "domain": "air", "attack": 99, "defense": 0, "move": 16, "hp": 10, "fp": 1, "cost": 160, "prereq": "nuclearfission", "obsoletedBy": null, "flags": ["missile", "nuke"], "abbr": "NM", "frame": 35 },
|
||||||
|
|
||||||
|
{ "id": "trireme", "name": "Trireme", "domain": "sea", "attack": 1, "defense": 1, "move": 3, "hp": 10, "fp": 1, "cost": 40, "prereq": "mapmaking", "obsoletedBy": "caravel", "flags": ["coastal"], "cargo": 2, "abbr": "TR", "frame": 36 },
|
||||||
|
{ "id": "caravel", "name": "Caravel", "domain": "sea", "attack": 2, "defense": 1, "move": 3, "hp": 10, "fp": 1, "cost": 40, "prereq": "navigation", "obsoletedBy": "galleon", "flags": [], "cargo": 3, "abbr": "CL", "frame": 37 },
|
||||||
|
{ "id": "galleon", "name": "Galleon", "domain": "sea", "attack": 0, "defense": 2, "move": 4, "hp": 20, "fp": 1, "cost": 40, "prereq": "magnetism", "obsoletedBy": "transport", "flags": [], "cargo": 4, "abbr": "GL", "frame": 38 },
|
||||||
|
{ "id": "frigate", "name": "Frigate", "domain": "sea", "attack": 4, "defense": 2, "move": 4, "hp": 20, "fp": 1, "cost": 50, "prereq": "magnetism", "obsoletedBy": "destroyer", "flags": [], "cargo": 2, "abbr": "FG", "frame": 39 },
|
||||||
|
{ "id": "ironclad", "name": "Ironclad", "domain": "sea", "attack": 4, "defense": 4, "move": 4, "hp": 30, "fp": 1, "cost": 60, "prereq": "steamengine", "obsoletedBy": "destroyer", "flags": [], "abbr": "IC", "frame": 40 },
|
||||||
|
{ "id": "destroyer", "name": "Destroyer", "domain": "sea", "attack": 4, "defense": 4, "move": 6, "hp": 30, "fp": 1, "cost": 60, "prereq": "combustion", "obsoletedBy": null, "flags": [], "abbr": "DS", "frame": 41 },
|
||||||
|
{ "id": "cruiser", "name": "Cruiser", "domain": "sea", "attack": 6, "defense": 6, "move": 5, "hp": 30, "fp": 2, "cost": 80, "prereq": "steel", "obsoletedBy": "aegiscruiser", "flags": [], "abbr": "CU", "frame": 42 },
|
||||||
|
{ "id": "aegiscruiser", "name": "AEGIS Cruiser", "domain": "sea", "attack": 8, "defense": 8, "move": 5, "hp": 30, "fp": 2, "cost": 100, "prereq": "rocketry", "obsoletedBy": null, "flags": ["aegis"], "abbr": "AE", "frame": 43 },
|
||||||
|
{ "id": "battleship", "name": "Battleship", "domain": "sea", "attack": 12, "defense": 12, "move": 4, "hp": 40, "fp": 2, "cost": 160, "prereq": "automobile", "obsoletedBy": null, "flags": [], "abbr": "BS", "frame": 44 },
|
||||||
|
{ "id": "submarine", "name": "Submarine", "domain": "sea", "attack": 10, "defense": 2, "move": 3, "hp": 30, "fp": 2, "cost": 60, "prereq": "combustion", "obsoletedBy": null, "flags": ["submarine"], "abbr": "SM", "frame": 45 },
|
||||||
|
{ "id": "carrier", "name": "Carrier", "domain": "sea", "attack": 1, "defense": 9, "move": 5, "hp": 40, "fp": 2, "cost": 160, "prereq": "advancedflight", "obsoletedBy": null, "flags": [], "cargoAir": 8, "abbr": "CR", "frame": 46 },
|
||||||
|
{ "id": "transport", "name": "Transport", "domain": "sea", "attack": 0, "defense": 3, "move": 5, "hp": 30, "fp": 1, "cost": 50, "prereq": "industrialization", "obsoletedBy": null, "flags": [], "cargo": 8, "abbr": "TP", "frame": 47 },
|
||||||
|
|
||||||
|
{ "id": "ssstructural", "name": "SS Structural", "domain": "project", "attack": 0, "defense": 0, "move": 0, "hp": 1, "fp": 1, "cost": 80, "prereq": "spaceflight", "obsoletedBy": null, "flags": ["spaceship"], "abbr": "S1", "frame": 48 },
|
||||||
|
{ "id": "sscomponent", "name": "SS Component", "domain": "project", "attack": 0, "defense": 0, "move": 0, "hp": 1, "fp": 1, "cost": 160, "prereq": "plastics", "obsoletedBy": null, "flags": ["spaceship"], "abbr": "S2", "frame": 49 },
|
||||||
|
{ "id": "ssmodule", "name": "SS Module", "domain": "project", "attack": 0, "defense": 0, "move": 0, "hp": 1, "fp": 1, "cost": 320, "prereq": "superconductor", "obsoletedBy": null, "flags": ["spaceship"], "abbr": "S3", "frame": 50 }
|
||||||
|
],
|
||||||
|
|
||||||
|
"spaceship": { "structuralNeeded": 8, "componentsNeeded": 4, "modulesNeeded": 3, "travelTurns": 15 },
|
||||||
|
|
||||||
|
"terrains": [
|
||||||
|
{ "id": "grassland", "name": "Grassland", "food": 2, "shield": 0, "trade": 0, "move": 1, "defense": 1.0, "irrigate": 1, "mine": null, "transform": null, "water": false, "frame": 0, "color": "#5a9e44" },
|
||||||
|
{ "id": "plains", "name": "Plains", "food": 1, "shield": 1, "trade": 0, "move": 1, "defense": 1.0, "irrigate": 1, "mine": null, "transform": "grassland", "water": false, "frame": 2, "color": "#b8a04e" },
|
||||||
|
{ "id": "forest", "name": "Forest", "food": 1, "shield": 2, "trade": 0, "move": 2, "defense": 1.5, "irrigate": null, "mine": null, "transform": "plains", "water": false, "frame": 3, "color": "#2f6b2f" },
|
||||||
|
{ "id": "hills", "name": "Hills", "food": 1, "shield": 0, "trade": 0, "move": 2, "defense": 2.0, "irrigate": 1, "mine": 3, "transform": null, "water": false, "frame": 4, "color": "#8a7a4a" },
|
||||||
|
{ "id": "mountains", "name": "Mountains", "food": 0, "shield": 1, "trade": 0, "move": 3, "defense": 3.0, "irrigate": null, "mine": 2, "transform": "hills", "water": false, "frame": 5, "color": "#7d7d85" },
|
||||||
|
{ "id": "desert", "name": "Desert", "food": 0, "shield": 1, "trade": 0, "move": 1, "defense": 1.0, "irrigate": 1, "mine": 1, "transform": "plains", "water": false, "frame": 6, "color": "#d8c072" },
|
||||||
|
{ "id": "tundra", "name": "Tundra", "food": 1, "shield": 0, "trade": 0, "move": 1, "defense": 1.0, "irrigate": null, "mine": null, "transform": "plains", "water": false, "frame": 7, "color": "#a8b0a0" },
|
||||||
|
{ "id": "glacier", "name": "Glacier", "food": 0, "shield": 0, "trade": 0, "move": 2, "defense": 1.0, "irrigate": null, "mine": 1, "transform": "tundra", "water": false, "frame": 8, "color": "#e8f0f4" },
|
||||||
|
{ "id": "swamp", "name": "Swamp", "food": 1, "shield": 0, "trade": 0, "move": 2, "defense": 1.5, "irrigate": null, "mine": null, "transform": "grassland", "water": false, "frame": 9, "color": "#4a6b52" },
|
||||||
|
{ "id": "jungle", "name": "Jungle", "food": 1, "shield": 0, "trade": 0, "move": 2, "defense": 1.5, "irrigate": null, "mine": null, "transform": "grassland", "water": false, "frame": 10, "color": "#1f7a3d" },
|
||||||
|
{ "id": "ocean", "name": "Ocean", "food": 1, "shield": 0, "trade": 2, "move": 1, "defense": 1.0, "irrigate": null, "mine": null, "transform": null, "water": true, "frame": 11, "color": "#2b5f9e" }
|
||||||
|
],
|
||||||
|
"grasslandShieldFrame": 1,
|
||||||
|
|
||||||
|
"specials": [
|
||||||
|
{ "id": "buffalo", "name": "Buffalo", "terrain": "plains", "food": 1, "shield": 3, "trade": 0, "frame": 0 },
|
||||||
|
{ "id": "wheat", "name": "Wheat", "terrain": "plains", "food": 3, "shield": 1, "trade": 0, "frame": 1 },
|
||||||
|
{ "id": "pheasant", "name": "Pheasant", "terrain": "forest", "food": 3, "shield": 2, "trade": 0, "frame": 2 },
|
||||||
|
{ "id": "silk", "name": "Silk", "terrain": "forest", "food": 1, "shield": 2, "trade": 3, "frame": 3 },
|
||||||
|
{ "id": "coal", "name": "Coal", "terrain": "hills", "food": 1, "shield": 2, "trade": 0, "frame": 4 },
|
||||||
|
{ "id": "wine", "name": "Wine", "terrain": "hills", "food": 1, "shield": 0, "trade": 4, "frame": 5 },
|
||||||
|
{ "id": "gold", "name": "Gold", "terrain": "mountains", "food": 0, "shield": 1, "trade": 6, "frame": 6 },
|
||||||
|
{ "id": "iron", "name": "Iron", "terrain": "mountains", "food": 0, "shield": 4, "trade": 0, "frame": 7 },
|
||||||
|
{ "id": "oasis", "name": "Oasis", "terrain": "desert", "food": 3, "shield": 1, "trade": 0, "frame": 8 },
|
||||||
|
{ "id": "oil", "name": "Oil", "terrain": "desert", "food": 0, "shield": 4, "trade": 0, "frame": 9 },
|
||||||
|
{ "id": "game", "name": "Game", "terrain": "tundra", "food": 3, "shield": 0, "trade": 0, "frame": 10 },
|
||||||
|
{ "id": "furs", "name": "Furs", "terrain": "tundra", "food": 2, "shield": 0, "trade": 3, "frame": 11 },
|
||||||
|
{ "id": "ivory", "name": "Ivory", "terrain": "glacier", "food": 1, "shield": 1, "trade": 4, "frame": 12 },
|
||||||
|
{ "id": "glacieroil", "name": "Oil", "terrain": "glacier", "food": 0, "shield": 4, "trade": 0, "frame": 13 },
|
||||||
|
{ "id": "peat", "name": "Peat", "terrain": "swamp", "food": 1, "shield": 4, "trade": 0, "frame": 14 },
|
||||||
|
{ "id": "spice", "name": "Spice", "terrain": "swamp", "food": 3, "shield": 0, "trade": 4, "frame": 15 },
|
||||||
|
{ "id": "gems", "name": "Gems", "terrain": "jungle", "food": 1, "shield": 0, "trade": 4, "frame": 16 },
|
||||||
|
{ "id": "fruit", "name": "Fruit", "terrain": "jungle", "food": 4, "shield": 0, "trade": 1, "frame": 17 },
|
||||||
|
{ "id": "fish", "name": "Fish", "terrain": "ocean", "food": 3, "shield": 0, "trade": 2, "frame": 18 },
|
||||||
|
{ "id": "whales", "name": "Whales", "terrain": "ocean", "food": 2, "shield": 2, "trade": 3, "frame": 19 }
|
||||||
|
],
|
||||||
|
|
||||||
|
"improvements": [
|
||||||
|
{ "id": "road", "name": "Road", "work": 2, "prereq": null, "engineerOnly": false },
|
||||||
|
{ "id": "railroad", "name": "Railroad", "work": 4, "prereq": "railroad", "engineerOnly": false, "requires": "road" },
|
||||||
|
{ "id": "irrigation", "name": "Irrigation", "work": 3, "prereq": null, "engineerOnly": false },
|
||||||
|
{ "id": "farmland", "name": "Farmland", "work": 4, "prereq": "refrigeration", "engineerOnly": false, "requires": "irrigation" },
|
||||||
|
{ "id": "mine", "name": "Mine", "work": 4, "prereq": null, "engineerOnly": false },
|
||||||
|
{ "id": "fortress", "name": "Fortress", "work": 5, "prereq": "construction", "engineerOnly": false },
|
||||||
|
{ "id": "transform", "name": "Transform", "work": 8, "prereq": "explosives", "engineerOnly": true }
|
||||||
|
],
|
||||||
|
|
||||||
|
"buildings": [
|
||||||
|
{ "id": "palace", "name": "Palace", "cost": 100, "upkeep": 0, "prereq": "masonry", "requires": null, "effect": "palace" },
|
||||||
|
{ "id": "barracks", "name": "Barracks", "cost": 40, "upkeep": 1, "prereq": null, "requires": null, "effect": "veterans" },
|
||||||
|
{ "id": "granary", "name": "Granary", "cost": 60, "upkeep": 1, "prereq": "pottery", "requires": null, "effect": "granary" },
|
||||||
|
{ "id": "citywalls", "name": "City Walls", "cost": 80, "upkeep": 0, "prereq": "masonry", "requires": null, "effect": "walls", "value": 3 },
|
||||||
|
{ "id": "courthouse", "name": "Courthouse", "cost": 80, "upkeep": 1, "prereq": "codeoflaws", "requires": null, "effect": "corruption", "value": 0.5 },
|
||||||
|
{ "id": "library", "name": "Library", "cost": 80, "upkeep": 1, "prereq": "writing", "requires": null, "effect": "science", "value": 0.5 },
|
||||||
|
{ "id": "marketplace", "name": "Marketplace", "cost": 80, "upkeep": 1, "prereq": "currency", "requires": null, "effect": "gold", "value": 0.5 },
|
||||||
|
{ "id": "aqueduct", "name": "Aqueduct", "cost": 80, "upkeep": 2, "prereq": "construction", "requires": null, "effect": "sizecap", "value": 8 },
|
||||||
|
{ "id": "harbor", "name": "Harbor", "cost": 60, "upkeep": 1, "prereq": "seafaring", "requires": null, "effect": "oceanfood", "value": 1 },
|
||||||
|
{ "id": "university", "name": "University", "cost": 160, "upkeep": 3, "prereq": "university", "requires": "library", "effect": "science", "value": 0.5 },
|
||||||
|
{ "id": "bank", "name": "Bank", "cost": 120, "upkeep": 2, "prereq": "banking", "requires": "marketplace", "effect": "gold", "value": 0.5 },
|
||||||
|
{ "id": "sewersystem", "name": "Sewer System", "cost": 120, "upkeep": 2, "prereq": "sanitation", "requires": "aqueduct", "effect": "sizecap", "value": 12 },
|
||||||
|
{ "id": "stockexchange", "name": "Stock Exchange", "cost": 160, "upkeep": 3, "prereq": "thecorporation", "requires": "bank", "effect": "gold", "value": 0.5 },
|
||||||
|
{ "id": "coastalfortress", "name": "Coastal Fortress", "cost": 80, "upkeep": 1, "prereq": "metallurgy", "requires": null, "effect": "defensesea", "value": 2 },
|
||||||
|
{ "id": "factory", "name": "Factory", "cost": 200, "upkeep": 4, "prereq": "industrialization", "requires": null, "effect": "shields", "value": 0.5 },
|
||||||
|
{ "id": "powerplant", "name": "Power Plant", "cost": 160, "upkeep": 4, "prereq": "refining", "requires": "factory", "effect": "power", "value": 0.25 },
|
||||||
|
{ "id": "hydroplant", "name": "Hydro Plant", "cost": 240, "upkeep": 4, "prereq": "electronics", "requires": "factory", "effect": "power", "value": 0.25 },
|
||||||
|
{ "id": "nuclearplant", "name": "Nuclear Plant", "cost": 160, "upkeep": 2, "prereq": "nuclearpower", "requires": "factory", "effect": "power", "value": 0.25 },
|
||||||
|
{ "id": "mfgplant", "name": "Mfg. Plant", "cost": 320, "upkeep": 6, "prereq": "robotics", "requires": "factory", "effect": "shields", "value": 0.25 },
|
||||||
|
{ "id": "offshoreplatform", "name": "Offshore Platform", "cost": 160, "upkeep": 3, "prereq": "miniaturization", "requires": null, "effect": "oceanshield", "value": 1 },
|
||||||
|
{ "id": "airport", "name": "Airport", "cost": 160, "upkeep": 3, "prereq": "radio", "requires": null, "effect": "airport" },
|
||||||
|
{ "id": "superhighways", "name": "Superhighways", "cost": 120, "upkeep": 5, "prereq": "automobile", "requires": null, "effect": "roadtrade", "value": 0.5 },
|
||||||
|
{ "id": "researchlab", "name": "Research Lab", "cost": 160, "upkeep": 3, "prereq": "computers", "requires": "university", "effect": "science", "value": 0.5 },
|
||||||
|
{ "id": "sambattery", "name": "SAM Battery", "cost": 100, "upkeep": 2, "prereq": "rocketry", "requires": null, "effect": "defenseair", "value": 2 },
|
||||||
|
{ "id": "sdidefense", "name": "SDI Defense", "cost": 200, "upkeep": 4, "prereq": "superconductor", "requires": null, "effect": "sdi" },
|
||||||
|
{ "id": "supermarket", "name": "Supermarket", "cost": 80, "upkeep": 3, "prereq": "refrigeration", "requires": null, "effect": "farmfood", "value": 0.5 }
|
||||||
|
],
|
||||||
|
|
||||||
|
"governments": [
|
||||||
|
{ "id": "despotism", "name": "Despotism", "prereq": null, "corruptionFactor": 1.0, "despotPenalty": true, "tradeBonus": 0, "flatCorruption": false, "freeUnits": 3, "unitUpkeep": "shield", "settlerFood": 1, "warPenalty": 1, "frame": 10 },
|
||||||
|
{ "id": "anarchy", "name": "Anarchy", "prereq": null, "corruptionFactor": 1.5, "despotPenalty": true, "tradeBonus": 0, "flatCorruption": false, "freeUnits": 3, "unitUpkeep": "shield", "settlerFood": 1, "warPenalty": 1, "noScience": true, "frame": 11 },
|
||||||
|
{ "id": "monarchy", "name": "Monarchy", "prereq": "monarchy", "corruptionFactor": 0.7, "despotPenalty": false, "tradeBonus": 0, "flatCorruption": false, "freeUnits": 3, "unitUpkeep": "shield", "settlerFood": 1, "warPenalty": 1, "frame": 12 },
|
||||||
|
{ "id": "communism", "name": "Communism", "prereq": "communism", "corruptionFactor": 0.4, "despotPenalty": false, "tradeBonus": 0, "flatCorruption": true, "freeUnits": 6, "unitUpkeep": "shield", "settlerFood": 1, "warPenalty": 1, "frame": 13 },
|
||||||
|
{ "id": "republic", "name": "The Republic", "prereq": "therepublic", "corruptionFactor": 0.3, "despotPenalty": false, "tradeBonus": 1, "flatCorruption": false, "freeUnits": 0, "unitUpkeep": "shield", "settlerFood": 2, "warPenalty": 1, "frame": 14 },
|
||||||
|
{ "id": "democracy", "name": "Democracy", "prereq": "democracy", "corruptionFactor": 0.0, "despotPenalty": false, "tradeBonus": 1, "flatCorruption": false, "freeUnits": 0, "unitUpkeep": "gold", "settlerFood": 2, "warPenalty": 2, "frame": 15 }
|
||||||
|
],
|
||||||
|
|
||||||
|
"difficulties": [
|
||||||
|
{ "id": "chieftain", "name": "Chieftain", "humanResearchFactor": 0.8, "aiProdBonus": 0.8, "aiScienceBonus": 0.8, "aiStartUnits": 0, "aiAggression": 0.25 },
|
||||||
|
{ "id": "warlord", "name": "Warlord", "humanResearchFactor": 0.9, "aiProdBonus": 0.9, "aiScienceBonus": 0.9, "aiStartUnits": 0, "aiAggression": 0.4 },
|
||||||
|
{ "id": "prince", "name": "Prince", "humanResearchFactor": 1.0, "aiProdBonus": 1.0, "aiScienceBonus": 1.0, "aiStartUnits": 0, "aiAggression": 0.5 },
|
||||||
|
{ "id": "king", "name": "King", "humanResearchFactor": 1.1, "aiProdBonus": 1.25, "aiScienceBonus": 1.25, "aiStartUnits": 1, "aiAggression": 0.65 },
|
||||||
|
{ "id": "emperor", "name": "Emperor", "humanResearchFactor": 1.2, "aiProdBonus": 1.5, "aiScienceBonus": 1.5, "aiStartUnits": 1, "aiAggression": 0.8 }
|
||||||
|
],
|
||||||
|
|
||||||
|
"worldSizes": [
|
||||||
|
{ "id": "small", "name": "Small", "cols": 40, "rows": 32, "startSettlers": 1 },
|
||||||
|
{ "id": "medium", "name": "Medium", "cols": 56, "rows": 44, "startSettlers": 2 },
|
||||||
|
{ "id": "large", "name": "Large", "cols": 72, "rows": 56, "startSettlers": 2 }
|
||||||
|
],
|
||||||
|
|
||||||
|
"playerColors": ["#3b82f6", "#ef4444", "#22c55e", "#eab308", "#a855f7", "#06b6d4", "#f97316", "#ec4899"],
|
||||||
|
|
||||||
|
"cityNames": [
|
||||||
|
"Rivermouth", "Stonehaven", "Fairview", "Oakdale", "Silverport", "Highgate", "Westmarch", "Eastwick",
|
||||||
|
"Northolt", "Southmere", "Goldcrest", "Ironforge", "Millbrook", "Ashford", "Briarwood", "Cedar Falls",
|
||||||
|
"Duskvale", "Elmshade", "Foxglove", "Greenfield", "Harborview", "Ivorydale", "Junction", "Kingsbridge",
|
||||||
|
"Lakeshore", "Maplewood", "Newhaven", "Oxbow", "Pinecrest", "Quarrytown", "Redcliff", "Sandpoint",
|
||||||
|
"Thornbury", "Umberfell", "Vineyard", "Willowdale", "Yellowbrook", "Zephyr Bay", "Amberton", "Blackwater",
|
||||||
|
"Coppermine", "Driftwood", "Emberly", "Frostholm", "Glasswater", "Hearthstone", "Islegard", "Jadeport",
|
||||||
|
"Kestrel Point", "Larkspur", "Moonwell", "Nightfall", "Opal Coast", "Palisade", "Quietwater", "Ravenrock",
|
||||||
|
"Starfall", "Tidewater", "Underhill", "Violetgate", "Wheatfield", "Wolfden", "Yarrow", "Zenith"
|
||||||
|
],
|
||||||
|
|
||||||
|
"yearCurve": [
|
||||||
|
{ "until": -1000, "step": 50 },
|
||||||
|
{ "until": 1, "step": 25 },
|
||||||
|
{ "until": 1500, "step": 20 },
|
||||||
|
{ "until": 1750, "step": 10 },
|
||||||
|
{ "until": 1850, "step": 5 },
|
||||||
|
{ "until": 1900, "step": 2 },
|
||||||
|
{ "until": 99999, "step": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -151,6 +151,15 @@ export const MANIFEST = {
|
||||||
return ids.map((id) => image(`peggle-bg-${id}`, `assets/images/peggle/${id}.png`));
|
return ids.map((id) => image(`peggle-bg-${id}`, `assets/images/peggle/${id}.png`));
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
// Rules data always loads; sprite sheets are optional drop-ins declared in
|
||||||
|
// data/civilization-artwork.json (spec: src/games/civilization/sprites.md).
|
||||||
|
// Sheets with path:null stay procedural. citySheets is a map keyed by theme
|
||||||
|
// (classic today; asian etc. drop in later with no code change).
|
||||||
|
civilization: [
|
||||||
|
{ type: 'json', key: 'civilization-rules', path: 'data/civilization-rules.json' },
|
||||||
|
(scene) => sheetsFrom(scene, 'civilization-artwork',
|
||||||
|
['terrainSheet', 'resourceSheet', 'improvementSheet', 'unitSheet', 'iconSheet', 'citySheets']),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Returns the full (normalized) descriptor list for a slug — including assets
|
// Returns the full (normalized) descriptor list for a slug — including assets
|
||||||
|
|
|
||||||
|
|
@ -110,3 +110,4 @@ registerGame({ slug: 'balatro', name: 'Balatro', category: 'cards', cardGame: tr
|
||||||
registerGame({ slug: 'peggle', name: 'Peggle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 80 });
|
registerGame({ slug: 'peggle', name: 'Peggle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 80 });
|
||||||
registerGame({ slug: 'coloradodefense', name: 'Colorado Defense', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 81 });
|
registerGame({ slug: 'coloradodefense', name: 'Colorado Defense', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 81 });
|
||||||
registerGame({ slug: 'starcontrol', name: 'Star Control', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 0, iconFrame: 82 });
|
registerGame({ slug: 'starcontrol', name: 'Star Control', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 0, iconFrame: 82 });
|
||||||
|
registerGame({ slug: 'civilization', name: 'Civilization', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 83 });
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,643 @@
|
||||||
|
// Civilization — AI civ controller. Headless (no Phaser).
|
||||||
|
//
|
||||||
|
// runAITurn(rules, state, civIdx) plays one civ's whole turn: strategy pick,
|
||||||
|
// diplomacy, research, city builds, then unit orders. respondToProposal
|
||||||
|
// answers human (or other-AI) diplomacy. AI-initiated proposals toward the
|
||||||
|
// human are queued on state.events as { type: 'aiProposal', keep: true } for
|
||||||
|
// the scene to present at the start of the human turn.
|
||||||
|
|
||||||
|
import {
|
||||||
|
rand, randInt, cheb, tileIndex, inBounds, terrainAt, cityAt, unitsAt,
|
||||||
|
civUnits, civCities, cityById, knownCount, availableTechs, availableUnits,
|
||||||
|
availableBuildings, canFoundCity, foundCity, setBuild, buyCost, buyBuild,
|
||||||
|
tryMove, disembark, findPath, startWork, canWork, canEstablishRoute,
|
||||||
|
establishTradeRoute, resolveAttack, attackerStrength, defenderStrength,
|
||||||
|
pickDefender, launchSpaceship, canPropose, applyTreaty, declareWar,
|
||||||
|
exchangeTechs, civPower, cityYields, buildCost,
|
||||||
|
setResearch, startRevolution,
|
||||||
|
} from './CivilizationLogic.js';
|
||||||
|
import { siteQuality } from './CivilizationWorldGen.js';
|
||||||
|
|
||||||
|
const MAX_UNIT_STEPS = 40; // per unit per turn, guards against loops
|
||||||
|
|
||||||
|
export function runAITurn(rules, state, civIdx) {
|
||||||
|
const civ = state.civs[civIdx];
|
||||||
|
if (!civ.alive || state.over) return;
|
||||||
|
|
||||||
|
const strategy = computeStrategy(rules, state, civIdx);
|
||||||
|
doDiplomacy(rules, state, civIdx, strategy);
|
||||||
|
doResearch(rules, state, civIdx, strategy);
|
||||||
|
doGovernment(rules, state, civIdx);
|
||||||
|
for (const city of civCities(state, civIdx)) {
|
||||||
|
manageCityBuild(rules, state, civIdx, city, strategy);
|
||||||
|
}
|
||||||
|
doUnits(rules, state, civIdx, strategy);
|
||||||
|
|
||||||
|
// Launch when ready unless conquest is already in hand.
|
||||||
|
const ship = civ.spaceship;
|
||||||
|
if (!ship.launched
|
||||||
|
&& ship.structural >= rules.spaceship.structuralNeeded
|
||||||
|
&& ship.component >= rules.spaceship.componentsNeeded
|
||||||
|
&& ship.module >= rules.spaceship.modulesNeeded) {
|
||||||
|
launchSpaceship(rules, state, civ);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Strategy
|
||||||
|
|
||||||
|
export function computeStrategy(rules, state, civIdx) {
|
||||||
|
const civ = state.civs[civIdx];
|
||||||
|
const diff = rules.difficulties[state.difficultyId];
|
||||||
|
const myCities = civCities(state, civIdx);
|
||||||
|
const myPower = civPower(rules, state, civIdx);
|
||||||
|
|
||||||
|
const atWarWith = state.civs.filter((c) => c.alive && c.id !== civIdx
|
||||||
|
&& civ.relations[c.id] === 'war').map((c) => c.id);
|
||||||
|
|
||||||
|
const techTotal = rules.techList.filter((t) => !t.repeatable).length;
|
||||||
|
const techFrac = knownCount(civ) / techTotal;
|
||||||
|
const wantsSpace = civ.known.spaceflight || techFrac >= 0.8;
|
||||||
|
|
||||||
|
const settlerCount = civUnits(state, civIdx)
|
||||||
|
.filter((u) => rules.units[u.type].flags.includes('settler')).length;
|
||||||
|
const landTiles = state.world.cols * state.world.rows * (state.world.landFraction ?? 0.3);
|
||||||
|
const targetCities = Math.max(5, Math.round(landTiles / 40));
|
||||||
|
const canExpand = myCities.length < targetCities && settlerCount < 3;
|
||||||
|
|
||||||
|
let phase = 'develop';
|
||||||
|
if (atWarWith.length) phase = 'war';
|
||||||
|
else if (wantsSpace) phase = 'space';
|
||||||
|
else if (canExpand || myCities.length === 0) phase = 'expand';
|
||||||
|
|
||||||
|
return { phase, atWarWith, myPower, aggression: diff.aiAggression, techFrac };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Diplomacy
|
||||||
|
|
||||||
|
function doDiplomacy(rules, state, civIdx, strategy) {
|
||||||
|
const civ = state.civs[civIdx];
|
||||||
|
for (const other of state.civs) {
|
||||||
|
if (other.id === civIdx || !other.alive) continue;
|
||||||
|
const rel = civ.relations[other.id];
|
||||||
|
if (rel === 'nocontact') continue;
|
||||||
|
const attitude = civ.attitude[other.id] ?? 0;
|
||||||
|
const powerRatio = strategy.myPower / Math.max(1, civPower(rules, state, other.id));
|
||||||
|
|
||||||
|
if (rel === 'war') {
|
||||||
|
// Sue for peace when clearly losing.
|
||||||
|
if (powerRatio < 0.6 && canPropose(state, civIdx, other.id, 'ceasefire')) {
|
||||||
|
proposeOrQueue(rules, state, civIdx, other.id, 'ceasefire');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Escalate to war: hostile attitude + power advantage + aggression roll,
|
||||||
|
// opportunism against a much weaker neighbour, or (rarely) a true sneak
|
||||||
|
// attack on a treaty partner who has fallen far behind.
|
||||||
|
const sneakBar = rel === 'peace' || rel === 'alliance' ? -70 : -10;
|
||||||
|
const hostile = attitude <= sneakBar && powerRatio > 1.15;
|
||||||
|
const opportunist = rel === 'contact' && powerRatio > 1.6 && attitude < 40;
|
||||||
|
const sneak = (rel === 'peace') && powerRatio > 2.5 && attitude < 0;
|
||||||
|
if ((hostile || opportunist) && rand(state) < strategy.aggression * 0.3) {
|
||||||
|
declareWar(rules, state, civIdx, other.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sneak && rand(state) < strategy.aggression * 0.1) {
|
||||||
|
declareWar(rules, state, civIdx, other.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Peace-seeking & alliances with actual friends only — a world of
|
||||||
|
// reflexive peace treaties stalls the game forever.
|
||||||
|
if (rel === 'ceasefire' && attitude > 0) proposeOrQueue(rules, state, civIdx, other.id, 'peace');
|
||||||
|
else if (rel === 'contact' && attitude > 30) proposeOrQueue(rules, state, civIdx, other.id, 'peace');
|
||||||
|
else if (rel === 'peace' && attitude > 50 && strategy.atWarWith.length === 0
|
||||||
|
&& sharedEnemy(state, civIdx, other.id)) {
|
||||||
|
proposeOrQueue(rules, state, civIdx, other.id, 'alliance');
|
||||||
|
}
|
||||||
|
// Fair tech trades with non-hostile AIs (human trades happen in their UI).
|
||||||
|
if (!other.human && attitude > 0 && rand(state) < 0.25) {
|
||||||
|
tryFairExchange(rules, state, civIdx, other.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sharedEnemy(state, a, b) {
|
||||||
|
return state.civs.some((c) => c.alive && c.id !== a && c.id !== b
|
||||||
|
&& state.civs[a].relations[c.id] === 'war' && state.civs[b].relations[c.id] === 'war');
|
||||||
|
}
|
||||||
|
|
||||||
|
function proposeOrQueue(rules, state, fromIdx, toIdx, kind) {
|
||||||
|
const target = state.civs[toIdx];
|
||||||
|
if (target.human) {
|
||||||
|
if (!state.events.some((e) => e.type === 'aiProposal' && e.from === fromIdx && e.kind === kind)) {
|
||||||
|
state.events.push({ type: 'aiProposal', from: fromIdx, to: toIdx, kind, keep: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (respondToProposal(rules, state, toIdx, fromIdx, kind)) {
|
||||||
|
applyTreaty(state, fromIdx, toIdx, kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function respondToProposal(rules, state, aiIdx, fromIdx, kind, payload = {}) {
|
||||||
|
const civ = state.civs[aiIdx];
|
||||||
|
const attitude = civ.attitude[fromIdx] ?? 0;
|
||||||
|
const powerRatio = civPower(rules, state, aiIdx)
|
||||||
|
/ Math.max(1, civPower(rules, state, fromIdx));
|
||||||
|
switch (kind) {
|
||||||
|
case 'ceasefire':
|
||||||
|
// Winners press on; only accept when not dominant or genuinely friendly.
|
||||||
|
return powerRatio < 1.1 || attitude > 0;
|
||||||
|
case 'peace':
|
||||||
|
return powerRatio < 0.8 || attitude > 20;
|
||||||
|
case 'alliance':
|
||||||
|
return attitude > 40 && (sharedEnemy(state, aiIdx, fromIdx) || attitude > 65);
|
||||||
|
case 'exchange': {
|
||||||
|
const { giveId, getId } = payload; // from `fromIdx`'s perspective
|
||||||
|
if (!giveId || !getId) return false;
|
||||||
|
const rankGive = rules.techRank[giveId] ?? 0;
|
||||||
|
const rankGet = rules.techRank[getId] ?? 0;
|
||||||
|
return attitude > -10 && rankGive >= rankGet - 1;
|
||||||
|
}
|
||||||
|
case 'tribute':
|
||||||
|
return powerRatio < 0.6 && (payload.amount ?? 0) <= civ.gold * 0.25;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryFairExchange(rules, state, a, b) {
|
||||||
|
const civA = state.civs[a];
|
||||||
|
const civB = state.civs[b];
|
||||||
|
const aOffers = Object.keys(civA.known).filter((t) => !civB.known[t]);
|
||||||
|
const bOffers = Object.keys(civB.known).filter((t) => !civA.known[t]);
|
||||||
|
if (!aOffers.length || !bOffers.length) return;
|
||||||
|
aOffers.sort((x, y) => (rules.techRank[x] ?? 0) - (rules.techRank[y] ?? 0));
|
||||||
|
bOffers.sort((x, y) => (rules.techRank[x] ?? 0) - (rules.techRank[y] ?? 0));
|
||||||
|
const give = aOffers[0];
|
||||||
|
const get = bOffers[0];
|
||||||
|
if (Math.abs((rules.techRank[give] ?? 0) - (rules.techRank[get] ?? 0)) <= 1) {
|
||||||
|
exchangeTechs(rules, state, a, b, give, get);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Research & government
|
||||||
|
|
||||||
|
const SPACE_CHAIN = ['spaceflight', 'plastics', 'superconductor', 'fusionpower'];
|
||||||
|
|
||||||
|
function doResearch(rules, state, civIdx, strategy) {
|
||||||
|
const civ = state.civs[civIdx];
|
||||||
|
if (civ.researching) return;
|
||||||
|
const options = availableTechs(rules, civ);
|
||||||
|
if (!options.length) return;
|
||||||
|
let best = null;
|
||||||
|
let bestScore = -Infinity;
|
||||||
|
for (const t of options) {
|
||||||
|
const g = rules.techGates[t.id];
|
||||||
|
let score = rand(state) * 4; // jitter
|
||||||
|
score += g.prereqOf.length * 2;
|
||||||
|
const gatesUnits = g.units.length > 0;
|
||||||
|
const gatesEcon = g.buildings.some((b) => ['science', 'gold', 'shields', 'granary', 'sizecap']
|
||||||
|
.includes(rules.buildings[b].effect));
|
||||||
|
if (strategy.phase === 'war' && gatesUnits) score += 8;
|
||||||
|
if (strategy.phase !== 'war' && gatesEcon) score += 6;
|
||||||
|
if (g.governments.length && strategy.phase !== 'war') score += 5;
|
||||||
|
if (strategy.phase === 'space' && (SPACE_CHAIN.includes(t.id)
|
||||||
|
|| g.prereqOf.some((p) => SPACE_CHAIN.includes(p)))) score += 12;
|
||||||
|
if (t.repeatable) score -= 20;
|
||||||
|
if (score > bestScore) { bestScore = score; best = t; }
|
||||||
|
}
|
||||||
|
if (best) setResearch(rules, state, civ, best.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doGovernment(rules, state, civIdx) {
|
||||||
|
const civ = state.civs[civIdx];
|
||||||
|
if (civ.government === 'anarchy') return;
|
||||||
|
// Simple ladder: despotism -> monarchy -> republic (peace) / communism (war-heavy).
|
||||||
|
const wants = civ.known.democracy && atPeace(state, civIdx) ? 'democracy'
|
||||||
|
: civ.known.therepublic && atPeace(state, civIdx) ? 'republic'
|
||||||
|
: civ.known.communism && !atPeace(state, civIdx) ? 'communism'
|
||||||
|
: civ.known.monarchy ? 'monarchy' : null;
|
||||||
|
if (wants && wants !== civ.government
|
||||||
|
&& govRank(wants) > govRank(civ.government)) {
|
||||||
|
startRevolution(rules, state, civ, wants);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function atPeace(state, civIdx) {
|
||||||
|
return !Object.values(state.civs[civIdx].relations).includes('war');
|
||||||
|
}
|
||||||
|
function govRank(id) {
|
||||||
|
return { despotism: 0, anarchy: -1, monarchy: 1, communism: 2, republic: 2, democracy: 3 }[id] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// City builds
|
||||||
|
|
||||||
|
const DEVELOP_CHAIN = ['granary', 'library', 'marketplace', 'barracks', 'aqueduct', 'harbor',
|
||||||
|
'university', 'bank', 'courthouse', 'sewersystem', 'factory', 'powerplant', 'stockexchange',
|
||||||
|
'superhighways', 'researchlab', 'supermarket', 'mfgplant'];
|
||||||
|
|
||||||
|
function manageCityBuild(rules, state, civIdx, city, strategy) {
|
||||||
|
const civ = state.civs[civIdx];
|
||||||
|
const def = rules.units[city.build?.id];
|
||||||
|
const midBuild = city.shieldBox > 0 && city.shieldBox < buildCost(rules, city) * 0.9;
|
||||||
|
const defenders = unitsAt(state, city.x, city.y)
|
||||||
|
.filter((u) => u.civ === civIdx && rules.units[u.type].domain === 'land'
|
||||||
|
&& !rules.units[u.type].flags.includes('noncombat'));
|
||||||
|
|
||||||
|
// Emergency: garrison first, buy it when the enemy is at the gate.
|
||||||
|
const wantGarrison = strategy.phase === 'war' ? 2 : 1;
|
||||||
|
if (defenders.length < wantGarrison) {
|
||||||
|
const best = bestDefender(rules, state, civ, city);
|
||||||
|
if (best && city.build?.id !== best.id) setBuild(rules, state, city, 'unit', best.id);
|
||||||
|
const enemyNear = state.units.some((u) => u.civ !== civIdx
|
||||||
|
&& state.civs[civIdx].relations[u.civ] === 'war'
|
||||||
|
&& !rules.units[u.type].flags.includes('noncombat')
|
||||||
|
&& cheb(u.x, u.y, city.x, city.y) <= 3);
|
||||||
|
if (defenders.length === 0 && enemyNear && civ.gold > buyCost(rules, city) + 50) {
|
||||||
|
buyBuild(rules, state, city);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (midBuild && city.build.type === 'unit' && def?.flags.includes('spaceship')) return;
|
||||||
|
if (midBuild && rand(state) < 0.7) return; // usually let builds finish
|
||||||
|
|
||||||
|
// Spaceship race: pour high-shield cities into parts.
|
||||||
|
if (strategy.phase === 'space') {
|
||||||
|
const parts = availableUnits(rules, state, civ, city)
|
||||||
|
.filter((u) => u.flags.includes('spaceship'))
|
||||||
|
.sort((a, b) => a.cost - b.cost);
|
||||||
|
if (parts.length) { setBuild(rules, state, city, 'unit', parts[0].id); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expansion: settlers from cities that can spare the head.
|
||||||
|
if (strategy.phase === 'expand' && city.size >= 2) {
|
||||||
|
const settlerType = civ.known.explosives ? 'engineers' : 'settlers';
|
||||||
|
const settlersOut = civUnits(state, civIdx)
|
||||||
|
.filter((u) => rules.units[u.type].flags.includes('settler')).length;
|
||||||
|
if (settlersOut < 3 && rand(state) < 0.7) {
|
||||||
|
setBuild(rules, state, city, 'unit', 'settlers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (settlerType === 'engineers' && !civUnits(state, civIdx).some((u) => u.type === 'engineers')) {
|
||||||
|
setBuild(rules, state, city, 'unit', 'engineers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// War: attackers with a side of siege.
|
||||||
|
if (strategy.phase === 'war') {
|
||||||
|
const units = availableUnits(rules, state, civ, city)
|
||||||
|
.filter((u) => u.domain === 'land' && !u.flags.includes('noncombat') && !u.flags.includes('spaceship'));
|
||||||
|
if (units.length) {
|
||||||
|
const attackers = units.sort((a, b) => b.attack - a.attack);
|
||||||
|
const pick = rand(state) < 0.3
|
||||||
|
? attackers.find((u) => u.attack >= 6 && u.defense <= 2) ?? attackers[0]
|
||||||
|
: attackers[0];
|
||||||
|
setBuild(rules, state, city, 'unit', pick.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a worker corps alive after expansion: roads/irrigation are the whole
|
||||||
|
// economy, and settlers all get consumed founding cities.
|
||||||
|
const workers = civUnits(state, civIdx)
|
||||||
|
.filter((u) => rules.units[u.type].flags.includes('settler')).length;
|
||||||
|
const wantWorkers = Math.min(3, Math.max(1, Math.ceil(civCities(state, civIdx).length / 2)));
|
||||||
|
if (workers < wantWorkers && city.size >= 2 && rand(state) < 0.5) {
|
||||||
|
setBuild(rules, state, city, 'unit', civ.known.explosives ? 'engineers' : 'settlers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Develop: walk the improvement chain; caravans for big trade cities.
|
||||||
|
const avail = availableBuildings(rules, state, civ, city);
|
||||||
|
const yields = cityYields(rules, state, city);
|
||||||
|
if (yields.netTrade >= 8 && city.routes.length < 2 && civ.known.trade && rand(state) < 0.3) {
|
||||||
|
const cvType = civ.known.thecorporation ? 'freight' : 'caravan';
|
||||||
|
setBuild(rules, state, city, 'unit', cvType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const id of DEVELOP_CHAIN) {
|
||||||
|
if (avail.some((b) => b.id === id)) {
|
||||||
|
// Skip aqueduct/sewer until the city is close to its cap.
|
||||||
|
if (id === 'aqueduct' && city.size < 6) continue;
|
||||||
|
if (id === 'sewersystem' && city.size < 10) continue;
|
||||||
|
setBuild(rules, state, city, 'building', id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fallback = bestDefender(rules, state, civ, city);
|
||||||
|
if (fallback) setBuild(rules, state, city, 'unit', fallback.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestDefender(rules, state, civ, city) {
|
||||||
|
return availableUnits(rules, state, civ, city)
|
||||||
|
.filter((u) => u.domain === 'land' && !u.flags.includes('noncombat') && !u.flags.includes('spaceship'))
|
||||||
|
.sort((a, b) => b.defense - a.defense || a.cost - b.cost)[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Units
|
||||||
|
|
||||||
|
function doUnits(rules, state, civIdx, strategy) {
|
||||||
|
const civ = state.civs[civIdx];
|
||||||
|
for (const unit of [...civUnits(state, civIdx)]) {
|
||||||
|
if (!state.units.includes(unit) || state.over) continue;
|
||||||
|
if (unit.carriedBy) { handleCargo(rules, state, unit, strategy); continue; }
|
||||||
|
if (unit.order?.kind === 'work') continue;
|
||||||
|
const def = rules.units[unit.type];
|
||||||
|
if (def.domain === 'project') continue;
|
||||||
|
let steps = 0;
|
||||||
|
while (unit.mp > 0 && steps < MAX_UNIT_STEPS && state.units.includes(unit) && !state.over) {
|
||||||
|
const acted = stepUnit(rules, state, civIdx, unit, strategy);
|
||||||
|
steps += 1;
|
||||||
|
if (!acted) break;
|
||||||
|
}
|
||||||
|
if (state.units.includes(unit) && !unit.moved && !unit.fortified && def.defense > 0
|
||||||
|
&& cityAt(state, unit.x, unit.y)) {
|
||||||
|
unit.fortified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepUnit(rules, state, civIdx, unit, strategy) {
|
||||||
|
const def = rules.units[unit.type];
|
||||||
|
if (def.flags.includes('settler')) return stepSettler(rules, state, civIdx, unit, strategy);
|
||||||
|
if (def.flags.includes('caravan')) return stepCaravan(rules, state, civIdx, unit);
|
||||||
|
if (def.flags.includes('ignoreterrain') && def.attack === 0) return stepExplorer(rules, state, civIdx, unit);
|
||||||
|
if (def.domain === 'sea') return stepShip(rules, state, civIdx, unit, strategy);
|
||||||
|
if (def.domain === 'air') { unit.mp = 0; return false; } // AI keeps air home (v1)
|
||||||
|
return stepMilitary(rules, state, civIdx, unit, strategy);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveToward(rules, state, unit, tx, ty) {
|
||||||
|
// Pathfind once per decision, then walk the whole path while movement
|
||||||
|
// lasts — re-planning every tile step dominates AI turn time otherwise.
|
||||||
|
const path = findPath(rules, state, unit, tx, ty);
|
||||||
|
if (!path || !path.length) return false;
|
||||||
|
let progressed = false;
|
||||||
|
for (const [nx, ny] of path) {
|
||||||
|
if (unit.mp <= 0 || !state.units.includes(unit) || state.over) break;
|
||||||
|
const out = tryMove(rules, state, unit, Math.sign(nx - unit.x), Math.sign(ny - unit.y));
|
||||||
|
if (out.result !== 'moved' && out.result !== 'boarded' && out.result !== 'captured') break;
|
||||||
|
progressed = true;
|
||||||
|
if (out.result !== 'moved') break; // boarded/captured ends the walk
|
||||||
|
if (out.hut) break; // hut outcomes can change the situation
|
||||||
|
}
|
||||||
|
return progressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepSettler(rules, state, civIdx, unit, strategy) {
|
||||||
|
const civ = state.civs[civIdx];
|
||||||
|
// Improve home turf when expansion is done (or this is an engineer).
|
||||||
|
const wantsFound = strategy.phase === 'expand' || civCities(state, civIdx).length === 0;
|
||||||
|
if (!wantsFound || rules.units[unit.type].flags.includes('engineer')) {
|
||||||
|
return stepWorker(rules, state, civIdx, unit);
|
||||||
|
}
|
||||||
|
const best = bestCitySite(rules, state, civIdx, unit);
|
||||||
|
if (!best) return stepWorker(rules, state, civIdx, unit);
|
||||||
|
if (best.x === unit.x && best.y === unit.y && canFoundCity(rules, state, unit.x, unit.y)) {
|
||||||
|
foundCity(rules, state, unit);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return moveToward(rules, state, unit, best.x, best.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bestCitySite(rules, state, civIdx, unit) {
|
||||||
|
const { world } = state;
|
||||||
|
let best = null;
|
||||||
|
let bestScore = -Infinity;
|
||||||
|
const R = 7;
|
||||||
|
for (let dy = -R; dy <= R; dy += 1) {
|
||||||
|
for (let dx = -R; dx <= R; dx += 1) {
|
||||||
|
const x = unit.x + dx;
|
||||||
|
const y = unit.y + dy;
|
||||||
|
if (!inBounds(world, x, y)) continue;
|
||||||
|
if (!canFoundCity(rules, state, x, y)) continue;
|
||||||
|
const terr = terrainAt(rules, world, x, y);
|
||||||
|
if (terr.water || terr.id === 'mountains') continue;
|
||||||
|
const enemyClose = state.civs.some((c) => c.alive && c.id !== civIdx
|
||||||
|
&& civCities(state, c.id).some((ct) => cheb(ct.x, ct.y, x, y) <= 3));
|
||||||
|
if (enemyClose) continue;
|
||||||
|
const score = siteQuality(rules, world, x, y) - cheb(unit.x, unit.y, x, y) * 2;
|
||||||
|
if (score > bestScore) { bestScore = score; best = { x, y }; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepWorker(rules, state, civIdx, unit) {
|
||||||
|
// Improve tiles inside own city radii: irrigation > mine > road.
|
||||||
|
if (!cityNeedsMe(rules, state, civIdx, unit.x, unit.y)) {
|
||||||
|
const target = nearestWorkTile(rules, state, civIdx, unit);
|
||||||
|
if (target) return moveToward(rules, state, unit, target[0], target[1]);
|
||||||
|
unit.mp = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const imp of ['irrigation', 'mine', 'road', 'railroad', 'farmland']) {
|
||||||
|
if (canWork(rules, state, unit, imp)) {
|
||||||
|
// Don't irrigate what a mine serves better and vice versa: terrain fields
|
||||||
|
// already gate this; simple priority order is enough for the AI.
|
||||||
|
startWork(rules, state, unit, imp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const target = nearestWorkTile(rules, state, civIdx, unit);
|
||||||
|
if (target) return moveToward(rules, state, unit, target[0], target[1]);
|
||||||
|
unit.mp = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cityNeedsMe(rules, state, civIdx, x, y) {
|
||||||
|
return civCities(state, civIdx).some((c) => cheb(c.x, c.y, x, y) <= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestWorkTile(rules, state, civIdx, unit) {
|
||||||
|
const { world } = state;
|
||||||
|
let best = null;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
for (const city of civCities(state, civIdx)) {
|
||||||
|
for (let dy = -2; dy <= 2; dy += 1) {
|
||||||
|
for (let dx = -2; dx <= 2; dx += 1) {
|
||||||
|
const x = city.x + dx;
|
||||||
|
const y = city.y + dy;
|
||||||
|
if (!inBounds(world, x, y)) continue;
|
||||||
|
const terr = terrainAt(rules, world, x, y);
|
||||||
|
if (terr.water) continue;
|
||||||
|
const bits = world.improvements[tileIndex(world, x, y)];
|
||||||
|
const needsSomething = (!(bits & 4) && terr.irrigate !== null)
|
||||||
|
|| (!(bits & 16) && terr.mine !== null) || !(bits & 1);
|
||||||
|
if (!needsSomething) continue;
|
||||||
|
const d = cheb(unit.x, unit.y, x, y);
|
||||||
|
if (d < bestDist) { bestDist = d; best = [x, y]; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepExplorer(rules, state, civIdx, unit) {
|
||||||
|
const target = nearestFrontier(state, civIdx, unit);
|
||||||
|
if (!target) { unit.mp = 0; return false; }
|
||||||
|
return moveToward(rules, state, unit, target[0], target[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestFrontier(state, civIdx, unit) {
|
||||||
|
const { world } = state;
|
||||||
|
const grid = state.explored[civIdx];
|
||||||
|
let best = null;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
// Sample the map (stride 2) for unexplored tiles adjacent to explored ones.
|
||||||
|
for (let y = 0; y < world.rows; y += 2) {
|
||||||
|
for (let x = 0; x < world.cols; x += 2) {
|
||||||
|
if (grid[tileIndex(world, x, y)]) continue;
|
||||||
|
const d = cheb(unit.x, unit.y, x, y);
|
||||||
|
if (d < bestDist) { bestDist = d; best = [x, y]; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepCaravan(rules, state, civIdx, unit) {
|
||||||
|
if (canEstablishRoute(rules, state, unit)) {
|
||||||
|
establishTradeRoute(rules, state, unit);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const home = cityById(state, unit.homeCity) ?? civCities(state, civIdx)[0];
|
||||||
|
if (!home) { unit.mp = 0; return false; }
|
||||||
|
if (!unit.homeCity) unit.homeCity = home.id;
|
||||||
|
let best = null;
|
||||||
|
let bestScore = -Infinity;
|
||||||
|
for (const city of state.cities) {
|
||||||
|
if (city.id === home.id) continue;
|
||||||
|
if (city.civ !== civIdx && state.civs[civIdx].relations[city.civ] !== 'peace'
|
||||||
|
&& state.civs[civIdx].relations[city.civ] !== 'alliance') continue;
|
||||||
|
const d = cheb(home.x, home.y, city.x, city.y);
|
||||||
|
if (d < 8) continue;
|
||||||
|
const score = (city.civ === civIdx ? 0 : 20) + d - cheb(unit.x, unit.y, city.x, city.y) * 0.5;
|
||||||
|
if (score > bestScore) { bestScore = score; best = city; }
|
||||||
|
}
|
||||||
|
if (!best) { unit.mp = 0; return false; }
|
||||||
|
return moveToward(rules, state, unit, best.x, best.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepShip(rules, state, civIdx, unit, strategy) {
|
||||||
|
const def = rules.units[unit.type];
|
||||||
|
// Warships hunt enemy ships/coastal targets during war; else patrol home.
|
||||||
|
if (def.attack > 0 && strategy.atWarWith.length) {
|
||||||
|
const target = state.units.find((u) => strategy.atWarWith.includes(u.civ)
|
||||||
|
&& rules.units[u.type].domain === 'sea' && cheb(u.x, u.y, unit.x, unit.y) <= 6);
|
||||||
|
if (target) {
|
||||||
|
if (cheb(target.x, target.y, unit.x, unit.y) <= 1) {
|
||||||
|
resolveAttack(rules, state, unit, target.x, target.y);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return moveToward(rules, state, unit, target.x, target.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unit.mp = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCargo(rules, state, unit, strategy) {
|
||||||
|
// Disembark next to a hostile city or onto open land when the boat parks.
|
||||||
|
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]];
|
||||||
|
for (const [dx, dy] of dirs) {
|
||||||
|
const x = unit.x + dx;
|
||||||
|
const y = unit.y + dy;
|
||||||
|
if (!inBounds(state.world, x, y)) continue;
|
||||||
|
if (terrainAt(rules, state.world, x, y).water) continue;
|
||||||
|
const city = cityAt(state, x, y);
|
||||||
|
if (city && strategy.atWarWith.includes(city.civ)) {
|
||||||
|
disembark(rules, state, unit, dx, dy);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepMilitary(rules, state, civIdx, unit, strategy) {
|
||||||
|
const civ = state.civs[civIdx];
|
||||||
|
const def = rules.units[unit.type];
|
||||||
|
const homeCity = cityAt(state, unit.x, unit.y);
|
||||||
|
|
||||||
|
// Keep the garrison staffed before adventuring.
|
||||||
|
if (homeCity && homeCity.civ === civIdx) {
|
||||||
|
const garrison = unitsAt(state, unit.x, unit.y).filter((u) => u.civ === civIdx
|
||||||
|
&& rules.units[u.type].domain === 'land' && !rules.units[u.type].flags.includes('noncombat'));
|
||||||
|
const wanted = strategy.phase === 'war' ? 2 : 1;
|
||||||
|
const myRank = garrison.sort((a, b) => rules.units[b.type].defense - rules.units[a.type].defense)
|
||||||
|
.indexOf(unit);
|
||||||
|
if (myRank >= 0 && myRank < wanted) {
|
||||||
|
unit.fortified = true;
|
||||||
|
unit.mp = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attack adjacent enemies when odds look good.
|
||||||
|
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]];
|
||||||
|
for (const [dx, dy] of dirs) {
|
||||||
|
const x = unit.x + dx;
|
||||||
|
const y = unit.y + dy;
|
||||||
|
if (!inBounds(state.world, x, y)) continue;
|
||||||
|
const enemies = unitsAt(state, x, y).filter((u) => civ.relations[u.civ] === 'war');
|
||||||
|
const enemyCity = cityAt(state, x, y);
|
||||||
|
const cityHostile = enemyCity && civ.relations[enemyCity.civ] === 'war';
|
||||||
|
if (!enemies.length && !cityHostile) continue;
|
||||||
|
if (def.domain === 'land' && terrainAt(rules, state.world, x, y).water) continue;
|
||||||
|
if (def.attack <= 0) continue;
|
||||||
|
if (enemies.length) {
|
||||||
|
const defender = pickDefender(rules, state, x, y, unit);
|
||||||
|
const A = attackerStrength(rules, state, unit);
|
||||||
|
const D = defenderStrength(rules, state, defender, unit);
|
||||||
|
// Massed assault: with friends beside us, grind the walls down even at
|
||||||
|
// poor odds — defenders keep their damage between waves.
|
||||||
|
const allies = unitsAt(state, unit.x, unit.y).filter((u) => u.civ === civIdx
|
||||||
|
&& rules.units[u.type].attack >= 2).length;
|
||||||
|
const gate = cityHostile ? (allies >= 2 ? 0.35 : 0.6) : 0.8;
|
||||||
|
if (A >= D * gate) {
|
||||||
|
const out = tryMove(rules, state, unit, dx, dy);
|
||||||
|
return out.result === 'combat';
|
||||||
|
}
|
||||||
|
} else if (cityHostile) {
|
||||||
|
const out = tryMove(rules, state, unit, dx, dy);
|
||||||
|
return out.result === 'captured';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// March on the weakest reachable enemy city.
|
||||||
|
if (strategy.atWarWith.length && def.attack > 0) {
|
||||||
|
let target = null;
|
||||||
|
let bestScore = -Infinity;
|
||||||
|
for (const city of state.cities) {
|
||||||
|
if (!strategy.atWarWith.includes(city.civ)) continue;
|
||||||
|
const defenders = unitsAt(state, city.x, city.y).length;
|
||||||
|
const d = cheb(unit.x, unit.y, city.x, city.y);
|
||||||
|
const score = -defenders * 4 - d;
|
||||||
|
if (score > bestScore) { bestScore = score; target = city; }
|
||||||
|
}
|
||||||
|
if (target) return moveToward(rules, state, unit, target.x, target.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peacetime: a slice of the army scouts outward (finds neighbours, pops
|
||||||
|
// huts); the rest drifts home and fortifies.
|
||||||
|
if (unit.id % 3 === 0 && strategy.atWarWith.length === 0) {
|
||||||
|
const frontier = nearestFrontier(state, civIdx, unit);
|
||||||
|
if (frontier && moveToward(rules, state, unit, frontier[0], frontier[1])) return true;
|
||||||
|
}
|
||||||
|
const own = civCities(state, civIdx);
|
||||||
|
if (own.length) {
|
||||||
|
let nearest = own[0];
|
||||||
|
for (const c of own) {
|
||||||
|
if (cheb(unit.x, unit.y, c.x, c.y) < cheb(unit.x, unit.y, nearest.x, nearest.y)) nearest = c;
|
||||||
|
}
|
||||||
|
if (cheb(unit.x, unit.y, nearest.x, nearest.y) > 1) {
|
||||||
|
return moveToward(rules, state, unit, nearest.x, nearest.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unit.fortified = true;
|
||||||
|
unit.mp = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
// Civilization — city detail modal: growth, yields, worked-tile emphasis,
|
||||||
|
// build queue with buy, buildings, supported units and trade routes.
|
||||||
|
|
||||||
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||||
|
import { Button } from '../../ui/Button.js';
|
||||||
|
import * as Logic from './CivilizationLogic.js';
|
||||||
|
|
||||||
|
const FONT = '"Julius Sans One"';
|
||||||
|
|
||||||
|
export function openCityScreen(scene, rules, state, city, onClose) {
|
||||||
|
const root = scene.add.container(0, 0).setDepth(65);
|
||||||
|
const dim = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6)
|
||||||
|
.setInteractive();
|
||||||
|
const W = 1500;
|
||||||
|
const H = 860;
|
||||||
|
const px = GAME_WIDTH / 2;
|
||||||
|
const py = GAME_HEIGHT / 2;
|
||||||
|
const panel = scene.add.rectangle(px, py, W, H, COLORS.panel)
|
||||||
|
.setStrokeStyle(3, COLORS.accent);
|
||||||
|
root.add([dim, panel]);
|
||||||
|
|
||||||
|
let dynamic = scene.add.container(0, 0);
|
||||||
|
root.add(dynamic);
|
||||||
|
|
||||||
|
const close = () => { root.destroy(true); onClose(); };
|
||||||
|
const closeBtn = new Button(scene, px + W / 2 - 80, py - H / 2 + 40, '✕', close,
|
||||||
|
{ width: 60, height: 44, fontSize: 22, variant: 'ghost' });
|
||||||
|
root.add(closeBtn);
|
||||||
|
|
||||||
|
function redraw() {
|
||||||
|
dynamic.destroy(true);
|
||||||
|
dynamic = scene.add.container(0, 0);
|
||||||
|
root.add(dynamic);
|
||||||
|
const civ = state.civs[city.civ];
|
||||||
|
Logic.autoAssignTiles(rules, state, city);
|
||||||
|
const y = Logic.cityYields(rules, state, city);
|
||||||
|
const left = px - W / 2 + 40;
|
||||||
|
const top = py - H / 2 + 30;
|
||||||
|
|
||||||
|
dynamic.add(scene.add.text(px, top + 10, `${city.name} — Population ${city.size}`, {
|
||||||
|
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.accentHex,
|
||||||
|
}).setOrigin(0.5, 0));
|
||||||
|
|
||||||
|
// Growth bar.
|
||||||
|
const boxSize = (city.size + 1) * Logic.FOODBOX_PER_SIZE;
|
||||||
|
const gw = 400;
|
||||||
|
dynamic.add(scene.add.text(left, top + 70, 'Growth', { fontFamily: FONT, fontSize: '19px', color: COLORS.mutedHex }));
|
||||||
|
dynamic.add(scene.add.rectangle(left + 90, top + 80, gw, 20, 0x0a0d12).setOrigin(0, 0.5)
|
||||||
|
.setStrokeStyle(1, COLORS.muted));
|
||||||
|
dynamic.add(scene.add.rectangle(left + 90, top + 80,
|
||||||
|
gw * Math.max(0, Math.min(1, city.foodBox / boxSize)), 16, 0x4a9e44).setOrigin(0, 0.5));
|
||||||
|
dynamic.add(scene.add.text(left + 100 + gw, top + 70, `${city.foodBox}/${boxSize}`, {
|
||||||
|
fontFamily: FONT, fontSize: '18px', color: COLORS.textHex,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Yields breakdown.
|
||||||
|
const lines = [
|
||||||
|
`Food ${y.food} (need ${y.foodNeed}) surplus ${y.foodSurplus >= 0 ? '+' : ''}${y.foodSurplus}`,
|
||||||
|
`Shields ${y.shield}${y.supportShields ? ` (support −${y.supportShields})` : ''}`,
|
||||||
|
`Trade ${y.trade} corruption −${y.corruption}${y.routeTrade ? ` routes +${y.routeTrade}` : ''}`,
|
||||||
|
`Gold +${y.gold} · Science +${y.science} · Upkeep −${y.upkeep}`,
|
||||||
|
];
|
||||||
|
dynamic.add(scene.add.text(left, top + 112, lines.join('\n'), {
|
||||||
|
fontFamily: FONT, fontSize: '20px', color: COLORS.textHex, lineSpacing: 8,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Emphasis toggle.
|
||||||
|
dynamic.add(scene.add.text(left, top + 260, 'Worker emphasis:', {
|
||||||
|
fontFamily: FONT, fontSize: '19px', color: COLORS.mutedHex,
|
||||||
|
}));
|
||||||
|
['balanced', 'food', 'production', 'trade'].forEach((emp, i) => {
|
||||||
|
const bx = left + 200 + i * 150;
|
||||||
|
const rect = scene.add.rectangle(bx, top + 270, 138, 40, COLORS.panel)
|
||||||
|
.setStrokeStyle(2, city.emphasis === emp ? COLORS.gold : COLORS.muted);
|
||||||
|
const txt = scene.add.text(bx, top + 270, emp.toUpperCase(), {
|
||||||
|
fontFamily: FONT, fontSize: '16px',
|
||||||
|
color: city.emphasis === emp ? COLORS.goldHex : COLORS.textHex,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
rect.setInteractive({ useHandCursor: true });
|
||||||
|
rect.on('pointerdown', () => { city.emphasis = emp; redraw(); });
|
||||||
|
dynamic.add(rect);
|
||||||
|
dynamic.add(txt);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buildings list.
|
||||||
|
dynamic.add(scene.add.text(left, top + 320, 'City Improvements', {
|
||||||
|
fontFamily: FONT, fontSize: '21px', color: COLORS.accentHex,
|
||||||
|
}));
|
||||||
|
const built = Object.keys(city.buildings).map((id) => rules.buildings[id]?.name ?? id);
|
||||||
|
dynamic.add(scene.add.text(left, top + 355, built.length ? built.join(', ') : '(none yet)', {
|
||||||
|
fontFamily: FONT, fontSize: '18px', color: COLORS.textHex,
|
||||||
|
wordWrap: { width: 640 }, lineSpacing: 6,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Supported units + routes.
|
||||||
|
const supported = state.units.filter((u) => u.homeCity === city.id)
|
||||||
|
.map((u) => rules.units[u.type].name);
|
||||||
|
dynamic.add(scene.add.text(left, top + 500, `Supported units (${supported.length})`, {
|
||||||
|
fontFamily: FONT, fontSize: '21px', color: COLORS.accentHex,
|
||||||
|
}));
|
||||||
|
dynamic.add(scene.add.text(left, top + 535, supported.length ? supported.join(', ') : '(none)', {
|
||||||
|
fontFamily: FONT, fontSize: '17px', color: COLORS.textHex,
|
||||||
|
wordWrap: { width: 640 }, lineSpacing: 5,
|
||||||
|
}));
|
||||||
|
const routes = city.routes.map((r) => {
|
||||||
|
const other = Logic.cityById(state, r.cityId);
|
||||||
|
return other ? `${other.name} (+${r.amount})` : `(lost city +${r.amount})`;
|
||||||
|
});
|
||||||
|
dynamic.add(scene.add.text(left, top + 660, `Trade routes: ${routes.length ? routes.join(', ') : '(none)'}`, {
|
||||||
|
fontFamily: FONT, fontSize: '18px', color: COLORS.textHex, wordWrap: { width: 640 },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// --- Build picker (right column)
|
||||||
|
const rx = px + 60;
|
||||||
|
const cost = Logic.buildCost(rules, city);
|
||||||
|
const cur = city.build.type === 'unit' ? rules.units[city.build.id] : rules.buildings[city.build.id];
|
||||||
|
const turns = y.shield > 0 ? Math.ceil((cost - city.shieldBox) / y.shield) : '∞';
|
||||||
|
dynamic.add(scene.add.text(rx, top + 64,
|
||||||
|
`Building: ${cur.name} ${city.shieldBox}/${cost} shields (${turns} turns)`, {
|
||||||
|
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex,
|
||||||
|
}));
|
||||||
|
const buyPrice = Logic.buyCost(rules, city);
|
||||||
|
const buyBtn = new Button(scene, rx + 560, top + 120, `BUY (${buyPrice}g)`, () => {
|
||||||
|
if (Logic.buyBuild(rules, state, city)) redraw();
|
||||||
|
}, { width: 190, height: 46, fontSize: 18, variant: civ.gold >= buyPrice && !city.boughtThisTurn ? 'solid' : 'ghost' });
|
||||||
|
dynamic.add(buyBtn);
|
||||||
|
dynamic.add(scene.add.text(rx, top + 108, `Treasury: ${civ.gold} gold`, {
|
||||||
|
fontFamily: FONT, fontSize: '19px', color: COLORS.goldHex,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Choices: units then buildings, two columns of rows.
|
||||||
|
const unitChoices = Logic.availableUnits(rules, state, civ, city)
|
||||||
|
.map((u) => ({ type: 'unit', id: u.id, label: `${u.name} (${u.cost})`, tip: `A${u.attack} D${u.defense} M${u.move}` }));
|
||||||
|
const bldChoices = Logic.availableBuildings(rules, state, civ, city)
|
||||||
|
.map((b) => ({ type: 'building', id: b.id, label: `${b.name} (${b.cost})`, tip: `upkeep ${b.upkeep}` }));
|
||||||
|
const choices = [...unitChoices, ...bldChoices];
|
||||||
|
const colW = 330;
|
||||||
|
const rowH = 36;
|
||||||
|
const perCol = 17;
|
||||||
|
choices.slice(0, perCol * 2).forEach((ch, i) => {
|
||||||
|
const colX = rx + Math.floor(i / perCol) * (colW + 20);
|
||||||
|
const rowY = top + 170 + (i % perCol) * rowH;
|
||||||
|
const active = city.build.type === ch.type && city.build.id === ch.id;
|
||||||
|
const rect = scene.add.rectangle(colX + colW / 2, rowY, colW, rowH - 4,
|
||||||
|
active ? 0x3a3222 : 0x181510)
|
||||||
|
.setStrokeStyle(1, active ? COLORS.gold : COLORS.muted, active ? 1 : 0.5);
|
||||||
|
const txt = scene.add.text(colX + 10, rowY, ch.label, {
|
||||||
|
fontFamily: FONT, fontSize: '16px',
|
||||||
|
color: ch.type === 'unit' ? '#cfe3ff' : '#ffe9bd',
|
||||||
|
}).setOrigin(0, 0.5);
|
||||||
|
rect.setInteractive({ useHandCursor: true });
|
||||||
|
rect.on('pointerdown', () => {
|
||||||
|
Logic.setBuild(rules, state, city, ch.type, ch.id);
|
||||||
|
redraw();
|
||||||
|
});
|
||||||
|
dynamic.add(rect);
|
||||||
|
dynamic.add(txt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
redraw();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,825 @@
|
||||||
|
// Civilization (Civ II-lite) — main Phaser scene.
|
||||||
|
//
|
||||||
|
// Phases: setup -> playing -> over. Setup picks a leader character (the
|
||||||
|
// standard opponents double as civilizations), world size, rival count and
|
||||||
|
// difficulty. The engine (CivilizationLogic) is headless; this scene drives it
|
||||||
|
// and renders through CivilizationMapView. AI rivals play via CivilizationAI.
|
||||||
|
|
||||||
|
import * as Phaser from 'phaser';
|
||||||
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||||
|
import { Button } from '../../ui/Button.js';
|
||||||
|
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||||
|
import { compileRules, turnToYear, formatYear } from './CivilizationRules.js';
|
||||||
|
import * as Logic from './CivilizationLogic.js';
|
||||||
|
import { runAITurn, respondToProposal } from './CivilizationAI.js';
|
||||||
|
import { CivilizationMapView } from './CivilizationMapView.js';
|
||||||
|
import { openCityScreen } from './CivilizationCityScreen.js';
|
||||||
|
import {
|
||||||
|
openTechScreen, openDiplomacyScreen, openSpaceshipScreen, showVictoryOverlay,
|
||||||
|
} from './CivilizationScreens.js';
|
||||||
|
|
||||||
|
const FONT = '"Julius Sans One"';
|
||||||
|
const SAVE_KEY = 'civilization-save';
|
||||||
|
const D = { hud: 30, modal: 60, toast: 80 };
|
||||||
|
|
||||||
|
export default class CivilizationGame extends Phaser.Scene {
|
||||||
|
constructor() { super('CivilizationGame'); }
|
||||||
|
|
||||||
|
init(data) {
|
||||||
|
this.gameDef = data.game ?? { slug: 'civilization', name: 'Civilization' };
|
||||||
|
this.state = null;
|
||||||
|
this.view = null;
|
||||||
|
this.phase = 'setup';
|
||||||
|
this.modalOpen = false;
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
this.rules = compileRules(this.cache.json.get('civilization-rules'));
|
||||||
|
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0a0d12)
|
||||||
|
.setDepth(-10);
|
||||||
|
|
||||||
|
this.hudRoot = this.add.container(0, 0).setDepth(D.hud);
|
||||||
|
this.modalRoot = this.add.container(0, 0).setDepth(D.modal);
|
||||||
|
this.toastRoot = this.add.container(0, 0).setDepth(D.toast);
|
||||||
|
this.setupRoot = null;
|
||||||
|
this.toasts = [];
|
||||||
|
|
||||||
|
this.keys = this.input.keyboard.addKeys('UP,DOWN,LEFT,RIGHT,Q,E,Z,C,W,A,S,D,B,F,G,I,M,N,O,R,T,SPACE,ENTER');
|
||||||
|
this.escHandler = () => this.onEscape();
|
||||||
|
this.input.keyboard.on('keydown-ESC', this.escHandler);
|
||||||
|
this.events.once('shutdown', () => {
|
||||||
|
this.input.keyboard.off('keydown-ESC', this.escHandler);
|
||||||
|
this.view?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const music = this.cache.json.get('music');
|
||||||
|
if (music?.tracks) this.music = new MusicPlayer(this, music.tracks);
|
||||||
|
} catch (_) { /* optional */ }
|
||||||
|
|
||||||
|
this.opponentsData = [];
|
||||||
|
fetch('data/opponents.json')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((json) => { this.opponentsData = json.opponents ?? []; this.showSetup(); })
|
||||||
|
.catch(() => { this.opponentsData = []; this.showSetup(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
onEscape() {
|
||||||
|
if (this.modalOpen) return; // modals close themselves
|
||||||
|
if (this.phase === 'playing') this.openMenu();
|
||||||
|
else this.scene.start('GameMenu');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Setup phase
|
||||||
|
|
||||||
|
showSetup() {
|
||||||
|
this.phase = 'setup';
|
||||||
|
this.setupRoot?.destroy(true);
|
||||||
|
this.setupRoot = this.add.container(0, 0).setDepth(10);
|
||||||
|
const root = this.setupRoot;
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
|
||||||
|
root.add(this.add.text(cx, 52, 'CIVILIZATION', {
|
||||||
|
fontFamily: 'Righteous', fontSize: '54px', color: COLORS.accentHex,
|
||||||
|
}).setOrigin(0.5));
|
||||||
|
root.add(this.add.text(cx, 100, 'Build an empire to stand the test of time', {
|
||||||
|
fontFamily: FONT, fontSize: '20px', color: COLORS.mutedHex,
|
||||||
|
}).setOrigin(0.5));
|
||||||
|
|
||||||
|
// --- leader pick grid
|
||||||
|
root.add(this.add.text(cx, 150, 'CHOOSE YOUR LEADER', {
|
||||||
|
fontFamily: FONT, fontSize: '24px', color: COLORS.textHex,
|
||||||
|
}).setOrigin(0.5));
|
||||||
|
|
||||||
|
const leaders = this.opponentsData.length ? this.opponentsData
|
||||||
|
: Array.from({ length: 8 }, (_, i) => ({ id: `leader${i}`, name: `Leader ${i + 1}`, spriteIndex: 0 }));
|
||||||
|
this.setupLeaders = leaders;
|
||||||
|
this.pickedLeader = this.pickedLeader ?? 0;
|
||||||
|
|
||||||
|
const perRow = 15;
|
||||||
|
const cell = 96;
|
||||||
|
const gridW = Math.min(leaders.length, perRow) * cell;
|
||||||
|
const gx = cx - gridW / 2 + cell / 2;
|
||||||
|
const gy = 214;
|
||||||
|
this.leaderMarks = [];
|
||||||
|
leaders.forEach((op, i) => {
|
||||||
|
const x = gx + (i % perRow) * cell;
|
||||||
|
const y = gy + Math.floor(i / perRow) * (cell + 18);
|
||||||
|
const ring = this.add.circle(x, y, 40, COLORS.panel)
|
||||||
|
.setStrokeStyle(3, i === this.pickedLeader ? COLORS.gold : COLORS.muted);
|
||||||
|
let face;
|
||||||
|
if (this.textures.exists('opponents')) {
|
||||||
|
face = this.add.image(x, y, 'opponents', op.spriteIndex ?? 0).setDisplaySize(72, 72);
|
||||||
|
const maskShape = this.make.graphics({ add: false });
|
||||||
|
maskShape.fillStyle(0xffffff);
|
||||||
|
maskShape.fillCircle(x, y, 36);
|
||||||
|
face.setMask(maskShape.createGeometryMask());
|
||||||
|
} else {
|
||||||
|
face = this.add.text(x, y, op.name[0], {
|
||||||
|
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
}
|
||||||
|
const label = this.add.text(x, y + 52, op.name, {
|
||||||
|
fontFamily: FONT, fontSize: '14px',
|
||||||
|
color: i === this.pickedLeader ? COLORS.goldHex : COLORS.mutedHex,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
ring.setInteractive({ useHandCursor: true });
|
||||||
|
ring.on('pointerdown', () => {
|
||||||
|
this.pickedLeader = i;
|
||||||
|
this.leaderMarks.forEach((m, j) => {
|
||||||
|
m.ring.setStrokeStyle(3, j === i ? COLORS.gold : COLORS.muted);
|
||||||
|
m.label.setColor(j === i ? COLORS.goldHex : COLORS.mutedHex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.leaderMarks.push({ ring, label });
|
||||||
|
root.add([ring, face, label]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowsUsed = Math.ceil(leaders.length / perRow);
|
||||||
|
let oy = gy + rowsUsed * (cell + 18) + 40;
|
||||||
|
|
||||||
|
// --- option rows
|
||||||
|
this.setupOpts = this.setupOpts ?? { sizeId: 'medium', opponents: 3, difficultyId: 'prince' };
|
||||||
|
const mkRow = (label, options, current, onPick) => {
|
||||||
|
root.add(this.add.text(cx - 560, oy, label, {
|
||||||
|
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex,
|
||||||
|
}).setOrigin(0, 0.5));
|
||||||
|
const marks = [];
|
||||||
|
const startX = cx - 260;
|
||||||
|
options.forEach((opt, i) => {
|
||||||
|
const w = Math.max(90, opt.label.length * 13 + 30);
|
||||||
|
const x = startX + options.slice(0, i).reduce((a, o) => a + Math.max(90, o.label.length * 13 + 30) + 14, 0);
|
||||||
|
const rect = this.add.rectangle(x + w / 2, oy, w, 44, COLORS.panel)
|
||||||
|
.setStrokeStyle(2, opt.value === current ? COLORS.gold : COLORS.muted);
|
||||||
|
const txt = this.add.text(x + w / 2, oy, opt.label, {
|
||||||
|
fontFamily: FONT, fontSize: '19px',
|
||||||
|
color: opt.value === current ? COLORS.goldHex : COLORS.textHex,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
rect.setInteractive({ useHandCursor: true });
|
||||||
|
rect.on('pointerdown', () => {
|
||||||
|
onPick(opt.value);
|
||||||
|
marks.forEach((m, j) => {
|
||||||
|
m.rect.setStrokeStyle(2, options[j].value === opt.value ? COLORS.gold : COLORS.muted);
|
||||||
|
m.txt.setColor(options[j].value === opt.value ? COLORS.goldHex : COLORS.textHex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
marks.push({ rect, txt });
|
||||||
|
root.add([rect, txt]);
|
||||||
|
});
|
||||||
|
oy += 62;
|
||||||
|
};
|
||||||
|
|
||||||
|
mkRow('World Size',
|
||||||
|
this.rules.worldSizeList.map((w) => ({ label: `${w.name} (${w.cols}×${w.rows})`, value: w.id })),
|
||||||
|
this.setupOpts.sizeId, (v) => { this.setupOpts.sizeId = v; });
|
||||||
|
mkRow('Rival Civilizations',
|
||||||
|
[2, 3, 4, 5, 6, 7].map((n) => ({ label: `${n}`, value: n })),
|
||||||
|
this.setupOpts.opponents, (v) => { this.setupOpts.opponents = v; });
|
||||||
|
mkRow('Difficulty',
|
||||||
|
this.rules.difficultyList.map((d) => ({ label: d.name, value: d.id })),
|
||||||
|
this.setupOpts.difficultyId, (v) => { this.setupOpts.difficultyId = v; });
|
||||||
|
|
||||||
|
oy += 8;
|
||||||
|
const startBtn = new Button(this, cx - (this.hasSave() ? 160 : 0), oy + 20, 'BEGIN', () => {
|
||||||
|
this.startNewGame();
|
||||||
|
}, { width: 280, height: 64, fontSize: 30 });
|
||||||
|
root.add(startBtn);
|
||||||
|
if (this.hasSave()) {
|
||||||
|
const resumeBtn = new Button(this, cx + 160, oy + 20, 'RESUME GAME', () => {
|
||||||
|
this.resumeGame();
|
||||||
|
}, { width: 280, height: 64, fontSize: 26, variant: 'ghost' });
|
||||||
|
root.add(resumeBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSave() {
|
||||||
|
try { return !!localStorage.getItem(SAVE_KEY); } catch (_) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
startNewGame() {
|
||||||
|
const { sizeId, opponents, difficultyId } = this.setupOpts;
|
||||||
|
const pool = this.setupLeaders;
|
||||||
|
const me = pool[this.pickedLeader];
|
||||||
|
const rivals = pool.filter((_, i) => i !== this.pickedLeader);
|
||||||
|
// Shuffle rivals with Math.random (game determinism starts at engine seed).
|
||||||
|
for (let i = rivals.length - 1; i > 0; i -= 1) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[rivals[i], rivals[j]] = [rivals[j], rivals[i]];
|
||||||
|
}
|
||||||
|
const leaders = [me, ...rivals.slice(0, opponents)]
|
||||||
|
.map((op) => ({ id: op.id, name: op.name }));
|
||||||
|
const seed = (Date.now() % 1000000) + 1;
|
||||||
|
try {
|
||||||
|
this.state = Logic.createGame(this.rules, {
|
||||||
|
sizeId, seed, difficultyId, leaders, humanIndex: 0,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.toast(`World generation failed — try again (${err.message})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.leaderPool = leaders;
|
||||||
|
this.beginPlaying();
|
||||||
|
}
|
||||||
|
|
||||||
|
resumeGame() {
|
||||||
|
try {
|
||||||
|
const state = Logic.deserialize(localStorage.getItem(SAVE_KEY));
|
||||||
|
if (!state) { this.toast('Saved game is from an old version'); localStorage.removeItem(SAVE_KEY); return; }
|
||||||
|
this.state = state;
|
||||||
|
this.beginPlaying(true);
|
||||||
|
} catch (_) {
|
||||||
|
this.toast('Could not load the save');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveGame() {
|
||||||
|
try { localStorage.setItem(SAVE_KEY, Logic.serialize(this.state)); } catch (_) { /* full */ }
|
||||||
|
}
|
||||||
|
clearSave() {
|
||||||
|
try { localStorage.removeItem(SAVE_KEY); } catch (_) { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Playing phase
|
||||||
|
|
||||||
|
beginPlaying(resumed = false) {
|
||||||
|
this.phase = 'playing';
|
||||||
|
this.setupRoot?.destroy(true);
|
||||||
|
this.setupRoot = null;
|
||||||
|
this.view = new CivilizationMapView(this, this.rules, this.state, {
|
||||||
|
onCityClick: (city) => this.onCityClick(city),
|
||||||
|
});
|
||||||
|
this.view.buildMinimap(16, GAME_HEIGHT - 260, 300);
|
||||||
|
this.buildHud();
|
||||||
|
this.bindPointer();
|
||||||
|
|
||||||
|
const human = this.state.civs[this.state.humanIndex];
|
||||||
|
const capital = Logic.civCities(this.state, human.id)[0];
|
||||||
|
const firstUnit = Logic.civUnits(this.state, human.id)[0];
|
||||||
|
const focus = capital ?? firstUnit;
|
||||||
|
if (focus) this.view.centerOn(focus.x, focus.y);
|
||||||
|
|
||||||
|
if (!resumed || this.state.current !== this.state.humanIndex) {
|
||||||
|
// Fresh games (and saves mid-AI-round) run up to the human turn.
|
||||||
|
this.runToHumanTurn();
|
||||||
|
} else {
|
||||||
|
this.startHumanTurn(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildHud() {
|
||||||
|
this.hudRoot.removeAll(true);
|
||||||
|
const bar = this.add.rectangle(GAME_WIDTH / 2, 28, GAME_WIDTH, 56, COLORS.panel, 0.95)
|
||||||
|
.setStrokeStyle(1, COLORS.accent, 0.6);
|
||||||
|
this.hudRoot.add(bar);
|
||||||
|
this.hudText = this.add.text(20, 28, '', {
|
||||||
|
fontFamily: FONT, fontSize: '21px', color: COLORS.textHex,
|
||||||
|
}).setOrigin(0, 0.5);
|
||||||
|
this.hudRoot.add(this.hudText);
|
||||||
|
|
||||||
|
const mkBtn = (x, label, fn, w = 150) => {
|
||||||
|
const b = new Button(this, x, 28, label, fn, { width: w, height: 42, fontSize: 18 });
|
||||||
|
this.hudRoot.add(b);
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
mkBtn(GAME_WIDTH - 760, 'TECH', () => this.openTech());
|
||||||
|
mkBtn(GAME_WIDTH - 600, 'DIPLOMACY', () => this.openDiplomacy(), 170);
|
||||||
|
mkBtn(GAME_WIDTH - 430, 'SPACESHIP', () => this.openSpaceship(), 170);
|
||||||
|
mkBtn(GAME_WIDTH - 270, 'MENU', () => this.openMenu(), 120);
|
||||||
|
this.endTurnBtn = new Button(this, GAME_WIDTH - 110, 28, 'END TURN', () => this.onEndTurn(),
|
||||||
|
{ width: 180, height: 42, fontSize: 18, bg: COLORS.gold, textColor: COLORS.textDarkHex });
|
||||||
|
this.hudRoot.add(this.endTurnBtn);
|
||||||
|
|
||||||
|
// Right-side unit/terrain panel.
|
||||||
|
this.unitPanel = this.add.container(GAME_WIDTH - 300, 80);
|
||||||
|
const panelBg = this.add.rectangle(0, 0, 284, 240, COLORS.panel, 0.92)
|
||||||
|
.setOrigin(0, 0).setStrokeStyle(2, COLORS.accent, 0.7);
|
||||||
|
this.unitPanelText = this.add.text(14, 14, '', {
|
||||||
|
fontFamily: FONT, fontSize: '17px', color: COLORS.textHex,
|
||||||
|
wordWrap: { width: 256 }, lineSpacing: 5,
|
||||||
|
});
|
||||||
|
this.unitPanel.add([panelBg, this.unitPanelText]);
|
||||||
|
this.hudRoot.add(this.unitPanel);
|
||||||
|
|
||||||
|
this.hintText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 24,
|
||||||
|
'Arrows/QEZC move · B found city · R road · I irrigate · M mine · O fortress · F fortify · Space skip · N next · Enter end turn',
|
||||||
|
{ fontFamily: FONT, fontSize: '15px', color: COLORS.mutedHex }).setOrigin(0.5);
|
||||||
|
this.hudRoot.add(this.hintText);
|
||||||
|
this.refreshHud();
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshHud() {
|
||||||
|
if (!this.state || this.phase !== 'playing') return;
|
||||||
|
const civ = this.state.civs[this.state.humanIndex];
|
||||||
|
const year = formatYear(turnToYear(this.state.turn, this.rules.yearCurve));
|
||||||
|
const gov = this.rules.governments[civ.government].name;
|
||||||
|
const research = civ.researching
|
||||||
|
? `${this.rules.techs[civ.researching].name} ${civ.beakers}/${Logic.currentResearchCost(this.rules, this.state, civ)}`
|
||||||
|
: '— pick research —';
|
||||||
|
this.hudText.setText(`${year} Gold: ${civ.gold} Science: ${research} ${gov}`);
|
||||||
|
this.refreshUnitPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshUnitPanel() {
|
||||||
|
const unit = this.selectedUnit();
|
||||||
|
if (!unit) {
|
||||||
|
this.unitPanelText.setText(this.busy ? 'Rivals are moving…' : 'No unit selected.\nClick a unit or press N.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const def = this.rules.units[unit.type];
|
||||||
|
const terr = Logic.terrainAt(this.rules, this.state.world, unit.x, unit.y);
|
||||||
|
const idx = Logic.tileIndex(this.state.world, unit.x, unit.y);
|
||||||
|
const spec = this.state.world.special[idx] >= 0
|
||||||
|
? this.rules.specialList[this.state.world.special[idx]].name : null;
|
||||||
|
const lines = [
|
||||||
|
`${def.name}${unit.vet ? ' (V)' : ''}`,
|
||||||
|
`A${def.attack} D${def.defense} HP ${unit.hp}/${def.hp}`,
|
||||||
|
`Moves: ${(unit.mp / 3).toFixed(unit.mp % 3 ? 1 : 0)}`,
|
||||||
|
`Terrain: ${terr.name}${spec ? ` (${spec})` : ''}`,
|
||||||
|
];
|
||||||
|
if (unit.order?.kind === 'work') {
|
||||||
|
lines.push(`Working: ${this.rules.improvements[unit.order.imp].name}`);
|
||||||
|
}
|
||||||
|
if (unit.fortified) lines.push('Fortified');
|
||||||
|
this.unitPanelText.setText(lines.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pointer + keyboard input
|
||||||
|
|
||||||
|
bindPointer() {
|
||||||
|
let dragStart = null;
|
||||||
|
let dragged = false;
|
||||||
|
this.input.on('pointerdown', (pointer) => {
|
||||||
|
if (this.modalOpen || this.phase !== 'playing') return;
|
||||||
|
dragStart = { x: pointer.x, y: pointer.y, rx: this.view.root.x, ry: this.view.root.y };
|
||||||
|
dragged = false;
|
||||||
|
});
|
||||||
|
this.input.on('pointermove', (pointer) => {
|
||||||
|
if (!pointer.isDown || !dragStart || this.modalOpen) return;
|
||||||
|
const dx = pointer.x - dragStart.x;
|
||||||
|
const dy = pointer.y - dragStart.y;
|
||||||
|
if (Math.abs(dx) + Math.abs(dy) > 8) dragged = true;
|
||||||
|
if (dragged) {
|
||||||
|
this.view.root.x = dragStart.rx + dx;
|
||||||
|
this.view.root.y = dragStart.ry + dy;
|
||||||
|
this.view.clampPan();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.input.on('pointerup', (pointer) => {
|
||||||
|
const wasDrag = dragged;
|
||||||
|
dragStart = null;
|
||||||
|
dragged = false;
|
||||||
|
if (wasDrag || this.modalOpen || this.phase !== 'playing' || this.busy) return;
|
||||||
|
if (pointer.y < 56 || pointer.y > GAME_HEIGHT - 44) return; // HUD bands
|
||||||
|
// Clicks that landed on any interactive object (buttons, minimap, city
|
||||||
|
// banners) are theirs, not the map's.
|
||||||
|
if (this.input.hitTestPointer(pointer).length > 0) return;
|
||||||
|
const tile = this.view.screenToTile(pointer.x, pointer.y);
|
||||||
|
if (tile) this.onTileClick(tile[0], tile[1]);
|
||||||
|
});
|
||||||
|
this.input.on('wheel', (pointer, objs, dx, dy) => {
|
||||||
|
if (this.modalOpen || this.phase !== 'playing') return;
|
||||||
|
this.view.zoomBy(dy > 0 ? -1 : 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onTileClick(c, r) {
|
||||||
|
const state = this.state;
|
||||||
|
const human = state.humanIndex;
|
||||||
|
const myUnits = Logic.unitsAt(state, c, r).filter((u) => u.civ === human);
|
||||||
|
const sel = this.selectedUnit();
|
||||||
|
|
||||||
|
if (myUnits.length && (!sel || sel.x !== c || sel.y !== r)) {
|
||||||
|
this.selectUnit(myUnits[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (myUnits.length && sel && sel.x === c && sel.y === r) {
|
||||||
|
// Cycle the stack.
|
||||||
|
const i = myUnits.indexOf(sel);
|
||||||
|
this.selectUnit(myUnits[(i + 1) % myUnits.length]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const city = Logic.cityAt(state, c, r);
|
||||||
|
if (city && city.civ === human && !myUnits.length) {
|
||||||
|
this.onCityClick(city);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Move the selected unit toward the clicked tile.
|
||||||
|
if (sel && state.current === human) {
|
||||||
|
if (Logic.cheb(sel.x, sel.y, c, r) === 1) {
|
||||||
|
this.tryStep(sel, c - sel.x, r - sel.y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const path = Logic.findPath(this.rules, state, sel, c, r);
|
||||||
|
if (!path) { this.toast('No route there'); return; }
|
||||||
|
this.view.showPath(path);
|
||||||
|
this.walkPath(sel, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCityClick(city) {
|
||||||
|
if (city.civ !== this.state.humanIndex || this.modalOpen) return;
|
||||||
|
this.modalOpen = true;
|
||||||
|
openCityScreen(this, this.rules, this.state, city, () => {
|
||||||
|
this.modalOpen = false;
|
||||||
|
this.view.refresh();
|
||||||
|
this.refreshHud();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (this.phase !== 'playing' || this.modalOpen || this.busy) return;
|
||||||
|
// Edge-of-keyboard panning.
|
||||||
|
const k = this.keys;
|
||||||
|
const pan = 14;
|
||||||
|
if (k.W.isDown) this.view.panBy(0, pan);
|
||||||
|
if (k.S.isDown && !k.S.shiftKey) this.view.panBy(0, -pan);
|
||||||
|
if (k.A.isDown) this.view.panBy(pan, 0);
|
||||||
|
if (k.D.isDown) this.view.panBy(-pan, 0);
|
||||||
|
|
||||||
|
if (this.state?.current !== this.state?.humanIndex) return;
|
||||||
|
const sel = this.selectedUnit();
|
||||||
|
const just = (key) => Phaser.Input.Keyboard.JustDown(key);
|
||||||
|
|
||||||
|
if (just(k.N)) this.selectNextUnit();
|
||||||
|
if (just(k.ENTER)) this.onEndTurn();
|
||||||
|
if (!sel) return;
|
||||||
|
|
||||||
|
if (just(k.UP)) this.tryStep(sel, 0, -1);
|
||||||
|
else if (just(k.DOWN)) this.tryStep(sel, 0, 1);
|
||||||
|
else if (just(k.LEFT)) this.tryStep(sel, -1, 0);
|
||||||
|
else if (just(k.RIGHT)) this.tryStep(sel, 1, 0);
|
||||||
|
else if (just(k.Q)) this.tryStep(sel, -1, -1);
|
||||||
|
else if (just(k.E)) this.tryStep(sel, 1, -1);
|
||||||
|
else if (just(k.Z)) this.tryStep(sel, -1, 1);
|
||||||
|
else if (just(k.C)) this.tryStep(sel, 1, 1);
|
||||||
|
else if (just(k.SPACE)) { sel.mp = 0; this.selectNextUnit(); }
|
||||||
|
else if (just(k.F)) { sel.fortified = true; sel.mp = 0; this.afterAction(); this.selectNextUnit(); }
|
||||||
|
else if (just(k.B)) this.tryFound(sel);
|
||||||
|
else if (just(k.R)) this.tryWork(sel, this.canRail(sel) ? 'railroad' : 'road');
|
||||||
|
else if (just(k.I)) this.tryWork(sel, 'irrigation');
|
||||||
|
else if (just(k.M)) this.tryWork(sel, 'mine');
|
||||||
|
else if (just(k.O)) this.tryWork(sel, 'fortress');
|
||||||
|
else if (just(k.T)) this.tryWork(sel, 'transform');
|
||||||
|
else if (just(k.G)) this.tryCaravan(sel);
|
||||||
|
}
|
||||||
|
|
||||||
|
canRail(unit) {
|
||||||
|
const bits = this.state.world.improvements[Logic.tileIndex(this.state.world, unit.x, unit.y)];
|
||||||
|
return (bits & Logic.IMP.ROAD) && this.state.civs[unit.civ].known.railroad;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedUnit() {
|
||||||
|
if (!this.state || this.selectedUnitId == null) return null;
|
||||||
|
const u = Logic.unitById(this.state, this.selectedUnitId);
|
||||||
|
if (!u || u.civ !== this.state.humanIndex) return null;
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectUnit(unit) {
|
||||||
|
this.selectedUnitId = unit?.id ?? null;
|
||||||
|
this.view.selectedUnitId = this.selectedUnitId;
|
||||||
|
this.view.showPath(null);
|
||||||
|
if (unit) this.view.centerOnIfOffscreen?.(unit.x, unit.y);
|
||||||
|
this.view.refresh();
|
||||||
|
this.refreshUnitPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectNextUnit() {
|
||||||
|
const units = Logic.civUnits(this.state, this.state.humanIndex)
|
||||||
|
.filter((u) => u.mp > 0 && !u.fortified && !u.sentry && !u.order && !u.carriedBy
|
||||||
|
&& this.rules.units[u.type].domain !== 'project');
|
||||||
|
if (!units.length) { this.selectUnit(null); return; }
|
||||||
|
const curIdx = units.findIndex((u) => u.id === this.selectedUnitId);
|
||||||
|
const next = units[(curIdx + 1) % units.length];
|
||||||
|
this.selectUnit(next);
|
||||||
|
this.view.centerOn(next.x, next.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
tryStep(unit, dx, dy) {
|
||||||
|
if (this.state.current !== this.state.humanIndex) return;
|
||||||
|
const out = unit.carriedBy
|
||||||
|
? Logic.disembark(this.rules, this.state, unit, dx, dy)
|
||||||
|
: Logic.tryMove(this.rules, this.state, unit, dx, dy);
|
||||||
|
if (out.result === 'blocked' && out.needsWar !== undefined) {
|
||||||
|
this.confirmWar(out.needsWar, () => {
|
||||||
|
Logic.declareWar(this.rules, this.state, this.state.humanIndex, out.needsWar);
|
||||||
|
this.tryStep(unit, dx, dy);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (out.result === 'invalid') return;
|
||||||
|
if (out.hut) this.toastHut(out.hut);
|
||||||
|
this.afterAction();
|
||||||
|
if (unit.mp <= 0 && this.state.units.includes(unit)) this.selectNextUnit();
|
||||||
|
if (!this.state.units.includes(unit)) this.selectNextUnit();
|
||||||
|
}
|
||||||
|
|
||||||
|
walkPath(unit, path) {
|
||||||
|
// Step along the found path until movement runs out or something happens.
|
||||||
|
let i = 0;
|
||||||
|
const step = () => {
|
||||||
|
if (i >= path.length || unit.mp <= 0 || !this.state.units.includes(unit) || this.state.over) {
|
||||||
|
this.view.showPath(null);
|
||||||
|
this.afterAction();
|
||||||
|
if (!this.state.units.includes(unit) || unit.mp <= 0) this.selectNextUnit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [nx, ny] = path[i];
|
||||||
|
i += 1;
|
||||||
|
const out = Logic.tryMove(this.rules, this.state, unit,
|
||||||
|
Math.sign(nx - unit.x), Math.sign(ny - unit.y));
|
||||||
|
if (out.result === 'invalid' || out.result === 'blocked') {
|
||||||
|
this.view.showPath(null);
|
||||||
|
this.afterAction();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (out.hut) this.toastHut(out.hut);
|
||||||
|
this.view.refresh();
|
||||||
|
this.time.delayedCall(60, step);
|
||||||
|
};
|
||||||
|
step();
|
||||||
|
}
|
||||||
|
|
||||||
|
tryFound(unit) {
|
||||||
|
const def = this.rules.units[unit.type];
|
||||||
|
if (!def.flags.includes('settler')) return;
|
||||||
|
if (!Logic.canFoundCity(this.rules, this.state, unit.x, unit.y)) {
|
||||||
|
this.toast('Cannot found a city here');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const city = Logic.foundCity(this.rules, this.state, unit);
|
||||||
|
if (city) {
|
||||||
|
this.toast(`${city.name} founded!`);
|
||||||
|
this.view.repaintTileAndNeighbors(city.x, city.y);
|
||||||
|
this.afterAction();
|
||||||
|
this.selectNextUnit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tryWork(unit, impId) {
|
||||||
|
if (Logic.startWork(this.rules, this.state, unit, impId)) {
|
||||||
|
this.toast(`${this.rules.units[unit.type].name}: building ${this.rules.improvements[impId].name}`);
|
||||||
|
this.afterAction();
|
||||||
|
this.selectNextUnit();
|
||||||
|
} else {
|
||||||
|
this.toast(`Cannot build ${this.rules.improvements[impId]?.name ?? impId} here`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tryCaravan(unit) {
|
||||||
|
const pair = Logic.canEstablishRoute(this.rules, this.state, unit);
|
||||||
|
if (!pair) { this.toast('Caravans need a city 8+ tiles from home'); return; }
|
||||||
|
const out = Logic.establishTradeRoute(this.rules, this.state, unit);
|
||||||
|
if (out) {
|
||||||
|
this.toast(`Trade route: ${out.from.name} ↔ ${out.to.name} (+${out.bonus} gold & beakers)`);
|
||||||
|
this.afterAction();
|
||||||
|
this.selectNextUnit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAction() {
|
||||||
|
// Repaint tiles that may have changed (work orders complete on turn start,
|
||||||
|
// but roads from engineers etc. show up next refresh — cheap full check
|
||||||
|
// is unnecessary; unit/city layer + HUD is enough here).
|
||||||
|
this.view.refresh();
|
||||||
|
this.refreshHud();
|
||||||
|
if (this.state.over) this.onGameOver();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Turn cycle
|
||||||
|
|
||||||
|
startHumanTurn(process = true) {
|
||||||
|
const human = this.state.humanIndex;
|
||||||
|
if (process) Logic.beginCivTurn(this.rules, this.state, human);
|
||||||
|
this.repaintFromEvents();
|
||||||
|
this.busy = false;
|
||||||
|
this.endTurnBtn?.setAlpha(1);
|
||||||
|
|
||||||
|
const civ = this.state.civs[human];
|
||||||
|
if (!civ.alive) { this.onGameOver(); return; }
|
||||||
|
|
||||||
|
// Research prompt.
|
||||||
|
if (!civ.researching && Logic.availableTechs(this.rules, civ).length) {
|
||||||
|
this.openTech();
|
||||||
|
}
|
||||||
|
this.presentAIProposals();
|
||||||
|
this.announceEvents();
|
||||||
|
this.view.refresh();
|
||||||
|
this.refreshHud();
|
||||||
|
this.selectNextUnit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onEndTurn() {
|
||||||
|
if (this.busy || this.modalOpen || this.phase !== 'playing') return;
|
||||||
|
if (this.state.current !== this.state.humanIndex) return;
|
||||||
|
Logic.endCivTurn(this.rules, this.state, this.state.humanIndex);
|
||||||
|
this.saveGame();
|
||||||
|
this.runToHumanTurn();
|
||||||
|
}
|
||||||
|
|
||||||
|
runToHumanTurn() {
|
||||||
|
this.busy = true;
|
||||||
|
this.endTurnBtn?.setAlpha(0.4);
|
||||||
|
this.refreshUnitPanel();
|
||||||
|
const stepCiv = () => {
|
||||||
|
if (this.phase !== 'playing') return;
|
||||||
|
if (this.state.over) { this.onGameOver(); return; }
|
||||||
|
if (!this.state.civs[this.state.humanIndex].alive) { this.onGameOver(); return; }
|
||||||
|
const cur = this.state.current;
|
||||||
|
if (cur === this.state.humanIndex) {
|
||||||
|
this.startHumanTurn(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Logic.beginCivTurn(this.rules, this.state, cur);
|
||||||
|
runAITurn(this.rules, this.state, cur);
|
||||||
|
Logic.endCivTurn(this.rules, this.state, cur);
|
||||||
|
this.view.refresh();
|
||||||
|
this.refreshHud();
|
||||||
|
this.time.delayedCall(90, stepCiv);
|
||||||
|
};
|
||||||
|
stepCiv();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Events, proposals, toasts
|
||||||
|
|
||||||
|
// Terrain changes (work orders, founded cities' roads) arrive as engine
|
||||||
|
// events; repaint just those tiles instead of rebaking the world.
|
||||||
|
repaintFromEvents() {
|
||||||
|
for (const e of this.state.events) {
|
||||||
|
if (e.painted) continue;
|
||||||
|
e.painted = true;
|
||||||
|
if (e.type === 'workDone') {
|
||||||
|
this.view.repaintTileAndNeighbors(e.x, e.y);
|
||||||
|
} else if (e.type === 'cityFounded' || e.type === 'cityDestroyed') {
|
||||||
|
const city = Logic.cityById(this.state, e.cityId);
|
||||||
|
if (city) this.view.repaintTileAndNeighbors(city.x, city.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
announceEvents() {
|
||||||
|
const human = this.state.humanIndex;
|
||||||
|
for (const e of this.state.events) {
|
||||||
|
if (e.announced) continue;
|
||||||
|
e.announced = true;
|
||||||
|
if (e.type === 'war' && e.b === human) {
|
||||||
|
this.toast(`${this.state.civs[e.a].name} declares WAR on you!`);
|
||||||
|
} else if (e.type === 'techDone' && e.civ === human) {
|
||||||
|
this.toast(`Research complete: ${this.rules.techs[e.tech].name}`);
|
||||||
|
} else if (e.type === 'cityCaptured' && (e.from === human || e.to === human)) {
|
||||||
|
this.toast(e.to === human ? `You captured ${e.name}!` : `${e.name} has fallen!`);
|
||||||
|
} else if (e.type === 'civEliminated') {
|
||||||
|
this.toast(`${this.state.civs[e.civ].name} has been destroyed`);
|
||||||
|
} else if (e.type === 'spaceshipLaunched') {
|
||||||
|
this.toast(`${this.state.civs[e.civ].name} launched a spaceship!`);
|
||||||
|
} else if (e.type === 'contact' && (e.a === human || e.b === human)) {
|
||||||
|
const other = e.a === human ? e.b : e.a;
|
||||||
|
this.toast(`You have made contact with ${this.state.civs[other].name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
presentAIProposals() {
|
||||||
|
const human = this.state.humanIndex;
|
||||||
|
const proposals = this.state.events.filter((e) => e.type === 'aiProposal' && e.to === human);
|
||||||
|
this.state.events = this.state.events.filter((e) => !(e.type === 'aiProposal' && e.to === human));
|
||||||
|
const next = () => {
|
||||||
|
const p = proposals.shift();
|
||||||
|
if (!p) return;
|
||||||
|
if (!Logic.canPropose(this.state, p.from, human, p.kind)) { next(); return; }
|
||||||
|
const from = this.state.civs[p.from];
|
||||||
|
this.confirmDialog(
|
||||||
|
`${from.name} proposes a ${p.kind === 'ceasefire' ? 'cease-fire' : p.kind}. Accept?`,
|
||||||
|
() => { Logic.applyTreaty(this.state, p.from, human, p.kind); this.refreshHud(); next(); },
|
||||||
|
() => next(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
toastHut(hut) {
|
||||||
|
const msgs = {
|
||||||
|
gold: `You found ${hut.gold} gold in the hut!`,
|
||||||
|
tech: hut.tech ? `The tribe teaches you ${this.rules.techs[hut.tech].name}!` : 'A gift!',
|
||||||
|
unit: `A band of ${hut.unit} joins you!`,
|
||||||
|
ambushWon: 'Hostile tribe! Your unit fought them off.',
|
||||||
|
ambushLost: 'Hostile tribe! Your unit was lost!',
|
||||||
|
};
|
||||||
|
this.toast(msgs[hut.outcome] ?? 'An empty hut.');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast(msg) {
|
||||||
|
const y = 90 + this.toasts.length * 40;
|
||||||
|
const t = this.add.text(GAME_WIDTH / 2, y, msg, {
|
||||||
|
fontFamily: FONT, fontSize: '20px', color: COLORS.textHex,
|
||||||
|
backgroundColor: '#1e1a12ee', padding: { x: 16, y: 7 },
|
||||||
|
}).setOrigin(0.5).setDepth(D.toast);
|
||||||
|
this.toastRoot.add(t);
|
||||||
|
this.toasts.push(t);
|
||||||
|
this.tweens.add({
|
||||||
|
targets: t, alpha: 0, delay: 2600, duration: 500,
|
||||||
|
onComplete: () => {
|
||||||
|
this.toasts = this.toasts.filter((x) => x !== t);
|
||||||
|
t.destroy();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmWar(civIdx, onYes) {
|
||||||
|
const name = this.state.civs[civIdx].name;
|
||||||
|
this.confirmDialog(`Attack ${name}? This means WAR!`, onYes, () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDialog(message, onYes, onNo) {
|
||||||
|
this.modalOpen = true;
|
||||||
|
const root = this.add.container(0, 0).setDepth(D.modal);
|
||||||
|
const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
|
||||||
|
.setInteractive();
|
||||||
|
const panel = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, 640, 220, COLORS.panel)
|
||||||
|
.setStrokeStyle(2, COLORS.accent);
|
||||||
|
const txt = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 40, message, {
|
||||||
|
fontFamily: FONT, fontSize: '24px', color: COLORS.textHex,
|
||||||
|
wordWrap: { width: 580 }, align: 'center',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
const close = (fn) => () => { root.destroy(true); this.modalOpen = false; fn?.(); };
|
||||||
|
const yes = new Button(this, GAME_WIDTH / 2 - 110, GAME_HEIGHT / 2 + 50, 'YES', close(onYes),
|
||||||
|
{ width: 180, height: 52 });
|
||||||
|
const no = new Button(this, GAME_WIDTH / 2 + 110, GAME_HEIGHT / 2 + 50, 'NO', close(onNo),
|
||||||
|
{ width: 180, height: 52, variant: 'ghost' });
|
||||||
|
root.add([dim, panel, txt, yes, no]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Screens
|
||||||
|
|
||||||
|
openTech() {
|
||||||
|
if (this.modalOpen) return;
|
||||||
|
this.modalOpen = true;
|
||||||
|
openTechScreen(this, this.rules, this.state, () => {
|
||||||
|
this.modalOpen = false;
|
||||||
|
this.refreshHud();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openDiplomacy() {
|
||||||
|
if (this.modalOpen) return;
|
||||||
|
this.modalOpen = true;
|
||||||
|
openDiplomacyScreen(this, this.rules, this.state, this.opponentsData, respondToProposal, () => {
|
||||||
|
this.modalOpen = false;
|
||||||
|
this.view.refresh();
|
||||||
|
this.refreshHud();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openSpaceship() {
|
||||||
|
if (this.modalOpen) return;
|
||||||
|
this.modalOpen = true;
|
||||||
|
openSpaceshipScreen(this, this.rules, this.state, () => {
|
||||||
|
this.modalOpen = false;
|
||||||
|
this.refreshHud();
|
||||||
|
if (this.state.over) this.onGameOver();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openMenu() {
|
||||||
|
if (this.modalOpen) return;
|
||||||
|
this.modalOpen = true;
|
||||||
|
const root = this.add.container(0, 0).setDepth(D.modal);
|
||||||
|
const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
|
||||||
|
.setInteractive();
|
||||||
|
const panel = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, 460, 340, COLORS.panel)
|
||||||
|
.setStrokeStyle(2, COLORS.accent);
|
||||||
|
const title = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 120, 'MENU', {
|
||||||
|
fontFamily: 'Righteous', fontSize: '32px', color: COLORS.accentHex,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
const close = () => { root.destroy(true); this.modalOpen = false; };
|
||||||
|
const resume = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 - 50, 'RESUME', close,
|
||||||
|
{ width: 320, height: 56 });
|
||||||
|
const save = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 20, 'SAVE GAME', () => {
|
||||||
|
this.saveGame();
|
||||||
|
this.toast('Game saved');
|
||||||
|
close();
|
||||||
|
}, { width: 320, height: 56, variant: 'ghost' });
|
||||||
|
const quit = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT / 2 + 90, 'SAVE & QUIT', () => {
|
||||||
|
this.saveGame();
|
||||||
|
this.scene.start('GameMenu');
|
||||||
|
}, { width: 320, height: 56, variant: 'ghost' });
|
||||||
|
root.add([dim, panel, title, resume, save, quit]);
|
||||||
|
}
|
||||||
|
|
||||||
|
onGameOver() {
|
||||||
|
if (this.phase === 'over') return;
|
||||||
|
this.phase = 'over';
|
||||||
|
this.busy = false;
|
||||||
|
this.clearSave();
|
||||||
|
showVictoryOverlay(this, this.rules, this.state, this.opponentsData, () => {
|
||||||
|
this.scene.restart({ game: this.gameDef });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,667 @@
|
||||||
|
// Civilization — isometric map renderer.
|
||||||
|
//
|
||||||
|
// The engine works on a square grid; this view draws it as Civ II-style
|
||||||
|
// diamonds. Terrain (plus improvements/specials/huts) is baked into one
|
||||||
|
// RenderTexture and repainted per-tile when the world changes; fog is a second
|
||||||
|
// RenderTexture of black diamonds erased as the human explores. Units and
|
||||||
|
// cities are lightweight display objects rebuilt on refresh() — the game is
|
||||||
|
// turn-based, so refreshes happen on actions, not per frame.
|
||||||
|
//
|
||||||
|
// Art: uses the optional civilization-* sheets when loaded (see sprites.md),
|
||||||
|
// otherwise draws flat-shaded diamonds, glyphs and roundels procedurally.
|
||||||
|
|
||||||
|
import * as Phaser from 'phaser';
|
||||||
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||||
|
import {
|
||||||
|
IMP, tileIndex, inBounds, cityAt, unitsAt, civUnits, civCities,
|
||||||
|
computeVisible, isUnitVisibleTo, shieldGrassAt,
|
||||||
|
} from './CivilizationLogic.js';
|
||||||
|
|
||||||
|
export const TILE_W = 128;
|
||||||
|
export const TILE_H = 64;
|
||||||
|
export const FRAME_H = 96; // sprite frames carry 32px of headroom above the diamond
|
||||||
|
|
||||||
|
const ZOOMS = [0.5, 0.75, 1.0];
|
||||||
|
|
||||||
|
export class CivilizationMapView {
|
||||||
|
constructor(scene, rules, state, callbacks = {}) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.rules = rules;
|
||||||
|
this.state = state;
|
||||||
|
this.cb = callbacks;
|
||||||
|
this.humanIdx = state.humanIndex;
|
||||||
|
this.zoomIdx = 1;
|
||||||
|
this.selectedUnitId = null;
|
||||||
|
this.exploredDrawn = null;
|
||||||
|
|
||||||
|
const { world } = state;
|
||||||
|
this.originX = world.rows * (TILE_W / 2); // keeps iso x positive
|
||||||
|
this.worldW = (world.cols + world.rows) * (TILE_W / 2);
|
||||||
|
this.worldH = (world.cols + world.rows) * (TILE_H / 2) + FRAME_H;
|
||||||
|
|
||||||
|
this.root = scene.add.container(0, 0).setDepth(1);
|
||||||
|
// Chunked RenderTextures: a Large map is ~8200px wide — beyond one safe
|
||||||
|
// GPU texture — so terrain and fog are tiled into <=2048px chunks.
|
||||||
|
const CHUNK = 2048;
|
||||||
|
this.chunkSize = CHUNK;
|
||||||
|
this.terrainChunks = [];
|
||||||
|
this.fogChunks = [];
|
||||||
|
for (let oy = 0; oy < this.worldH; oy += CHUNK) {
|
||||||
|
for (let ox = 0; ox < this.worldW; ox += CHUNK) {
|
||||||
|
const w = Math.min(CHUNK, this.worldW - ox);
|
||||||
|
const h = Math.min(CHUNK, this.worldH - oy);
|
||||||
|
const trt = scene.add.renderTexture(ox, oy, w, h).setOrigin(0, 0);
|
||||||
|
const frt = scene.add.renderTexture(ox, oy, w, h).setOrigin(0, 0);
|
||||||
|
this.terrainChunks.push({ rt: trt, ox, oy, w, h });
|
||||||
|
this.fogChunks.push({ rt: frt, ox, oy, w, h });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.pathGfx = scene.add.graphics();
|
||||||
|
this.dynamic = scene.add.container(0, 0);
|
||||||
|
this.root.add([
|
||||||
|
...this.terrainChunks.map((c) => c.rt),
|
||||||
|
this.pathGfx, this.dynamic,
|
||||||
|
...this.fogChunks.map((c) => c.rt),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.stamp = scene.add.graphics().setVisible(false);
|
||||||
|
this.miniGfx = null;
|
||||||
|
|
||||||
|
this.bakeTerrain();
|
||||||
|
this.initFog();
|
||||||
|
this.setZoom(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run fn(rt, ox, oy) on every chunk overlapping the given world-space box.
|
||||||
|
forEachChunk(chunks, x0, y0, x1, y1, fn) {
|
||||||
|
for (const c of chunks) {
|
||||||
|
if (x1 < c.ox || x0 > c.ox + c.w || y1 < c.oy || y0 > c.oy + c.h) continue;
|
||||||
|
fn(c.rt, c.ox, c.oy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tile bounding box in world space (frame headroom included).
|
||||||
|
tileBounds(x, y) {
|
||||||
|
return [x - TILE_W / 2 - 4, y - (FRAME_H - TILE_H) - 4, x + TILE_W / 2 + 4, y + FRAME_H + 4];
|
||||||
|
}
|
||||||
|
|
||||||
|
drawStampAt(gfx, x, y) {
|
||||||
|
const [x0, y0, x1, y1] = this.tileBounds(x, y);
|
||||||
|
this.forEachChunk(this.terrainChunks, x0, y0, x1, y1,
|
||||||
|
(rt, ox, oy) => rt.draw(gfx, x - ox, y - oy));
|
||||||
|
}
|
||||||
|
|
||||||
|
drawFrameAt(key, frame, x, y) {
|
||||||
|
const [x0, y0, x1, y1] = this.tileBounds(x + 64, y);
|
||||||
|
this.forEachChunk(this.terrainChunks, x0, y0, x1, y1,
|
||||||
|
(rt, ox, oy) => rt.drawFrame(key, frame, x - ox, y - oy));
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.root.destroy(true);
|
||||||
|
this.stamp.destroy();
|
||||||
|
this.fogStamp?.destroy();
|
||||||
|
this.miniRoot?.destroy(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Coordinates
|
||||||
|
|
||||||
|
isoX(c, r) { return this.originX + (c - r) * (TILE_W / 2); }
|
||||||
|
isoY(c, r) { return (c + r) * (TILE_H / 2); }
|
||||||
|
|
||||||
|
// Screen -> tile (or null). Checks the two diamond candidates around the
|
||||||
|
// inverse transform.
|
||||||
|
screenToTile(px, py) {
|
||||||
|
const wx = (px - this.root.x) / this.root.scaleX;
|
||||||
|
const wy = (py - this.root.y) / this.root.scaleY;
|
||||||
|
const a = (wx - this.originX) / (TILE_W / 2);
|
||||||
|
const b = (wy - TILE_H / 2) / (TILE_H / 2); // diamond centre offset
|
||||||
|
const cf = (a + b) / 2;
|
||||||
|
const rf = (b - a) / 2;
|
||||||
|
let best = null;
|
||||||
|
let bestDist = Infinity;
|
||||||
|
for (const [c, r] of [
|
||||||
|
[Math.floor(cf), Math.floor(rf)], [Math.ceil(cf), Math.floor(rf)],
|
||||||
|
[Math.floor(cf), Math.ceil(rf)], [Math.ceil(cf), Math.ceil(rf)],
|
||||||
|
[Math.round(cf), Math.round(rf)],
|
||||||
|
]) {
|
||||||
|
if (!inBounds(this.state.world, c, r)) continue;
|
||||||
|
const cx = this.isoX(c, r);
|
||||||
|
const cy = this.isoY(c, r) + TILE_H / 2;
|
||||||
|
// Diamond containment via L1 distance in tile units.
|
||||||
|
const dx = Math.abs(wx - cx) / (TILE_W / 2);
|
||||||
|
const dy = Math.abs(wy - cy) / (TILE_H / 2);
|
||||||
|
if (dx + dy <= 1.02) {
|
||||||
|
const d = dx + dy;
|
||||||
|
if (d < bestDist) { bestDist = d; best = [c, r]; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
setZoom(idx) {
|
||||||
|
this.zoomIdx = Phaser.Math.Clamp(idx, 0, ZOOMS.length - 1);
|
||||||
|
this.root.setScale(ZOOMS[this.zoomIdx]);
|
||||||
|
this.clampPan();
|
||||||
|
}
|
||||||
|
zoomBy(delta) { this.setZoom(this.zoomIdx + delta); }
|
||||||
|
|
||||||
|
panBy(dx, dy) {
|
||||||
|
this.root.x += dx;
|
||||||
|
this.root.y += dy;
|
||||||
|
this.clampPan();
|
||||||
|
}
|
||||||
|
|
||||||
|
clampPan() {
|
||||||
|
const s = this.root.scaleX;
|
||||||
|
const minX = GAME_WIDTH - this.worldW * s - 100;
|
||||||
|
const minY = GAME_HEIGHT - this.worldH * s - 100;
|
||||||
|
this.root.x = Phaser.Math.Clamp(this.root.x, Math.min(100, minX), 100);
|
||||||
|
this.root.y = Phaser.Math.Clamp(this.root.y, Math.min(100, minY), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
centerOn(c, r) {
|
||||||
|
const s = this.root.scaleX;
|
||||||
|
this.root.x = GAME_WIDTH / 2 - this.isoX(c, r) * s;
|
||||||
|
this.root.y = GAME_HEIGHT / 2 - (this.isoY(c, r) + TILE_H / 2) * s;
|
||||||
|
this.clampPan();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Terrain baking
|
||||||
|
|
||||||
|
bakeTerrain() {
|
||||||
|
for (const c of this.terrainChunks) c.rt.clear();
|
||||||
|
const { world } = this.state;
|
||||||
|
// Paint back-to-front (row-major works: greater c+r paints later).
|
||||||
|
for (let sum = 0; sum <= world.cols + world.rows - 2; sum += 1) {
|
||||||
|
for (let c = Math.max(0, sum - world.rows + 1); c <= Math.min(sum, world.cols - 1); c += 1) {
|
||||||
|
const r = sum - c;
|
||||||
|
this.paintTile(c, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repaintTileAndNeighbors(c, r) {
|
||||||
|
for (let dy = -1; dy <= 1; dy += 1) {
|
||||||
|
for (let dx = -1; dx <= 1; dx += 1) {
|
||||||
|
if (inBounds(this.state.world, c + dx, r + dy)) this.paintTile(c + dx, r + dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
paintTile(c, r) {
|
||||||
|
const { world } = this.state;
|
||||||
|
const idx = tileIndex(world, c, r);
|
||||||
|
const terr = this.rules.terrainList[world.terrain[idx]];
|
||||||
|
const x = this.isoX(c, r);
|
||||||
|
const y = this.isoY(c, r);
|
||||||
|
const useSheet = this.scene.textures.exists('civilization-terrain');
|
||||||
|
if (useSheet) {
|
||||||
|
let frame = terr.frame;
|
||||||
|
if (terr.id === 'grassland' && shieldGrassAt(c, r) && world.special[idx] < 0) {
|
||||||
|
frame = this.rules.grasslandShieldFrame;
|
||||||
|
}
|
||||||
|
this.drawFrameAt('civilization-terrain', frame, x - TILE_W / 2, y + TILE_H - FRAME_H);
|
||||||
|
} else {
|
||||||
|
this.paintProceduralTile(c, r, terr, x, y);
|
||||||
|
}
|
||||||
|
this.paintImprovements(c, r, x, y);
|
||||||
|
this.paintSpecial(c, r, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
paintProceduralTile(c, r, terr, x, y) {
|
||||||
|
const g = this.stamp;
|
||||||
|
g.clear();
|
||||||
|
const base = Phaser.Display.Color.HexStringToColor(terr.color).color;
|
||||||
|
const cy = TILE_H / 2; // stamp-local diamond centre
|
||||||
|
g.fillStyle(base, 1);
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(0, cy - TILE_H / 2);
|
||||||
|
g.lineTo(TILE_W / 2, cy);
|
||||||
|
g.lineTo(0, cy + TILE_H / 2);
|
||||||
|
g.lineTo(-TILE_W / 2, cy);
|
||||||
|
g.closePath();
|
||||||
|
g.fillPath();
|
||||||
|
g.lineStyle(1, 0x000000, 0.18);
|
||||||
|
g.strokePath();
|
||||||
|
|
||||||
|
// Simple per-terrain glyphs.
|
||||||
|
const darker = Phaser.Display.Color.ValueToColor(base).darken(25).color;
|
||||||
|
const lighter = Phaser.Display.Color.ValueToColor(base).lighten(20).color;
|
||||||
|
if (terr.id === 'forest' || terr.id === 'jungle') {
|
||||||
|
g.fillStyle(darker, 1);
|
||||||
|
for (const [tx, ty] of [[-24, 0], [0, -8], [22, 2]]) {
|
||||||
|
g.fillTriangle(tx - 9, cy + ty + 8, tx + 9, cy + ty + 8, tx, cy + ty - 12);
|
||||||
|
}
|
||||||
|
} else if (terr.id === 'mountains') {
|
||||||
|
g.fillStyle(darker, 1);
|
||||||
|
g.fillTriangle(-28, cy + 12, 4, cy + 12, -12, cy - 22);
|
||||||
|
g.fillTriangle(-4, cy + 14, 30, cy + 14, 13, cy - 16);
|
||||||
|
g.fillStyle(0xffffff, 0.9);
|
||||||
|
g.fillTriangle(-16, cy - 14, -8, cy - 14, -12, cy - 22);
|
||||||
|
} else if (terr.id === 'hills') {
|
||||||
|
g.fillStyle(darker, 1);
|
||||||
|
g.fillEllipse(-16, cy + 4, 34, 16);
|
||||||
|
g.fillEllipse(14, cy + 8, 38, 18);
|
||||||
|
} else if (terr.id === 'ocean') {
|
||||||
|
g.lineStyle(2, lighter, 0.7);
|
||||||
|
for (const [tx, ty] of [[-24, -6], [8, 2], [-8, 10]]) {
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(tx, cy + ty);
|
||||||
|
g.lineTo(tx + 14, cy + ty);
|
||||||
|
g.strokePath();
|
||||||
|
}
|
||||||
|
} else if (terr.id === 'swamp') {
|
||||||
|
g.lineStyle(2, darker, 0.8);
|
||||||
|
for (const [tx, ty] of [[-20, 4], [4, -4], [18, 8]]) {
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(tx, cy + ty);
|
||||||
|
g.lineTo(tx, cy + ty - 8);
|
||||||
|
g.strokePath();
|
||||||
|
}
|
||||||
|
} else if (terr.id === 'desert') {
|
||||||
|
g.fillStyle(darker, 0.6);
|
||||||
|
g.fillEllipse(-14, cy + 4, 10, 4);
|
||||||
|
g.fillEllipse(12, cy - 4, 12, 4);
|
||||||
|
} else if (terr.id === 'grassland') {
|
||||||
|
const idx = tileIndex(this.state.world, c, r);
|
||||||
|
if (shieldGrassAt(c, r) && this.state.world.special[idx] < 0) {
|
||||||
|
g.fillStyle(lighter, 1);
|
||||||
|
g.fillCircle(18, cy - 6, 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.drawStampAt(g, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
paintImprovements(c, r, x, y) {
|
||||||
|
const { world } = this.state;
|
||||||
|
const idx = tileIndex(world, c, r);
|
||||||
|
const bits = world.improvements[idx];
|
||||||
|
const g = this.stamp;
|
||||||
|
const cy = TILE_H / 2;
|
||||||
|
const hasCity = !!cityAt(this.state, c, r);
|
||||||
|
|
||||||
|
// Roads/rails connect toward neighbours that also have them (or cities).
|
||||||
|
if (bits & (IMP.ROAD | IMP.RAILROAD)) {
|
||||||
|
g.clear();
|
||||||
|
const rail = !!(bits & IMP.RAILROAD);
|
||||||
|
let drewAny = false;
|
||||||
|
for (let dy = -1; dy <= 1; dy += 1) {
|
||||||
|
for (let dx = -1; dx <= 1; dx += 1) {
|
||||||
|
if (dx === 0 && dy === 0) continue;
|
||||||
|
const nc = c + dx;
|
||||||
|
const nr = r + dy;
|
||||||
|
if (!inBounds(world, nc, nr)) continue;
|
||||||
|
const nBits = world.improvements[tileIndex(world, nc, nr)];
|
||||||
|
if (!(nBits & (IMP.ROAD | IMP.RAILROAD))) continue;
|
||||||
|
const ex = (this.isoX(nc, nr) - this.isoX(c, r)) / 2;
|
||||||
|
const ey = (this.isoY(nc, nr) - this.isoY(c, r)) / 2;
|
||||||
|
g.lineStyle(rail ? 4 : 3, rail ? 0x4a4038 : 0x8a6f4d, 0.9);
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(0, cy);
|
||||||
|
g.lineTo(ex, cy + ey);
|
||||||
|
g.strokePath();
|
||||||
|
drewAny = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!drewAny && !hasCity) {
|
||||||
|
g.lineStyle(3, 0x8a6f4d, 0.9);
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(-14, cy);
|
||||||
|
g.lineTo(14, cy);
|
||||||
|
g.strokePath();
|
||||||
|
}
|
||||||
|
this.drawStampAt(g, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
const useSheet = this.scene.textures.exists('civilization-improvements');
|
||||||
|
const drawBadge = (frame, fallback) => {
|
||||||
|
if (useSheet) {
|
||||||
|
this.drawFrameAt('civilization-improvements', frame, x - 32, y);
|
||||||
|
} else {
|
||||||
|
fallback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if ((bits & IMP.IRRIGATION) && !hasCity) {
|
||||||
|
drawBadge(bits & IMP.FARMLAND ? 1 : 0, () => {
|
||||||
|
g.clear();
|
||||||
|
g.lineStyle(2, 0x2f8fbf, 0.8);
|
||||||
|
for (let i = -1; i <= 1; i += 1) {
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(-18, cy + i * 7);
|
||||||
|
g.lineTo(18, cy + i * 7);
|
||||||
|
g.strokePath();
|
||||||
|
}
|
||||||
|
this.drawStampAt(g, x, y);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (bits & IMP.MINE) {
|
||||||
|
drawBadge(2, () => {
|
||||||
|
g.clear();
|
||||||
|
g.fillStyle(0x3a3a3a, 1);
|
||||||
|
g.fillTriangle(-8, cy + 6, 8, cy + 6, 0, cy - 8);
|
||||||
|
g.fillStyle(0x111111, 1);
|
||||||
|
g.fillRect(-2, cy - 2, 4, 8);
|
||||||
|
this.drawStampAt(g, x, y);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (bits & IMP.FORTRESS) {
|
||||||
|
drawBadge(3, () => {
|
||||||
|
g.clear();
|
||||||
|
g.lineStyle(3, 0x9a8866, 1);
|
||||||
|
g.strokeRect(-20, cy - 12, 40, 24);
|
||||||
|
this.drawStampAt(g, x, y);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (world.huts[idx]) {
|
||||||
|
drawBadge(4, () => {
|
||||||
|
g.clear();
|
||||||
|
g.fillStyle(0x8a5a2a, 1);
|
||||||
|
g.fillRect(-8, cy - 4, 16, 10);
|
||||||
|
g.fillStyle(0xb08040, 1);
|
||||||
|
g.fillTriangle(-11, cy - 4, 11, cy - 4, 0, cy - 14);
|
||||||
|
this.drawStampAt(g, x, y);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
paintSpecial(c, r, x, y) {
|
||||||
|
const { world } = this.state;
|
||||||
|
const idx = tileIndex(world, c, r);
|
||||||
|
if (world.special[idx] < 0) return;
|
||||||
|
const spec = this.rules.specialList[world.special[idx]];
|
||||||
|
if (this.scene.textures.exists('civilization-resources')) {
|
||||||
|
this.drawFrameAt('civilization-resources', spec.frame, x - 32, y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const g = this.stamp;
|
||||||
|
const cy = TILE_H / 2;
|
||||||
|
g.clear();
|
||||||
|
g.fillStyle(0xffffff, 0.85);
|
||||||
|
g.fillCircle(0, cy, 9);
|
||||||
|
g.fillStyle(specialColor(spec.id), 1);
|
||||||
|
g.fillCircle(0, cy, 7);
|
||||||
|
this.drawStampAt(g, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Fog
|
||||||
|
|
||||||
|
initFog() {
|
||||||
|
const { world } = this.state;
|
||||||
|
for (const c of this.fogChunks) {
|
||||||
|
c.rt.clear();
|
||||||
|
c.rt.fill(0x05060a, 1);
|
||||||
|
}
|
||||||
|
// Erase diamonds that are already explored; remember what we've erased.
|
||||||
|
// The stamp is oversized by a pixel so adjacent erases leave no seams.
|
||||||
|
this.exploredDrawn = new Array(world.cols * world.rows).fill(0);
|
||||||
|
const g = this.scene.add.graphics().setVisible(false);
|
||||||
|
const cy = TILE_H / 2;
|
||||||
|
g.fillStyle(0xffffff, 1);
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(TILE_W / 2, cy - TILE_H / 2 - 2);
|
||||||
|
g.lineTo(TILE_W + 3, cy);
|
||||||
|
g.lineTo(TILE_W / 2, cy + TILE_H / 2 + 2);
|
||||||
|
g.lineTo(-3, cy);
|
||||||
|
g.closePath();
|
||||||
|
g.fillPath();
|
||||||
|
this.fogStamp = g;
|
||||||
|
this.updateFog();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFog() {
|
||||||
|
if (this.humanIdx < 0) {
|
||||||
|
for (const c of this.fogChunks) c.rt.setVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { world } = this.state;
|
||||||
|
const explored = this.state.explored[this.humanIdx];
|
||||||
|
for (let r = 0; r < world.rows; r += 1) {
|
||||||
|
for (let c = 0; c < world.cols; c += 1) {
|
||||||
|
const idx = tileIndex(world, c, r);
|
||||||
|
if (explored[idx] && !this.exploredDrawn[idx]) {
|
||||||
|
this.exploredDrawn[idx] = 1;
|
||||||
|
const x = this.isoX(c, r) - TILE_W / 2;
|
||||||
|
const y = this.isoY(c, r);
|
||||||
|
this.forEachChunk(this.fogChunks, x - 4, y - 4, x + TILE_W + 4, y + TILE_H + 4,
|
||||||
|
(rt, ox, oy) => rt.erase(this.fogStamp, x - ox, y - oy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Dynamic layer (units + cities)
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.updateFog();
|
||||||
|
this.dynamic.removeAll(true);
|
||||||
|
const { state, rules } = this;
|
||||||
|
const povIdx = this.humanIdx >= 0 ? this.humanIdx : state.current;
|
||||||
|
const visible = computeVisible(state, povIdx);
|
||||||
|
const explored = this.humanIdx >= 0 ? state.explored[povIdx] : null;
|
||||||
|
|
||||||
|
for (const city of state.cities) {
|
||||||
|
const idx = tileIndex(state.world, city.x, city.y);
|
||||||
|
if (explored && !explored[idx]) continue;
|
||||||
|
this.drawCity(city);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One marker per occupied tile (top defender), with a stack badge.
|
||||||
|
const byTile = new Map();
|
||||||
|
for (const u of state.units) {
|
||||||
|
if (u.carriedBy) continue;
|
||||||
|
const idx = tileIndex(state.world, u.x, u.y);
|
||||||
|
if (u.civ !== povIdx) {
|
||||||
|
if (!visible.has(idx)) continue;
|
||||||
|
if (!isUnitVisibleTo(rules, state, u, povIdx)) continue;
|
||||||
|
}
|
||||||
|
(byTile.get(idx) ?? byTile.set(idx, []).get(idx)).push(u);
|
||||||
|
}
|
||||||
|
for (const [idx, units] of byTile) {
|
||||||
|
const c = idx % state.world.cols;
|
||||||
|
const r = (idx / state.world.cols) | 0;
|
||||||
|
const selected = units.find((u) => u.id === this.selectedUnitId);
|
||||||
|
const top = selected ?? units[0];
|
||||||
|
this.drawUnit(top, units.length, c, r);
|
||||||
|
}
|
||||||
|
this.drawSelection();
|
||||||
|
this.refreshMinimap();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCity(city) {
|
||||||
|
const { scene } = this;
|
||||||
|
const x = this.isoX(city.x, city.y);
|
||||||
|
const y = this.isoY(city.x, city.y);
|
||||||
|
const civ = this.state.civs[city.civ];
|
||||||
|
const color = Phaser.Display.Color.HexStringToColor(civ.color).color;
|
||||||
|
const container = scene.add.container(x, y).setDepth(y);
|
||||||
|
const walled = !!city.buildings.citywalls;
|
||||||
|
|
||||||
|
const themeKey = 'civilization-cities-classic';
|
||||||
|
if (scene.textures.exists(themeKey)) {
|
||||||
|
const tier = city.size >= 13 ? 3 : city.size >= 8 ? 2 : city.size >= 4 ? 1 : 0;
|
||||||
|
const img = scene.add.image(0, TILE_H - FRAME_H + 48, themeKey, walled ? 4 + tier : tier);
|
||||||
|
container.add(img);
|
||||||
|
} else {
|
||||||
|
const g = scene.add.graphics();
|
||||||
|
const cy = TILE_H / 2;
|
||||||
|
const tier = city.size >= 13 ? 3 : city.size >= 8 ? 2 : city.size >= 4 ? 1 : 0;
|
||||||
|
g.fillStyle(0x6b6255, 1);
|
||||||
|
for (let i = 0; i <= tier; i += 1) {
|
||||||
|
const bw = 26 - i * 3;
|
||||||
|
const bh = 16 + i * 8;
|
||||||
|
const bx = -24 + i * 16;
|
||||||
|
g.fillRect(bx, cy - bh + 4, bw, bh);
|
||||||
|
g.fillStyle(0x7d7466, 1);
|
||||||
|
}
|
||||||
|
g.fillStyle(0x332f28, 1);
|
||||||
|
for (let i = 0; i <= tier; i += 1) g.fillRect(-18 + i * 16, cy - 6, 5, 6);
|
||||||
|
if (walled) {
|
||||||
|
g.lineStyle(3, 0x9a8866, 1);
|
||||||
|
g.strokeRect(-34, cy - 14, 68, 22);
|
||||||
|
}
|
||||||
|
container.add(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
const banner = scene.add.rectangle(0, TILE_H + 10, 0, 22, 0x000000, 0.65)
|
||||||
|
.setStrokeStyle(1, color, 1);
|
||||||
|
const label = scene.add.text(0, TILE_H + 10, `${city.size} ${city.name}`, {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '15px', color: civ.color,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
banner.width = label.width + 16;
|
||||||
|
container.add([banner, label]);
|
||||||
|
container.setDepth(y + 1);
|
||||||
|
banner.setInteractive({ useHandCursor: true });
|
||||||
|
banner.on('pointerdown', (pointer, lx, ly, event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.cb.onCityClick?.(city);
|
||||||
|
});
|
||||||
|
this.dynamic.add(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawUnit(unit, stackCount, c, r) {
|
||||||
|
const { scene, rules } = this;
|
||||||
|
const def = rules.units[unit.type];
|
||||||
|
const civ = this.state.civs[unit.civ];
|
||||||
|
const color = Phaser.Display.Color.HexStringToColor(civ.color).color;
|
||||||
|
const x = this.isoX(c, r);
|
||||||
|
const y = this.isoY(c, r) + TILE_H / 2;
|
||||||
|
const container = scene.add.container(x, y);
|
||||||
|
|
||||||
|
if (scene.textures.exists('civilization-units')) {
|
||||||
|
const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1);
|
||||||
|
const img = scene.add.image(0, -6, 'civilization-units', def.frame);
|
||||||
|
container.add([ring, img]);
|
||||||
|
} else {
|
||||||
|
const g = scene.add.graphics();
|
||||||
|
g.fillStyle(0x000000, 0.35);
|
||||||
|
g.fillEllipse(0, 12, 40, 14);
|
||||||
|
g.fillStyle(color, 1);
|
||||||
|
g.fillCircle(0, -2, 17);
|
||||||
|
g.lineStyle(2, 0xffffff, unit.fortified ? 1 : 0.5);
|
||||||
|
g.strokeCircle(0, -2, 17);
|
||||||
|
container.add(g);
|
||||||
|
const label = scene.add.text(0, -2, def.abbr, {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#ffffff', fontStyle: 'bold',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
container.add(label);
|
||||||
|
}
|
||||||
|
if (unit.vet) {
|
||||||
|
container.add(scene.add.circle(12, -14, 4, 0xd4a017).setStrokeStyle(1, 0x000000, 0.6));
|
||||||
|
}
|
||||||
|
if (stackCount > 1) {
|
||||||
|
const badge = scene.add.circle(-16, -14, 8, 0x000000, 0.8);
|
||||||
|
const num = scene.add.text(-16, -14, `${stackCount}`, {
|
||||||
|
fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#ffffff',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
container.add([badge, num]);
|
||||||
|
}
|
||||||
|
container.setDepth(y + 2);
|
||||||
|
this.dynamic.add(container);
|
||||||
|
if (unit.id === this.selectedUnitId) this.selectedContainer = container;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawSelection() {
|
||||||
|
if (!this.selectedUnitId) return;
|
||||||
|
const unit = this.state.units.find((u) => u.id === this.selectedUnitId);
|
||||||
|
if (!unit) { this.selectedUnitId = null; return; }
|
||||||
|
const x = this.isoX(unit.x, unit.y);
|
||||||
|
const y = this.isoY(unit.x, unit.y) + TILE_H / 2;
|
||||||
|
const ring = this.scene.add.circle(x, y - 2, 24).setStrokeStyle(3, 0xffffff, 1);
|
||||||
|
ring.setDepth(y + 3);
|
||||||
|
this.dynamic.add(ring);
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: ring, alpha: 0.25, duration: 420, yoyo: true, repeat: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showPath(path) {
|
||||||
|
this.pathGfx.clear();
|
||||||
|
if (!path || !path.length) return;
|
||||||
|
this.pathGfx.lineStyle(3, 0xffffff, 0.6);
|
||||||
|
for (const [c, r] of path) {
|
||||||
|
const x = this.isoX(c, r);
|
||||||
|
const y = this.isoY(c, r) + TILE_H / 2;
|
||||||
|
this.pathGfx.strokeCircle(x, y, 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Minimap
|
||||||
|
|
||||||
|
buildMinimap(x, y, width) {
|
||||||
|
const { world } = this.state;
|
||||||
|
const scale = width / (world.cols + world.rows);
|
||||||
|
this.miniScale = scale;
|
||||||
|
this.miniRoot = this.scene.add.container(x, y).setDepth(40);
|
||||||
|
const h = (world.cols + world.rows) * (scale / 2) + 8;
|
||||||
|
const bg = this.scene.add.rectangle(0, 0, width + 12, h + 12, COLORS.panel, 0.92)
|
||||||
|
.setOrigin(0, 0).setStrokeStyle(2, COLORS.accent);
|
||||||
|
this.miniGfx = this.scene.add.graphics();
|
||||||
|
this.miniGfx.setPosition(6, 6);
|
||||||
|
this.miniRoot.add([bg, this.miniGfx]);
|
||||||
|
this.miniW = width;
|
||||||
|
this.miniH = h;
|
||||||
|
bg.setInteractive({ useHandCursor: true });
|
||||||
|
bg.on('pointerdown', (pointer, lx, ly) => {
|
||||||
|
// Invert the mini iso transform.
|
||||||
|
const mx = lx - 6;
|
||||||
|
const my = ly - 6;
|
||||||
|
const a = (mx - world.rows * (scale / 2)) / (scale / 2);
|
||||||
|
const b = my / (scale / 4);
|
||||||
|
const c = Math.round((a + b / 2) / 2);
|
||||||
|
const r = Math.round((b / 2 - a) / 2);
|
||||||
|
if (inBounds(world, c, r)) {
|
||||||
|
this.centerOn(c, r);
|
||||||
|
this.cb.onMinimapJump?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.refreshMinimap();
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshMinimap() {
|
||||||
|
if (!this.miniGfx) return;
|
||||||
|
const { world } = this.state;
|
||||||
|
const scale = this.miniScale;
|
||||||
|
const explored = this.humanIdx >= 0 ? this.state.explored[this.humanIdx] : null;
|
||||||
|
const g = this.miniGfx;
|
||||||
|
g.clear();
|
||||||
|
for (let r = 0; r < world.rows; r += 1) {
|
||||||
|
for (let c = 0; c < world.cols; c += 1) {
|
||||||
|
const idx = tileIndex(world, c, r);
|
||||||
|
const px = (world.rows + c - r) * (scale / 2);
|
||||||
|
const py = (c + r) * (scale / 4);
|
||||||
|
if (explored && !explored[idx]) {
|
||||||
|
g.fillStyle(0x05060a, 1);
|
||||||
|
} else {
|
||||||
|
const terr = this.rules.terrainList[world.terrain[idx]];
|
||||||
|
g.fillStyle(Phaser.Display.Color.HexStringToColor(terr.color).color, 1);
|
||||||
|
}
|
||||||
|
g.fillRect(px, py, Math.max(1.5, scale / 2), Math.max(1.5, scale / 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const city of this.state.cities) {
|
||||||
|
const idx = tileIndex(world, city.x, city.y);
|
||||||
|
if (explored && !explored[idx]) continue;
|
||||||
|
const px = (world.rows + city.x - city.y) * (scale / 2);
|
||||||
|
const py = (city.x + city.y) * (scale / 4);
|
||||||
|
g.fillStyle(Phaser.Display.Color.HexStringToColor(this.state.civs[city.civ].color).color, 1);
|
||||||
|
g.fillRect(px - 1, py - 1, 4, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function specialColor(id) {
|
||||||
|
const map = {
|
||||||
|
buffalo: 0x8a5a2a, wheat: 0xe8c84a, pheasant: 0xc06030, silk: 0xe8e8f0,
|
||||||
|
coal: 0x30302e, wine: 0x7a2050, gold: 0xffd700, iron: 0x8a8a92,
|
||||||
|
oasis: 0x30a060, oil: 0x1a1a1a, game: 0x9a6a3a, furs: 0xb0885a,
|
||||||
|
ivory: 0xf0ead8, glacieroil: 0x1a1a1a, peat: 0x5a4a2a, spice: 0xd07030,
|
||||||
|
gems: 0x30c0c0, fruit: 0xe07040, fish: 0x60a0e0, whales: 0x4060a0,
|
||||||
|
};
|
||||||
|
return map[id] ?? 0xffffff;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
// Civilization — rules compiler. Pure data module, no Phaser imports, so it can
|
||||||
|
// run headless in Node (tools/verifyCivilization.js) and in the browser scene.
|
||||||
|
//
|
||||||
|
// compileRules(json) validates data/civilization-rules.json and returns an
|
||||||
|
// indexed, derived rule set the engine and UI both consume.
|
||||||
|
|
||||||
|
export function compileRules(json) {
|
||||||
|
const errors = [];
|
||||||
|
const need = (cond, msg) => { if (!cond) errors.push(msg); };
|
||||||
|
|
||||||
|
need(Array.isArray(json.techs) && json.techs.length > 0, 'techs missing');
|
||||||
|
need(Array.isArray(json.units) && json.units.length > 0, 'units missing');
|
||||||
|
need(Array.isArray(json.terrains) && json.terrains.length > 0, 'terrains missing');
|
||||||
|
need(Array.isArray(json.buildings) && json.buildings.length > 0, 'buildings missing');
|
||||||
|
need(Array.isArray(json.governments) && json.governments.length > 0, 'governments missing');
|
||||||
|
need(Array.isArray(json.difficulties) && json.difficulties.length > 0, 'difficulties missing');
|
||||||
|
if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`);
|
||||||
|
|
||||||
|
const byId = (list, label) => {
|
||||||
|
const map = {};
|
||||||
|
for (const item of list) {
|
||||||
|
need(typeof item.id === 'string' && item.id.length > 0, `${label} entry missing id`);
|
||||||
|
need(!map[item.id], `${label} duplicate id ${item.id}`);
|
||||||
|
map[item.id] = item;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
const techs = byId(json.techs, 'tech');
|
||||||
|
const units = byId(json.units, 'unit');
|
||||||
|
const terrains = byId(json.terrains, 'terrain');
|
||||||
|
const specials = byId(json.specials ?? [], 'special');
|
||||||
|
const buildings = byId(json.buildings, 'building');
|
||||||
|
const governments = byId(json.governments, 'government');
|
||||||
|
const difficulties = byId(json.difficulties, 'difficulty');
|
||||||
|
const improvements = byId(json.improvements ?? [], 'improvement');
|
||||||
|
const worldSizes = byId(json.worldSizes ?? [], 'worldSize');
|
||||||
|
|
||||||
|
// --- tech graph validation: prereqs resolve, <=2 each, acyclic, all reachable
|
||||||
|
for (const t of json.techs) {
|
||||||
|
need(Array.isArray(t.prereqs) && t.prereqs.length <= 2, `tech ${t.id} needs 0-2 prereqs`);
|
||||||
|
for (const p of t.prereqs) need(!!techs[p], `tech ${t.id} prereq ${p} unknown`);
|
||||||
|
need(['ancient', 'medieval', 'industrial', 'modern'].includes(t.era), `tech ${t.id} bad era`);
|
||||||
|
}
|
||||||
|
if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`);
|
||||||
|
|
||||||
|
// Topological rank: rank 0 = no prereqs; rank(t) = 1 + max(rank(prereqs)).
|
||||||
|
// Also proves acyclicity and reachability (unrankable => cycle or dangling).
|
||||||
|
const rank = {};
|
||||||
|
let assigned = 0;
|
||||||
|
let progress = true;
|
||||||
|
while (progress) {
|
||||||
|
progress = false;
|
||||||
|
for (const t of json.techs) {
|
||||||
|
if (rank[t.id] !== undefined) continue;
|
||||||
|
if (t.prereqs.every((p) => rank[p] !== undefined)) {
|
||||||
|
rank[t.id] = t.prereqs.length ? 1 + Math.max(...t.prereqs.map((p) => rank[p])) : 0;
|
||||||
|
assigned += 1;
|
||||||
|
progress = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
need(assigned === json.techs.length,
|
||||||
|
`tech graph has a cycle or unreachable techs (${json.techs.length - assigned} unranked)`);
|
||||||
|
|
||||||
|
// --- cross references
|
||||||
|
for (const u of json.units) {
|
||||||
|
if (u.prereq) need(!!techs[u.prereq], `unit ${u.id} prereq tech ${u.prereq} unknown`);
|
||||||
|
if (u.obsoletedBy) need(!!units[u.obsoletedBy], `unit ${u.id} obsoletedBy ${u.obsoletedBy} unknown`);
|
||||||
|
need(['land', 'sea', 'air', 'project'].includes(u.domain), `unit ${u.id} bad domain`);
|
||||||
|
}
|
||||||
|
for (const b of json.buildings) {
|
||||||
|
if (b.prereq) need(!!techs[b.prereq], `building ${b.id} prereq tech ${b.prereq} unknown`);
|
||||||
|
if (b.requires) need(!!buildings[b.requires], `building ${b.id} requires ${b.requires} unknown`);
|
||||||
|
}
|
||||||
|
for (const g of json.governments) {
|
||||||
|
if (g.prereq) need(!!techs[g.prereq], `government ${g.id} prereq tech ${g.prereq} unknown`);
|
||||||
|
}
|
||||||
|
for (const s of json.specials ?? []) {
|
||||||
|
need(!!terrains[s.terrain], `special ${s.id} terrain ${s.terrain} unknown`);
|
||||||
|
}
|
||||||
|
for (const t of json.terrains) {
|
||||||
|
if (t.transform) need(!!terrains[t.transform], `terrain ${t.id} transform ${t.transform} unknown`);
|
||||||
|
}
|
||||||
|
for (const imp of json.improvements ?? []) {
|
||||||
|
if (imp.prereq) need(!!techs[imp.prereq], `improvement ${imp.id} prereq tech ${imp.prereq} unknown`);
|
||||||
|
if (imp.requires) need(!!improvements[imp.requires], `improvement ${imp.id} requires ${imp.requires} unknown`);
|
||||||
|
}
|
||||||
|
if (errors.length) throw new Error(`civilization-rules invalid: ${errors.join('; ')}`);
|
||||||
|
|
||||||
|
// --- derived: what each tech unlocks (for UI hovers and the "every tech
|
||||||
|
// matters" verify check)
|
||||||
|
const gates = {};
|
||||||
|
for (const id of Object.keys(techs)) gates[id] = { units: [], buildings: [], governments: [], improvements: [], prereqOf: [] };
|
||||||
|
for (const u of json.units) if (u.prereq) gates[u.prereq].units.push(u.id);
|
||||||
|
for (const b of json.buildings) if (b.prereq) gates[b.prereq].buildings.push(b.id);
|
||||||
|
for (const g of json.governments) if (g.prereq) gates[g.prereq].governments.push(g.id);
|
||||||
|
for (const imp of json.improvements ?? []) if (imp.prereq) gates[imp.prereq].improvements.push(imp.id);
|
||||||
|
for (const t of json.techs) for (const p of t.prereqs) gates[p].prereqOf.push(t.id);
|
||||||
|
|
||||||
|
// Specials grouped by terrain for worldgen.
|
||||||
|
const specialsByTerrain = {};
|
||||||
|
for (const s of json.specials ?? []) {
|
||||||
|
(specialsByTerrain[s.terrain] ??= []).push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: json.version ?? 1,
|
||||||
|
raw: json,
|
||||||
|
techs, units, terrains, specials, buildings, governments, difficulties,
|
||||||
|
improvements, worldSizes,
|
||||||
|
techList: json.techs,
|
||||||
|
unitList: json.units,
|
||||||
|
terrainList: json.terrains,
|
||||||
|
specialList: json.specials ?? [],
|
||||||
|
buildingList: json.buildings,
|
||||||
|
governmentList: json.governments,
|
||||||
|
difficultyList: json.difficulties,
|
||||||
|
improvementList: json.improvements ?? [],
|
||||||
|
worldSizeList: json.worldSizes ?? [],
|
||||||
|
techRank: rank,
|
||||||
|
techGates: gates,
|
||||||
|
specialsByTerrain,
|
||||||
|
grasslandShieldFrame: json.grasslandShieldFrame ?? 1,
|
||||||
|
spaceship: json.spaceship,
|
||||||
|
playerColors: json.playerColors,
|
||||||
|
cityNames: json.cityNames,
|
||||||
|
yearCurve: json.yearCurve,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beaker cost of the (n+1)-th tech when n techs are already known.
|
||||||
|
export function techCost(nKnown, researchFactor = 1) {
|
||||||
|
return Math.round((10 + 10 * (nKnown + 1)) * researchFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Civ II-style turn -> calendar year.
|
||||||
|
export function turnToYear(turn, yearCurve) {
|
||||||
|
let year = -4000;
|
||||||
|
for (let i = 0; i < turn; i += 1) {
|
||||||
|
let step = 1;
|
||||||
|
for (const seg of yearCurve) {
|
||||||
|
if (year < seg.until) { step = seg.step; break; }
|
||||||
|
}
|
||||||
|
year += step;
|
||||||
|
if (year === 0) year = 1; // no year zero
|
||||||
|
}
|
||||||
|
return year;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatYear(year) {
|
||||||
|
return year < 0 ? `${-year} BC` : `${year} AD`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,412 @@
|
||||||
|
// Civilization — secondary screens: tech tree, diplomacy (with the standard
|
||||||
|
// opponents' mood videos via Portrait.js), spaceship status, victory overlay.
|
||||||
|
|
||||||
|
import * as Phaser from 'phaser';
|
||||||
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||||
|
import { Button } from '../../ui/Button.js';
|
||||||
|
import { createOpponentPortrait } from '../../ui/Portrait.js';
|
||||||
|
import * as Logic from './CivilizationLogic.js';
|
||||||
|
|
||||||
|
const FONT = '"Julius Sans One"';
|
||||||
|
const ERAS = ['ancient', 'medieval', 'industrial', 'modern'];
|
||||||
|
const ERA_LABEL = { ancient: 'ANCIENT', medieval: 'MEDIEVAL', industrial: 'INDUSTRIAL', modern: 'MODERN' };
|
||||||
|
|
||||||
|
function modalShell(scene, title, onClose, { width = 1700, height = 940 } = {}) {
|
||||||
|
const root = scene.add.container(0, 0).setDepth(65);
|
||||||
|
const dim = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65)
|
||||||
|
.setInteractive();
|
||||||
|
const panel = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, width, height, COLORS.panel)
|
||||||
|
.setStrokeStyle(3, COLORS.accent);
|
||||||
|
const titleTxt = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - height / 2 + 34, title, {
|
||||||
|
fontFamily: 'Righteous', fontSize: '32px', color: COLORS.accentHex,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
root.add([dim, panel, titleTxt]);
|
||||||
|
const close = () => { root.destroy(true); onClose(); };
|
||||||
|
const closeBtn = new Button(scene, GAME_WIDTH / 2 + width / 2 - 70, GAME_HEIGHT / 2 - height / 2 + 40,
|
||||||
|
'✕', close, { width: 60, height: 44, fontSize: 22, variant: 'ghost' });
|
||||||
|
root.add(closeBtn);
|
||||||
|
return { root, close };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tech tree
|
||||||
|
|
||||||
|
export function openTechScreen(scene, rules, state, onClose) {
|
||||||
|
const civ = state.civs[state.humanIndex];
|
||||||
|
const { root, close } = modalShell(scene, 'RESEARCH', onClose);
|
||||||
|
const width = 1700;
|
||||||
|
const height = 940;
|
||||||
|
const left = GAME_WIDTH / 2 - width / 2 + 30;
|
||||||
|
const top = GAME_HEIGHT / 2 - height / 2 + 70;
|
||||||
|
|
||||||
|
const cost = Logic.currentResearchCost(rules, state, civ);
|
||||||
|
const current = civ.researching
|
||||||
|
? `Researching: ${rules.techs[civ.researching].name} (${civ.beakers}/${cost} beakers)`
|
||||||
|
: 'Pick a technology to research';
|
||||||
|
root.add(scene.add.text(GAME_WIDTH / 2, top - 6, current, {
|
||||||
|
fontFamily: FONT, fontSize: '21px', color: COLORS.goldHex,
|
||||||
|
}).setOrigin(0.5));
|
||||||
|
|
||||||
|
// Scrollable grid: 4 era columns, techs sorted by rank inside each era.
|
||||||
|
const scrollArea = scene.add.container(0, 0);
|
||||||
|
root.add(scrollArea);
|
||||||
|
const maskShape = scene.make.graphics({ add: false });
|
||||||
|
maskShape.fillStyle(0xffffff);
|
||||||
|
maskShape.fillRect(left, top + 20, width - 60, height - 130);
|
||||||
|
scrollArea.setMask(maskShape.createGeometryMask());
|
||||||
|
|
||||||
|
const available = new Set(Logic.availableTechs(rules, civ).map((t) => t.id));
|
||||||
|
const colW = (width - 60) / 4;
|
||||||
|
const rowH = 42;
|
||||||
|
let maxRows = 0;
|
||||||
|
ERAS.forEach((era, col) => {
|
||||||
|
const list = rules.techList.filter((t) => t.era === era)
|
||||||
|
.sort((a, b) => rules.techRank[a.id] - rules.techRank[b.id]);
|
||||||
|
maxRows = Math.max(maxRows, list.length);
|
||||||
|
const cx = left + col * colW;
|
||||||
|
scrollArea.add(scene.add.text(cx + colW / 2, top + 34, ERA_LABEL[era], {
|
||||||
|
fontFamily: FONT, fontSize: '19px', color: COLORS.mutedHex,
|
||||||
|
}).setOrigin(0.5));
|
||||||
|
list.forEach((t, row) => {
|
||||||
|
const y = top + 70 + row * rowH;
|
||||||
|
const known = !!civ.known[t.id] || (t.repeatable && civ.futureCount > 0);
|
||||||
|
const canPick = available.has(t.id);
|
||||||
|
const isCurrent = civ.researching === t.id;
|
||||||
|
const bg = known ? 0x24401f : canPick ? 0x3a3222 : 0x181510;
|
||||||
|
const stroke = isCurrent ? COLORS.gold : known ? 0x4a9e44 : canPick ? COLORS.accent : COLORS.muted;
|
||||||
|
const rect = scene.add.rectangle(cx + colW / 2, y, colW - 18, rowH - 8, bg)
|
||||||
|
.setStrokeStyle(isCurrent ? 3 : 1, stroke, canPick || known || isCurrent ? 1 : 0.4);
|
||||||
|
const name = t.repeatable && civ.futureCount > 0 ? `${t.name} (${civ.futureCount})` : t.name;
|
||||||
|
const txt = scene.add.text(cx + colW / 2 - (colW - 18) / 2 + 10, y, name, {
|
||||||
|
fontFamily: FONT, fontSize: '16px',
|
||||||
|
color: known ? '#9fdf9a' : canPick ? COLORS.textHex : COLORS.mutedHex,
|
||||||
|
}).setOrigin(0, 0.5);
|
||||||
|
scrollArea.add(rect);
|
||||||
|
scrollArea.add(txt);
|
||||||
|
// Gate summary on hover.
|
||||||
|
const g = rules.techGates[t.id];
|
||||||
|
const unlocks = [
|
||||||
|
...g.units.map((u) => rules.units[u].name),
|
||||||
|
...g.buildings.map((b) => rules.buildings[b].name),
|
||||||
|
...g.governments.map((gv) => rules.governments[gv].name),
|
||||||
|
...g.improvements.map((im) => rules.improvements[im].name),
|
||||||
|
];
|
||||||
|
rect.setInteractive({ useHandCursor: canPick });
|
||||||
|
rect.on('pointerover', () => {
|
||||||
|
hoverText.setText(`${t.name}${t.prereqs.length ? ` ⟵ ${t.prereqs.map((p) => rules.techs[p].name).join(' + ')}` : ''}`
|
||||||
|
+ (unlocks.length ? `\nUnlocks: ${unlocks.join(', ')}` : ''));
|
||||||
|
});
|
||||||
|
rect.on('pointerout', () => hoverText.setText(''));
|
||||||
|
if (canPick) {
|
||||||
|
rect.on('pointerdown', () => {
|
||||||
|
Logic.setResearch(rules, state, civ, t.id);
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hoverText = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 + height / 2 - 46, '', {
|
||||||
|
fontFamily: FONT, fontSize: '17px', color: COLORS.goldHex, align: 'center',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
root.add(hoverText);
|
||||||
|
|
||||||
|
// Wheel scroll for tall columns.
|
||||||
|
const contentH = 70 + maxRows * rowH;
|
||||||
|
const viewH = height - 130;
|
||||||
|
let scrollY = 0;
|
||||||
|
scene.input.on('wheel', onWheel);
|
||||||
|
function onWheel(pointer, objs, dx, dy) {
|
||||||
|
scrollY = Phaser.Math.Clamp(scrollY + dy * 0.5, 0, Math.max(0, contentH - viewH));
|
||||||
|
scrollArea.y = -scrollY;
|
||||||
|
}
|
||||||
|
root.once('destroy', () => scene.input.off('wheel', onWheel));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Diplomacy
|
||||||
|
|
||||||
|
export function openDiplomacyScreen(scene, rules, state, opponentsData, respondToProposal, onClose) {
|
||||||
|
const human = state.humanIndex;
|
||||||
|
const civ = state.civs[human];
|
||||||
|
let portrait = null;
|
||||||
|
const { root, close } = modalShell(scene, 'DIPLOMACY', () => {
|
||||||
|
portrait?.destroy();
|
||||||
|
onClose();
|
||||||
|
});
|
||||||
|
const width = 1700;
|
||||||
|
const height = 940;
|
||||||
|
const left = GAME_WIDTH / 2 - width / 2 + 40;
|
||||||
|
const top = GAME_HEIGHT / 2 - height / 2 + 80;
|
||||||
|
|
||||||
|
const contacts = state.civs.filter((c) => c.id !== human && c.alive
|
||||||
|
&& civ.relations[c.id] !== 'nocontact');
|
||||||
|
|
||||||
|
if (!contacts.length) {
|
||||||
|
root.add(scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2,
|
||||||
|
'You have not met any other civilizations yet.\nSend units out to explore the world.', {
|
||||||
|
fontFamily: FONT, fontSize: '26px', color: COLORS.mutedHex, align: 'center',
|
||||||
|
}).setOrigin(0.5));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let detail = scene.add.container(0, 0);
|
||||||
|
root.add(detail);
|
||||||
|
let selected = contacts[0];
|
||||||
|
|
||||||
|
// Left rail: contacted civs.
|
||||||
|
contacts.forEach((other, i) => {
|
||||||
|
const y = top + 30 + i * 74;
|
||||||
|
const rel = civ.relations[other.id];
|
||||||
|
const rect = scene.add.rectangle(left + 190, y, 380, 64, 0x181510)
|
||||||
|
.setStrokeStyle(2, other === selected ? COLORS.gold : COLORS.muted, 0.9);
|
||||||
|
const mood = Logic.attitudeMood(civ.attitude[other.id] ?? 0);
|
||||||
|
const moodDot = { upset: 0xe06c75, idle: 0xc8a84b, happy: 0x4a9e44 }[mood];
|
||||||
|
const dot = scene.add.circle(left + 30, y, 9, moodDot);
|
||||||
|
const txt = scene.add.text(left + 52, y, `${other.name}\n${relLabel(rel)}`, {
|
||||||
|
fontFamily: FONT, fontSize: '17px', color: COLORS.textHex, lineSpacing: 3,
|
||||||
|
}).setOrigin(0, 0.5);
|
||||||
|
rect.setInteractive({ useHandCursor: true });
|
||||||
|
rect.on('pointerdown', () => { selected = other; drawDetail(); });
|
||||||
|
root.add([rect, dot, txt]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function relLabel(rel) {
|
||||||
|
return { contact: 'No treaty', war: 'AT WAR', ceasefire: 'Cease-fire', peace: 'Peace treaty', alliance: 'Alliance' }[rel] ?? rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawDetail() {
|
||||||
|
portrait?.destroy();
|
||||||
|
portrait = null;
|
||||||
|
detail.destroy(true);
|
||||||
|
detail = scene.add.container(0, 0);
|
||||||
|
root.add(detail);
|
||||||
|
const other = selected;
|
||||||
|
const rel = civ.relations[other.id];
|
||||||
|
const attitude = civ.attitude[other.id] ?? 0;
|
||||||
|
const mood = Logic.attitudeMood(attitude);
|
||||||
|
const cx = left + 700;
|
||||||
|
|
||||||
|
// Video portrait with the character's current mood.
|
||||||
|
const opData = opponentsData.find((o) => o.id === other.leaderId)
|
||||||
|
?? { id: other.leaderId, name: other.name, spriteIndex: 0 };
|
||||||
|
try {
|
||||||
|
portrait = createOpponentPortrait(scene, opData, cx, top + 170, 130, 66, { playIntro: false });
|
||||||
|
if (mood !== 'idle') portrait.playEmotion(mood);
|
||||||
|
} catch (_) { /* portrait optional */ }
|
||||||
|
|
||||||
|
const moodWord = { upset: 'is furious with you', idle: 'is indifferent', happy: 'is friendly' }[mood];
|
||||||
|
detail.add(scene.add.text(cx, top + 330,
|
||||||
|
`${other.name} of the ${other.name}ites ${moodWord}.\nStatus: ${relLabel(rel)}`, {
|
||||||
|
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex, align: 'center', lineSpacing: 6,
|
||||||
|
}).setOrigin(0.5, 0));
|
||||||
|
|
||||||
|
// Their known-tech / power intel.
|
||||||
|
detail.add(scene.add.text(cx, top + 410,
|
||||||
|
`Technologies: ${Logic.knownCount(other)} · Cities: ${Logic.civCities(state, other.id).length}`, {
|
||||||
|
fontFamily: FONT, fontSize: '18px', color: COLORS.mutedHex,
|
||||||
|
}).setOrigin(0.5, 0));
|
||||||
|
|
||||||
|
// Action buttons.
|
||||||
|
const actions = [];
|
||||||
|
if (rel === 'war') actions.push(['PROPOSE CEASE-FIRE', () => propose('ceasefire')]);
|
||||||
|
if (rel === 'ceasefire') actions.push(['PROPOSE PEACE', () => propose('peace')]);
|
||||||
|
if (rel === 'contact') actions.push(['PROPOSE PEACE', () => propose('peace')]);
|
||||||
|
if (rel === 'peace') actions.push(['PROPOSE ALLIANCE', () => propose('alliance')]);
|
||||||
|
if (rel !== 'war') actions.push(['DECLARE WAR', () => {
|
||||||
|
Logic.declareWar(rules, state, human, other.id);
|
||||||
|
drawDetail();
|
||||||
|
}]);
|
||||||
|
actions.push(['GIFT 50 GOLD', () => {
|
||||||
|
if (Logic.giftGold(state, human, other.id, 50)) drawDetail();
|
||||||
|
}]);
|
||||||
|
actions.push(['EXCHANGE TECH', () => openExchange(other)]);
|
||||||
|
actions.push(['GIFT TECH', () => openGift(other)]);
|
||||||
|
|
||||||
|
actions.forEach(([label, fn], i) => {
|
||||||
|
const bx = cx - 340 + (i % 2) * 360;
|
||||||
|
const by = top + 480 + Math.floor(i / 2) * 66;
|
||||||
|
detail.add(new Button(scene, bx + 170, by, label, fn, { width: 330, height: 52, fontSize: 18 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
function propose(kind) {
|
||||||
|
if (!Logic.canPropose(state, human, other.id, kind)) return;
|
||||||
|
const accepted = respondToProposal(rules, state, other.id, human, kind);
|
||||||
|
if (accepted) {
|
||||||
|
Logic.applyTreaty(state, human, other.id, kind);
|
||||||
|
portrait?.playEmotion('happy');
|
||||||
|
} else {
|
||||||
|
portrait?.playEmotion('upset');
|
||||||
|
Logic.bumpAttitude(state, other.id, human, -3);
|
||||||
|
}
|
||||||
|
scene.time.delayedCall(900, drawDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGift(target) {
|
||||||
|
const mine = Object.keys(civ.known).filter((t) => !target.known[t]);
|
||||||
|
pickTech(scene, rules, detail, cx, top + 120, 'GIFT A TECHNOLOGY', mine, (techId) => {
|
||||||
|
Logic.giftTech(rules, state, human, target.id, techId);
|
||||||
|
portrait?.playEmotion('happy');
|
||||||
|
drawDetail();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExchange(target) {
|
||||||
|
const mine = Object.keys(civ.known).filter((t) => !target.known[t]);
|
||||||
|
const theirs = Object.keys(target.known).filter((t) => !civ.known[t]);
|
||||||
|
if (!mine.length || !theirs.length) return;
|
||||||
|
pickTech(scene, rules, detail, cx, top + 120, 'OFFER WHICH TECH?', mine, (giveId) => {
|
||||||
|
pickTech(scene, rules, detail, cx, top + 120, 'ASK FOR WHICH TECH?', theirs, (getId) => {
|
||||||
|
const ok = respondToProposal(rules, state, target.id, human, 'exchange', { giveId, getId });
|
||||||
|
if (ok) {
|
||||||
|
Logic.exchangeTechs(rules, state, human, target.id, giveId, getId);
|
||||||
|
portrait?.playEmotion('happy');
|
||||||
|
} else {
|
||||||
|
portrait?.playEmotion('upset');
|
||||||
|
}
|
||||||
|
scene.time.delayedCall(900, drawDetail);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small chooser list overlaid on the diplomacy panel.
|
||||||
|
function pickTech(scene, rules, parent, cx, cy, title, techIds, onPick) {
|
||||||
|
const box = scene.add.container(0, 0);
|
||||||
|
parent.add(box);
|
||||||
|
const h = Math.min(560, 80 + techIds.length * 34);
|
||||||
|
box.add(scene.add.rectangle(cx + 480, cy + h / 2, 360, h, 0x11100c, 0.98)
|
||||||
|
.setStrokeStyle(2, COLORS.accent));
|
||||||
|
box.add(scene.add.text(cx + 480, cy + 24, title, {
|
||||||
|
fontFamily: FONT, fontSize: '18px', color: COLORS.goldHex,
|
||||||
|
}).setOrigin(0.5));
|
||||||
|
techIds.slice(0, 14).forEach((id, i) => {
|
||||||
|
const t = scene.add.text(cx + 480, cy + 58 + i * 34, rules.techs[id].name, {
|
||||||
|
fontFamily: FONT, fontSize: '17px', color: COLORS.textHex,
|
||||||
|
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
|
||||||
|
t.on('pointerover', () => t.setColor(COLORS.goldHex));
|
||||||
|
t.on('pointerout', () => t.setColor(COLORS.textHex));
|
||||||
|
t.on('pointerdown', () => { box.destroy(true); onPick(id); });
|
||||||
|
box.add(t);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Spaceship
|
||||||
|
|
||||||
|
export function openSpaceshipScreen(scene, rules, state, onClose) {
|
||||||
|
const { root, close } = modalShell(scene, 'SPACESHIP TO ALPHA CENTAURI', onClose,
|
||||||
|
{ width: 1100, height: 760 });
|
||||||
|
const human = state.humanIndex;
|
||||||
|
const civ = state.civs[human];
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
const top = GAME_HEIGHT / 2 - 380 + 80;
|
||||||
|
const need = rules.spaceship;
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
['Structural', civ.spaceship.structural, need.structuralNeeded, 'ssstructural'],
|
||||||
|
['Components', civ.spaceship.component, need.componentsNeeded, 'sscomponent'],
|
||||||
|
['Modules', civ.spaceship.module, need.modulesNeeded, 'ssmodule'],
|
||||||
|
];
|
||||||
|
rows.forEach(([label, have, needed, unitId], i) => {
|
||||||
|
const y = top + 60 + i * 90;
|
||||||
|
const unit = rules.units[unitId];
|
||||||
|
const gated = unit.prereq && !civ.known[unit.prereq];
|
||||||
|
root.add(scene.add.text(cx - 380, y, label, {
|
||||||
|
fontFamily: FONT, fontSize: '26px', color: COLORS.textHex,
|
||||||
|
}).setOrigin(0, 0.5));
|
||||||
|
for (let s = 0; s < needed; s += 1) {
|
||||||
|
const filled = s < have;
|
||||||
|
root.add(scene.add.rectangle(cx - 120 + s * 56, y, 44, 44,
|
||||||
|
filled ? 0x4a9e44 : 0x181510).setStrokeStyle(2, filled ? 0x9fdf9a : COLORS.muted));
|
||||||
|
}
|
||||||
|
root.add(scene.add.text(cx + 400, y,
|
||||||
|
gated ? `needs ${rules.techs[unit.prereq].name}` : `${have}/${needed}`, {
|
||||||
|
fontFamily: FONT, fontSize: '20px', color: gated ? COLORS.mutedHex : COLORS.goldHex,
|
||||||
|
}).setOrigin(1, 0.5));
|
||||||
|
});
|
||||||
|
|
||||||
|
const shipY = top + 360;
|
||||||
|
if (civ.spaceship.launched) {
|
||||||
|
root.add(scene.add.text(cx, shipY, `Spaceship en route!\nArrival: ${civ.spaceship.arrivalTurn - state.turn} turns`, {
|
||||||
|
fontFamily: FONT, fontSize: '28px', color: '#9fdf9a', align: 'center', lineSpacing: 8,
|
||||||
|
}).setOrigin(0.5));
|
||||||
|
root.add(scene.add.text(cx, shipY + 100, 'Guard your capital — losing it destroys the ship.', {
|
||||||
|
fontFamily: FONT, fontSize: '19px', color: COLORS.dangerHex,
|
||||||
|
}).setOrigin(0.5));
|
||||||
|
} else {
|
||||||
|
const ready = civ.spaceship.structural >= need.structuralNeeded
|
||||||
|
&& civ.spaceship.component >= need.componentsNeeded
|
||||||
|
&& civ.spaceship.module >= need.modulesNeeded;
|
||||||
|
const launch = new Button(scene, cx, shipY, ready ? 'LAUNCH!' : 'LAUNCH (parts missing)', () => {
|
||||||
|
if (Logic.launchSpaceship(rules, state, civ)) close();
|
||||||
|
}, { width: 420, height: 64, fontSize: 26, variant: ready ? 'solid' : 'ghost' });
|
||||||
|
root.add(launch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rival intel.
|
||||||
|
const rivals = state.civs.filter((c) => c.alive && c.id !== human
|
||||||
|
&& state.civs[human].relations[c.id] !== 'nocontact');
|
||||||
|
const intel = rivals.map((c) => {
|
||||||
|
const s = c.spaceship;
|
||||||
|
if (s.launched) return `${c.name}: LAUNCHED (arrives turn ${s.arrivalTurn})`;
|
||||||
|
const parts = s.structural + s.component + s.module;
|
||||||
|
return `${c.name}: ${parts ? `${parts} parts built` : 'no spaceship'}`;
|
||||||
|
});
|
||||||
|
root.add(scene.add.text(cx, shipY + 180, intel.join('\n') || 'No rival intelligence.', {
|
||||||
|
fontFamily: FONT, fontSize: '18px', color: COLORS.mutedHex, align: 'center', lineSpacing: 6,
|
||||||
|
}).setOrigin(0.5, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Victory / defeat
|
||||||
|
|
||||||
|
export function showVictoryOverlay(scene, rules, state, opponentsData, onPlayAgain) {
|
||||||
|
const root = scene.add.container(0, 0).setDepth(90);
|
||||||
|
root.add(scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.8)
|
||||||
|
.setInteractive());
|
||||||
|
const human = state.humanIndex;
|
||||||
|
const winnerIdx = state.over?.winner ?? -1;
|
||||||
|
const winner = winnerIdx >= 0 ? state.civs[winnerIdx] : null;
|
||||||
|
const humanWon = winnerIdx === human;
|
||||||
|
const mode = state.over?.type;
|
||||||
|
|
||||||
|
const title = humanWon
|
||||||
|
? (mode === 'spaceship' ? 'YOUR SHIP REACHES ALPHA CENTAURI!' : 'THE WORLD IS YOURS!')
|
||||||
|
: (winner ? `${winner.name.toUpperCase()} HAS ${mode === 'spaceship' ? 'REACHED THE STARS' : 'CONQUERED THE WORLD'}` : 'CIVILIZATION HAS FALLEN');
|
||||||
|
root.add(scene.add.text(GAME_WIDTH / 2, 280, title, {
|
||||||
|
fontFamily: 'Righteous', fontSize: '52px',
|
||||||
|
color: humanWon ? COLORS.goldHex : COLORS.dangerHex, align: 'center',
|
||||||
|
wordWrap: { width: 1500 },
|
||||||
|
}).setOrigin(0.5));
|
||||||
|
|
||||||
|
if (winner) {
|
||||||
|
const opData = opponentsData.find((o) => o.id === winner.leaderId)
|
||||||
|
?? { id: winner.leaderId, name: winner.name, spriteIndex: 0 };
|
||||||
|
try {
|
||||||
|
const p = createOpponentPortrait(scene, opData, GAME_WIDTH / 2, 480, 120, 95, { playIntro: false });
|
||||||
|
p.playEmotion(humanWon ? 'happy' : 'happy'); // the winner celebrates either way
|
||||||
|
root.once('destroy', () => p.destroy());
|
||||||
|
} catch (_) { /* optional */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final scores.
|
||||||
|
const scores = state.civs
|
||||||
|
.map((c) => ({ name: c.name, score: Logic.civScore(rules, state, c.id), alive: c.alive }))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.map((s, i) => `${i + 1}. ${s.name}${s.alive ? '' : ' †'} — ${s.score}`);
|
||||||
|
root.add(scene.add.text(GAME_WIDTH / 2, 660, `FINAL SCORES\n${scores.join('\n')}`, {
|
||||||
|
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex, align: 'center', lineSpacing: 8,
|
||||||
|
}).setOrigin(0.5, 0));
|
||||||
|
|
||||||
|
root.add(new Button(scene, GAME_WIDTH / 2 - 160, GAME_HEIGHT - 120, 'PLAY AGAIN', () => {
|
||||||
|
root.destroy(true);
|
||||||
|
onPlayAgain();
|
||||||
|
}, { width: 280, height: 60, fontSize: 24 }));
|
||||||
|
root.add(new Button(scene, GAME_WIDTH / 2 + 160, GAME_HEIGHT - 120, 'MAIN MENU', () => {
|
||||||
|
scene.scene.start('GameMenu');
|
||||||
|
}, { width: 280, height: 60, fontSize: 24, variant: 'ghost' }));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
// Civilization — seeded world generation. Headless (no Phaser).
|
||||||
|
//
|
||||||
|
// Square grid (col,row), rendered isometrically by the view. Flat world (no
|
||||||
|
// east-west wrap). Terrain from layered value noise: elevation picks land/sea
|
||||||
|
// (exact land fraction via percentile threshold), a ridged channel places
|
||||||
|
// hills/mountains, latitude bands place tundra/glacier/desert, and a moisture
|
||||||
|
// channel places forest/jungle/swamp. Specials sit on a Civ II-style diagonal
|
||||||
|
// lattice; huts scatter on land away from starting positions.
|
||||||
|
|
||||||
|
export function mulberry32(seed) {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return function next() {
|
||||||
|
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth value noise in [0,1]: random lattice at `freq` cells, cosine-blended.
|
||||||
|
function makeNoise(rng, cols, rows, octaves = 3, baseFreq = 5) {
|
||||||
|
const layers = [];
|
||||||
|
for (let o = 0; o < octaves; o += 1) {
|
||||||
|
const freq = baseFreq * (1 << o);
|
||||||
|
const gw = freq + 2;
|
||||||
|
const gh = Math.max(2, Math.round(freq * (rows / cols))) + 2;
|
||||||
|
const grid = [];
|
||||||
|
for (let i = 0; i < gw * gh; i += 1) grid.push(rng());
|
||||||
|
layers.push({ grid, gw, gh, amp: 1 / (1 << o) });
|
||||||
|
}
|
||||||
|
const smooth = (t) => (1 - Math.cos(t * Math.PI)) / 2;
|
||||||
|
return (x, y) => {
|
||||||
|
let sum = 0;
|
||||||
|
let ampSum = 0;
|
||||||
|
for (const { grid, gw, gh, amp } of layers) {
|
||||||
|
const fx = (x / cols) * (gw - 2);
|
||||||
|
const fy = (y / rows) * (gh - 2);
|
||||||
|
const x0 = Math.floor(fx);
|
||||||
|
const y0 = Math.floor(fy);
|
||||||
|
const tx = smooth(fx - x0);
|
||||||
|
const ty = smooth(fy - y0);
|
||||||
|
const v00 = grid[y0 * gw + x0];
|
||||||
|
const v10 = grid[y0 * gw + x0 + 1];
|
||||||
|
const v01 = grid[(y0 + 1) * gw + x0];
|
||||||
|
const v11 = grid[(y0 + 1) * gw + x0 + 1];
|
||||||
|
sum += amp * ((v00 * (1 - tx) + v10 * tx) * (1 - ty) + (v01 * (1 - tx) + v11 * tx) * ty);
|
||||||
|
ampSum += amp;
|
||||||
|
}
|
||||||
|
return sum / ampSum;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Civ II-ish deterministic lattices (independent of RNG so they look "placed").
|
||||||
|
export function shieldGrassAt(x, y) { return ((x * 2 + y) % 4) < 2; }
|
||||||
|
function specialSlotAt(x, y, offset) {
|
||||||
|
return ((x * 3 + y * 5 + offset) % 16) === 0;
|
||||||
|
}
|
||||||
|
function specialPick(x, y) { return ((x >> 1) + (y >> 1)) % 2; }
|
||||||
|
|
||||||
|
// Improvement bit flags stored per tile.
|
||||||
|
export const IMP = { ROAD: 1, RAILROAD: 2, IRRIGATION: 4, FARMLAND: 8, MINE: 16, FORTRESS: 32 };
|
||||||
|
|
||||||
|
export function generateWorld(rules, { sizeId = 'medium', seed = 1, numCivs = 4 } = {}) {
|
||||||
|
const size = rules.worldSizes[sizeId];
|
||||||
|
if (!size) throw new Error(`unknown world size ${sizeId}`);
|
||||||
|
const { cols, rows } = size;
|
||||||
|
const terrainIndex = {};
|
||||||
|
rules.terrainList.forEach((t, i) => { terrainIndex[t.id] = i; });
|
||||||
|
const T = terrainIndex;
|
||||||
|
|
||||||
|
// Retry with derived seeds until the continent constraint holds.
|
||||||
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||||
|
const rng = mulberry32((seed * 7919 + attempt * 104729) >>> 0);
|
||||||
|
const world = tryGenerate(rules, cols, rows, T, rng, numCivs, seed, attempt);
|
||||||
|
if (world) {
|
||||||
|
world.sizeId = sizeId;
|
||||||
|
world.seed = seed;
|
||||||
|
return world;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`worldgen failed after 12 attempts (seed ${seed}, ${sizeId}, ${numCivs} civs)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryGenerate(rules, cols, rows, T, rng, numCivs, seed, attempt) {
|
||||||
|
const n = cols * rows;
|
||||||
|
const elevNoise = makeNoise(rng, cols, rows, 3, 4);
|
||||||
|
const moistNoise = makeNoise(rng, cols, rows, 3, 5);
|
||||||
|
const ridgeNoise = makeNoise(rng, cols, rows, 2, 6);
|
||||||
|
const splitNoise = makeNoise(rng, cols, rows, 2, 7);
|
||||||
|
|
||||||
|
// Elevation with edge falloff so continents pull away from the map border.
|
||||||
|
const elev = new Float64Array(n);
|
||||||
|
for (let y = 0; y < rows; y += 1) {
|
||||||
|
for (let x = 0; x < cols; x += 1) {
|
||||||
|
const ex = Math.min(x, cols - 1 - x) / (cols / 2);
|
||||||
|
const ey = Math.min(y, rows - 1 - y) / (rows / 2);
|
||||||
|
const falloff = Math.min(1, Math.min(ex, ey) * 3.2);
|
||||||
|
elev[y * cols + x] = elevNoise(x, y) * (0.35 + 0.65 * falloff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Land threshold at the exact percentile for the target land fraction.
|
||||||
|
const landTarget = 0.30 + (rng() - 0.5) * 0.05; // 27.5%..32.5%
|
||||||
|
const sorted = Array.from(elev).sort((a, b) => a - b);
|
||||||
|
const threshold = sorted[Math.floor(n * (1 - landTarget))];
|
||||||
|
|
||||||
|
const terrain = new Array(n);
|
||||||
|
for (let y = 0; y < rows; y += 1) {
|
||||||
|
for (let x = 0; x < cols; x += 1) {
|
||||||
|
const i = y * cols + x;
|
||||||
|
if (elev[i] < threshold) { terrain[i] = T.ocean; continue; }
|
||||||
|
const lat = Math.abs(y - (rows - 1) / 2) / ((rows - 1) / 2);
|
||||||
|
const moist = moistNoise(x, y);
|
||||||
|
const ridge = 1 - Math.abs(2 * ridgeNoise(x, y) - 1);
|
||||||
|
if (lat > 0.92) { terrain[i] = T.glacier; continue; }
|
||||||
|
if (ridge > 0.86) { terrain[i] = T.mountains; continue; }
|
||||||
|
if (ridge > 0.74) { terrain[i] = T.hills; continue; }
|
||||||
|
if (lat > 0.78) { terrain[i] = moist > 0.6 ? T.tundra : (rng() < 0.4 ? T.glacier : T.tundra); continue; }
|
||||||
|
if (lat < 0.35 && moist < 0.32) { terrain[i] = T.desert; continue; }
|
||||||
|
if (lat < 0.42 && moist > 0.74) { terrain[i] = T.jungle; continue; }
|
||||||
|
if (moist > 0.82) { terrain[i] = T.swamp; continue; }
|
||||||
|
if (moist > 0.56) { terrain[i] = T.forest; continue; }
|
||||||
|
terrain[i] = splitNoise(x, y) > 0.48 ? T.grassland : T.plains;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continent labels (4-neighbour flood fill over land).
|
||||||
|
const continent = new Int16Array(n).fill(-1);
|
||||||
|
const contSizes = [];
|
||||||
|
for (let i = 0; i < n; i += 1) {
|
||||||
|
if (terrain[i] === T.ocean || continent[i] !== -1) continue;
|
||||||
|
const label = contSizes.length;
|
||||||
|
let count = 0;
|
||||||
|
const stack = [i];
|
||||||
|
continent[i] = label;
|
||||||
|
while (stack.length) {
|
||||||
|
const cur = stack.pop();
|
||||||
|
count += 1;
|
||||||
|
const cx = cur % cols;
|
||||||
|
const cy = (cur / cols) | 0;
|
||||||
|
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||||
|
const nx = cx + dx;
|
||||||
|
const ny = cy + dy;
|
||||||
|
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
|
||||||
|
const ni = ny * cols + nx;
|
||||||
|
if (terrain[ni] !== T.ocean && continent[ni] === -1) {
|
||||||
|
continent[ni] = label;
|
||||||
|
stack.push(ni);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contSizes.push(count);
|
||||||
|
}
|
||||||
|
const landCount = terrain.reduce((acc, t) => acc + (t === T.ocean ? 0 : 1), 0);
|
||||||
|
const largest = Math.max(0, ...contSizes);
|
||||||
|
if (landCount === 0 || largest / landCount < 0.15) return null; // reroll
|
||||||
|
|
||||||
|
// Specials on the diagonal lattice (grassland gets shield variant instead).
|
||||||
|
const latticeOffset = Math.floor(rng() * 16);
|
||||||
|
const special = new Array(n).fill(-1);
|
||||||
|
const specialIndexById = {};
|
||||||
|
rules.specialList.forEach((s, i) => { specialIndexById[s.id] = i; });
|
||||||
|
for (let y = 0; y < rows; y += 1) {
|
||||||
|
for (let x = 0; x < cols; x += 1) {
|
||||||
|
const i = y * cols + x;
|
||||||
|
const terr = rules.terrainList[terrain[i]];
|
||||||
|
if (terr.id === 'grassland') continue;
|
||||||
|
if (!specialSlotAt(x, y, latticeOffset)) continue;
|
||||||
|
const opts = rules.specialsByTerrain[terr.id];
|
||||||
|
if (!opts || !opts.length) continue;
|
||||||
|
const pick = opts[specialPick(x, y) % opts.length];
|
||||||
|
special[i] = specialIndexById[pick.id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starting positions: quality-scored land tiles, greedy farthest-point.
|
||||||
|
const starts = pickStarts(rules, cols, rows, T, terrain, special, continent, contSizes, numCivs, rng);
|
||||||
|
if (!starts) return null;
|
||||||
|
|
||||||
|
// Goody huts: ~1 per 40 land tiles, away from starts, not on glacier.
|
||||||
|
const huts = new Uint8Array(n);
|
||||||
|
const hutTarget = Math.floor(landCount / 40);
|
||||||
|
let placed = 0;
|
||||||
|
for (let tries = 0; tries < hutTarget * 30 && placed < hutTarget; tries += 1) {
|
||||||
|
const x = Math.floor(rng() * cols);
|
||||||
|
const y = Math.floor(rng() * rows);
|
||||||
|
const i = y * cols + x;
|
||||||
|
if (terrain[i] === T.ocean || terrain[i] === T.glacier || huts[i]) continue;
|
||||||
|
if (starts.some(([sx, sy]) => Math.max(Math.abs(sx - x), Math.abs(sy - y)) <= 2)) continue;
|
||||||
|
huts[i] = 1;
|
||||||
|
placed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
terrain,
|
||||||
|
special,
|
||||||
|
improvements: new Array(n).fill(0),
|
||||||
|
huts: Array.from(huts),
|
||||||
|
continent: Array.from(continent),
|
||||||
|
starts,
|
||||||
|
landFraction: landCount / n,
|
||||||
|
largestContinentFrac: largest / landCount,
|
||||||
|
attempt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quality of a would-be city site: yields of the centre + 8 neighbours.
|
||||||
|
export function siteQuality(rules, world, x, y) {
|
||||||
|
const { cols, rows } = world;
|
||||||
|
let q = 0;
|
||||||
|
for (let dy = -1; dy <= 1; dy += 1) {
|
||||||
|
for (let dx = -1; dx <= 1; dx += 1) {
|
||||||
|
const nx = x + dx;
|
||||||
|
const ny = y + dy;
|
||||||
|
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
|
||||||
|
const i = ny * cols + nx;
|
||||||
|
const terr = rules.terrainList[world.terrain[i]];
|
||||||
|
const spec = world.special[i] >= 0 ? rules.specialList[world.special[i]] : null;
|
||||||
|
const food = spec ? spec.food : terr.food;
|
||||||
|
const shield = spec ? spec.shield : terr.shield;
|
||||||
|
const trade = spec ? spec.trade : terr.trade;
|
||||||
|
q += food * 3 + shield * 2 + trade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickStarts(rules, cols, rows, T, terrain, special, continent, contSizes, numCivs, rng) {
|
||||||
|
const world = { cols, rows, terrain, special };
|
||||||
|
const candidates = [];
|
||||||
|
for (let y = 1; y < rows - 1; y += 1) {
|
||||||
|
for (let x = 1; x < cols - 1; x += 1) {
|
||||||
|
const i = y * cols + x;
|
||||||
|
const terr = rules.terrainList[terrain[i]];
|
||||||
|
if (terr.water || terr.id === 'glacier' || terr.id === 'mountains') continue;
|
||||||
|
if (contSizes[continent[i]] < 8) continue; // no dinghy islands
|
||||||
|
const q = siteQuality(rules, world, x, y);
|
||||||
|
candidates.push({ x, y, q });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.length < numCivs * 4) return null;
|
||||||
|
candidates.sort((a, b) => b.q - a.q);
|
||||||
|
const qualityFloor = candidates[Math.floor(candidates.length * 0.5)].q; // top half only
|
||||||
|
const pool = candidates.filter((c) => c.q >= qualityFloor);
|
||||||
|
|
||||||
|
const minDist = Math.max(6, Math.floor(Math.min(cols, rows) / (numCivs)));
|
||||||
|
for (let relax = 0; relax < 4; relax += 1) {
|
||||||
|
const dist = Math.max(4, minDist - relax * 2);
|
||||||
|
const chosen = [];
|
||||||
|
// Seed with a random high-quality site, then greedy farthest-point.
|
||||||
|
const first = pool[Math.floor(rng() * Math.min(pool.length, 20))];
|
||||||
|
chosen.push(first);
|
||||||
|
while (chosen.length < numCivs) {
|
||||||
|
let best = null;
|
||||||
|
let bestScore = -1;
|
||||||
|
for (const c of pool) {
|
||||||
|
let d = Infinity;
|
||||||
|
for (const s of chosen) {
|
||||||
|
d = Math.min(d, Math.max(Math.abs(s.x - c.x), Math.abs(s.y - c.y)));
|
||||||
|
}
|
||||||
|
if (d < dist) continue;
|
||||||
|
const score = d * 10 + c.q * 0.1;
|
||||||
|
if (score > bestScore) { bestScore = score; best = c; }
|
||||||
|
}
|
||||||
|
if (!best) break;
|
||||||
|
chosen.push(best);
|
||||||
|
}
|
||||||
|
if (chosen.length === numCivs) return chosen.map((c) => [c.x, c.y]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
# Civilization — Sprite / Art Spec
|
||||||
|
|
||||||
|
Everything in Civilization renders procedurally out of the box, so the game is
|
||||||
|
fully playable with **no** art: terrain is flat-shaded isometric diamonds with
|
||||||
|
glyphs, units are colored roundels with two-letter labels, cities are little
|
||||||
|
box clusters. This document lists the optional sprite sheets you can drop in
|
||||||
|
to replace those placeholders, with exact dimensions, layouts, and
|
||||||
|
frame-by-frame maps.
|
||||||
|
|
||||||
|
All art is wired through **`public/data/civilization-artwork.json`**. The scene
|
||||||
|
checks whether each sheet's texture is loaded; if it is, it uses your frames,
|
||||||
|
otherwise it falls back to procedural drawing. You never need to touch code —
|
||||||
|
just add a PNG under `public/assets/images/civilization/` and set its `path`
|
||||||
|
in that JSON.
|
||||||
|
|
||||||
|
Frame numbering everywhere is **row-major, 0-based**: frame 0 is top-left,
|
||||||
|
count left-to-right then down to the next row.
|
||||||
|
|
||||||
|
**The isometric diamond**: map tiles occupy a 128 × 64 px diamond. Terrain and
|
||||||
|
city frames are 128 × 96 px — the bottom 64 px is the diamond footprint, the
|
||||||
|
top 32 px is headroom for tall features (peaks, trees, towers). The diamond's
|
||||||
|
four corners touch the frame edges at (64, 32), (128, 64+32), (64, 96), and
|
||||||
|
(0, 64+32) — i.e. left/right corners at frame-y 64, top corner at frame-y 32,
|
||||||
|
bottom corner at frame-y 96. Fill the whole diamond opaquely; leave the
|
||||||
|
headroom transparent except where the feature rises into it. Tiles butt
|
||||||
|
against each other with no blending in v1 (hard Civ I-style edges).
|
||||||
|
|
||||||
|
> **Future work (not in v1):** terrain blend transitions between neighboring
|
||||||
|
> tile types, river overlays, and coastline foam. Roads, railroads and
|
||||||
|
> irrigation are currently vector-drawn; the improvements sheet below covers
|
||||||
|
> the badge-style overlays only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Terrain base sheet — `civilization-terrain.png`
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Path** | `public/assets/images/civilization/civilization-terrain.png` |
|
||||||
|
| **Sheet size** | **768 × 192 px** |
|
||||||
|
| **Frame size** | **128 × 96 px** |
|
||||||
|
| **Layout** | 6 columns × 2 rows = 12 frames |
|
||||||
|
| **Status** | ❌ not created — procedural diamonds render meanwhile |
|
||||||
|
| **JSON** | `terrainSheet` |
|
||||||
|
|
||||||
|
### Frame map
|
||||||
|
|
||||||
|
| Frame | id | Name | Notes |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 0 | `grassland` | Grassland | plain green |
|
||||||
|
| 1 | — | Grassland (shield) | grassland variant with the little shield mark |
|
||||||
|
| 2 | `plains` | Plains | dry golden grass |
|
||||||
|
| 3 | `forest` | Forest | trees rise into the 32px headroom |
|
||||||
|
| 4 | `hills` | Hills | rolling mounds |
|
||||||
|
| 5 | `mountains` | Mountains | peaks use full headroom, snow caps |
|
||||||
|
| 6 | `desert` | Desert | sand, maybe a dune |
|
||||||
|
| 7 | `tundra` | Tundra | patchy frozen grass |
|
||||||
|
| 8 | `glacier` | Glacier | ice sheet |
|
||||||
|
| 9 | `swamp` | Swamp | murky pools, reeds |
|
||||||
|
| 10 | `jungle` | Jungle | dense canopy, brighter green than forest |
|
||||||
|
| 11 | `ocean` | Ocean | deep blue, light wave marks |
|
||||||
|
|
||||||
|
Frame indexes are pinned in `data/civilization-rules.json` (`terrains[].frame`,
|
||||||
|
`grasslandShieldFrame`) — append-only, never renumber.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Special resources sheet — `civilization-resources.png`
|
||||||
|
|
||||||
|
Drawn centered on top of the base terrain tile (badge layer, 64 × 64 centered
|
||||||
|
on the diamond).
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Path** | `public/assets/images/civilization/civilization-resources.png` |
|
||||||
|
| **Sheet size** | **320 × 256 px** |
|
||||||
|
| **Frame size** | **64 × 64 px** |
|
||||||
|
| **Layout** | 5 columns × 4 rows = 20 frames |
|
||||||
|
| **Status** | ❌ not created — colored dot badges render meanwhile |
|
||||||
|
| **JSON** | `resourceSheet` |
|
||||||
|
|
||||||
|
### Frame map
|
||||||
|
|
||||||
|
| Frame | id | Name | On terrain |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 0 | `buffalo` | Buffalo | Plains |
|
||||||
|
| 1 | `wheat` | Wheat | Plains |
|
||||||
|
| 2 | `pheasant` | Pheasant | Forest |
|
||||||
|
| 3 | `silk` | Silk | Forest |
|
||||||
|
| 4 | `coal` | Coal | Hills |
|
||||||
|
| 5 | `wine` | Wine | Hills |
|
||||||
|
| 6 | `gold` | Gold | Mountains |
|
||||||
|
| 7 | `iron` | Iron | Mountains |
|
||||||
|
| 8 | `oasis` | Oasis | Desert |
|
||||||
|
| 9 | `oil` | Oil | Desert |
|
||||||
|
| 10 | `game` | Game | Tundra |
|
||||||
|
| 11 | `furs` | Furs | Tundra |
|
||||||
|
| 12 | `ivory` | Ivory | Glacier |
|
||||||
|
| 13 | `glacieroil` | Oil (arctic) | Glacier |
|
||||||
|
| 14 | `peat` | Peat | Swamp |
|
||||||
|
| 15 | `spice` | Spice | Swamp |
|
||||||
|
| 16 | `gems` | Gems | Jungle |
|
||||||
|
| 17 | `fruit` | Fruit | Jungle |
|
||||||
|
| 18 | `fish` | Fish | Ocean |
|
||||||
|
| 19 | `whales` | Whales | Ocean |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Tile improvements sheet — `civilization-improvements.png`
|
||||||
|
|
||||||
|
Badge overlays drawn centered on the tile. Roads and railroads stay
|
||||||
|
vector-drawn (they connect dynamically between tiles), so they are NOT here.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Path** | `public/assets/images/civilization/civilization-improvements.png` |
|
||||||
|
| **Sheet size** | **384 × 64 px** |
|
||||||
|
| **Frame size** | **64 × 64 px** |
|
||||||
|
| **Layout** | 6 columns × 1 row = 6 frames |
|
||||||
|
| **Status** | ❌ not created — vector glyphs render meanwhile |
|
||||||
|
| **JSON** | `improvementSheet` |
|
||||||
|
|
||||||
|
### Frame map
|
||||||
|
|
||||||
|
| Frame | id | Name | Notes |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 0 | `irrigation` | Irrigation | furrow lines |
|
||||||
|
| 1 | `farmland` | Farmland | denser irrigation (Refrigeration upgrade) |
|
||||||
|
| 2 | `mine` | Mine | pit head / spoil heap |
|
||||||
|
| 3 | `fortress` | Fortress | square rampart outline |
|
||||||
|
| 4 | `hut` | Goody hut | little thatched hut |
|
||||||
|
| 5 | `fortify` | Fortify shield | reserved for a unit "fortified" badge |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Units sheet — `civilization-units.png`
|
||||||
|
|
||||||
|
Unit art is **neutral** (no civ color baked in): the game draws a player-color
|
||||||
|
ring/roundel underneath your frame. Center the unit in the frame with its feet
|
||||||
|
around y = 52 so it sits on the tile.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Path** | `public/assets/images/civilization/civilization-units.png` |
|
||||||
|
| **Sheet size** | **512 × 448 px** |
|
||||||
|
| **Frame size** | **64 × 64 px** |
|
||||||
|
| **Layout** | 8 columns × 7 rows = 56 frames (51 used, 52–55 reserved) |
|
||||||
|
| **Status** | ❌ not created — roundel + 2-letter label renders meanwhile |
|
||||||
|
| **JSON** | `unitSheet` |
|
||||||
|
|
||||||
|
### Frame map
|
||||||
|
|
||||||
|
| Frame | id | Name | | Frame | id | Name |
|
||||||
|
|---:|---|---|---|---:|---|---|
|
||||||
|
| 0 | `settlers` | Settlers | | 26 | `cannon` | Cannon |
|
||||||
|
| 1 | `engineers` | Engineers | | 27 | `artillery` | Artillery |
|
||||||
|
| 2 | `explorer` | Explorer | | 28 | `howitzer` | Howitzer |
|
||||||
|
| 3 | `caravan` | Caravan | | 29 | `fighter` | Fighter |
|
||||||
|
| 4 | `freight` | Freight | | 30 | `bomber` | Bomber |
|
||||||
|
| 5 | `warriors` | Warriors | | 31 | `helicopter` | Helicopter |
|
||||||
|
| 6 | `phalanx` | Phalanx | | 32 | `stealthfighter` | Stealth Ftr. |
|
||||||
|
| 7 | `archers` | Archers | | 33 | `stealthbomber` | Stealth Bmb. |
|
||||||
|
| 8 | `legion` | Legion | | 34 | `cruisemsl` | Cruise Msl. |
|
||||||
|
| 9 | `pikemen` | Pikemen | | 35 | `nuclearmsl` | Nuclear Msl. |
|
||||||
|
| 10 | `musketeers` | Musketeers | | 36 | `trireme` | Trireme |
|
||||||
|
| 11 | `riflemen` | Riflemen | | 37 | `caravel` | Caravel |
|
||||||
|
| 12 | `alpinetroops` | Alpine Troops | | 38 | `galleon` | Galleon |
|
||||||
|
| 13 | `partisans` | Partisans | | 39 | `frigate` | Frigate |
|
||||||
|
| 14 | `marines` | Marines | | 40 | `ironclad` | Ironclad |
|
||||||
|
| 15 | `paratroopers` | Paratroopers | | 41 | `destroyer` | Destroyer |
|
||||||
|
| 16 | `mechinf` | Mech. Inf. | | 42 | `cruiser` | Cruiser |
|
||||||
|
| 17 | `horsemen` | Horsemen | | 43 | `aegiscruiser` | AEGIS Cruiser |
|
||||||
|
| 18 | `chariot` | Chariot | | 44 | `battleship` | Battleship |
|
||||||
|
| 19 | `elephant` | Elephant | | 45 | `submarine` | Submarine |
|
||||||
|
| 20 | `knights` | Knights | | 46 | `carrier` | Carrier |
|
||||||
|
| 21 | `crusaders` | Crusaders | | 47 | `transport` | Transport |
|
||||||
|
| 22 | `dragoons` | Dragoons | | 48 | `ssstructural` | SS Structural |
|
||||||
|
| 23 | `cavalry` | Cavalry | | 49 | `sscomponent` | SS Component |
|
||||||
|
| 24 | `armor` | Armor | | 50 | `ssmodule` | SS Module |
|
||||||
|
| 25 | `catapult` | Catapult | | 51–55 | — | reserved |
|
||||||
|
|
||||||
|
Frame indexes are pinned in `data/civilization-rules.json` (`units[].frame`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. City sheet (classic theme) — `civilization-cities-classic.png`
|
||||||
|
|
||||||
|
Cities scale with population and show walls when City Walls are built. This is
|
||||||
|
the **classic/western** theme; more themes (e.g. `asian`) drop in later by
|
||||||
|
adding an entry to `citySheets` in the artwork JSON — the format already
|
||||||
|
supports it and no code changes are needed.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Path** | `public/assets/images/civilization/civilization-cities-classic.png` |
|
||||||
|
| **Sheet size** | **512 × 192 px** |
|
||||||
|
| **Frame size** | **128 × 96 px** (same diamond+headroom as terrain) |
|
||||||
|
| **Status** | ❌ not created — box-cluster fallback renders meanwhile |
|
||||||
|
| **Layout** | 4 columns × 2 rows = 8 frames |
|
||||||
|
| **JSON** | `citySheets.classic` |
|
||||||
|
|
||||||
|
### Frame map
|
||||||
|
|
||||||
|
| Frame | Size tier | Walls | Notes |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 0 | 1–3 (village) | no | a few small buildings |
|
||||||
|
| 1 | 4–7 (town) | no | denser, a landmark roof |
|
||||||
|
| 2 | 8–12 (city) | no | multi-story buildings |
|
||||||
|
| 3 | 13+ (metropolis) | no | grand skyline into the headroom |
|
||||||
|
| 4 | 1–3 | **yes** | tier 0 ringed by walls |
|
||||||
|
| 5 | 4–7 | **yes** | |
|
||||||
|
| 6 | 8–12 | **yes** | |
|
||||||
|
| 7 | 13+ | **yes** | |
|
||||||
|
|
||||||
|
The player-color banner and name plate are drawn by the game below the tile —
|
||||||
|
keep the bottom ~6 px of the diamond clear of critical detail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. HUD icons sheet — `civilization-icons.png`
|
||||||
|
|
||||||
|
Small interface icons (yields, governments, spaceship parts, moods).
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Path** | `public/assets/images/civilization/civilization-icons.png` |
|
||||||
|
| **Sheet size** | **480 × 96 px** |
|
||||||
|
| **Frame size** | **48 × 48 px** |
|
||||||
|
| **Layout** | 10 columns × 2 rows = 20 frames |
|
||||||
|
| **Status** | ❌ not created — text labels render meanwhile |
|
||||||
|
| **JSON** | `iconSheet` |
|
||||||
|
|
||||||
|
### Frame map
|
||||||
|
|
||||||
|
| Frame | id | Notes | | Frame | id | Notes |
|
||||||
|
|---:|---|---|---|---:|---|---|
|
||||||
|
| 0 | `food` | wheat sheaf | | 10 | `gov-despotism` | |
|
||||||
|
| 1 | `shield` | production shield | | 11 | `gov-anarchy` | |
|
||||||
|
| 2 | `trade` | trade arrow | | 12 | `gov-monarchy` | |
|
||||||
|
| 3 | `gold` | coin | | 13 | `gov-communism` | |
|
||||||
|
| 4 | `beaker` | science flask | | 14 | `gov-republic` | |
|
||||||
|
| 5 | `pop` | citizen head | | 15 | `gov-democracy` | |
|
||||||
|
| 6 | `ss-structural` | | | 16 | `mood-happy` | diplomacy list dot |
|
||||||
|
| 7 | `ss-component` | | | 17 | `mood-idle` | |
|
||||||
|
| 8 | `ss-module` | | | 18 | `mood-upset` | |
|
||||||
|
| 9 | `vet` | veteran chevron | | 19 | — | reserved |
|
||||||
|
|
||||||
|
Government frames match `governments[].frame` in the rules JSON (10–15).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Menu icon — `game-icons.png` frame 83
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Path** | `public/assets/images/game-icons.png` (shared sheet) |
|
||||||
|
| **Sheet size** | 660 × 660 px (15 × 15 grid of 44 × 44 frames) |
|
||||||
|
| **Frame** | **83** (row 5, col 8) |
|
||||||
|
| **Status** | ❌ needs painting |
|
||||||
|
|
||||||
|
Suggested motif: a tiny isometric globe/hex of terrain, or a laurel-wreathed
|
||||||
|
city. The menu shows a generic fallback until painted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick checklist
|
||||||
|
|
||||||
|
| # | File | Size | Priority |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `civilization-terrain.png` | 768×192 | **High** — biggest visual upgrade |
|
||||||
|
| 2 | `civilization-cities-classic.png` | 512×192 | **High** |
|
||||||
|
| 3 | `civilization-units.png` | 512×448 | **High** (51 frames — biggest lift) |
|
||||||
|
| 4 | `civilization-resources.png` | 320×256 | Medium |
|
||||||
|
| 5 | `civilization-improvements.png` | 384×64 | Medium |
|
||||||
|
| 6 | `civilization-icons.png` | 480×96 | Low |
|
||||||
|
| 7 | `game-icons.png` frame 83 | 44×44 | Medium (menu presence) |
|
||||||
|
|
||||||
|
After painting a sheet, set its `path` in `public/data/civilization-artwork.json`,
|
||||||
|
e.g. `"path": "assets/images/civilization/civilization-terrain.png"`, and
|
||||||
|
reload — no code changes.
|
||||||
|
|
@ -91,6 +91,7 @@ import PeggleGame from './games/peggle/PeggleGame.js';
|
||||||
import PeggleEditor from './games/peggle/PeggleEditor.js';
|
import PeggleEditor from './games/peggle/PeggleEditor.js';
|
||||||
import ColoradoDefenseGame from './games/coloradodefense/ColoradoDefenseGame.js';
|
import ColoradoDefenseGame from './games/coloradodefense/ColoradoDefenseGame.js';
|
||||||
import StarControlGame from './games/starcontrol/StarControlGame.js';
|
import StarControlGame from './games/starcontrol/StarControlGame.js';
|
||||||
|
import CivilizationGame from './games/civilization/CivilizationGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -195,6 +196,7 @@ const config = {
|
||||||
PeggleEditor,
|
PeggleEditor,
|
||||||
ColoradoDefenseGame,
|
ColoradoDefenseGame,
|
||||||
StarControlGame,
|
StarControlGame,
|
||||||
|
CivilizationGame,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame' };
|
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame' };
|
||||||
if (slugDispatch[this.game.slug]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
const sceneKey = slugDispatch[this.game.slug];
|
const sceneKey = slugDispatch[this.game.slug];
|
||||||
const startData = {
|
const startData = {
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ export default class PreloadScene extends Phaser.Scene {
|
||||||
this.load.json('swdbg-artwork', 'data/swdbg-artwork.json');
|
this.load.json('swdbg-artwork', 'data/swdbg-artwork.json');
|
||||||
this.load.json('jumble', 'data/jumble.json');
|
this.load.json('jumble', 'data/jumble.json');
|
||||||
this.load.json('balatro-artwork', 'data/balatro-artwork.json');
|
this.load.json('balatro-artwork', 'data/balatro-artwork.json');
|
||||||
|
this.load.json('civilization-artwork', 'data/civilization-artwork.json');
|
||||||
this.load.json('peggle-levels', 'assets/gamedata/peggle/levels.json');
|
this.load.json('peggle-levels', 'assets/gamedata/peggle/levels.json');
|
||||||
this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json');
|
this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json');
|
||||||
this.load.json('star-control-ships', 'data/star-control-ships.json');
|
this.load.json('star-control-ships', 'data/star-control-ships.json');
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue