feat: add Total Annihilation game with campaign, AI, and full engine

Add a complete Total Annihilation clone including:

- TotalAnnihilationGame.js: Main scene with fixed-step loop, input handling,
  camera controls, selection, orders, and match lifecycle
- TAHud.js: Resource strip with flow rates, selection info, build/production
  grid, minimap with fog of war, and toast notifications
- TAScreens.js: Main menu, skirmish setup, campaign list, pause menu, and
  victory/defeat results
- totalannihilation-campaign.json: 6 hand-authored campaign missions with
  escalating difficulty (skill 1→5)
- tools/genTACampaign.js: Campaign generator that bakes terrain from the
  seeded map generator
- tools/verifyTotalAnnihilation.js: 13-section end-to-end test suite covering
  rules integrity, artwork, economy, pathfinding, combat, fog of war,
  map generation, serialization, AI skill ladder, and campaign winnability
- tools/lib/taStubScene.js: Minimal Phaser stub for headless testing

Engine improvements:
- TALogic.js: Swept projectile collision (no tunneling), quadratic AoE
  falloff, manual weapon support (D-Gun), unrecoverable elimination, onTick
  callback for AI stepping
- TAAI.js: Skill-based economy ladders, simultaneous factory+economy growth,
  sticky focus-fire, home defense gating by skill, tune/profile overrides
- TANav.js: fitField dilation for unit clearance, stale fit mask invalidation
- TAWorldView.js: _addWorld for layer ordering, frame-accurate zoom anchor
  math, container depth sorting every frame
- TARules.js: autoRangeOf excludes manual weapons, manual flag compilation

Art & data:
- Mayan civilization artwork added to civilization-artwork.json
- Several opponents switched to mayan citySheet
- totalannihilation-rules.json: balanced unit stats, new commanderlaser weapon,
  restructured AI skill tuning, formatted JSON
- sprites.md: complete art spec for drop-in PNG sheets
- Registered in gamesRegistry, assetManifest, PreloadScene, GameRoomScene,
  and soundtrack service
This commit is contained in:
Brian Fertig 2026-07-24 20:57:40 -06:00
parent ab05e2f1cd
commit e7da172896
25 changed files with 4703 additions and 291 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View File

@ -17,6 +17,7 @@
"classic": { "key": "civilization-cities-classic", "path": "assets/images/civilization/civilization-cities-classic.png", "frameWidth": 128, "frameHeight": 96 },
"cyberpunk": { "key": "civilization-cities-cyberpunk", "path": "assets/images/civilization/civilization-cities-cyberpunk.png", "frameWidth": 128, "frameHeight": 96 },
"japanese": { "key": "civilization-cities-japanese", "path": "assets/images/civilization/civilization-cities-japanese.png", "frameWidth": 128, "frameHeight": 96 },
"steampunk": { "key": "civilization-cities-steampunk", "path": "assets/images/civilization/civilization-cities-steampunk.png", "frameWidth": 128, "frameHeight": 96 }
"steampunk": { "key": "civilization-cities-steampunk", "path": "assets/images/civilization/civilization-cities-steampunk.png", "frameWidth": 128, "frameHeight": 96 },
"mayan": { "key": "civilization-cities-mayan", "path": "assets/images/civilization/civilization-cities-mayan.png", "frameWidth": 128, "frameHeight": 96 }
}
}

View File

@ -130,7 +130,7 @@
"bronzeworking",
"masonry"
],
"citySheet": "classic",
"citySheet": "mayan",
"bio": "Wassssuppp everybody! I'm here for the party!!",
"voice": "Batman - Killer Croc",
"convo": [
@ -172,7 +172,7 @@
"warriorcode",
"thewheel"
],
"citySheet": "classic",
"citySheet": "mayan",
"bio": "Can you find your way out of my maze of puzzles, games and fun!",
"speech": {
"intro": [
@ -543,7 +543,7 @@
"warriorcode",
"masonry"
],
"citySheet": "classic",
"citySheet": "mayan",
"bio": "Hey mon! Yous be knowin' I been wantin' ta play on da Fertig Games for a while now mon!",
"voice": "Djed Spence",
"convo": [
@ -616,7 +616,7 @@
"name": "Balam",
"trait": "pioneering",
"startingTechs": [],
"citySheet": "classic",
"citySheet": "mayan",
"bio": "Mystical Powers and a knack for games.",
"speech": {
"intro": [
@ -722,7 +722,7 @@
"masonry",
"alphabet"
],
"citySheet": "classic",
"citySheet": "mayan",
"bio": "Aaaaaaargh! Me be playin' these games matey!",
"voice": "pirate",
"speech": {
@ -933,7 +933,7 @@
"ceremonialburial",
"masonry"
],
"citySheet": "classic",
"citySheet": "mayan",
"bio": "I haven't had this much fun since the Mezasoic era!",
"speech": {
"intro": [

View File

@ -0,0 +1,988 @@
{
"_comment": "Generated by tools/genTACampaign.js — terrain is baked, design is hand-authored.",
"missions": [
{
"id": "m01",
"name": "Beachhead",
"theme": "grasslands",
"seed": 10461,
"playerArmy": "arm",
"playerCommander": "vance",
"enemies": [
{
"army": "core",
"commander": "klaxon",
"aiProfile": {
"skill": 1,
"aggression": 0.25
}
}
],
"startResources": [
{
"mass": 1600,
"energy": 2600
},
{
"mass": 600,
"energy": 900
}
],
"objective": {
"type": "destroyAll"
},
"briefing": [
{
"speaker": "ethel",
"mood": "idle",
"text": "Establish a base and remove the CORE presence. Power first — nothing moves without it."
}
],
"victoryLine": {
"speaker": "ethel",
"mood": "happy",
"text": "Beachhead secured. The corridor inland is open."
},
"defeatLine": {
"speaker": "klaxon",
"mood": "smug",
"text": "The landing has been thrown back into the sea."
},
"map": {
"w": 64,
"h": 64,
"theme": "grasslands",
"rows": [
"~~..rrrr.......rrr............................rrr.......rrrr..~~",
"~~..............rr............................rr..............~~",
"~~............................................................~~",
"~~............................................................~~",
"~~.........^^^....................................^^^.........~~",
"~~.........^^^....................................^^^.........~~",
"~~.........^^^....................................^^^.........~~",
"~~.........rrr....................................rrr.........~~",
"~~............r^^.............r^^r.............^^r............~~",
"~~~........^^^^^^............r.^^.r............^^^^^^........~~~",
"~~~r......^^^^^^.............^^^^^^.............^^^^^^......r~~~",
"~~~r....^^^^^^^.............^^^^^^^^.............^^^^^^^....r~~~",
"~~~.....^^^^^^^....rr......^^^^^^^^^^......rr....^^^^^^^.....~~~",
"~~~.....^^^^^^^...rrr.......^^^^^^^^.......rrr...^^^^^^^.....~~~",
"~~.....r^^^^^^.....rrr.......^^^^^^.......rrr.....^^^^^^r.....~~",
"~~.....r.^^^^^.....rrr........^^^^........rrr.....^^^^^.r.....~~",
"~~.....rr..........r........................r..........rr.....~~",
".......rrr.........r..r..................r..r.........rrr.......",
"r.......r..........rrrrr................rrrrr..........r.......r",
"...................rrrrr................rrrrr...................",
".....................rr..................rr.....................",
"....................~~~..................~~~....................",
"...................~~~~~~rr..........rr~~~~~~...................",
".......MM......r...~~~~~~~rr........rr~~~~~~~...r......MM.......",
".......MM.....rrr.r~~~~~~~~rr......rr~~~~~~~~r.rrr.....MM.......",
"..............rr...~~~~~~...r......r...~~~~~~...rr..............",
"....................~~~.....rr....rr.....~~~....................",
"............................rr....rr............................",
"..................MM.........rrrrrr.........MM..................",
"...MM.............MM.........rrrrrr.........MM.............MM...",
"...MM.........................rrrr.........................MM...",
"..................r..........^^^^^^..........r..................",
"r..................r....^^^^^^^^^^^^^^^^....r..................r",
"r.................rr...^^^^^^^^^^^^^^^^^^...rr.................r",
".......................^^^^^^^^^^^^^^^^^^.......................",
"....................rrr.^^^^^^^^^^^^^^^^.rrr....................",
"...............MM...rrr.^^^^^^^^^^^^^^^^.rrr...MM...............",
"...............MM...rrr.^^^^^^^^^^^^^^^^.rrr...MM...............",
".....................rrr....^^^^^^^^....rrr.....................",
"rr............r.......r.....rrrrrrrr.....r.......r............rr",
"r.....~~~~.~~~...............rrrrrr...............~~~.~~~~.....r",
"r.~~~~~~~~~~~~...............rrrrrr...............~~~~~~~~~~~~.r",
"~~~~~~~~~~~~~~..............rrrrrrrr..............~~~~~~~~~~~~~~",
"~~~~~~~~~~~~~~.............rr......rr.............~~~~~~~~~~~~~~",
"~~~~~~~~~~~~~~...........rrr........rrr...........~~~~~~~~~~~~~~",
"~~~~~~~~~~~~~~..........rrrr........rrrr..........~~~~~~~~~~~~~~",
"~~~~~~~~~~~~~~...........rrr........rrr...........~~~~~~~~~~~~~~",
"~~~~~~~~~~~~~~r..................................r~~~~~~~~~~~~~~",
"...~~~~~~~~~~~....................................~~~~~~~~~~~...",
"......~~~~~~~~....................................~~~~~~~~......",
".......~~~~~~~....................................~~~~~~~.......",
".......~~~~~~~....................................~~~~~~~.......",
"........~~~~~~....................................~~~~~~........",
"........~~~~~~....................................~~~~~~........",
"..rr....~~~~~~....................................~~~~~~....rr..",
"..rr....~~~~~~......rr..rrr..........rrr..rr......~~~~~~....rr..",
"..rr....~~~~~~......rr..rrr..........rrr..rr......~~~~~~....rr..",
"........~~~~~~....................................~~~~~~........",
"........~~~~~~....................................~~~~~~........",
"........~~~~~~r..................................r~~~~~~........",
"........~~~~~~r..................................r~~~~~~........",
"........~~~~~~r..................................r~~~~~~........",
"........~~~~~~rrrr...~....................~...rrrr~~~~~~........",
"..rr...r~~~~~~..r...~~~.....r......r.....~~~...r..~~~~~~r...rr.."
],
"starts": [
{
"army": 0,
"x": 10,
"y": 32
},
{
"army": 1,
"x": 53,
"y": 32
}
],
"buildings": []
}
},
{
"id": "m02",
"name": "Metal Fields",
"theme": "grasslands",
"seed": 20773,
"playerArmy": "arm",
"playerCommander": "vance",
"enemies": [
{
"army": "core",
"commander": "klaxon",
"aiProfile": {
"skill": 2,
"aggression": 0.4
}
}
],
"startResources": [
{
"mass": 1300,
"energy": 2200
},
{
"mass": 900,
"energy": 1400
}
],
"objective": {
"type": "destroyAll"
},
"briefing": [
{
"speaker": "ethel",
"mood": "idle",
"text": "Metal patches double a generator's yield. Take them before CORE does."
}
],
"victoryLine": {
"speaker": "ethel",
"mood": "happy",
"text": "The patches are ours. Production doubles from here."
},
"defeatLine": {
"speaker": "klaxon",
"mood": "smug",
"text": "CORE holds the metal. We cannot match their output."
},
"map": {
"w": 64,
"h": 64,
"theme": "grasslands",
"rows": [
".............r............^^^^^^^^^^^^............r.............",
".......rr.................^^^^^^^^^^^^.................rr.......",
"....rrrrr.................^^^^^^^^^^^^.................rrrrr....",
"...rrrrr...................^^^^^^^^^^...................rrrrr...",
"....r......................................................r....",
"................................................................",
"...r....r..~~......................................~~..r....r...",
"..rrr..~~~~~~~....r..........................r....~~~~~~~..rrr..",
"r..r...~~~~~~~....r..........................r....~~~~~~~...r..r",
"....r.~~~~~~~~....rrrr.......rrrrrr.......rrrr....~~~~~~~~.r....",
"...~~~~~~~~~~~....rrrr.......~~~~~~.......rrrr....~~~~~~~~~~~...",
"..~~~~~~~~~~..r~~..r........~~~~~~~~........r..~~r..~~~~~~~~~~..",
"..~~~~~~~~~r..r~~~.........~~~~~~~~~~.........~~~r..r~~~~~~~~~..",
"..~~~~~~~~~...r~~~..........~~~~~~~~..........~~~r...~~~~~~~~~..",
"...~~~~~~~.....~~~............~~~~............~~~.....~~~~~~~...",
"....~~r.........~~............................~~.........r~~....",
".....r....................................................r.....",
"................................................................",
".......................rr..............rr.......................",
"..........r..rr........rr..............rr........rr..r..........",
"....rrr...^^^^r..........r............r..........r^^^^...rrr....",
"....rr.^^^^^^^^.........~~............~~.........^^^^^^^^.rr....",
"...rrr.^^^^^^^^r.......~~~~..........~~~~.......r^^^^^^^^.rrr...",
"....rrrr^^^^^^^r.......~~~~..........~~~~.......r^^^^^^^rrrr....",
".....rr....^...........~~~~..........~~~~...........^....rr.....",
".....rr.................~~............~~.................rr.....",
"........................~~............~~........................",
"................................................................",
"........MM............................................MM........",
".rr.....MM............................................MM.....rr.",
"^rr..........................................................rr^",
"^rr..........................................................rr^",
"^r............................................................r^",
"^..............................................................^",
"^.....................rr................rr.....................^",
"^.....................rr................rr.....................^",
"..............MM......rr................rr......MM..............",
"..............MM......rr................rr......MM..............",
"...................MM......................MM...................",
"...................MM......................MM...................",
"...........~~rr.rr............................rr.rr~~...........",
"...........rrrr.rr............................rr.rrrr...........",
"..........rrrrMM................................MMrrrr..........",
".........rrr..MM................................MM..rrr.........",
"..........r..........................................r..........",
"........................rrrr........rrrr........................",
"........................rrrr........rrrr........................",
".......................rrrrr........rrrrr.......................",
".......................rrrrr........rrrrr.......................",
"........................rrrr........rrrr........................",
"...........................r........r...........................",
"...........................r........r...........................",
"........................rrrr........rrrr........................",
"r.......................rrrr........rrrr.......................r",
"......rr...............rr..rr......rr..rr...............rr......",
".....rrr...............rr..rr......rr..rr...............rrr.....",
".....rrr................................................rrr.....",
"......rr................................................rr......",
"r..............................................................r",
"................................................................",
".........rrr..........rr....rr....rr....rr..........rrr.........",
".........rrr.........rrr....rr....rr....rrr.........rrr.........",
"r........rrr.........rrr................rrr.........rrr........r",
".r.r.r.rrrr~~.......rr....................rr.......~~rrrr.r.r.r."
],
"starts": [
{
"army": 0,
"x": 10,
"y": 32
},
{
"army": 1,
"x": 53,
"y": 32
}
],
"buildings": [
{
"army": 1,
"type": "energygen",
"tx": 56,
"ty": 30
}
]
}
},
{
"id": "m03",
"name": "Cold Open",
"theme": "snowfields",
"seed": 31287,
"playerArmy": "arm",
"playerCommander": "vance",
"enemies": [
{
"army": "core",
"commander": "cybro",
"aiProfile": {
"skill": 2,
"aggression": 0.6
}
}
],
"startResources": [
{
"mass": 1200,
"energy": 2000
},
{
"mass": 1100,
"energy": 1800
}
],
"objective": {
"type": "destroyAll"
},
"briefing": [
{
"speaker": "ethel",
"mood": "idle",
"text": "Cy-Bro opens with infantry. Armour answers infantry — get a Vehicle Plant up early."
}
],
"victoryLine": {
"speaker": "ethel",
"mood": "happy",
"text": "Cy-Bro's foothold is slag."
},
"defeatLine": {
"speaker": "cybro",
"mood": "smug",
"text": "Overrun before the plant finished."
},
"map": {
"w": 64,
"h": 64,
"theme": "snowfields",
"rows": [
"...rr.....~~~~........r..........rrrrr...^^^^^^^^^^^^^^^^^^^^^^^",
"...........~~~....................rrr....^^^^^^^^^^^^^^^^^^^^^^^",
"..............................~~.~~........^^^^^^^^^r...........",
"rr.........................~~...................................",
"rr.....................................................rr.......",
".......................................................rr.......",
"..............................MM..................^^^^^^^^^^....",
"..............................MM................^^^^^^^^^^^^^...",
"................................................^^^^^^^^^^^^^^..",
"r..rr............................................^^^^^^^^^^^^^r.",
"rr.rr............................................r^^^^^^^^^^^rr.",
"rr.................................................^^^^^^^^^rr..",
"...............rr...................................^^^^^^^^...r",
"...............rr............MM.....................^^^^^^^.....",
".............................MM..MM..................^^^^^r....r",
".................................MM.............................",
"...~~~..........................................................",
"..~~~~~r...........r.......................................r..rr",
"r~~~~~~~...~~~~~~.rrr.......rrrr....................rr....rrrrrr",
"~~~~~~~~~~~~~~~~~~rr........rrrr..............r.....rr....rrrrrr",
"~~~~~~~~~~~~~~~~~~~..........................rrr................",
"~~~~~~~~~~~~~~~~~~~..........................rrr................",
"~~~~~~~~~~~~~~~~~~~..........................rrrrrrrr......rr..^",
"~~~~~~~~~~~~~~~~~...........................rr.rrrrrr......rr..^",
"~~~~~~~........r.................MM.......^^^^^...rr........rr^^",
"~~~~~~~........r.................MM......^^^^^^^..rr.......rrrr^",
"~~~~~~~.................................^^^^^^^^^.rr.......rrrr^",
"~~~~~~..................................^^^^^^^^^^............rr",
"~~~~~~..........rr.....................^^^^^^^^^^^^............r",
".~~~~...........rr....................^^^^^^^^^^^^^^...........r",
".....................~~..rrr..........^^^^^^^^^^^^^^^..........r",
".....................~~..rrr..........^^^^^^^^^^^^^^^^.........r",
".....................~~..rrr..........^^^^^^^^^^^^^^^^.........r",
".....................~~..rrr..........^^^^^^^^^^^^^^^..........r",
".~~~~...........rr....................^^^^^^^^^^^^^^...........r",
"~~~~~~..........rr.....................^^^^^^^^^^^^............r",
"~~~~~~..................................^^^^^^^^^^............rr",
"~~~~~~~.................................^^^^^^^^^.rr.......rrrr^",
"~~~~~~~........r.................MM......^^^^^^^..rr.......rrrr^",
"~~~~~~~........r.................MM.......^^^^^...rr........rr^^",
"~~~~~~~~~~~~~~~~~...........................rr.rrrrrr......rr..^",
"~~~~~~~~~~~~~~~~~~~..........................rrrrrrrr......rr..^",
"~~~~~~~~~~~~~~~~~~~..........................rrr................",
"~~~~~~~~~~~~~~~~~~~..........................rrr................",
"~~~~~~~~~~~~~~~~~~rr........rrrr..............r.....rr....rrrrrr",
"r~~~~~~~...~~~~~~.rrr.......rrrr....................rr....rrrrrr",
"..~~~~~r...........r.......................................r..rr",
"...~~~..........................................................",
".................................MM.............................",
".............................MM..MM..................^^^^^r....r",
"...............rr............MM.....................^^^^^^^.....",
"...............rr...................................^^^^^^^^...r",
"rr.................................................^^^^^^^^^rr..",
"rr.rr............................................r^^^^^^^^^^^rr.",
"r..rr............................................^^^^^^^^^^^^^r.",
"................................................^^^^^^^^^^^^^^..",
"..............................MM................^^^^^^^^^^^^^...",
"..............................MM..................^^^^^^^^^^....",
".......................................................rr.......",
"rr.....................................................rr.......",
"rr.........................~~...................................",
"..............................~~.~~........^^^^^^^^^r...........",
"...........~~~....................rrr....^^^^^^^^^^^^^^^^^^^^^^^",
"...rr.....~~~~........r..........rrrrr...^^^^^^^^^^^^^^^^^^^^^^^"
],
"starts": [
{
"army": 0,
"x": 32,
"y": 10
},
{
"army": 1,
"x": 32,
"y": 53
}
],
"buildings": [
{
"army": 0,
"type": "energygen",
"tx": 35,
"ty": 8
},
{
"army": 1,
"type": "barracks",
"tx": 36,
"ty": 56
}
]
}
},
{
"id": "m04",
"name": "The Long Ridge",
"theme": "grasslands",
"seed": 44377,
"playerArmy": "arm",
"playerCommander": "vance",
"enemies": [
{
"army": "core",
"commander": "klaxon",
"aiProfile": {
"skill": 3,
"aggression": 0.5
}
}
],
"startResources": [
{
"mass": 2200,
"energy": 3600
},
{
"mass": 1300,
"energy": 2200
}
],
"objective": {
"type": "destroyAll"
},
"briefing": [
{
"speaker": "ethel",
"mood": "idle",
"text": "A fair fight on a wide map. Expand hard — whoever out-earns the other wins the third engagement."
}
],
"victoryLine": {
"speaker": "ethel",
"mood": "happy",
"text": "The ridge is held."
},
"defeatLine": {
"speaker": "klaxon",
"mood": "smug",
"text": "KLAXON out-produced us and it showed."
},
"map": {
"w": 96,
"h": 96,
"theme": "grasslands",
"rows": [
"~~~~~~~~~~~~~~~....~~~.....~~~~~~~~~..r...........rr.~~~~~~...r......r...r..............r.......",
"~~~~~~~~r~~~~~~.....~~.....~~~~~~~~~..................~~~~......................................",
"~~~........~~~rr............~~~~~~~~..................~~~~................................rrrrrr",
"~~~...........r.............~~~~~~~......................................................rrrrr..",
"~~~.........................~~~~~.................rr......................................r.....",
"~~~.......MM................~~~~r................rrrr..........................................^",
"~~~...MM..MM................~~~~r.................rr..........................rr................",
"~~~...MM..................r~~~~~..................rr..........................rrrrr.............",
".~~....................~~~~~.r...........rr....................................rrrrr............",
".~~....................r~~~..............rr......................r^^r.....rrr~~rrrrr............",
"~~......................................rrr......................^^^^.....rrr~~................^",
"~~~MM..................................r.........................^^^^........~~................^",
"~~.MM..................................rr........................^^^^........r~................^",
".......................................rrr.................r..................~r................",
"....................MM................rrrr.................rrrr..............~~~rrr.............",
"....................MM................rr....................rrrr...........~~~~~~rr.............",
"..................................rrr...........................rrr........~~~~~~~..............",
"..................................rrr............................rr.......~~~~~~~......rrrr.r...",
"..................................rrr......................................~~~~~.......rrrrrrr..",
"........~~~~~~.....................r........~~~~~~~~r..........r..~~....................rrrrr...",
".......~~~~~~~~...........rrr...^^^rr......~~~~~~~~~~~~..~~~~~~~~~~~~~~~~.......................",
"^......~~~~~~~~~..........rrrrr^^^^rr.....~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~......................",
"^^......~~~~~~~~...........rr..^^^^r....rr~~~~~~~~~~~......~~~~~~~~~~~~~~.......................",
"^^r.....~~~~~~~~~..............r^^^.....rr.~~~~~~~~..........~~~~~~~~~~~r.......................",
"^^^......~~~...~~...rr...........^......rrr.~~.................~~~~~~~..........................",
"^^^................rrr..................rrrr......................~~............................",
"^^^.............................................................................................",
"^^^r............................................................................................",
"^^^.............................................................................................",
"^^^............................................................................r...............r",
"^^^..........................................................................^^^rr..............",
"^^r....................^^.................................................r^^^^^^^^.............",
"^^.................^^^^^^^.................^^^^^^^^r.....................^^^^^^^^^^^^...........",
"^^.................^^^^^^^................^^^^^^^^^^^....................^^^^^^^^^^^^^..........",
"^^...rr............^^^^^^^................^^^^^^^^^^^....................^^^^^^^^^^^^r..........",
"^.rrrrrr.............r.^^^...rr............^^^^^^.........................^^^^^^^^^^............",
"..rrrrrr................^....rr...........................................^^^^^^^^^............r",
"...rrrr....................................................................^^^^^^^..............",
"............................................................................^^^^................",
"................................................................................rr..............",
".........rr.................................................rr.................rrr..............",
".........r~~~~.....r~~......................................rr.................rr...............",
".....~...~~~~~~~..r~~~~.....................................rrr................rrr.....~~~~~~...",
"....~~~~~~~~~~~~~.~~~~~.........................r....~~~~~~~~~~~~...............rrr...~~~~~~~~~.",
"....~~~~~~~~~~~~~~~~~~~~.....................rrrrr.~~~~~~~~~~~~~~~~~............rrrrrr~~~~~~~~~~",
"...r~~~~~~~~~~~~~~~~~~~~....................rrrrrrr~~~~~~~~~~~~~~~~~............rrrr..~~~~~~~~~~",
"...r~~~~~~~~~~~~~~~~~~~~...................rr..rrrr~~~~~~~~~~~r.................rrr....~~~~~~~~.",
"...r~~~~~~~~~~~~~~~~~~~~.............r....rr....r...~~~~~~~~rrr..........r.............~~~~~~r..",
"..r~~~~~~.............r..........rrr~~~~~~~~...r....rr....r.............~~~~~~~~~~~~~~~~~~~~r...",
".~~~~~~~~....rrr.................r~~~~~~~~~~~rrrr..rr...................~~~~~~~~~~~~~~~~~~~~r...",
"~~~~~~~~~~..rrrr............~~~~~~~~~~~~~~~~~rrrrrrr....................~~~~~~~~~~~~~~~~~~~~r...",
"~~~~~~~~~~rrrrrr............~~~~~~~~~~~~~~~~~.rrrrr.....................~~~~~~~~~~~~~~~~~~~~....",
".~~~~~~~~~...rrr...............~~~~~~~~~~~~....r.........................~~~~~.~~~~~~~~~~~~~....",
"...~~~~~~.....rrr................rrr.....................................~~~~r..~~~~~~~...~.....",
"...............rr.................rr......................................~~r.....~~~~r.........",
"..............rrr.................rr.................................................rr.........",
"..............rr................................................................................",
"................^^^^............................................................................",
"..............^^^^^^^....................................................................rrrr...",
"r............^^^^^^^^^...........................................rr....^................rrrrrr..",
"............^^^^^^^^^^.........................^^^^^^............rr...^^^.r.............rrrrrr.^",
"..........r^^^^^^^^^^^^....................^^^^^^^^^^^................^^^^^^^............rr...^^",
"..........^^^^^^^^^^^^^....................^^^^^^^^^^^................^^^^^^^.................^^",
"...........^^^^^^^^^^^^.....................r^^^^^^^^.................^^^^^^^.................^^",
".............^^^^^^^^r.................................................^^....................r^^",
"..............rr^^^..........................................................................^^^",
"r...............r............................................................................^^^",
".............................................................................................^^^",
"............................................................................................r^^^",
".............................................................................................^^^",
"............................~~......................rrrr..................rrr................^^^",
"..........................~~~~~~~.................~~.rrr......^...........rr...~~...~~~......^^^",
".......................r~~~~~~~~~~~..........~~~~~~~~.rr.....^^^r..............~~~~~~~~~.....r^^",
".......................~~~~~~~~~~~~~~......~~~~~~~~~~~rr....r^^^^..rr...........~~~~~~~~......^^",
"......................~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.....rr^^^^rrrrr..........~~~~~~~~~......^",
".......................~~~~~~~~~~~~~~~~..~~~~~~~~~~~~......rr^^^...rrr...........~~~~~~~~.......",
"...rrrrr....................~~..r..........r~~~~~~~~........r.....................~~~~~~........",
"..rrrrrrr.......~~~~~......................................rrr..................................",
"...r.rrrr......~~~~~~~.......rr............................rrr..................................",
"..............~~~~~~~........rrr...........................rrr..................................",
".............rr~~~~~~...........rrrr....................rr................MM....................",
".............rrr~~~..............rrrr.................rrrr................MM....................",
"................r~..................r.................rrr.......................................",
"^................~r........^^^^........................rr..................................MM.~~",
"^................~~........^^^^.........................r..................................MM~~~",
"^................~~rrr.....^^^^......................rrr......................................~~",
"............rrrrr~~rrr.....r^^r......................rr..............~~~r....................~~.",
"............rrrrr....................................rr...........r.~~~~~....................~~.",
".............rrrrr..........................rr..................~~~~~r..................MM...~~~",
"................rr..........................rr.................r~~~~................MM..MM...~~~",
"^..........................................rrrr................r~~~~................MM.......~~~",
".....r......................................rr.................~~~~~.........................~~~",
"..rrrrr......................................................~~~~~~~.............r...........~~~",
"rrrrrr................................~~~~..................~~~~~~~~............rr~~~........~~~",
"......................................~~~~..................~~~~~~~~~.....~~.....~~~~~~r~~~~~~~~",
".......r..............r...r......r...~~~~~~.rr...........r..~~~~~~~~~.....~~~....~~~~~~~~~~~~~~~"
],
"starts": [
{
"army": 0,
"x": 10,
"y": 10
},
{
"army": 1,
"x": 85,
"y": 85
}
],
"buildings": [
{
"army": 0,
"type": "energygen",
"tx": 13,
"ty": 8
},
{
"army": 0,
"type": "energygen",
"tx": 7,
"ty": 8
}
]
}
},
{
"id": "m05",
"name": "Tropic Vice",
"theme": "tropics",
"seed": 55930,
"playerArmy": "arm",
"playerCommander": "vance",
"enemies": [
{
"army": "core",
"commander": "cybro",
"aiProfile": {
"skill": 4,
"aggression": 0.7
}
}
],
"startResources": [
{
"mass": 2600,
"energy": 4200
},
{
"mass": 1500,
"energy": 2600
}
],
"objective": {
"type": "destroyAll"
},
"briefing": [
{
"speaker": "ethel",
"mood": "idle",
"text": "Cy-Bro starts ahead and pushes early. Rocket tanks out-range everything CORE fields."
}
],
"victoryLine": {
"speaker": "ethel",
"mood": "happy",
"text": "Cy-Bro is scrap on the sand."
},
"defeatLine": {
"speaker": "cybro",
"mood": "smug",
"text": "The push came faster than the plant did."
},
"map": {
"w": 96,
"h": 96,
"theme": "tropics",
"rows": [
"~~~..........r.....~~~~~~~~~~~~~~~.rr.rr................rr.rr.~~~~~~~~~~~~~~~.....r..........~~~",
"~~.................~~~~~~~~~~~~~~~.rrrrr................rrrrr.~~~~~~~~~~~~~~~.................~~",
"~~..................~~~~..r~~~~~~~~..........................~~~~~~~~r..~~~~..................~~",
"~.....................r........~~~~..........................~~~~........r.....................~",
"...............................~~~~~........................~~~~~...............................",
".......................r.......~~~~~........................~~~~~.......r.......................",
"......................rr.......~~~~~........................~~~~~.......rr......................",
"......................rr.......~~~~~........................~~~~~.......rr......................",
"....................rrrr.......~~~~~........................~~~~~.......rrrr....................",
"...............rrr^^^^r........~~~~~........................~~~~~........r^^^^rrr...............",
"...............r^^^^^^^^.......~~~~~~r....................r~~~~~~.......^^^^^^^^r...............",
"..............rr^^^^^^^^r......~~~~~~~~~~~~~~~~..~~~~~~~~~~~~~~~~......r^^^^^^^^rr..............",
".............rrr^^^^^^^^.......~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.......^^^^^^^^rrr.............",
".............rrr^^^^^^^........~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~........^^^^^^^rrr.............",
"................^^^^............~~~~~~~~~~~~~~~rr~~~~~~~~~~~~~~~............^^^^................",
".................r..............rr..~~~r...r........r...r~~~..rr..............r.................",
"...............................................rr...............................................",
"r..r...........................................rr...........................................r..r",
"..rrr..rr.^..........................................................................^.rr..rrr..",
"~~.r...rrrr^.................r....................................r.................^rrrr...r.~~",
"~~~.rrr..rr^^................r....................................r................^^rr..rrr.~~~",
"~~~..rr..r^^^^....................................................................^^^^r..rr..~~~",
"~~~......^^^^^....................................................................^^^^^......~~~",
"~~~.....^^^^^^^rr..............................................................rr^^^^^^^.....~~~",
"........^^^^^^^^^..............................................................^^^^^^^^^........",
"........^^^^^^^^^..............................................................^^^^^^^^^........",
".......r^^^^r.^^^..............................................................^^^.r^^^^r.......",
".......r^^^rr.r^^^............................................................^^^r.rr^^^r.......",
".......r^^rrrr..^^^^^^..r..............rr..............rr..............r..^^^^^^..rrrr^^r.......",
"........^...r...^^^^^^^^rr.............rrr.....rr.....rrr.............rr^^^^^^^^...r...^........",
"^^..............^^^^^^^^^r......~~~~...rrrrr^^.rr.^^rrrrr...~~~~......r^^^^^^^^^..............^^",
"^^^.............^^^^^^^^^......~~~~~...rrr^^^^^^^^^^^^rrr...~~~~~......^^^^^^^^^.............^^^",
"^^^^^^....rrr....^^^^^^^^^....~~~~~~.rr..^^^^^^^^^^^^^^..rr.~~~~~~....^^^^^^^^^....rrr....^^^^^^",
"^^^^^^r..rrrr....^^^^^^^^^...r~~~~~~rr...^^^^^^^^^^^^^^...rr~~~~~~r...^^^^^^^^^....rrrr..r^^^^^^",
"^^^^^^...rrrr.....^^^^^^^.....~~~~~~r....^^^^^^^^^^^^^^....r~~~~~~.....^^^^^^^.....rrrr...^^^^^^",
"^^^^r..rr..r.....rr^^^^^.......~~~~~.....^^^^^^^^^^^^^^.....~~~~~.......^^^^^rr.....r..rr..r^^^^",
"^^^r..rrr.......rrr^^^^^........~~~.......^^^^^^^^^^^^.......~~~........^^^^^rrr.......rrr..r^^^",
"^^..rrrr.........r.^^^^r.........~........^^^^^^^^^^^^........~.........r^^^^.r.........rrrr..^^",
"^r.rrrrr........rr.^^^^..........rrr.......^^^^^^^^^^.......rrr..........^^^^.rr........rrrrr.r^",
"..rrrrrr.......rrr..^^^..........rr........r........r........rr..........^^^..rrr.......rrrrrr..",
"....rrrr......rrrr..^^...........rr........rr......rr........rr...........^^..rrrr......rrrr....",
"...............rrr...............rr........rr......rr........rr...............rrr...............",
"................................................................................................",
"................................................................................................",
"................................................................................................",
"................................................................................................",
".............................rrr................................rrr.............................",
"...................MM........rrr................................rrr........MM...................",
"...................MM.........rrr..............................rrr.........MM...................",
"................................r..............................r................................",
"................................................................................................",
"................................................................................................",
".....MM.................~~................~~~~~~~~~~~~................~~.................MM.....",
".....MM................~~~~.............~~~~~~~~~~~~~~~~.............~~~~................MM.....",
".......................~~~~.............~~~~~~~~~~~~~~~~.............~~~~.......................",
"......................r~~~~............~~~~~~~~~~~~~~~~~~............~~~~r......................",
"........MM....MM......r~~~~............~~~~~~~~~~~~~~~~~~............~~~~r......MM....MM........",
"........MM....MM......r~~~~............r~~~~~~~~~~~~~~~~r............~~~~r......MM....MM........",
"r........rrr............~~~...............~~~~~~~~~~~~...............~~~............rrr........r",
".........rrr............~~............................................~~............rrr.........",
"..........r..........................................................................r..........",
".........~~~~~....................................................................~~~~~.........",
"........~~~~~~....................................................................~~~~~~........",
"........~~~~~~....r..........................................................r....~~~~~~........",
"^....rr.~~~~~~~...rr^^^..................................................^^^rr...~~~~~~~.rr....^",
"^^...rr.~~~~~~~...rr^^^...................^^^^^^^^^^^^...................^^^rr...~~~~~~~.rr...^^",
"^^......~~~~~~~...rr^^^^.................^^^^^^^^^^^^^^.................^^^^rr...~~~~~~~......^^",
"^^......~~~~~~~r....^^^^.................^^^^^^^^^^^^^^.................^^^^....r~~~~~~~......^^",
"^^......~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~......^^",
"^^r.....~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~.....r^^",
"^^......~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~......^^",
"^^.....~~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~~.....^^",
"^^....r~~~~~~~~.....^^^...................^^^^^^^^^^^^...................^^^.....~~~~~~~~r....^^",
"^.r..rr~~~~~~~~.....^^^...................^^^^^^^^^^^^...................^^^.....~~~~~~~~rr..r.^",
"^.rrr..~~~~~~~~.....^^^.............rr....^^^^^^^^^^^^....rr.............^^^.....~~~~~~~~..rrr.^",
"..rrr..~~~~~~~~....r^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^r....~~~~~~~~..rrr..",
"..rrr..~~~~~~~~.....^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^.....~~~~~~~~..rrr..",
"..rrr..~~~~~~~~~...r^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^r...~~~~~~~~~..rrr..",
"..rrr..~~~~~~~~~....^^^..............rrrrr^^^^^^^^^^^^rrrrr..............^^^....~~~~~~~~~..rrr..",
"..rrr..~~~~~~~~.....^^^...............rrrr^^^^^^^^^^^^rrrr...............^^^.....~~~~~~~~..rrr..",
"..rrr..~~~~~~~~.....^^^...............rrr.^^^^^^^^^^^^.rrr...............^^^.....~~~~~~~~..rrr..",
"^.rrr...~~~~~~~.....^^^................r..^^^^^^^^^^^^..r................^^^.....~~~~~~~...rrr.^",
"^^rrr....~~~~~......^^....................^^^^^^^^^^^^....................^^......~~~~~....rrr^^",
"^^r.......................................^^^^^^^^^^^^.......................................r^^",
"^^^.......................................^^^^^^^^^^^^.......................................^^^",
"^^^.........................................^......^.........................................^^^",
"^^^^.........................^^^^^^^^^^^................^^^^^^^^^^^.........................^^^^",
"^^^^........................^^^^^^^^^^^^^..............^^^^^^^^^^^^^........................^^^^",
"^^^^^.......................^^^^^^^^^^^^^..............^^^^^^^^^^^^^.......................^^^^^",
"^^^^^.......................^^^^^^^^^^^^^.....r..r.....^^^^^^^^^^^^^.......................^^^^^",
"^^^^^.......................^^^^^^^^^^^^^....rrrrrr....^^^^^^^^^^^^^.......................^^^^^",
"^^^^.........................^^^^^^^^^^......rrrrrr......^^^^^^^^^^.........................^^^^",
"^^r...............................r..........rrrrrr..........r...............................r^^",
".r.....................r................................................r.....................r.",
".............rr.......rr~~............................................~~rr.......rr.............",
"r........rrrrrrr.......~~~~.......r..........................r.......~~~~.......rrrrrrr........r"
],
"starts": [
{
"army": 0,
"x": 10,
"y": 48
},
{
"army": 1,
"x": 85,
"y": 48
}
],
"buildings": [
{
"army": 0,
"type": "energygen",
"tx": 13,
"ty": 46
},
{
"army": 0,
"type": "energygen",
"tx": 7,
"ty": 46
},
{
"army": 0,
"type": "barracks",
"tx": 14,
"ty": 51
},
{
"army": 1,
"type": "energygen",
"tx": 88,
"ty": 50
}
]
}
},
{
"id": "m06",
"name": "Annihilation",
"theme": "snowfields",
"seed": 63541,
"playerArmy": "arm",
"playerCommander": "vance",
"enemies": [
{
"army": "core",
"commander": "klaxon",
"aiProfile": {
"skill": 5,
"aggression": 0.85
}
}
],
"startResources": [
{
"mass": 3200,
"energy": 5200
},
{
"mass": 1800,
"energy": 3000
}
],
"objective": {
"type": "destroyAll"
},
"briefing": [
{
"speaker": "ethel",
"mood": "idle",
"text": "KLAXON at full strength with a base already standing. Nothing clever left — out-build it and end it."
}
],
"victoryLine": {
"speaker": "ethel",
"mood": "happy",
"text": "Total annihilation. There is nothing left to fight."
},
"defeatLine": {
"speaker": "klaxon",
"mood": "smug",
"text": "KLAXON was simply better prepared."
},
"map": {
"w": 96,
"h": 96,
"theme": "snowfields",
"rows": [
"................^^^...^^^^^.............^^^^^^.......r...rr....r.........r....r...........rrr...",
"..............MM.^^..r^^^^^..............^^^^^............................................rrr...",
"..............MM......^^^^^^...............rr..............................................rr...",
"......................^^^^^^..............r...............................................rrr...",
"....................................................................rrr...................rrr...",
"........................rr..........................................rrrr.....................r..",
"........................rr.........................................^..rr.......rr............rr.",
"................................................................r^^^^^..........rr...........rr.",
".......................~~~.......................................^^^^^^.......................rr",
"......................~~~~~.....................................^^^^^^^^........................",
"......................~~~~~......................................^^^^^^^........................",
"......................~~~~.....rr..............................rrrr...........................rr",
".......................~~......rr...........................rrrrr.......................rr....rr",
"...............................r...rr.................rr....rrrr.........................r....rr",
"................................rrrrr................rrr.....rrr................................",
"...............MM................rrrr..............rrrr.......rrr...............................",
"...............MM...............rrr...............rrrrr...rr..rr................................",
"......r..........................rr..r............rrrr....rrrrrr................rrr............r",
"r....MM.............................rrr.........................................rrr.............",
".....MM..............................rr...........................~~~~~~~~~~~~~~~~~.............",
".......................^^^^..rr......rr...r~~~..................~~~~~~~~~~~~~~~~~~~~............",
".........MM...........^^^^^.rrr......rr...~~~~~................~~~~~~~~~~~~~~~~~~~~~~~..........",
".........MM.........^^^^^^^rrrr..........~~~~~~~...............~~~~~~~~~~~~~~~~~~~~~~~~..rr....r",
"^^................r^^^^^rr...rr..........~~~~~~~...............~~~~~~~~~~~~~~~~~~~~~~~~.rrrrrrrr",
"^^^...............^^^^^..................~~~~~~~.......rr......~~~~~~~~~~~~~~~~~~~~~~~~~.rrrr...",
"^^^................^^^^...................~~~~~........rr.......~~~~~~~~~~~~~~~~~~~~~~~~~~......",
"r..................^^^^.........................................~~~~~~~~~~~~~~~~~~~~~~~~~~~~....",
"r..................rr^............................................~~~~~~~~~~~~~~~~~~~~~~~~~~~~..",
".......................................................................~~~~~~~~~~~~~~~~~~~~~~~~.",
"...rr..................rr.................................................~~~~~~~~~~~~~~~~~~~~~~",
"r..rr..................rrr.......................................................~~~~~~~~~~~~~~~",
"r..rr..................rrrr........................................................~~~~~~~~~~~~~",
"...rr....................rr..........rr....................r........................~~~~~~~~~~~~",
"..........................rr.........rr...................rrr.......................~~~~~~~~~~~~",
"..........................rr......................rr.......r........................r~~~~~~~~~~~",
".........................rrrr...rr................rr.................................r~~~~~~~~~~",
".........................rrrr...rr..r.............rr.................................rrr~~~~~~..",
".........................r.r.....rrrrrr.............................................rrrrrrrrr...",
".................................rr..rr................^^...........................rr.rr.......",
"................................rr...................^^^^^..rr..................................",
".....rrr............~~..........rr..............^^^^^^^^^^..rr..................................",
".....rrr.......rr...~~..........rr........^^^^^^^^^^^^^^^^..rr..................................",
".......rr......rr..~~~..........rr.......^^^^^^^^^^^^^^^^^..rr..................................",
"........rr.........~~~..................^^^^^^^^^^^^^^^^^^..rrrr..............^.................",
"........rr.........~~~~.................^^^^^^^^^^^^^^^^^^^.rrrr.............^^^................",
"...................~~~~................^^^^^^^^^^^^^^^^^^^..rrr..............^^^rrr......rrrrrrr",
"...................~~~..................^^^^^^^^^^^^^^^^^^..rr...............^^^rrr......rrrrrrr",
"....................~~..................^^^^^^^^^^^^^^^^......................^................r",
"r................^......................^^^^^^^^^^^^^^^^..................~~....................",
"rrrrrrr......rrr^^^...............rr..^^^^^^^^^^^^^^^^^^..................~~~...................",
"rrrrrrr......rrr^^^..............rrr..^^^^^^^^^^^^^^^^^^^................~~~~...................",
"................^^^.............rrrr.^^^^^^^^^^^^^^^^^^^.................~~~~.........rr........",
".................^..............rrrr..^^^^^^^^^^^^^^^^^^..................~~~.........rr........",
"..................................rr..^^^^^^^^^^^^^^^^^.......rr..........~~~..rr......rr.......",
"..................................rr..^^^^^^^^^^^^^^^^........rr..........~~...rr.......rrr.....",
"..................................rr..^^^^^^^^^^..............rr..........~~............rrr.....",
"..................................rr..^^^^^...................rr................................",
".......rr.rr...........................^^................rr..rr.................................",
"...rrrrrrrrr.............................................rrrrrr.....r.r.........................",
"..~~~~~~rrr.................................rr.............r..rr...rrrr.........................",
"~~~~~~~~~~r.................................rr................rr...rrrr.........................",
"~~~~~~~~~~~r........................r.......rr......................rr..........................",
"~~~~~~~~~~~~.......................rrr...................rr.........rr..........................",
"~~~~~~~~~~~~........................r....................rr..........rr....................rr...",
"~~~~~~~~~~~~~........................................................rrrr..................rr..r",
"~~~~~~~~~~~~~~~.......................................................rrr..................rr..r",
"~~~~~~~~~~~~~~~~~~~~~~.................................................rr..................rr...",
".~~~~~~~~~~~~~~~~~~~~~~~~.......................................................................",
"..~~~~~~~~~~~~~~~~~~~~~~~~~~~~............................................^rr..................r",
"....~~~~~~~~~~~~~~~~~~~~~~~~~~~~.........................................^^^^..................r",
"......~~~~~~~~~~~~~~~~~~~~~~~~~~.......rr........~~~~~...................^^^^................^^^",
"...rrrr.~~~~~~~~~~~~~~~~~~~~~~~~~......rr.......~~~~~~~..................^^^^^...............^^^",
"rrrrrrrr.~~~~~~~~~~~~~~~~~~~~~~~~...............~~~~~~~..........rr...rr^^^^^r................^^",
"r....rr..~~~~~~~~~~~~~~~~~~~~~~~~...............~~~~~~~..........rrrr^^^^^^^.........MM.........",
"..........~~~~~~~~~~~~~~~~~~~~~~~................~~~~~...rr......rrr.^^^^^...........MM.........",
"............~~~~~~~~~~~~~~~~~~~~..................~~~r...rr......rr..^^^^.......................",
".............~~~~~~~~~~~~~~~~~...........................rr..............................MM.....",
".............rrr.........................................rrr.............................MM....r",
"r............rrr................rrrrrr....rrrr............r..rr..........................r......",
"................................rr..rr...rrrrr...............rrr...............MM...............",
"...............................rrr.......rrrr..............rrrr................MM...............",
"................................rrr.....rrr................rrrrr................................",
"rr....r.........................rrrr....rr.................rr...r...............................",
"rr....rr.......................rrrrr...........................rr......~~.......................",
"rr...........................rrrr..............................rr.....~~~~......................",
"........................^^^^^^^......................................~~~~~......................",
"........................^^^^^^^^.....................................~~~~~......................",
"rr.......................^^^^^^.......................................~~~.......................",
".rr...........rr..........^^^^^r................................................................",
".rr............rr.......rr..^.........................................rr........................",
"..r.....................rrrr..........................................rr........................",
"...rrr...................rrr....................................................................",
"...rrr...............................................r..............^^^^^^......................",
"...rr..............................................rr...............^^^^^^......MM..............",
"...rrr............................................^^^^^..............^^^^^r..^^.MM..............",
"...rrr...........r....r.........r....rr...r.......^^^^^^.............^^^^^...^^^................"
],
"starts": [
{
"army": 0,
"x": 10,
"y": 10
},
{
"army": 1,
"x": 85,
"y": 85
}
],
"buildings": [
{
"army": 0,
"type": "energygen",
"tx": 13,
"ty": 7
},
{
"army": 0,
"type": "energygen",
"tx": 7,
"ty": 7
},
{
"army": 0,
"type": "barracks",
"tx": 14,
"ty": 13
},
{
"army": 0,
"type": "vehicleplant",
"tx": 6,
"ty": 13
},
{
"army": 1,
"type": "energygen",
"tx": 88,
"ty": 82
},
{
"army": 1,
"type": "barracks",
"tx": 89,
"ty": 88
}
]
}
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -233,6 +233,23 @@ export const MANIFEST = {
// round begins; anything missing just never plays.
(scene) => musicFrom(scene, 'nintendo-music'),
],
// Rules always load; every unit/structure/terrain sheet is an optional drop-in
// declared in data/totalannihilation-artwork.json (spec: src/games/
// totalannihilation/sprites.md) — each is path:null today and paints
// procedurally, so the game is fully playable with zero PNGs. `sheets` is a MAP
// of sheets rather than a list of field names, which is why one resolver line
// covers every sheet ever added: sheetsFrom() Object.values() anything without a
// `key`. Uses the hacker soundtrack (see services/soundtrack.js).
totalannihilation: [
{ type: 'json', key: 'totalannihilation-rules', path: 'data/totalannihilation-rules.json' },
{ type: 'json', key: 'totalannihilation-campaign', path: 'data/totalannihilation-campaign.json' },
(scene) => sheetsFrom(scene, 'totalannihilation-artwork', ['sheets']),
// Weapon sounds keyed by each weapon's `sound` in totalannihilation-rules.json.
...['gunfire-modern', 'tank', 'missle'].map((n) => ({ type: 'audio', key: `sfx-battle-${n}`, path: `assets/fx/battle-${n}.mp3` })),
{ type: 'audio', key: 'sfx-laser-zap', path: 'assets/fx/laser-zap.mp3' },
{ type: 'audio', key: 'sfx-scifi-launch', path: 'assets/fx/scifi-launch.mp3' },
(scene) => musicFrom(scene, 'hacker-music'),
],
coloradodefense: [
// arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here
// so its audio only downloads once Colorado Defense is actually entered.

View File

@ -115,3 +115,4 @@ registerGame({ slug: 'tempest', name: 'Tempest', category: 'arcade-console-pc',
registerGame({ slug: 'superkart', name: 'Super Kart', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 85 });
registerGame({ slug: 'advancewars', name: 'Advance Wars', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 86 });
registerGame({ slug: 'tetrisattack', name: 'Tetris Attack', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 87 });
registerGame({ slug: 'totalannihilation', name: 'Total Annihilation', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 88 });

View File

@ -25,7 +25,7 @@ function memFor(state, armyIdx) {
m = state.aiMem[armyIdx] = {
nextThinkTick: 0,
ordersThisSecond: 0, secondMark: 0,
squad: [], squadPeak: 0, phase: 'build',
squad: [], squadPeak: 0, phase: 'build', focusId: 0,
scoutId: 0,
knownEnemies: [], // [{x,y,defId,isBuilding,tick}]
lastAttackTick: -99999,
@ -46,7 +46,10 @@ export function runAI(rules, state, armyIdx, profile = {}) {
if (!army || !army.alive) return;
const skill = Math.max(1, Math.min(5, profile.skill ?? army.aiSkill ?? 3));
const tuning = rules.aiBySkill[skill] ?? rules.aiSkills[rules.aiSkills.length - 1];
const base = rules.aiBySkill[skill] ?? rules.aiSkills[rules.aiSkills.length - 1];
// `tune` lets a mission (or a tuning experiment) override individual dials without
// inventing a whole new skill row.
const tuning = profile.tune ? { ...base, ...profile.tune } : base;
const aggression = profile.aggression ?? army.aiProfile?.aggression ?? 0.5;
// Economic ambition is primarily a SKILL trait — tuning.expansion runs 0.6..3.0 across
// skills 1-5, normalised here to 0.2..1.0. A commander's own aiProfile only nudges it,
@ -68,7 +71,7 @@ export function runAI(rules, state, armyIdx, profile = {}) {
mem.ordersThisSecond = 0;
}
const ctx = {
rules, state, armyIdx, army, skill, tuning, aggression, expansion, mem,
rules, state, armyIdx, army, skill, tuning, aggression, expansion, mem, profile,
budget: Math.max(1, tuning.apmCap),
};
@ -165,7 +168,7 @@ function manageEconomyAndBase(ctx, mine) {
}
function chooseBuilding(ctx, mine) {
const { rules, army, mem, expansion } = ctx;
const { rules, army, mem, expansion, skill } = ctx;
const n = (id) => (mine.byDef[id] ?? 0);
const eGen = rules.buildingById.energygen;
const mGen = rules.buildingById.massgen;
@ -180,6 +183,19 @@ function chooseBuilding(ctx, mine) {
if (n('massgen') < 2) return mGen;
if (n('vehicleplant') < 1) return plant;
// Skills 1-2 stop here: a short fixed ladder and no governor at all. Without this the
// bottom of the ladder still runs the same ratio-aware economy as the top and plays a
// perfectly respectable game, which is why difficulty barely registered.
if (skill <= 2) {
const lim = skill === 1 ? 1 : 2;
if (n('energygen') < 3 + lim) return eGen;
if (n('massgen') < 2 + lim) return mGen;
if (n('barracks') + n('vehicleplant') < 2 + lim) {
return n('vehicleplant') <= n('barracks') ? plant : barracks;
}
return null;
}
// Steady state: chase the energy:mass income RATIO that the unit roster actually costs.
// A Tank is 700 energy to 180 mass, so roughly 4:1 — an AI that treats the two resources
// symmetrically ends up capping out on energy while its factories starve for mass.
@ -196,19 +212,33 @@ function chooseBuilding(ctx, mine) {
if (army.stallM < 0.98 && roomM) return mGen;
if (army.stallE < 0.98 && roomE) return eGen;
// Production capacity is grown ALONGSIDE the economy, sized to what the income can
// actually feed. Saturating generators first (the obvious ordering) is why a big economy
// used to win nothing: with only two factories the surplus just floated in storage, and an
// army that out-earned its opponent two to one still fielded the same number of tanks.
const factories = n('barracks') + n('vehicleplant');
const nextFactory = () => (n('vehicleplant') <= n('barracks') ? plant : barracks);
const factoryCap = Math.round(2 + expansion * 4);
const supported = Math.floor(army.mIncome / Math.max(0.5, factoryMassDrain(rules, plant)));
if (factories < Math.min(factoryCap, Math.max(1, supported))) return nextFactory();
if (ratio > TARGET_RATIO && roomM) return mGen;
if (ratio < TARGET_RATIO && roomE) return eGen;
if (roomM) return mGen;
if (roomE) return eGen;
// Economy is built out — convert the surplus into production capacity.
const factories = n('barracks') + n('vehicleplant');
if (factories < Math.round(2 + expansion * 3)) {
return n('vehicleplant') <= n('barracks') ? plant : barracks;
}
if (factories < factoryCap) return nextFactory();
return null;
}
/** Mass per second a factory of this type burns when kept busy. */
function factoryMassDrain(rules, def) {
const units = (def.builds ?? []).map((id) => rules.unitById[id]).filter(Boolean);
if (!units.length) return 1;
const avg = units.reduce((s, u) => s + u.cost.mass / u.buildTime, 0) / units.length;
return avg * (def.buildPower / rules.constants.buildPowerNominal);
}
/**
* Spiral outward from the base for a legal footprint. Mass Generators additionally hunt for
* a metal patch, which is what makes map control matter without a reclaim economy.
@ -301,8 +331,8 @@ function randomPick(ctx, builds) {
/** Pick whichever buildable unit is furthest below its share of the desired mix. */
function templatePick(ctx, mine, builds) {
const { rules, skill } = ctx;
const weights = { ...COMPOSITION };
const { rules, skill, profile } = ctx;
const weights = { ...COMPOSITION, ...(profile.composition ?? {}) };
if (skill >= 4) applyCounters(ctx, weights);
const total = builds.reduce((s, id) => s + (mine.byDef[id] ?? 0), 0) + 1;
@ -424,8 +454,8 @@ function manageMilitary(ctx, mine) {
mem.squad = mem.squad.filter((id) => state.entities.some((e) => e.id === id && !e.dead));
for (const u of idle) if (!mem.squad.includes(u.id)) mem.squad.push(u.id);
// Home defence always wins over pushing out.
const threat = nearestThreatToBase(ctx);
// Home defence always wins over pushing out — skill 1 never thinks to come home.
const threat = skill >= 2 ? nearestThreatToBase(ctx) : null;
if (threat) {
const defenders = mem.squad.slice(0, Math.max(3, Math.ceil(mem.squad.length * 0.7)));
if (defenders.length) {
@ -507,27 +537,40 @@ function focusFire(ctx, mine) {
.filter(Boolean);
if (members.length < 3) return;
const cx = members.reduce((s, u) => s + u.x, 0) / members.length;
const cy = members.reduce((s, u) => s + u.y, 0) / members.length;
// An `attack` order REPLACES the unit's queue and pins its target, so re-picking one every
// think interrupts the push and leaves the squad ignoring whatever is actually shooting it.
// Focus fire is therefore sticky: hold the current victim until it dies or gets out of
// reach, and only ever commit to something the squad can already shoot without moving.
const focus = state.entities.find((e) => e.id === mem.focusId && !e.dead);
if (focus && isVisibleTo(state, armyIdx, focus)) {
const stillOn = members.filter((u) => inReach(rules, u, focus)).length;
if (stillOn >= 2) return; // current focus is fine; don't churn orders
}
mem.focusId = 0;
let best = null, bestScore = -Infinity;
let best = null, bestScore = -Infinity, bestShooters = null;
for (const e of state.entities) {
if (e.dead || e.army === armyIdx || !state.armies[e.army]) continue;
if (!isVisibleTo(state, armyIdx, e)) continue;
const d = Math.hypot(e.x - cx, e.y - cy);
if (d > state.tileSize * 9) continue;
const shooters = members.filter((u) => inReach(rules, u, e));
if (shooters.length < Math.max(2, Math.ceil(members.length * 0.4))) continue;
const def = rules.defById[e.defId];
// Prefer the closest thing to dying that can still shoot back.
const score = (def.weaponDefs?.length ? 2 : 1) * (1 - e.hp / e.maxHp + 0.3) * (1 - d / (state.tileSize * 9));
if (score > bestScore) { best = e; bestScore = score; }
const score = (def.weaponDefs?.length ? 2 : 1) * (1.3 - e.hp / e.maxHp) * shooters.length;
if (score > bestScore) { best = e; bestScore = score; bestShooters = shooters; }
}
if (!best) return;
const shooters = members.filter((u) => Math.hypot(u.x - best.x, u.y - best.y) < rules.defById[u.defId].maxRange * 1.5);
if (shooters.length < 2) return;
mem.focusId = best.id;
order(ctx, {
army: armyIdx, unitIds: shooters.map((u) => u.id),
army: armyIdx, unitIds: bestShooters.map((u) => u.id),
order: { type: 'attack', targetId: best.id },
});
}
/** True when `u` could fire on `t` from where it already stands. */
function inReach(rules, u, t) {
const def = rules.defById[u.defId];
return Math.hypot(u.x - t.x, u.y - t.y) - t.radius <= def.maxRange;
}
export { memFor };

View File

@ -32,7 +32,18 @@ function frameRng(seed) {
* Create a canvas texture laid out as a spritesheet and register its frames.
* `at(frame, draw)` translates the context to that frame's origin; `finish()` uploads.
*/
function mkCanvasSheet(scene, key, frameW, frameH, cols, count) {
function mkCanvasSheet(scene, key, wantW, wantH, wantCols, wantCount) {
// Guard every dimension. A NaN anywhere here reaches createCanvas as a zero-height texture
// and surfaces as an opaque WebGL "no canvas" error a long way from the actual mistake.
const size = (v, fallback) => (Number.isFinite(v) && v > 0 ? Math.floor(v) : fallback);
const frameW = size(wantW, 64);
const frameH = size(wantH, 64);
const cols = size(wantCols, 8);
const count = size(wantCount, 1);
if (frameW !== wantW || frameH !== wantH || cols !== wantCols || count !== wantCount) {
console.warn(`[TAArt] sheet "${key}" has a bad layout (${wantW}x${wantH}, cols ${wantCols}, `
+ `frames ${wantCount}) — falling back to ${frameW}x${frameH}, cols ${cols}, frames ${count}`);
}
const rows = Math.max(1, Math.ceil(count / cols));
const tex = scene.textures.createCanvas(key, cols * frameW, rows * frameH);
const ctx = tex.getContext();
@ -361,7 +372,11 @@ const PROC_PAINTERS = {
const theme = Object.values(art.themes ?? {}).find((t) => t.sheet === sheetName);
const p = theme?.palette ?? {};
const F = art.terrainFrames ?? {};
const count = Math.max(9, ...Object.values(F).map((v) => v + 1));
// The JSON carries a `_comment` key alongside the frame numbers, so filter to actual
// numbers — one string in this list turns the max into NaN and the sheet into a
// zero-height canvas.
const frameNums = Object.values(F).filter((v) => Number.isFinite(v));
const count = Math.max(9, ...frameNums.map((v) => v + 1));
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
const speckle = (ctx, S, seed, color, n, r0, r1) => {

View File

@ -14,6 +14,8 @@ export default class TAFx {
// Under the actors: trails, nanolathe streams, scorch. Over them: tracers and blasts.
this.gUnder = scene.add.graphics().setDepth(depths.fxUnder);
this.gOver = scene.add.graphics().setDepth(depths.fxOver);
// Depth on a Container child does nothing until the container is sorted; TAWorldView
// re-sorts the world container every frame, which is what puts these layers in place.
worldRoot.add(this.gUnder);
worldRoot.add(this.gOver);
this.list = [];

View File

@ -0,0 +1,398 @@
// Total Annihilation — screen-space HUD.
//
// Everything here lives on `view.uiRoot` so the UI camera draws it and the world camera
// ignores it (TAWorldView._setupCameras). The HUD owns no game rules: it reads state and
// calls back into the scene, which is the only thing allowed to issue orders.
//
// The resource strip is the important piece. A flow economy is unreadable without showing
// the RATES and the stall factor — "1200 mass" tells a player nothing, "+18 24, STALL 74%"
// tells them to build another Mass Generator.
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import { canAfford, buildOptionsFor } from './TALogic.js';
import { Tooltip } from '../../ui/Tooltip.js';
export const FONT = 'm6x11, "Julius Sans One"';
const PANEL = 0x141a24;
const EDGE = 0x39465e;
const TEXT = '#e8eef7';
const DIM = '#8fa0b8';
const BAR_H = 62; // top resource strip
const BOT_H = 190; // bottom command bar
const MINI = 176; // minimap edge length
const BTN_W = 88, BTN_H = 74; // build-menu button size
/** Build-button captions have ~7 characters of room; "Vehicle Plant" needs shortening. */
function shortName(name) {
const words = name.split(' ');
if (words.length > 1) return words.map((w) => w[0]).join('').toUpperCase();
return name.length > 8 ? name.slice(0, 8) : name;
}
export default class TAHud {
constructor(scene, rules, state, view, playerArmy, handlers) {
this.scene = scene;
this.rules = rules;
this.state = state;
this.view = view;
this.playerArmy = playerArmy;
this.h = handlers; // { onBuildPick, onProduce, onCommand, onMinimapJump }
this.buildButtons = [];
this.tooltip = new Tooltip(scene);
this.root = scene.add.container(0, 0).setDepth(1000);
view.uiRoot.add(this.root);
this._buildTopStrip();
this._buildBottomBar();
this._buildMinimap();
this._pulse = 0;
}
/** Screen rects the camera must NOT pan from — clicks here are UI, not world orders. */
get blockRects() {
return [
new Phaser.Geom.Rectangle(0, 0, GAME_WIDTH, BAR_H),
new Phaser.Geom.Rectangle(0, GAME_HEIGHT - BOT_H, GAME_WIDTH, BOT_H),
];
}
hitsUi(x, y) {
return this.blockRects.some((r) => Phaser.Geom.Rectangle.Contains(r, x, y));
}
// -------------------------------------------------------------------------
// Top strip: MASS and ENERGY as stored/cap plus flow rates
// -------------------------------------------------------------------------
_buildTopStrip() {
const s = this.scene;
const bg = s.add.rectangle(0, 0, GAME_WIDTH, BAR_H, PANEL, 0.94).setOrigin(0, 0);
bg.setStrokeStyle(2, EDGE);
this.root.add(bg);
this.res = {};
const mk = (key, label, x, color) => {
const g = s.add.graphics();
const name = s.add.text(x, 10, label, { fontFamily: FONT, fontSize: '20px', color: DIM });
const amount = s.add.text(x, 32, '', { fontFamily: FONT, fontSize: '22px', color: TEXT });
const flow = s.add.text(x + 300, 32, '', { fontFamily: FONT, fontSize: '22px', color: TEXT });
this.root.add([g, name, amount, flow]);
this.res[key] = { g, name, amount, flow, x, color };
};
mk('mass', 'MASS', 28, 0x9fb4cf);
mk('energy', 'ENERGY', GAME_WIDTH / 2 - 240, 0xf2c744);
this.stallText = s.add.text(GAME_WIDTH - 28, 20, '', {
fontFamily: FONT, fontSize: '26px', color: '#ff6b5a',
}).setOrigin(1, 0);
this.clock = s.add.text(GAME_WIDTH - 28, 4, '', {
fontFamily: FONT, fontSize: '20px', color: DIM,
}).setOrigin(1, 0);
this.root.add([this.stallText, this.clock]);
}
_drawResource(key, stored, cap, income, drain, stall) {
const r = this.res[key];
const w = 260, h = 12, y = 16;
r.g.clear();
r.g.fillStyle(0x0b0e14, 1);
r.g.fillRect(r.x + 84, y, w, h);
const frac = cap > 0 ? Math.max(0, Math.min(1, stored / cap)) : 0;
r.g.fillStyle(r.color, 1);
r.g.fillRect(r.x + 84, y, w * frac, h);
r.g.lineStyle(1, EDGE, 1);
r.g.strokeRect(r.x + 84, y, w, h);
r.amount.setText(`${Math.round(stored)} / ${Math.round(cap)}`);
const net = income - drain;
r.flow.setText(`+${income.toFixed(1)} -${drain.toFixed(1)}`);
r.flow.setColor(net < -0.05 ? '#ff9a6b' : '#9ce6a0');
// Empty and still draining is the state the player must notice.
const critical = stall < 0.98 && stored < cap * 0.02;
r.amount.setColor(critical ? '#ff6b5a' : TEXT);
}
// -------------------------------------------------------------------------
// Bottom bar: selection roster, build/production grid, command buttons
// -------------------------------------------------------------------------
_buildBottomBar() {
const s = this.scene;
const y = GAME_HEIGHT - BOT_H;
const bg = s.add.rectangle(0, y, GAME_WIDTH, BOT_H, PANEL, 0.94).setOrigin(0, 0);
bg.setStrokeStyle(2, EDGE);
this.root.add(bg);
this.selTitle = s.add.text(MINI + 60, y + 12, '', {
fontFamily: FONT, fontSize: '26px', color: TEXT,
});
this.selDetail = s.add.text(MINI + 60, y + 44, '', {
fontFamily: FONT, fontSize: '19px', color: DIM, lineSpacing: 3,
});
this.queueText = s.add.text(MINI + 60, y + 120, '', {
fontFamily: FONT, fontSize: '19px', color: '#9ce6a0',
});
this.hint = s.add.text(GAME_WIDTH - 24, y + BOT_H - 26, '', {
fontFamily: FONT, fontSize: '17px', color: DIM,
}).setOrigin(1, 0.5);
this.root.add([this.selTitle, this.selDetail, this.queueText, this.hint]);
this.gridX = GAME_WIDTH - 24 - 6 * 96;
this.gridY = y + 16;
}
_clearGrid() {
for (const b of this.buildButtons) b.destroy();
this.buildButtons.length = 0;
}
/**
* Repaint the build/production grid for the current selection. Buttons carry the def so
* the scene can tell "start placing a building" from "enqueue a unit" without re-deriving.
*/
_drawGrid(options, kind) {
this._clearGrid();
const s = this.scene;
const army = this.state.armies[this.playerArmy];
options.forEach((def, i) => {
const col = i % 6, row = Math.floor(i / 6);
// The container is placed at the button's CENTRE, not its top-left — see below.
const x = this.gridX + col * 96 + BTN_W / 2;
const y = this.gridY + row * 82 + BTN_H / 2;
const affordable = canAfford(this.state, this.playerArmy, def);
const c = s.add.container(x, y);
// A Container's origin is hard-coded to 0.5 and cannot be changed, so setInteractive()
// always builds a hit area CENTRED on the container's position. Children drawn from a
// top-left origin therefore sit half a button down-right of their own hitbox. Every
// child here is centred on (0,0) so the visual and the hit area coincide.
const box = s.add.rectangle(0, 0, BTN_W, BTN_H, affordable ? 0x22304a : 0x1a2130, 1)
.setStrokeStyle(2, affordable ? EDGE : 0x2a3244);
const label = s.add.text(0, -BTN_H / 2 + 12, shortName(def.name), {
fontFamily: FONT, fontSize: '20px', color: affordable ? TEXT : '#5d6b80',
}).setOrigin(0.5, 0);
const cost = s.add.text(0, -BTN_H / 2 + 44, `${def.cost.mass}m ${def.cost.energy}e`, {
fontFamily: FONT, fontSize: '15px', color: affordable ? DIM : '#4e5a6d',
}).setOrigin(0.5, 0);
c.add([box, label, cost]);
c.setSize(BTN_W, BTN_H);
c.setInteractive({ useHandCursor: true });
c.on('pointerover', () => box.setFillStyle(affordable ? 0x2e3f5f : 0x1a2130, 1));
c.on('pointerout', () => box.setFillStyle(affordable ? 0x22304a : 0x1a2130, 1));
this.tooltip.attachTo(c, () => this._defTip(def));
c.on('pointerdown', (p, lx, ly, ev) => {
ev?.stopPropagation();
if (kind === 'build') this.h.onBuildPick(def);
else this.h.onProduce(def, p.event?.shiftKey ? 5 : 1);
});
this.root.add(c);
this.buildButtons.push(c);
});
}
_defTip(def) {
const lines = [`${def.cost.mass} mass ${def.cost.energy} energy ${def.buildTime}s`];
if (def.hp) lines.push(`HP ${def.hp}`);
if (def.speed) lines.push(`Speed ${def.speed} Sight ${def.sight}`);
for (const w of def.weaponDefs ?? []) {
lines.push(`${w.name}: ${w.damage} dmg / ${w.reload}s / ${w.range} range${w.manual ? ' (manual)' : ''}`);
}
if (def.makes) {
const m = [];
if (def.makes.mass) m.push(`+${def.makes.mass} mass/s`);
if (def.makes.energy) m.push(`+${def.makes.energy} energy/s`);
if (m.length) lines.push(m.join(' '));
}
if (def.upkeep?.energy) lines.push(`Upkeep ${def.upkeep.energy} energy/s`);
if (def.terrainMultiplier) lines.push('Build on a metal patch for double yield');
if (def.builds?.length) lines.push(`Builds: ${def.builds.join(', ')}`);
return { title: def.name, lines: lines.map((text) => ({ text })) };
}
// -------------------------------------------------------------------------
// Minimap — a canvas texture rebaked a few times a second, dots stroked live
// -------------------------------------------------------------------------
_buildMinimap() {
const s = this.scene;
const x = 20, y = GAME_HEIGHT - BOT_H + 8;
this.miniX = x; this.miniY = y;
const key = `ta-minimap-${s.scene.key}`;
if (s.textures.exists(key)) s.textures.remove(key);
this.miniTex = s.textures.createCanvas(key, this.state.w, this.state.h);
this.miniImg = s.add.image(x, y, key).setOrigin(0, 0)
.setDisplaySize(MINI, MINI).setInteractive({ useHandCursor: true });
this.miniImg.texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
this.miniG = s.add.graphics();
this.root.add([this.miniImg, this.miniG]);
const jump = (p) => {
const fx = Phaser.Math.Clamp((p.x - x) / MINI, 0, 1);
const fy = Phaser.Math.Clamp((p.y - y) / MINI, 0, 1);
this.h.onMinimapJump(fx * this.state.worldW, fy * this.state.worldH);
};
this.miniImg.on('pointerdown', (p) => { this._miniDrag = true; jump(p); });
s.input.on('pointerup', () => { this._miniDrag = false; });
s.input.on('pointermove', (p) => { if (this._miniDrag) jump(p); });
this._miniBakedAt = -1e9;
}
_bakeMinimap(timeMs) {
if (timeMs - this._miniBakedAt < 250) return;
this._miniBakedAt = timeMs;
const st = this.state, ctx = this.miniTex.context;
const img = ctx.createImageData(st.w, st.h);
const pal = this.rules.terrain.map((t) => {
const hex = this.view.themePalette?.[t.id] ?? '#404040';
const c = parseInt(String(hex).slice(1), 16);
return [(c >> 16) & 255, (c >> 8) & 255, c & 255];
});
const army = st.armies[this.playerArmy];
for (let ty = 0; ty < st.h; ty++) {
for (let tx = 0; tx < st.w; tx++) {
const i = ty * st.w + tx;
const [r, g, b] = pal[st.terrain[i]] ?? [64, 64, 64];
// The vision grid is half tile resolution, and unexplored must read as black:
// a minimap that ignores fog is just a wallhack with extra steps.
const v = (ty >> 1) * st.visW + (tx >> 1);
const k = army.explored[v] ? (army.visible[v] ? 1 : 0.45) : 0;
const o = i * 4;
img.data[o] = r * k; img.data[o + 1] = g * k; img.data[o + 2] = b * k; img.data[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
this.miniTex.refresh();
}
_drawMinimapOverlay() {
const g = this.miniG, st = this.state;
g.clear();
const sx = MINI / st.worldW, sy = MINI / st.worldH;
for (const e of st.entities) {
if (e.dead) continue;
if (e.army !== this.playerArmy && !this.view.visibleToPlayer(e)) continue;
const a = this.rules.armies[e.army];
g.fillStyle(a ? a.colorInt : 0x888888, 1);
const r = e.isBuilding ? 2.5 : 1.6;
g.fillRect(this.miniX + e.x * sx - r, this.miniY + e.y * sy - r, r * 2, r * 2);
}
const cam = this.scene.cameras.main;
g.lineStyle(1.5, 0xffffff, 0.8);
g.strokeRect(
this.miniX + cam.worldView.x * sx, this.miniY + cam.worldView.y * sy,
cam.worldView.width * sx, cam.worldView.height * sy,
);
}
// -------------------------------------------------------------------------
// Per-frame update
// -------------------------------------------------------------------------
update(timeMs, selection, placementDef) {
const st = this.state, army = st.armies[this.playerArmy];
if (!army) return;
this._drawResource('mass', army.mass, army.massCap, army.mIncome, army.mDrain * army.stallM, army.stallM);
this._drawResource('energy', army.energy, army.energyCap, army.eIncome, (army.eDrain * army.stallE) + army.eUpkeep * army.upkeepFactor, army.stallE);
const eff = army.buildEff ?? 1;
if (eff < 0.98) {
this._pulse = (this._pulse + 0.12) % (Math.PI * 2);
this.stallText.setText(`STALL ${Math.round(eff * 100)}%`);
this.stallText.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(this._pulse)));
} else {
this.stallText.setText('');
}
const t = Math.floor(st.elapsedSec);
this.clock.setText(`${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`);
this._bakeMinimap(timeMs);
this._drawMinimapOverlay();
this._refreshSelection(selection, placementDef);
}
_refreshSelection(selection, placementDef) {
const sel = [...selection];
const sig = sel.map((e) => e.id).join(',') + '|' + (placementDef?.id ?? '')
+ '|' + sel.map((e) => e.queue?.length ?? 0).join(',')
+ '|' + Math.floor((this.state.armies[this.playerArmy]?.mass ?? 0) / 25);
if (sig === this._selSig) { this._refreshQueue(sel); return; }
this._selSig = sig;
if (!sel.length) {
this.selTitle.setText('');
this.selDetail.setText('');
this.queueText.setText('');
this.hint.setText('LMB select · drag box · RMB order · A attack-move · X stop · H hold · WASD pan · Ctrl+1-9 groups');
this._clearGrid();
return;
}
const counts = new Map();
for (const e of sel) {
const def = this.rules.defById[e.defId];
counts.set(def.name, (counts.get(def.name) ?? 0) + 1);
}
this.selTitle.setText([...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(' '));
const lead = sel[0];
const def = this.rules.defById[lead.defId];
const lines = [];
if (sel.length === 1) {
lines.push(`HP ${Math.ceil(lead.hp)} / ${lead.maxHp}`);
if (lead.site) lines.push(`Under construction — ${Math.round((lead.progress ?? 0) * 100)}%`);
if (!lead.site && lead.hasRally) lines.push('Rally point set');
for (const w of def.weaponDefs ?? []) lines.push(`${w.name}${w.manual ? ' — manual (attack order)' : ''}`);
} else {
const hp = sel.reduce((s, e) => s + e.hp, 0), max = sel.reduce((s, e) => s + e.maxHp, 0);
lines.push(`${sel.length} units HP ${Math.ceil(hp)} / ${max}`);
}
this.selDetail.setText(lines.join('\n'));
if (placementDef) {
this.hint.setText(`Placing ${placementDef.name} — LMB to site it, Esc to cancel`);
} else {
this.hint.setText('LMB select · drag box · RMB order · A attack-move · X stop · H hold · WASD pan · Ctrl+1-9 groups');
}
// Grid shows the builder's structures, or the factory's units — never both.
const builder = sel.find((e) => !e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length);
const factory = sel.find((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length);
if (builder) this._drawGrid(buildOptionsFor(this.rules, builder), 'build');
else if (factory) this._drawGrid(buildOptionsFor(this.rules, factory), 'produce');
else this._clearGrid();
}
_refreshQueue(sel) {
const f = sel.find((e) => e.isBuilding && !e.site && e.queue?.length);
if (!f) { this.queueText.setText(''); return; }
const parts = f.queue.map((q, i) => {
const name = this.rules.defById[q.defId].name;
const pct = i === 0 ? ` ${Math.round((f.jobProgress ?? 0) * 100)}%` : '';
return `${name}${q.count > 1 ? ` ×${q.count}` : ''}${pct}`;
});
this.queueText.setText(`QUEUE ${parts.join(' | ')}`);
}
toast(msg, color = '#ffd27a') {
if (this._toast) this._toast.destroy();
this._toast = this.scene.add.text(GAME_WIDTH / 2, BAR_H + 40, msg, {
fontFamily: FONT, fontSize: '28px', color,
}).setOrigin(0.5, 0);
this.view.uiRoot.add(this._toast);
this.scene.tweens.add({
targets: this._toast, alpha: 0, delay: 1400, duration: 600,
onComplete: () => { this._toast?.destroy(); this._toast = null; },
});
}
destroy() {
this._clearGrid();
this.tooltip?.destroy();
this._toast?.destroy();
this.miniG?.destroy();
this.miniImg?.destroy();
this.root.destroy(true);
}
}

View File

@ -931,6 +931,8 @@ function stepCombat(state, rules) {
for (let wi = 0; wi < def.weaponDefs.length; wi++) {
const w = def.weaponDefs[wi];
if (e.reload[wi] > 0) continue;
// Manual weapons (the D-Gun) only fire when this exact target was ordered attacked.
if (w.manual && !(holdOrder && e.orders[0].targetId === target.id)) continue;
const dist = Math.hypot(dx, dy) - target.radius;
if (dist > w.range || dist < (w.minRange ?? 0)) continue;
if (!aligned) continue;
@ -949,6 +951,7 @@ function stepCombat(state, rules) {
// Continue an in-flight burst.
for (let wi = 0; wi < def.weaponDefs.length; wi++) {
const w = def.weaponDefs[wi];
if (w.manual) continue;
if (e.burstLeft[wi] > 0 && e.reload[wi] === 0) {
fireWeapon(state, rules, e, w, target, facing);
e.burstLeft[wi]--;
@ -1083,16 +1086,30 @@ function stepProjectiles(state, rules) {
if (--p.ttl <= 0) detonate = true;
if (p.x < 0 || p.y < 0 || p.x > state.worldW || p.y > state.worldH) detonate = true;
// Proximity to the aim point (ballistic) or the live target (guided).
// Collision is SWEPT along this tick's segment, not sampled at the new position. A shell
// travels 35px in one 20 Hz tick — well past a 12px hit window and past most unit radii —
// so a point test made tank fire pass clean through whatever it was aimed at.
if (!detonate) {
const hitR = w.kind === 'guided' ? 14 : 12;
if (Math.hypot(p.aimX - p.x, p.aimY - p.y) <= hitR) detonate = true;
if (segDistSq(p.px, p.py, p.x, p.y, p.aimX, p.aimY) <= hitR * hitR) {
detonate = true;
// Detonate where the shell actually met the aim point, not where the step ended.
const s = segClosestT(p.px, p.py, p.x, p.y, p.aimX, p.aimY);
p.x = p.px + (p.x - p.px) * s; p.y = p.py + (p.y - p.py) * s;
}
}
// Direct contact with any hostile it passes through.
if (!detonate) {
let bestT = Infinity, hit = null;
for (const e of state.entities) {
if (e.dead || e.site || e.army === p.army) continue;
if (Math.hypot(e.x - p.x, e.y - p.y) <= e.radius) { detonate = true; break; }
if (segDistSq(p.px, p.py, p.x, p.y, e.x, e.y) > e.radius * e.radius) continue;
const s = segClosestT(p.px, p.py, p.x, p.y, e.x, e.y);
if (s < bestT) { bestT = s; hit = e; }
}
if (hit) {
detonate = true;
p.x = p.px + (p.x - p.px) * bestT; p.y = p.py + (p.y - p.py) * bestT;
}
}
@ -1115,6 +1132,22 @@ function stepProjectiles(state, rules) {
state.projectiles = keep;
}
/** Parametric position along segment AB of the point on it closest to P, clamped to [0,1]. */
function segClosestT(ax, ay, bx, by, px, py) {
const dx = bx - ax, dy = by - ay;
const len2 = dx * dx + dy * dy;
if (len2 <= 1e-9) return 0;
const t = ((px - ax) * dx + (py - ay) * dy) / len2;
return t < 0 ? 0 : t > 1 ? 1 : t;
}
/** Squared distance from P to segment AB. */
function segDistSq(ax, ay, bx, by, px, py) {
const t = segClosestT(ax, ay, bx, by, px, py);
const cx = ax + (bx - ax) * t - px, cy = ay + (by - ay) * t - py;
return cx * cx + cy * cy;
}
function applyAoe(state, rules, x, y, w, source) {
const r = w.aoe;
const falloff = w.aoeFalloff ?? 0.4;
@ -1124,7 +1157,10 @@ function applyAoe(state, rules, x, y, w, source) {
const d = Math.hypot(e.x - x, e.y - y) - e.radius;
if (d > r) continue;
const t = Math.max(0, Math.min(1, d / r));
const scale = 1 - (1 - falloff) * t;
// Quadratic falloff, not linear. Linear left near-full damage on every neighbour of the
// unit actually hit, so one shell wiped a whole packed formation and splash weapons beat
// everything else on cost regardless of what they were pointed at.
const scale = falloff + (1 - falloff) * (1 - t) * (1 - t);
const mul = w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1;
applyDamage(state, rules, e, w.damage * mul * scale, source);
}
@ -1218,21 +1254,27 @@ export function isVisibleTo(state, armyIdx, e) {
function checkResult(state, rules) {
if (state.over) return;
const commanderEnds = rules.constants.commanderDeathEnds !== false;
const unrecoverableEnds = rules.constants.eliminateWhenUnrecoverable !== false;
for (const a of state.armies) {
if (!a.alive) continue;
let hasAny = false, hasBuilder = false;
let hasAny = false, hasBuilder = false, hasFactory = false;
for (const e of state.entities) {
if (e.dead || e.army !== a.idx || e.site) continue;
hasAny = true;
const def = rules.defById[e.defId];
if (!e.isBuilding && (def.builds ?? []).length) hasBuilder = true;
if (!(def.builds ?? []).length) continue;
if (e.isBuilding) hasFactory = true; else hasBuilder = true;
}
// TA's "Commander Ends" rule. Without it a match can sit at the time limit while the
// loser hides one Mass Generator in a corner with no way to ever build again.
if (!hasAny || (commanderEnds && !hasBuilder)) {
// Elimination is "can never produce again", not "lost the Commander". Ending on Commander
// death made a single lucky raid decide matches, which flattened the skill ladder — economy
// and army quality never got to matter. An army that still owns a factory can rebuild, so it
// stays in; one with neither a mobile builder nor a factory is only running down the clock.
const unrecoverable = unrecoverableEnds && !hasBuilder && !hasFactory;
if (!hasAny || unrecoverable) {
a.alive = false;
state.events.push({ t: 'armyEliminated', army: a.idx, reason: hasAny ? 'commander' : 'wiped' });
state.events.push({
t: 'armyEliminated', army: a.idx, reason: hasAny ? 'unrecoverable' : 'wiped',
});
}
}
const alive = state.armies.filter((a) => a.alive);
@ -1277,12 +1319,17 @@ export function tick(state, rules) {
* in state so the verify script runs the identical loop headlessly, and `state.alpha` gives
* the view its interpolation factor between the last two ticks.
*/
export function step(state, rules, deltaMs, maxSteps = 4) {
/**
* @param {function} [onTick] runs just BEFORE each fixed step where the AI belongs, so its
* think cadence is measured in sim ticks rather than riding on the render framerate.
*/
export function step(state, rules, deltaMs, maxSteps = 4, onTick = null) {
const out = [];
state.accumulatorMs += deltaMs;
let n = 0;
while (state.accumulatorMs >= rules.stepMs && n < maxSteps) {
state.accumulatorMs -= rules.stepMs;
if (onTick) onTick(state);
const ev = tick(state, rules);
for (let i = 0; i < ev.length; i++) out.push(ev[i]);
n++;

View File

@ -82,6 +82,7 @@ export function refreshRegion(nav, rules, x0, y0, x1, y1) {
// affect tiles up-left of it — expand the rect that way and sweep in reverse.
const cx0 = Math.max(0, x0 - MAX_CLEARANCE);
const cy0 = Math.max(0, y0 - MAX_CLEARANCE);
nav._fit = null; // clearance moved, so every dilated fit mask is stale
for (const mc of nav.moveClassIds) {
const pass = nav.passable[mc];
const cl = nav.clearance[mc];
@ -123,10 +124,50 @@ export const worldToTileY = (nav, y) => Math.max(0, Math.min(nav.h - 1, (y / nav
export const tileCenterX = (nav, tx) => tx * nav.tileSize + nav.tileSize / 2;
export const tileCenterY = (nav, ty) => ty * nav.tileSize + nav.tileSize / 2;
/**
* Per-tile "a unit this wide can stand here" mask, cached per move class and need.
*
* clearance[i] is the largest all-passable square whose TOP-LEFT corner is tile i, so
* reading it directly asks "does the square start here" which wrongly rejects every tile
* along the bottom and right edge of an open area, even though a unit centred there fits
* perfectly well. A tile is genuinely usable when ANY anchor covering it qualifies, so this
* dilates the clearance test by the need window (a separable two-pass OR).
*/
export function fitField(nav, mc, need) {
if (need <= 1) return nav.clearance[mc];
const key = `${mc}:${need}`;
if (!nav._fit) nav._fit = Object.create(null);
const hit = nav._fit[key];
if (hit) return hit;
const { w, h } = nav;
const cl = nav.clearance[mc];
const rowPass = new Uint8Array(w * h);
for (let y = 0; y < h; y++) {
let run = 0; // how many of the last `need` tiles to the left qualify as anchors
for (let x = 0; x < w; x++) {
const i = y * w + x;
run = cl[i] >= need ? need : (run > 0 ? run - 1 : 0);
rowPass[i] = run > 0 ? 1 : 0;
}
}
const out = new Uint8Array(w * h);
for (let x = 0; x < w; x++) {
let run = 0;
for (let y = 0; y < h; y++) {
const i = y * w + x;
run = rowPass[i] ? need : (run > 0 ? run - 1 : 0);
out[i] = run > 0 ? need : 0;
}
}
nav._fit[key] = out;
return out;
}
/** Is this tile usable by a unit with the given move class and clearance need? */
export function tileOk(nav, mc, need, tx, ty) {
if (tx < 0 || ty < 0 || tx >= nav.w || ty >= nav.h) return false;
return nav.clearance[mc][ty * nav.w + tx] >= need;
return fitField(nav, mc, need)[ty * nav.w + tx] >= need;
}
/**
@ -216,7 +257,7 @@ const NEIGHBOR_DY = [0, 0, 1, -1, 1, -1, 1, -1];
*/
export function findPath(nav, mc, need, startIdx, goalIdx, expansionCap = 20000) {
if (startIdx === goalIdx) return Int32Array.of(startIdx);
const cl = nav.clearance[mc];
const cl = fitField(nav, mc, need);
const cost = nav.cost[mc];
if (cl[goalIdx] < need || cl[startIdx] < need) return null;
@ -297,7 +338,7 @@ function reconstruct(came, startIdx, goalIdx) {
*/
export function segmentClear(nav, mc, need, x0, y0, x1, y1) {
const ts = nav.tileSize;
const cl = nav.clearance[mc];
const cl = fitField(nav, mc, need);
let tx = Math.floor(x0 / ts), ty = Math.floor(y0 / ts);
const tx1 = Math.floor(x1 / ts), ty1 = Math.floor(y1 / ts);
const dx = x1 - x0, dy = y1 - y0;

View File

@ -42,6 +42,16 @@ function requirePositive(obj, keys, what) {
}
}
/**
* Acquisition range. Manual weapons are excluded so a Commander doesn't chase enemies into
* D-Gun range it will never voluntarily use; a manual-only def falls back to its full reach
* so it still knows when something is worth shooting at.
*/
function autoRangeOf(weaponDefs) {
const auto = weaponDefs.filter((w) => !w.manual);
return (auto.length ? auto : weaponDefs).reduce((m, w) => Math.max(m, w.range), 0);
}
function indexById(list) {
const m = Object.create(null);
for (const e of list) m[e.id] = e;
@ -163,6 +173,10 @@ export function compileRules(json) {
w.rangeSq = w.range * w.range;
w.minRangeSq = (w.minRange ?? 0) * (w.minRange ?? 0);
w.targetsAir = (w.targets ?? ['ground']).includes('air');
// A manual weapon never auto-fires: it needs an explicit attack order on the victim.
// The D-Gun is the reason this flag exists — left on auto-fire, a defending Commander
// deletes one attacker every reload for free, which makes assaulting any base suicide.
w.manual = w.manual === true;
}
const weaponById = indexById(weapons);
@ -193,7 +207,7 @@ export function compileRules(json) {
u.sightSq = u.sight * u.sight;
u.isBuilding = false;
u.weaponDefs = (u.weapons ?? []).map((wid) => weaponById[wid]);
u.maxRange = u.weaponDefs.reduce((m, w) => Math.max(m, w.range), 0);
u.maxRange = autoRangeOf(u.weaponDefs);
u.spritePx = u.spritePx ?? sc.radius * 2;
buildable[u.id] = u;
}
@ -221,7 +235,7 @@ export function compileRules(json) {
b.sight = b.sight ?? 0;
b.sightSq = b.sight * b.sight;
b.weaponDefs = (b.weapons ?? []).map((wid) => weaponById[wid]);
b.maxRange = b.weaponDefs.reduce((m, w) => Math.max(m, w.range), 0);
b.maxRange = autoRangeOf(b.weaponDefs);
buildable[b.id] = b;
}
const buildingById = indexById(buildings);

View File

@ -0,0 +1,295 @@
// Total Annihilation — front-end screens.
//
// Each builder returns a plain `{ destroy() }` handle and the scene keeps exactly one alive
// (TotalAnnihilationGame.swapScreen), which is the AdvanceWarsScreens.js router pattern.
// Screens never touch the simulation: they collect a choice and hand it back through a
// callback, so the same match can be started from a menu, a save, or the verify script.
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import { createOpponentPortrait } from '../../ui/Portrait.js';
import { FONT } from './TAHud.js';
const BG = 0x0b0f16;
const PANEL = 0x161d29;
const EDGE = 0x39465e;
const ACCENT = 0x7dc4ff;
const TEXT = '#e8eef7';
const DIM = '#93a3ba';
function screen(scene, objs) {
return {
objs,
destroy() { for (const o of objs) o.destroy?.(); objs.length = 0; },
};
}
function backdrop(scene, objs, title, subtitle) {
objs.push(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, BG, 1).setOrigin(0, 0).setDepth(900)
.setInteractive()); // swallow clicks so they never reach the world beneath
const t = scene.add.text(GAME_WIDTH / 2, 92, title, {
fontFamily: FONT, fontSize: '76px', color: TEXT,
}).setOrigin(0.5).setDepth(901);
objs.push(t);
if (subtitle) {
objs.push(scene.add.text(GAME_WIDTH / 2, 158, subtitle, {
fontFamily: FONT, fontSize: '24px', color: DIM,
}).setOrigin(0.5).setDepth(901));
}
return t;
}
function button(scene, objs, x, y, w, h, label, onClick, { enabled = true, size = 28 } = {}) {
const c = scene.add.container(x, y).setDepth(902);
const bg = scene.add.rectangle(0, 0, w, h, enabled ? 0x22304a : 0x1a2130, 1)
.setStrokeStyle(2, enabled ? EDGE : 0x2a3244);
const txt = scene.add.text(0, 0, label, {
fontFamily: FONT, fontSize: `${size}px`, color: enabled ? TEXT : '#5d6b80',
}).setOrigin(0.5);
c.add([bg, txt]);
// A Container's origin is fixed at 0.5 (read-only in Phaser), so setInteractive() always
// produces a hit area centred on the container's position. Both children above are drawn
// centred on (0,0) to match; a child with a top-left origin would render half a button
// down-right of the area that actually receives the click.
c.setSize(w, h);
if (enabled) {
c.setInteractive({ useHandCursor: true });
c.on('pointerover', () => bg.setFillStyle(0x2e3f5f, 1));
c.on('pointerout', () => bg.setFillStyle(0x22304a, 1));
c.on('pointerdown', (p, lx, ly, ev) => { ev?.stopPropagation(); onClick(); });
}
objs.push(c);
c.label = txt; c.bg = bg;
return c;
}
/** A labelled left/right cycler over a list of options. */
function cycler(scene, objs, x, y, label, options, index, onChange) {
objs.push(scene.add.text(x, y - 30, label, {
fontFamily: FONT, fontSize: '20px', color: DIM,
}).setOrigin(0, 0.5).setDepth(902));
const value = scene.add.text(x + 150, y, String(options[index].label), {
fontFamily: FONT, fontSize: '28px', color: TEXT,
}).setOrigin(0.5).setDepth(902);
objs.push(value);
let i = index;
const step = (d) => {
i = (i + d + options.length) % options.length;
value.setText(String(options[i].label));
onChange(options[i].value, i);
};
button(scene, objs, x + 26, y, 40, 40, '<', () => step(-1), { size: 22 });
button(scene, objs, x + 274, y, 40, 40, '>', () => step(1), { size: 22 });
return { get value() { return options[i].value; } };
}
// ---------------------------------------------------------------------------
// Main menu
// ---------------------------------------------------------------------------
export function mainMenu(scene, { hasSave, onSkirmish, onCampaign, onContinue, onLeave }) {
const objs = [];
backdrop(scene, objs, 'TOTAL ANNIHILATION', 'Commander — your war machine awaits');
const cx = GAME_WIDTH / 2;
let y = 330;
button(scene, objs, cx, y, 460, 76, 'SKIRMISH', onSkirmish); y += 100;
button(scene, objs, cx, y, 460, 76, 'CAMPAIGN', onCampaign); y += 100;
button(scene, objs, cx, y, 460, 76, 'CONTINUE', onContinue, { enabled: hasSave }); y += 100;
button(scene, objs, cx, y, 460, 76, 'BACK TO ARCADE', onLeave);
objs.push(scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 56,
'Build power and mass, field an army, and leave nothing standing.', {
fontFamily: FONT, fontSize: '20px', color: DIM,
}).setOrigin(0.5).setDepth(901));
return screen(scene, objs);
}
// ---------------------------------------------------------------------------
// Skirmish setup
// ---------------------------------------------------------------------------
export function skirmishSetup(scene, { onBack, onStart }) {
const objs = [];
const rules = scene.rules;
backdrop(scene, objs, 'SKIRMISH', 'Generated map — every seed is a new battlefield');
const sk = rules.skirmish ?? {};
const sizes = Object.keys(sk.sizes ?? { small: 64, medium: 96, large: 128 })
.map((k) => ({ label: `${k.toUpperCase()} ${sk.sizes?.[k] ?? ''}`.trim(), value: k }));
const themes = Object.entries(scene.art.themes ?? {}).map(([id, t]) => ({ label: (t.name ?? id).toUpperCase(), value: id }));
const syms = (sk.symmetries ?? ['mirror-x', 'mirror-y', 'rotational'])
.map((s) => ({ label: s.replace('-', ' ').toUpperCase(), value: s }));
const skills = [1, 2, 3, 4, 5].map((n) => ({ label: `SKILL ${n}`, value: n }));
const cfg = {
seed: (Math.random() * 0xffffffff) >>> 0,
size: sizes[0].value,
theme: themes[0]?.value ?? 'grasslands',
symmetry: syms[0].value,
armies: [
{ armyId: rules.armies[0].id, commanderId: rules.commanders[0]?.id, skill: 3 },
{ armyId: rules.armies[1].id, commanderId: rules.commanders[1]?.id, skill: 3 },
],
};
const left = 300, right = GAME_WIDTH - 700;
cycler(scene, objs, left, 300, 'MAP SIZE', sizes, 0, (v) => { cfg.size = v; });
cycler(scene, objs, left, 400, 'THEME', themes, 0, (v) => { cfg.theme = v; });
cycler(scene, objs, left, 500, 'SYMMETRY', syms, 0, (v) => { cfg.symmetry = v; });
cycler(scene, objs, left, 600, 'OPPONENT SKILL', skills, 2, (v) => { cfg.armies[1].skill = v; });
const seedText = scene.add.text(left, 690, `SEED ${cfg.seed}`, {
fontFamily: FONT, fontSize: '22px', color: DIM,
}).setOrigin(0, 0.5).setDepth(902);
objs.push(seedText);
button(scene, objs, left + 420, 690, 150, 44, 'REROLL', () => {
cfg.seed = (Math.random() * 0xffffffff) >>> 0;
seedText.setText(`SEED ${cfg.seed}`);
}, { size: 20 });
// Commander picker — the armies double as the shared opponent personas.
const portraits = [];
const drawCommanders = () => {
for (const p of portraits) p.destroy?.();
portraits.length = 0;
rules.commanders.forEach((c, i) => {
const col = i % 2, row = Math.floor(i / 2);
const x = right + col * 240, y = 320 + row * 210;
const chosen = cfg.armies[1].commanderId === c.id;
const box = scene.add.rectangle(x, y, 210, 190, chosen ? 0x2b3f5e : PANEL, 1)
.setStrokeStyle(2, chosen ? ACCENT : EDGE).setDepth(901)
.setInteractive({ useHandCursor: true });
box.on('pointerdown', () => {
cfg.armies[1].commanderId = c.id;
cfg.armies[1].armyId = c.armyId;
// The player always takes the other side, so a CORE opponent means an ARM player.
const mine = rules.commanders.find((o) => o.armyId !== c.armyId) ?? rules.commanders[0];
cfg.armies[0].commanderId = mine.id;
cfg.armies[0].armyId = mine.armyId;
drawCommanders();
});
const label = scene.add.text(x, y + 66, c.name, {
fontFamily: FONT, fontSize: '20px', color: chosen ? TEXT : DIM,
}).setOrigin(0.5).setDepth(903);
portraits.push(box, label);
objs.push(box, label);
try {
const opp = scene.oppById?.[c.opponentId];
if (opp) {
const port = createOpponentPortrait(scene, opp, x, y - 24, 52, 902, { playIntro: false });
for (const o of [].concat(port ?? [])) if (o) { portraits.push(o); objs.push(o); }
}
} catch (_) { /* portraits are decorative — a missing one must not block play */ }
});
};
objs.push(scene.add.text(right + 120, 250, 'ENEMY COMMANDER', {
fontFamily: FONT, fontSize: '22px', color: DIM,
}).setOrigin(0.5).setDepth(902));
drawCommanders();
button(scene, objs, GAME_WIDTH / 2 - 180, GAME_HEIGHT - 110, 300, 76, 'BACK', onBack);
button(scene, objs, GAME_WIDTH / 2 + 180, GAME_HEIGHT - 110, 300, 76, 'LAUNCH', () => onStart(cfg));
return screen(scene, objs);
}
// ---------------------------------------------------------------------------
// Campaign list
// ---------------------------------------------------------------------------
export function campaignList(scene, campaign, cleared, { onBack, onPick }) {
const objs = [];
const missions = campaign.missions ?? [];
backdrop(scene, objs, 'CAMPAIGN',
missions.length ? `${cleared} of ${missions.length} missions cleared` : 'No missions installed yet');
missions.forEach((m, i) => {
const col = i % 3, row = Math.floor(i / 3);
const x = GAME_WIDTH / 2 + (col - 1) * 500, y = 300 + row * 130;
// Missions unlock in order — the same rule the puzzle_progress endpoint enforces.
const unlocked = i <= cleared;
const b = button(scene, objs, x, y, 460, 104,
`${String(i + 1).padStart(2, '0')} ${unlocked ? m.name : 'LOCKED'}`,
() => onPick(i), { enabled: unlocked, size: 26 });
if (unlocked && i < cleared) b.label.setColor('#9ce6a0');
});
button(scene, objs, GAME_WIDTH / 2, GAME_HEIGHT - 110, 300, 76, 'BACK', onBack);
return screen(scene, objs);
}
// ---------------------------------------------------------------------------
// Pause
// ---------------------------------------------------------------------------
export function pause(scene, { settings, onResume, onSave, onQuit, onToggleFog, onToggleEdge, onSpeed }) {
const objs = [];
objs.push(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62)
.setOrigin(0, 0).setDepth(900).setInteractive());
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const panel = scene.add.rectangle(cx, cy, 620, 640, PANEL, 0.98)
.setStrokeStyle(3, EDGE).setDepth(901);
objs.push(panel);
objs.push(scene.add.text(cx, cy - 260, 'PAUSED', {
fontFamily: FONT, fontSize: '54px', color: TEXT,
}).setOrigin(0.5).setDepth(902));
const fogBtn = button(scene, objs, cx, cy - 150, 440, 62,
`FOG OF WAR: ${settings.fog !== false ? 'ON' : 'OFF'}`, () => {
const on = !(settings.fog !== false);
onToggleFog(on);
fogBtn.label.setText(`FOG OF WAR: ${on ? 'ON' : 'OFF'}`);
}, { size: 24 });
const edgeBtn = button(scene, objs, cx, cy - 76, 440, 62,
`EDGE SCROLL: ${settings.edgeScroll !== false ? 'ON' : 'OFF'}`, () => {
const on = !(settings.edgeScroll !== false);
onToggleEdge(on);
edgeBtn.label.setText(`EDGE SCROLL: ${on ? 'ON' : 'OFF'}`);
}, { size: 24 });
const speeds = [0.5, 1, 2];
let si = 1;
const speedBtn = button(scene, objs, cx, cy - 2, 440, 62, 'SPEED: 1x', () => {
si = (si + 1) % speeds.length;
onSpeed(speeds[si]);
speedBtn.label.setText(`SPEED: ${speeds[si]}x`);
}, { size: 24 });
button(scene, objs, cx, cy + 88, 440, 66, 'RESUME', onResume);
button(scene, objs, cx, cy + 166, 440, 66, 'SAVE', onSave);
button(scene, objs, cx, cy + 244, 440, 66, 'QUIT TO MENU', onQuit);
return screen(scene, objs);
}
// ---------------------------------------------------------------------------
// Results
// ---------------------------------------------------------------------------
export function results(scene, { won, stats, elapsed }, { onMenu, onAgain }) {
const objs = [];
objs.push(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.7)
.setOrigin(0, 0).setDepth(900).setInteractive());
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
objs.push(scene.add.rectangle(cx, cy, 760, 560, PANEL, 0.98).setStrokeStyle(3, EDGE).setDepth(901));
objs.push(scene.add.text(cx, cy - 210, won ? 'VICTORY' : 'DEFEAT', {
fontFamily: FONT, fontSize: '72px', color: won ? '#9ce6a0' : '#ff8a6b',
}).setOrigin(0.5).setDepth(902));
const mins = Math.floor(elapsed / 60), secs = Math.floor(elapsed % 60);
const lines = [
`Duration ${mins}m ${String(secs).padStart(2, '0')}s`,
`Units built ${stats.built}`,
`Units lost ${stats.lost}`,
`Kills ${stats.kills}`,
`Mass left ${stats.mass}`,
`Energy left ${stats.energy}`,
];
objs.push(scene.add.text(cx, cy - 100, lines.join('\n'), {
fontFamily: FONT, fontSize: '26px', color: TEXT, align: 'left', lineSpacing: 10,
}).setOrigin(0.5, 0).setDepth(902));
button(scene, objs, cx - 170, cy + 210, 300, 72, 'PLAY AGAIN', onAgain);
button(scene, objs, cx + 170, cy + 210, 300, 72, 'MENU', onMenu);
return screen(scene, objs);
}

View File

@ -61,9 +61,9 @@ export default class TAWorldView {
this.gSelection = scene.add.graphics().setDepth(DEPTHS.selection);
this.gBars = scene.add.graphics().setDepth(DEPTHS.bars);
this.gGhost = scene.add.graphics().setDepth(DEPTHS.ghost);
this.worldRoot.add(this.gSelection);
this.worldRoot.add(this.gBars);
this.worldRoot.add(this.gGhost);
this._addWorld(this.gSelection);
this._addWorld(this.gBars);
this._addWorld(this.gGhost);
this.sprites = new Map(); // entity id -> { img, turret }
this.selection = new Set();
@ -90,6 +90,22 @@ export default class TAWorldView {
cam.ignore(this.uiRoot);
}
/**
* Add a child to the world container.
*
* Phaser Containers render their children in INSERTION order and never sort by depth on
* their own `sortChildrenFlag` exists on DisplayList and Layer, not Container. So depth
* here is inert until `sort('depth')` runs, which is why the fog image (built in the
* constructor, long before the terrain chunks it must cover) was drawn underneath the map
* and the whole battlefield was visible. Anything added mid-frame a lazily painted
* terrain chunk, a new unit sprite re-dirties the order.
*/
_addWorld(obj) {
this.worldRoot.add(obj);
this._layerDirty = true;
return obj;
}
/** Keep screen-space extras (music controls, fullscreen button) off the world camera. */
ignoreOnWorldCam(objs) {
if (!objs?.length) return;
@ -103,22 +119,50 @@ export default class TAWorldView {
centerOn(x, y) { this.scene.cameras.main.centerOn(x, y); }
/**
* Zoom one step, keeping whatever the cursor is over pinned under the cursor.
*
* The scroll is solved directly from the camera's screen->world mapping rather than by
* diffing two getWorldPoint() calls around the zoom change. getWorldPoint inverts
* `camera.matrix`, and that matrix is only rebuilt in preRender so the second call reads
* the OLD zoom while the camera already holds the new one, and the correction it yields is
* wrong. Solving it makes the anchor exact and independent of when in the frame this runs.
*
* At the edges of the map the scroll clamps to the camera bounds, so the anchor gives way
* rather than letting the view slide off the world.
*/
zoomBy(dir, focusX, focusY) {
const cam = this.scene.cameras.main;
const next = Math.max(0, Math.min(ZOOMS.length - 1, this.zoomIdx + dir));
if (next === this.zoomIdx) return;
// Zoom toward the cursor: keep the world point under the pointer pinned.
const before = cam.getWorldPoint(focusX, focusY);
const anchor = this.worldPoint(focusX, focusY);
this.zoomIdx = next;
cam.setZoom(ZOOMS[next]);
const after = cam.getWorldPoint(focusX, focusY);
cam.setScroll(cam.scrollX + (before.x - after.x), cam.scrollY + (before.y - after.y));
const z = ZOOMS[next];
cam.setZoom(z);
cam.setScroll(
anchor.x - cam.width / 2 + cam.width / (2 * z) - focusX / z,
anchor.y - cam.height / 2 + cam.height / (2 * z) - focusY / z,
);
}
get zoom() { return this.scene.cameras.main.zoom; }
/**
* Screen -> world, from the camera's live scroll and zoom.
*
* Deliberately not Camera.getWorldPoint(): that inverts a matrix rebuilt only during
* preRender, so it reports a stale transform for any call made after the camera has moved
* earlier in the same frame which is every click, since camera panning runs first in
* update(). This mirrors Camera.preRender's own maths:
* worldView.x = scrollX + width/2 - width/(2*zoom), worldX = worldView.x + screenX/zoom
*/
worldPoint(screenX, screenY) {
return this.scene.cameras.main.getWorldPoint(screenX, screenY);
const cam = this.scene.cameras.main;
const z = cam.zoom;
return {
x: cam.scrollX + cam.width / 2 - cam.width / (2 * z) + screenX / z,
y: cam.scrollY + cam.height / 2 - cam.height / (2 * z) + screenY / z,
};
}
// -------------------------------------------------------------------------
@ -142,7 +186,7 @@ export default class TAWorldView {
const w = Math.min(CHUNK_PX, state.worldW - ox);
const h = Math.min(CHUNK_PX, state.worldH - oy);
const rt = this.scene.add.renderTexture(ox, oy, w, h).setOrigin(0, 0).setDepth(DEPTHS.terrain);
this.worldRoot.add(rt);
this._addWorld(rt);
const t0 = Math.floor(ox / ts), t1 = Math.ceil((ox + w) / ts);
const r0 = Math.floor(oy / ts), r1 = Math.ceil((oy + h) / ts);
@ -219,7 +263,7 @@ export default class TAWorldView {
// Linear filtering upscales the coarse grid into a soft gradient for the cost of a few
// hundred pixels of texture — far cheaper than a per-tile fog RenderTexture.
this.fogTex.setFilter(1); // Phaser.Textures.FilterMode.LINEAR
this.worldRoot.add(this.fogImg);
this._addWorld(this.fogImg);
this.fogDirty = true;
}
@ -277,14 +321,14 @@ export default class TAWorldView {
} else {
img.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
}
this.worldRoot.add(img);
this._addWorld(img);
let turret = null;
if (!def.isBuilding && def.turretFrame != null) {
turret = this.scene.add.image(e.x, e.y, key, def.turretFrame);
turret.setTint(sheets.color);
turret.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
this.worldRoot.add(turret);
this._addWorld(turret);
}
s = { img, turret, defId: e.defId, site: e.site };
this.sprites.set(e.id, s);
@ -371,6 +415,11 @@ export default class TAWorldView {
this._drawPlacementGhost();
// Actor depth is recomputed every frame from Y, so the container has to be re-sorted
// every frame too — not just when a child is added.
this.worldRoot.sort('depth');
this._layerDirty = false;
// Nanolathe links: builder -> whatever it is working on.
const nanoLinks = [];
for (const e of state.entities) {
@ -432,7 +481,7 @@ export default class TAWorldView {
if (!on) return;
const { state, rules, ts } = this;
const g = this.scene.add.graphics().setDepth(DEPTHS.decal);
this.worldRoot.add(g);
this._addWorld(g);
g.lineStyle(2, 0xffd27a, 0.85);
for (let ty = 0; ty < state.h; ty++) {
for (let tx = 0; tx < state.w; tx++) {

View File

@ -0,0 +1,665 @@
// Total Annihilation — the Phaser scene.
//
// A thin coordinator: it owns the fixed-step loop, input bindings and screen routing, and
// holds NO game rules. Every state change goes through TALogic.issueOrder, the same entry
// point the AI uses — which is what lets tools/verifyTotalAnnihilation.js bot the human side
// and assert a campaign mission is actually winnable.
//
// The loop is TALogic.step(state, rules, deltaMs): the accumulator lives inside the headless
// state (the StarControlLogic.stepMatch idiom), so the verify script drives an identical loop
// in Node with a synthetic delta and gets identical results.
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { getGameSoundtrack } from '../../services/soundtrack.js';
import { api } from '../../services/api.js';
import { compileRules } from './TARules.js';
import { generateMap, decodeMap } from './TAMapGen.js';
import * as Logic from './TALogic.js';
import { runAI } from './TAAI.js';
import TAWorldView, { DEPTHS } from './TAWorldView.js';
import TAFx from './TAFx.js';
import TAHud from './TAHud.js';
import * as Screens from './TAScreens.js';
const SAVE_KEY = 'totalannihilation-save';
const SETTINGS_KEY = 'totalannihilation-settings';
const EDGE_PAN = 26; // screen-edge scroll band, px
const PAN_SPEED = 1100; // px/s at zoom 1
const DRAG_MIN = 8; // px before a click becomes a drag-box
export default class TotalAnnihilationGame extends Phaser.Scene {
constructor() { super('TotalAnnihilationGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'totalannihilation', name: 'Total Annihilation' };
this.phase = 'menu'; // menu | setup | briefing | playing | paused | result
this.match = null;
this.view = null;
this.hud = null;
this.fx = null;
this.screen = null;
this.selection = new Set();
this.groups = new Map();
this.placement = null;
this.pendingCommand = null; // 'attackMove' | 'patrol' | 'guard' | 'attack'
this.simSpeed = 1;
this.settings = readJson(SETTINGS_KEY, { edgeScroll: true, fog: true });
}
create() {
try {
const { tracks, volume } = getGameSoundtrack(this);
if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
} catch (_) { /* soundtrack is optional */ }
this.input.mouse?.disableContextMenu();
this.rules = compileRules(this.cache.json.get('totalannihilation-rules'));
this.art = this.cache.json.get('totalannihilation-artwork');
this.campaign = this.cache.json.get('totalannihilation-campaign') ?? { missions: [] };
this.keys = this.input.keyboard.addKeys(
'W,A,S,D,X,H,P,G,Q,E,ESC,SPACE,UP,DOWN,LEFT,RIGHT,SHIFT,CTRL',
);
this._bindInput();
// Commander portraits come from the shared opponent roster; a fetch failure just means
// the setup screen falls back to name-only cards.
this.oppById = {};
fetch('data/opponents.json')
.then((r) => r.json())
.then((d) => { for (const o of d.opponents ?? []) this.oppById[o.id] = o; })
.catch(() => { /* portraits are optional */ });
this.events.once('shutdown', () => this._teardown());
this.showMenu();
}
// -------------------------------------------------------------------------
// Screen routing
// -------------------------------------------------------------------------
swapScreen(next) {
this.screen?.destroy();
this.screen = next ?? null;
}
showMenu() {
this.phase = 'menu';
this._endMatch();
this.swapScreen(Screens.mainMenu(this, {
hasSave: !!localStorage.getItem(SAVE_KEY),
onSkirmish: () => this.showSkirmishSetup(),
onCampaign: () => this.showCampaign(),
onContinue: () => this.loadSave(),
onLeave: () => this.scene.start('GameMenu'),
}));
}
showSkirmishSetup() {
this.phase = 'setup';
this.swapScreen(Screens.skirmishSetup(this, {
onBack: () => this.showMenu(),
onStart: (cfg) => this.startSkirmish(cfg),
}));
}
showCampaign() {
this.phase = 'setup';
api.get(`/puzzles/${this.gameDef.slug}/progress`)
.then((p) => p?.levelsCompleted ?? 0)
.catch(() => 0)
.then((cleared) => {
this.swapScreen(Screens.campaignList(this, this.campaign, cleared, {
onBack: () => this.showMenu(),
onPick: (idx) => this.startMission(idx),
}));
});
}
// -------------------------------------------------------------------------
// Match lifecycle
// -------------------------------------------------------------------------
startSkirmish(cfg) {
const map = generateMap(this.rules, {
seed: cfg.seed, size: cfg.size, theme: cfg.theme, symmetry: cfg.symmetry,
armies: cfg.armies.length,
});
const state = Logic.createMatch(this.rules, {
seed: cfg.seed, map,
armies: cfg.armies.map((a, i) => ({
armyId: a.armyId, commanderId: a.commanderId,
isHuman: i === 0, aiSkill: a.skill,
aiProfile: this.rules.commanderById?.[a.commanderId]?.aiProfile ?? null,
})),
});
this.meta = { mode: 'skirmish', cfg };
this._beginMatch(state, 0);
}
startMission(idx) {
const m = this.campaign.missions[idx];
if (!m) return this.showMenu();
const map = decodeMap(this.rules, m.map);
const state = Logic.createMatch(this.rules, {
seed: m.seed ?? 1234, map,
armies: [
{ armyId: m.playerArmy, commanderId: m.playerCommander, isHuman: true },
...(m.enemies ?? []).map((e) => ({
armyId: e.army, commanderId: e.commander,
aiSkill: e.aiProfile?.skill ?? 3, aiProfile: e.aiProfile ?? null,
})),
],
});
(m.startResources ?? []).forEach((r, i) => {
if (!state.armies[i]) return;
state.armies[i].mass = r.mass ?? state.armies[i].mass;
state.armies[i].energy = r.energy ?? state.armies[i].energy;
});
this.meta = { mode: 'campaign', missionIdx: idx };
this.mission = m;
this._beginMatch(state, 0);
}
_beginMatch(state, playerArmy) {
this.swapScreen(null);
this.match = state;
this.playerArmy = playerArmy;
this.selection.clear();
this.groups.clear();
this.placement = null;
this.view = new TAWorldView(this, this.rules, this.art, state, playerArmy);
this.view.setFogEnabled(this.settings.fog !== false);
this.fx = new TAFx(this, this.view.worldRoot, DEPTHS);
this.hud = new TAHud(this, this.rules, state, this.view, playerArmy, {
onBuildPick: (def) => this._beginPlacement(def),
onProduce: (def, n) => this._enqueue(def, n),
onMinimapJump: (x, y) => this.view.centerOn(x, y),
});
if (this.music?._objs?.length) this.view.ignoreOnWorldCam(this.music._objs);
const start = state.starts.find((s) => s.army === playerArmy);
if (start) this.view.centerOn(start.x * state.tileSize, start.y * state.tileSize);
this.phase = 'playing';
this._lastAutosave = this.time.now;
}
_endMatch() {
this.hud?.destroy(); this.hud = null;
this.fx?.destroy(); this.fx = null;
this.view?.destroy(); this.view = null;
this.match = null;
this.selection.clear();
}
_teardown() {
this._endMatch();
this.screen?.destroy();
this.music?.destroy?.();
}
// -------------------------------------------------------------------------
// The loop
// -------------------------------------------------------------------------
update(time, delta) {
if (this.phase !== 'playing' || !this.match) return;
const st = this.match;
this._updateCamera(delta);
// The AI thinks on sim ticks, not render frames, so it is stepped inside the same
// fixed-step loop the simulation uses — otherwise its cadence would ride framerate.
const before = st.tick;
const events = Logic.step(st, this.rules, delta * this.simSpeed, 4, (s) => {
for (const a of s.armies) {
if (!a.alive || a.isHuman) continue;
runAI(this.rules, s, a.idx, { skill: a.aiSkill, ...(a.aiProfile ?? {}) });
}
});
if (st.tick !== before) this.view.fogDirty = true;
for (const ev of events) this._onSimEvent(ev);
const { nanoLinks, projectiles } = this.view.render(st.alpha);
this.fx.draw(delta, nanoLinks, projectiles, time);
this._syncSelection();
this.hud.update(time, this._selectedEntities(), this.placement?.def ?? null);
this._drawDragBox();
if (this.meta?.mode === 'campaign' && time - this._lastAutosave > 60000) {
this._lastAutosave = time;
this.saveGame();
}
if (st.over) this._finish(st.over);
}
_onSimEvent(ev) {
this.fx.onEvent(ev, this.rules);
if (ev.t === 'buildingComplete' && ev.army === this.playerArmy) {
// Terrain under a finished structure changes, so its chunk has to be restamped.
this.view.repaintArea(ev.x - 128, ev.y - 128, ev.x + 128, ev.y + 128);
}
if (ev.t === 'armyEliminated' && ev.army !== this.playerArmy) {
this.hud.toast(`${this.rules.armies[ev.army]?.name ?? 'Enemy'} eliminated`, '#9ce6a0');
}
if (ev.t === 'noPath' && ev.army === this.playerArmy) {
this.hud.toast('No route there', '#ff9a6b');
}
}
_finish(over) {
this.phase = 'result';
const won = over.winner === this.playerArmy;
api.post('/history/single-player', {
slug: this.gameDef.slug, score: Math.round(this.match.elapsedSec),
opponentScores: [], result: won ? 'win' : 'loss',
}).catch(() => { /* best effort */ });
if (won && this.meta?.mode === 'campaign') {
api.post(`/puzzles/${this.gameDef.slug}/complete`, { level: this.meta.missionIdx + 1 })
.catch(() => { /* best effort */ });
}
localStorage.removeItem(SAVE_KEY);
const stats = summarise(this.match, this.playerArmy);
this.swapScreen(Screens.results(this, { won, stats, elapsed: this.match.elapsedSec }, {
onMenu: () => this.showMenu(),
onAgain: () => {
if (this.meta?.mode === 'campaign') this.startMission(this.meta.missionIdx);
else this.startSkirmish({ ...this.meta.cfg, seed: (this.meta.cfg.seed * 7919 + 13) >>> 0 });
},
}));
}
// -------------------------------------------------------------------------
// Camera
// -------------------------------------------------------------------------
_updateCamera(delta) {
const k = this.keys;
const dt = delta / 1000;
let dx = 0, dy = 0;
if (k.A.isDown || k.LEFT.isDown) dx -= 1;
if (k.D.isDown || k.RIGHT.isDown) dx += 1;
if (k.W.isDown || k.UP.isDown) dy -= 1;
if (k.S.isDown || k.DOWN.isDown) dy += 1;
// Edge scroll stays off until the mouse has actually moved over the canvas. Phaser's
// activePointer sits at (0,0) until its first event, which is inside the top-left edge
// band — so without this the camera silently flies off the corner of the map the moment
// a match starts, taking the player's Commander off screen before they touch anything.
if (this.settings.edgeScroll !== false && this._pointerLive && !this._dragging) {
const p = this.input.activePointer;
if (p.x >= 0 && p.y >= 0 && p.x <= GAME_WIDTH && p.y <= GAME_HEIGHT) {
if (p.x < EDGE_PAN) dx -= 1;
if (p.x > GAME_WIDTH - EDGE_PAN) dx += 1;
if (p.y < EDGE_PAN) dy -= 1;
if (p.y > GAME_HEIGHT - EDGE_PAN) dy += 1;
}
}
if (dx || dy) {
const len = Math.hypot(dx, dy) || 1;
this.view.panBy((dx / len) * PAN_SPEED * dt, (dy / len) * PAN_SPEED * dt);
}
}
// -------------------------------------------------------------------------
// Input
// -------------------------------------------------------------------------
_bindInput() {
this.input.on('pointermove', () => { this._pointerLive = true; });
this.input.on('pointerdown', (p) => { this._pointerLive = true; this._onPointerDown(p); });
this.input.on('pointerup', (p) => this._onPointerUp(p));
this.input.on('wheel', (p, objs, dx, dy) => {
if (this.phase !== 'playing') return;
this.view.zoomBy(dy > 0 ? -1 : 1, p.x, p.y);
});
this.input.keyboard.on('keydown', (ev) => {
if (this.phase === 'playing') this._onKey(ev);
});
}
_onKey(ev) {
const code = ev.key?.toLowerCase();
if (ev.code === 'Escape') {
if (this.placement) { this._cancelPlacement(); return; }
if (this.pendingCommand) { this.pendingCommand = null; return; }
this.togglePause();
return;
}
// Control groups: Ctrl+N assigns, N recalls, N again centres on the group.
if (/^[1-9]$/.test(code)) {
const n = Number(code);
if (ev.ctrlKey) {
this.groups.set(n, [...this.selection]);
this.hud.toast(`Group ${n} set (${this.selection.size})`);
} else {
const ids = (this.groups.get(n) ?? []).filter((id) => Logic.entityById(this.match, id));
if (!ids.length) return;
const same = ids.length === this.selection.size && ids.every((id) => this.selection.has(id));
this.selection = new Set(ids);
if (same) {
const e = Logic.entityById(this.match, ids[0]);
if (e) this.view.centerOn(e.x, e.y);
}
}
return;
}
switch (code) {
case 'a': this.pendingCommand = 'attackMove'; this.hud.toast('Attack-move: pick a target point'); break;
case 'p': this.pendingCommand = 'patrol'; this.hud.toast('Patrol: pick a point'); break;
case 'g': this.pendingCommand = 'guard'; this.hud.toast('Guard: pick a friendly unit'); break;
case 'x': this._order({ type: 'stop' }); break;
case 'h': this._order({ type: 'hold' }); break;
case 'q': this.view.zoomBy(-1, GAME_WIDTH / 2, GAME_HEIGHT / 2); break;
case 'e': this.view.zoomBy(1, GAME_WIDTH / 2, GAME_HEIGHT / 2); break;
case ' ': this._jumpToAction(); break;
default: break;
}
if (ev.ctrlKey && code === 'a') this._selectAll();
}
_onPointerDown(p) {
if (this.phase !== 'playing') return;
if (this.hud.hitsUi(p.x, p.y)) return;
if (p.middleButtonDown()) { this._panAnchor = { x: p.x, y: p.y }; return; }
const w = this.view.worldPoint(p.x, p.y);
if (p.rightButtonDown()) { this._issueContextual(w, p.event?.shiftKey); return; }
if (this.placement) { this._commitPlacement(w, p.event?.shiftKey); return; }
if (this.pendingCommand) { this._applyPendingCommand(w, p.event?.shiftKey); return; }
this._dragStart = { x: p.x, y: p.y, wx: w.x, wy: w.y, add: !!p.event?.shiftKey };
this._dragging = false;
}
_onPointerUp(p) {
this._panAnchor = null;
if (this.phase !== 'playing' || !this._dragStart) return;
const d = this._dragStart;
this._dragStart = null;
const w = this.view.worldPoint(p.x, p.y);
if (this._dragging) this._selectInBox(d.wx, d.wy, w.x, w.y, d.add);
else this._selectAt(w.x, w.y, d.add, !!p.event?.detail && p.event.detail > 1);
this._dragging = false;
}
_drawDragBox() {
if (!this._dragBoxG) this._dragBoxG = this.add.graphics().setDepth(2000);
const g = this._dragBoxG;
g.clear();
if (!this._dragStart) return;
const p = this.input.activePointer;
if (Math.abs(p.x - this._dragStart.x) + Math.abs(p.y - this._dragStart.y) > DRAG_MIN) this._dragging = true;
if (!this._dragging) return;
this.view.uiRoot.add(g);
const x = Math.min(p.x, this._dragStart.x), y = Math.min(p.y, this._dragStart.y);
const w = Math.abs(p.x - this._dragStart.x), h = Math.abs(p.y - this._dragStart.y);
g.fillStyle(0x7dff9b, 0.10); g.fillRect(x, y, w, h);
g.lineStyle(1.5, 0x7dff9b, 0.9); g.strokeRect(x, y, w, h);
if (this._panAnchor) {
this.view.panBy(this._panAnchor.x - p.x, this._panAnchor.y - p.y);
this._panAnchor = { x: p.x, y: p.y };
}
}
// -------------------------------------------------------------------------
// Selection
// -------------------------------------------------------------------------
_mine() {
return this.match.entities.filter((e) => !e.dead && e.army === this.playerArmy);
}
_selectedEntities() {
return [...this.selection].map((id) => Logic.entityById(this.match, id)).filter(Boolean);
}
/** Drop dead entities so the HUD and orders never reference a corpse. */
_syncSelection() {
for (const id of [...this.selection]) {
const e = Logic.entityById(this.match, id);
if (!e || e.dead) this.selection.delete(id);
}
this.view.selection = this.selection;
}
_selectAt(x, y, add, double) {
let best = null, bestD = Infinity;
for (const e of this.match.entities) {
if (e.dead) continue;
if (e.army !== this.playerArmy && !this.view.visibleToPlayer(e)) continue;
const d = Math.hypot(e.x - x, e.y - y);
const r = e.isBuilding
? Math.max(e.radius, Math.max(...Object.values(this.rules.defById[e.defId].footprint)) * this.match.tileSize * 0.5)
: e.radius + 6;
if (d <= r && d < bestD) { best = e; bestD = d; }
}
if (!best) { if (!add) this.selection.clear(); return; }
if (!add) this.selection.clear();
if (double && best.army === this.playerArmy) {
// Double-click grabs every on-screen unit of the same type — the standard RTS idiom.
const cam = this.cameras.main.worldView;
for (const e of this._mine()) {
if (e.defId === best.defId && Phaser.Geom.Rectangle.Contains(cam, e.x, e.y)) this.selection.add(e.id);
}
return;
}
this.selection.add(best.id);
}
_selectInBox(x0, y0, x1, y1, add) {
if (!add) this.selection.clear();
const lo = { x: Math.min(x0, x1), y: Math.min(y0, y1) };
const hi = { x: Math.max(x0, x1), y: Math.max(y0, y1) };
// A box prefers mobile units: dragging over a base to grab tanks shouldn't also
// select the factory they came out of.
const hits = this._mine().filter((e) => e.x >= lo.x && e.x <= hi.x && e.y >= lo.y && e.y <= hi.y);
const mobile = hits.filter((e) => !e.isBuilding && !e.site);
for (const e of (mobile.length ? mobile : hits)) this.selection.add(e.id);
}
_selectAll() {
this.selection.clear();
for (const e of this._mine()) if (!e.isBuilding && !e.site) this.selection.add(e.id);
}
_jumpToAction() {
const e = this._mine().find((u) => u.targetId) ?? this._mine()[0];
if (e) this.view.centerOn(e.x, e.y);
}
// -------------------------------------------------------------------------
// Orders
// -------------------------------------------------------------------------
_order(order, queue = false) {
const unitIds = [...this.selection];
if (!unitIds.length) return { ok: false };
const r = Logic.issueOrder(this.match, this.rules, {
army: this.playerArmy, unitIds, order, queue,
});
if (!r.ok && r.error) this.hud.toast(r.error, '#ff9a6b');
return r;
}
/** Right-click: move, attack an enemy, or set a factory's rally point. */
_issueContextual(w, queue) {
const sel = this._selectedEntities();
if (!sel.length) return;
const factories = sel.filter((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length);
if (factories.length === sel.length) {
for (const f of factories) {
Logic.issueOrder(this.match, this.rules, {
army: this.playerArmy, order: { type: 'setRally', factoryId: f.id, x: w.x, y: w.y },
});
}
this.hud.toast('Rally point set');
return;
}
const target = this._entityAt(w.x, w.y);
if (target && target.army !== this.playerArmy && this.view.visibleToPlayer(target)) {
this._order({ type: 'attack', targetId: target.id }, queue);
return;
}
if (target && target.army === this.playerArmy && target.site) {
this._order({ type: 'assist', targetId: target.id }, queue);
return;
}
this._order({ type: 'move', x: w.x, y: w.y }, queue);
}
_applyPendingCommand(w, queue) {
const cmd = this.pendingCommand;
this.pendingCommand = null;
if (cmd === 'guard') {
const t = this._entityAt(w.x, w.y);
if (t && t.army === this.playerArmy) this._order({ type: 'guard', targetId: t.id }, queue);
return;
}
this._order({ type: cmd, x: w.x, y: w.y }, queue);
}
_entityAt(x, y) {
let best = null, bestD = Infinity;
for (const e of this.match.entities) {
if (e.dead) continue;
const d = Math.hypot(e.x - x, e.y - y);
if (d <= e.radius + 8 && d < bestD) { best = e; bestD = d; }
}
return best;
}
_enqueue(def, count) {
const f = this._selectedEntities().find((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.includes(def.id));
if (!f) return;
Logic.issueOrder(this.match, this.rules, {
army: this.playerArmy,
order: { type: 'factoryEnqueue', factoryId: f.id, defId: def.id, count },
});
}
// -------------------------------------------------------------------------
// Building placement
// -------------------------------------------------------------------------
_beginPlacement(def) {
const builder = this._selectedEntities().find((e) => !e.isBuilding && this.rules.defById[e.defId].builds?.includes(def.id));
if (!builder) return;
this.placement = { def, builderId: builder.id };
this.view.highlightMassSpots(!!def.terrainMultiplier);
this.input.on('pointermove', this._onPlacementMove, this);
this._onPlacementMove(this.input.activePointer);
}
_onPlacementMove(p) {
if (!this.placement) return;
const w = this.view.worldPoint(p.x, p.y);
const ts = this.match.tileSize;
const def = this.placement.def;
// Centre the footprint on the cursor, then snap to the tile grid.
const tx = Math.round(w.x / ts - def.footprint.w / 2);
const ty = Math.round(w.y / ts - def.footprint.h / 2);
const builder = Logic.entityById(this.match, this.placement.builderId);
this.placement.tx = tx; this.placement.ty = ty;
this.view.placement = {
def, tx, ty,
legal: Logic.canPlaceAt(this.match, this.rules, tx, ty, def),
builderX: builder?.x, builderY: builder?.y,
buildRange: this.rules.defById[builder?.defId ?? '']?.buildRange ?? 0,
};
}
_commitPlacement(w, queue) {
const p = this.placement;
if (!p) return;
const r = Logic.issueOrder(this.match, this.rules, {
army: this.playerArmy, unitIds: [p.builderId],
order: { type: 'build', defId: p.def.id, tx: p.tx, ty: p.ty },
queue,
});
if (!r.ok) { this.hud.toast(r.error ?? 'Cannot build there', '#ff9a6b'); return; }
// Shift keeps the cursor loaded so a row of generators is one gesture.
if (!queue) this._cancelPlacement();
}
_cancelPlacement() {
this.placement = null;
this.view.placement = null;
this.view.highlightMassSpots(false);
this.input.off('pointermove', this._onPlacementMove, this);
}
// -------------------------------------------------------------------------
// Pause / persistence
// -------------------------------------------------------------------------
togglePause() {
if (this.phase === 'playing') {
this.phase = 'paused';
this.swapScreen(Screens.pause(this, {
settings: this.settings,
onResume: () => { this.phase = 'playing'; this.swapScreen(null); },
onSave: () => { this.saveGame(); this.hud.toast('Saved'); },
onQuit: () => this.showMenu(),
onToggleFog: (on) => {
this.settings.fog = on;
writeJson(SETTINGS_KEY, this.settings);
this.view.setFogEnabled(on);
},
onToggleEdge: (on) => { this.settings.edgeScroll = on; writeJson(SETTINGS_KEY, this.settings); },
onSpeed: (v) => { this.simSpeed = v; },
}));
} else if (this.phase === 'paused') {
this.phase = 'playing';
this.swapScreen(null);
}
}
saveGame() {
if (!this.match) return;
try {
localStorage.setItem(SAVE_KEY, JSON.stringify({
v: Logic.SAVE_VERSION, meta: this.meta, state: Logic.serialize(this.match),
}));
} catch (_) { /* quota — a failed autosave must not kill the match */ }
}
loadSave() {
const raw = readJson(SAVE_KEY, null);
if (!raw || raw.v !== Logic.SAVE_VERSION) { this.hud?.toast('No compatible save'); return; }
const state = Logic.deserialize(this.rules, raw.state);
this.meta = raw.meta;
if (raw.meta?.mode === 'campaign') this.mission = this.campaign.missions[raw.meta.missionIdx];
this._beginMatch(state, 0);
}
}
function summarise(state, armyIdx) {
const a = state.armies[armyIdx];
return {
built: a.builtEver, lost: a.lostEver, kills: a.killsEver,
mass: Math.round(a.mass), energy: Math.round(a.energy),
};
}
function readJson(key, fallback) {
try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch (_) { return fallback; }
}
function writeJson(key, v) {
try { localStorage.setItem(key, JSON.stringify(v)); } catch (_) { /* ignore */ }
}

View File

@ -0,0 +1,173 @@
# Total Annihilation — art spec
Every sheet is **optional**. `data/totalannihilation-artwork.json` ships with `path: null` on
all of them, and `TAArt.js` paints a procedural stand-in at runtime, so the game is fully
playable with zero PNG files. Drop a sheet in and it replaces the stand-in with no code change.
To install a sheet: save the PNG under `assets/images/totalannihilation/`, then set that
sheet's `path` in `data/totalannihilation-artwork.json`. Nothing else needs editing — the
asset manifest resolves every entry in the `sheets` map automatically
(`src/data/assetManifest.js`), so **new sheets never need a manifest change either**.
---
## The two rules that make the art cheap
1. **Units are drawn once, facing right.** `0 rad` is Phaser's `+x` axis, and the renderer
calls `setRotation(heading)` — so a unit needs exactly **one** frame, not eight facings.
Draw it pointing **right**, dead centre in its cell.
2. **Units are drawn in neutral grey.** The renderer applies `setTint(armyColour)` per army.
Paint in greys with the detail carried by *value* (light/dark), not hue; any hue you paint
is multiplied by the team colour and will read as muddy. Keep the brightest highlight near
white so the tint stays vivid.
Terrain is exempt from both: tiles are drawn in full colour and are never rotated or tinted.
---
## Sheets
| Sheet name (JSON key) | Texture key | Cell | Cols | Contents |
|---|---|---|---|---|
| `arm-units` | `ta-arm-units` | 64×64 | 8 | ARM mobile units |
| `core-units` | `ta-core-units` | 64×64 | 8 | CORE mobile units |
| `arm-structures` | `ta-arm-structures` | 128×128 | 6 | ARM buildings |
| `core-structures` | `ta-core-structures` | 128×128 | 6 | CORE buildings |
| `terrain-grasslands` | `ta-terrain-grasslands` | 64×64 | 9 | Grassland tiles |
| `terrain-snowfields` | `ta-terrain-snowfields` | 64×64 | 9 | Snow tiles |
| `terrain-tropics` | `ta-terrain-tropics` | 64×64 | 9 | Jungle tiles |
| `icons` | `ta-icons` | 44×44 | 10 | Command/build glyphs |
Frames are numbered **left to right, then top to bottom**, starting at 0.
---
## Unit sheets (64×64 cells, 8 per row)
Both army sheets use the identical frame layout — that is what lets one unit definition serve
every army. `spritePx` is the on-screen size the frame is scaled to; draw to fill the cell and
let the scale do the work.
| Frame | Unit | Drawn as | `spritePx` |
|---|---|---|---|
| 0 | Commander | hull / legs, no weapon | 56 |
| 1 | Commander turret | upper body + D-Gun arm | 56 |
| 2 | Infantry | whole figure, top-down | 30 |
| 3 | Sniper | whole figure, long rifle | 30 |
| 4 | Jeep | chassis + wheels | 40 |
| 5 | Jeep turret | pintle gun | 40 |
| 6 | Tank | hull + tracks | 54 |
| 7 | Tank turret | turret + barrel | 54 |
| 8 | Rocket Tank | hull + tracks | 56 |
| 9 | Rocket Tank turret | rocket pod | 56 |
**Turret frames** are drawn as a separate image stacked on the hull and rotated
independently, so the unit aims while it drives. Rules:
- The turret's **pivot is the centre of the cell** — draw the turret so its rotation point
sits at (32, 32), with the barrel extending to the **right**.
- Leave the rest of the turret cell transparent. The hull shows through it.
- A unit with no turret (infantry, sniper) simply has no turret frame; its whole sprite
rotates to face its target.
Frames 1063 are free. Add a unit by appending a definition to `units[]` in
`data/totalannihilation-rules.json` with its `frame` (and optional `turretFrame`) — no code.
---
## Structure sheets (128×128 cells, 6 per row)
Buildings do **not** rotate. They are scaled to `footprint × 64px`, so a 2×2 building renders
at 128px and a 3×3 at 192px — draw the 3×3 buildings to fill the cell and accept that they are
scaled up 1.5×, or supply a larger cell size in the artwork JSON.
Each building has **two** frames: the finished structure, and a **build frame** shown while it
is under construction (the renderer also fades it in by build progress). Draw the build frame
as a skeleton/scaffold version — girders, no panels, no lights.
| Frame | Building | Footprint | Notes |
|---|---|---|---|
| 0 | Energy Generator | 2×2 | radiating fins / vents |
| 1 | Energy Generator — under construction | 2×2 | |
| 2 | Mass Generator | 2×2 | drill ring; sits on metal patches |
| 3 | Mass Generator — under construction | 2×2 | |
| 4 | Barracks | 3×3 | doorway on the **bottom** edge (units exit downward) |
| 5 | Barracks — under construction | 3×3 | |
| 6 | Vehicle Plant | 3×3 | wide bay door on the **bottom** edge |
| 7 | Vehicle Plant — under construction | 3×3 | |
Frames 835 are free.
---
## Terrain sheets (64×64 cells, 9 per row)
**Every `kind: "terrain"` sheet must follow this exact frame order** — it is fixed in
`terrainFrames` in the artwork JSON, which is what lets a new theme be a pure drop-in:
| Frame | Tile | Notes |
|---|---|---|
| 0 | ground | the base tile — most of the map |
| 1 | groundAlt1 | variation, scattered to break up tiling |
| 2 | groundAlt2 | second variation |
| 3 | rough | slows wheeled units; still buildable |
| 4 | cliff | impassable, not buildable |
| 5 | water | impassable to current units |
| 6 | waterDeep | impassable; used for the interior of large bodies |
| 7 | metal | metal patch — **doubles a Mass Generator's yield** |
| 8 | road | fast movement |
Tiles must be **seamlessly tileable on all four edges**, since the terrain is stamped into
chunked RenderTextures with no blending. Keep them low-contrast: units and health bars are
drawn on top, and busy terrain makes a battle unreadable.
Metal (frame 7) should read as *obviously* valuable at a glance — it is the one tile the
player hunts for.
### Adding a new theme
1. Draw a 9-frame terrain sheet in the order above.
2. Add it to `sheets` in `data/totalannihilation-artwork.json` with `"kind": "terrain"`.
3. Add an entry to `themes` naming that sheet, plus a `palette` giving a colour per terrain
id (`ground`, `rough`, `cliff`, `water`, `metal`, `road`, …). The palette drives the
minimap and the procedural fallback, so it is required even when a real sheet exists.
The theme then appears in the skirmish setup screen automatically.
---
## Icons (44×44 cells, 10 per row)
Command glyphs for the HUD: move, attack, attack-move, stop, hold, patrol, guard, build,
repair, rally. Purely decorative today — the build menu labels itself from unit names — so
this sheet is the lowest priority of the eight.
---
## Adding a third army (no code)
Append two sheets (`kind: "unit"` and `kind: "structure"`) to the artwork JSON, then add a row
to `armies[]` in the rules JSON naming them plus a colour:
```json
{ "id": "hive", "name": "HIVE", "color": "#8ad46a",
"unitSheet": "hive-units", "structureSheet": "hive-structures" }
```
Every existing unit and building definition works for the new army unchanged, because each
definition references a **sheet slot** (`unitSheet` / `structureSheet`) rather than a sheet
name. Add commanders for it in `commanders[]`, pointing `opponentId` at an entry in
`data/opponents.json` for the portrait.
---
## Checking your work
```
node tools/verifyTotalAnnihilation.js --quick
```
Section 2 asserts that every declared sheet has a key and frame size, that each army's sheets
exist, that every theme has a palette entry for every terrain type, and that no unit or
building references a frame index beyond its sheet's `cols × rows` capacity — so a
mis-numbered frame fails the build instead of rendering the wrong tank in a battle.

View File

@ -97,6 +97,7 @@ import SuperKartGame from './games/superkart/SuperKartGame.js';
import SuperKartEditor from './games/superkart/SuperKartEditor.js';
import AdvanceWarsGame from './games/advancewars/AdvanceWarsGame.js';
import TetrisAttackGame from './games/tetrisattack/TetrisAttackGame.js';
import TotalAnnihilationGame from './games/totalannihilation/TotalAnnihilationGame.js';
const config = {
type: Phaser.AUTO,
@ -207,6 +208,7 @@ const config = {
SuperKartEditor,
AdvanceWarsGame,
TetrisAttackGame,
TotalAnnihilationGame,
],
};

View File

@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
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', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame' };
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', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame' };
if (slugDispatch[this.game.slug]) {
const sceneKey = slugDispatch[this.game.slug];
const startData = {

View File

@ -49,6 +49,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('adventure-music', 'data/adventure-music.json');
this.load.json('advancewars-artwork', 'data/advancewars-artwork.json');
this.load.json('tetrisattack-artwork', 'data/tetrisattack-artwork.json');
this.load.json('totalannihilation-artwork', 'data/totalannihilation-artwork.json');
this.load.json('rushhour', 'data/rushhour.json');
this.load.json('puddingmonsters', 'data/puddingmonsters.json');
this.load.json('shift-artwork', 'data/shift-artwork.json');

View File

@ -20,6 +20,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = {
superkart: 'nintendo',
advancewars: 'nintendo',
tetrisattack: 'nintendo',
totalannihilation: 'hacker',
coloradodefense: 'arcadedark',
tempest: 'arcadedark',
mastermind: 'hacker',

141
tools/genTACampaign.js Normal file
View File

@ -0,0 +1,141 @@
#!/usr/bin/env node
// Emits data/totalannihilation-campaign.json.
//
// Mission terrain is baked from the seeded generator at authoring time and frozen into the
// file as plain row strings — after that it is ordinary hand-editable map data, and the
// briefings, prebuilt bases, enemy skills and objectives below are hand-authored. Re-running
// this regenerates the terrain for the recorded seeds; editing the JSON by hand afterwards is
// entirely fine and will simply be overwritten if you run it again.
//
// node tools/genTACampaign.js
import { readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { compileRules } from '../src/games/totalannihilation/TARules.js';
import { generateMap } from '../src/games/totalannihilation/TAMapGen.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const rules = compileRules(JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8')));
const CH = rules.terrain.map((t) => t.ch);
// Hand-authored mission table. `seed`/`size`/`symmetry` pick the battlefield; everything
// else is the mission design proper.
//
// The seeds are not arbitrary. Even a mirrored map can hand one start a decisive positional
// edge (metal spread, chokes, how much room the base has to grow), and across candidate seeds
// the same mission swung between unwinnable and a walkover. Every seed here was chosen by
// running a skill-5 bot on the player's side — the same assertion tools/
// verifyTotalAnnihilation.js §13 makes, so a bad seed fails the build rather than shipping.
const MISSIONS = [
{
id: 'm01', name: 'Beachhead', seed: 10461, size: 'small', theme: 'grasslands', symmetry: 'mirror-x',
enemy: { commander: 'klaxon', skill: 1, aggression: 0.25 },
startResources: [{ mass: 1600, energy: 2600 }, { mass: 600, energy: 900 }],
prebuilt: [],
briefing: 'Establish a base and remove the CORE presence. Power first — nothing moves without it.',
victory: 'Beachhead secured. The corridor inland is open.',
defeat: 'The landing has been thrown back into the sea.',
},
{
id: 'm02', name: 'Metal Fields', seed: 20773, size: 'small', theme: 'grasslands', symmetry: 'mirror-x',
enemy: { commander: 'klaxon', skill: 2, aggression: 0.4 },
startResources: [{ mass: 1300, energy: 2200 }, { mass: 900, energy: 1400 }],
prebuilt: [{ enemy: true, id: 'energygen', dx: 3, dy: -2 }],
briefing: 'Metal patches double a generator\'s yield. Take them before CORE does.',
victory: 'The patches are ours. Production doubles from here.',
defeat: 'CORE holds the metal. We cannot match their output.',
},
{
id: 'm03', name: 'Cold Open', seed: 31287, size: 'small', theme: 'snowfields', symmetry: 'mirror-y',
enemy: { commander: 'cybro', skill: 2, aggression: 0.6 },
startResources: [{ mass: 1200, energy: 2000 }, { mass: 1100, energy: 1800 }],
prebuilt: [{ id: 'energygen', dx: 3, dy: -2 }, { enemy: true, id: 'barracks', dx: 4, dy: 3 }],
briefing: 'Cy-Bro opens with infantry. Armour answers infantry — get a Vehicle Plant up early.',
victory: 'Cy-Bro\'s foothold is slag.',
defeat: 'Overrun before the plant finished.',
},
{
id: 'm04', name: 'The Long Ridge', seed: 44377, size: 'medium', theme: 'grasslands', symmetry: 'rotational',
enemy: { commander: 'klaxon', skill: 3, aggression: 0.5 },
startResources: [{ mass: 2200, energy: 3600 }, { mass: 1300, energy: 2200 }],
prebuilt: [{ id: 'energygen', dx: 3, dy: -2 }, { id: 'energygen', dx: -3, dy: -2 }],
briefing: 'A fair fight on a wide map. Expand hard — whoever out-earns the other wins the third engagement.',
victory: 'The ridge is held.',
defeat: 'KLAXON out-produced us and it showed.',
},
{
id: 'm05', name: 'Tropic Vice', seed: 55930, size: 'medium', theme: 'tropics', symmetry: 'mirror-x',
enemy: { commander: 'cybro', skill: 4, aggression: 0.7 },
startResources: [{ mass: 2600, energy: 4200 }, { mass: 1500, energy: 2600 }],
prebuilt: [
{ id: 'energygen', dx: 3, dy: -2 }, { id: 'energygen', dx: -3, dy: -2 },
{ id: 'barracks', dx: 4, dy: 3 },
{ enemy: true, id: 'energygen', dx: 3, dy: 2 },
],
briefing: 'Cy-Bro starts ahead and pushes early. Rocket tanks out-range everything CORE fields.',
victory: 'Cy-Bro is scrap on the sand.',
defeat: 'The push came faster than the plant did.',
},
{
id: 'm06', name: 'Annihilation', seed: 63541, size: 'medium', theme: 'snowfields', symmetry: 'rotational',
enemy: { commander: 'klaxon', skill: 5, aggression: 0.85 },
startResources: [{ mass: 3200, energy: 5200 }, { mass: 1800, energy: 3000 }],
prebuilt: [
{ id: 'energygen', dx: 3, dy: -3 }, { id: 'energygen', dx: -3, dy: -3 },
{ id: 'barracks', dx: 4, dy: 3 }, { id: 'vehicleplant', dx: -4, dy: 3 },
{ enemy: true, id: 'energygen', dx: 3, dy: -3 }, { enemy: true, id: 'barracks', dx: 4, dy: 3 },
],
briefing: 'KLAXON at full strength with a base already standing. Nothing clever left — out-build it and end it.',
victory: 'Total annihilation. There is nothing left to fight.',
defeat: 'KLAXON was simply better prepared.',
},
];
function rowsFor(map) {
const rows = [];
for (let y = 0; y < map.h; y++) {
let s = '';
for (let x = 0; x < map.w; x++) s += CH[map.terrain[y * map.w + x]];
rows.push(s);
}
return rows;
}
const missions = MISSIONS.map((m) => {
const map = generateMap(rules, { seed: m.seed, size: m.size, theme: m.theme, symmetry: m.symmetry, armies: 2 });
const starts = map.starts.map((s) => ({ army: s.army, x: s.x, y: s.y }));
const enemyStart = starts.find((s) => s.army === 1) ?? starts[0];
const playerCmd = rules.commanders.find((c) => c.armyId === 'arm');
const enemyCmd = rules.commanderById[m.enemy.commander];
const playerStart = starts.find((s) => s.army === 0) ?? starts[0];
const buildings = (m.prebuilt ?? []).map((b) => {
const anchor = b.enemy ? enemyStart : playerStart;
return {
army: b.enemy ? 1 : 0, type: b.id,
tx: Math.max(1, Math.min(map.w - 4, anchor.x + b.dx)),
ty: Math.max(1, Math.min(map.h - 4, anchor.y + b.dy)),
};
});
return {
id: m.id, name: m.name, theme: m.theme, seed: m.seed,
playerArmy: 'arm', playerCommander: playerCmd.id,
enemies: [{
army: enemyCmd.armyId, commander: enemyCmd.id,
aiProfile: { skill: m.enemy.skill, aggression: m.enemy.aggression },
}],
startResources: m.startResources,
objective: { type: 'destroyAll' },
briefing: [{ speaker: playerCmd.opponentId, mood: 'idle', text: m.briefing }],
victoryLine: { speaker: playerCmd.opponentId, mood: 'happy', text: m.victory },
defeatLine: { speaker: enemyCmd.opponentId, mood: 'smug', text: m.defeat },
map: { w: map.w, h: map.h, theme: m.theme, rows: rowsFor(map), starts, buildings },
};
});
const out = { _comment: 'Generated by tools/genTACampaign.js — terrain is baked, design is hand-authored.', missions };
writeFileSync(join(ROOT, 'data/totalannihilation-campaign.json'), `${JSON.stringify(out, null, 1)}\n`);
console.log(`wrote ${missions.length} missions`);
for (const m of missions) console.log(` ${m.id} ${m.name.padEnd(16)} ${m.map.w}x${m.map.h} skill ${m.enemies[0].aiProfile.skill}`);

134
tools/lib/taStubScene.js Normal file
View File

@ -0,0 +1,134 @@
// Minimal Phaser stand-in for tools/verifyTotalAnnihilation.js.
//
// TAWorldView, TAFx and TAArt deliberately import no Phaser, so the real render path can be
// exercised in Node against this. It models the behaviour that matters and NOT the behaviour
// that would hide bugs — in particular a Container here draws in insertion order and only
// reorders when sort() is called, exactly like the real thing.
export function makeStubScene(w = 1920, h = 1080) {
const created = [];
const track = (o) => { created.push(o); return o; };
const base = (type, extra = {}) => {
const o = {
type, x: 0, y: 0, depth: 0, visible: true, alpha: 1, rotation: 0,
scaleX: 1, scaleY: 1, displayWidth: 0, displayHeight: 0, tint: null,
frame: null, texture: null, parent: null,
setPosition(x, y) { this.x = x; this.y = y; return this; },
setOrigin() { return this; },
setDepth(d) { this.depth = d; return this; },
setVisible(v) { this.visible = v; return this; },
setAlpha(a) { this.alpha = a; return this; },
setRotation(r) { this.rotation = r; return this; },
setScale(s) { this.scaleX = this.scaleY = s; return this; },
setDisplaySize(dw, dh) { this.displayWidth = dw; this.displayHeight = dh; return this; },
setTint(t) { this.tint = t; return this; },
setTexture(k) { this.texture = k; return this; },
setFrame(f) { this.frame = f; return this; },
setInteractive() { return this; },
setSize() { return this; },
setStrokeStyle() { return this; },
setFillStyle() { return this; },
on() { return this; },
destroy() { this.destroyed = true; },
...extra,
};
return track(o);
};
const gfx = () => base('graphics', {
ops: 0,
clear() { this.ops = 0; return this; },
lineStyle() { return this; }, fillStyle() { return this; },
beginPath() { return this; }, closePath() { return this; },
moveTo() { return this; }, lineTo() { return this; },
fillPath() { return this; }, strokePath() { return this; },
fillRect() { this.ops++; return this; }, strokeRect() { this.ops++; return this; },
fillCircle() { this.ops++; return this; }, strokeCircle() { this.ops++; return this; },
fillEllipse() { this.ops++; return this; }, strokeEllipse() { this.ops++; return this; },
lineBetween() { this.ops++; return this; }, fillTriangle() { this.ops++; return this; },
fillRoundedRect() { this.ops++; return this; }, strokeRoundedRect() { this.ops++; return this; },
});
const container = () => base('container', {
list: [],
add(o) { for (const c of [].concat(o)) { if (c) { c.parent = this; this.list.push(c); } } return this; },
remove() { return this; },
// Phaser Containers draw in INSERTION order and only reorder when sort() is called,
// so the stub must not quietly sort for us or it hides exactly the bug we're hunting.
sort(prop) { this.list = this.list.slice().sort((a, b) => (a[prop] ?? 0) - (b[prop] ?? 0)); return this; },
});
const ctxStub = new Proxy({}, {
get: (_t, p) => {
if (p === 'createImageData') return (w2, h2) => ({ data: new Uint8ClampedArray(w2 * h2 * 4) });
if (p === 'getImageData') return (x, y, w2, h2) => ({ data: new Uint8ClampedArray(w2 * h2 * 4) });
return () => {};
},
set: () => true,
});
const textures = new Map();
// Camera maths copied from Phaser's Camera.preRender so the stub agrees with the engine:
// midX = scrollX + width/2; worldView.x = midX - (width/zoom)/2
// Anything looser would let a broken zoom anchor pass.
const cam = (x = 0, y = 0, cw = w, ch = h) => ({
x, y, width: cw, height: ch,
scrollX: 0, scrollY: 0, zoom: 1, ignored: [],
worldView: { x: 0, y: 0, width: cw, height: ch, right: cw, bottom: ch },
setBounds() { return this; }, setBackgroundColor() { return this; },
setZoom(z) { this.zoom = z; this._sync(); return this; },
setScroll(sx, sy) { this.scrollX = sx; this.scrollY = sy; this._sync(); return this; },
centerOn(px, py) { this.scrollX = px - cw / 2; this.scrollY = py - ch / 2; this._sync(); return this; },
getWorldPoint(px, py) {
return { x: this.worldView.x + px / this.zoom, y: this.worldView.y + py / this.zoom };
},
ignore(o) { this.ignored.push(o); return this; },
shake() { return this; },
_sync() {
this.worldView.width = cw / this.zoom;
this.worldView.height = ch / this.zoom;
this.worldView.x = this.scrollX + cw / 2 - this.worldView.width / 2;
this.worldView.y = this.scrollY + ch / 2 - this.worldView.height / 2;
this.worldView.right = this.worldView.x + this.worldView.width;
this.worldView.bottom = this.worldView.y + this.worldView.height;
},
});
const main = cam();
const scene = {
created,
scale: { width: w, height: h },
time: { now: 1000, delayedCall: () => ({ remove() {} }) },
tweens: { add: () => ({}) },
input: { activePointer: { x: 0, y: 0 }, on() {}, off() {}, keyboard: { addKeys: () => ({}), on() {} }, mouse: { disableContextMenu() {} } },
events: { once() {}, on() {} },
cameras: { main, cameras: [main], add: () => cam(), remove() {} },
add: {
container: () => container(),
graphics: () => gfx(),
image: (x, y, key, frame) => base('image', { x, y, texture: key, frame }),
rectangle: (x, y, rw, rh) => base('rectangle', { x, y, width: rw, height: rh }),
text: (x, y, t) => base('text', { x, y, text: t, width: 10, height: 10, setText(v) { this.text = v; return this; }, setColor() { return this; } }),
renderTexture: (x, y, rw, rh) => base('renderTexture', {
x, y, width: rw, height: rh, draws: 0,
beginDraw() { return this; }, batchDraw() { this.draws++; return this; }, endDraw() { return this; },
draw() { this.draws++; return this; },
}),
circle: (x, y) => base('circle', { x, y }),
},
make: { image: (cfg) => base('image', { texture: cfg.key }), graphics: () => gfx() },
textures: {
exists: (k) => textures.has(k),
remove: (k) => textures.delete(k),
get: (k) => textures.get(k),
createCanvas(key, cw2, ch2) {
if (!Number.isFinite(cw2) || !Number.isFinite(ch2) || cw2 <= 0 || ch2 <= 0) {
throw new Error(`createCanvas(${key}) got ${cw2}x${ch2}`);
}
const frames = [];
const t = {
key, width: cw2, height: ch2, frames, context: ctxStub,
getContext: () => ctxStub, refresh() {}, setFilter() { return this; },
add(f) { frames.push(f); return this; },
};
textures.set(key, t);
return t;
},
},
};
return scene;
}

View File

@ -0,0 +1,856 @@
#!/usr/bin/env node
// Verifies the Total Annihilation engine, data and AI end to end.
//
// node tools/verifyTotalAnnihilation.js [--quick]
//
// Everything here runs headless: TARules/TALogic/TANav/TAMapGen/TAAI import no Phaser. The
// campaign section is the payoff of routing every order through TALogic.issueOrder — the AI
// plays the HUMAN side, so each mission gets an automated winnability assertion rather than a
// promise that it is probably beatable.
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { compileRules, armorMul } from '../src/games/totalannihilation/TARules.js';
import { generateMap, decodeMap } from '../src/games/totalannihilation/TAMapGen.js';
import * as L from '../src/games/totalannihilation/TALogic.js';
import { runAI } from '../src/games/totalannihilation/TAAI.js';
import { ensureSheets } from '../src/games/totalannihilation/TAArt.js';
import TAWorldView, { DEPTHS } from '../src/games/totalannihilation/TAWorldView.js';
import TAFx from '../src/games/totalannihilation/TAFx.js';
import { makeStubScene } from './lib/taStubScene.js';
import {
createNav, findPath, clearanceFor, segmentClear, tileOk, worldToTileX, worldToTileY,
} from '../src/games/totalannihilation/TANav.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const QUICK = process.argv.includes('--quick');
const rulesJson = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
const artJson = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-artwork.json'), 'utf8'));
const campaign = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-campaign.json'), 'utf8'));
const opponents = JSON.parse(readFileSync(join(ROOT, 'data/opponents.json'), 'utf8'));
const rules = compileRules(rulesJson);
const HZ = rules.constants.tickHz;
let pass = 0; const failures = [];
function check(name, cond, detail) {
if (cond) { pass++; return true; }
failures.push(detail ? `${name}${detail}` : name);
return false;
}
function section(title) { console.log(`\n── ${title}`); }
// ---------------------------------------------------------------------------
section('1. Rules integrity');
// ---------------------------------------------------------------------------
{
const ids = new Set();
for (const d of [...rules.units, ...rules.buildings]) {
check(`unique def id ${d.id}`, !ids.has(d.id), 'duplicate');
ids.add(d.id);
}
for (const d of [...rules.units, ...rules.buildings]) {
check(`${d.id} costs are positive`, (d.cost?.mass ?? 0) >= 0 && (d.cost?.energy ?? 0) >= 0);
check(`${d.id} has hp`, d.hp > 0);
for (const w of d.weaponDefs ?? []) {
for (const a of rules.armorClasses) {
check(`${w.id} vs ${a}`, Number.isFinite(armorMul(w, a)), 'missing armorMul');
}
}
}
for (const u of rules.units) {
if (!u.buildTime) continue;
const makers = rules.buildings.filter((b) => (b.builds ?? []).includes(u.id));
check(`${u.id} is buildable by some factory`, makers.length > 0);
}
// Every producible thing must be reachable from the Commander's tech tree, or the player
// can see it in the rules and never build it.
const commander = rules.unitById.commander;
const reach = new Set(commander.builds ?? []);
for (let i = 0; i < 4; i++) {
for (const id of [...reach]) for (const n of rules.defById[id].builds ?? []) reach.add(n);
}
for (const d of [...rules.units, ...rules.buildings]) {
if (d.id === 'commander') continue;
check(`${d.id} reachable from the Commander`, reach.has(d.id));
}
for (const c of rules.commanders) {
check(`commander ${c.id} maps to a real opponent`,
(opponents.opponents ?? []).some((o) => o.id === c.opponentId), c.opponentId);
check(`commander ${c.id} army exists`, !!rules.armyById[c.armyId]);
}
// The D-Gun must stay manual — auto-firing it turns every Commander into a base turret
// that deletes one attacker per reload, which makes assaulting anything suicide.
check('D-Gun is manual-fire', rules.weaponById.dgun.manual === true);
check('Commander has a non-manual weapon',
(commander.weaponDefs ?? []).some((w) => !w.manual), 'would be defenceless');
}
// ---------------------------------------------------------------------------
section('2. Artwork manifest');
// ---------------------------------------------------------------------------
{
const sheets = artJson.sheets ?? {};
check('artwork declares sheets', Object.keys(sheets).length > 0);
for (const [name, s] of Object.entries(sheets)) {
check(`sheet ${name} has a key`, !!s.key);
check(`sheet ${name} has frame size`, s.frameWidth > 0 && s.frameHeight > 0);
check(`sheet ${name} has a kind`, ['unit', 'structure', 'terrain', 'icon'].includes(s.kind), s.kind);
}
for (const a of rules.armies) {
check(`army ${a.id} unit sheet declared`, !!sheets[a.unitSheet], a.unitSheet);
check(`army ${a.id} structure sheet declared`, !!sheets[a.structureSheet], a.structureSheet);
}
for (const [id, t] of Object.entries(artJson.themes ?? {})) {
check(`theme ${id} sheet declared`, !!sheets[t.sheet], t.sheet);
for (const terr of rules.terrain) {
check(`theme ${id} has a colour for ${terr.id}`, !!t.palette?.[terr.id]);
}
}
// terrainFrames drives the procedural terrain sheet's size. It also carries a `_comment`
// key, and one non-numeric value in there turns the frame count into NaN and the painted
// sheet into a zero-height canvas — which fails in the browser as an opaque WebGL error,
// so it gets caught here instead.
const tf = Object.entries(artJson.terrainFrames ?? {}).filter(([k]) => !k.startsWith('_'));
check('terrainFrames declares frames', tf.length > 0);
for (const [name, v] of tf) {
check(`terrainFrames.${name} is a frame index`, Number.isInteger(v) && v >= 0, String(v));
}
for (const terr of rules.terrain) {
check(`terrain "${terr.id}" has a frame in terrainFrames`,
tf.some(([name]) => name === terr.id) || Number.isInteger(terr.frame));
}
const maxTerrainFrame = Math.max(...tf.map(([, v]) => v)) + 1;
for (const [name, sheet] of Object.entries(sheets)) {
if (sheet.kind !== 'terrain') continue;
const cols = sheet.cols ?? 8;
check(`${name} layout is fully numeric`,
[sheet.frameWidth, sheet.frameHeight, cols].every((v) => Number.isFinite(v) && v > 0));
check(`${name} holds every terrain frame`, maxTerrainFrame <= cols * Math.ceil(maxTerrainFrame / cols));
}
// Frame indices must exist within each sheet's declared capacity.
for (const d of [...rules.units, ...rules.buildings]) {
for (const a of rules.armies) {
const sheet = sheets[d.sheetSlot === 'unitSheet' ? a.unitSheet : a.structureSheet];
const cap = (sheet.cols ?? 8) * (sheet.rows ?? 8);
check(`${d.id} frame ${d.frame} fits ${sheet.key}`, d.frame < cap);
if (d.turretFrame != null) check(`${d.id} turret frame fits`, d.turretFrame < cap);
}
}
}
// ---------------------------------------------------------------------------
section('2b. Procedural art actually paints');
// ---------------------------------------------------------------------------
{
// TAArt imports no Phaser, so the painters can be run right here against a stub canvas.
// Checking the artwork JSON alone is not enough: the terrain sheet's frame count is DERIVED
// from that JSON, and a bad derivation produced a zero-height texture that only failed in
// the browser, as a WebGL error pointing nowhere near the cause. Running the painters also
// catches NaN geometry, which paints nothing at all and is otherwise invisible until a
// player sees a blank tank.
const bad = [];
const num = (where, ...vals) => {
for (const v of vals) if (typeof v === 'number' && !Number.isFinite(v)) bad.push(where);
};
const mkCtx = () => new Proxy({}, {
get: (_t, prop) => {
if (prop === 'save' || prop === 'restore' || prop === 'beginPath' || prop === 'closePath'
|| prop === 'fill' || prop === 'stroke') return () => {};
if (typeof prop === 'string') return (...args) => num(prop, ...args);
return () => {};
},
set: (_t, prop, value) => { num(String(prop), value); return true; },
});
const made = new Map();
const scene = {
textures: {
exists: (k) => made.has(k),
remove: (k) => made.delete(k),
createCanvas(key, w, h) {
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
bad.push(`createCanvas(${key}, ${w}, ${h})`);
}
const frames = [];
const tex = {
width: w, height: h, frames,
getContext: () => mkCtx(),
refresh() {},
add(f, _s, fx, fy, fw, fh) {
num(`add(${key})`, fx, fy, fw, fh);
if (fx + fw > w || fy + fh > h) bad.push(`frame ${f} outside ${key}`);
frames.push(f);
},
};
made.set(key, tex);
return tex;
},
},
};
const { keys, procedural } = ensureSheets(scene, rules, artJson);
for (const name of Object.keys(artJson.sheets ?? {})) {
check(`sheet ${name} resolves to a texture key`, !!keys[name]);
const tex = made.get(keys[name]);
check(`sheet ${name} painted a non-empty canvas`, !!tex && tex.width > 0 && tex.height > 0,
tex ? `${tex.width}x${tex.height}` : 'no texture');
check(`sheet ${name} registered frames`, !!tex && tex.frames.length > 0);
}
check('every sheet fell back to a painted stand-in', procedural.length === Object.keys(artJson.sheets ?? {}).length,
`${procedural.length} procedural`);
check('no painter emitted a non-finite value', bad.length === 0, [...new Set(bad)].slice(0, 5).join(', '));
// Every frame a def references must have been registered by the painter that owns it.
for (const d of [...rules.units, ...rules.buildings]) {
for (const a of rules.armies) {
const sheetName = d.sheetSlot === 'unitSheet' ? a.unitSheet : a.structureSheet;
const tex = made.get(keys[sheetName]);
check(`${d.id} frame ${d.frame} was painted on ${sheetName}`, tex?.frames.includes(d.frame));
if (d.turretFrame != null) {
check(`${d.id} turret frame was painted`, tex?.frames.includes(d.turretFrame));
}
if (d.buildFrame != null) {
check(`${d.id} build frame was painted`, tex?.frames.includes(d.buildFrame));
}
}
}
for (const [name, sheet] of Object.entries(artJson.sheets ?? {})) {
if (sheet.kind !== 'terrain') continue;
const tex = made.get(keys[name]);
for (const terr of rules.terrain) {
check(`terrain ${terr.id} frame ${terr.frame} painted on ${name}`, tex?.frames.includes(terr.frame));
}
}
}
// ---------------------------------------------------------------------------
section('2c. View layering and first frame');
// ---------------------------------------------------------------------------
{
// TAWorldView and TAFx import no Phaser either, so the real render path runs here against
// a stub scene. This section exists because two bugs got past every data-level check and
// straight into the browser: the fog sheet drew UNDER the terrain (a Phaser Container
// renders in insertion order and never sorts by depth on its own), and the camera walked
// off the map before the first frame. Both are invisible to any test that only inspects
// simulation state.
const map = generateMap(rules, { seed: 123456, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', armies: 2 });
const st = L.createMatch(rules, { seed: 123456, map, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }] });
const scene = makeStubScene();
let view = null;
try {
view = new TAWorldView(scene, rules, artJson, st, 0);
const fx = new TAFx(scene, view.worldRoot, DEPTHS);
view.setFogEnabled(true);
const start = st.starts.find((x) => x.army === 0);
view.centerOn(start.x * st.tileSize, start.y * st.tileSize);
const out = view.render(0);
check('render() returns fx payloads', !!out && Array.isArray(out.projectiles) && Array.isArray(out.nanoLinks));
const order = view.worldRoot.list;
const lastChunk = Math.max(-1, ...order.map((o, i) => (o.type === 'renderTexture' ? i : -1)));
check('terrain chunks were painted', lastChunk >= 0);
check('fog draws ON TOP of terrain', order.indexOf(view.fogImg) > lastChunk,
`fog at ${order.indexOf(view.fogImg)}, last chunk at ${lastChunk}`);
check('fx layers are in the world container',
order.includes(fx.gUnder) && order.includes(fx.gOver));
const own = st.entities.find((e) => e.army === 0);
const foe = st.entities.find((e) => e.army === 1);
const ownSprite = view.sprites.get(own.id);
check('the player\'s starting unit has a sprite', !!ownSprite);
check('own unit draws on top of terrain', order.indexOf(ownSprite.img) > lastChunk);
check('own unit is visible through fog', ownSprite.img.visible);
check('the enemy is hidden at match start', !view.visibleToPlayer(foe));
// The Commander must be ON SCREEN when the match opens — an off-screen start reads to a
// player as "I have no units" and there is nothing they can do about it.
const cam = scene.cameras.main.worldView;
const onScreen = own.x >= cam.x && own.x <= cam.x + cam.width
&& own.y >= cam.y && own.y <= cam.y + cam.height;
check('the opening camera frames the player\'s Commander', onScreen,
`unit ${own.x.toFixed(0)},${own.y.toFixed(0)} vs view ${cam.x.toFixed(0)},${cam.y.toFixed(0)} ${cam.width}x${cam.height}`);
// Wheel zoom must keep the world point under the cursor fixed, at any cursor position —
// including well away from the screen centre, which is where a centre-anchored zoom (or a
// correction computed from a stale camera matrix) visibly drifts.
for (const [fx2, fy2] of [[960, 540], [320, 220], [1700, 900], [40, 1040]]) {
for (const dir of [1, 1, -1, -1, -1, 1]) {
const beforePt = view.worldPoint(fx2, fy2);
const beforeZoom = view.zoom;
view.zoomBy(dir, fx2, fy2);
if (view.zoom === beforeZoom) continue; // already at the end of the ladder
const afterPt = view.worldPoint(fx2, fy2);
const drift = Math.hypot(afterPt.x - beforePt.x, afterPt.y - beforePt.y);
check(`zoom at (${fx2},${fy2}) keeps the cursor's world point fixed`, drift < 0.5,
`drifted ${drift.toFixed(1)}px`);
}
}
// Depth bands must stay in the intended order.
check('bars draw above actors', DEPTHS.bars > DEPTHS.actor);
check('fog draws above everything', DEPTHS.fog > Math.max(DEPTHS.fxOver, DEPTHS.bars, DEPTHS.actor));
check('selection draws below actors', DEPTHS.selection < DEPTHS.actor);
} catch (e) {
check('the view layer builds and renders a frame', false, e.message);
}
}
// ---------------------------------------------------------------------------
section('2d. Container hitbox lint');
// ---------------------------------------------------------------------------
{
// TAHud/TAScreens import Phaser directly, so they cannot be constructed here. This is a
// source lint instead, guarding one specific mistake that is invisible until a human tries
// to click something: a Phaser Container's origin is hard-coded to 0.5, so setInteractive()
// centres the hit area on the container's position. A child drawn from a top-left origin
// then sits half a button down-right of the region that actually receives the click.
const files = ['src/games/totalannihilation/TAHud.js', 'src/games/totalannihilation/TAScreens.js'];
for (const rel of files) {
const src = readFileSync(join(ROOT, rel), 'utf8');
const lines = src.split('\n');
let offenders = 0;
lines.forEach((line, i) => {
if (!/add\.container\(/.test(line)) return;
// Look at the block this container is built in.
const block = lines.slice(i, i + 26).join('\n');
if (!/setInteractive\(/.test(block)) return;
if (/setOrigin\(\s*0\s*,\s*0\s*\)/.test(block)) offenders++;
});
check(`${rel.split('/').pop()} has no top-left-origin child in an interactive container`,
offenders === 0, `${offenders} block(s)`);
}
}
// ---------------------------------------------------------------------------
section('3. Economy fixtures');
// ---------------------------------------------------------------------------
{
const map = generateMap(rules, { seed: 7, size: 'small', symmetry: 'mirror-x' });
const st = L.createMatch(rules, { seed: 7, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
const a = st.armies[0];
a.mass = 0; a.energy = 0;
for (let i = 0; i < HZ; i++) L.tick(st, rules);
check('idle army banks its commander income', a.energy > 0 && a.mass > 0);
// Building an Energy Generator takes exactly buildTime seconds at nominal build power,
// given enough stored resources that nothing stalls.
const st2 = L.createMatch(rules, { seed: 8, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
const b = st2.armies[0];
b.mass = 99999; b.energy = 99999; b.massCap = 99999; b.energyCap = 99999;
const cmd = st2.entities.find((e) => e.army === 0);
const gen = rules.buildingById.energygen;
let tx = worldToTileX(st2.nav, cmd.x) + 2, ty = worldToTileY(st2.nav, cmd.y);
while (!L.canPlaceAt(st2, rules, tx, ty, gen) && tx < st2.w - 4) tx++;
const r = L.issueOrder(st2, rules, { army: 0, unitIds: [cmd.id], order: { type: 'build', defId: 'energygen', tx, ty } });
check('build order accepted', r.ok, r.error);
let ticks = 0;
while (ticks < 200 * HZ) {
b.mass = 99999; b.energy = 99999; // hold the economy open so only build power matters
L.tick(st2, rules); ticks++;
const site = st2.entities.find((e) => e.defId === 'energygen');
if (site && !site.site) break;
}
const secs = ticks / HZ;
const expected = gen.buildTime * (rules.constants.buildPowerNominal / rules.unitById.commander.buildPower);
check('unstalled build takes buildTime seconds', Math.abs(secs - expected) <= expected * 0.35 + 2,
`${secs.toFixed(1)}s vs ${expected}s`);
// Stall factor: halve the available mass flow and construction must slow, not stop.
const st3 = L.createMatch(rules, { seed: 9, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
const c = st3.armies[0];
c.mass = 0; c.energy = 0;
L.tick(st3, rules);
check('stall factors stay in [0,1]', c.stallM >= 0 && c.stallM <= 1 && c.stallE >= 0 && c.stallE <= 1);
check('buildEff is the binding factor', Math.abs(c.buildEff - Math.min(c.stallE, c.stallM)) < 1e-9);
// Storage is capped and the overflow is discarded rather than silently banked.
const st4 = L.createMatch(rules, { seed: 10, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
const d = st4.armies[0];
d.energy = d.energyCap; d.mass = d.massCap;
for (let i = 0; i < HZ * 5; i++) L.tick(st4, rules);
check('stored energy never exceeds cap', d.energy <= d.energyCap + 1e-6);
check('stored mass never exceeds cap', d.mass <= d.massCap + 1e-6);
// A Mass Generator on a metal patch must out-yield one on plain ground.
const mgen = rules.buildingById.massgen;
check('mass generator has a terrain multiplier', !!mgen.terrainMultiplier);
const multTerrain = rules.terrain.find((t) => t[mgen.terrainMultiplier] > 1);
check('some terrain carries that multiplier', !!multTerrain);
}
// ---------------------------------------------------------------------------
section('4. Pathfinding');
// ---------------------------------------------------------------------------
{
const ts = rules.constants.tileSize;
const mk = (rows) => {
const w = rows[0].length, h = rows.length;
const terrain = new Uint8Array(w * h);
const wall = rules.terrainByCh['^'].index, open = rules.terrainByCh['.'].index;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) terrain[y * w + x] = rows[y][x] === '#' ? wall : open;
return createNav(rules, { w, h, terrain });
};
const nav = mk([
'..........',
'..######..',
'..#....#..',
'..#....#..',
'..######..',
'..........',
]);
const mc = 'tread';
check('tread is a declared movement class', mc in rules.moveClasses);
const at = (nav2, x, y) => y * nav2.w + x;
const p = findPath(nav, mc, 1, at(nav, 0, 0), at(nav, 9, 5));
check('A* finds a route around an obstacle', !!p && p.length > 0);
const inside = findPath(nav, mc, 1, at(nav, 0, 0), at(nav, 4, 3));
check('sealed region is unreachable', !inside);
// Clearance: a wide unit must refuse a one-tile gap but accept a wide corridor.
const narrow = mk([
'####.####',
'####.####',
'####.####',
]);
const wide = mk([
'##.....##',
'##.....##',
'##.....##',
]);
const big = clearanceFor(rules.sizeClasses.large.radius, ts);
check('large clearance requirement is > 1 tile', big > 1);
check('large unit refuses a 1-wide corridor', !findPath(narrow, mc, big, 4, at(narrow, 4, 2)));
check('large unit accepts a wide corridor', !!findPath(wide, mc, big, at(wide, 3, 0), at(wide, 5, 2)));
// String-pulling must never shortcut through a blocked tile.
const blocked = mk(['...', '.#.', '...']);
check('segmentClear rejects a line through a wall',
!segmentClear(blocked, mc, 1, 0.5 * ts, 0.5 * ts, 2.5 * ts, 2.5 * ts));
check('segmentClear accepts a clear line',
segmentClear(blocked, mc, 1, 0.5 * ts, 0.5 * ts, 2.5 * ts, 0.5 * ts));
}
// ---------------------------------------------------------------------------
section('5. Movement, separation and size classes');
// ---------------------------------------------------------------------------
{
const map = generateMap(rules, { seed: 21, size: 'small', symmetry: 'mirror-x' });
const st = L.createMatch(rules, { seed: 21, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
// Find a genuinely open tile with open neighbours — the middle of a generated map is as
// likely to be a lake as a field, and units shoved out of water prove nothing.
const openTile = (() => {
for (let y = 3; y < map.h - 3; y++) {
for (let x = 3; x < map.w - 3; x++) {
let ok = true;
for (let dy = -2; dy <= 2 && ok; dy++) {
for (let dx = -2; dx <= 2 && ok; dx++) {
if (!tileOk(st.nav, 'foot', 1, x + dx, y + dy)) ok = false;
}
}
if (ok) return { x, y };
}
}
return { x: (map.w / 2) | 0, y: (map.h / 2) | 0 };
})();
const cx = openTile.x * st.tileSize + st.tileSize / 2;
const cy = openTile.y * st.tileSize + st.tileSize / 2;
check('found open ground for the packing fixture', tileOk(st.nav, 'foot', 1, openTile.x, openTile.y));
// The size-class requirement, asserted: three smalls share one tile, two mediums cannot.
const packed = (defId, n) => {
const s2 = L.createMatch(rules, { seed: 21, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
const made = [];
for (let i = 0; i < n; i++) {
const u = L.spawnUnit(s2, rules, 0, defId, cx + (i - n / 2) * 6, cy + (i % 2) * 6);
if (u) made.push(u);
}
for (let i = 0; i < 60; i++) L.tick(s2, rules);
const ts = s2.tileSize;
return made.filter((u) => Math.abs(u.x - cx) <= ts / 2 && Math.abs(u.y - cy) <= ts / 2).length;
};
// The size-class contract is a statement about collision radii, so assert it there — and
// then confirm the emergent behaviour matches, which is the part a player actually sees.
const ts0 = rules.constants.tileSize;
const small = rules.sizeClasses.small, medium = rules.sizeClasses.medium, large = rules.sizeClasses.large;
check('small units are under half a tile wide', small.radius * 2 <= ts0 / 2, `${small.radius * 2}px`);
check('medium units are about one tile wide',
medium.radius * 2 > ts0 / 2 && medium.radius * 2 <= ts0, `${medium.radius * 2}px`);
check('large units span more than one tile', large.radius * 2 > ts0, `${large.radius * 2}px`);
check('large units claim a multi-tile footprint', (large.footprint ?? 1) > 1);
check('3 small units settle inside one tile', packed('infantry', 3) >= 3, `${packed('infantry', 3)}`);
check('3 medium units cannot settle inside one tile', packed('tank', 3) < 3, `${packed('tank', 3)}`);
// Settled units must not overlap.
for (let i = 0; i < 12; i++) L.spawnUnit(st, rules, 0, 'tank', cx + (i % 4) * 30, cy + Math.floor(i / 4) * 30);
for (let i = 0; i < 120; i++) L.tick(st, rules);
const mine = st.entities.filter((e) => !e.dead && e.army === 0 && e.defId === 'tank');
let worst = 0;
for (let i = 0; i < mine.length; i++) {
for (let j = i + 1; j < mine.length; j++) {
const overlap = (mine[i].radius + mine[j].radius) - Math.hypot(mine[i].x - mine[j].x, mine[i].y - mine[j].y);
worst = Math.max(worst, overlap);
}
}
check('settled units barely overlap', worst < 12, `worst overlap ${worst.toFixed(1)}px`);
check('all positions finite', st.entities.every((e) => Number.isFinite(e.x) && Number.isFinite(e.y)));
}
// ---------------------------------------------------------------------------
section('6. Combat');
// ---------------------------------------------------------------------------
{
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
raw.constants.eliminateWhenUnrecoverable = false; // these fixtures field no builders
const combatRules = compileRules(raw);
const map = generateMap(combatRules, { seed: 31, size: 'small', symmetry: 'mirror-x' });
const cx = (map.w / 2) * combatRules.constants.tileSize, cy = (map.h / 2) * combatRules.constants.tileSize;
const duel = (aId, na, bId, nb, sec = 150) => {
const st = L.createMatch(combatRules, { seed: 31, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
for (const e of st.entities) e.dead = true;
st.entities = [];
const line = (army, id, n, dx) => {
for (let i = 0; i < n; i++) {
L.spawnUnit(st, combatRules, army, id,
cx + dx - Math.sign(dx) * Math.floor(i / 10) * 40, cy - 180 + (i % 10) * 40);
}
};
line(0, aId, na, -240); line(1, bId, nb, 240);
st.over = null;
for (const a of st.armies) a.alive = true;
const ids = (army) => st.entities.filter((e) => e.army === army && !e.dead).map((e) => e.id);
L.issueOrder(st, combatRules, { army: 0, unitIds: ids(0), order: { type: 'attackMove', x: cx + 240, y: cy } });
L.issueOrder(st, combatRules, { army: 1, unitIds: ids(1), order: { type: 'attackMove', x: cx - 240, y: cy } });
let t = 0;
for (; t < sec * HZ; t++) {
L.tick(st, combatRules);
const a0 = st.entities.some((e) => !e.dead && e.army === 0);
const a1 = st.entities.some((e) => !e.dead && e.army === 1);
if (!a0 || !a1) break;
}
const left = (a) => st.entities.filter((e) => !e.dead && e.army === a).length;
const hp = (a) => st.entities.filter((e) => !e.dead && e.army === a).reduce((s, e) => s + e.hp, 0);
return { a: left(0), b: left(1), hpA: hp(0), hpB: hp(1), secs: t / HZ };
};
const cost = (id) => combatRules.unitById[id].cost.mass + combatRules.unitById[id].cost.energy / 4;
const equal = (a, b, n = 8) => {
const nb = Math.max(1, Math.round(n * cost(a) / cost(b)));
const r = duel(a, n, b, nb);
// Score on surviving fraction of the force each side paid for, so a slow grind that the
// tanks are clearly winning counts as a win even if the clock runs out first.
r.fracA = r.a / n; r.fracB = r.b / nb;
return r;
};
// Ballistic shells travel 35px per 20Hz tick, so a point-sampled hit test tunnels straight
// through its target. Tanks doing real damage is the observable proof the sweep works.
const tvi = equal('tank', 'infantry');
check('tanks beat equal-cost infantry', tvi.fracA > tvi.fracB,
`${tvi.a} tanks (${(tvi.fracA * 100) | 0}%) v ${tvi.b} infantry (${(tvi.fracB * 100) | 0}%)`);
const rvs = equal('rockettank', 'sniper');
check('rocket tanks beat equal-cost snipers', rvs.fracA > rvs.fracB,
`${rvs.a} (${(rvs.fracA * 100) | 0}%) v ${rvs.b} (${(rvs.fracB * 100) | 0}%)`);
// Rockets out-range and out-damage armour; that is what makes the roster a triangle
// rather than a ladder where one unit is simply the answer.
const rvt = equal('rockettank', 'tank');
check('rocket tanks beat equal-cost tanks', rvt.fracA > rvt.fracB,
`${rvt.a} (${(rvt.fracA * 100) | 0}%) v ${rvt.b} (${(rvt.fracB * 100) | 0}%)`);
// Engagements must take long enough for reinforcement and composition to matter. When
// fights resolved in 2-4 seconds the whole AI skill ladder collapsed to a coin flip.
check('an even engagement is not instant', tvi.secs > 5, `${tvi.secs.toFixed(1)}s`);
// Damage fixtures.
const st = L.createMatch(combatRules, { seed: 32, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
const victim = L.spawnUnit(st, combatRules, 1, 'tank', cx, cy);
const before = victim.hp;
L.applyDamage(st, combatRules, victim, 100, { army: 0, id: 0 });
check('applyDamage subtracts exactly', Math.abs((before - victim.hp) - 100) < 1e-9);
const rifle = combatRules.weaponById.rifle;
check('rifle is strong vs infantry, weak vs medium',
armorMul(rifle, 'infantry') > armorMul(rifle, 'medium') * 3);
const rocket = combatRules.weaponById.rocketpod;
check('rockets favour armour over infantry', armorMul(rocket, 'heavy') > armorMul(rocket, 'infantry') * 3);
// Commander death explosion.
const dth = combatRules.unitById.commander.deathExplosion;
check('commander has a death explosion', dth && dth.radius > 200 && dth.damage > 500);
}
// ---------------------------------------------------------------------------
section('7. Fog of war');
// ---------------------------------------------------------------------------
{
const map = generateMap(rules, { seed: 41, size: 'small', symmetry: 'mirror-x' });
const st = L.createMatch(rules, { seed: 41, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
const mine = st.entities.find((e) => e.army === 0);
const theirs = st.entities.find((e) => e.army === 1);
L.computeVision(st, rules);
check('own units are always visible', L.isVisibleTo(st, 0, mine));
check('a distant enemy starts hidden', !L.isVisibleTo(st, 0, theirs));
// Explored is sticky; visible is not.
const cell = st.tileSize * 2;
const vx = Math.floor(mine.x / cell), vy = Math.floor(mine.y / cell);
const idx = vy * st.visW + vx;
check('own tile is explored', st.armies[0].explored[idx] === 1);
L.spawnUnit(st, rules, 0, 'jeep', theirs.x, theirs.y);
L.computeVision(st, rules);
check('scouting reveals the enemy', L.isVisibleTo(st, 0, theirs));
// The AI must be fog-limited too — it may not target something it cannot see.
const st2 = L.createMatch(rules, { seed: 42, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
for (let i = 0; i < HZ * 3; i++) { runAI(rules, st2, 0, { skill: 5 }); L.tick(st2, rules); }
const mem = st2.aiMem?.[0];
const cheated = (mem?.knownEnemies ?? []).filter((k) => {
const e = L.entityById(st2, k.id);
return e && !L.isVisibleTo(st2, 0, e) && k.tick === st2.tick;
});
check('AI never records an unseen enemy as seen-now', cheated.length === 0, `${cheated.length} leaks`);
}
// ---------------------------------------------------------------------------
section('8. Map generation');
// ---------------------------------------------------------------------------
{
const seeds = QUICK ? 24 : 120;
let symMismatch = 0, badStarts = 0, poorMetal = 0, unreachable = 0;
const sizes = Object.keys(rules.skirmish.sizes);
for (let i = 0; i < seeds; i++) {
const size = sizes[i % sizes.length];
const sym = rules.skirmish.symmetries[i % rules.skirmish.symmetries.length];
const map = generateMap(rules, { seed: 5000 + i * 13, size, symmetry: sym, armies: 2 });
if (map.starts.length !== 2) { badStarts++; continue; }
if (sym === 'mirror-x') {
for (let y = 0; y < map.h; y++) {
for (let x = 0; x < map.w; x++) {
if (map.terrain[y * map.w + x] !== map.terrain[y * map.w + (map.w - 1 - x)]) { symMismatch++; y = map.h; break; }
}
}
}
// Every start needs metal within reach or its economy can never leave the ground.
const mgen = rules.buildingById.massgen;
for (const s of map.starts) {
let spots = 0;
const R = rules.skirmish.gen.metalSpotRadiusTiles;
for (let y = Math.max(0, s.y - R); y < Math.min(map.h, s.y + R); y++) {
for (let x = Math.max(0, s.x - R); x < Math.min(map.w, s.x + R); x++) {
if (rules.terrain[map.terrain[y * map.w + x]][mgen.terrainMultiplier] > 1) spots++;
}
}
if (spots < 4) poorMetal++;
}
// And the two starts must be mutually reachable by a medium ground unit.
const nav = createNav(rules, map);
const clr = clearanceFor(rules.sizeClasses.medium.radius, rules.constants.tileSize);
const path = findPath(nav, 'tread', clr,
map.starts[0].y * map.w + map.starts[0].x, map.starts[1].y * map.w + map.starts[1].x);
if (!path) unreachable++;
}
check('every generated map places both starts', badStarts === 0, `${badStarts} bad`);
check('mirror-x maps are exactly symmetric', symMismatch === 0, `${symMismatch} mismatches`);
check('every start has metal nearby', poorMetal === 0, `${poorMetal} starved starts`);
check('starts are mutually reachable', unreachable === 0, `${unreachable} unreachable`);
}
// ---------------------------------------------------------------------------
section('9. Campaign data');
// ---------------------------------------------------------------------------
{
const missions = campaign.missions ?? [];
check('campaign has missions', missions.length > 0);
const OBJECTIVES = new Set(['destroyAll', 'destroyCommander', 'survive', 'holdArea', 'reachArea', 'protect', 'buildCount']);
missions.forEach((m, i) => {
let map = null;
try { map = decodeMap(rules, m.map); } catch (e) { /* reported below */ }
check(`${m.id} terrain decodes`, !!map);
if (!map) return;
check(`${m.id} declares both starts`, (map.starts ?? []).length >= 2);
check(`${m.id} theme exists`, !!artJson.themes?.[m.theme], m.theme);
check(`${m.id} player army exists`, !!rules.armyById[m.playerArmy]);
check(`${m.id} player commander exists`, !!rules.commanderById[m.playerCommander]);
check(`${m.id} objective is a known type`, OBJECTIVES.has(m.objective?.type), m.objective?.type);
for (const e of m.enemies ?? []) {
check(`${m.id} enemy commander exists`, !!rules.commanderById[e.commander], e.commander);
const sk = e.aiProfile?.skill;
check(`${m.id} enemy skill is 1-5`, sk >= 1 && sk <= 5, String(sk));
}
for (const b of map.buildings ?? []) {
check(`${m.id} prebuilt ${b.type} exists`, !!rules.buildingById[b.type], b.type);
check(`${m.id} prebuilt ${b.type} is on the map`, b.tx >= 0 && b.ty >= 0 && b.tx < map.w && b.ty < map.h);
}
for (const line of [...(m.briefing ?? []), m.victoryLine, m.defeatLine].filter(Boolean)) {
check(`${m.id} speaker ${line.speaker} exists`,
(opponents.opponents ?? []).some((o) => o.id === line.speaker), line.speaker);
}
// Missions must get harder, or the unlock order is meaningless.
if (i > 0) {
const prev = missions[i - 1].enemies?.[0]?.aiProfile?.skill ?? 0;
check(`${m.id} is no easier than the one before`, (m.enemies?.[0]?.aiProfile?.skill ?? 0) >= prev);
}
});
}
// ---------------------------------------------------------------------------
section('10. Serialization and determinism');
// ---------------------------------------------------------------------------
{
const map = generateMap(rules, { seed: 51, size: 'small', symmetry: 'mirror-x' });
const st = L.createMatch(rules, { seed: 51, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
for (let i = 0; i < HZ * 20; i++) { runAI(rules, st, 0, { skill: 3 }); runAI(rules, st, 1, { skill: 3 }); L.tick(st, rules); }
const blob = L.serialize(st);
const back = L.deserialize(rules, blob);
check('serialize round-trips to the same hash', L.hashState(back) === L.hashState(st));
check('serialized save is a sane size', blob.length < 400000, `${blob.length} bytes`);
const run = () => {
const m2 = generateMap(rules, { seed: 52, size: 'small', symmetry: 'mirror-x' });
const s2 = L.createMatch(rules, { seed: 52, map: m2, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
for (let i = 0; i < HZ * 30; i++) { runAI(rules, s2, 0, { skill: 4 }); runAI(rules, s2, 1, { skill: 2 }); L.tick(s2, rules); }
return L.hashState(s2);
};
check('same seed replays identically', run() === run());
// The step() harness must produce the same result as driving tick() directly.
const m3 = generateMap(rules, { seed: 53, size: 'small', symmetry: 'mirror-x' });
const byTick = L.createMatch(rules, { seed: 53, map: m3, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
for (let i = 0; i < 100; i++) L.tick(byTick, rules);
const byStep = L.createMatch(rules, { seed: 53, map: m3, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
for (let i = 0; i < 100; i++) L.step(byStep, rules, rules.stepMs, 4);
check('step() and tick() agree', L.hashState(byStep) === L.hashState(byTick));
}
// ---------------------------------------------------------------------------
section('11. Skirmish soak');
// ---------------------------------------------------------------------------
{
const games = QUICK ? 8 : 24;
let decided = 0, negative = 0, nonFinite = 0, overCap = 0, peakUnits = 0;
let totalTickMs = 0, ticks = 0, worstTick = 0;
for (let g = 0; g < games; g++) {
const map = generateMap(rules, { seed: 6000 + g * 17, size: g % 2 ? 'medium' : 'small', symmetry: 'mirror-x' });
const st = L.createMatch(rules, { seed: 6000 + g, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
const cap = rules.constants.unitCapPerArmy;
while (!st.over && st.tick < 1200 * HZ) {
runAI(rules, st, 0, { skill: 3 }); runAI(rules, st, 1, { skill: 3 });
const t0 = process.hrtime.bigint();
L.tick(st, rules);
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
totalTickMs += ms; ticks++; worstTick = Math.max(worstTick, ms);
for (const a of st.armies) if (a.mass < -1e-6 || a.energy < -1e-6) negative++;
if (st.tick % 200 === 0) {
for (const e of st.entities) if (!Number.isFinite(e.x) || !Number.isFinite(e.y)) nonFinite++;
for (let i = 0; i < st.armies.length; i++) {
const n = st.entities.filter((e) => !e.dead && e.army === i && !e.isBuilding).length;
peakUnits = Math.max(peakUnits, n);
if (n > cap) overCap++;
}
}
}
if (st.over && st.over.winner >= 0) decided++;
}
const avg = totalTickMs / Math.max(1, ticks);
console.log(` ${decided}/${games} decided · peak ${peakUnits} units/army · tick avg ${avg.toFixed(2)}ms worst ${worstTick.toFixed(1)}ms`);
check('most games reach a decision', decided >= Math.ceil(games * 0.85), `${decided}/${games}`);
check('resources never go negative', negative === 0, `${negative}`);
check('positions stay finite', nonFinite === 0, `${nonFinite}`);
check('unit cap is respected', overCap === 0, `${overCap} breaches`);
// Average is the real budget signal at 20Hz (50ms per tick). The worst case is allowed
// more headroom because one slow tick — a burst of path requests, or a GC landing on it —
// costs a single dropped frame, not a stall; it is printed above either way.
check('average tick is well inside the 20Hz budget', avg < 8, `avg ${avg.toFixed(2)}ms`);
check('no tick blows the frame budget outright', worstTick < 120, `worst ${worstTick.toFixed(1)}ms`);
}
// ---------------------------------------------------------------------------
section('12. AI skill ladder');
// ---------------------------------------------------------------------------
{
const pairs = QUICK ? 6 : 16;
// Each seed is played BOTH ways round so that side advantage — turn order, spawn corner,
// commander profile — cancels exactly instead of being assumed away. Measured over 32
// mirrored pairs the ladder runs 5v1 ≈ 84%, 3v1 ≈ 81%, 5v3 ≈ 59%; the bars below sit far
// enough below those to be stable at this sample size.
const ladder = (sa, sb) => {
let win = 0, dec = 0;
for (let i = 0; i < pairs; i++) {
for (const flip of [false, true]) {
const map = generateMap(rules, { seed: 7000 + i * 7, size: 'small', symmetry: 'mirror-x' });
const st = L.createMatch(rules, { seed: 7000 + i, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
const p0 = flip ? sb : sa, p1 = flip ? sa : sb;
while (!st.over && st.tick < 1200 * HZ) {
runAI(rules, st, 0, { skill: p0 }); runAI(rules, st, 1, { skill: p1 });
L.tick(st, rules);
}
if (st.over && st.over.winner >= 0) {
dec++;
if (st.over.winner === (flip ? 1 : 0)) win++;
}
}
}
return dec ? win / dec : 0;
};
const hi = ladder(5, 1);
console.log(` skill 5 vs skill 1: ${(hi * 100).toFixed(1)}%`);
check('skill 5 beats skill 1 decisively', hi >= 0.72, `${(hi * 100).toFixed(1)}%`);
if (!QUICK) {
const mid = ladder(4, 2);
console.log(` skill 4 vs skill 2: ${(mid * 100).toFixed(1)}%`);
check('skill 4 beats skill 2', mid >= 0.52, `${(mid * 100).toFixed(1)}%`);
}
}
// ---------------------------------------------------------------------------
section('13. Campaign winnability');
// ---------------------------------------------------------------------------
{
// A skill-5 bot plays the PLAYER's side through the same issueOrder API a human uses. This
// is the entire reason the order API is shared, and it is the only way to know a mission is
// actually beatable rather than merely plausible.
const runs = QUICK ? 2 : 4;
for (const m of campaign.missions ?? []) {
let wins = 0, played = 0;
for (let r = 0; r < runs; r++) {
const map = decodeMap(rules, m.map);
const st = L.createMatch(rules, {
seed: (m.seed ?? 1) + r * 101, map,
armies: [
{ armyId: m.playerArmy, commanderId: m.playerCommander, isHuman: true },
...(m.enemies ?? []).map((e) => ({
armyId: e.army, commanderId: e.commander,
aiSkill: e.aiProfile?.skill ?? 3, aiProfile: e.aiProfile ?? null,
})),
],
});
(m.startResources ?? []).forEach((res, i) => {
if (!st.armies[i]) return;
st.armies[i].mass = res.mass ?? st.armies[i].mass;
st.armies[i].energy = res.energy ?? st.armies[i].energy;
});
while (!st.over && st.tick < 1500 * HZ) {
runAI(rules, st, 0, { skill: 5 });
for (let i = 1; i < st.armies.length; i++) {
const e = m.enemies?.[i - 1];
runAI(rules, st, i, { skill: e?.aiProfile?.skill ?? 3, ...(e?.aiProfile ?? {}) });
}
L.tick(st, rules);
}
played++;
if (st.over && st.over.winner === 0) wins++;
}
const rate = played ? wins / played : 0;
console.log(` ${m.id} ${m.name.padEnd(16)} bot wins ${wins}/${played}`);
check(`${m.id} is winnable by a skill-5 bot`, rate >= 0.5, `${wins}/${played}`);
}
}
// ---------------------------------------------------------------------------
console.log(`\n${'─'.repeat(60)}`);
if (failures.length) {
console.log(`FAILED — ${pass} checks passed, ${failures.length} failed:`);
for (const f of failures) console.log(`${f}`);
process.exit(1);
}
console.log(`OK — all ${pass} checks passed${QUICK ? ' (quick)' : ''}.`);