Compare commits

...

5 Commits

Author SHA1 Message Date
Brian Fertig ddac7fcd5a feat(ta): add order queueing with CTRL and visual waypoint feedback
- Queue multiple orders per unit by holding CTRL (matching original game)
- Draw colored waypoint chains on the minimap/world for each queued order
- Display queued order count in selection detail panel
- Limit queue overlay to MAX_QUEUE_LINES to avoid clutter in large selections
- Use distinct colors per order type (move, attack, patrol, build, etc.)
- Update hint text and placement UI to mention queueing behavior
- Add verification tests for queue append/replace semantics, consumption order,
  and queued build orders
2026-07-24 20:58:08 -06:00
Brian Fertig e7da172896 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
2026-07-24 20:57:40 -06:00
Brian Fertig ab05e2f1cd feat(civilization): add steampunk city theme and caravan diplomacy
- Add steampunk city sheet asset configuration
- Reassign Victor and other opponents to steampunk city theme
- Enable wheel scrolling on city choices list
- Allow non-combat units (e.g., caravans) to enter enemy cities
- Block movement into enemy cities when at war
2026-07-24 18:09:22 -06:00
Brian Fertig 79c18c595e feat: add notification for buildings sold due to insufficient gold
Handle 'buildingSold' events to inform players when their buildings are
automatically sold to cover upkeep costs, displaying the building name
and city where the sale occurred.
2026-07-24 16:26:57 -06:00
Brian Fertig 93b4877c2d **Update game icons assets**
Refresh game icons in both source (PSD) and exported (PNG) formats to reflect updated design.
2026-07-24 16:01:13 -06:00
33 changed files with 9281 additions and 13 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

View File

@ -16,6 +16,8 @@
"citySheets": {
"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 }
"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 },
"mayan": { "key": "civilization-cities-mayan", "path": "assets/images/civilization/civilization-cities-mayan.png", "frameWidth": 128, "frameHeight": 96 }
}
}

View File

@ -89,7 +89,7 @@
"name": "Victor",
"trait": "pioneering",
"startingTechs": [],
"citySheet": "cyberpunk",
"citySheet": "steampunk",
"bio": "Patient and calculating. Moves are made using ancient technology.",
"voice": "Starfield - Vasco",
"convo": [
@ -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": [
@ -968,7 +968,7 @@
"ceremonialburial",
"horsebackriding"
],
"citySheet": "classic",
"citySheet": "steampunk",
"bio": "Ahoy you land lovers!",
"voice": "Defiant Calder",
"convo": [
@ -1010,7 +1010,7 @@
"pottery",
"thewheel"
],
"citySheet": "classic",
"citySheet": "steampunk",
"bio": "Bucky Beaver and I are there to TRAP all the fun and games!",
"speech": {
"intro": [

View File

@ -0,0 +1,99 @@
{
"_readme": [
"Drop-in painted art for Total Annihilation (spec: src/games/totalannihilation/sprites.md).",
"Paint a sheet, drop the PNG under assets/images/totalannihilation/, set its `path` below and",
"reload — no code changes needed. Any sheet left path:null is rendered procedurally.",
"",
"`kind` selects which procedural painter stands in for an unpainted sheet, and it is the reason",
"adding a sheet here is a JSON-only change: painters are keyed by kind, never by sheet name.",
" unit — frameWidth == frameHeight, art drawn FACING RIGHT (+x) and CENTRED in the cell,",
" in neutral grey so the runtime army tint multiplies correctly. The engine rotates",
" the sprite, so keep the silhouette inside a circle of diameter frameWidth*0.7 or",
" the corners will clip as it turns.",
" structure — frameWidth == frameHeight, buildings are NOT rotated and NOT drawn facing right.",
" Two frames per building: the finished one and a translucent wireframe used while",
" it is still being nanolathed.",
" terrain — one sheet per theme. Frame order is fixed (see `terrainFrames` below) so any new",
" theme sheet drops straight in.",
" icon — build-menu glyphs, full colour, never tinted.",
"",
"`cols` only sizes the procedural stand-in canvas; a painted sheet is measured from the PNG.",
"Adding a THIRD ARMY = add two sheets here (kind unit + kind structure) and one `armies` entry",
"in data/totalannihilation-rules.json. Adding a THEME = one sheet here, one `themes` entry, and",
"the theme id in rules.skirmish.themes."
],
"terrainFrames": {
"_comment": "Fixed frame order every kind:terrain sheet must follow.",
"ground": 0, "groundAlt1": 1, "groundAlt2": 2, "rough": 3,
"cliff": 4, "water": 5, "waterDeep": 6, "metal": 7, "road": 8
},
"sheets": {
"arm-units": {
"key": "ta-arm-units", "path": null, "kind": "unit",
"frameWidth": 64, "frameHeight": 64, "cols": 8
},
"arm-structures": {
"key": "ta-arm-structures", "path": null, "kind": "structure",
"frameWidth": 128, "frameHeight": 128, "cols": 6
},
"core-units": {
"key": "ta-core-units", "path": null, "kind": "unit",
"frameWidth": 64, "frameHeight": 64, "cols": 8
},
"core-structures": {
"key": "ta-core-structures", "path": null, "kind": "structure",
"frameWidth": 128, "frameHeight": 128, "cols": 6
},
"terrain-grasslands": {
"key": "ta-terrain-grasslands", "path": null, "kind": "terrain",
"frameWidth": 64, "frameHeight": 64, "cols": 9
},
"terrain-snowfields": {
"key": "ta-terrain-snowfields", "path": null, "kind": "terrain",
"frameWidth": 64, "frameHeight": 64, "cols": 9
},
"terrain-tropics": {
"key": "ta-terrain-tropics", "path": null, "kind": "terrain",
"frameWidth": 64, "frameHeight": 64, "cols": 9
},
"icons": {
"key": "ta-icons", "path": null, "kind": "icon",
"frameWidth": 44, "frameHeight": 44, "cols": 10
}
},
"themes": {
"grasslands": {
"name": "Grasslands",
"sheet": "terrain-grasslands",
"palette": {
"ground": "#5f7f3d", "groundAlt1": "#6b8c45", "groundAlt2": "#55743a",
"rough": "#7d7048", "cliff": "#4c4838", "cliffTop": "#6b6552",
"water": "#2f5f9e", "waterDeep": "#1d3f70",
"metal": "#9aa3ab", "road": "#8b8578", "speck": "#3f5a28"
}
},
"snowfields": {
"name": "Snowfields",
"sheet": "terrain-snowfields",
"palette": {
"ground": "#d6dee6", "groundAlt1": "#e4ebf1", "groundAlt2": "#c4cfda",
"rough": "#a9b4bf", "cliff": "#6a737e", "cliffTop": "#8e99a4",
"water": "#3d6d95", "waterDeep": "#27506f",
"metal": "#9aa3ab", "road": "#9fa6ad", "speck": "#aebccb"
}
},
"tropics": {
"name": "Tropics",
"sheet": "terrain-tropics",
"palette": {
"ground": "#2f6b3c", "groundAlt1": "#3a7f46", "groundAlt2": "#265c33",
"rough": "#7a6b3a", "cliff": "#4a3f2e", "cliffTop": "#6d5f45",
"water": "#1f8fa8", "waterDeep": "#12667a",
"metal": "#9aa3ab", "road": "#9c8f6e", "speck": "#1d4a26"
}
}
}
}

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

View File

@ -0,0 +1,804 @@
{
"_readme": [
"Engine data for Total Annihilation. Everything the simulation knows lives here \u2014 adding a unit,",
"building, weapon, terrain type, army or map theme is a JSON-only change.",
"After editing, run `node tools/verifyTotalAnnihilation.js` \u2014 it cross-checks every id reference,",
"frame index and closed key set in this file and will tell you exactly what broke.",
"",
"Sheet resolution: a unit/building's `sheetSlot` names a field on its ARMY (unitSheet or",
"structureSheet), so one unit definition renders correctly for every army. That is what makes a",
"third army a two-line change: add an `armies` entry pointing at two new sheets in",
"data/totalannihilation-artwork.json.",
"",
"Units are drawn once and ROTATED at runtime \u2014 there are no per-facing frames. `turretFrame` is",
"an optional second sprite that aims independently of the hull."
],
"version": 1,
"constants": {
"tickHz": 20,
"tileSize": 64,
"unitCapPerArmy": 100,
"projectileCap": 400,
"startEnergy": 1000,
"startMass": 1000,
"baseEnergyCap": 500,
"baseMassCap": 500,
"pathBudgetPerTick": 8,
"retargetPeriodTicks": 10,
"separationStiffness": 0.55,
"shoveAfterSec": 0.6,
"stuckGiveUpSec": 3.0,
"arriveSlackPx": 12,
"friendlyFire": false,
"buildPowerNominal": 100,
"eliminateWhenUnrecoverable": true
},
"sizeClasses": {
"small": {
"radius": 16,
"footprint": 1,
"mass": 1
},
"medium": {
"radius": 26,
"footprint": 1,
"mass": 4
},
"large": {
"radius": 44,
"footprint": 2,
"mass": 16
}
},
"armorClasses": [
"infantry",
"light",
"medium",
"heavy",
"structure"
],
"moveClasses": {
"foot": {
"cost": {
"ground": 1.0,
"rough": 1.3,
"road": 0.85,
"metal": 1.0,
"cliff": null,
"water": null
}
},
"tread": {
"cost": {
"ground": 1.0,
"rough": 1.8,
"road": 0.7,
"metal": 1.0,
"cliff": null,
"water": null
}
},
"wheel": {
"cost": {
"ground": 1.0,
"rough": 2.4,
"road": 0.6,
"metal": 1.0,
"cliff": null,
"water": null
}
}
},
"armies": [
{
"id": "arm",
"name": "ARM",
"color": "#4a90e2",
"unitSheet": "arm-units",
"structureSheet": "arm-structures"
},
{
"id": "core",
"name": "CORE",
"color": "#e05a2f",
"unitSheet": "core-units",
"structureSheet": "core-structures"
}
],
"commanders": [
{
"id": "vance",
"armyId": "arm",
"opponentId": "ethel",
"name": "Commander Ethel",
"tagline": "Holds the line, and makes you pay for every metre of it.",
"aiProfile": {
"aggression": 0.45,
"expansion": 0.65
}
},
{
"id": "klaxon",
"armyId": "core",
"opponentId": "klaxon",
"name": "Unit KLAXON",
"tagline": "Efficiency is a weapon. You are an inefficiency.",
"aiProfile": {
"aggression": 0.7,
"expansion": 0.45
}
},
{
"id": "cybro",
"armyId": "core",
"opponentId": "cybro",
"name": "Cy-Bro",
"tagline": "Builds faster than you can knock it down.",
"aiProfile": {
"aggression": 0.35,
"expansion": 0.85
}
},
{
"id": "nadia",
"armyId": "arm",
"opponentId": "nadia",
"name": "Commander Nadia",
"tagline": "Hits the flank while you watch the front.",
"aiProfile": {
"aggression": 0.8,
"expansion": 0.4
}
}
],
"terrain": [
{
"id": "ground",
"ch": ".",
"frame": 0,
"buildable": true,
"blocksMove": false,
"elevation": 0
},
{
"id": "rough",
"ch": "r",
"frame": 3,
"buildable": true,
"blocksMove": false,
"elevation": 0
},
{
"id": "road",
"ch": "=",
"frame": 8,
"buildable": true,
"blocksMove": false,
"elevation": 0
},
{
"id": "metal",
"ch": "M",
"frame": 7,
"buildable": true,
"blocksMove": false,
"elevation": 0,
"massMultiplier": 2.0
},
{
"id": "cliff",
"ch": "^",
"frame": 4,
"buildable": false,
"blocksMove": true,
"blocksFire": true,
"elevation": 1
},
{
"id": "water",
"ch": "~",
"frame": 5,
"buildable": false,
"blocksMove": true,
"elevation": -1
}
],
"weapons": [
{
"id": "rifle",
"name": "Assault Rifle",
"kind": "hitscan",
"damage": 6,
"reload": 0.5,
"burst": 3,
"burstDelay": 0.06,
"range": 200,
"spread": 0.05,
"targets": [
"ground"
],
"armorMul": {
"infantry": 1.3,
"light": 0.85,
"medium": 0.3,
"heavy": 0.12,
"structure": 0.2
},
"fx": {
"style": "tracer",
"color": "#ffe6a0",
"width": 1.5,
"lifeMs": 70
},
"sound": "sfx-battle-gunfire-modern"
},
{
"id": "sniperrifle",
"name": "Marksman Rifle",
"kind": "hitscan",
"damage": 55,
"reload": 3.0,
"burst": 1,
"range": 460,
"spread": 0.0,
"targets": [
"ground"
],
"armorMul": {
"infantry": 2.2,
"light": 0.9,
"medium": 0.3,
"heavy": 0.1,
"structure": 0.1
},
"fx": {
"style": "beam",
"color": "#d8f0ff",
"width": 2,
"lifeMs": 160
},
"sound": "sfx-laser-zap"
},
{
"id": "tankgun",
"name": "120mm Cannon",
"kind": "ballistic",
"damage": 95,
"reload": 2.0,
"burst": 1,
"range": 350,
"speed": 700,
"spread": 0.015,
"leadTarget": true,
"aoe": 40,
"aoeFalloff": 0.15,
"targets": [
"ground"
],
"armorMul": {
"infantry": 1.3,
"light": 1.15,
"medium": 1.0,
"heavy": 0.8,
"structure": 1.2
},
"fx": {
"style": "shell",
"color": "#ffd27a",
"width": 3,
"muzzle": 18
},
"sound": "sfx-battle-tank"
},
{
"id": "rocketpod",
"name": "Rocket Pod",
"kind": "guided",
"damage": 130,
"reload": 4.0,
"burst": 2,
"burstDelay": 0.25,
"range": 440,
"minRange": 110,
"speed": 340,
"turnRate": 2.2,
"aoe": 48,
"aoeFalloff": 0.15,
"targets": [
"ground"
],
"armorMul": {
"infantry": 0.3,
"light": 0.6,
"medium": 1.0,
"heavy": 1.4,
"structure": 1.5
},
"fx": {
"style": "rocket",
"color": "#ff8844",
"width": 2,
"trail": true
},
"sound": "sfx-battle-missle"
},
{
"id": "commanderlaser",
"name": "Commander Laser",
"kind": "hitscan",
"damage": 38,
"reload": 1.1,
"burst": 1,
"range": 260,
"spread": 0.01,
"targets": [
"ground"
],
"armorMul": {
"infantry": 1.3,
"light": 1.0,
"medium": 0.8,
"heavy": 0.6,
"structure": 0.5
},
"fx": {
"style": "beam",
"color": "#9ff0c8",
"width": 3,
"lifeMs": 120
},
"sound": "sfx-laser-zap"
},
{
"id": "dgun",
"name": "D-Gun",
"kind": "beam",
"damage": 2000,
"reload": 1.5,
"burst": 1,
"range": 220,
"energyPerShot": 500,
"targets": [
"ground"
],
"manual": true,
"armorMul": {
"infantry": 1,
"light": 1,
"medium": 1,
"heavy": 1,
"structure": 1
},
"fx": {
"style": "dgun",
"color": "#8ad4ff",
"width": 9,
"lifeMs": 260
},
"sound": "sfx-scifi-launch"
}
],
"units": [
{
"id": "commander",
"name": "Commander",
"role": "builder",
"size": "medium",
"radius": 20,
"hp": 7000,
"speed": 62,
"turnRate": 3.5,
"moveClass": "foot",
"armorClass": "heavy",
"sight": 420,
"cost": {
"energy": 0,
"mass": 0
},
"buildTime": 0,
"buildPower": 100,
"buildRange": 200,
"builds": [
"energygen",
"massgen",
"barracks",
"vehicleplant"
],
"produce": {
"energy": 25,
"mass": 3.0
},
"storage": {
"energy": 1000,
"mass": 1000
},
"weapons": [
"commanderlaser",
"dgun"
],
"deathExplosion": {
"radius": 280,
"damage": 1200
},
"sheetSlot": "unitSheet",
"frame": 0,
"turretFrame": 1,
"procShape": "commander",
"icon": 0,
"spritePx": 56
},
{
"id": "infantry",
"name": "Infantry",
"role": "combat",
"size": "small",
"radius": 16,
"hp": 260,
"speed": 55,
"turnRate": 6.0,
"moveClass": "foot",
"armorClass": "infantry",
"sight": 260,
"cost": {
"energy": 120,
"mass": 24
},
"buildTime": 6,
"builtBy": [
"barracks"
],
"weapons": [
"rifle"
],
"sheetSlot": "unitSheet",
"frame": 2,
"procShape": "infantry",
"icon": 1,
"spritePx": 30,
"moveSound": "sfx-march"
},
{
"id": "sniper",
"name": "Sniper",
"role": "combat",
"size": "small",
"radius": 16,
"hp": 200,
"speed": 46,
"turnRate": 5.0,
"moveClass": "foot",
"armorClass": "infantry",
"sight": 500,
"cost": {
"energy": 260,
"mass": 55
},
"buildTime": 12,
"builtBy": [
"barracks"
],
"weapons": [
"sniperrifle"
],
"sheetSlot": "unitSheet",
"frame": 3,
"procShape": "sniper",
"icon": 2,
"spritePx": 30,
"moveSound": "sfx-march"
},
{
"id": "jeep",
"name": "Jeep",
"role": "scout",
"size": "small",
"radius": 20,
"hp": 480,
"speed": 175,
"turnRate": 3.4,
"moveClass": "wheel",
"armorClass": "light",
"sight": 520,
"cost": {
"energy": 320,
"mass": 70
},
"buildTime": 8,
"builtBy": [
"vehicleplant"
],
"weapons": [
"rifle"
],
"sheetSlot": "unitSheet",
"frame": 4,
"turretFrame": 5,
"procShape": "jeep",
"icon": 3,
"spritePx": 40,
"moveSound": "sfx-engine-medium"
},
{
"id": "tank",
"name": "Tank",
"role": "combat",
"size": "medium",
"radius": 26,
"hp": 1900,
"speed": 90,
"turnRate": 2.2,
"moveClass": "tread",
"armorClass": "medium",
"sight": 300,
"cost": {
"energy": 700,
"mass": 180
},
"buildTime": 20,
"builtBy": [
"vehicleplant"
],
"weapons": [
"tankgun"
],
"sheetSlot": "unitSheet",
"frame": 6,
"turretFrame": 7,
"procShape": "tank",
"icon": 4,
"spritePx": 54,
"moveSound": "sfx-engine-heavy"
},
{
"id": "rockettank",
"name": "Rocket Tank",
"role": "artillery",
"size": "medium",
"radius": 28,
"hp": 1250,
"speed": 75,
"turnRate": 1.8,
"moveClass": "tread",
"armorClass": "medium",
"sight": 320,
"cost": {
"energy": 1100,
"mass": 300
},
"buildTime": 28,
"builtBy": [
"vehicleplant"
],
"weapons": [
"rocketpod"
],
"sheetSlot": "unitSheet",
"frame": 8,
"turretFrame": 9,
"procShape": "rockettank",
"icon": 5,
"spritePx": 56,
"moveSound": "sfx-engine-heavy"
}
],
"buildings": [
{
"id": "energygen",
"name": "Energy Generator",
"footprint": {
"w": 2,
"h": 2
},
"hp": 1440,
"armorClass": "structure",
"sight": 160,
"cost": {
"energy": 200,
"mass": 60
},
"buildTime": 12,
"produce": {
"energy": 20
},
"storage": {
"energy": 500
},
"deathExplosion": {
"radius": 110,
"damage": 160
},
"sheetSlot": "structureSheet",
"frame": 0,
"buildFrame": 1,
"procShape": "energyGen",
"icon": 10
},
{
"id": "massgen",
"name": "Mass Generator",
"footprint": {
"w": 2,
"h": 2
},
"hp": 1260,
"armorClass": "structure",
"sight": 160,
"cost": {
"energy": 600,
"mass": 50
},
"buildTime": 16,
"produce": {
"mass": 2.0
},
"upkeep": {
"energy": 15
},
"storage": {
"mass": 250
},
"terrainMultiplier": "massMultiplier",
"sheetSlot": "structureSheet",
"frame": 2,
"buildFrame": 3,
"procShape": "massGen",
"icon": 11
},
{
"id": "barracks",
"name": "Barracks",
"footprint": {
"w": 3,
"h": 3
},
"hp": 2520,
"armorClass": "structure",
"sight": 220,
"cost": {
"energy": 600,
"mass": 180
},
"buildTime": 26,
"buildPower": 100,
"builds": [
"infantry",
"sniper"
],
"spawnOffset": {
"x": 0,
"y": 2.2
},
"sheetSlot": "structureSheet",
"frame": 4,
"buildFrame": 5,
"procShape": "barracks",
"icon": 12
},
{
"id": "vehicleplant",
"name": "Vehicle Plant",
"footprint": {
"w": 3,
"h": 3
},
"hp": 3960,
"armorClass": "structure",
"sight": 220,
"cost": {
"energy": 1400,
"mass": 520
},
"buildTime": 40,
"buildPower": 100,
"builds": [
"jeep",
"tank",
"rockettank"
],
"spawnOffset": {
"x": 0,
"y": 2.2
},
"sheetSlot": "structureSheet",
"frame": 6,
"buildFrame": 7,
"procShape": "vehiclePlant",
"icon": 13
}
],
"commandIcons": {
"move": 20,
"attack": 21,
"attackMove": 22,
"stop": 23,
"hold": 24,
"patrol": 25,
"guard": 26,
"assist": 27
},
"aiSkills": [
{
"skill": 1,
"decisionPeriodMs": 1500,
"apmCap": 1,
"squadSize": 3,
"expansion": 0.6,
"microLevel": 0,
"reactionMs": 4000
},
{
"skill": 2,
"decisionPeriodMs": 1100,
"apmCap": 2,
"squadSize": 4,
"expansion": 1.0,
"microLevel": 1,
"reactionMs": 3000
},
{
"skill": 3,
"decisionPeriodMs": 800,
"apmCap": 4,
"squadSize": 6,
"expansion": 1.6,
"microLevel": 2,
"reactionMs": 2000
},
{
"skill": 4,
"decisionPeriodMs": 500,
"apmCap": 8,
"squadSize": 8,
"expansion": 2.2,
"microLevel": 3,
"reactionMs": 1000
},
{
"skill": 5,
"decisionPeriodMs": 300,
"apmCap": 15,
"squadSize": 10,
"expansion": 3.0,
"microLevel": 4,
"reactionMs": 300
}
],
"skirmish": {
"sizes": {
"small": 64,
"medium": 96,
"large": 128
},
"symmetries": [
"mirror-x",
"mirror-y",
"rotational"
],
"themes": [
"grasslands",
"snowfields",
"tropics"
],
"defaults": {
"size": "medium",
"symmetry": "mirror-x",
"theme": "grasslands",
"aiSkill": 3,
"playerArmy": "arm"
},
"startUnits": [
"commander"
],
"gen": {
"waterFraction": 0.1,
"roughFraction": 0.18,
"cliffFraction": 0.1,
"metalSpotsPerStart": 4,
"metalSpotRadiusTiles": 14,
"minStartSeparationTiles": 40,
"smoothPasses": 3,
"startClearRadiusTiles": 8,
"noiseScale": 0.09
}
}
}

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

@ -393,6 +393,11 @@ export function openCityScreen(scene, rules, state, city, onClose) {
Logic.setBuild(rules, state, city, ch.type, ch.id);
redraw();
});
rect.on('wheel', (pointer, _dx, dy) => {
scrollY = Phaser.Math.Clamp(scrollY + dy * 0.5, 0, maxScroll);
tooltip.hide();
syncRows();
});
tooltip.attachTo(rect, () => {
if (ch.type === 'unit') return describeUnitTooltip(rules, ch.ruleObj);
if (ch.type === 'building') return describeBuildingTooltip(rules, ch.ruleObj);
@ -423,7 +428,7 @@ export function openCityScreen(scene, rules, state, city, onClose) {
if (thumb) thumb.y = listY + (listH - thumbH) * (scrollY / maxScroll);
};
listBg.setInteractive();
listBg.on('wheel', (pointer, dx, dy) => {
listBg.on('wheel', (pointer, _dx, dy) => {
scrollY = Phaser.Math.Clamp(scrollY + dy * 0.5, 0, maxScroll);
// A row disabled mid-hover never fires pointerout, so its tooltip would
// hang around over the list.

View File

@ -1483,6 +1483,11 @@ export default class CivilizationGame extends Phaser.Scene {
});
},
});
} else if (e.type === 'buildingSold' && e.civ === human) {
const city = Logic.cityById(this.state, e.cityId);
const building = this.rules.buildings[e.building];
if (!city || !building) continue;
this.announceStatus(`Not enough gold! ${building.name} in ${city.name} was sold to cover upkeep.`);
} else if (e.type === 'spaceshipPart' && e.civ === human) {
const city = Logic.cityById(this.state, e.cityId);
const part = this.rules.units[e.part];

View File

@ -916,7 +916,8 @@ export function tryMove(rules, state, unit, dx, dy) {
}
if (!canOccupy(rules, state, unit, tx, ty)) return { result: 'invalid' };
if (targetCity && targetCity.civ !== unit.civ) return { result: 'invalid' };
if (targetCity && targetCity.civ !== unit.civ && !def.flags.includes('caravan')) return { result: 'invalid' };
if (targetCity && targetCity.civ !== unit.civ && state.civs[unit.civ].relations[targetCity.civ] === 'war') return { result: 'invalid' };
const cost = moveCost(rules, state, unit, unit.x, unit.y, tx, ty);
if (!Number.isFinite(cost)) return { result: 'invalid' };

View File

@ -0,0 +1,576 @@
// Total Annihilation — the CPU commander.
//
// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js.
//
// Two rules this file must never break:
// 1. It issues orders ONLY through TALogic.issueOrder(). It never mutates state directly.
// That's what lets the verify script point this same AI at the human's side and assert
// a campaign mission is actually winnable.
// 2. It sees only what fog lets it see (isVisibleTo), and it never gets free resources.
// Difficulty is decision quality, reaction time and an orders-per-second cap — not
// income cheating. A cheating AI's win rate would tell us nothing about the balance.
import { issueOrder, isVisibleTo, canPlaceAt, rngNext, rngInt } from './TALogic.js';
import { worldToTileX, worldToTileY } from './TANav.js';
/** Desired force mix per skill. Below skill 3 the AI just buys whatever it can afford. */
const COMPOSITION = {
infantry: 0.34, sniper: 0.10, jeep: 0.06, tank: 0.34, rockettank: 0.16,
};
function memFor(state, armyIdx) {
if (!state.aiMem) state.aiMem = [];
let m = state.aiMem[armyIdx];
if (!m) {
m = state.aiMem[armyIdx] = {
nextThinkTick: 0,
ordersThisSecond: 0, secondMark: 0,
squad: [], squadPeak: 0, phase: 'build', focusId: 0,
scoutId: 0,
knownEnemies: [], // [{x,y,defId,isBuilding,tick}]
lastAttackTick: -99999,
buildSpiral: 0,
baseX: 0, baseY: 0, baseSet: false,
};
}
return m;
}
/**
* Run one AI slice. Call every sim tick — it throttles itself to the skill's think period.
* @param {object} profile { skill, aggression, expansion } — mission JSON can override
*/
export function runAI(rules, state, armyIdx, profile = {}) {
if (state.over) return;
const army = state.armies[armyIdx];
if (!army || !army.alive) return;
const skill = Math.max(1, Math.min(5, profile.skill ?? army.aiSkill ?? 3));
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,
// otherwise every skill level expands identically and the ladder collapses.
const skillExpansion = (tuning.expansion ?? 1.6) / 3;
const expansion = profile.expansion
?? (army.aiProfile?.expansion != null
? (skillExpansion * 0.6 + army.aiProfile.expansion * 0.4)
: skillExpansion);
const mem = memFor(state, armyIdx);
const periodTicks = Math.max(1, Math.round((tuning.decisionPeriodMs / 1000) * rules.constants.tickHz));
if (state.tick < mem.nextThinkTick) return;
mem.nextThinkTick = state.tick + periodTicks;
// Orders-per-second cap: the honest handicap. A skill-1 AI physically cannot micro.
if (state.tick - mem.secondMark >= rules.constants.tickHz) {
mem.secondMark = state.tick;
mem.ordersThisSecond = 0;
}
const ctx = {
rules, state, armyIdx, army, skill, tuning, aggression, expansion, mem, profile,
budget: Math.max(1, tuning.apmCap),
};
observe(ctx);
const mine = gatherOwn(ctx);
if (!mem.baseSet) {
const seed = mine.commanders[0] ?? mine.buildings[0] ?? mine.units[0];
if (seed) { mem.baseX = seed.x; mem.baseY = seed.y; mem.baseSet = true; }
}
manageEconomyAndBase(ctx, mine);
manageProduction(ctx, mine);
manageScout(ctx, mine);
manageMilitary(ctx, mine);
}
function order(ctx, cmd) {
if (ctx.mem.ordersThisSecond >= ctx.budget) return { ok: false, error: 'apm cap' };
ctx.mem.ordersThisSecond++;
return issueOrder(ctx.state, ctx.rules, cmd);
}
// ---------------------------------------------------------------------------
// Perception — fog-limited, with memory of where things were last seen
// ---------------------------------------------------------------------------
function observe(ctx) {
const { state, armyIdx, mem } = ctx;
const seen = [];
for (const e of state.entities) {
if (e.dead || e.army === armyIdx) continue;
if (!state.armies[e.army]) continue;
if (!isVisibleTo(state, armyIdx, e)) continue;
seen.push({ id: e.id, x: e.x, y: e.y, defId: e.defId, isBuilding: e.isBuilding, tick: state.tick });
}
// Merge into memory: refresh what we can see, keep stale entries as "last known".
const byId = new Map(mem.knownEnemies.map((k) => [k.id, k]));
for (const s of seen) byId.set(s.id, s);
// Forget anything we haven't seen in two minutes, and anything we can see is gone.
const cutoff = state.tick - ctx.rules.constants.tickHz * 120;
const live = new Set(state.entities.filter((e) => !e.dead).map((e) => e.id));
mem.knownEnemies = [...byId.values()]
.filter((k) => k.tick >= cutoff && live.has(k.id))
.sort((a, b) => a.id - b.id);
}
function gatherOwn(ctx) {
const { state, armyIdx, rules } = ctx;
const out = { commanders: [], builders: [], factories: [], buildings: [], units: [], sites: [], byDef: {} };
for (const e of state.entities) {
if (e.dead || e.army !== armyIdx) continue;
const def = rules.defById[e.defId];
out.byDef[e.defId] = (out.byDef[e.defId] ?? 0) + 1;
if (e.site) { out.sites.push(e); continue; }
if (e.isBuilding) {
out.buildings.push(e);
if (def.builds?.length) out.factories.push(e);
} else {
out.units.push(e);
if (def.builds?.length) { out.builders.push(e); out.commanders.push(e); }
}
}
return out;
}
// ---------------------------------------------------------------------------
// Economy and base building
// ---------------------------------------------------------------------------
function manageEconomyAndBase(ctx, mine) {
const { state, army, rules, mem, expansion } = ctx;
const builder = mine.builders[0];
if (!builder) return;
// Build orders QUEUE on the builder rather than replacing its current job, so the
// Commander rolls straight from one structure into the next with no idle gap. Issuing
// these unqueued caps every skill at one building in flight, which flattens the whole
// skill ladder — a skill-5 economy then looks identical to a skill-1 one.
const maxQueued = Math.max(1, Math.round(1 + expansion * 3));
const queued = builder.orders.reduce((n, o) => n + (o.type === 'build' ? 1 : 0), 0);
if (queued >= maxQueued) return;
const want = chooseBuilding(ctx, mine);
if (!want) return;
const spot = findPlacement(ctx, mine, want, builder);
if (!spot) return;
order(ctx, {
army: ctx.armyIdx, unitIds: [builder.id],
order: { type: 'build', defId: want.id, tx: spot.tx, ty: spot.ty },
queue: queued > 0 || builder.orders.length > 0,
});
}
function chooseBuilding(ctx, mine) {
const { rules, army, mem, expansion, skill } = ctx;
const n = (id) => (mine.byDef[id] ?? 0);
const eGen = rules.buildingById.energygen;
const mGen = rules.buildingById.massgen;
const barracks = rules.buildingById.barracks;
const plant = rules.buildingById.vehicleplant;
// Opening: two power, one mass, then a barracks so there's something to fight with.
if (n('energygen') < 2) return eGen;
if (n('massgen') < 1) return mGen;
if (n('barracks') < 1) return barracks;
if (n('energygen') < 3) return eGen;
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.
const netE = Math.max(0, army.eIncome - army.eUpkeep);
const ratio = netE / Math.max(0.5, army.mIncome);
const TARGET_RATIO = 4.0;
const cap = Math.round(3 + expansion * 7);
const roomE = n('energygen') < cap;
// A Mass Generator is an energy CONSUMER. Building one without the headroom to power it
// just throttles the ones already standing, so require spare energy before adding another.
const upkeepPer = mGen.upkeep?.energy ?? 0;
const roomM = n('massgen') < cap && netE > upkeepPer * 1.6;
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;
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.
*/
function findPlacement(ctx, mine, def, builder) {
const { state, rules } = ctx;
const mem = ctx.mem;
const ts = state.tileSize;
const bx = worldToTileX(state.nav, mem.baseSet ? mem.baseX : builder.x);
const by = worldToTileY(state.nav, mem.baseSet ? mem.baseY : builder.y);
if (def.terrainMultiplier) {
const spot = findMetalSpot(ctx, def, bx, by);
if (spot) return spot;
}
const maxR = 22;
for (let r = 2; r < maxR; r++) {
// Rotate the starting angle per attempt so the base doesn't grow as a solid slab —
// AoE chains through tightly packed buildings.
const steps = Math.max(8, r * 4);
const off = (ctx.mem.buildSpiral++ % steps);
for (let s = 0; s < steps; s++) {
const a = ((s + off) / steps) * Math.PI * 2;
const tx = bx + Math.round(Math.cos(a) * r);
const ty = by + Math.round(Math.sin(a) * r);
if (!spacedEnough(ctx, mine, tx, ty, def)) continue;
if (canPlaceAt(state, rules, tx, ty, def).ok) return { tx, ty };
}
}
return null;
}
function findMetalSpot(ctx, def, bx, by) {
const { state, rules } = ctx;
let best = null, bestD = Infinity;
for (let ty = 0; ty < state.h; ty++) {
for (let tx = 0; tx < state.w; tx++) {
const t = rules.terrain[state.terrain[ty * state.w + tx]];
if (!t.massMultiplier) continue;
const d = Math.hypot(tx - bx, ty - by);
if (d > 30 || d >= bestD) continue;
if (!canPlaceAt(state, rules, tx, ty, def).ok) continue;
best = { tx, ty }; bestD = d;
}
}
return best;
}
function spacedEnough(ctx, mine, tx, ty, def) {
const gap = 1;
for (const b of [...mine.buildings, ...mine.sites]) {
if (tx + def.footprint.w + gap <= b.tx) continue;
if (b.tx + b.fw + gap <= tx) continue;
if (ty + def.footprint.h + gap <= b.ty) continue;
if (b.ty + b.fh + gap <= ty) continue;
return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Production
// ---------------------------------------------------------------------------
function manageProduction(ctx, mine) {
const { rules, state, skill, army } = ctx;
for (const f of mine.factories) {
const def = rules.defById[f.defId];
const builds = def.builds ?? [];
if (!builds.length) continue;
const queued = f.queue.reduce((s, q) => s + q.count, 0);
if (queued >= 3) continue;
// Don't feed a factory while the economy is already choking — an over-queued plant
// starves the base builder and the AI never gets off the ground.
if (army.buildEff < 0.55 && queued >= 1) continue;
const pick = skill <= 2 ? randomPick(ctx, builds) : templatePick(ctx, mine, builds);
if (!pick) continue;
order(ctx, {
army: ctx.armyIdx,
order: { type: 'factoryEnqueue', factoryId: f.id, defId: pick, count: 1 },
});
}
}
function randomPick(ctx, builds) {
return builds[rngInt(ctx.state, builds.length)];
}
/** Pick whichever buildable unit is furthest below its share of the desired mix. */
function templatePick(ctx, mine, builds) {
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;
let best = null, bestGap = -Infinity;
for (const id of builds) {
const wgt = weights[id] ?? 0.1;
const have = (mine.byDef[id] ?? 0) / total;
const gap = wgt - have;
if (gap > bestGap) { bestGap = gap; best = id; }
}
return best;
}
/** Skill 4+: bias the mix against what we've actually seen the enemy field. */
function applyCounters(ctx, weights) {
const { mem, rules } = ctx;
let infantryish = 0, armour = 0, structures = 0;
for (const k of mem.knownEnemies) {
const def = rules.defById[k.defId];
if (!def) continue;
if (k.isBuilding) { structures++; continue; }
if (def.armorClass === 'infantry' || def.armorClass === 'light') infantryish++;
else armour++;
}
const seen = infantryish + armour;
if (!seen) return;
const armourShare = armour / seen;
// Tanks shred infantry; rockets and snipers answer armour and buildings.
weights.tank *= 1 + (1 - armourShare) * 0.8;
weights.rockettank *= 1 + armourShare * 1.0 + (structures > 2 ? 0.3 : 0);
weights.sniper *= 1 + (1 - armourShare) * 0.5;
}
// ---------------------------------------------------------------------------
// Scouting
// ---------------------------------------------------------------------------
function manageScout(ctx, mine) {
const { state, mem, skill } = ctx;
if (skill < 3) return;
const scout = state.entities.find((e) => e.id === mem.scoutId && !e.dead);
if (scout) {
if (scout.orders.length) return;
const t = enemyBaseGuess(ctx);
if (t) order(ctx, { army: ctx.armyIdx, unitIds: [scout.id], order: { type: 'move', x: t.x, y: t.y } });
return;
}
const jeep = mine.units.find((u) => u.defId === 'jeep' && !u.orders.length);
if (!jeep) return;
mem.scoutId = jeep.id;
const t = enemyBaseGuess(ctx);
if (t) order(ctx, { army: ctx.armyIdx, unitIds: [jeep.id], order: { type: 'attackMove', x: t.x, y: t.y } });
}
function enemyBaseGuess(ctx) {
const { state, armyIdx, mem } = ctx;
// Prefer something we've actually seen; fall back to the enemy's start position.
const building = mem.knownEnemies.find((k) => k.isBuilding);
if (building) return { x: building.x, y: building.y };
if (mem.knownEnemies.length) return { x: mem.knownEnemies[0].x, y: mem.knownEnemies[0].y };
// Nothing in sight. If we've already been to the enemy start, sweep the map instead of
// parking on a stale position — otherwise a won game never actually ends, because the
// loser's last builder is hiding in a corner we stopped looking at.
const start = state.starts.find((s) => s.army !== armyIdx);
if (start) {
const sx = (start.x + 0.5) * state.tileSize, sy = (start.y + 0.5) * state.tileSize;
if (!mem.startCleared) {
const near = state.entities.some((e) => !e.dead && e.army === armyIdx
&& Math.hypot(e.x - sx, e.y - sy) < state.tileSize * 8);
if (near) mem.startCleared = true;
else return { x: sx, y: sy };
}
}
return sweepTarget(ctx);
}
/**
* Rotate through map sectors, preferring ones this army has never explored. This is what
* turns a won game into a finished game.
*/
function sweepTarget(ctx) {
const { state, army, mem } = ctx;
const SECTORS = 4;
const cellW = state.worldW / SECTORS, cellH = state.worldH / SECTORS;
const cell = state.tileSize * 2;
const candidates = [];
for (let sy = 0; sy < SECTORS; sy++) {
for (let sx = 0; sx < SECTORS; sx++) {
const cx = (sx + 0.5) * cellW, cy = (sy + 0.5) * cellH;
const gx = Math.min(state.visW - 1, Math.floor(cx / cell));
const gy = Math.min(state.visH - 1, Math.floor(cy / cell));
const explored = army.explored[gy * state.visW + gx] === 1;
candidates.push({ x: cx, y: cy, explored, i: sy * SECTORS + sx });
}
}
const unexplored = candidates.filter((c) => !c.explored);
const pool = unexplored.length ? unexplored : candidates;
const pick = pool[(mem.sweepIdx = ((mem.sweepIdx ?? 0) + 1) % pool.length)];
return { x: pick.x, y: pick.y };
}
// ---------------------------------------------------------------------------
// Military
// ---------------------------------------------------------------------------
function manageMilitary(ctx, mine) {
const { state, rules, mem, tuning, aggression, skill, armyIdx } = ctx;
// Anything combat-capable and idle joins the pool.
const idle = mine.units.filter((u) => {
if (u.id === mem.scoutId) return false;
const def = rules.defById[u.defId];
if (!def.weaponDefs?.length || def.builds?.length) return false;
return u.orders.length === 0;
});
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 — 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) {
order(ctx, {
army: armyIdx, unitIds: defenders,
order: { type: 'attackMove', x: threat.x, y: threat.y },
});
mem.phase = 'defend';
}
return;
}
const target = enemyBaseGuess(ctx);
if (!target) return;
const wantSize = Math.max(2, Math.round(tuning.squadSize * (0.6 + aggression * 0.8)));
if (mem.phase !== 'attack') {
if (mem.squad.length < wantSize) {
// Gather near the base while we build up.
const gathering = idle.filter((u) => Math.hypot(u.x - mem.baseX, u.y - mem.baseY) > state.tileSize * 8);
if (gathering.length) {
order(ctx, {
army: armyIdx, unitIds: gathering.map((u) => u.id),
order: { type: 'move', x: mem.baseX, y: mem.baseY },
});
}
return;
}
mem.phase = 'attack';
mem.squadPeak = mem.squad.length;
mem.lastAttackTick = state.tick;
}
if (mem.phase === 'attack') {
// Break off if the push has been gutted — skill 2+ knows when it's lost a fight.
if (skill >= 2 && mem.squad.length < mem.squadPeak * 0.4) {
mem.phase = 'build';
order(ctx, {
army: armyIdx, unitIds: mem.squad.slice(),
order: { type: 'move', x: mem.baseX, y: mem.baseY },
});
mem.squad = [];
return;
}
const pushing = mem.squad.filter((id) => {
const e = state.entities.find((x) => x.id === id && !x.dead);
return e && e.orders.length === 0;
});
if (pushing.length) {
order(ctx, {
army: armyIdx, unitIds: pushing,
order: { type: 'attackMove', x: target.x, y: target.y },
});
}
if (skill >= 3) focusFire(ctx, mine);
}
}
function nearestThreatToBase(ctx) {
const { state, mem, armyIdx } = ctx;
if (!mem.baseSet) return null;
const radius = state.tileSize * 16;
let best = null, bestD = Infinity;
for (const e of state.entities) {
if (e.dead || e.army === armyIdx || !state.armies[e.army]) continue;
if (!isVisibleTo(state, armyIdx, e)) continue;
if (e.isBuilding) continue;
const d = Math.hypot(e.x - mem.baseX, e.y - mem.baseY);
if (d < radius && d < bestD) { best = e; bestD = d; }
}
return best;
}
/** Skill 3+: concentrate the squad on one target instead of spreading damage. */
function focusFire(ctx, mine) {
const { state, rules, mem, armyIdx } = ctx;
const members = mem.squad
.map((id) => state.entities.find((e) => e.id === id && !e.dead))
.filter(Boolean);
if (members.length < 3) return;
// 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, 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 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.3 - e.hp / e.maxHp) * shooters.length;
if (score > bestScore) { best = e; bestScore = score; bestShooters = shooters; }
}
if (!best) return;
mem.focusId = best.id;
order(ctx, {
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

@ -0,0 +1,576 @@
// Total Annihilation — procedural art fallback.
//
// Every spritesheet declared in data/totalannihilation-artwork.json is optional. If its
// `path` is null (or the PNG 404s), we paint a canvas stand-in with the identical frame
// layout, so the view never knows the difference and the game is fully playable with zero
// art files present.
//
// The one structural change from the Advance Wars original this is modelled on
// (AdvanceWarsMapView.js ensureSheets/PROC_PAINTERS): painters are keyed by the sheet's
// `kind`, NOT by its name. That is what makes adding a third army or a fourth terrain theme
// a pure JSON edit — a new sheet with kind:"unit" automatically gets the unit painter.
const BODY = '#dcdcdc'; // neutral grey — the runtime army tint multiplies over this
const BODY_DARK = '#a8adb5';
const OUTLINE = '#26262e';
const ACCENT = '#8f939b';
const GLASS = '#3a4450';
const HOT = '#ffd27a';
/** Deterministic per-frame PRNG so speckles are stable across reloads. */
function frameRng(seed) {
let a = (seed * 2654435761) >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), a | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* 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, 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();
return {
ctx,
at(frame, draw) {
const fx = (frame % cols) * frameW;
const fy = ((frame / cols) | 0) * frameH;
ctx.save();
ctx.translate(fx, fy);
draw(ctx, frameW, frameH);
ctx.restore();
},
finish() {
tex.refresh();
for (let f = 0; f < count; f++) {
const fx = (f % cols) * frameW;
const fy = ((f / cols) | 0) * frameH;
tex.add(f, 0, fx, fy, frameW, frameH);
}
return tex;
},
};
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function fillStroke(ctx, fill, stroke = OUTLINE, lw = 2) {
ctx.fillStyle = fill; ctx.fill();
if (stroke) { ctx.lineWidth = lw; ctx.strokeStyle = stroke; ctx.stroke(); }
}
// ---------------------------------------------------------------------------
// Unit shapes — all drawn FACING RIGHT (+x), centred, inside a rotation-safe circle
// ---------------------------------------------------------------------------
const UNIT_SHAPES = {
commander(ctx, S) {
const c = S / 2;
// legs behind
ctx.save(); ctx.translate(c, c);
ctx.fillStyle = BODY_DARK; ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2;
for (const sy of [-9, 9]) {
roundRect(ctx, -14, sy - 4, 14, 8, 3); ctx.fill(); ctx.stroke();
}
// torso
roundRect(ctx, -11, -11, 24, 22, 6); fillStroke(ctx, BODY);
// shoulder pods
for (const sy of [-12, 12]) {
ctx.beginPath(); ctx.arc(-2, sy, 5.5, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
}
// visor
ctx.fillStyle = GLASS;
roundRect(ctx, 6, -5, 6, 10, 2); ctx.fill();
// nanolathe arm
ctx.strokeStyle = ACCENT; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(10, -3); ctx.lineTo(21, -3); ctx.stroke();
ctx.fillStyle = HOT;
ctx.beginPath(); ctx.arc(22, -3, 2.5, 0, Math.PI * 2); ctx.fill();
ctx.restore();
},
commanderTurret(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
ctx.beginPath(); ctx.arc(0, 0, 7, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
roundRect(ctx, 5, -4.5, 17, 9, 3); fillStroke(ctx, BODY);
ctx.strokeStyle = '#8ad4ff'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(21, 0, 5, -0.9, 0.9); ctx.stroke();
ctx.restore();
},
infantry(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
roundRect(ctx, -8, -5, 16, 10, 5); fillStroke(ctx, BODY);
ctx.beginPath(); ctx.arc(1, 0, 4.5, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(4, -2); ctx.lineTo(14, -2); ctx.stroke();
ctx.restore();
},
sniper(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
roundRect(ctx, -9, -4, 17, 8, 4); fillStroke(ctx, BODY);
ctx.beginPath(); ctx.arc(0, 0, 4, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(3, -2); ctx.lineTo(20, -2); ctx.stroke();
ctx.fillStyle = GLASS;
ctx.beginPath(); ctx.arc(6, -4.5, 2.2, 0, Math.PI * 2); ctx.fill();
ctx.restore();
},
jeep(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
// wheels first, so the body overlaps them
ctx.fillStyle = '#2f3238';
for (const [wx, wy] of [[-8, -9], [8, -9], [-8, 9], [8, 9]]) {
roundRect(ctx, wx - 4, wy - 3, 8, 6, 2.5); ctx.fill();
}
roundRect(ctx, -13, -8, 26, 16, 4); fillStroke(ctx, BODY);
// windscreen wedge
ctx.fillStyle = GLASS;
ctx.beginPath(); ctx.moveTo(4, -6); ctx.lineTo(12, -3); ctx.lineTo(12, 3); ctx.lineTo(4, 6);
ctx.closePath(); ctx.fill();
ctx.restore();
},
jeepTurret(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
ctx.beginPath(); ctx.arc(0, 0, 4.5, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(3, 0); ctx.lineTo(14, 0); ctx.stroke();
ctx.restore();
},
tank(ctx, S) { tankHull(ctx, S, 34, 24); },
rockettank(ctx, S) { tankHull(ctx, S, 36, 24); },
tankTurret(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
// trapezoid turret
ctx.beginPath();
ctx.moveTo(-8, -8); ctx.lineTo(7, -6); ctx.lineTo(7, 6); ctx.lineTo(-8, 8);
ctx.closePath(); fillStroke(ctx, BODY);
ctx.beginPath(); ctx.arc(-1, 0, 4, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(5, 0); ctx.lineTo(24, 0); ctx.stroke();
ctx.strokeStyle = BODY_DARK; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(5, 0); ctx.lineTo(24, 0); ctx.stroke();
ctx.restore();
},
rockettankTurret(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
roundRect(ctx, -8, -9, 18, 18, 3); fillStroke(ctx, BODY);
ctx.fillStyle = '#2f3238';
for (const [tx, ty] of [[3, -4.5], [3, 4.5], [-2, -4.5], [-2, 4.5]]) {
ctx.beginPath(); ctx.arc(tx, ty, 2.6, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
},
};
function tankHull(ctx, S, len, wid) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
// tread rails
ctx.fillStyle = '#33363c'; ctx.strokeStyle = OUTLINE; ctx.lineWidth = 1.5;
for (const sy of [-wid / 2, wid / 2 - 6]) {
roundRect(ctx, -len / 2, sy, len, 6, 2); ctx.fill(); ctx.stroke();
}
ctx.fillStyle = '#4a4e56';
for (let i = 0; i < 6; i++) {
const bx = -len / 2 + 2 + i * ((len - 4) / 6);
ctx.fillRect(bx, -wid / 2 + 1, 3, 4);
ctx.fillRect(bx, wid / 2 - 5, 3, 4);
}
// hull
roundRect(ctx, -len / 2 + 3, -wid / 2 + 5, len - 6, wid - 10, 4); fillStroke(ctx, BODY);
// glacis plate
ctx.fillStyle = BODY_DARK;
ctx.beginPath();
ctx.moveTo(len / 2 - 3, -wid / 2 + 6); ctx.lineTo(len / 2 - 3, wid / 2 - 6);
ctx.lineTo(len / 2 - 9, wid / 2 - 6); ctx.lineTo(len / 2 - 9, -wid / 2 + 6);
ctx.closePath(); ctx.fill();
ctx.restore();
}
// ---------------------------------------------------------------------------
// Structure shapes — NOT rotated, drawn to fill the cell
// ---------------------------------------------------------------------------
const STRUCT_SHAPES = {
energyGen(ctx, S) {
const p = S * 0.12, d = S - p * 2;
roundRect(ctx, p, p, d, d, S * 0.10); fillStroke(ctx, BODY, OUTLINE, 3);
// vent slots
ctx.fillStyle = '#3b3f47';
for (let i = 0; i < 4; i++) ctx.fillRect(p + d * 0.14, p + d * (0.16 + i * 0.18), d * 0.24, d * 0.10);
// lightning glyph
ctx.fillStyle = HOT;
ctx.beginPath();
ctx.moveTo(p + d * 0.66, p + d * 0.14);
ctx.lineTo(p + d * 0.48, p + d * 0.52);
ctx.lineTo(p + d * 0.62, p + d * 0.52);
ctx.lineTo(p + d * 0.46, p + d * 0.90);
ctx.lineTo(p + d * 0.82, p + d * 0.44);
ctx.lineTo(p + d * 0.66, p + d * 0.44);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2; ctx.stroke();
},
massGen(ctx, S) {
const c = S / 2, r = S * 0.40;
// hex pad
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2 + Math.PI / 6;
const x = c + Math.cos(a) * r, y = c + Math.sin(a) * r;
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
ctx.closePath(); fillStroke(ctx, BODY, OUTLINE, 3);
// concentric rings
ctx.strokeStyle = ACCENT; ctx.lineWidth = 2.5;
for (const rr of [r * 0.62, r * 0.44, r * 0.26]) {
ctx.beginPath(); ctx.arc(c, c, rr, 0, Math.PI * 2); ctx.stroke();
}
// drill spindle
ctx.beginPath(); ctx.arc(c, c, r * 0.16, 0, Math.PI * 2); fillStroke(ctx, '#3b3f47', OUTLINE, 2);
// corner anchors
ctx.fillStyle = BODY_DARK;
for (const [ax, ay] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
roundRect(ctx, c + ax * r * 0.74 - 5, c + ay * r * 0.74 - 5, 10, 10, 2); ctx.fill();
}
},
barracks(ctx, S) {
const p = S * 0.10, w = S - p * 2, h = S - p * 2;
roundRect(ctx, p, p, w, h, S * 0.06); fillStroke(ctx, BODY, OUTLINE, 3);
// corrugated roof
ctx.strokeStyle = BODY_DARK; ctx.lineWidth = 2;
for (let i = 1; i < 7; i++) {
const y = p + (h * i) / 8;
ctx.beginPath(); ctx.moveTo(p + 4, y); ctx.lineTo(p + w - 4, y); ctx.stroke();
}
// arched door at the bay end
ctx.fillStyle = '#2f3238';
ctx.beginPath();
const dw = w * 0.34, dx = p + w / 2 - dw / 2, dy = p + h * 0.62;
ctx.moveTo(dx, p + h - 4);
ctx.lineTo(dx, dy + dw / 2);
ctx.arc(dx + dw / 2, dy + dw / 2, dw / 2, Math.PI, 0);
ctx.lineTo(dx + dw, p + h - 4);
ctx.closePath(); ctx.fill();
// marching chevron over the door
ctx.strokeStyle = HOT; ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(p + w / 2 - 10, dy - 12); ctx.lineTo(p + w / 2, dy - 4); ctx.lineTo(p + w / 2 + 10, dy - 12);
ctx.stroke();
},
vehiclePlant(ctx, S) {
const p = S * 0.07, w = S - p * 2, h = S - p * 2;
roundRect(ctx, p, p, w, h, S * 0.05); fillStroke(ctx, BODY, OUTLINE, 3);
// gantry rail across the roof
ctx.fillStyle = BODY_DARK;
ctx.fillRect(p + 4, p + h * 0.12, w - 8, h * 0.09);
ctx.fillStyle = '#3b3f47';
for (let i = 0; i < 5; i++) ctx.fillRect(p + 8 + i * ((w - 16) / 5), p + h * 0.12, 5, h * 0.09);
// roll-up bay door with slats
const dw = w * 0.62, dx = p + w / 2 - dw / 2, dy = p + h * 0.38, dh = h * 0.44;
ctx.fillStyle = '#2f3238';
ctx.fillRect(dx, dy, dw, dh);
ctx.strokeStyle = '#4a4e56'; ctx.lineWidth = 2;
for (let i = 1; i < 6; i++) {
const y = dy + (dh * i) / 6;
ctx.beginPath(); ctx.moveTo(dx + 2, y); ctx.lineTo(dx + dw - 2, y); ctx.stroke();
}
// floor chevron pointing out of the mouth — also shows where units will spawn
ctx.strokeStyle = HOT; ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(p + w / 2 - 14, p + h - 20); ctx.lineTo(p + w / 2, p + h - 8); ctx.lineTo(p + w / 2 + 14, p + h - 20);
ctx.stroke();
},
};
// ---------------------------------------------------------------------------
// Painters, keyed by sheet kind
// ---------------------------------------------------------------------------
const PROC_PAINTERS = {
/** One frame per unit hull, plus one per turret. Driven entirely off the rules. */
unit(scene, key, spec, rules) {
const defs = rules.units.filter((u) => u.sheetSlot === 'unitSheet');
let count = 0;
for (const d of defs) count = Math.max(count, d.frame + 1, (d.turretFrame ?? -1) + 1);
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
for (const d of defs) {
const hull = UNIT_SHAPES[d.procShape];
if (hull) sh.at(d.frame, (ctx, S) => hull(ctx, S));
if (d.turretFrame != null) {
const turret = UNIT_SHAPES[`${d.procShape}Turret`];
if (turret) sh.at(d.turretFrame, (ctx, S) => turret(ctx, S));
}
}
return sh.finish();
},
/** Two frames per building: finished, then a translucent wireframe for the build site. */
structure(scene, key, spec, rules) {
const defs = rules.buildings.filter((b) => b.sheetSlot === 'structureSheet');
let count = 0;
for (const d of defs) count = Math.max(count, d.frame + 1, (d.buildFrame ?? -1) + 1);
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
for (const d of defs) {
const draw = STRUCT_SHAPES[d.procShape];
if (!draw) continue;
sh.at(d.frame, (ctx, S) => draw(ctx, S));
if (d.buildFrame != null) {
sh.at(d.buildFrame, (ctx, S) => {
ctx.globalAlpha = 0.35;
draw(ctx, S);
ctx.globalAlpha = 1;
});
}
}
return sh.finish();
},
/** One sheet per theme, coloured entirely from that theme's palette. */
terrain(scene, key, spec, rules, art, sheetName) {
const theme = Object.values(art.themes ?? {}).find((t) => t.sheet === sheetName);
const p = theme?.palette ?? {};
const F = art.terrainFrames ?? {};
// 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) => {
const rnd = frameRng(seed);
ctx.fillStyle = color;
for (let i = 0; i < n; i++) {
const x = rnd() * S, y = rnd() * S, r = r0 + rnd() * (r1 - r0);
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
}
};
const flat = (frame, color, seed, speckColor, n = 10) => sh.at(frame, (ctx, S) => {
ctx.fillStyle = color; ctx.fillRect(0, 0, S, S);
speckle(ctx, S, seed, speckColor, n, 1, 2.6);
});
flat(F.ground ?? 0, p.ground ?? '#5f7f3d', 11, p.speck ?? '#000', 10);
flat(F.groundAlt1 ?? 1, p.groundAlt1 ?? p.ground ?? '#6b8c45', 22, p.speck ?? '#000', 12);
flat(F.groundAlt2 ?? 2, p.groundAlt2 ?? p.ground ?? '#55743a', 33, p.speck ?? '#000', 8);
sh.at(F.rough ?? 3, (ctx, S) => {
ctx.fillStyle = p.rough ?? '#7d7048'; ctx.fillRect(0, 0, S, S);
speckle(ctx, S, 44, 'rgba(0,0,0,0.30)', 22, 1.5, 3.6);
speckle(ctx, S, 45, 'rgba(255,255,255,0.14)', 14, 1, 2.4);
});
sh.at(F.cliff ?? 4, (ctx, S) => {
ctx.fillStyle = p.cliff ?? '#4c4838'; ctx.fillRect(0, 0, S, S);
// lit top edge sells the height difference without a real height layer
ctx.fillStyle = p.cliffTop ?? '#6b6552';
ctx.fillRect(0, 0, S, S * 0.30);
ctx.strokeStyle = 'rgba(0,0,0,0.45)'; ctx.lineWidth = 2;
const rnd = frameRng(55);
for (let i = 0; i < 5; i++) {
const x = rnd() * S;
ctx.beginPath(); ctx.moveTo(x, S * 0.30); ctx.lineTo(x + (rnd() - 0.5) * 12, S); ctx.stroke();
}
});
const wave = (frame, color, seed) => sh.at(frame, (ctx, S) => {
ctx.fillStyle = color; ctx.fillRect(0, 0, S, S);
const rnd = frameRng(seed);
ctx.strokeStyle = 'rgba(255,255,255,0.16)'; ctx.lineWidth = 2;
for (let i = 0; i < 3; i++) {
const y = rnd() * S;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.quadraticCurveTo(S / 2, y + (rnd() - 0.5) * 10, S, y);
ctx.stroke();
}
});
wave(F.water ?? 5, p.water ?? '#2f5f9e', 66);
wave(F.waterDeep ?? 6, p.waterDeep ?? '#1d3f70', 77);
sh.at(F.metal ?? 7, (ctx, S) => {
ctx.fillStyle = p.metal ?? '#9aa3ab'; ctx.fillRect(0, 0, S, S);
ctx.fillStyle = 'rgba(0,0,0,0.35)';
for (let ry = 0; ry < 3; ry++) {
for (let rx = 0; rx < 3; rx++) {
ctx.beginPath();
ctx.arc(S * (0.22 + rx * 0.28), S * (0.22 + ry * 0.28), 2.6, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.strokeStyle = 'rgba(255,255,255,0.20)'; ctx.lineWidth = 2;
ctx.strokeRect(3, 3, S - 6, S - 6);
});
flat(F.road ?? 8, p.road ?? '#8b8578', 88, 'rgba(0,0,0,0.25)', 6);
return sh.finish();
},
/** Build-menu glyphs: a dark plate plus a shrunken copy of the unit/structure shape. */
icon(scene, key, spec, rules) {
const entries = [];
for (const d of [...rules.units, ...rules.buildings]) {
if (d.icon != null) entries.push({ frame: d.icon, shape: d.procShape, building: !!d.isBuilding });
}
const cmd = rules.commandIcons ?? {};
let count = 0;
for (const e of entries) count = Math.max(count, e.frame + 1);
for (const f of Object.values(cmd)) count = Math.max(count, f + 1);
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
const plate = (ctx, S) => {
roundRect(ctx, 2, 2, S - 4, S - 4, 6);
ctx.fillStyle = '#1b2029'; ctx.fill();
ctx.strokeStyle = '#3d4753'; ctx.lineWidth = 2; ctx.stroke();
};
for (const e of entries) {
sh.at(e.frame, (ctx, S) => {
plate(ctx, S);
const shape = e.building ? STRUCT_SHAPES[e.shape] : UNIT_SHAPES[e.shape];
if (!shape) return;
ctx.save();
if (e.building) {
ctx.translate(S * 0.12, S * 0.12);
ctx.scale(0.76, 0.76);
shape(ctx, S);
} else {
// Units are drawn facing right around the cell centre; turn them 45 degrees so a
// build icon reads as a vehicle rather than as a sideways smear.
ctx.translate(S / 2, S / 2); ctx.rotate(-Math.PI / 4); ctx.scale(0.95, 0.95);
ctx.translate(-S / 2, -S / 2);
shape(ctx, S);
}
ctx.restore();
});
}
const glyph = (frame, draw) => { if (frame != null) sh.at(frame, (ctx, S) => { plate(ctx, S); draw(ctx, S); }); };
const stroke = (ctx, color = '#cfe3ff', lw = 3) => { ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.stroke(); };
glyph(cmd.move, (ctx, S) => {
ctx.beginPath(); ctx.moveTo(S * 0.28, S * 0.72); ctx.lineTo(S * 0.72, S * 0.28); stroke(ctx);
ctx.beginPath(); ctx.moveTo(S * 0.72, S * 0.28); ctx.lineTo(S * 0.52, S * 0.30);
ctx.moveTo(S * 0.72, S * 0.28); ctx.lineTo(S * 0.70, S * 0.48); stroke(ctx);
});
glyph(cmd.attack, (ctx, S) => {
ctx.beginPath(); ctx.arc(S / 2, S / 2, S * 0.24, 0, Math.PI * 2); stroke(ctx, '#ff9a8a');
ctx.beginPath();
ctx.moveTo(S / 2, S * 0.16); ctx.lineTo(S / 2, S * 0.34);
ctx.moveTo(S / 2, S * 0.66); ctx.lineTo(S / 2, S * 0.84);
ctx.moveTo(S * 0.16, S / 2); ctx.lineTo(S * 0.34, S / 2);
ctx.moveTo(S * 0.66, S / 2); ctx.lineTo(S * 0.84, S / 2);
stroke(ctx, '#ff9a8a', 2.5);
});
glyph(cmd.attackMove, (ctx, S) => {
ctx.beginPath(); ctx.arc(S / 2, S / 2, S * 0.20, 0, Math.PI * 2); stroke(ctx, '#ffc98a', 2.5);
ctx.beginPath(); ctx.moveTo(S * 0.20, S * 0.80); ctx.lineTo(S * 0.80, S * 0.20); stroke(ctx, '#ffc98a', 2.5);
});
glyph(cmd.stop, (ctx, S) => {
roundRect(ctx, S * 0.30, S * 0.30, S * 0.40, S * 0.40, 3);
ctx.fillStyle = '#ff9a8a'; ctx.fill();
});
glyph(cmd.hold, (ctx, S) => {
ctx.beginPath();
ctx.moveTo(S / 2, S * 0.20); ctx.lineTo(S * 0.76, S * 0.34);
ctx.lineTo(S * 0.76, S * 0.58); ctx.lineTo(S / 2, S * 0.80);
ctx.lineTo(S * 0.24, S * 0.58); ctx.lineTo(S * 0.24, S * 0.34);
ctx.closePath(); stroke(ctx, '#9ee8b0');
});
glyph(cmd.patrol, (ctx, S) => {
ctx.beginPath(); ctx.arc(S / 2, S / 2, S * 0.22, 0.6, Math.PI * 1.7); stroke(ctx);
ctx.beginPath(); ctx.moveTo(S * 0.70, S * 0.62); ctx.lineTo(S * 0.78, S * 0.72);
ctx.lineTo(S * 0.62, S * 0.76); stroke(ctx, '#cfe3ff', 2.5);
});
glyph(cmd.guard, (ctx, S) => {
ctx.beginPath();
ctx.moveTo(S / 2, S * 0.18); ctx.lineTo(S * 0.78, S * 0.32);
ctx.lineTo(S * 0.70, S * 0.72); ctx.lineTo(S / 2, S * 0.84);
ctx.lineTo(S * 0.30, S * 0.72); ctx.lineTo(S * 0.22, S * 0.32);
ctx.closePath(); ctx.fillStyle = 'rgba(158,232,176,0.25)'; ctx.fill(); stroke(ctx, '#9ee8b0', 2.5);
});
glyph(cmd.assist, (ctx, S) => {
ctx.strokeStyle = '#8ad4ff'; ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(S / 2, S * 0.24); ctx.lineTo(S / 2, S * 0.76);
ctx.moveTo(S * 0.24, S / 2); ctx.lineTo(S * 0.76, S / 2);
ctx.stroke();
});
return sh.finish();
},
};
/**
* Resolve every declared sheet to a usable texture key, painting stand-ins as needed.
* @returns {{keys:Object<string,string>, procedural:string[]}} sheetName -> texture key
*/
export function ensureSheets(scene, rules, art) {
const keys = Object.create(null);
const procedural = [];
for (const [name, spec] of Object.entries(art.sheets ?? {})) {
if (spec.path && scene.textures.exists(spec.key)) { keys[name] = spec.key; continue; }
const procKey = `${spec.key}-proc`;
if (!scene.textures.exists(procKey)) {
const painter = PROC_PAINTERS[spec.kind];
if (!painter) {
console.warn(`[TAArt] sheet "${name}" has unknown kind "${spec.kind}" — skipping`);
continue;
}
painter(scene, procKey, spec, rules, art, name);
}
keys[name] = procKey;
procedural.push(name);
}
return { keys, procedural };
}
/** Frame dimensions of a resolved sheet, for sprite scaling. */
export function sheetFrameSize(art, sheetName) {
const s = art.sheets?.[sheetName];
return s ? { w: s.frameWidth, h: s.frameHeight } : { w: 64, h: 64 };
}
export { PROC_PAINTERS, UNIT_SHAPES, STRUCT_SHAPES, mkCanvasSheet, roundRect };

View File

@ -0,0 +1,231 @@
// Total Annihilation — procedural combat effects.
//
// Every weapon effect in this game is drawn, not sprited: gunfire tracers, tank shells,
// rocket trails, beams, explosions, nanolathe streams and craters are all Graphics strokes.
// Two layers are cleared and re-stroked each frame from a TTL list — the Star Control
// `fxList` idiom (StarControlGame.js renderWorld) using the repo's glow-stroke pair: one
// wide low-alpha pass, then a thin opaque one on top.
const MAX_FX = 420; // hard cap; oldest transients are dropped rather than dropping frames
export default class TAFx {
constructor(scene, worldRoot, depths) {
this.scene = scene;
// 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 = [];
this.shakeUntil = 0;
}
destroy() {
this.gUnder.destroy();
this.gOver.destroy();
this.list.length = 0;
}
clear() { this.list.length = 0; }
push(fx) {
if (this.list.length >= MAX_FX) this.list.shift();
fx.age = 0;
this.list.push(fx);
}
/** Translate a simulation event into a transient effect. */
onEvent(ev, rules) {
switch (ev.t) {
case 'shot':
this.push({
kind: ev.style === 'beam' || ev.style === 'dgun' ? 'beam' : 'tracer',
x1: ev.x1, y1: ev.y1, x2: ev.x2, y2: ev.y2,
color: colorInt(ev.color), width: ev.width ?? 2, ttl: ev.lifeMs ?? 80,
});
break;
case 'weaponFired':
this.push({
kind: 'muzzle', x: ev.x, y: ev.y, heading: ev.heading,
color: 0xffe6a0, ttl: 70,
});
break;
case 'impact':
this.push({
kind: 'blast', x: ev.x, y: ev.y, r: Math.max(10, ev.radius),
color: 0xffb45a, ttl: ev.radius > 30 ? 380 : 200,
});
break;
case 'bigExplosion':
this.push({ kind: 'shockwave', x: ev.x, y: ev.y, r: ev.radius, color: 0xfff0c0, ttl: 700 });
this.push({ kind: 'blast', x: ev.x, y: ev.y, r: ev.radius * 0.55, color: 0xff8a3c, ttl: 520 });
this.shakeUntil = this.scene.time.now + 320;
this.scene.cameras.main.shake(300, 0.010);
break;
case 'unitDestroyed':
this.push({
kind: 'blast', x: ev.x, y: ev.y,
r: Math.max(16, ev.radius * (ev.isBuilding ? 2.0 : 1.4)),
color: ev.isBuilding ? 0xffa040 : 0xff9060,
ttl: ev.isBuilding ? 460 : 300,
});
this.push({ kind: 'scorch', x: ev.x, y: ev.y, r: ev.radius * 1.2, ttl: 4200 });
break;
case 'nanolathe':
// The stream itself is drawn live from builder->site in draw(); this is the sparkle
// at the receiving end so a site under construction always reads as active.
this.push({ kind: 'spark', x: ev.x, y: ev.y, color: 0x8ad4ff, ttl: 220 });
break;
default: break;
}
}
/**
* @param {number} delta ms since last frame
* @param {Array} nanoLinks live [{x1,y1,x2,y2,color}] builder->target streams
* @param {Array} projectiles interpolated [{x,y,vx,vy,color,width,style,trail}]
*/
draw(delta, nanoLinks, projectiles, timeMs) {
const gu = this.gUnder, go = this.gOver;
gu.clear(); go.clear();
// ---- persistent-ish world marks (under actors) ----
for (const fx of this.list) {
if (fx.kind !== 'scorch') continue;
const t = fx.age / fx.ttl;
gu.fillStyle(0x1a1410, 0.5 * (1 - t));
gu.fillCircle(fx.x, fx.y, fx.r);
}
// ---- nanolathe streams (under actors) ----
for (const link of nanoLinks) {
const phase = (timeMs * 0.006) % 1;
gu.lineStyle(5, link.color, 0.14);
gu.lineBetween(link.x1, link.y1, link.x2, link.y2);
gu.lineStyle(2, link.color, 0.9);
// dashed, marching toward the target so it reads as material flowing
const dx = link.x2 - link.x1, dy = link.y2 - link.y1;
const len = Math.hypot(dx, dy) || 1;
const ux = dx / len, uy = dy / len;
const dash = 14, gap = 10;
for (let d = phase * (dash + gap); d < len; d += dash + gap) {
const a = Math.min(len, d), b = Math.min(len, d + dash);
gu.lineBetween(link.x1 + ux * a, link.y1 + uy * a, link.x1 + ux * b, link.y1 + uy * b);
}
}
// ---- rocket trails (under actors) ----
for (const p of projectiles) {
if (!p.trail || p.trail.length < 4) continue;
const n = p.trail.length / 2;
for (let i = 1; i < n; i++) {
const a = i / n;
go.lineStyle(p.width * 1.6 * a, p.color, 0.30 * a);
gu.lineStyle(p.width * 1.6 * a, p.color, 0.30 * a);
gu.lineBetween(p.trail[(i - 1) * 2], p.trail[(i - 1) * 2 + 1], p.trail[i * 2], p.trail[i * 2 + 1]);
}
}
// ---- live projectiles (over actors) ----
for (const p of projectiles) {
const ang = Math.atan2(p.vy, p.vx);
if (p.style === 'rocket') {
const bx = p.x - Math.cos(ang) * 9, by = p.y - Math.sin(ang) * 9;
go.lineStyle(p.width * 2.4, p.color, 0.22);
go.lineBetween(bx, by, p.x, p.y);
go.fillStyle(0xfff0c0, 1);
go.fillCircle(p.x, p.y, p.width * 0.9);
go.fillStyle(p.color, 0.85);
go.fillCircle(bx, by, p.width * 1.3);
} else {
const bx = p.x - Math.cos(ang) * 12, by = p.y - Math.sin(ang) * 12;
go.lineStyle(p.width * 1.9, p.color, 0.20);
go.lineBetween(bx, by, p.x, p.y);
go.lineStyle(p.width * 0.8, p.color, 1);
go.lineBetween(bx, by, p.x, p.y);
go.fillStyle(0xfff6d8, 1);
go.fillCircle(p.x, p.y, p.width * 0.75);
}
}
// ---- transients (over actors) ----
const keep = [];
for (const fx of this.list) {
fx.age += delta;
if (fx.age >= fx.ttl) continue;
keep.push(fx);
const t = fx.age / fx.ttl;
switch (fx.kind) {
case 'tracer': {
const a = 1 - t;
go.lineStyle(fx.width * 3.2, fx.color, 0.16 * a);
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
go.lineStyle(fx.width, fx.color, a);
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
break;
}
case 'beam': {
const a = 1 - t * 0.75;
go.lineStyle(fx.width * 2.6 * (1 - t) + 1, fx.color, 0.18 * a);
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
go.lineStyle(fx.width * (1 - t) + 1, fx.color, a);
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
break;
}
case 'muzzle': {
const a = 1 - t;
const len = 13 * (1 - t) + 4;
go.fillStyle(fx.color, 0.85 * a);
for (const spread of [-0.35, 0, 0.35]) {
const ang = fx.heading + spread;
go.beginPath();
go.moveTo(fx.x, fx.y);
go.lineTo(fx.x + Math.cos(ang - 0.13) * len, fx.y + Math.sin(ang - 0.13) * len);
go.lineTo(fx.x + Math.cos(ang + 0.13) * len, fx.y + Math.sin(ang + 0.13) * len);
go.closePath();
go.fillPath();
}
break;
}
case 'blast': {
const a = 1 - t;
const r = fx.r * (0.35 + t * 0.9);
go.fillStyle(fx.color, 0.55 * a);
go.fillCircle(fx.x, fx.y, r);
go.fillStyle(0xfff6d8, 0.7 * a * a);
go.fillCircle(fx.x, fx.y, r * 0.45);
go.lineStyle(3 * a + 1, 0xffffff, 0.7 * a);
go.strokeCircle(fx.x, fx.y, r);
break;
}
case 'shockwave': {
const a = 1 - t;
go.lineStyle(9 * a + 1, fx.color, a);
go.strokeCircle(fx.x, fx.y, fx.r * t);
go.lineStyle(4 * a + 1, 0xff8a3c, a * 0.7);
go.strokeCircle(fx.x, fx.y, fx.r * t * 0.72);
break;
}
case 'spark': {
const a = 1 - t;
go.fillStyle(fx.color, a);
go.fillCircle(fx.x, fx.y, 3 * a + 1);
break;
}
default: break;
}
}
this.list = keep;
}
}
function colorInt(v) {
if (typeof v === 'number') return v;
if (typeof v === 'string' && v[0] === '#') return parseInt(v.slice(1), 16);
return 0xffffff;
}
export { colorInt };

View File

@ -0,0 +1,408 @@
// 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
// CTRL is the queue modifier (as in the original game); SHIFT adds to the selection and
// buys x5 from a factory. Kept in one place so the hint and the bindings cannot drift.
const HINT = 'LMB select · drag box · RMB order · CTRL+RMB queue · A attack-move · X stop · H hold · WASD pan';
/** 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(',')
// Order-queue length is part of the signature, otherwise queueing a command with CTRL
// changes nothing on screen until the selection itself changes.
+ '|' + sel.map((e) => e.orders?.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(HINT);
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.orders?.length > 1) lines.push(`${lead.orders.length} orders queued`);
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}`);
const queued = sel.reduce((n, e) => Math.max(n, e.orders?.length ?? 0), 0);
if (queued > 1) lines.push(`${queued} orders queued (hold CTRL to add more)`);
}
this.selDetail.setText(lines.join('\n'));
if (placementDef) {
this.hint.setText(`Placing ${placementDef.name} — LMB to site it, hold CTRL to queue several, Esc to cancel`);
} else {
this.hint.setText(HINT);
}
// 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);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,295 @@
// Total Annihilation — seeded procedural skirmish map generator.
//
// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js.
//
// Produces exactly the same `map` shape a hand-authored campaign mission declares
// ({ w, h, theme, terrain, starts }), so campaign and skirmish share one loader.
//
// Determinism: same seed + same options => byte-identical map. Everything draws from the
// local mulberry32; never call Math.random() here.
import { createNav, findPath, clearanceFor, nearestUsableTile, tileIndex } from './TANav.js';
function mulberry32(seed) {
let a = seed >>> 0;
return function next() {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), a | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Smooth 2D value noise: a coarse random lattice, bilinearly interpolated, two octaves. */
function valueNoise(rnd, w, h, scale) {
const lw = Math.max(2, Math.ceil(w * scale) + 1);
const lh = Math.max(2, Math.ceil(h * scale) + 1);
const lattice = new Float32Array(lw * lh);
for (let i = 0; i < lattice.length; i++) lattice[i] = rnd();
const smooth = (t) => t * t * (3 - 2 * t);
const sample = (fx, fy) => {
const x0 = Math.min(lw - 1, Math.floor(fx)), y0 = Math.min(lh - 1, Math.floor(fy));
const x1 = Math.min(lw - 1, x0 + 1), y1 = Math.min(lh - 1, y0 + 1);
const tx = smooth(fx - x0), ty = smooth(fy - y0);
const a = lattice[y0 * lw + x0], b = lattice[y0 * lw + x1];
const c = lattice[y1 * lw + x0], d = lattice[y1 * lw + x1];
return (a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty;
};
const out = new Float32Array(w * h);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const base = sample(x * scale, y * scale);
const detail = sample(x * scale * 2.7, y * scale * 2.7);
out[y * w + x] = base * 0.72 + detail * 0.28;
}
}
return out;
}
/** Map (x,y) onto the canonical half/quadrant so the result is exactly symmetric. */
function canonical(sym, x, y, w, h) {
switch (sym) {
case 'mirror-x': return x < w / 2 ? [x, y] : [w - 1 - x, y];
case 'mirror-y': return y < h / 2 ? [x, y] : [x, h - 1 - y];
case 'rotational': {
// 180-degree rotation about the centre.
const beforeCentre = (y * w + x) < ((h * w) / 2);
return beforeCentre ? [x, y] : [w - 1 - x, h - 1 - y];
}
default: return [x, y];
}
}
function startPositions(sym, w, h, margin) {
switch (sym) {
case 'mirror-x': return [[margin, h >> 1], [w - 1 - margin, h >> 1]];
case 'mirror-y': return [[w >> 1, margin], [w >> 1, h - 1 - margin]];
case 'rotational': return [[margin, margin], [w - 1 - margin, h - 1 - margin]];
default: return [[margin, h >> 1], [w - 1 - margin, h >> 1]];
}
}
/**
* @param {object} rules compiled rules
* @param {object} opts { seed, size:'small'|'medium'|'large', symmetry, theme }
* @returns {{w,h,theme,terrain:Uint8Array,starts:Array,metalSpots:Array}}
*/
export function generateMap(rules, opts = {}) {
const sk = rules.skirmish;
const size = opts.size ?? sk.defaults.size;
const symmetry = opts.symmetry ?? sk.defaults.symmetry;
const theme = opts.theme ?? sk.defaults.theme;
const seed = (opts.seed ?? 1) >>> 0;
const g = sk.gen;
const dim = sk.sizes[size];
if (!dim) throw new Error(`[TAMapGen] unknown size "${size}"`);
if (!sk.symmetries.includes(symmetry)) throw new Error(`[TAMapGen] unknown symmetry "${symmetry}"`);
if (!sk.themes.includes(theme)) throw new Error(`[TAMapGen] unknown theme "${theme}"`);
const w = dim, h = dim;
const rnd = mulberry32(seed);
const T = {};
for (const t of rules.terrain) T[t.id] = t.index;
const noise = valueNoise(rnd, w, h, g.noiseScale);
const detail = valueNoise(rnd, w, h, g.noiseScale * 2.2);
const terrain = new Uint8Array(w * h);
// Thresholds are PERCENTILES of the actual noise, not raw values. Smoothed value noise
// clusters hard around 0.5, so comparing it against a raw 0.10 yields almost no water —
// quantiles make `waterFraction: 0.10` mean literally a tenth of the map.
const quantile = (arr, q) => {
const s = Float32Array.from(arr).sort();
return s[Math.max(0, Math.min(s.length - 1, Math.floor(q * (s.length - 1))))];
};
const waterT = quantile(noise, g.waterFraction);
const cliffT = quantile(noise, 1 - g.cliffFraction);
const roughLo = quantile(detail, 0.5 - g.roughFraction / 2);
const roughHi = quantile(detail, 0.5 + g.roughFraction / 2);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const [cx, cy] = canonical(symmetry, x, y, w, h);
const n = noise[cy * w + cx];
const d = detail[cy * w + cx];
let id = T.ground;
if (n < waterT) id = T.water;
else if (n > cliffT) id = T.cliff;
else if (d > roughLo && d < roughHi) id = T.rough;
terrain[y * w + x] = id;
}
}
// Majority smoothing removes single-tile speckle that would otherwise make pathing ugly.
for (let pass = 0; pass < g.smoothPasses; pass++) {
const src = terrain.slice();
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
const counts = new Map();
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const v = src[(y + dy) * w + (x + dx)];
counts.set(v, (counts.get(v) ?? 0) + 1);
}
}
let bestV = src[y * w + x], bestC = 0;
// Deterministic tie-break by terrain index — never rely on Map iteration order alone.
for (const [v, c] of [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0])) {
if (c > bestC) { bestV = v; bestC = c; }
}
if (bestC >= 6) terrain[y * w + x] = bestV;
}
}
}
// ---- starts ------------------------------------------------------------
const margin = Math.max(g.startClearRadiusTiles + 2, Math.floor(dim * 0.10));
const [p0, p1] = startPositions(symmetry, w, h, margin);
const starts = [
{ army: 0, x: p0[0], y: p0[1] },
{ army: 1, x: p1[0], y: p1[1] },
];
const clearR = g.startClearRadiusTiles;
for (const s of starts) {
for (let y = s.y - clearR; y <= s.y + clearR; y++) {
for (let x = s.x - clearR; x <= s.x + clearR; x++) {
if (x < 0 || y < 0 || x >= w || y >= h) continue;
if (Math.hypot(x - s.x, y - s.y) > clearR) continue;
terrain[y * w + x] = T.ground;
}
}
}
// ---- metal spots -------------------------------------------------------
const metalSpots = [];
const SPOT = 2; // spots are 2x2 tiles
const canPlaceSpot = (x, y) => {
if (x < 1 || y < 1 || x + SPOT > w - 1 || y + SPOT > h - 1) return false;
for (let dy = 0; dy < SPOT; dy++) {
for (let dx = 0; dx < SPOT; dx++) {
const t = terrain[(y + dy) * w + (x + dx)];
if (t !== T.ground && t !== T.rough) return false;
}
}
// Keep spots apart so a single Mass Generator can't cover two.
for (const s of metalSpots) if (Math.hypot(s.x - x, s.y - y) < 4) return false;
return true;
};
const commitSpot = (x, y) => {
for (let dy = 0; dy < SPOT; dy++) {
for (let dx = 0; dx < SPOT; dx++) terrain[(y + dy) * w + (x + dx)] = T.metal;
}
metalSpots.push({ x, y });
};
// Mirror the BLOCK, not its corner: a 2x2 block starting at x mirrors to one starting at
// w - x - SPOT. Mirroring the corner alone shifts the block one tile and breaks symmetry.
const mirrorOf = (x, y) => {
switch (symmetry) {
case 'mirror-x': return [w - x - SPOT, y];
case 'mirror-y': return [x, h - y - SPOT];
default: return [w - x - SPOT, h - y - SPOT];
}
};
const spotR = g.metalSpotRadiusTiles;
let guard = 0;
while (metalSpots.length < g.metalSpotsPerStart * 2 && guard++ < 4000) {
const a = rnd() * Math.PI * 2;
const r = clearR * 0.5 + rnd() * (spotR - clearR * 0.5);
const x = Math.round(starts[0].x + Math.cos(a) * r);
const y = Math.round(starts[0].y + Math.sin(a) * r);
const [mx, my] = mirrorOf(x, y);
// Commit both or neither, or the two players end up with different economies.
if (!canPlaceSpot(x, y)) continue;
if (!canPlaceSpot(mx, my)) continue;
commitSpot(x, y);
commitSpot(mx, my);
}
const map = { w, h, theme, terrain, starts, metalSpots, seed, size, symmetry };
// ---- reachability ------------------------------------------------------
// Guarantee the starts can actually reach each other for the widest unit in the ruleset,
// carving a corridor rather than rejecting the seed — a retry loop makes generation
// non-deterministic in wall-clock terms and can hang on a hostile parameter set.
const maxNeed = rules.units.reduce(
(m, u) => Math.max(m, clearanceFor(u.radius, rules.constants.tileSize)), 1);
const widestMoveClass = pickWidestMoveClass(rules);
ensureConnected(rules, map, widestMoveClass, maxNeed, T);
return map;
}
/** The move class with the most restrictive terrain table — if it can get through, all can. */
function pickWidestMoveClass(rules) {
let worst = null, worstScore = Infinity;
for (const [mc, spec] of Object.entries(rules.moveClasses)) {
const score = spec.costByTerrainIndex.reduce((s, c) => s + (c == null ? 0 : 1 / c), 0);
if (score < worstScore) { worstScore = score; worst = mc; }
}
return worst ?? Object.keys(rules.moveClasses)[0];
}
function ensureConnected(rules, map, mc, need, T) {
const nav = createNav(rules, map);
const [a, b] = map.starts;
const ai = nearestUsableTile(nav, mc, need, a.x, a.y, 12);
const bi = nearestUsableTile(nav, mc, need, b.x, b.y, 12);
if (ai >= 0 && bi >= 0 && findPath(nav, mc, need, ai, bi)) return;
// Carve a straight corridor wide enough for the widest unit.
const halfW = Math.max(1, need + 1);
const steps = Math.ceil(Math.hypot(b.x - a.x, b.y - a.y)) * 2;
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const cx = Math.round(a.x + (b.x - a.x) * t);
const cy = Math.round(a.y + (b.y - a.y) * t);
for (let y = cy - halfW; y <= cy + halfW; y++) {
for (let x = cx - halfW; x <= cx + halfW; x++) {
if (x < 0 || y < 0 || x >= map.w || y >= map.h) continue;
const cur = map.terrain[y * map.w + x];
if (cur === T.metal) continue;
map.terrain[y * map.w + x] = T.ground;
}
}
}
}
/** Decode a hand-authored mission map (rows of terrain chars) into the same shape. */
export function decodeMap(rules, spec) {
const rows = spec.rows ?? spec.tiles ?? [];
const w = spec.w ?? (rows[0]?.length ?? 0);
const h = spec.h ?? rows.length;
const terrain = new Uint8Array(w * h);
const fallback = rules.terrain[0].index;
for (let y = 0; y < h; y++) {
const row = rows[y] ?? '';
for (let x = 0; x < w; x++) {
const ch = row[x];
const t = ch != null ? rules.terrainByCh[ch] : null;
if (ch != null && ch !== ' ' && !t) {
throw new Error(`[TAMapGen] unknown terrain char "${ch}" at ${x},${y}`);
}
terrain[y * w + x] = t ? t.index : fallback;
}
}
return {
w, h,
theme: spec.theme ?? rules.skirmish.defaults.theme,
terrain,
starts: spec.starts ?? [],
units: spec.units ?? [],
buildings: spec.buildings ?? [],
areas: spec.areas ?? [],
};
}
export { tileIndex };

View File

@ -0,0 +1,482 @@
// Total Annihilation — navigation: grid, clearance field, A*, path smoothing, spatial hash.
//
// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js.
//
// The tile grid exists ONLY for pathfinding cost and build legality. Units carry float
// positions and a heading; they never snap to tiles. That is what lets three small units
// share one tile while a tank takes it alone — the packing falls out of collision radii,
// not out of a slot system.
//
// Two things here are load-bearing for performance:
// 1. Units are NOT stamped into the grid. Only terrain and buildings block. If every unit
// dirtied the grid, every path would be invalidated every tick.
// 2. Clearance-based pathfinding: clearance[i] is the side of the largest all-passable
// square with tile i at its top-left, so a large unit simply refuses tiles whose
// clearance is below its need. One DP pass at load, patched over a dirty rect when a
// building goes up or dies.
const SQRT2 = Math.SQRT2;
export const MAX_CLEARANCE = 8; // clamped so clearance fits a Uint8Array and the DP stays cheap
// ---------------------------------------------------------------------------
// Nav grid
// ---------------------------------------------------------------------------
/**
* Build the navigation grid for a map.
* @param {object} rules compiled rules
* @param {{w:number,h:number,terrain:Uint8Array}} map terrain indices into rules.terrain
*/
export function createNav(rules, map) {
const { w, h } = map;
const n = w * h;
const moveClassIds = Object.keys(rules.moveClasses);
const nav = {
w, h, n,
tileSize: rules.constants.tileSize,
terrain: map.terrain,
blocked: new Uint8Array(n), // buildings and build sites
moveClassIds,
passable: Object.create(null), // mc -> Uint8Array
cost: Object.create(null), // mc -> Float32Array (Infinity where impassable)
clearance: Object.create(null), // mc -> Uint8Array
// Scratch reused by every A* call so a path request allocates nothing.
_g: new Float64Array(n),
_came: new Int32Array(n),
_stamp: new Int32Array(n),
_epoch: 0,
_heap: createHeap(Math.max(64, n >> 2)),
};
for (const mc of moveClassIds) {
nav.passable[mc] = new Uint8Array(n);
nav.cost[mc] = new Float32Array(n);
nav.clearance[mc] = new Uint8Array(n);
}
refreshRegion(nav, rules, 0, 0, w - 1, h - 1);
return nav;
}
/** Recompute passability, cost and clearance over a tile rect (inclusive). */
export function refreshRegion(nav, rules, x0, y0, x1, y1) {
const { w, h } = nav;
x0 = Math.max(0, x0); y0 = Math.max(0, y0);
x1 = Math.min(w - 1, x1); y1 = Math.min(h - 1, y1);
if (x1 < x0 || y1 < y0) return;
for (const mc of nav.moveClassIds) {
const costTable = rules.moveClasses[mc].costByTerrainIndex;
const pass = nav.passable[mc];
const cost = nav.cost[mc];
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const i = y * w + x;
const c = nav.blocked[i] ? null : costTable[nav.terrain[i]];
pass[i] = c == null ? 0 : 1;
cost[i] = c == null ? Infinity : c;
}
}
}
// Clearance at (x,y) reads (x+1,y), (x,y+1), (x+1,y+1), so a change at (x,y) can only
// 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];
for (let y = y1; y >= cy0; y--) {
for (let x = x1; x >= cx0; x--) {
const i = y * w + x;
if (!pass[i]) { cl[i] = 0; continue; }
if (x === w - 1 || y === h - 1) { cl[i] = 1; continue; }
const a = cl[i + 1], b = cl[i + w], d = cl[i + w + 1];
cl[i] = Math.min(MAX_CLEARANCE, 1 + Math.min(a, b, d));
}
}
}
}
/** Stamp (or clear) a building footprint and patch the affected clearance. */
export function stampFootprint(nav, rules, tx, ty, fw, fh, on) {
const { w, h } = nav;
for (let y = ty; y < ty + fh; y++) {
if (y < 0 || y >= h) continue;
for (let x = tx; x < tx + fw; x++) {
if (x < 0 || x >= w) continue;
nav.blocked[y * w + x] = on ? 1 : 0;
}
}
refreshRegion(nav, rules, tx, ty, tx + fw - 1, ty + fh - 1);
}
/** Clearance a unit of this collision radius needs, in tiles. */
export function clearanceFor(radius, tileSize) {
return Math.max(1, Math.ceil((radius * 2) / tileSize));
}
export const tileIndex = (nav, tx, ty) => ty * nav.w + tx;
export const tileX = (nav, i) => i % nav.w;
export const tileY = (nav, i) => (i / nav.w) | 0;
export const worldToTileX = (nav, x) => Math.max(0, Math.min(nav.w - 1, (x / nav.tileSize) | 0));
export const worldToTileY = (nav, y) => Math.max(0, Math.min(nav.h - 1, (y / nav.tileSize) | 0));
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 fitField(nav, mc, need)[ty * nav.w + tx] >= need;
}
/**
* Nearest tile to (tx,ty) that a unit of this move class/clearance can stand on.
* Spiral BFS, bounded — used when an order lands on a wall or inside a building.
*/
export function nearestUsableTile(nav, mc, need, tx, ty, maxRings = 24) {
if (tileOk(nav, mc, need, tx, ty)) return tileIndex(nav, tx, ty);
for (let r = 1; r <= maxRings; r++) {
for (let dx = -r; dx <= r; dx++) {
for (const dy of (dx === -r || dx === r) ? range(-r, r) : [-r, r]) {
const x = tx + dx, y = ty + dy;
if (tileOk(nav, mc, need, x, y)) return tileIndex(nav, x, y);
}
}
}
return -1;
}
function range(a, b) {
const out = [];
for (let v = a; v <= b; v++) out.push(v);
return out;
}
// ---------------------------------------------------------------------------
// Binary min-heap (parallel arrays, grows in place, never allocates mid-search)
// ---------------------------------------------------------------------------
function createHeap(cap) {
return { f: new Float64Array(cap), k: new Int32Array(cap), size: 0, cap };
}
function heapClear(hp) { hp.size = 0; }
function heapPush(hp, f, k) {
if (hp.size === hp.cap) {
const cap = hp.cap * 2;
const nf = new Float64Array(cap); nf.set(hp.f);
const nk = new Int32Array(cap); nk.set(hp.k);
hp.f = nf; hp.k = nk; hp.cap = cap;
}
let i = hp.size++;
hp.f[i] = f; hp.k[i] = k;
while (i > 0) {
const p = (i - 1) >> 1;
if (hp.f[p] <= hp.f[i]) break;
swapHeap(hp, i, p);
i = p;
}
}
function heapPop(hp) {
const top = hp.k[0];
hp.size--;
if (hp.size > 0) {
hp.f[0] = hp.f[hp.size]; hp.k[0] = hp.k[hp.size];
let i = 0;
for (;;) {
const l = i * 2 + 1, r = l + 1;
let m = i;
if (l < hp.size && hp.f[l] < hp.f[m]) m = l;
if (r < hp.size && hp.f[r] < hp.f[m]) m = r;
if (m === i) break;
swapHeap(hp, i, m);
i = m;
}
}
return top;
}
function swapHeap(hp, a, b) {
const f = hp.f[a]; hp.f[a] = hp.f[b]; hp.f[b] = f;
const k = hp.k[a]; hp.k[a] = hp.k[b]; hp.k[b] = k;
}
// ---------------------------------------------------------------------------
// A*
// ---------------------------------------------------------------------------
const NEIGHBOR_DX = [1, -1, 0, 0, 1, 1, -1, -1];
const NEIGHBOR_DY = [0, 0, 1, -1, 1, -1, 1, -1];
/**
* A* over the tile grid.
* @returns {Int32Array|null} tile indices from start to goal inclusive, or null if unreachable
*/
export function findPath(nav, mc, need, startIdx, goalIdx, expansionCap = 20000) {
if (startIdx === goalIdx) return Int32Array.of(startIdx);
const cl = fitField(nav, mc, need);
const cost = nav.cost[mc];
if (cl[goalIdx] < need || cl[startIdx] < need) return null;
const { w, h } = nav;
const g = nav._g, came = nav._came, stamp = nav._stamp, hp = nav._heap;
const epoch = ++nav._epoch;
heapClear(hp);
const gx = goalIdx % w, gy = (goalIdx / w) | 0;
const heuristic = (i) => {
const dx = Math.abs((i % w) - gx);
const dy = Math.abs(((i / w) | 0) - gy);
// Octile: exact for 8-way movement on a uniform grid, so it never over-estimates
// once terrain cost is >= 1 — which the rules validator guarantees.
return (dx > dy ? dx - dy + SQRT2 * dy : dy - dx + SQRT2 * dx);
};
g[startIdx] = 0;
came[startIdx] = -1;
stamp[startIdx] = epoch;
heapPush(hp, heuristic(startIdx), startIdx);
let expansions = 0;
const closed = new Set();
while (hp.size > 0) {
const cur = heapPop(hp);
if (cur === goalIdx) return reconstruct(came, startIdx, goalIdx);
if (closed.has(cur)) continue;
closed.add(cur);
if (++expansions > expansionCap) return null;
const cx = cur % w, cy = (cur / w) | 0;
const gcur = g[cur];
for (let d = 0; d < 8; d++) {
const nx = cx + NEIGHBOR_DX[d], ny = cy + NEIGHBOR_DY[d];
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
const ni = ny * w + nx;
if (cl[ni] < need) continue;
const diag = d >= 4;
if (diag) {
// No corner cutting: both orthogonal neighbours must also be usable, or a tank
// squeezes diagonally between two buildings that visually touch.
if (cl[cy * w + nx] < need || cl[ny * w + cx] < need) continue;
}
const step = cost[ni] * (diag ? SQRT2 : 1);
const tentative = gcur + step;
if (stamp[ni] === epoch && tentative >= g[ni]) continue;
stamp[ni] = epoch;
g[ni] = tentative;
came[ni] = cur;
heapPush(hp, tentative + heuristic(ni), ni);
}
}
return null;
}
function reconstruct(came, startIdx, goalIdx) {
let len = 1;
for (let i = goalIdx; i !== startIdx; i = came[i]) len++;
const out = new Int32Array(len);
let p = len - 1;
for (let i = goalIdx; i !== -1; i = came[i]) {
out[p--] = i;
if (i === startIdx) break;
}
return out;
}
// ---------------------------------------------------------------------------
// Line of sight and string pulling
// ---------------------------------------------------------------------------
/**
* Can a unit of this clearance travel the straight segment a->b without clipping a
* blocked tile? Supercover walk (every tile the segment touches, not just Bresenham's).
*/
export function segmentClear(nav, mc, need, x0, y0, x1, y1) {
const ts = nav.tileSize;
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;
const stepX = dx > 0 ? 1 : -1, stepY = dy > 0 ? 1 : -1;
const tDeltaX = dx === 0 ? Infinity : Math.abs(ts / dx);
const tDeltaY = dy === 0 ? Infinity : Math.abs(ts / dy);
let tMaxX = dx === 0 ? Infinity
: Math.abs(((dx > 0 ? (tx + 1) * ts : tx * ts) - x0) / dx);
let tMaxY = dy === 0 ? Infinity
: Math.abs(((dy > 0 ? (ty + 1) * ts : ty * ts) - y0) / dy);
let guard = 0;
const limit = nav.w + nav.h + 4;
for (;;) {
if (tx < 0 || ty < 0 || tx >= nav.w || ty >= nav.h) return false;
if (cl[ty * nav.w + tx] < need) return false;
if (tx === tx1 && ty === ty1) return true;
if (++guard > limit) return false;
if (tMaxX < tMaxY) { tx += stepX; tMaxX += tDeltaX; }
else { ty += stepY; tMaxY += tDeltaY; }
}
}
/**
* String-pull a tile path into a short list of world waypoints. Greedily skips any
* waypoint the unit can reach in a straight line, so units drive smooth diagonals
* instead of staircasing along tile centres.
* @returns {number[]} flat [x0,y0, x1,y1, ...] world coordinates
*/
export function smoothPath(nav, mc, need, path, endX, endY) {
const out = [];
if (!path || path.length === 0) return out;
const px = (i) => tileCenterX(nav, path[i] % nav.w);
const py = (i) => tileCenterY(nav, (path[i] / nav.w) | 0);
let anchorX = px(0), anchorY = py(0);
let i = 0;
while (i < path.length - 1) {
// Advance as far as we can still see from the anchor.
let j = i + 1;
while (j + 1 < path.length && segmentClear(nav, mc, need, anchorX, anchorY, px(j + 1), py(j + 1))) j++;
out.push(px(j), py(j));
anchorX = px(j); anchorY = py(j);
i = j;
}
// Replace the final tile centre with the true destination when it's directly reachable.
if (endX != null && out.length >= 2) {
const lx = out[out.length - 2], ly = out[out.length - 1];
if (segmentClear(nav, mc, need, lx, ly, endX, endY)) {
out[out.length - 2] = endX;
out[out.length - 1] = endY;
} else {
out.push(endX, endY);
}
} else if (endX != null) {
out.push(endX, endY);
}
return out;
}
// ---------------------------------------------------------------------------
// Formation slots
// ---------------------------------------------------------------------------
/**
* Fan a group out around a destination on a hex-ish lattice, then assign units to slots
* greedily by distance. One shared path plus these offsets is what makes a 40-unit move
* order cost one A* instead of forty.
* @returns {number[]} flat [x,y] per unit, in the same order as `units`
*/
export function formationSlots(cx, cy, units, spacingScale = 1.15) {
const nMax = units.length;
let maxR = 0;
for (const u of units) maxR = Math.max(maxR, u.radius);
const spacing = maxR * 2 * spacingScale;
const slots = [[cx, cy]];
for (let ring = 1; slots.length < nMax; ring++) {
const count = 6 * ring;
for (let s = 0; s < count && slots.length < nMax; s++) {
const a = (s / count) * Math.PI * 2;
slots.push([cx + Math.cos(a) * spacing * ring, cy + Math.sin(a) * spacing * ring]);
}
}
// Greedy nearest assignment: sort both sides by distance from the destination and pair
// up, so the units already closest take the inner slots and nobody crosses the group.
const order = units.map((u, i) => ({
i, d: (u.x - cx) * (u.x - cx) + (u.y - cy) * (u.y - cy),
})).sort((a, b) => a.d - b.d || a.i - b.i);
const out = new Array(nMax * 2);
order.forEach((entry, rank) => {
out[entry.i * 2] = slots[rank][0];
out[entry.i * 2 + 1] = slots[rank][1];
});
return out;
}
// ---------------------------------------------------------------------------
// Spatial hash — rebuilt every tick, drives separation and target acquisition
// ---------------------------------------------------------------------------
export class SpatialHash {
constructor(cellSize, worldW, worldH) {
this.cell = cellSize;
this.cols = Math.max(1, Math.ceil(worldW / cellSize));
this.rows = Math.max(1, Math.ceil(worldH / cellSize));
this.buckets = Array.from({ length: this.cols * this.rows }, () => []);
}
clear() {
for (const b of this.buckets) b.length = 0;
}
_bucket(x, y) {
const cx = Math.max(0, Math.min(this.cols - 1, (x / this.cell) | 0));
const cy = Math.max(0, Math.min(this.rows - 1, (y / this.cell) | 0));
return cy * this.cols + cx;
}
insert(item, x, y) {
this.buckets[this._bucket(x, y)].push(item);
}
/** Append every item within `r` of (x,y) into `out`. Order is deterministic. */
query(x, y, r, out) {
const c0 = Math.max(0, ((x - r) / this.cell) | 0);
const c1 = Math.min(this.cols - 1, ((x + r) / this.cell) | 0);
const r0 = Math.max(0, ((y - r) / this.cell) | 0);
const r1 = Math.min(this.rows - 1, ((y + r) / this.cell) | 0);
for (let cy = r0; cy <= r1; cy++) {
const base = cy * this.cols;
for (let cx = c0; cx <= c1; cx++) {
const b = this.buckets[base + cx];
for (let i = 0; i < b.length; i++) out.push(b[i]);
}
}
return out;
}
}

View File

@ -0,0 +1,321 @@
// Total Annihilation — rules compiler and validator.
//
// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js.
//
// compileRules() indexes data/totalannihilation-rules.json by id and validates every
// cross-reference against a closed key set, throwing on the first problem. The point is
// that a typo in the JSON fails loudly in the verify script rather than silently at
// runtime three minutes into a match.
export const WEAPON_KINDS = new Set(['hitscan', 'ballistic', 'guided', 'beam']);
export const FX_STYLES = new Set(['tracer', 'beam', 'shell', 'rocket', 'dgun']);
export const UNIT_ROLES = new Set(['builder', 'combat', 'scout', 'artillery']);
export const SHEET_SLOTS = new Set(['unitSheet', 'structureSheet']);
export const TARGET_DOMAINS = new Set(['ground', 'air']);
// Procedural painter shapes. A def may only name a shape TAArt knows how to draw;
// this set is duplicated there and the verify script asserts the two agree.
export const PROC_SHAPES = new Set([
'commander', 'infantry', 'sniper', 'jeep', 'tank', 'rockettank',
'energyGen', 'massGen', 'barracks', 'vehiclePlant',
]);
function fail(msg) {
throw new Error(`[totalannihilation-rules] ${msg}`);
}
function requireUnique(list, what) {
const seen = new Set();
for (const e of list) {
if (!e || typeof e.id !== 'string' || !e.id) fail(`${what} entry is missing an id`);
if (seen.has(e.id)) fail(`duplicate ${what} id "${e.id}"`);
seen.add(e.id);
}
}
function requirePositive(obj, keys, what) {
for (const k of keys) {
const v = obj[k];
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) {
fail(`${what} "${obj.id}" needs a positive ${k} (got ${JSON.stringify(v)})`);
}
}
}
/**
* 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;
return m;
}
/**
* Compile and validate the raw rules JSON.
* @param {object} json parsed data/totalannihilation-rules.json
* @returns {object} compiled rules with by-id indexes and derived tables
*/
export function compileRules(json) {
if (!json || typeof json !== 'object') fail('rules JSON is empty');
const c = json.constants;
if (!c) fail('missing constants');
requirePositive(c, ['tickHz', 'tileSize', 'unitCapPerArmy', 'buildPowerNominal'], 'constants');
if (c.tileSize % 2 !== 0) fail('constants.tileSize must be even');
// ---- closed sets -------------------------------------------------------
const armorClasses = json.armorClasses ?? [];
if (!armorClasses.length) fail('armorClasses must be a non-empty array');
const armorSet = new Set(armorClasses);
const sizeClasses = json.sizeClasses ?? {};
for (const [id, s] of Object.entries(sizeClasses)) {
if (!(s.radius > 0)) fail(`sizeClass "${id}" needs a positive radius`);
if (!(s.footprint >= 1)) fail(`sizeClass "${id}" needs footprint >= 1`);
if (!(s.mass > 0)) fail(`sizeClass "${id}" needs a positive mass`);
}
const sizeSet = new Set(Object.keys(sizeClasses));
// ---- terrain -----------------------------------------------------------
const terrain = json.terrain ?? [];
requireUnique(terrain, 'terrain');
const terrainById = indexById(terrain);
const terrainByCh = Object.create(null);
terrain.forEach((t, i) => {
if (typeof t.ch !== 'string' || t.ch.length !== 1) fail(`terrain "${t.id}" needs a single-char ch`);
if (terrainByCh[t.ch]) fail(`terrain char "${t.ch}" used by both "${terrainByCh[t.ch].id}" and "${t.id}"`);
if (!Number.isInteger(t.frame) || t.frame < 0) fail(`terrain "${t.id}" needs a non-negative integer frame`);
t.index = i;
terrainByCh[t.ch] = t;
});
if (!terrain.length) fail('at least one terrain type is required');
// ---- move classes ------------------------------------------------------
const moveClasses = json.moveClasses ?? {};
for (const [mc, spec] of Object.entries(moveClasses)) {
if (!spec.cost) fail(`moveClass "${mc}" is missing its cost table`);
for (const t of terrain) {
if (!(t.id in spec.cost)) fail(`moveClass "${mc}" has no cost for terrain "${t.id}"`);
const v = spec.cost[t.id];
if (v !== null && !(typeof v === 'number' && v > 0)) {
fail(`moveClass "${mc}" cost for "${t.id}" must be a positive number or null (impassable)`);
}
}
for (const key of Object.keys(spec.cost)) {
if (!terrainById[key]) fail(`moveClass "${mc}" references unknown terrain "${key}"`);
}
// Flat per-terrain-index cost array — the pathfinder's hot path reads this.
spec.costByTerrainIndex = terrain.map((t) => (t.blocksMove ? null : spec.cost[t.id]));
}
const moveClassSet = new Set(Object.keys(moveClasses));
// ---- armies & commanders ----------------------------------------------
const armies = json.armies ?? [];
requireUnique(armies, 'army');
if (armies.length < 2) fail('at least two armies are required');
for (const a of armies) {
if (!a.unitSheet || !a.structureSheet) fail(`army "${a.id}" needs unitSheet and structureSheet`);
if (!/^#[0-9a-f]{6}$/i.test(a.color ?? '')) fail(`army "${a.id}" needs a #rrggbb color`);
a.colorInt = parseInt(a.color.slice(1), 16);
}
const armyById = indexById(armies);
const armyIndexById = Object.create(null);
armies.forEach((a, i) => { armyIndexById[a.id] = i; });
const commanders = json.commanders ?? [];
requireUnique(commanders, 'commander');
for (const cm of commanders) {
if (!armyById[cm.armyId]) fail(`commander "${cm.id}" references unknown army "${cm.armyId}"`);
if (!cm.opponentId) fail(`commander "${cm.id}" needs an opponentId for its portrait`);
}
const commanderById = indexById(commanders);
// ---- weapons -----------------------------------------------------------
const weapons = json.weapons ?? [];
requireUnique(weapons, 'weapon');
for (const w of weapons) {
if (!WEAPON_KINDS.has(w.kind)) {
fail(`weapon "${w.id}" has unknown kind "${w.kind}" (expected ${[...WEAPON_KINDS].join('|')})`);
}
requirePositive(w, ['damage', 'reload', 'range'], 'weapon');
if (w.kind === 'ballistic' || w.kind === 'guided') {
requirePositive(w, ['speed'], 'weapon');
}
if (w.kind === 'guided' && !(w.turnRate > 0)) fail(`guided weapon "${w.id}" needs a positive turnRate`);
if (w.minRange != null && !(w.minRange >= 0 && w.minRange < w.range)) {
fail(`weapon "${w.id}" minRange must be >= 0 and < range`);
}
for (const key of Object.keys(w.armorMul ?? {})) {
if (!armorSet.has(key)) fail(`weapon "${w.id}" armorMul references unknown armor class "${key}"`);
}
for (const a of armorClasses) {
if (!(a in (w.armorMul ?? {}))) fail(`weapon "${w.id}" armorMul is missing armor class "${a}"`);
}
for (const d of w.targets ?? []) {
if (!TARGET_DOMAINS.has(d)) fail(`weapon "${w.id}" targets unknown domain "${d}"`);
}
if (!w.fx || !FX_STYLES.has(w.fx.style)) {
fail(`weapon "${w.id}" needs fx.style from ${[...FX_STYLES].join('|')}`);
}
if (w.aoe != null && !(w.aoe > 0)) fail(`weapon "${w.id}" aoe must be positive when present`);
// Reload/burst in ticks, precomputed so the sim never divides in its hot loop.
w.reloadTicks = Math.max(1, Math.round(w.reload * c.tickHz));
w.burstDelayTicks = Math.max(1, Math.round((w.burstDelay ?? 0) * c.tickHz));
w.burst = w.burst ?? 1;
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);
// ---- shared unit/building validation ----------------------------------
const buildable = Object.create(null); // id -> def, for `builds` cross-checks
const units = json.units ?? [];
requireUnique(units, 'unit');
for (const u of units) {
if (!sizeSet.has(u.size)) fail(`unit "${u.id}" has unknown size "${u.size}"`);
if (!armorSet.has(u.armorClass)) fail(`unit "${u.id}" has unknown armorClass "${u.armorClass}"`);
if (!moveClassSet.has(u.moveClass)) fail(`unit "${u.id}" has unknown moveClass "${u.moveClass}"`);
if (u.role && !UNIT_ROLES.has(u.role)) fail(`unit "${u.id}" has unknown role "${u.role}"`);
if (!SHEET_SLOTS.has(u.sheetSlot)) fail(`unit "${u.id}" has unknown sheetSlot "${u.sheetSlot}"`);
if (!PROC_SHAPES.has(u.procShape)) fail(`unit "${u.id}" has unknown procShape "${u.procShape}"`);
requirePositive(u, ['hp', 'speed', 'turnRate', 'sight'], 'unit');
if (!Number.isInteger(u.frame) || u.frame < 0) fail(`unit "${u.id}" needs a non-negative integer frame`);
if (u.turretFrame != null && (!Number.isInteger(u.turretFrame) || u.turretFrame < 0)) {
fail(`unit "${u.id}" turretFrame must be a non-negative integer`);
}
for (const wid of u.weapons ?? []) {
if (!weaponById[wid]) fail(`unit "${u.id}" references unknown weapon "${wid}"`);
}
const sc = sizeClasses[u.size];
u.radius = u.radius ?? sc.radius;
u.massClass = sc.mass;
u.footprintTiles = sc.footprint;
u.sightSq = u.sight * u.sight;
u.isBuilding = false;
u.weaponDefs = (u.weapons ?? []).map((wid) => weaponById[wid]);
u.maxRange = autoRangeOf(u.weaponDefs);
u.spritePx = u.spritePx ?? sc.radius * 2;
buildable[u.id] = u;
}
const unitById = indexById(units);
const buildings = json.buildings ?? [];
requireUnique(buildings, 'building');
for (const b of buildings) {
if (!armorSet.has(b.armorClass)) fail(`building "${b.id}" has unknown armorClass "${b.armorClass}"`);
if (!SHEET_SLOTS.has(b.sheetSlot)) fail(`building "${b.id}" has unknown sheetSlot "${b.sheetSlot}"`);
if (!PROC_SHAPES.has(b.procShape)) fail(`building "${b.id}" has unknown procShape "${b.procShape}"`);
requirePositive(b, ['hp', 'buildTime'], 'building');
if (!b.footprint || !(b.footprint.w >= 1) || !(b.footprint.h >= 1)) {
fail(`building "${b.id}" needs a footprint of at least 1x1 tiles`);
}
if (!Number.isInteger(b.frame) || b.frame < 0) fail(`building "${b.id}" needs a non-negative integer frame`);
if (b.terrainMultiplier && !terrain.some((t) => b.terrainMultiplier in t)) {
fail(`building "${b.id}" terrainMultiplier "${b.terrainMultiplier}" is on no terrain type`);
}
for (const wid of b.weapons ?? []) {
if (!weaponById[wid]) fail(`building "${b.id}" references unknown weapon "${wid}"`);
}
b.isBuilding = true;
b.radius = (Math.max(b.footprint.w, b.footprint.h) * c.tileSize) / 2;
b.sight = b.sight ?? 0;
b.sightSq = b.sight * b.sight;
b.weaponDefs = (b.weapons ?? []).map((wid) => weaponById[wid]);
b.maxRange = autoRangeOf(b.weaponDefs);
buildable[b.id] = b;
}
const buildingById = indexById(buildings);
// `builds` lists must resolve, and a factory may only build units (not buildings).
for (const def of [...units, ...buildings]) {
for (const id of def.builds ?? []) {
if (!buildable[id]) fail(`"${def.id}" builds unknown def "${id}"`);
if (def.isBuilding && !unitById[id]) fail(`factory "${def.id}" may only build units, not "${id}"`);
if (!def.isBuilding && !buildingById[id]) fail(`mobile builder "${def.id}" may only build buildings, not "${id}"`);
}
if ((def.builds ?? []).length && !(def.buildPower > 0)) {
fail(`"${def.id}" has a builds list but no positive buildPower`);
}
}
// Every producible unit must be reachable from some factory, or it's dead data.
for (const u of units) {
if (u.buildTime === 0) continue; // starting units (the Commander) are placed, not built
const madeBy = buildings.filter((b) => (b.builds ?? []).includes(u.id));
if (!madeBy.length) fail(`unit "${u.id}" has a buildTime but no building builds it`);
for (const bid of u.builtBy ?? []) {
if (!buildingById[bid]) fail(`unit "${u.id}" builtBy references unknown building "${bid}"`);
if (!(buildingById[bid].builds ?? []).includes(u.id)) {
fail(`unit "${u.id}" claims builtBy "${bid}" but that building does not list it`);
}
}
}
// ---- AI skills ---------------------------------------------------------
const aiSkills = json.aiSkills ?? [];
const aiBySkill = Object.create(null);
for (const s of aiSkills) {
if (!Number.isInteger(s.skill) || s.skill < 1) fail('aiSkills entries need an integer skill >= 1');
aiBySkill[s.skill] = s;
}
if (!aiSkills.length) fail('at least one aiSkills entry is required');
// ---- skirmish ----------------------------------------------------------
const sk = json.skirmish;
if (!sk) fail('missing skirmish config');
for (const [name, tiles] of Object.entries(sk.sizes ?? {})) {
if (!Number.isInteger(tiles) || tiles < 32) fail(`skirmish size "${name}" must be an integer >= 32 tiles`);
}
for (const id of sk.startUnits ?? []) {
if (!unitById[id]) fail(`skirmish.startUnits references unknown unit "${id}"`);
}
if (!sk.sizes?.[sk.defaults?.size]) fail('skirmish.defaults.size is not one of skirmish.sizes');
if (!(sk.symmetries ?? []).includes(sk.defaults?.symmetry)) fail('skirmish.defaults.symmetry is not one of skirmish.symmetries');
if (!(sk.themes ?? []).includes(sk.defaults?.theme)) fail('skirmish.defaults.theme is not one of skirmish.themes');
if (!armyById[sk.defaults?.playerArmy]) fail('skirmish.defaults.playerArmy is not a known army');
return {
version: json.version ?? 1,
constants: c,
armorClasses, armorSet,
sizeClasses,
terrain, terrainById, terrainByCh,
moveClasses,
armies, armyById, armyIndexById,
commanders, commanderById,
weapons, weaponById,
units, unitById,
buildings, buildingById,
defById: buildable,
commandIcons: json.commandIcons ?? {},
aiSkills, aiBySkill,
skirmish: sk,
// Derived conveniences the sim and view both want.
stepMs: 1000 / c.tickHz,
dt: 1 / c.tickHz,
halfTile: c.tileSize / 2,
};
}
/** Damage multiplier of `weapon` against a defender of `armorClass`. */
export function armorMul(weapon, armorClass) {
return weapon.armorMul?.[armorClass] ?? 1;
}
/** Look up a def (unit or building) by id. */
export function defOf(rules, id) {
return rules.defById[id] ?? null;
}

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

@ -0,0 +1,590 @@
// Total Annihilation — world renderer.
//
// Camera model: a REAL Phaser world camera plus a second screen-space UI camera, the
// MiniMotorwaysGame.js:213-227 pattern. A real camera buys frustum culling, setBounds,
// zoomTo and — decisively for an RTS — pointer.worldX/worldY for click-to-order.
//
// Terrain bakes into chunked RenderTextures (CivilizationMapView's forEachChunk model). A
// large map is 128 tiles = 8192px, well past a safe single GPU texture, so it tiles into
// 1024px chunks created LAZILY as the camera reaches them — 36 chunks eagerly stamped would
// cost several seconds and ~150MB at mission start.
//
// Units are plain Images, never Containers: at 300+ units the Container overhead is real.
// Selection rings, health bars and build ghosts all share ONE Graphics each.
import { ensureSheets, sheetFrameSize } from './TAArt.js';
import { colorInt } from './TAFx.js';
const CHUNK_PX = 1024;
const ZOOMS = [0.5, 0.7, 1.0, 1.4];
const CHUNK_IDLE_MS = 30000; // reclaim chunks the camera hasn't looked at in a while
const MAX_QUEUE_LINES = 24; // selected units whose order queue is drawn
/** Waypoint colour per order type, so a queue reads at a glance. */
const ORDER_COLORS = {
move: 0x7dff9b, attackMove: 0xff8a5a, attack: 0xff5a5a, patrol: 0x8ad4ff,
guard: 0xffd27a, assist: 0x8affd0, build: 0x8ad4ff,
};
export const DEPTHS = {
terrain: 0, decal: 5, fxUnder: 8, ghost: 12,
selection: 15, actor: 20, bars: 55, projectile: 60, fxOver: 65, fog: 80,
};
export default class TAWorldView {
constructor(scene, rules, art, state, playerArmy) {
this.scene = scene;
this.rules = rules;
this.art = art;
this.state = state;
this.playerArmy = playerArmy;
this.ts = state.tileSize;
this.worldRoot = scene.add.container(0, 0);
this.uiRoot = scene.add.container(0, 0);
const { keys, procedural } = ensureSheets(scene, rules, art);
this.sheetKeys = keys;
this.proceduralSheets = procedural;
// Per-army resolved sheet keys, so a unit def's `sheetSlot` renders correctly for any army.
this.armySheets = rules.armies.map((a) => ({
unitSheet: keys[a.unitSheet],
structureSheet: keys[a.structureSheet],
unitFrame: sheetFrameSize(art, a.unitSheet),
structureFrame: sheetFrameSize(art, a.structureSheet),
color: a.colorInt,
}));
const themeName = state.theme;
const theme = art.themes?.[themeName];
this.terrainKey = keys[theme?.sheet] ?? keys[Object.keys(art.sheets)[0]];
this.themePalette = theme?.palette ?? {};
this._setupCameras();
this._setupTerrain();
this._setupFog();
this.gSelection = scene.add.graphics().setDepth(DEPTHS.selection);
this.gOrders = scene.add.graphics().setDepth(DEPTHS.selection);
this.gBars = scene.add.graphics().setDepth(DEPTHS.bars);
this.gGhost = scene.add.graphics().setDepth(DEPTHS.ghost);
this._addWorld(this.gSelection);
this._addWorld(this.gOrders);
this._addWorld(this.gBars);
this._addWorld(this.gGhost);
this.sprites = new Map(); // entity id -> { img, turret }
this.selection = new Set();
this.placement = null; // { def, tx, ty, legal }
this.showAllBars = false;
this._stamp = scene.make.image({ x: 0, y: 0, key: this.terrainKey, add: false }).setOrigin(0, 0);
}
// -------------------------------------------------------------------------
// Cameras
// -------------------------------------------------------------------------
_setupCameras() {
const { scene, state } = this;
const cam = scene.cameras.main;
const margin = this.ts * 3;
cam.setBounds(-margin, -margin, state.worldW + margin * 2, state.worldH + margin * 2);
cam.setBackgroundColor(0x0b0e12);
this.zoomIdx = 2;
cam.setZoom(ZOOMS[this.zoomIdx]);
this.uiCam = scene.cameras.add(0, 0, scene.scale.width, scene.scale.height);
this.uiCam.ignore(this.worldRoot);
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;
this.scene.cameras.main.ignore(objs);
}
panBy(dx, dy) {
const cam = this.scene.cameras.main;
cam.setScroll(cam.scrollX + dx / cam.zoom, cam.scrollY + dy / cam.zoom);
}
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;
const anchor = this.worldPoint(focusX, focusY);
this.zoomIdx = next;
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) {
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,
};
}
// -------------------------------------------------------------------------
// Terrain (lazy chunked RenderTextures)
// -------------------------------------------------------------------------
_setupTerrain() {
const { state } = this;
this.chunkCols = Math.ceil(state.worldW / CHUNK_PX);
this.chunkRows = Math.ceil(state.worldH / CHUNK_PX);
this.chunks = new Map(); // "cx,cy" -> { rt, ox, oy, w, h, touched }
}
_paintChunk(cx, cy) {
const key = `${cx},${cy}`;
let chunk = this.chunks.get(key);
if (chunk) { chunk.touched = this.scene.time.now; return chunk; }
const { state, rules, ts } = this;
const ox = cx * CHUNK_PX, oy = cy * CHUNK_PX;
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._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);
const img = this._stamp;
img.setTexture(this.terrainKey);
img.setDisplaySize(ts, ts);
rt.beginDraw();
for (let ty = r0; ty < r1 && ty < state.h; ty++) {
for (let tx = t0; tx < t1 && tx < state.w; tx++) {
const terr = rules.terrain[state.terrain[ty * state.w + tx]];
// Break up flat ground with the two alternate frames, deterministically per tile.
let frame = terr.frame;
if (terr.id === 'ground') {
const hsh = (tx * 73856093) ^ (ty * 19349663);
const pick = (hsh >>> 3) % 5;
if (pick === 1) frame = this.art.terrainFrames?.groundAlt1 ?? frame;
else if (pick === 2) frame = this.art.terrainFrames?.groundAlt2 ?? frame;
}
img.setFrame(frame);
rt.batchDraw(img, tx * ts - ox, ty * ts - oy);
}
}
rt.endDraw();
chunk = { rt, ox, oy, w, h, touched: this.scene.time.now };
this.chunks.set(key, chunk);
return chunk;
}
/** Create chunks the camera can see; retire ones it hasn't looked at for a while. */
_updateChunks() {
const cam = this.scene.cameras.main;
const view = cam.worldView;
const c0 = Math.max(0, Math.floor((view.x - CHUNK_PX * 0.25) / CHUNK_PX));
const c1 = Math.min(this.chunkCols - 1, Math.floor((view.right + CHUNK_PX * 0.25) / CHUNK_PX));
const r0 = Math.max(0, Math.floor((view.y - CHUNK_PX * 0.25) / CHUNK_PX));
const r1 = Math.min(this.chunkRows - 1, Math.floor((view.bottom + CHUNK_PX * 0.25) / CHUNK_PX));
for (let cy = r0; cy <= r1; cy++) {
for (let cx = c0; cx <= c1; cx++) this._paintChunk(cx, cy);
}
const now = this.scene.time.now;
for (const [key, chunk] of this.chunks) {
if (now - chunk.touched < CHUNK_IDLE_MS) continue;
chunk.rt.destroy();
this.chunks.delete(key);
}
}
/** Repaint the chunks overlapping a world rect — used when terrain changes. */
repaintArea(x0, y0, x1, y1) {
for (const [key, chunk] of this.chunks) {
if (x1 < chunk.ox || x0 > chunk.ox + chunk.w) continue;
if (y1 < chunk.oy || y0 > chunk.oy + chunk.h) continue;
chunk.rt.destroy();
this.chunks.delete(key);
}
}
// -------------------------------------------------------------------------
// Fog of war
// -------------------------------------------------------------------------
_setupFog() {
const { state, scene } = this;
const key = 'ta-fog-canvas';
if (scene.textures.exists(key)) scene.textures.remove(key);
this.fogTex = scene.textures.createCanvas(key, state.visW, state.visH);
this.fogCtx = this.fogTex.getContext();
const cell = this.ts * 2;
this.fogImg = scene.add.image(0, 0, key).setOrigin(0, 0).setDepth(DEPTHS.fog);
this.fogImg.setDisplaySize(state.visW * cell, state.visH * cell);
// 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._addWorld(this.fogImg);
this.fogDirty = true;
}
setFogEnabled(on) {
this.fogEnabled = on;
this.fogImg.setVisible(on);
}
_redrawFog() {
const { state } = this;
const army = state.armies[this.playerArmy];
if (!army) return;
const ctx = this.fogCtx;
ctx.clearRect(0, 0, state.visW, state.visH);
const img = ctx.createImageData(state.visW, state.visH);
const d = img.data;
for (let i = 0; i < state.visW * state.visH; i++) {
const vis = army.visible[i], exp = army.explored[i];
const a = vis ? 0 : (exp ? 140 : 255);
d[i * 4] = 4; d[i * 4 + 1] = 6; d[i * 4 + 2] = 10; d[i * 4 + 3] = a;
}
ctx.putImageData(img, 0, 0);
this.fogTex.refresh();
}
/** Is this entity currently drawable for the viewing player? */
visibleToPlayer(e) {
if (!this.fogEnabled) return true;
if (e.army === this.playerArmy) return true;
const army = this.state.armies[this.playerArmy];
if (!army) return true;
const cell = this.ts * 2;
const x = Math.floor(e.x / cell), y = Math.floor(e.y / cell);
if (x < 0 || y < 0 || x >= this.state.visW || y >= this.state.visH) return false;
return army.visible[y * this.state.visW + x] === 1;
}
// -------------------------------------------------------------------------
// Sprites
// -------------------------------------------------------------------------
_ensureSprite(e) {
let s = this.sprites.get(e.id);
if (s) return s;
const rules = this.rules;
const def = rules.defById[e.defId];
const sheets = this.armySheets[e.army];
const key = sheets[def.sheetSlot];
const frameSize = def.sheetSlot === 'unitSheet' ? sheets.unitFrame : sheets.structureFrame;
const img = this.scene.add.image(e.x, e.y, key, def.frame);
img.setTint(sheets.color);
if (def.isBuilding) {
img.setDisplaySize(def.footprint.w * this.ts, def.footprint.h * this.ts);
} else {
img.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
}
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._addWorld(turret);
}
s = { img, turret, defId: e.defId, site: e.site };
this.sprites.set(e.id, s);
return s;
}
_releaseSprite(id) {
const s = this.sprites.get(id);
if (!s) return;
s.img.destroy();
s.turret?.destroy();
this.sprites.delete(id);
}
// -------------------------------------------------------------------------
// Frame
// -------------------------------------------------------------------------
/**
* @param {number} alpha interpolation factor between the last two sim ticks
* @returns {{nanoLinks:Array, projectiles:Array}} data the FX layer needs, in view space
*/
render(alpha) {
const { state, rules } = this;
this._updateChunks();
if (this.fogEnabled && this.fogDirty) { this._redrawFog(); this.fogDirty = false; }
const live = new Set();
const gSel = this.gSelection, gBar = this.gBars;
gSel.clear(); gBar.clear();
for (const e of state.entities) {
if (e.dead) continue;
live.add(e.id);
const def = rules.defById[e.defId];
const shown = this.visibleToPlayer(e);
const s = this._ensureSprite(e);
s.img.setVisible(shown);
if (s.turret) s.turret.setVisible(shown);
if (!shown) continue;
const x = e.px + (e.x - e.px) * alpha;
const y = e.py + (e.y - e.py) * alpha;
const heading = lerpAngle(e.pheading, e.heading, alpha);
s.img.setPosition(x, y);
if (!def.isBuilding) s.img.setRotation(heading);
// Y-sorted actor band; buildings sit just under units sharing a row.
s.img.setDepth(DEPTHS.actor + (y / state.worldH) * 10 + (def.isBuilding ? 0 : 0.05));
// Build sites show the wireframe frame and fade in as they complete.
if (e.site !== s.site) {
s.img.setFrame(e.site ? (def.buildFrame ?? def.frame) : def.frame);
s.site = e.site;
}
s.img.setAlpha(e.site ? 0.35 + e.progress * 0.55 : 1);
if (s.turret) {
const tr = lerpAngle(e.pturretRot, e.turretRot, alpha);
s.turret.setPosition(x, y).setRotation(tr);
s.turret.setDepth(DEPTHS.actor + (y / state.worldH) * 10 + 0.08);
}
// Selection ring
if (this.selection.has(e.id)) {
const r = def.isBuilding ? Math.max(def.footprint.w, def.footprint.h) * this.ts * 0.55 : e.radius + 4;
gSel.lineStyle(2, 0x7dff9b, 0.95);
gSel.strokeEllipse(x, y + r * 0.25, r * 2, r * 1.1);
}
// Health / build bars — only when they say something, so we're not stroking 300 of them.
const damaged = e.hp < e.maxHp - 0.5;
if (e.site) {
drawBar(gBar, x, y - e.radius - 10, e.radius * 1.8, e.progress, 0x8ad4ff);
} else if (damaged || this.selection.has(e.id) || this.showAllBars) {
const frac = Math.max(0, e.hp / e.maxHp);
const col = frac > 0.6 ? 0x6fe27a : frac > 0.3 ? 0xe2d16f : 0xe2705f;
drawBar(gBar, x, y - e.radius - 10, e.radius * 1.8, frac, col);
}
}
for (const id of [...this.sprites.keys()]) if (!live.has(id)) this._releaseSprite(id);
this._drawOrderQueues();
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) {
if (e.dead || !e.buildTargetId) continue;
if (!this.visibleToPlayer(e)) continue;
const target = state.entities.find((t) => t.id === e.buildTargetId && !t.dead);
if (!target) continue;
nanoLinks.push({
x1: e.x, y1: e.y, x2: target.x, y2: target.y,
color: this.armySheets[e.army].color,
});
}
// Projectiles, interpolated and pre-coloured for the FX layer.
const projectiles = [];
for (const p of state.projectiles) {
const w = rules.weaponById[p.weapon];
const px = p.px + (p.x - p.px) * alpha;
const py = p.py + (p.y - p.py) * alpha;
if (this.fogEnabled && p.army !== this.playerArmy) {
const cell = this.ts * 2;
const gx = Math.floor(px / cell), gy = Math.floor(py / cell);
const army = state.armies[this.playerArmy];
if (gx < 0 || gy < 0 || gx >= state.visW || gy >= state.visH) continue;
if (!army.visible[gy * state.visW + gx]) continue;
}
projectiles.push({
x: px, y: py, vx: p.vx, vy: p.vy,
color: colorInt(w.fx.color), width: w.fx.width ?? 2,
style: w.fx.style, trail: w.fx.trail ? p.trail : null,
});
}
return { nanoLinks, projectiles };
}
/**
* Draw the pending order queue for every selected unit: a waypoint chain from the unit
* through each queued order, coloured by order type.
*
* Queued commands are close to unusable without this — once orders stop replacing each
* other, the player has no way to know what a unit has already been told to do, and a
* queued build is otherwise indistinguishable from one that failed to register.
*/
_drawOrderQueues() {
const g = this.gOrders;
g.clear();
if (!this.selection.size) return;
let drawn = 0;
for (const e of this.state.entities) {
if (e.dead || e.isBuilding || !this.selection.has(e.id)) continue;
if (!e.orders.length) continue;
// With a big selection every unit has near-identical orders; drawing all of them is
// just cost and clutter.
if (++drawn > MAX_QUEUE_LINES) break;
let px = e.x, py = e.y;
for (let i = 0; i < e.orders.length; i++) {
const o = e.orders[i];
const pt = this._orderPoint(o);
if (!pt) continue;
const col = ORDER_COLORS[o.type] ?? 0x9fe8ff;
const active = i === 0;
g.lineStyle(active ? 2.5 : 1.5, col, active ? 0.9 : 0.5);
g.lineBetween(px, py, pt.x, pt.y);
if (o.type === 'build') {
g.lineStyle(2, col, 0.9);
g.strokeRect(pt.x - 14, pt.y - 14, 28, 28);
} else {
g.fillStyle(col, active ? 0.9 : 0.6);
g.fillCircle(pt.x, pt.y, active ? 5 : 4);
}
px = pt.x; py = pt.y;
}
}
}
/** World position an order points at, or null if it has no meaningful location. */
_orderPoint(o) {
if (o.type === 'move' || o.type === 'attackMove' || o.type === 'patrol') {
const x = o.sx ?? o.x, y = o.sy ?? o.y;
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
}
if (o.targetId) {
const t = this.state.entities.find((e) => e.id === o.targetId && !e.dead);
return t ? { x: t.x, y: t.y } : null;
}
return null;
}
_drawPlacementGhost() {
const g = this.gGhost;
g.clear();
const p = this.placement;
if (!p) return;
const ts = this.ts;
const x = p.tx * ts, y = p.ty * ts;
const w = p.def.footprint.w * ts, h = p.def.footprint.h * ts;
g.fillStyle(p.legal ? 0x4affa0 : 0xff5a5a, 0.22);
g.fillRect(x, y, w, h);
g.lineStyle(2, p.legal ? 0x4affa0 : 0xff5a5a, 0.95);
g.strokeRect(x, y, w, h);
// Show the builder's reach so it's obvious why a distant placement won't start.
if (p.builderX != null) {
g.lineStyle(1, 0x8ad4ff, 0.35);
g.strokeCircle(p.builderX, p.builderY, p.buildRange);
}
}
/** Highlight mass spots while a Mass Generator is being placed. */
highlightMassSpots(on) {
if (this.massSpotG) { this.massSpotG.destroy(); this.massSpotG = null; }
if (!on) return;
const { state, rules, ts } = this;
const g = this.scene.add.graphics().setDepth(DEPTHS.decal);
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++) {
const t = rules.terrain[state.terrain[ty * state.w + tx]];
if (!t.massMultiplier) continue;
g.strokeRect(tx * ts + 2, ty * ts + 2, ts - 4, ts - 4);
}
}
this.massSpotG = g;
}
destroy() {
for (const id of [...this.sprites.keys()]) this._releaseSprite(id);
for (const [, chunk] of this.chunks) chunk.rt.destroy();
this.chunks.clear();
this.massSpotG?.destroy();
this.gOrders?.destroy();
this.fogImg?.destroy();
if (this.scene.textures.exists('ta-fog-canvas')) this.scene.textures.remove('ta-fog-canvas');
this._stamp?.destroy();
this.worldRoot.destroy(true);
this.uiRoot.destroy(true);
if (this.uiCam) this.scene.cameras.remove(this.uiCam);
}
}
function drawBar(g, cx, y, w, frac, color) {
const h = 4;
const x = cx - w / 2;
g.fillStyle(0x101418, 0.85);
g.fillRect(x - 1, y - 1, w + 2, h + 2);
g.fillStyle(color, 1);
g.fillRect(x, y, w * Math.max(0, Math.min(1, frac)), h);
}
function lerpAngle(a, b, t) {
let d = (b - a) % (Math.PI * 2);
if (d > Math.PI) d -= Math.PI * 2;
if (d < -Math.PI) d += Math.PI * 2;
return a + d * t;
}

View File

@ -0,0 +1,673 @@
// 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);
// CTRL is the queue modifier, as in the original: hold it and every order appends to the
// unit's queue instead of replacing it. SHIFT stays on selection (add to selection) and
// on the factory buttons (x5), so the two never fight over the same click.
if (p.rightButtonDown()) { this._issueContextual(w, queueHeld(p)); return; }
if (this.placement) { this._commitPlacement(w, queueHeld(p)); return; }
if (this.pendingCommand) { this._applyPendingCommand(w, queueHeld(p)); 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),
};
}
/** Is the queue modifier down for this pointer event? */
function queueHeld(p) {
return !!(p.event?.ctrlKey || p.event?.metaKey);
}
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 10–63 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 8–35 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,949 @@
#!/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}`);
// The queue overlay is the only feedback a player gets that a CTRL-queued order
// registered at all, so assert it actually strokes something for a queued unit and
// nothing at all when the selection is empty.
const cmdr = st.entities.find((e) => e.army === 0);
const tsz = st.tileSize;
L.issueOrder(st, rules, { army: 0, unitIds: [cmdr.id], order: { type: 'move', x: cmdr.x + 3 * tsz, y: cmdr.y } });
L.issueOrder(st, rules, { army: 0, unitIds: [cmdr.id], order: { type: 'move', x: cmdr.x + 3 * tsz, y: cmdr.y + 3 * tsz }, queue: true });
L.issueOrder(st, rules, { army: 0, unitIds: [cmdr.id], order: { type: 'attackMove', x: cmdr.x, y: cmdr.y + 3 * tsz }, queue: true });
view.selection = new Set([cmdr.id]);
view.render(0);
check('the order-queue overlay draws for a queued unit', view.gOrders.ops > 0, `${view.gOrders.ops} ops`);
view.selection = new Set();
view.render(0);
check('the order-queue overlay draws nothing with no selection', view.gOrders.ops === 0);
L.issueOrder(st, rules, { army: 0, unitIds: [cmdr.id], order: { type: 'stop' } });
// 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('4b. Order queueing (CTRL)');
// ---------------------------------------------------------------------------
{
// Holding CTRL passes `queue: true` to issueOrder. The engine contract that has to hold:
// a queued order APPENDS and leaves the current one running, an unqueued one REPLACES the
// lot, and the unit then works through them in order.
const map = generateMap(rules, { seed: 61, size: 'small', symmetry: 'mirror-x' });
const st = L.createMatch(rules, { seed: 61, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
const cmd = st.entities.find((e) => e.army === 0);
const ts = st.tileSize;
const pt = (dx, dy) => ({ x: cmd.x + dx * ts, y: cmd.y + dy * ts });
const a = pt(3, 0), b = pt(3, 3), c = pt(0, 3);
L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...a } });
check('first order replaces an empty queue', cmd.orders.length === 1);
L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...b }, queue: true });
L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...c }, queue: true });
check('queued orders append', cmd.orders.length === 3, `${cmd.orders.length}`);
check('the queue keeps its issue order',
Math.abs(cmd.orders[0].x - a.x) < 1 && Math.abs(cmd.orders[2].x - c.x) < 1);
// An unqueued order wipes the queue — the standard "plain click cancels everything" rule.
L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...a } });
check('an unqueued order clears the queue', cmd.orders.length === 1, `${cmd.orders.length}`);
// Run a three-leg queue and confirm it is actually consumed in sequence.
L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...b }, queue: true });
L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...c }, queue: true });
const seen = [cmd.orders.length];
for (let i = 0; i < 240 * HZ && cmd.orders.length; i++) {
L.tick(st, rules);
if (cmd.orders.length !== seen[seen.length - 1]) seen.push(cmd.orders.length);
}
check('a queued route is consumed one leg at a time',
seen.join(',') === '3,2,1,0', seen.join(','));
check('the unit ends up at the final waypoint',
Math.hypot(cmd.x - c.x, cmd.y - c.y) < ts * 2,
`${Math.hypot(cmd.x - c.x, cmd.y - c.y).toFixed(0)}px away`);
// Queued BUILD orders: each places its site immediately (so the player sees the ghosts)
// while the builder works through them one at a time.
const st2 = L.createMatch(rules, { seed: 62, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
const b2 = st2.armies[0];
b2.mass = 99999; b2.energy = 99999; b2.massCap = 99999; b2.energyCap = 99999;
const builder = st2.entities.find((e) => e.army === 0);
const gen = rules.buildingById.energygen;
// Candidate spots must clear each other by the building's own footprint, or siting the
// first one blocks the second and the "queue" under test never forms.
const spots = [];
const step = gen.footprint.w + 1;
for (let d = 2; d < 30 && spots.length < 3; d += step) {
const tx = worldToTileX(st2.nav, builder.x) + d;
const ty = worldToTileY(st2.nav, builder.y);
if (L.canPlaceAt(st2, rules, tx, ty, gen).ok) spots.push({ tx, ty });
}
check('found room for three queued generators', spots.length === 3, `${spots.length}`);
let queuedOk = 0;
spots.forEach((sp, i) => {
const r = L.issueOrder(st2, rules, {
army: 0, unitIds: [builder.id],
order: { type: 'build', defId: 'energygen', tx: sp.tx, ty: sp.ty }, queue: i > 0,
});
if (r.ok) queuedOk++;
});
check('three build orders queue onto one builder', queuedOk === 3, `${queuedOk}`);
check('every queued building is sited straight away',
st2.entities.filter((e) => e.defId === 'energygen').length === 3);
check('the builder holds all three in its queue', builder.orders.length === 3, `${builder.orders.length}`);
for (let i = 0; i < 400 * HZ && builder.orders.length; i++) {
b2.mass = 99999; b2.energy = 99999;
L.tick(st2, rules);
}
const finished = st2.entities.filter((e) => e.defId === 'energygen' && !e.site && !e.dead).length;
check('a queued build list completes', finished === 3, `${finished}/3 built`);
}
// ---------------------------------------------------------------------------
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)' : ''}.`);