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:
Brian Fertig 2026-07-15 18:56:02 -06:00
parent bd693e0191
commit 67a7ac857e
19 changed files with 6465 additions and 1 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

View File

@ -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 }
}
}

View File

@ -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 }
]
}

View File

@ -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

View File

@ -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 });

View File

@ -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;
}

View File

@ -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();
}

View File

@ -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

View File

@ -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;
}

View File

@ -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`;
}

View File

@ -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' }));
}

View File

@ -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;
}

View File

@ -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, 5255 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 | | 5155 | — | 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 | 13 (village) | no | a few small buildings |
| 1 | 47 (town) | no | denser, a landmark roof |
| 2 | 812 (city) | no | multi-story buildings |
| 3 | 13+ (metropolis) | no | grand skyline into the headroom |
| 4 | 13 | **yes** | tier 0 ringed by walls |
| 5 | 47 | **yes** | |
| 6 | 812 | **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 (1015).
---
## 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.

View File

@ -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,
], ],
}; };

View File

@ -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 = {

View File

@ -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');

1149
tools/verifyCivilization.js Normal file

File diff suppressed because it is too large Load Diff