Compare commits

...

4 Commits

Author SHA1 Message Date
Brian Fertig d6d3bafc7f feat(civilization): add battle sound effects for unit combat
Add audio feedback for combat events by introducing unit-specific sound
effects that play during attack animations. Each unit in civilization-rules.json
now has a battleSound property mapping to appropriate audio assets. Adds 10
new MP3 files (arrows, cannon, catapult, gunfire-classic/modern, helicopter,
missile, submarine, sword, tank) and integrates playback in CivilizationMapView's
combat lunge animation.
2026-07-17 13:03:26 -06:00
Brian Fertig 8b5aa6064a Updates to research display 2026-07-17 11:46:27 -06:00
Brian Fertig 02b14b88c8 chore: reduce minimum trade route distance from 8 to 6 tiles
Lower the minimum distance requirement for establishing trade routes
from 8 to 6 tiles, making caravan routes more accessible. Also refactors
the log message in CivilizationGame to use the TRADE_ROUTE_MIN_DIST
constant instead of a hardcoded value.
2026-07-17 11:11:59 -06:00
Brian Fertig b214f6b8bd feat(civ): add shield waste mechanic and AI corruption triage
- Implement "waste" as corruption's production sibling: distant cities lose
  shields to waste (half the corruption decay rate), halved by courthouses.
- Update city yields, UI summaries, and government screen to display waste
  alongside corruption and support costs.
- AI now builds courthouses early when combined corruption + waste loss
  exceeds 4 per turn, preventing distant cities from becoming useless.
- Refresh building effect tooltips and palace description to reflect waste.
2026-07-17 11:05:01 -06:00
19 changed files with 133 additions and 72 deletions

BIN
assets/fx/battle-arrows.mp3 Normal file

Binary file not shown.

BIN
assets/fx/battle-cannon.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/fx/battle-missle.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/fx/battle-sword.mp3 Normal file

Binary file not shown.

BIN
assets/fx/battle-tank.mp3 Normal file

Binary file not shown.

View File

@ -108,52 +108,52 @@
{ "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": "warriors", "name": "Warriors", "domain": "land", "attack": 1, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 10, "prereq": null, "obsoletedBy": "pikemen", "flags": [], "battleSound": "sword", "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": [], "battleSound": "sword", "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": [], "battleSound": "arrows", "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": [], "battleSound": "sword", "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"], "battleSound": "sword", "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": [], "battleSound": "gunfire-classic", "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": [], "battleSound": "gunfire-classic", "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"], "battleSound": "gunfire-modern", "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"], "battleSound": "gunfire-modern", "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"], "battleSound": "gunfire-modern", "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"], "battleSound": "gunfire-modern", "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": [], "battleSound": "gunfire-modern", "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": "horsemen", "name": "Horsemen", "domain": "land", "attack": 2, "defense": 1, "move": 2, "hp": 10, "fp": 1, "cost": 20, "prereq": "horsebackriding", "obsoletedBy": "knights", "flags": ["mounted"], "battleSound": "sword", "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"], "battleSound": "sword", "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"], "battleSound": "sword", "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"], "battleSound": "sword", "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"], "battleSound": "sword", "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"], "battleSound": "gunfire-classic", "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"], "battleSound": "gunfire-classic", "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"], "battleSound": "tank", "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": "catapult", "name": "Catapult", "domain": "land", "attack": 6, "defense": 1, "move": 1, "hp": 10, "fp": 1, "cost": 40, "prereq": "mathematics", "obsoletedBy": "cannon", "flags": [], "battleSound": "catapult", "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": [], "battleSound": "cannon", "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": [], "battleSound": "cannon", "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"], "battleSound": "cannon", "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": "fighter", "name": "Fighter", "domain": "air", "attack": 4, "defense": 3, "move": 10, "hp": 20, "fp": 2, "cost": 60, "prereq": "flight", "obsoletedBy": "stealthfighter", "flags": ["fighter"], "battleSound": "gunfire-modern", "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": [], "battleSound": "missle", "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": [], "battleSound": "helicopter", "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"], "battleSound": "missle", "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": [], "battleSound": "missle", "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"], "battleSound": "missle", "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"], "battleSound": "missle", "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": "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, "battleSound": "arrows", "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, "battleSound": "cannon", "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": "frigate", "name": "Frigate", "domain": "sea", "attack": 4, "defense": 2, "move": 4, "hp": 20, "fp": 1, "cost": 50, "prereq": "magnetism", "obsoletedBy": "destroyer", "flags": [], "cargo": 2, "battleSound": "cannon", "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": [], "battleSound": "cannon", "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": [], "battleSound": "cannon", "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": [], "battleSound": "cannon", "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"], "battleSound": "missle", "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": [], "battleSound": "cannon", "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"], "battleSound": "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, "battleSound": "gunfire-modern", "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 },

View File

@ -158,6 +158,11 @@ export const MANIFEST = {
civilization: [
{ type: 'json', key: 'civilization-rules', path: 'data/civilization-rules.json' },
image('civilization-setup-bg', 'assets/images/civilization/background.png'),
// Battle sounds keyed by each unit's `battleSound` in civilization-rules.json
// (played on attack by CivilizationMapView.playCombatEvent).
...['arrows', 'cannon', 'catapult', 'gunfire-classic', 'gunfire-modern',
'helicopter', 'missle', 'submarine', 'sword', 'tank']
.map((n) => ({ type: 'audio', key: `sfx-battle-${n}`, path: `assets/fx/battle-${n}.mp3` })),
(scene) => sheetsFrom(scene, 'civilization-artwork',
['terrainSheet', 'resourceSheet', 'improvementSheet', 'unitSheet', 'iconSheet', 'citySheets']),
],

View File

@ -319,6 +319,13 @@ function manageCityBuild(rules, state, civIdx, city, strategy) {
setBuild(rules, state, city, 'unit', cvType);
return;
}
// Corruption triage: distant cities bleed trade to corruption and shields
// to waste — when the combined loss is worth a building's upkeep several
// times over, jump the chain straight to a courthouse (halves both).
if ((yields.corruption + yields.waste) >= 4 && avail.some((b) => b.id === 'courthouse')) {
setBuild(rules, state, city, 'building', 'courthouse');
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.

View File

@ -61,7 +61,7 @@ export function openCityScreen(scene, rules, state, city, onClose) {
// Yields breakdown.
const lines = [
`Food ${y.food} (need ${y.foodNeed}) surplus ${y.foodSurplus >= 0 ? '+' : ''}${y.foodSurplus}`,
`Shields ${y.shield}${y.supportShields ? ` (support −${y.supportShields})` : ''}`,
`Shields ${y.shield}${y.waste ? ` waste −${y.waste}` : ''}${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}`,
];

View File

@ -1014,7 +1014,7 @@ export default class CivilizationGame extends Phaser.Scene {
tryCaravan(unit) {
const pair = Logic.canEstablishRoute(this.rules, this.state, unit);
if (!pair) { this.logMessage('Caravans need a city 8+ tiles from home'); return; }
if (!pair) { this.logMessage(`Caravans need a city ${Logic.TRADE_ROUTE_MIN_DIST}+ tiles from home`); return; }
const out = Logic.establishTradeRoute(this.rules, this.state, unit);
if (out) {
this.announceStatus(`Trade route: ${out.from.name} ↔ ${out.to.name} (+${out.bonus} gold & beakers)`);
@ -1236,8 +1236,18 @@ export default class CivilizationGame extends Phaser.Scene {
const civ = this.state.civs[human];
if (!civ.alive) { this.onGameOver(); return; }
// Research prompt.
if (!civ.researching && Logic.availableTechs(this.rules, civ).length) {
// Research: announce the completed discovery FIRST, open the picker as
// that popup is dismissed (onDismiss replaces the queue-resume; the tech
// screen's close handler resumes it), and let everything else queue up
// behind. Without a completion event (fresh game, resumed save) the
// picker still opens directly.
const techEvent = this.state.events.find((e) => e.type === 'techDone' && e.civ === human && !e.announced);
const needsPick = !civ.researching && Logic.availableTechs(this.rules, civ).length > 0;
if (techEvent) {
techEvent.announced = true;
this.announceStatus(`Research complete: ${this.rules.techs[techEvent.tech].name}`,
needsPick ? () => this.openTech() : undefined);
} else if (needsPick) {
this.openTech();
}
this.presentAIProposals();
@ -1283,20 +1293,26 @@ export default class CivilizationGame extends Phaser.Scene {
// "resolve now, animate after" trick as the player's own moves, just
// via a before/after snapshot instead of a waypoint path.
const combatEvents = this.collectNewCombatEvents();
// Combats against any city the human has explored — their own or a
// rival's — are always deferred to a dedicated cinematic (camera pan +
// replay + outcome popup) played at the start of the human's next
// turn, rather than silently replayed here (possibly off-screen) like
// ordinary field battles. City tiles stay marked once explored (see
// MapView.refresh()'s `explored[idx]` check), so this reaches a known
// rival city even if it's currently outside the fog-of-war vision set.
// Combats against the HUMAN's cities are deferred to a dedicated
// cinematic (camera pan + replay + outcome popup) at the start of the
// human's next turn — those demand a response. Rival-vs-rival battles
// at cities the human has explored play live right here instead: pan,
// replay, and a status-log line, no blocking popup. City tiles stay
// marked once explored (see MapView.refresh()'s `explored[idx]`
// check), so this reaches a known rival city even if it's currently
// outside the fog-of-war vision set. `e.defenderCiv === human` catches
// a city the human just lost this step (city.civ already shows the
// conqueror by the time we classify).
const human = this.state.humanIndex;
const elsewhere = [];
const rivalCityCombats = [];
for (const e of combatEvents) {
const city = Logic.cityAt(this.state, e.x, e.y);
const cityExplored = city && this.state.explored[human][Logic.tileIndex(this.state.world, city.x, city.y)];
if (city && (city.civ === human || cityExplored)) {
if (city && (city.civ === human || e.defenderCiv === human)) {
this.pendingCityAttacks.push({ e, cityId: city.id, cityName: city.name, x: city.x, y: city.y });
} else if (city && cityExplored) {
rivalCityCombats.push({ e, city });
} else {
elsewhere.push(e);
}
@ -1318,8 +1334,25 @@ export default class CivilizationGame extends Phaser.Scene {
this.time.delayedCall(90, stepCiv);
});
};
if (visibleCombat.length) this.view.animateCombat(visibleCombat, afterCombat);
else afterCombat();
// Rival-vs-rival city battles first (pan + replay + log line, one at a
// time), then ordinary visible field combats, then the move glide.
const playRivalCityCombats = (done) => {
const item = rivalCityCombats.shift();
if (!item) { done(); return; }
const { e, city } = item;
const attackerName = this.state.civs[e.attackerCiv]?.name ?? 'An enemy';
const captured = e.attackerWon && city.civ === e.attackerCiv;
const outcome = captured ? `${city.name} has fallen to ${attackerName}!`
: e.attackerWon ? `${city.name}'s defenders were defeated!`
: `${city.name} held its ground!`;
this.logMessage(`${attackerName} attacked ${city.name} — ${outcome}`);
this.view.panToTile(city.x, city.y);
this.view.animateCombat([e], () => playRivalCityCombats(done));
};
playRivalCityCombats(() => {
if (visibleCombat.length) this.view.animateCombat(visibleCombat, afterCombat);
else afterCombat();
});
};
stepCiv();
}
@ -1342,10 +1375,10 @@ export default class CivilizationGame extends Phaser.Scene {
}
}
// Combats against any city the human has explored (their own or a
// rival's), deferred from runToHumanTurn()'s per-civ stepping (see
// stepCiv there) so they always get a camera pan + replay + outcome popup
// instead of possibly playing silently off-screen.
// Combats against the human's own cities, deferred from runToHumanTurn()'s
// per-civ stepping (see stepCiv there) so they always get a camera pan +
// replay + outcome popup at the start of the human's turn. Rival-vs-rival
// city battles play live during the AI phase instead.
announceCityAttacks() {
const attacks = this.pendingCityAttacks;
this.pendingCityAttacks = [];
@ -1504,6 +1537,11 @@ export default class CivilizationGame extends Phaser.Scene {
openTechScreen(this, this.rules, this.state, () => {
this.modalOpen = false;
this.refreshHud();
// Resume any statuses that queued while the tech screen was up — in
// particular the start-of-turn flow, where the research-complete
// popup's onDismiss opens this screen with the rest of the turn's
// announcements still waiting in the queue.
this.showNextStatus();
});
}

View File

@ -380,14 +380,21 @@ export function cityYields(rules, state, city) {
}
const grossShield = Math.floor(shield * shieldMult);
const netShield = Math.max(0, grossShield - supportShields);
// Waste — corruption's production sibling: the same capital-distance decay
// applied to shields, at half strength (full strength would zero out
// distant cities' build boxes entirely under Despotism). Courthouse halves
// it, same as corruption.
let waste = Math.floor(grossShield * gov.corruptionFactor * Math.min(1, dist / 20) * 0.5);
if (city.buildings.courthouse) waste = Math.floor(waste * 0.5);
waste = Math.min(waste, grossShield);
const netShield = Math.max(0, grossShield - waste - supportShields);
const foodNeed = city.size * FOOD_PER_CITIZEN + settlerFood;
const upkeep = Object.keys(city.buildings)
.reduce((sum, id) => sum + (rules.buildings[id]?.upkeep ?? 0), 0);
return {
food, foodNeed, foodSurplus: food - foodNeed,
shield: netShield, grossShield, supportShields,
shield: netShield, grossShield, waste, supportShields,
trade, corruption, netTrade, routeTrade,
gold: Math.floor(baseGold * goldMult), science: Math.floor(baseScience * sciMult),
upkeep, supportGold,
@ -1337,7 +1344,7 @@ export function makeContacts(rules, state, civIdx, x, y) {
// ---------------------------------------------------------------------------
// Trade routes (caravans)
export const TRADE_ROUTE_MIN_DIST = 8;
export const TRADE_ROUTE_MIN_DIST = 6;
export const MAX_ROUTES = 3;
export function canEstablishRoute(rules, state, unit) {

View File

@ -17,6 +17,7 @@ import {
computeVisible, isUnitVisibleTo, shieldGrassAt,
} from './CivilizationLogic.js';
import { Tooltip } from '../../ui/Tooltip.js';
import { playSound } from '../../ui/Sounds.js';
import { describeUnitStackTooltip, describeCityTooltip } from './CivilizationTooltips.js';
export const TILE_W = 128;
@ -914,6 +915,9 @@ export class CivilizationMapView {
const target = { x: this.isoX(e.x, e.y), y: this.isoY(e.x, e.y) + TILE_H / 2 };
const LUNGE_MS = 250;
const battleSound = this.rules.units[e.attackerType].battleSound;
if (battleSound) playSound(this.scene, `sfx-battle-${battleSound}`);
// Lunge onto the defender's tile...
this.scene.tweens.add({
targets: attackerGhost.container,

View File

@ -466,12 +466,12 @@ function govBullets(g) {
const pro = (text) => out.push({ text, pro: true });
const con = (text) => out.push({ text, pro: false });
if (g.noScience) con('No research — science output is zero');
if (g.corruptionFactor === 0) pro('No corruption at all');
if (g.corruptionFactor === 0) pro('No corruption or waste at all');
else if (g.corruptionFactor <= 0.4) {
pro(g.flatCorruption ? 'Low corruption, the same across the whole empire' : 'Low corruption');
} else if (g.corruptionFactor <= 0.7) pro('Reduced corruption');
else if (g.corruptionFactor >= 1.5) con('Crippling corruption');
else con('Heavy corruption, worse far from the capital');
pro(g.flatCorruption ? 'Low corruption & waste, the same across the whole empire' : 'Low corruption & waste');
} else if (g.corruptionFactor <= 0.7) pro('Reduced corruption & waste');
else if (g.corruptionFactor >= 1.5) con('Crippling corruption & waste');
else con('Heavy corruption & waste, worse far from the capital');
if (g.tradeBonus) pro(`+${g.tradeBonus} trade on tiles already producing trade`);
if (g.despotPenalty) con('−1 food/shield/trade on tiles yielding 3 or more');
if (g.freeUnits > 0) pro(`${g.freeUnits} free units per city, then 1 ${g.unitUpkeep}/unit each turn`);
@ -502,12 +502,12 @@ export function openGovernmentScreen(scene, rules, state, onClose) {
// aggregated from the same cityYields the cities themselves run on.
const cities = Logic.civCities(state, state.humanIndex);
const sum = {
gross: 0, support: 0, shield: 0, trade: 0, corruption: 0, netTrade: 0,
gross: 0, waste: 0, support: 0, shield: 0, trade: 0, corruption: 0, netTrade: 0,
gold: 0, upkeep: 0, supportGold: 0, science: 0, pop: 0,
};
for (const c of cities) {
const y = Logic.cityYields(rules, state, c);
sum.gross += y.grossShield; sum.support += y.supportShields; sum.shield += y.shield;
sum.gross += y.grossShield; sum.waste += y.waste; sum.support += y.supportShields; sum.shield += y.shield;
sum.trade += y.trade; sum.corruption += y.corruption; sum.netTrade += y.netTrade;
sum.gold += y.gold; sum.upkeep += y.upkeep; sum.supportGold += y.supportGold;
sum.science += y.science; sum.pop += c.size;
@ -521,7 +521,7 @@ export function openGovernmentScreen(scene, rules, state, onClose) {
}));
const netGold = sum.gold - sum.upkeep - sum.supportGold;
content.add(scene.add.text(left, top + 44,
`Production: ${sum.shield} shields/turn (${sum.gross} gross − ${sum.support} unit support) `
`Production: ${sum.shield} shields/turn (${sum.gross} gross − ${sum.waste} waste − ${sum.support} unit support) `
+ `Trade: ${sum.netTrade}/turn (${sum.trade} − ${sum.corruption} corruption)`, {
fontFamily: FONT, fontSize: '19px', color: COLORS.textHex,
}));

View File

@ -43,7 +43,7 @@ const EFFECT_TEXT = {
farmfood: (b) => `+${Math.round(b.value * 100)}% food from farmland-improved tiles worked by this city.`,
defenseair: (b) => `Multiplies this city's defense against air attacks by ${b.value}.`,
power: (b) => `+${Math.round(b.value * 100)}% shield output from the city's Factory (requires a Factory).`,
corruption: (b) => `Cuts this city's corruption by ${Math.round(b.value * 100)}%.`,
corruption: (b) => `Cuts this city's corruption and waste by ${Math.round(b.value * 100)}%.`,
gold: (b) => `+${Math.round(b.value * 100)}% gold income from this city's trade.`,
veterans: () => 'Units built here start as veterans, with a combat bonus.',
sizecap: (b) => (b.id === 'sewersystem'
@ -52,7 +52,7 @@ const EFFECT_TEXT = {
oceanfood: (b) => `+${b.value} food from every ocean tile this city works.`,
shields: (b) => `+${Math.round(b.value * 100)}% shield (production) output from this city.`,
oceanshield: (b) => `+${b.value} shield from every ocean tile this city works.`,
palace: () => 'Makes this your capital — sharply reduces corruption and stores the seat of government.',
palace: () => 'Makes this your capital — corruption and waste grow with distance from it.',
defensesea: (b) => `Multiplies this city's defense against naval attacks by ${b.value}.`,
};