feat: add Total Annihilation game with campaign, AI, and full engine
Add a complete Total Annihilation clone including: - TotalAnnihilationGame.js: Main scene with fixed-step loop, input handling, camera controls, selection, orders, and match lifecycle - TAHud.js: Resource strip with flow rates, selection info, build/production grid, minimap with fog of war, and toast notifications - TAScreens.js: Main menu, skirmish setup, campaign list, pause menu, and victory/defeat results - totalannihilation-campaign.json: 6 hand-authored campaign missions with escalating difficulty (skill 1→5) - tools/genTACampaign.js: Campaign generator that bakes terrain from the seeded map generator - tools/verifyTotalAnnihilation.js: 13-section end-to-end test suite covering rules integrity, artwork, economy, pathfinding, combat, fog of war, map generation, serialization, AI skill ladder, and campaign winnability - tools/lib/taStubScene.js: Minimal Phaser stub for headless testing Engine improvements: - TALogic.js: Swept projectile collision (no tunneling), quadratic AoE falloff, manual weapon support (D-Gun), unrecoverable elimination, onTick callback for AI stepping - TAAI.js: Skill-based economy ladders, simultaneous factory+economy growth, sticky focus-fire, home defense gating by skill, tune/profile overrides - TANav.js: fitField dilation for unit clearance, stale fit mask invalidation - TAWorldView.js: _addWorld for layer ordering, frame-accurate zoom anchor math, container depth sorting every frame - TARules.js: autoRangeOf excludes manual weapons, manual flag compilation Art & data: - Mayan civilization artwork added to civilization-artwork.json - Several opponents switched to mayan citySheet - totalannihilation-rules.json: balanced unit stats, new commanderlaser weapon, restructured AI skill tuning, formatted JSON - sprites.md: complete art spec for drop-in PNG sheets - Registered in gamesRegistry, assetManifest, PreloadScene, GameRoomScene, and soundtrack service
This commit is contained in:
parent
ab05e2f1cd
commit
e7da172896
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
|
|
@ -17,6 +17,7 @@
|
|||
"classic": { "key": "civilization-cities-classic", "path": "assets/images/civilization/civilization-cities-classic.png", "frameWidth": 128, "frameHeight": 96 },
|
||||
"cyberpunk": { "key": "civilization-cities-cyberpunk", "path": "assets/images/civilization/civilization-cities-cyberpunk.png", "frameWidth": 128, "frameHeight": 96 },
|
||||
"japanese": { "key": "civilization-cities-japanese", "path": "assets/images/civilization/civilization-cities-japanese.png", "frameWidth": 128, "frameHeight": 96 },
|
||||
"steampunk": { "key": "civilization-cities-steampunk", "path": "assets/images/civilization/civilization-cities-steampunk.png", "frameWidth": 128, "frameHeight": 96 }
|
||||
"steampunk": { "key": "civilization-cities-steampunk", "path": "assets/images/civilization/civilization-cities-steampunk.png", "frameWidth": 128, "frameHeight": 96 },
|
||||
"mayan": { "key": "civilization-cities-mayan", "path": "assets/images/civilization/civilization-cities-mayan.png", "frameWidth": 128, "frameHeight": 96 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,988 @@
|
|||
{
|
||||
"_comment": "Generated by tools/genTACampaign.js — terrain is baked, design is hand-authored.",
|
||||
"missions": [
|
||||
{
|
||||
"id": "m01",
|
||||
"name": "Beachhead",
|
||||
"theme": "grasslands",
|
||||
"seed": 10461,
|
||||
"playerArmy": "arm",
|
||||
"playerCommander": "vance",
|
||||
"enemies": [
|
||||
{
|
||||
"army": "core",
|
||||
"commander": "klaxon",
|
||||
"aiProfile": {
|
||||
"skill": 1,
|
||||
"aggression": 0.25
|
||||
}
|
||||
}
|
||||
],
|
||||
"startResources": [
|
||||
{
|
||||
"mass": 1600,
|
||||
"energy": 2600
|
||||
},
|
||||
{
|
||||
"mass": 600,
|
||||
"energy": 900
|
||||
}
|
||||
],
|
||||
"objective": {
|
||||
"type": "destroyAll"
|
||||
},
|
||||
"briefing": [
|
||||
{
|
||||
"speaker": "ethel",
|
||||
"mood": "idle",
|
||||
"text": "Establish a base and remove the CORE presence. Power first — nothing moves without it."
|
||||
}
|
||||
],
|
||||
"victoryLine": {
|
||||
"speaker": "ethel",
|
||||
"mood": "happy",
|
||||
"text": "Beachhead secured. The corridor inland is open."
|
||||
},
|
||||
"defeatLine": {
|
||||
"speaker": "klaxon",
|
||||
"mood": "smug",
|
||||
"text": "The landing has been thrown back into the sea."
|
||||
},
|
||||
"map": {
|
||||
"w": 64,
|
||||
"h": 64,
|
||||
"theme": "grasslands",
|
||||
"rows": [
|
||||
"~~..rrrr.......rrr............................rrr.......rrrr..~~",
|
||||
"~~..............rr............................rr..............~~",
|
||||
"~~............................................................~~",
|
||||
"~~............................................................~~",
|
||||
"~~.........^^^....................................^^^.........~~",
|
||||
"~~.........^^^....................................^^^.........~~",
|
||||
"~~.........^^^....................................^^^.........~~",
|
||||
"~~.........rrr....................................rrr.........~~",
|
||||
"~~............r^^.............r^^r.............^^r............~~",
|
||||
"~~~........^^^^^^............r.^^.r............^^^^^^........~~~",
|
||||
"~~~r......^^^^^^.............^^^^^^.............^^^^^^......r~~~",
|
||||
"~~~r....^^^^^^^.............^^^^^^^^.............^^^^^^^....r~~~",
|
||||
"~~~.....^^^^^^^....rr......^^^^^^^^^^......rr....^^^^^^^.....~~~",
|
||||
"~~~.....^^^^^^^...rrr.......^^^^^^^^.......rrr...^^^^^^^.....~~~",
|
||||
"~~.....r^^^^^^.....rrr.......^^^^^^.......rrr.....^^^^^^r.....~~",
|
||||
"~~.....r.^^^^^.....rrr........^^^^........rrr.....^^^^^.r.....~~",
|
||||
"~~.....rr..........r........................r..........rr.....~~",
|
||||
".......rrr.........r..r..................r..r.........rrr.......",
|
||||
"r.......r..........rrrrr................rrrrr..........r.......r",
|
||||
"...................rrrrr................rrrrr...................",
|
||||
".....................rr..................rr.....................",
|
||||
"....................~~~..................~~~....................",
|
||||
"...................~~~~~~rr..........rr~~~~~~...................",
|
||||
".......MM......r...~~~~~~~rr........rr~~~~~~~...r......MM.......",
|
||||
".......MM.....rrr.r~~~~~~~~rr......rr~~~~~~~~r.rrr.....MM.......",
|
||||
"..............rr...~~~~~~...r......r...~~~~~~...rr..............",
|
||||
"....................~~~.....rr....rr.....~~~....................",
|
||||
"............................rr....rr............................",
|
||||
"..................MM.........rrrrrr.........MM..................",
|
||||
"...MM.............MM.........rrrrrr.........MM.............MM...",
|
||||
"...MM.........................rrrr.........................MM...",
|
||||
"..................r..........^^^^^^..........r..................",
|
||||
"r..................r....^^^^^^^^^^^^^^^^....r..................r",
|
||||
"r.................rr...^^^^^^^^^^^^^^^^^^...rr.................r",
|
||||
".......................^^^^^^^^^^^^^^^^^^.......................",
|
||||
"....................rrr.^^^^^^^^^^^^^^^^.rrr....................",
|
||||
"...............MM...rrr.^^^^^^^^^^^^^^^^.rrr...MM...............",
|
||||
"...............MM...rrr.^^^^^^^^^^^^^^^^.rrr...MM...............",
|
||||
".....................rrr....^^^^^^^^....rrr.....................",
|
||||
"rr............r.......r.....rrrrrrrr.....r.......r............rr",
|
||||
"r.....~~~~.~~~...............rrrrrr...............~~~.~~~~.....r",
|
||||
"r.~~~~~~~~~~~~...............rrrrrr...............~~~~~~~~~~~~.r",
|
||||
"~~~~~~~~~~~~~~..............rrrrrrrr..............~~~~~~~~~~~~~~",
|
||||
"~~~~~~~~~~~~~~.............rr......rr.............~~~~~~~~~~~~~~",
|
||||
"~~~~~~~~~~~~~~...........rrr........rrr...........~~~~~~~~~~~~~~",
|
||||
"~~~~~~~~~~~~~~..........rrrr........rrrr..........~~~~~~~~~~~~~~",
|
||||
"~~~~~~~~~~~~~~...........rrr........rrr...........~~~~~~~~~~~~~~",
|
||||
"~~~~~~~~~~~~~~r..................................r~~~~~~~~~~~~~~",
|
||||
"...~~~~~~~~~~~....................................~~~~~~~~~~~...",
|
||||
"......~~~~~~~~....................................~~~~~~~~......",
|
||||
".......~~~~~~~....................................~~~~~~~.......",
|
||||
".......~~~~~~~....................................~~~~~~~.......",
|
||||
"........~~~~~~....................................~~~~~~........",
|
||||
"........~~~~~~....................................~~~~~~........",
|
||||
"..rr....~~~~~~....................................~~~~~~....rr..",
|
||||
"..rr....~~~~~~......rr..rrr..........rrr..rr......~~~~~~....rr..",
|
||||
"..rr....~~~~~~......rr..rrr..........rrr..rr......~~~~~~....rr..",
|
||||
"........~~~~~~....................................~~~~~~........",
|
||||
"........~~~~~~....................................~~~~~~........",
|
||||
"........~~~~~~r..................................r~~~~~~........",
|
||||
"........~~~~~~r..................................r~~~~~~........",
|
||||
"........~~~~~~r..................................r~~~~~~........",
|
||||
"........~~~~~~rrrr...~....................~...rrrr~~~~~~........",
|
||||
"..rr...r~~~~~~..r...~~~.....r......r.....~~~...r..~~~~~~r...rr.."
|
||||
],
|
||||
"starts": [
|
||||
{
|
||||
"army": 0,
|
||||
"x": 10,
|
||||
"y": 32
|
||||
},
|
||||
{
|
||||
"army": 1,
|
||||
"x": 53,
|
||||
"y": 32
|
||||
}
|
||||
],
|
||||
"buildings": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m02",
|
||||
"name": "Metal Fields",
|
||||
"theme": "grasslands",
|
||||
"seed": 20773,
|
||||
"playerArmy": "arm",
|
||||
"playerCommander": "vance",
|
||||
"enemies": [
|
||||
{
|
||||
"army": "core",
|
||||
"commander": "klaxon",
|
||||
"aiProfile": {
|
||||
"skill": 2,
|
||||
"aggression": 0.4
|
||||
}
|
||||
}
|
||||
],
|
||||
"startResources": [
|
||||
{
|
||||
"mass": 1300,
|
||||
"energy": 2200
|
||||
},
|
||||
{
|
||||
"mass": 900,
|
||||
"energy": 1400
|
||||
}
|
||||
],
|
||||
"objective": {
|
||||
"type": "destroyAll"
|
||||
},
|
||||
"briefing": [
|
||||
{
|
||||
"speaker": "ethel",
|
||||
"mood": "idle",
|
||||
"text": "Metal patches double a generator's yield. Take them before CORE does."
|
||||
}
|
||||
],
|
||||
"victoryLine": {
|
||||
"speaker": "ethel",
|
||||
"mood": "happy",
|
||||
"text": "The patches are ours. Production doubles from here."
|
||||
},
|
||||
"defeatLine": {
|
||||
"speaker": "klaxon",
|
||||
"mood": "smug",
|
||||
"text": "CORE holds the metal. We cannot match their output."
|
||||
},
|
||||
"map": {
|
||||
"w": 64,
|
||||
"h": 64,
|
||||
"theme": "grasslands",
|
||||
"rows": [
|
||||
".............r............^^^^^^^^^^^^............r.............",
|
||||
".......rr.................^^^^^^^^^^^^.................rr.......",
|
||||
"....rrrrr.................^^^^^^^^^^^^.................rrrrr....",
|
||||
"...rrrrr...................^^^^^^^^^^...................rrrrr...",
|
||||
"....r......................................................r....",
|
||||
"................................................................",
|
||||
"...r....r..~~......................................~~..r....r...",
|
||||
"..rrr..~~~~~~~....r..........................r....~~~~~~~..rrr..",
|
||||
"r..r...~~~~~~~....r..........................r....~~~~~~~...r..r",
|
||||
"....r.~~~~~~~~....rrrr.......rrrrrr.......rrrr....~~~~~~~~.r....",
|
||||
"...~~~~~~~~~~~....rrrr.......~~~~~~.......rrrr....~~~~~~~~~~~...",
|
||||
"..~~~~~~~~~~..r~~..r........~~~~~~~~........r..~~r..~~~~~~~~~~..",
|
||||
"..~~~~~~~~~r..r~~~.........~~~~~~~~~~.........~~~r..r~~~~~~~~~..",
|
||||
"..~~~~~~~~~...r~~~..........~~~~~~~~..........~~~r...~~~~~~~~~..",
|
||||
"...~~~~~~~.....~~~............~~~~............~~~.....~~~~~~~...",
|
||||
"....~~r.........~~............................~~.........r~~....",
|
||||
".....r....................................................r.....",
|
||||
"................................................................",
|
||||
".......................rr..............rr.......................",
|
||||
"..........r..rr........rr..............rr........rr..r..........",
|
||||
"....rrr...^^^^r..........r............r..........r^^^^...rrr....",
|
||||
"....rr.^^^^^^^^.........~~............~~.........^^^^^^^^.rr....",
|
||||
"...rrr.^^^^^^^^r.......~~~~..........~~~~.......r^^^^^^^^.rrr...",
|
||||
"....rrrr^^^^^^^r.......~~~~..........~~~~.......r^^^^^^^rrrr....",
|
||||
".....rr....^...........~~~~..........~~~~...........^....rr.....",
|
||||
".....rr.................~~............~~.................rr.....",
|
||||
"........................~~............~~........................",
|
||||
"................................................................",
|
||||
"........MM............................................MM........",
|
||||
".rr.....MM............................................MM.....rr.",
|
||||
"^rr..........................................................rr^",
|
||||
"^rr..........................................................rr^",
|
||||
"^r............................................................r^",
|
||||
"^..............................................................^",
|
||||
"^.....................rr................rr.....................^",
|
||||
"^.....................rr................rr.....................^",
|
||||
"..............MM......rr................rr......MM..............",
|
||||
"..............MM......rr................rr......MM..............",
|
||||
"...................MM......................MM...................",
|
||||
"...................MM......................MM...................",
|
||||
"...........~~rr.rr............................rr.rr~~...........",
|
||||
"...........rrrr.rr............................rr.rrrr...........",
|
||||
"..........rrrrMM................................MMrrrr..........",
|
||||
".........rrr..MM................................MM..rrr.........",
|
||||
"..........r..........................................r..........",
|
||||
"........................rrrr........rrrr........................",
|
||||
"........................rrrr........rrrr........................",
|
||||
".......................rrrrr........rrrrr.......................",
|
||||
".......................rrrrr........rrrrr.......................",
|
||||
"........................rrrr........rrrr........................",
|
||||
"...........................r........r...........................",
|
||||
"...........................r........r...........................",
|
||||
"........................rrrr........rrrr........................",
|
||||
"r.......................rrrr........rrrr.......................r",
|
||||
"......rr...............rr..rr......rr..rr...............rr......",
|
||||
".....rrr...............rr..rr......rr..rr...............rrr.....",
|
||||
".....rrr................................................rrr.....",
|
||||
"......rr................................................rr......",
|
||||
"r..............................................................r",
|
||||
"................................................................",
|
||||
".........rrr..........rr....rr....rr....rr..........rrr.........",
|
||||
".........rrr.........rrr....rr....rr....rrr.........rrr.........",
|
||||
"r........rrr.........rrr................rrr.........rrr........r",
|
||||
".r.r.r.rrrr~~.......rr....................rr.......~~rrrr.r.r.r."
|
||||
],
|
||||
"starts": [
|
||||
{
|
||||
"army": 0,
|
||||
"x": 10,
|
||||
"y": 32
|
||||
},
|
||||
{
|
||||
"army": 1,
|
||||
"x": 53,
|
||||
"y": 32
|
||||
}
|
||||
],
|
||||
"buildings": [
|
||||
{
|
||||
"army": 1,
|
||||
"type": "energygen",
|
||||
"tx": 56,
|
||||
"ty": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m03",
|
||||
"name": "Cold Open",
|
||||
"theme": "snowfields",
|
||||
"seed": 31287,
|
||||
"playerArmy": "arm",
|
||||
"playerCommander": "vance",
|
||||
"enemies": [
|
||||
{
|
||||
"army": "core",
|
||||
"commander": "cybro",
|
||||
"aiProfile": {
|
||||
"skill": 2,
|
||||
"aggression": 0.6
|
||||
}
|
||||
}
|
||||
],
|
||||
"startResources": [
|
||||
{
|
||||
"mass": 1200,
|
||||
"energy": 2000
|
||||
},
|
||||
{
|
||||
"mass": 1100,
|
||||
"energy": 1800
|
||||
}
|
||||
],
|
||||
"objective": {
|
||||
"type": "destroyAll"
|
||||
},
|
||||
"briefing": [
|
||||
{
|
||||
"speaker": "ethel",
|
||||
"mood": "idle",
|
||||
"text": "Cy-Bro opens with infantry. Armour answers infantry — get a Vehicle Plant up early."
|
||||
}
|
||||
],
|
||||
"victoryLine": {
|
||||
"speaker": "ethel",
|
||||
"mood": "happy",
|
||||
"text": "Cy-Bro's foothold is slag."
|
||||
},
|
||||
"defeatLine": {
|
||||
"speaker": "cybro",
|
||||
"mood": "smug",
|
||||
"text": "Overrun before the plant finished."
|
||||
},
|
||||
"map": {
|
||||
"w": 64,
|
||||
"h": 64,
|
||||
"theme": "snowfields",
|
||||
"rows": [
|
||||
"...rr.....~~~~........r..........rrrrr...^^^^^^^^^^^^^^^^^^^^^^^",
|
||||
"...........~~~....................rrr....^^^^^^^^^^^^^^^^^^^^^^^",
|
||||
"..............................~~.~~........^^^^^^^^^r...........",
|
||||
"rr.........................~~...................................",
|
||||
"rr.....................................................rr.......",
|
||||
".......................................................rr.......",
|
||||
"..............................MM..................^^^^^^^^^^....",
|
||||
"..............................MM................^^^^^^^^^^^^^...",
|
||||
"................................................^^^^^^^^^^^^^^..",
|
||||
"r..rr............................................^^^^^^^^^^^^^r.",
|
||||
"rr.rr............................................r^^^^^^^^^^^rr.",
|
||||
"rr.................................................^^^^^^^^^rr..",
|
||||
"...............rr...................................^^^^^^^^...r",
|
||||
"...............rr............MM.....................^^^^^^^.....",
|
||||
".............................MM..MM..................^^^^^r....r",
|
||||
".................................MM.............................",
|
||||
"...~~~..........................................................",
|
||||
"..~~~~~r...........r.......................................r..rr",
|
||||
"r~~~~~~~...~~~~~~.rrr.......rrrr....................rr....rrrrrr",
|
||||
"~~~~~~~~~~~~~~~~~~rr........rrrr..............r.....rr....rrrrrr",
|
||||
"~~~~~~~~~~~~~~~~~~~..........................rrr................",
|
||||
"~~~~~~~~~~~~~~~~~~~..........................rrr................",
|
||||
"~~~~~~~~~~~~~~~~~~~..........................rrrrrrrr......rr..^",
|
||||
"~~~~~~~~~~~~~~~~~...........................rr.rrrrrr......rr..^",
|
||||
"~~~~~~~........r.................MM.......^^^^^...rr........rr^^",
|
||||
"~~~~~~~........r.................MM......^^^^^^^..rr.......rrrr^",
|
||||
"~~~~~~~.................................^^^^^^^^^.rr.......rrrr^",
|
||||
"~~~~~~..................................^^^^^^^^^^............rr",
|
||||
"~~~~~~..........rr.....................^^^^^^^^^^^^............r",
|
||||
".~~~~...........rr....................^^^^^^^^^^^^^^...........r",
|
||||
".....................~~..rrr..........^^^^^^^^^^^^^^^..........r",
|
||||
".....................~~..rrr..........^^^^^^^^^^^^^^^^.........r",
|
||||
".....................~~..rrr..........^^^^^^^^^^^^^^^^.........r",
|
||||
".....................~~..rrr..........^^^^^^^^^^^^^^^..........r",
|
||||
".~~~~...........rr....................^^^^^^^^^^^^^^...........r",
|
||||
"~~~~~~..........rr.....................^^^^^^^^^^^^............r",
|
||||
"~~~~~~..................................^^^^^^^^^^............rr",
|
||||
"~~~~~~~.................................^^^^^^^^^.rr.......rrrr^",
|
||||
"~~~~~~~........r.................MM......^^^^^^^..rr.......rrrr^",
|
||||
"~~~~~~~........r.................MM.......^^^^^...rr........rr^^",
|
||||
"~~~~~~~~~~~~~~~~~...........................rr.rrrrrr......rr..^",
|
||||
"~~~~~~~~~~~~~~~~~~~..........................rrrrrrrr......rr..^",
|
||||
"~~~~~~~~~~~~~~~~~~~..........................rrr................",
|
||||
"~~~~~~~~~~~~~~~~~~~..........................rrr................",
|
||||
"~~~~~~~~~~~~~~~~~~rr........rrrr..............r.....rr....rrrrrr",
|
||||
"r~~~~~~~...~~~~~~.rrr.......rrrr....................rr....rrrrrr",
|
||||
"..~~~~~r...........r.......................................r..rr",
|
||||
"...~~~..........................................................",
|
||||
".................................MM.............................",
|
||||
".............................MM..MM..................^^^^^r....r",
|
||||
"...............rr............MM.....................^^^^^^^.....",
|
||||
"...............rr...................................^^^^^^^^...r",
|
||||
"rr.................................................^^^^^^^^^rr..",
|
||||
"rr.rr............................................r^^^^^^^^^^^rr.",
|
||||
"r..rr............................................^^^^^^^^^^^^^r.",
|
||||
"................................................^^^^^^^^^^^^^^..",
|
||||
"..............................MM................^^^^^^^^^^^^^...",
|
||||
"..............................MM..................^^^^^^^^^^....",
|
||||
".......................................................rr.......",
|
||||
"rr.....................................................rr.......",
|
||||
"rr.........................~~...................................",
|
||||
"..............................~~.~~........^^^^^^^^^r...........",
|
||||
"...........~~~....................rrr....^^^^^^^^^^^^^^^^^^^^^^^",
|
||||
"...rr.....~~~~........r..........rrrrr...^^^^^^^^^^^^^^^^^^^^^^^"
|
||||
],
|
||||
"starts": [
|
||||
{
|
||||
"army": 0,
|
||||
"x": 32,
|
||||
"y": 10
|
||||
},
|
||||
{
|
||||
"army": 1,
|
||||
"x": 32,
|
||||
"y": 53
|
||||
}
|
||||
],
|
||||
"buildings": [
|
||||
{
|
||||
"army": 0,
|
||||
"type": "energygen",
|
||||
"tx": 35,
|
||||
"ty": 8
|
||||
},
|
||||
{
|
||||
"army": 1,
|
||||
"type": "barracks",
|
||||
"tx": 36,
|
||||
"ty": 56
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m04",
|
||||
"name": "The Long Ridge",
|
||||
"theme": "grasslands",
|
||||
"seed": 44377,
|
||||
"playerArmy": "arm",
|
||||
"playerCommander": "vance",
|
||||
"enemies": [
|
||||
{
|
||||
"army": "core",
|
||||
"commander": "klaxon",
|
||||
"aiProfile": {
|
||||
"skill": 3,
|
||||
"aggression": 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"startResources": [
|
||||
{
|
||||
"mass": 2200,
|
||||
"energy": 3600
|
||||
},
|
||||
{
|
||||
"mass": 1300,
|
||||
"energy": 2200
|
||||
}
|
||||
],
|
||||
"objective": {
|
||||
"type": "destroyAll"
|
||||
},
|
||||
"briefing": [
|
||||
{
|
||||
"speaker": "ethel",
|
||||
"mood": "idle",
|
||||
"text": "A fair fight on a wide map. Expand hard — whoever out-earns the other wins the third engagement."
|
||||
}
|
||||
],
|
||||
"victoryLine": {
|
||||
"speaker": "ethel",
|
||||
"mood": "happy",
|
||||
"text": "The ridge is held."
|
||||
},
|
||||
"defeatLine": {
|
||||
"speaker": "klaxon",
|
||||
"mood": "smug",
|
||||
"text": "KLAXON out-produced us and it showed."
|
||||
},
|
||||
"map": {
|
||||
"w": 96,
|
||||
"h": 96,
|
||||
"theme": "grasslands",
|
||||
"rows": [
|
||||
"~~~~~~~~~~~~~~~....~~~.....~~~~~~~~~..r...........rr.~~~~~~...r......r...r..............r.......",
|
||||
"~~~~~~~~r~~~~~~.....~~.....~~~~~~~~~..................~~~~......................................",
|
||||
"~~~........~~~rr............~~~~~~~~..................~~~~................................rrrrrr",
|
||||
"~~~...........r.............~~~~~~~......................................................rrrrr..",
|
||||
"~~~.........................~~~~~.................rr......................................r.....",
|
||||
"~~~.......MM................~~~~r................rrrr..........................................^",
|
||||
"~~~...MM..MM................~~~~r.................rr..........................rr................",
|
||||
"~~~...MM..................r~~~~~..................rr..........................rrrrr.............",
|
||||
".~~....................~~~~~.r...........rr....................................rrrrr............",
|
||||
".~~....................r~~~..............rr......................r^^r.....rrr~~rrrrr............",
|
||||
"~~......................................rrr......................^^^^.....rrr~~................^",
|
||||
"~~~MM..................................r.........................^^^^........~~................^",
|
||||
"~~.MM..................................rr........................^^^^........r~................^",
|
||||
".......................................rrr.................r..................~r................",
|
||||
"....................MM................rrrr.................rrrr..............~~~rrr.............",
|
||||
"....................MM................rr....................rrrr...........~~~~~~rr.............",
|
||||
"..................................rrr...........................rrr........~~~~~~~..............",
|
||||
"..................................rrr............................rr.......~~~~~~~......rrrr.r...",
|
||||
"..................................rrr......................................~~~~~.......rrrrrrr..",
|
||||
"........~~~~~~.....................r........~~~~~~~~r..........r..~~....................rrrrr...",
|
||||
".......~~~~~~~~...........rrr...^^^rr......~~~~~~~~~~~~..~~~~~~~~~~~~~~~~.......................",
|
||||
"^......~~~~~~~~~..........rrrrr^^^^rr.....~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~......................",
|
||||
"^^......~~~~~~~~...........rr..^^^^r....rr~~~~~~~~~~~......~~~~~~~~~~~~~~.......................",
|
||||
"^^r.....~~~~~~~~~..............r^^^.....rr.~~~~~~~~..........~~~~~~~~~~~r.......................",
|
||||
"^^^......~~~...~~...rr...........^......rrr.~~.................~~~~~~~..........................",
|
||||
"^^^................rrr..................rrrr......................~~............................",
|
||||
"^^^.............................................................................................",
|
||||
"^^^r............................................................................................",
|
||||
"^^^.............................................................................................",
|
||||
"^^^............................................................................r...............r",
|
||||
"^^^..........................................................................^^^rr..............",
|
||||
"^^r....................^^.................................................r^^^^^^^^.............",
|
||||
"^^.................^^^^^^^.................^^^^^^^^r.....................^^^^^^^^^^^^...........",
|
||||
"^^.................^^^^^^^................^^^^^^^^^^^....................^^^^^^^^^^^^^..........",
|
||||
"^^...rr............^^^^^^^................^^^^^^^^^^^....................^^^^^^^^^^^^r..........",
|
||||
"^.rrrrrr.............r.^^^...rr............^^^^^^.........................^^^^^^^^^^............",
|
||||
"..rrrrrr................^....rr...........................................^^^^^^^^^............r",
|
||||
"...rrrr....................................................................^^^^^^^..............",
|
||||
"............................................................................^^^^................",
|
||||
"................................................................................rr..............",
|
||||
".........rr.................................................rr.................rrr..............",
|
||||
".........r~~~~.....r~~......................................rr.................rr...............",
|
||||
".....~...~~~~~~~..r~~~~.....................................rrr................rrr.....~~~~~~...",
|
||||
"....~~~~~~~~~~~~~.~~~~~.........................r....~~~~~~~~~~~~...............rrr...~~~~~~~~~.",
|
||||
"....~~~~~~~~~~~~~~~~~~~~.....................rrrrr.~~~~~~~~~~~~~~~~~............rrrrrr~~~~~~~~~~",
|
||||
"...r~~~~~~~~~~~~~~~~~~~~....................rrrrrrr~~~~~~~~~~~~~~~~~............rrrr..~~~~~~~~~~",
|
||||
"...r~~~~~~~~~~~~~~~~~~~~...................rr..rrrr~~~~~~~~~~~r.................rrr....~~~~~~~~.",
|
||||
"...r~~~~~~~~~~~~~~~~~~~~.............r....rr....r...~~~~~~~~rrr..........r.............~~~~~~r..",
|
||||
"..r~~~~~~.............r..........rrr~~~~~~~~...r....rr....r.............~~~~~~~~~~~~~~~~~~~~r...",
|
||||
".~~~~~~~~....rrr.................r~~~~~~~~~~~rrrr..rr...................~~~~~~~~~~~~~~~~~~~~r...",
|
||||
"~~~~~~~~~~..rrrr............~~~~~~~~~~~~~~~~~rrrrrrr....................~~~~~~~~~~~~~~~~~~~~r...",
|
||||
"~~~~~~~~~~rrrrrr............~~~~~~~~~~~~~~~~~.rrrrr.....................~~~~~~~~~~~~~~~~~~~~....",
|
||||
".~~~~~~~~~...rrr...............~~~~~~~~~~~~....r.........................~~~~~.~~~~~~~~~~~~~....",
|
||||
"...~~~~~~.....rrr................rrr.....................................~~~~r..~~~~~~~...~.....",
|
||||
"...............rr.................rr......................................~~r.....~~~~r.........",
|
||||
"..............rrr.................rr.................................................rr.........",
|
||||
"..............rr................................................................................",
|
||||
"................^^^^............................................................................",
|
||||
"..............^^^^^^^....................................................................rrrr...",
|
||||
"r............^^^^^^^^^...........................................rr....^................rrrrrr..",
|
||||
"............^^^^^^^^^^.........................^^^^^^............rr...^^^.r.............rrrrrr.^",
|
||||
"..........r^^^^^^^^^^^^....................^^^^^^^^^^^................^^^^^^^............rr...^^",
|
||||
"..........^^^^^^^^^^^^^....................^^^^^^^^^^^................^^^^^^^.................^^",
|
||||
"...........^^^^^^^^^^^^.....................r^^^^^^^^.................^^^^^^^.................^^",
|
||||
".............^^^^^^^^r.................................................^^....................r^^",
|
||||
"..............rr^^^..........................................................................^^^",
|
||||
"r...............r............................................................................^^^",
|
||||
".............................................................................................^^^",
|
||||
"............................................................................................r^^^",
|
||||
".............................................................................................^^^",
|
||||
"............................~~......................rrrr..................rrr................^^^",
|
||||
"..........................~~~~~~~.................~~.rrr......^...........rr...~~...~~~......^^^",
|
||||
".......................r~~~~~~~~~~~..........~~~~~~~~.rr.....^^^r..............~~~~~~~~~.....r^^",
|
||||
".......................~~~~~~~~~~~~~~......~~~~~~~~~~~rr....r^^^^..rr...........~~~~~~~~......^^",
|
||||
"......................~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.....rr^^^^rrrrr..........~~~~~~~~~......^",
|
||||
".......................~~~~~~~~~~~~~~~~..~~~~~~~~~~~~......rr^^^...rrr...........~~~~~~~~.......",
|
||||
"...rrrrr....................~~..r..........r~~~~~~~~........r.....................~~~~~~........",
|
||||
"..rrrrrrr.......~~~~~......................................rrr..................................",
|
||||
"...r.rrrr......~~~~~~~.......rr............................rrr..................................",
|
||||
"..............~~~~~~~........rrr...........................rrr..................................",
|
||||
".............rr~~~~~~...........rrrr....................rr................MM....................",
|
||||
".............rrr~~~..............rrrr.................rrrr................MM....................",
|
||||
"................r~..................r.................rrr.......................................",
|
||||
"^................~r........^^^^........................rr..................................MM.~~",
|
||||
"^................~~........^^^^.........................r..................................MM~~~",
|
||||
"^................~~rrr.....^^^^......................rrr......................................~~",
|
||||
"............rrrrr~~rrr.....r^^r......................rr..............~~~r....................~~.",
|
||||
"............rrrrr....................................rr...........r.~~~~~....................~~.",
|
||||
".............rrrrr..........................rr..................~~~~~r..................MM...~~~",
|
||||
"................rr..........................rr.................r~~~~................MM..MM...~~~",
|
||||
"^..........................................rrrr................r~~~~................MM.......~~~",
|
||||
".....r......................................rr.................~~~~~.........................~~~",
|
||||
"..rrrrr......................................................~~~~~~~.............r...........~~~",
|
||||
"rrrrrr................................~~~~..................~~~~~~~~............rr~~~........~~~",
|
||||
"......................................~~~~..................~~~~~~~~~.....~~.....~~~~~~r~~~~~~~~",
|
||||
".......r..............r...r......r...~~~~~~.rr...........r..~~~~~~~~~.....~~~....~~~~~~~~~~~~~~~"
|
||||
],
|
||||
"starts": [
|
||||
{
|
||||
"army": 0,
|
||||
"x": 10,
|
||||
"y": 10
|
||||
},
|
||||
{
|
||||
"army": 1,
|
||||
"x": 85,
|
||||
"y": 85
|
||||
}
|
||||
],
|
||||
"buildings": [
|
||||
{
|
||||
"army": 0,
|
||||
"type": "energygen",
|
||||
"tx": 13,
|
||||
"ty": 8
|
||||
},
|
||||
{
|
||||
"army": 0,
|
||||
"type": "energygen",
|
||||
"tx": 7,
|
||||
"ty": 8
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m05",
|
||||
"name": "Tropic Vice",
|
||||
"theme": "tropics",
|
||||
"seed": 55930,
|
||||
"playerArmy": "arm",
|
||||
"playerCommander": "vance",
|
||||
"enemies": [
|
||||
{
|
||||
"army": "core",
|
||||
"commander": "cybro",
|
||||
"aiProfile": {
|
||||
"skill": 4,
|
||||
"aggression": 0.7
|
||||
}
|
||||
}
|
||||
],
|
||||
"startResources": [
|
||||
{
|
||||
"mass": 2600,
|
||||
"energy": 4200
|
||||
},
|
||||
{
|
||||
"mass": 1500,
|
||||
"energy": 2600
|
||||
}
|
||||
],
|
||||
"objective": {
|
||||
"type": "destroyAll"
|
||||
},
|
||||
"briefing": [
|
||||
{
|
||||
"speaker": "ethel",
|
||||
"mood": "idle",
|
||||
"text": "Cy-Bro starts ahead and pushes early. Rocket tanks out-range everything CORE fields."
|
||||
}
|
||||
],
|
||||
"victoryLine": {
|
||||
"speaker": "ethel",
|
||||
"mood": "happy",
|
||||
"text": "Cy-Bro is scrap on the sand."
|
||||
},
|
||||
"defeatLine": {
|
||||
"speaker": "cybro",
|
||||
"mood": "smug",
|
||||
"text": "The push came faster than the plant did."
|
||||
},
|
||||
"map": {
|
||||
"w": 96,
|
||||
"h": 96,
|
||||
"theme": "tropics",
|
||||
"rows": [
|
||||
"~~~..........r.....~~~~~~~~~~~~~~~.rr.rr................rr.rr.~~~~~~~~~~~~~~~.....r..........~~~",
|
||||
"~~.................~~~~~~~~~~~~~~~.rrrrr................rrrrr.~~~~~~~~~~~~~~~.................~~",
|
||||
"~~..................~~~~..r~~~~~~~~..........................~~~~~~~~r..~~~~..................~~",
|
||||
"~.....................r........~~~~..........................~~~~........r.....................~",
|
||||
"...............................~~~~~........................~~~~~...............................",
|
||||
".......................r.......~~~~~........................~~~~~.......r.......................",
|
||||
"......................rr.......~~~~~........................~~~~~.......rr......................",
|
||||
"......................rr.......~~~~~........................~~~~~.......rr......................",
|
||||
"....................rrrr.......~~~~~........................~~~~~.......rrrr....................",
|
||||
"...............rrr^^^^r........~~~~~........................~~~~~........r^^^^rrr...............",
|
||||
"...............r^^^^^^^^.......~~~~~~r....................r~~~~~~.......^^^^^^^^r...............",
|
||||
"..............rr^^^^^^^^r......~~~~~~~~~~~~~~~~..~~~~~~~~~~~~~~~~......r^^^^^^^^rr..............",
|
||||
".............rrr^^^^^^^^.......~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.......^^^^^^^^rrr.............",
|
||||
".............rrr^^^^^^^........~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~........^^^^^^^rrr.............",
|
||||
"................^^^^............~~~~~~~~~~~~~~~rr~~~~~~~~~~~~~~~............^^^^................",
|
||||
".................r..............rr..~~~r...r........r...r~~~..rr..............r.................",
|
||||
"...............................................rr...............................................",
|
||||
"r..r...........................................rr...........................................r..r",
|
||||
"..rrr..rr.^..........................................................................^.rr..rrr..",
|
||||
"~~.r...rrrr^.................r....................................r.................^rrrr...r.~~",
|
||||
"~~~.rrr..rr^^................r....................................r................^^rr..rrr.~~~",
|
||||
"~~~..rr..r^^^^....................................................................^^^^r..rr..~~~",
|
||||
"~~~......^^^^^....................................................................^^^^^......~~~",
|
||||
"~~~.....^^^^^^^rr..............................................................rr^^^^^^^.....~~~",
|
||||
"........^^^^^^^^^..............................................................^^^^^^^^^........",
|
||||
"........^^^^^^^^^..............................................................^^^^^^^^^........",
|
||||
".......r^^^^r.^^^..............................................................^^^.r^^^^r.......",
|
||||
".......r^^^rr.r^^^............................................................^^^r.rr^^^r.......",
|
||||
".......r^^rrrr..^^^^^^..r..............rr..............rr..............r..^^^^^^..rrrr^^r.......",
|
||||
"........^...r...^^^^^^^^rr.............rrr.....rr.....rrr.............rr^^^^^^^^...r...^........",
|
||||
"^^..............^^^^^^^^^r......~~~~...rrrrr^^.rr.^^rrrrr...~~~~......r^^^^^^^^^..............^^",
|
||||
"^^^.............^^^^^^^^^......~~~~~...rrr^^^^^^^^^^^^rrr...~~~~~......^^^^^^^^^.............^^^",
|
||||
"^^^^^^....rrr....^^^^^^^^^....~~~~~~.rr..^^^^^^^^^^^^^^..rr.~~~~~~....^^^^^^^^^....rrr....^^^^^^",
|
||||
"^^^^^^r..rrrr....^^^^^^^^^...r~~~~~~rr...^^^^^^^^^^^^^^...rr~~~~~~r...^^^^^^^^^....rrrr..r^^^^^^",
|
||||
"^^^^^^...rrrr.....^^^^^^^.....~~~~~~r....^^^^^^^^^^^^^^....r~~~~~~.....^^^^^^^.....rrrr...^^^^^^",
|
||||
"^^^^r..rr..r.....rr^^^^^.......~~~~~.....^^^^^^^^^^^^^^.....~~~~~.......^^^^^rr.....r..rr..r^^^^",
|
||||
"^^^r..rrr.......rrr^^^^^........~~~.......^^^^^^^^^^^^.......~~~........^^^^^rrr.......rrr..r^^^",
|
||||
"^^..rrrr.........r.^^^^r.........~........^^^^^^^^^^^^........~.........r^^^^.r.........rrrr..^^",
|
||||
"^r.rrrrr........rr.^^^^..........rrr.......^^^^^^^^^^.......rrr..........^^^^.rr........rrrrr.r^",
|
||||
"..rrrrrr.......rrr..^^^..........rr........r........r........rr..........^^^..rrr.......rrrrrr..",
|
||||
"....rrrr......rrrr..^^...........rr........rr......rr........rr...........^^..rrrr......rrrr....",
|
||||
"...............rrr...............rr........rr......rr........rr...............rrr...............",
|
||||
"................................................................................................",
|
||||
"................................................................................................",
|
||||
"................................................................................................",
|
||||
"................................................................................................",
|
||||
".............................rrr................................rrr.............................",
|
||||
"...................MM........rrr................................rrr........MM...................",
|
||||
"...................MM.........rrr..............................rrr.........MM...................",
|
||||
"................................r..............................r................................",
|
||||
"................................................................................................",
|
||||
"................................................................................................",
|
||||
".....MM.................~~................~~~~~~~~~~~~................~~.................MM.....",
|
||||
".....MM................~~~~.............~~~~~~~~~~~~~~~~.............~~~~................MM.....",
|
||||
".......................~~~~.............~~~~~~~~~~~~~~~~.............~~~~.......................",
|
||||
"......................r~~~~............~~~~~~~~~~~~~~~~~~............~~~~r......................",
|
||||
"........MM....MM......r~~~~............~~~~~~~~~~~~~~~~~~............~~~~r......MM....MM........",
|
||||
"........MM....MM......r~~~~............r~~~~~~~~~~~~~~~~r............~~~~r......MM....MM........",
|
||||
"r........rrr............~~~...............~~~~~~~~~~~~...............~~~............rrr........r",
|
||||
".........rrr............~~............................................~~............rrr.........",
|
||||
"..........r..........................................................................r..........",
|
||||
".........~~~~~....................................................................~~~~~.........",
|
||||
"........~~~~~~....................................................................~~~~~~........",
|
||||
"........~~~~~~....r..........................................................r....~~~~~~........",
|
||||
"^....rr.~~~~~~~...rr^^^..................................................^^^rr...~~~~~~~.rr....^",
|
||||
"^^...rr.~~~~~~~...rr^^^...................^^^^^^^^^^^^...................^^^rr...~~~~~~~.rr...^^",
|
||||
"^^......~~~~~~~...rr^^^^.................^^^^^^^^^^^^^^.................^^^^rr...~~~~~~~......^^",
|
||||
"^^......~~~~~~~r....^^^^.................^^^^^^^^^^^^^^.................^^^^....r~~~~~~~......^^",
|
||||
"^^......~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~......^^",
|
||||
"^^r.....~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~.....r^^",
|
||||
"^^......~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~......^^",
|
||||
"^^.....~~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~~.....^^",
|
||||
"^^....r~~~~~~~~.....^^^...................^^^^^^^^^^^^...................^^^.....~~~~~~~~r....^^",
|
||||
"^.r..rr~~~~~~~~.....^^^...................^^^^^^^^^^^^...................^^^.....~~~~~~~~rr..r.^",
|
||||
"^.rrr..~~~~~~~~.....^^^.............rr....^^^^^^^^^^^^....rr.............^^^.....~~~~~~~~..rrr.^",
|
||||
"..rrr..~~~~~~~~....r^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^r....~~~~~~~~..rrr..",
|
||||
"..rrr..~~~~~~~~.....^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^.....~~~~~~~~..rrr..",
|
||||
"..rrr..~~~~~~~~~...r^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^r...~~~~~~~~~..rrr..",
|
||||
"..rrr..~~~~~~~~~....^^^..............rrrrr^^^^^^^^^^^^rrrrr..............^^^....~~~~~~~~~..rrr..",
|
||||
"..rrr..~~~~~~~~.....^^^...............rrrr^^^^^^^^^^^^rrrr...............^^^.....~~~~~~~~..rrr..",
|
||||
"..rrr..~~~~~~~~.....^^^...............rrr.^^^^^^^^^^^^.rrr...............^^^.....~~~~~~~~..rrr..",
|
||||
"^.rrr...~~~~~~~.....^^^................r..^^^^^^^^^^^^..r................^^^.....~~~~~~~...rrr.^",
|
||||
"^^rrr....~~~~~......^^....................^^^^^^^^^^^^....................^^......~~~~~....rrr^^",
|
||||
"^^r.......................................^^^^^^^^^^^^.......................................r^^",
|
||||
"^^^.......................................^^^^^^^^^^^^.......................................^^^",
|
||||
"^^^.........................................^......^.........................................^^^",
|
||||
"^^^^.........................^^^^^^^^^^^................^^^^^^^^^^^.........................^^^^",
|
||||
"^^^^........................^^^^^^^^^^^^^..............^^^^^^^^^^^^^........................^^^^",
|
||||
"^^^^^.......................^^^^^^^^^^^^^..............^^^^^^^^^^^^^.......................^^^^^",
|
||||
"^^^^^.......................^^^^^^^^^^^^^.....r..r.....^^^^^^^^^^^^^.......................^^^^^",
|
||||
"^^^^^.......................^^^^^^^^^^^^^....rrrrrr....^^^^^^^^^^^^^.......................^^^^^",
|
||||
"^^^^.........................^^^^^^^^^^......rrrrrr......^^^^^^^^^^.........................^^^^",
|
||||
"^^r...............................r..........rrrrrr..........r...............................r^^",
|
||||
".r.....................r................................................r.....................r.",
|
||||
".............rr.......rr~~............................................~~rr.......rr.............",
|
||||
"r........rrrrrrr.......~~~~.......r..........................r.......~~~~.......rrrrrrr........r"
|
||||
],
|
||||
"starts": [
|
||||
{
|
||||
"army": 0,
|
||||
"x": 10,
|
||||
"y": 48
|
||||
},
|
||||
{
|
||||
"army": 1,
|
||||
"x": 85,
|
||||
"y": 48
|
||||
}
|
||||
],
|
||||
"buildings": [
|
||||
{
|
||||
"army": 0,
|
||||
"type": "energygen",
|
||||
"tx": 13,
|
||||
"ty": 46
|
||||
},
|
||||
{
|
||||
"army": 0,
|
||||
"type": "energygen",
|
||||
"tx": 7,
|
||||
"ty": 46
|
||||
},
|
||||
{
|
||||
"army": 0,
|
||||
"type": "barracks",
|
||||
"tx": 14,
|
||||
"ty": 51
|
||||
},
|
||||
{
|
||||
"army": 1,
|
||||
"type": "energygen",
|
||||
"tx": 88,
|
||||
"ty": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "m06",
|
||||
"name": "Annihilation",
|
||||
"theme": "snowfields",
|
||||
"seed": 63541,
|
||||
"playerArmy": "arm",
|
||||
"playerCommander": "vance",
|
||||
"enemies": [
|
||||
{
|
||||
"army": "core",
|
||||
"commander": "klaxon",
|
||||
"aiProfile": {
|
||||
"skill": 5,
|
||||
"aggression": 0.85
|
||||
}
|
||||
}
|
||||
],
|
||||
"startResources": [
|
||||
{
|
||||
"mass": 3200,
|
||||
"energy": 5200
|
||||
},
|
||||
{
|
||||
"mass": 1800,
|
||||
"energy": 3000
|
||||
}
|
||||
],
|
||||
"objective": {
|
||||
"type": "destroyAll"
|
||||
},
|
||||
"briefing": [
|
||||
{
|
||||
"speaker": "ethel",
|
||||
"mood": "idle",
|
||||
"text": "KLAXON at full strength with a base already standing. Nothing clever left — out-build it and end it."
|
||||
}
|
||||
],
|
||||
"victoryLine": {
|
||||
"speaker": "ethel",
|
||||
"mood": "happy",
|
||||
"text": "Total annihilation. There is nothing left to fight."
|
||||
},
|
||||
"defeatLine": {
|
||||
"speaker": "klaxon",
|
||||
"mood": "smug",
|
||||
"text": "KLAXON was simply better prepared."
|
||||
},
|
||||
"map": {
|
||||
"w": 96,
|
||||
"h": 96,
|
||||
"theme": "snowfields",
|
||||
"rows": [
|
||||
"................^^^...^^^^^.............^^^^^^.......r...rr....r.........r....r...........rrr...",
|
||||
"..............MM.^^..r^^^^^..............^^^^^............................................rrr...",
|
||||
"..............MM......^^^^^^...............rr..............................................rr...",
|
||||
"......................^^^^^^..............r...............................................rrr...",
|
||||
"....................................................................rrr...................rrr...",
|
||||
"........................rr..........................................rrrr.....................r..",
|
||||
"........................rr.........................................^..rr.......rr............rr.",
|
||||
"................................................................r^^^^^..........rr...........rr.",
|
||||
".......................~~~.......................................^^^^^^.......................rr",
|
||||
"......................~~~~~.....................................^^^^^^^^........................",
|
||||
"......................~~~~~......................................^^^^^^^........................",
|
||||
"......................~~~~.....rr..............................rrrr...........................rr",
|
||||
".......................~~......rr...........................rrrrr.......................rr....rr",
|
||||
"...............................r...rr.................rr....rrrr.........................r....rr",
|
||||
"................................rrrrr................rrr.....rrr................................",
|
||||
"...............MM................rrrr..............rrrr.......rrr...............................",
|
||||
"...............MM...............rrr...............rrrrr...rr..rr................................",
|
||||
"......r..........................rr..r............rrrr....rrrrrr................rrr............r",
|
||||
"r....MM.............................rrr.........................................rrr.............",
|
||||
".....MM..............................rr...........................~~~~~~~~~~~~~~~~~.............",
|
||||
".......................^^^^..rr......rr...r~~~..................~~~~~~~~~~~~~~~~~~~~............",
|
||||
".........MM...........^^^^^.rrr......rr...~~~~~................~~~~~~~~~~~~~~~~~~~~~~~..........",
|
||||
".........MM.........^^^^^^^rrrr..........~~~~~~~...............~~~~~~~~~~~~~~~~~~~~~~~~..rr....r",
|
||||
"^^................r^^^^^rr...rr..........~~~~~~~...............~~~~~~~~~~~~~~~~~~~~~~~~.rrrrrrrr",
|
||||
"^^^...............^^^^^..................~~~~~~~.......rr......~~~~~~~~~~~~~~~~~~~~~~~~~.rrrr...",
|
||||
"^^^................^^^^...................~~~~~........rr.......~~~~~~~~~~~~~~~~~~~~~~~~~~......",
|
||||
"r..................^^^^.........................................~~~~~~~~~~~~~~~~~~~~~~~~~~~~....",
|
||||
"r..................rr^............................................~~~~~~~~~~~~~~~~~~~~~~~~~~~~..",
|
||||
".......................................................................~~~~~~~~~~~~~~~~~~~~~~~~.",
|
||||
"...rr..................rr.................................................~~~~~~~~~~~~~~~~~~~~~~",
|
||||
"r..rr..................rrr.......................................................~~~~~~~~~~~~~~~",
|
||||
"r..rr..................rrrr........................................................~~~~~~~~~~~~~",
|
||||
"...rr....................rr..........rr....................r........................~~~~~~~~~~~~",
|
||||
"..........................rr.........rr...................rrr.......................~~~~~~~~~~~~",
|
||||
"..........................rr......................rr.......r........................r~~~~~~~~~~~",
|
||||
".........................rrrr...rr................rr.................................r~~~~~~~~~~",
|
||||
".........................rrrr...rr..r.............rr.................................rrr~~~~~~..",
|
||||
".........................r.r.....rrrrrr.............................................rrrrrrrrr...",
|
||||
".................................rr..rr................^^...........................rr.rr.......",
|
||||
"................................rr...................^^^^^..rr..................................",
|
||||
".....rrr............~~..........rr..............^^^^^^^^^^..rr..................................",
|
||||
".....rrr.......rr...~~..........rr........^^^^^^^^^^^^^^^^..rr..................................",
|
||||
".......rr......rr..~~~..........rr.......^^^^^^^^^^^^^^^^^..rr..................................",
|
||||
"........rr.........~~~..................^^^^^^^^^^^^^^^^^^..rrrr..............^.................",
|
||||
"........rr.........~~~~.................^^^^^^^^^^^^^^^^^^^.rrrr.............^^^................",
|
||||
"...................~~~~................^^^^^^^^^^^^^^^^^^^..rrr..............^^^rrr......rrrrrrr",
|
||||
"...................~~~..................^^^^^^^^^^^^^^^^^^..rr...............^^^rrr......rrrrrrr",
|
||||
"....................~~..................^^^^^^^^^^^^^^^^......................^................r",
|
||||
"r................^......................^^^^^^^^^^^^^^^^..................~~....................",
|
||||
"rrrrrrr......rrr^^^...............rr..^^^^^^^^^^^^^^^^^^..................~~~...................",
|
||||
"rrrrrrr......rrr^^^..............rrr..^^^^^^^^^^^^^^^^^^^................~~~~...................",
|
||||
"................^^^.............rrrr.^^^^^^^^^^^^^^^^^^^.................~~~~.........rr........",
|
||||
".................^..............rrrr..^^^^^^^^^^^^^^^^^^..................~~~.........rr........",
|
||||
"..................................rr..^^^^^^^^^^^^^^^^^.......rr..........~~~..rr......rr.......",
|
||||
"..................................rr..^^^^^^^^^^^^^^^^........rr..........~~...rr.......rrr.....",
|
||||
"..................................rr..^^^^^^^^^^..............rr..........~~............rrr.....",
|
||||
"..................................rr..^^^^^...................rr................................",
|
||||
".......rr.rr...........................^^................rr..rr.................................",
|
||||
"...rrrrrrrrr.............................................rrrrrr.....r.r.........................",
|
||||
"..~~~~~~rrr.................................rr.............r..rr...rrrr.........................",
|
||||
"~~~~~~~~~~r.................................rr................rr...rrrr.........................",
|
||||
"~~~~~~~~~~~r........................r.......rr......................rr..........................",
|
||||
"~~~~~~~~~~~~.......................rrr...................rr.........rr..........................",
|
||||
"~~~~~~~~~~~~........................r....................rr..........rr....................rr...",
|
||||
"~~~~~~~~~~~~~........................................................rrrr..................rr..r",
|
||||
"~~~~~~~~~~~~~~~.......................................................rrr..................rr..r",
|
||||
"~~~~~~~~~~~~~~~~~~~~~~.................................................rr..................rr...",
|
||||
".~~~~~~~~~~~~~~~~~~~~~~~~.......................................................................",
|
||||
"..~~~~~~~~~~~~~~~~~~~~~~~~~~~~............................................^rr..................r",
|
||||
"....~~~~~~~~~~~~~~~~~~~~~~~~~~~~.........................................^^^^..................r",
|
||||
"......~~~~~~~~~~~~~~~~~~~~~~~~~~.......rr........~~~~~...................^^^^................^^^",
|
||||
"...rrrr.~~~~~~~~~~~~~~~~~~~~~~~~~......rr.......~~~~~~~..................^^^^^...............^^^",
|
||||
"rrrrrrrr.~~~~~~~~~~~~~~~~~~~~~~~~...............~~~~~~~..........rr...rr^^^^^r................^^",
|
||||
"r....rr..~~~~~~~~~~~~~~~~~~~~~~~~...............~~~~~~~..........rrrr^^^^^^^.........MM.........",
|
||||
"..........~~~~~~~~~~~~~~~~~~~~~~~................~~~~~...rr......rrr.^^^^^...........MM.........",
|
||||
"............~~~~~~~~~~~~~~~~~~~~..................~~~r...rr......rr..^^^^.......................",
|
||||
".............~~~~~~~~~~~~~~~~~...........................rr..............................MM.....",
|
||||
".............rrr.........................................rrr.............................MM....r",
|
||||
"r............rrr................rrrrrr....rrrr............r..rr..........................r......",
|
||||
"................................rr..rr...rrrrr...............rrr...............MM...............",
|
||||
"...............................rrr.......rrrr..............rrrr................MM...............",
|
||||
"................................rrr.....rrr................rrrrr................................",
|
||||
"rr....r.........................rrrr....rr.................rr...r...............................",
|
||||
"rr....rr.......................rrrrr...........................rr......~~.......................",
|
||||
"rr...........................rrrr..............................rr.....~~~~......................",
|
||||
"........................^^^^^^^......................................~~~~~......................",
|
||||
"........................^^^^^^^^.....................................~~~~~......................",
|
||||
"rr.......................^^^^^^.......................................~~~.......................",
|
||||
".rr...........rr..........^^^^^r................................................................",
|
||||
".rr............rr.......rr..^.........................................rr........................",
|
||||
"..r.....................rrrr..........................................rr........................",
|
||||
"...rrr...................rrr....................................................................",
|
||||
"...rrr...............................................r..............^^^^^^......................",
|
||||
"...rr..............................................rr...............^^^^^^......MM..............",
|
||||
"...rrr............................................^^^^^..............^^^^^r..^^.MM..............",
|
||||
"...rrr...........r....r.........r....rr...r.......^^^^^^.............^^^^^...^^^................"
|
||||
],
|
||||
"starts": [
|
||||
{
|
||||
"army": 0,
|
||||
"x": 10,
|
||||
"y": 10
|
||||
},
|
||||
{
|
||||
"army": 1,
|
||||
"x": 85,
|
||||
"y": 85
|
||||
}
|
||||
],
|
||||
"buildings": [
|
||||
{
|
||||
"army": 0,
|
||||
"type": "energygen",
|
||||
"tx": 13,
|
||||
"ty": 7
|
||||
},
|
||||
{
|
||||
"army": 0,
|
||||
"type": "energygen",
|
||||
"tx": 7,
|
||||
"ty": 7
|
||||
},
|
||||
{
|
||||
"army": 0,
|
||||
"type": "barracks",
|
||||
"tx": 14,
|
||||
"ty": 13
|
||||
},
|
||||
{
|
||||
"army": 0,
|
||||
"type": "vehicleplant",
|
||||
"tx": 6,
|
||||
"ty": 13
|
||||
},
|
||||
{
|
||||
"army": 1,
|
||||
"type": "energygen",
|
||||
"tx": 88,
|
||||
"ty": 82
|
||||
},
|
||||
{
|
||||
"army": 1,
|
||||
"type": "barracks",
|
||||
"tx": 89,
|
||||
"ty": 88
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ function memFor(state, armyIdx) {
|
|||
m = state.aiMem[armyIdx] = {
|
||||
nextThinkTick: 0,
|
||||
ordersThisSecond: 0, secondMark: 0,
|
||||
squad: [], squadPeak: 0, phase: 'build',
|
||||
squad: [], squadPeak: 0, phase: 'build', focusId: 0,
|
||||
scoutId: 0,
|
||||
knownEnemies: [], // [{x,y,defId,isBuilding,tick}]
|
||||
lastAttackTick: -99999,
|
||||
|
|
@ -46,7 +46,10 @@ export function runAI(rules, state, armyIdx, profile = {}) {
|
|||
if (!army || !army.alive) return;
|
||||
|
||||
const skill = Math.max(1, Math.min(5, profile.skill ?? army.aiSkill ?? 3));
|
||||
const tuning = rules.aiBySkill[skill] ?? rules.aiSkills[rules.aiSkills.length - 1];
|
||||
const base = rules.aiBySkill[skill] ?? rules.aiSkills[rules.aiSkills.length - 1];
|
||||
// `tune` lets a mission (or a tuning experiment) override individual dials without
|
||||
// inventing a whole new skill row.
|
||||
const tuning = profile.tune ? { ...base, ...profile.tune } : base;
|
||||
const aggression = profile.aggression ?? army.aiProfile?.aggression ?? 0.5;
|
||||
// Economic ambition is primarily a SKILL trait — tuning.expansion runs 0.6..3.0 across
|
||||
// skills 1-5, normalised here to 0.2..1.0. A commander's own aiProfile only nudges it,
|
||||
|
|
@ -68,7 +71,7 @@ export function runAI(rules, state, armyIdx, profile = {}) {
|
|||
mem.ordersThisSecond = 0;
|
||||
}
|
||||
const ctx = {
|
||||
rules, state, armyIdx, army, skill, tuning, aggression, expansion, mem,
|
||||
rules, state, armyIdx, army, skill, tuning, aggression, expansion, mem, profile,
|
||||
budget: Math.max(1, tuning.apmCap),
|
||||
};
|
||||
|
||||
|
|
@ -165,7 +168,7 @@ function manageEconomyAndBase(ctx, mine) {
|
|||
}
|
||||
|
||||
function chooseBuilding(ctx, mine) {
|
||||
const { rules, army, mem, expansion } = ctx;
|
||||
const { rules, army, mem, expansion, skill } = ctx;
|
||||
const n = (id) => (mine.byDef[id] ?? 0);
|
||||
const eGen = rules.buildingById.energygen;
|
||||
const mGen = rules.buildingById.massgen;
|
||||
|
|
@ -180,6 +183,19 @@ function chooseBuilding(ctx, mine) {
|
|||
if (n('massgen') < 2) return mGen;
|
||||
if (n('vehicleplant') < 1) return plant;
|
||||
|
||||
// Skills 1-2 stop here: a short fixed ladder and no governor at all. Without this the
|
||||
// bottom of the ladder still runs the same ratio-aware economy as the top and plays a
|
||||
// perfectly respectable game, which is why difficulty barely registered.
|
||||
if (skill <= 2) {
|
||||
const lim = skill === 1 ? 1 : 2;
|
||||
if (n('energygen') < 3 + lim) return eGen;
|
||||
if (n('massgen') < 2 + lim) return mGen;
|
||||
if (n('barracks') + n('vehicleplant') < 2 + lim) {
|
||||
return n('vehicleplant') <= n('barracks') ? plant : barracks;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Steady state: chase the energy:mass income RATIO that the unit roster actually costs.
|
||||
// A Tank is 700 energy to 180 mass, so roughly 4:1 — an AI that treats the two resources
|
||||
// symmetrically ends up capping out on energy while its factories starve for mass.
|
||||
|
|
@ -196,19 +212,33 @@ function chooseBuilding(ctx, mine) {
|
|||
|
||||
if (army.stallM < 0.98 && roomM) return mGen;
|
||||
if (army.stallE < 0.98 && roomE) return eGen;
|
||||
|
||||
// Production capacity is grown ALONGSIDE the economy, sized to what the income can
|
||||
// actually feed. Saturating generators first (the obvious ordering) is why a big economy
|
||||
// used to win nothing: with only two factories the surplus just floated in storage, and an
|
||||
// army that out-earned its opponent two to one still fielded the same number of tanks.
|
||||
const factories = n('barracks') + n('vehicleplant');
|
||||
const nextFactory = () => (n('vehicleplant') <= n('barracks') ? plant : barracks);
|
||||
const factoryCap = Math.round(2 + expansion * 4);
|
||||
const supported = Math.floor(army.mIncome / Math.max(0.5, factoryMassDrain(rules, plant)));
|
||||
if (factories < Math.min(factoryCap, Math.max(1, supported))) return nextFactory();
|
||||
|
||||
if (ratio > TARGET_RATIO && roomM) return mGen;
|
||||
if (ratio < TARGET_RATIO && roomE) return eGen;
|
||||
if (roomM) return mGen;
|
||||
if (roomE) return eGen;
|
||||
|
||||
// Economy is built out — convert the surplus into production capacity.
|
||||
const factories = n('barracks') + n('vehicleplant');
|
||||
if (factories < Math.round(2 + expansion * 3)) {
|
||||
return n('vehicleplant') <= n('barracks') ? plant : barracks;
|
||||
}
|
||||
if (factories < factoryCap) return nextFactory();
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Mass per second a factory of this type burns when kept busy. */
|
||||
function factoryMassDrain(rules, def) {
|
||||
const units = (def.builds ?? []).map((id) => rules.unitById[id]).filter(Boolean);
|
||||
if (!units.length) return 1;
|
||||
const avg = units.reduce((s, u) => s + u.cost.mass / u.buildTime, 0) / units.length;
|
||||
return avg * (def.buildPower / rules.constants.buildPowerNominal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spiral outward from the base for a legal footprint. Mass Generators additionally hunt for
|
||||
* a metal patch, which is what makes map control matter without a reclaim economy.
|
||||
|
|
@ -301,8 +331,8 @@ function randomPick(ctx, builds) {
|
|||
|
||||
/** Pick whichever buildable unit is furthest below its share of the desired mix. */
|
||||
function templatePick(ctx, mine, builds) {
|
||||
const { rules, skill } = ctx;
|
||||
const weights = { ...COMPOSITION };
|
||||
const { rules, skill, profile } = ctx;
|
||||
const weights = { ...COMPOSITION, ...(profile.composition ?? {}) };
|
||||
if (skill >= 4) applyCounters(ctx, weights);
|
||||
|
||||
const total = builds.reduce((s, id) => s + (mine.byDef[id] ?? 0), 0) + 1;
|
||||
|
|
@ -424,8 +454,8 @@ function manageMilitary(ctx, mine) {
|
|||
mem.squad = mem.squad.filter((id) => state.entities.some((e) => e.id === id && !e.dead));
|
||||
for (const u of idle) if (!mem.squad.includes(u.id)) mem.squad.push(u.id);
|
||||
|
||||
// Home defence always wins over pushing out.
|
||||
const threat = nearestThreatToBase(ctx);
|
||||
// Home defence always wins over pushing out — skill 1 never thinks to come home.
|
||||
const threat = skill >= 2 ? nearestThreatToBase(ctx) : null;
|
||||
if (threat) {
|
||||
const defenders = mem.squad.slice(0, Math.max(3, Math.ceil(mem.squad.length * 0.7)));
|
||||
if (defenders.length) {
|
||||
|
|
@ -507,27 +537,40 @@ function focusFire(ctx, mine) {
|
|||
.filter(Boolean);
|
||||
if (members.length < 3) return;
|
||||
|
||||
const cx = members.reduce((s, u) => s + u.x, 0) / members.length;
|
||||
const cy = members.reduce((s, u) => s + u.y, 0) / members.length;
|
||||
// An `attack` order REPLACES the unit's queue and pins its target, so re-picking one every
|
||||
// think interrupts the push and leaves the squad ignoring whatever is actually shooting it.
|
||||
// Focus fire is therefore sticky: hold the current victim until it dies or gets out of
|
||||
// reach, and only ever commit to something the squad can already shoot without moving.
|
||||
const focus = state.entities.find((e) => e.id === mem.focusId && !e.dead);
|
||||
if (focus && isVisibleTo(state, armyIdx, focus)) {
|
||||
const stillOn = members.filter((u) => inReach(rules, u, focus)).length;
|
||||
if (stillOn >= 2) return; // current focus is fine; don't churn orders
|
||||
}
|
||||
mem.focusId = 0;
|
||||
|
||||
let best = null, bestScore = -Infinity;
|
||||
let best = null, bestScore = -Infinity, bestShooters = null;
|
||||
for (const e of state.entities) {
|
||||
if (e.dead || e.army === armyIdx || !state.armies[e.army]) continue;
|
||||
if (!isVisibleTo(state, armyIdx, e)) continue;
|
||||
const d = Math.hypot(e.x - cx, e.y - cy);
|
||||
if (d > state.tileSize * 9) continue;
|
||||
const shooters = members.filter((u) => inReach(rules, u, e));
|
||||
if (shooters.length < Math.max(2, Math.ceil(members.length * 0.4))) continue;
|
||||
const def = rules.defById[e.defId];
|
||||
// Prefer the closest thing to dying that can still shoot back.
|
||||
const score = (def.weaponDefs?.length ? 2 : 1) * (1 - e.hp / e.maxHp + 0.3) * (1 - d / (state.tileSize * 9));
|
||||
if (score > bestScore) { best = e; bestScore = score; }
|
||||
const score = (def.weaponDefs?.length ? 2 : 1) * (1.3 - e.hp / e.maxHp) * shooters.length;
|
||||
if (score > bestScore) { best = e; bestScore = score; bestShooters = shooters; }
|
||||
}
|
||||
if (!best) return;
|
||||
const shooters = members.filter((u) => Math.hypot(u.x - best.x, u.y - best.y) < rules.defById[u.defId].maxRange * 1.5);
|
||||
if (shooters.length < 2) return;
|
||||
mem.focusId = best.id;
|
||||
order(ctx, {
|
||||
army: armyIdx, unitIds: shooters.map((u) => u.id),
|
||||
army: armyIdx, unitIds: bestShooters.map((u) => u.id),
|
||||
order: { type: 'attack', targetId: best.id },
|
||||
});
|
||||
}
|
||||
|
||||
/** True when `u` could fire on `t` from where it already stands. */
|
||||
function inReach(rules, u, t) {
|
||||
const def = rules.defById[u.defId];
|
||||
return Math.hypot(u.x - t.x, u.y - t.y) - t.radius <= def.maxRange;
|
||||
}
|
||||
|
||||
export { memFor };
|
||||
|
|
|
|||
|
|
@ -32,7 +32,18 @@ function frameRng(seed) {
|
|||
* Create a canvas texture laid out as a spritesheet and register its frames.
|
||||
* `at(frame, draw)` translates the context to that frame's origin; `finish()` uploads.
|
||||
*/
|
||||
function mkCanvasSheet(scene, key, frameW, frameH, cols, count) {
|
||||
function mkCanvasSheet(scene, key, wantW, wantH, wantCols, wantCount) {
|
||||
// Guard every dimension. A NaN anywhere here reaches createCanvas as a zero-height texture
|
||||
// and surfaces as an opaque WebGL "no canvas" error a long way from the actual mistake.
|
||||
const size = (v, fallback) => (Number.isFinite(v) && v > 0 ? Math.floor(v) : fallback);
|
||||
const frameW = size(wantW, 64);
|
||||
const frameH = size(wantH, 64);
|
||||
const cols = size(wantCols, 8);
|
||||
const count = size(wantCount, 1);
|
||||
if (frameW !== wantW || frameH !== wantH || cols !== wantCols || count !== wantCount) {
|
||||
console.warn(`[TAArt] sheet "${key}" has a bad layout (${wantW}x${wantH}, cols ${wantCols}, `
|
||||
+ `frames ${wantCount}) — falling back to ${frameW}x${frameH}, cols ${cols}, frames ${count}`);
|
||||
}
|
||||
const rows = Math.max(1, Math.ceil(count / cols));
|
||||
const tex = scene.textures.createCanvas(key, cols * frameW, rows * frameH);
|
||||
const ctx = tex.getContext();
|
||||
|
|
@ -361,7 +372,11 @@ const PROC_PAINTERS = {
|
|||
const theme = Object.values(art.themes ?? {}).find((t) => t.sheet === sheetName);
|
||||
const p = theme?.palette ?? {};
|
||||
const F = art.terrainFrames ?? {};
|
||||
const count = Math.max(9, ...Object.values(F).map((v) => v + 1));
|
||||
// The JSON carries a `_comment` key alongside the frame numbers, so filter to actual
|
||||
// numbers — one string in this list turns the max into NaN and the sheet into a
|
||||
// zero-height canvas.
|
||||
const frameNums = Object.values(F).filter((v) => Number.isFinite(v));
|
||||
const count = Math.max(9, ...frameNums.map((v) => v + 1));
|
||||
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
|
||||
|
||||
const speckle = (ctx, S, seed, color, n, r0, r1) => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export default class TAFx {
|
|||
// Under the actors: trails, nanolathe streams, scorch. Over them: tracers and blasts.
|
||||
this.gUnder = scene.add.graphics().setDepth(depths.fxUnder);
|
||||
this.gOver = scene.add.graphics().setDepth(depths.fxOver);
|
||||
// Depth on a Container child does nothing until the container is sorted; TAWorldView
|
||||
// re-sorts the world container every frame, which is what puts these layers in place.
|
||||
worldRoot.add(this.gUnder);
|
||||
worldRoot.add(this.gOver);
|
||||
this.list = [];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,398 @@
|
|||
// Total Annihilation — screen-space HUD.
|
||||
//
|
||||
// Everything here lives on `view.uiRoot` so the UI camera draws it and the world camera
|
||||
// ignores it (TAWorldView._setupCameras). The HUD owns no game rules: it reads state and
|
||||
// calls back into the scene, which is the only thing allowed to issue orders.
|
||||
//
|
||||
// The resource strip is the important piece. A flow economy is unreadable without showing
|
||||
// the RATES and the stall factor — "1200 mass" tells a player nothing, "+18 −24, STALL 74%"
|
||||
// tells them to build another Mass Generator.
|
||||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
||||
import { canAfford, buildOptionsFor } from './TALogic.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
|
||||
export const FONT = 'm6x11, "Julius Sans One"';
|
||||
|
||||
const PANEL = 0x141a24;
|
||||
const EDGE = 0x39465e;
|
||||
const TEXT = '#e8eef7';
|
||||
const DIM = '#8fa0b8';
|
||||
|
||||
const BAR_H = 62; // top resource strip
|
||||
const BOT_H = 190; // bottom command bar
|
||||
const MINI = 176; // minimap edge length
|
||||
const BTN_W = 88, BTN_H = 74; // build-menu button size
|
||||
|
||||
/** Build-button captions have ~7 characters of room; "Vehicle Plant" needs shortening. */
|
||||
function shortName(name) {
|
||||
const words = name.split(' ');
|
||||
if (words.length > 1) return words.map((w) => w[0]).join('').toUpperCase();
|
||||
return name.length > 8 ? name.slice(0, 8) : name;
|
||||
}
|
||||
|
||||
export default class TAHud {
|
||||
constructor(scene, rules, state, view, playerArmy, handlers) {
|
||||
this.scene = scene;
|
||||
this.rules = rules;
|
||||
this.state = state;
|
||||
this.view = view;
|
||||
this.playerArmy = playerArmy;
|
||||
this.h = handlers; // { onBuildPick, onProduce, onCommand, onMinimapJump }
|
||||
this.buildButtons = [];
|
||||
this.tooltip = new Tooltip(scene);
|
||||
this.root = scene.add.container(0, 0).setDepth(1000);
|
||||
view.uiRoot.add(this.root);
|
||||
|
||||
this._buildTopStrip();
|
||||
this._buildBottomBar();
|
||||
this._buildMinimap();
|
||||
this._pulse = 0;
|
||||
}
|
||||
|
||||
/** Screen rects the camera must NOT pan from — clicks here are UI, not world orders. */
|
||||
get blockRects() {
|
||||
return [
|
||||
new Phaser.Geom.Rectangle(0, 0, GAME_WIDTH, BAR_H),
|
||||
new Phaser.Geom.Rectangle(0, GAME_HEIGHT - BOT_H, GAME_WIDTH, BOT_H),
|
||||
];
|
||||
}
|
||||
|
||||
hitsUi(x, y) {
|
||||
return this.blockRects.some((r) => Phaser.Geom.Rectangle.Contains(r, x, y));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Top strip: MASS and ENERGY as stored/cap plus flow rates
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_buildTopStrip() {
|
||||
const s = this.scene;
|
||||
const bg = s.add.rectangle(0, 0, GAME_WIDTH, BAR_H, PANEL, 0.94).setOrigin(0, 0);
|
||||
bg.setStrokeStyle(2, EDGE);
|
||||
this.root.add(bg);
|
||||
|
||||
this.res = {};
|
||||
const mk = (key, label, x, color) => {
|
||||
const g = s.add.graphics();
|
||||
const name = s.add.text(x, 10, label, { fontFamily: FONT, fontSize: '20px', color: DIM });
|
||||
const amount = s.add.text(x, 32, '', { fontFamily: FONT, fontSize: '22px', color: TEXT });
|
||||
const flow = s.add.text(x + 300, 32, '', { fontFamily: FONT, fontSize: '22px', color: TEXT });
|
||||
this.root.add([g, name, amount, flow]);
|
||||
this.res[key] = { g, name, amount, flow, x, color };
|
||||
};
|
||||
mk('mass', 'MASS', 28, 0x9fb4cf);
|
||||
mk('energy', 'ENERGY', GAME_WIDTH / 2 - 240, 0xf2c744);
|
||||
|
||||
this.stallText = s.add.text(GAME_WIDTH - 28, 20, '', {
|
||||
fontFamily: FONT, fontSize: '26px', color: '#ff6b5a',
|
||||
}).setOrigin(1, 0);
|
||||
this.clock = s.add.text(GAME_WIDTH - 28, 4, '', {
|
||||
fontFamily: FONT, fontSize: '20px', color: DIM,
|
||||
}).setOrigin(1, 0);
|
||||
this.root.add([this.stallText, this.clock]);
|
||||
}
|
||||
|
||||
_drawResource(key, stored, cap, income, drain, stall) {
|
||||
const r = this.res[key];
|
||||
const w = 260, h = 12, y = 16;
|
||||
r.g.clear();
|
||||
r.g.fillStyle(0x0b0e14, 1);
|
||||
r.g.fillRect(r.x + 84, y, w, h);
|
||||
const frac = cap > 0 ? Math.max(0, Math.min(1, stored / cap)) : 0;
|
||||
r.g.fillStyle(r.color, 1);
|
||||
r.g.fillRect(r.x + 84, y, w * frac, h);
|
||||
r.g.lineStyle(1, EDGE, 1);
|
||||
r.g.strokeRect(r.x + 84, y, w, h);
|
||||
r.amount.setText(`${Math.round(stored)} / ${Math.round(cap)}`);
|
||||
const net = income - drain;
|
||||
r.flow.setText(`+${income.toFixed(1)} -${drain.toFixed(1)}`);
|
||||
r.flow.setColor(net < -0.05 ? '#ff9a6b' : '#9ce6a0');
|
||||
// Empty and still draining is the state the player must notice.
|
||||
const critical = stall < 0.98 && stored < cap * 0.02;
|
||||
r.amount.setColor(critical ? '#ff6b5a' : TEXT);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Bottom bar: selection roster, build/production grid, command buttons
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_buildBottomBar() {
|
||||
const s = this.scene;
|
||||
const y = GAME_HEIGHT - BOT_H;
|
||||
const bg = s.add.rectangle(0, y, GAME_WIDTH, BOT_H, PANEL, 0.94).setOrigin(0, 0);
|
||||
bg.setStrokeStyle(2, EDGE);
|
||||
this.root.add(bg);
|
||||
|
||||
this.selTitle = s.add.text(MINI + 60, y + 12, '', {
|
||||
fontFamily: FONT, fontSize: '26px', color: TEXT,
|
||||
});
|
||||
this.selDetail = s.add.text(MINI + 60, y + 44, '', {
|
||||
fontFamily: FONT, fontSize: '19px', color: DIM, lineSpacing: 3,
|
||||
});
|
||||
this.queueText = s.add.text(MINI + 60, y + 120, '', {
|
||||
fontFamily: FONT, fontSize: '19px', color: '#9ce6a0',
|
||||
});
|
||||
this.hint = s.add.text(GAME_WIDTH - 24, y + BOT_H - 26, '', {
|
||||
fontFamily: FONT, fontSize: '17px', color: DIM,
|
||||
}).setOrigin(1, 0.5);
|
||||
this.root.add([this.selTitle, this.selDetail, this.queueText, this.hint]);
|
||||
|
||||
this.gridX = GAME_WIDTH - 24 - 6 * 96;
|
||||
this.gridY = y + 16;
|
||||
}
|
||||
|
||||
_clearGrid() {
|
||||
for (const b of this.buildButtons) b.destroy();
|
||||
this.buildButtons.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repaint the build/production grid for the current selection. Buttons carry the def so
|
||||
* the scene can tell "start placing a building" from "enqueue a unit" without re-deriving.
|
||||
*/
|
||||
_drawGrid(options, kind) {
|
||||
this._clearGrid();
|
||||
const s = this.scene;
|
||||
const army = this.state.armies[this.playerArmy];
|
||||
options.forEach((def, i) => {
|
||||
const col = i % 6, row = Math.floor(i / 6);
|
||||
// The container is placed at the button's CENTRE, not its top-left — see below.
|
||||
const x = this.gridX + col * 96 + BTN_W / 2;
|
||||
const y = this.gridY + row * 82 + BTN_H / 2;
|
||||
const affordable = canAfford(this.state, this.playerArmy, def);
|
||||
const c = s.add.container(x, y);
|
||||
// A Container's origin is hard-coded to 0.5 and cannot be changed, so setInteractive()
|
||||
// always builds a hit area CENTRED on the container's position. Children drawn from a
|
||||
// top-left origin therefore sit half a button down-right of their own hitbox. Every
|
||||
// child here is centred on (0,0) so the visual and the hit area coincide.
|
||||
const box = s.add.rectangle(0, 0, BTN_W, BTN_H, affordable ? 0x22304a : 0x1a2130, 1)
|
||||
.setStrokeStyle(2, affordable ? EDGE : 0x2a3244);
|
||||
const label = s.add.text(0, -BTN_H / 2 + 12, shortName(def.name), {
|
||||
fontFamily: FONT, fontSize: '20px', color: affordable ? TEXT : '#5d6b80',
|
||||
}).setOrigin(0.5, 0);
|
||||
const cost = s.add.text(0, -BTN_H / 2 + 44, `${def.cost.mass}m ${def.cost.energy}e`, {
|
||||
fontFamily: FONT, fontSize: '15px', color: affordable ? DIM : '#4e5a6d',
|
||||
}).setOrigin(0.5, 0);
|
||||
c.add([box, label, cost]);
|
||||
c.setSize(BTN_W, BTN_H);
|
||||
c.setInteractive({ useHandCursor: true });
|
||||
c.on('pointerover', () => box.setFillStyle(affordable ? 0x2e3f5f : 0x1a2130, 1));
|
||||
c.on('pointerout', () => box.setFillStyle(affordable ? 0x22304a : 0x1a2130, 1));
|
||||
this.tooltip.attachTo(c, () => this._defTip(def));
|
||||
c.on('pointerdown', (p, lx, ly, ev) => {
|
||||
ev?.stopPropagation();
|
||||
if (kind === 'build') this.h.onBuildPick(def);
|
||||
else this.h.onProduce(def, p.event?.shiftKey ? 5 : 1);
|
||||
});
|
||||
this.root.add(c);
|
||||
this.buildButtons.push(c);
|
||||
});
|
||||
}
|
||||
|
||||
_defTip(def) {
|
||||
const lines = [`${def.cost.mass} mass ${def.cost.energy} energy ${def.buildTime}s`];
|
||||
if (def.hp) lines.push(`HP ${def.hp}`);
|
||||
if (def.speed) lines.push(`Speed ${def.speed} Sight ${def.sight}`);
|
||||
for (const w of def.weaponDefs ?? []) {
|
||||
lines.push(`${w.name}: ${w.damage} dmg / ${w.reload}s / ${w.range} range${w.manual ? ' (manual)' : ''}`);
|
||||
}
|
||||
if (def.makes) {
|
||||
const m = [];
|
||||
if (def.makes.mass) m.push(`+${def.makes.mass} mass/s`);
|
||||
if (def.makes.energy) m.push(`+${def.makes.energy} energy/s`);
|
||||
if (m.length) lines.push(m.join(' '));
|
||||
}
|
||||
if (def.upkeep?.energy) lines.push(`Upkeep ${def.upkeep.energy} energy/s`);
|
||||
if (def.terrainMultiplier) lines.push('Build on a metal patch for double yield');
|
||||
if (def.builds?.length) lines.push(`Builds: ${def.builds.join(', ')}`);
|
||||
return { title: def.name, lines: lines.map((text) => ({ text })) };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Minimap — a canvas texture rebaked a few times a second, dots stroked live
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_buildMinimap() {
|
||||
const s = this.scene;
|
||||
const x = 20, y = GAME_HEIGHT - BOT_H + 8;
|
||||
this.miniX = x; this.miniY = y;
|
||||
const key = `ta-minimap-${s.scene.key}`;
|
||||
if (s.textures.exists(key)) s.textures.remove(key);
|
||||
this.miniTex = s.textures.createCanvas(key, this.state.w, this.state.h);
|
||||
this.miniImg = s.add.image(x, y, key).setOrigin(0, 0)
|
||||
.setDisplaySize(MINI, MINI).setInteractive({ useHandCursor: true });
|
||||
this.miniImg.texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
|
||||
this.miniG = s.add.graphics();
|
||||
this.root.add([this.miniImg, this.miniG]);
|
||||
|
||||
const jump = (p) => {
|
||||
const fx = Phaser.Math.Clamp((p.x - x) / MINI, 0, 1);
|
||||
const fy = Phaser.Math.Clamp((p.y - y) / MINI, 0, 1);
|
||||
this.h.onMinimapJump(fx * this.state.worldW, fy * this.state.worldH);
|
||||
};
|
||||
this.miniImg.on('pointerdown', (p) => { this._miniDrag = true; jump(p); });
|
||||
s.input.on('pointerup', () => { this._miniDrag = false; });
|
||||
s.input.on('pointermove', (p) => { if (this._miniDrag) jump(p); });
|
||||
this._miniBakedAt = -1e9;
|
||||
}
|
||||
|
||||
_bakeMinimap(timeMs) {
|
||||
if (timeMs - this._miniBakedAt < 250) return;
|
||||
this._miniBakedAt = timeMs;
|
||||
const st = this.state, ctx = this.miniTex.context;
|
||||
const img = ctx.createImageData(st.w, st.h);
|
||||
const pal = this.rules.terrain.map((t) => {
|
||||
const hex = this.view.themePalette?.[t.id] ?? '#404040';
|
||||
const c = parseInt(String(hex).slice(1), 16);
|
||||
return [(c >> 16) & 255, (c >> 8) & 255, c & 255];
|
||||
});
|
||||
const army = st.armies[this.playerArmy];
|
||||
for (let ty = 0; ty < st.h; ty++) {
|
||||
for (let tx = 0; tx < st.w; tx++) {
|
||||
const i = ty * st.w + tx;
|
||||
const [r, g, b] = pal[st.terrain[i]] ?? [64, 64, 64];
|
||||
// The vision grid is half tile resolution, and unexplored must read as black:
|
||||
// a minimap that ignores fog is just a wallhack with extra steps.
|
||||
const v = (ty >> 1) * st.visW + (tx >> 1);
|
||||
const k = army.explored[v] ? (army.visible[v] ? 1 : 0.45) : 0;
|
||||
const o = i * 4;
|
||||
img.data[o] = r * k; img.data[o + 1] = g * k; img.data[o + 2] = b * k; img.data[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
this.miniTex.refresh();
|
||||
}
|
||||
|
||||
_drawMinimapOverlay() {
|
||||
const g = this.miniG, st = this.state;
|
||||
g.clear();
|
||||
const sx = MINI / st.worldW, sy = MINI / st.worldH;
|
||||
for (const e of st.entities) {
|
||||
if (e.dead) continue;
|
||||
if (e.army !== this.playerArmy && !this.view.visibleToPlayer(e)) continue;
|
||||
const a = this.rules.armies[e.army];
|
||||
g.fillStyle(a ? a.colorInt : 0x888888, 1);
|
||||
const r = e.isBuilding ? 2.5 : 1.6;
|
||||
g.fillRect(this.miniX + e.x * sx - r, this.miniY + e.y * sy - r, r * 2, r * 2);
|
||||
}
|
||||
const cam = this.scene.cameras.main;
|
||||
g.lineStyle(1.5, 0xffffff, 0.8);
|
||||
g.strokeRect(
|
||||
this.miniX + cam.worldView.x * sx, this.miniY + cam.worldView.y * sy,
|
||||
cam.worldView.width * sx, cam.worldView.height * sy,
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Per-frame update
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
update(timeMs, selection, placementDef) {
|
||||
const st = this.state, army = st.armies[this.playerArmy];
|
||||
if (!army) return;
|
||||
|
||||
this._drawResource('mass', army.mass, army.massCap, army.mIncome, army.mDrain * army.stallM, army.stallM);
|
||||
this._drawResource('energy', army.energy, army.energyCap, army.eIncome, (army.eDrain * army.stallE) + army.eUpkeep * army.upkeepFactor, army.stallE);
|
||||
|
||||
const eff = army.buildEff ?? 1;
|
||||
if (eff < 0.98) {
|
||||
this._pulse = (this._pulse + 0.12) % (Math.PI * 2);
|
||||
this.stallText.setText(`STALL ${Math.round(eff * 100)}%`);
|
||||
this.stallText.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(this._pulse)));
|
||||
} else {
|
||||
this.stallText.setText('');
|
||||
}
|
||||
const t = Math.floor(st.elapsedSec);
|
||||
this.clock.setText(`${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`);
|
||||
|
||||
this._bakeMinimap(timeMs);
|
||||
this._drawMinimapOverlay();
|
||||
this._refreshSelection(selection, placementDef);
|
||||
}
|
||||
|
||||
_refreshSelection(selection, placementDef) {
|
||||
const sel = [...selection];
|
||||
const sig = sel.map((e) => e.id).join(',') + '|' + (placementDef?.id ?? '')
|
||||
+ '|' + sel.map((e) => e.queue?.length ?? 0).join(',')
|
||||
+ '|' + Math.floor((this.state.armies[this.playerArmy]?.mass ?? 0) / 25);
|
||||
if (sig === this._selSig) { this._refreshQueue(sel); return; }
|
||||
this._selSig = sig;
|
||||
|
||||
if (!sel.length) {
|
||||
this.selTitle.setText('');
|
||||
this.selDetail.setText('');
|
||||
this.queueText.setText('');
|
||||
this.hint.setText('LMB select · drag box · RMB order · A attack-move · X stop · H hold · WASD pan · Ctrl+1-9 groups');
|
||||
this._clearGrid();
|
||||
return;
|
||||
}
|
||||
|
||||
const counts = new Map();
|
||||
for (const e of sel) {
|
||||
const def = this.rules.defById[e.defId];
|
||||
counts.set(def.name, (counts.get(def.name) ?? 0) + 1);
|
||||
}
|
||||
this.selTitle.setText([...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(' '));
|
||||
|
||||
const lead = sel[0];
|
||||
const def = this.rules.defById[lead.defId];
|
||||
const lines = [];
|
||||
if (sel.length === 1) {
|
||||
lines.push(`HP ${Math.ceil(lead.hp)} / ${lead.maxHp}`);
|
||||
if (lead.site) lines.push(`Under construction — ${Math.round((lead.progress ?? 0) * 100)}%`);
|
||||
if (!lead.site && lead.hasRally) lines.push('Rally point set');
|
||||
for (const w of def.weaponDefs ?? []) lines.push(`${w.name}${w.manual ? ' — manual (attack order)' : ''}`);
|
||||
} else {
|
||||
const hp = sel.reduce((s, e) => s + e.hp, 0), max = sel.reduce((s, e) => s + e.maxHp, 0);
|
||||
lines.push(`${sel.length} units HP ${Math.ceil(hp)} / ${max}`);
|
||||
}
|
||||
this.selDetail.setText(lines.join('\n'));
|
||||
|
||||
if (placementDef) {
|
||||
this.hint.setText(`Placing ${placementDef.name} — LMB to site it, Esc to cancel`);
|
||||
} else {
|
||||
this.hint.setText('LMB select · drag box · RMB order · A attack-move · X stop · H hold · WASD pan · Ctrl+1-9 groups');
|
||||
}
|
||||
|
||||
// Grid shows the builder's structures, or the factory's units — never both.
|
||||
const builder = sel.find((e) => !e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length);
|
||||
const factory = sel.find((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length);
|
||||
if (builder) this._drawGrid(buildOptionsFor(this.rules, builder), 'build');
|
||||
else if (factory) this._drawGrid(buildOptionsFor(this.rules, factory), 'produce');
|
||||
else this._clearGrid();
|
||||
}
|
||||
|
||||
_refreshQueue(sel) {
|
||||
const f = sel.find((e) => e.isBuilding && !e.site && e.queue?.length);
|
||||
if (!f) { this.queueText.setText(''); return; }
|
||||
const parts = f.queue.map((q, i) => {
|
||||
const name = this.rules.defById[q.defId].name;
|
||||
const pct = i === 0 ? ` ${Math.round((f.jobProgress ?? 0) * 100)}%` : '';
|
||||
return `${name}${q.count > 1 ? ` ×${q.count}` : ''}${pct}`;
|
||||
});
|
||||
this.queueText.setText(`QUEUE ${parts.join(' | ')}`);
|
||||
}
|
||||
|
||||
toast(msg, color = '#ffd27a') {
|
||||
if (this._toast) this._toast.destroy();
|
||||
this._toast = this.scene.add.text(GAME_WIDTH / 2, BAR_H + 40, msg, {
|
||||
fontFamily: FONT, fontSize: '28px', color,
|
||||
}).setOrigin(0.5, 0);
|
||||
this.view.uiRoot.add(this._toast);
|
||||
this.scene.tweens.add({
|
||||
targets: this._toast, alpha: 0, delay: 1400, duration: 600,
|
||||
onComplete: () => { this._toast?.destroy(); this._toast = null; },
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._clearGrid();
|
||||
this.tooltip?.destroy();
|
||||
this._toast?.destroy();
|
||||
this.miniG?.destroy();
|
||||
this.miniImg?.destroy();
|
||||
this.root.destroy(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -931,6 +931,8 @@ function stepCombat(state, rules) {
|
|||
for (let wi = 0; wi < def.weaponDefs.length; wi++) {
|
||||
const w = def.weaponDefs[wi];
|
||||
if (e.reload[wi] > 0) continue;
|
||||
// Manual weapons (the D-Gun) only fire when this exact target was ordered attacked.
|
||||
if (w.manual && !(holdOrder && e.orders[0].targetId === target.id)) continue;
|
||||
const dist = Math.hypot(dx, dy) - target.radius;
|
||||
if (dist > w.range || dist < (w.minRange ?? 0)) continue;
|
||||
if (!aligned) continue;
|
||||
|
|
@ -949,6 +951,7 @@ function stepCombat(state, rules) {
|
|||
// Continue an in-flight burst.
|
||||
for (let wi = 0; wi < def.weaponDefs.length; wi++) {
|
||||
const w = def.weaponDefs[wi];
|
||||
if (w.manual) continue;
|
||||
if (e.burstLeft[wi] > 0 && e.reload[wi] === 0) {
|
||||
fireWeapon(state, rules, e, w, target, facing);
|
||||
e.burstLeft[wi]--;
|
||||
|
|
@ -1083,16 +1086,30 @@ function stepProjectiles(state, rules) {
|
|||
if (--p.ttl <= 0) detonate = true;
|
||||
if (p.x < 0 || p.y < 0 || p.x > state.worldW || p.y > state.worldH) detonate = true;
|
||||
|
||||
// Proximity to the aim point (ballistic) or the live target (guided).
|
||||
// Collision is SWEPT along this tick's segment, not sampled at the new position. A shell
|
||||
// travels 35px in one 20 Hz tick — well past a 12px hit window and past most unit radii —
|
||||
// so a point test made tank fire pass clean through whatever it was aimed at.
|
||||
if (!detonate) {
|
||||
const hitR = w.kind === 'guided' ? 14 : 12;
|
||||
if (Math.hypot(p.aimX - p.x, p.aimY - p.y) <= hitR) detonate = true;
|
||||
if (segDistSq(p.px, p.py, p.x, p.y, p.aimX, p.aimY) <= hitR * hitR) {
|
||||
detonate = true;
|
||||
// Detonate where the shell actually met the aim point, not where the step ended.
|
||||
const s = segClosestT(p.px, p.py, p.x, p.y, p.aimX, p.aimY);
|
||||
p.x = p.px + (p.x - p.px) * s; p.y = p.py + (p.y - p.py) * s;
|
||||
}
|
||||
}
|
||||
// Direct contact with any hostile it passes through.
|
||||
if (!detonate) {
|
||||
let bestT = Infinity, hit = null;
|
||||
for (const e of state.entities) {
|
||||
if (e.dead || e.site || e.army === p.army) continue;
|
||||
if (Math.hypot(e.x - p.x, e.y - p.y) <= e.radius) { detonate = true; break; }
|
||||
if (segDistSq(p.px, p.py, p.x, p.y, e.x, e.y) > e.radius * e.radius) continue;
|
||||
const s = segClosestT(p.px, p.py, p.x, p.y, e.x, e.y);
|
||||
if (s < bestT) { bestT = s; hit = e; }
|
||||
}
|
||||
if (hit) {
|
||||
detonate = true;
|
||||
p.x = p.px + (p.x - p.px) * bestT; p.y = p.py + (p.y - p.py) * bestT;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1115,6 +1132,22 @@ function stepProjectiles(state, rules) {
|
|||
state.projectiles = keep;
|
||||
}
|
||||
|
||||
/** Parametric position along segment AB of the point on it closest to P, clamped to [0,1]. */
|
||||
function segClosestT(ax, ay, bx, by, px, py) {
|
||||
const dx = bx - ax, dy = by - ay;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (len2 <= 1e-9) return 0;
|
||||
const t = ((px - ax) * dx + (py - ay) * dy) / len2;
|
||||
return t < 0 ? 0 : t > 1 ? 1 : t;
|
||||
}
|
||||
|
||||
/** Squared distance from P to segment AB. */
|
||||
function segDistSq(ax, ay, bx, by, px, py) {
|
||||
const t = segClosestT(ax, ay, bx, by, px, py);
|
||||
const cx = ax + (bx - ax) * t - px, cy = ay + (by - ay) * t - py;
|
||||
return cx * cx + cy * cy;
|
||||
}
|
||||
|
||||
function applyAoe(state, rules, x, y, w, source) {
|
||||
const r = w.aoe;
|
||||
const falloff = w.aoeFalloff ?? 0.4;
|
||||
|
|
@ -1124,7 +1157,10 @@ function applyAoe(state, rules, x, y, w, source) {
|
|||
const d = Math.hypot(e.x - x, e.y - y) - e.radius;
|
||||
if (d > r) continue;
|
||||
const t = Math.max(0, Math.min(1, d / r));
|
||||
const scale = 1 - (1 - falloff) * t;
|
||||
// Quadratic falloff, not linear. Linear left near-full damage on every neighbour of the
|
||||
// unit actually hit, so one shell wiped a whole packed formation and splash weapons beat
|
||||
// everything else on cost regardless of what they were pointed at.
|
||||
const scale = falloff + (1 - falloff) * (1 - t) * (1 - t);
|
||||
const mul = w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1;
|
||||
applyDamage(state, rules, e, w.damage * mul * scale, source);
|
||||
}
|
||||
|
|
@ -1218,21 +1254,27 @@ export function isVisibleTo(state, armyIdx, e) {
|
|||
|
||||
function checkResult(state, rules) {
|
||||
if (state.over) return;
|
||||
const commanderEnds = rules.constants.commanderDeathEnds !== false;
|
||||
const unrecoverableEnds = rules.constants.eliminateWhenUnrecoverable !== false;
|
||||
for (const a of state.armies) {
|
||||
if (!a.alive) continue;
|
||||
let hasAny = false, hasBuilder = false;
|
||||
let hasAny = false, hasBuilder = false, hasFactory = false;
|
||||
for (const e of state.entities) {
|
||||
if (e.dead || e.army !== a.idx || e.site) continue;
|
||||
hasAny = true;
|
||||
const def = rules.defById[e.defId];
|
||||
if (!e.isBuilding && (def.builds ?? []).length) hasBuilder = true;
|
||||
if (!(def.builds ?? []).length) continue;
|
||||
if (e.isBuilding) hasFactory = true; else hasBuilder = true;
|
||||
}
|
||||
// TA's "Commander Ends" rule. Without it a match can sit at the time limit while the
|
||||
// loser hides one Mass Generator in a corner with no way to ever build again.
|
||||
if (!hasAny || (commanderEnds && !hasBuilder)) {
|
||||
// Elimination is "can never produce again", not "lost the Commander". Ending on Commander
|
||||
// death made a single lucky raid decide matches, which flattened the skill ladder — economy
|
||||
// and army quality never got to matter. An army that still owns a factory can rebuild, so it
|
||||
// stays in; one with neither a mobile builder nor a factory is only running down the clock.
|
||||
const unrecoverable = unrecoverableEnds && !hasBuilder && !hasFactory;
|
||||
if (!hasAny || unrecoverable) {
|
||||
a.alive = false;
|
||||
state.events.push({ t: 'armyEliminated', army: a.idx, reason: hasAny ? 'commander' : 'wiped' });
|
||||
state.events.push({
|
||||
t: 'armyEliminated', army: a.idx, reason: hasAny ? 'unrecoverable' : 'wiped',
|
||||
});
|
||||
}
|
||||
}
|
||||
const alive = state.armies.filter((a) => a.alive);
|
||||
|
|
@ -1277,12 +1319,17 @@ export function tick(state, rules) {
|
|||
* in state so the verify script runs the identical loop headlessly, and `state.alpha` gives
|
||||
* the view its interpolation factor between the last two ticks.
|
||||
*/
|
||||
export function step(state, rules, deltaMs, maxSteps = 4) {
|
||||
/**
|
||||
* @param {function} [onTick] runs just BEFORE each fixed step — where the AI belongs, so its
|
||||
* think cadence is measured in sim ticks rather than riding on the render framerate.
|
||||
*/
|
||||
export function step(state, rules, deltaMs, maxSteps = 4, onTick = null) {
|
||||
const out = [];
|
||||
state.accumulatorMs += deltaMs;
|
||||
let n = 0;
|
||||
while (state.accumulatorMs >= rules.stepMs && n < maxSteps) {
|
||||
state.accumulatorMs -= rules.stepMs;
|
||||
if (onTick) onTick(state);
|
||||
const ev = tick(state, rules);
|
||||
for (let i = 0; i < ev.length; i++) out.push(ev[i]);
|
||||
n++;
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export function refreshRegion(nav, rules, x0, y0, x1, y1) {
|
|||
// affect tiles up-left of it — expand the rect that way and sweep in reverse.
|
||||
const cx0 = Math.max(0, x0 - MAX_CLEARANCE);
|
||||
const cy0 = Math.max(0, y0 - MAX_CLEARANCE);
|
||||
nav._fit = null; // clearance moved, so every dilated fit mask is stale
|
||||
for (const mc of nav.moveClassIds) {
|
||||
const pass = nav.passable[mc];
|
||||
const cl = nav.clearance[mc];
|
||||
|
|
@ -123,10 +124,50 @@ export const worldToTileY = (nav, y) => Math.max(0, Math.min(nav.h - 1, (y / nav
|
|||
export const tileCenterX = (nav, tx) => tx * nav.tileSize + nav.tileSize / 2;
|
||||
export const tileCenterY = (nav, ty) => ty * nav.tileSize + nav.tileSize / 2;
|
||||
|
||||
/**
|
||||
* Per-tile "a unit this wide can stand here" mask, cached per move class and need.
|
||||
*
|
||||
* clearance[i] is the largest all-passable square whose TOP-LEFT corner is tile i, so
|
||||
* reading it directly asks "does the square start here" — which wrongly rejects every tile
|
||||
* along the bottom and right edge of an open area, even though a unit centred there fits
|
||||
* perfectly well. A tile is genuinely usable when ANY anchor covering it qualifies, so this
|
||||
* dilates the clearance test by the need window (a separable two-pass OR).
|
||||
*/
|
||||
export function fitField(nav, mc, need) {
|
||||
if (need <= 1) return nav.clearance[mc];
|
||||
const key = `${mc}:${need}`;
|
||||
if (!nav._fit) nav._fit = Object.create(null);
|
||||
const hit = nav._fit[key];
|
||||
if (hit) return hit;
|
||||
|
||||
const { w, h } = nav;
|
||||
const cl = nav.clearance[mc];
|
||||
const rowPass = new Uint8Array(w * h);
|
||||
for (let y = 0; y < h; y++) {
|
||||
let run = 0; // how many of the last `need` tiles to the left qualify as anchors
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = y * w + x;
|
||||
run = cl[i] >= need ? need : (run > 0 ? run - 1 : 0);
|
||||
rowPass[i] = run > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
const out = new Uint8Array(w * h);
|
||||
for (let x = 0; x < w; x++) {
|
||||
let run = 0;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const i = y * w + x;
|
||||
run = rowPass[i] ? need : (run > 0 ? run - 1 : 0);
|
||||
out[i] = run > 0 ? need : 0;
|
||||
}
|
||||
}
|
||||
nav._fit[key] = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Is this tile usable by a unit with the given move class and clearance need? */
|
||||
export function tileOk(nav, mc, need, tx, ty) {
|
||||
if (tx < 0 || ty < 0 || tx >= nav.w || ty >= nav.h) return false;
|
||||
return nav.clearance[mc][ty * nav.w + tx] >= need;
|
||||
return fitField(nav, mc, need)[ty * nav.w + tx] >= need;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -216,7 +257,7 @@ const NEIGHBOR_DY = [0, 0, 1, -1, 1, -1, 1, -1];
|
|||
*/
|
||||
export function findPath(nav, mc, need, startIdx, goalIdx, expansionCap = 20000) {
|
||||
if (startIdx === goalIdx) return Int32Array.of(startIdx);
|
||||
const cl = nav.clearance[mc];
|
||||
const cl = fitField(nav, mc, need);
|
||||
const cost = nav.cost[mc];
|
||||
if (cl[goalIdx] < need || cl[startIdx] < need) return null;
|
||||
|
||||
|
|
@ -297,7 +338,7 @@ function reconstruct(came, startIdx, goalIdx) {
|
|||
*/
|
||||
export function segmentClear(nav, mc, need, x0, y0, x1, y1) {
|
||||
const ts = nav.tileSize;
|
||||
const cl = nav.clearance[mc];
|
||||
const cl = fitField(nav, mc, need);
|
||||
let tx = Math.floor(x0 / ts), ty = Math.floor(y0 / ts);
|
||||
const tx1 = Math.floor(x1 / ts), ty1 = Math.floor(y1 / ts);
|
||||
const dx = x1 - x0, dy = y1 - y0;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,16 @@ function requirePositive(obj, keys, what) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquisition range. Manual weapons are excluded so a Commander doesn't chase enemies into
|
||||
* D-Gun range it will never voluntarily use; a manual-only def falls back to its full reach
|
||||
* so it still knows when something is worth shooting at.
|
||||
*/
|
||||
function autoRangeOf(weaponDefs) {
|
||||
const auto = weaponDefs.filter((w) => !w.manual);
|
||||
return (auto.length ? auto : weaponDefs).reduce((m, w) => Math.max(m, w.range), 0);
|
||||
}
|
||||
|
||||
function indexById(list) {
|
||||
const m = Object.create(null);
|
||||
for (const e of list) m[e.id] = e;
|
||||
|
|
@ -163,6 +173,10 @@ export function compileRules(json) {
|
|||
w.rangeSq = w.range * w.range;
|
||||
w.minRangeSq = (w.minRange ?? 0) * (w.minRange ?? 0);
|
||||
w.targetsAir = (w.targets ?? ['ground']).includes('air');
|
||||
// A manual weapon never auto-fires: it needs an explicit attack order on the victim.
|
||||
// The D-Gun is the reason this flag exists — left on auto-fire, a defending Commander
|
||||
// deletes one attacker every reload for free, which makes assaulting any base suicide.
|
||||
w.manual = w.manual === true;
|
||||
}
|
||||
const weaponById = indexById(weapons);
|
||||
|
||||
|
|
@ -193,7 +207,7 @@ export function compileRules(json) {
|
|||
u.sightSq = u.sight * u.sight;
|
||||
u.isBuilding = false;
|
||||
u.weaponDefs = (u.weapons ?? []).map((wid) => weaponById[wid]);
|
||||
u.maxRange = u.weaponDefs.reduce((m, w) => Math.max(m, w.range), 0);
|
||||
u.maxRange = autoRangeOf(u.weaponDefs);
|
||||
u.spritePx = u.spritePx ?? sc.radius * 2;
|
||||
buildable[u.id] = u;
|
||||
}
|
||||
|
|
@ -221,7 +235,7 @@ export function compileRules(json) {
|
|||
b.sight = b.sight ?? 0;
|
||||
b.sightSq = b.sight * b.sight;
|
||||
b.weaponDefs = (b.weapons ?? []).map((wid) => weaponById[wid]);
|
||||
b.maxRange = b.weaponDefs.reduce((m, w) => Math.max(m, w.range), 0);
|
||||
b.maxRange = autoRangeOf(b.weaponDefs);
|
||||
buildable[b.id] = b;
|
||||
}
|
||||
const buildingById = indexById(buildings);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -61,9 +61,9 @@ export default class TAWorldView {
|
|||
this.gSelection = scene.add.graphics().setDepth(DEPTHS.selection);
|
||||
this.gBars = scene.add.graphics().setDepth(DEPTHS.bars);
|
||||
this.gGhost = scene.add.graphics().setDepth(DEPTHS.ghost);
|
||||
this.worldRoot.add(this.gSelection);
|
||||
this.worldRoot.add(this.gBars);
|
||||
this.worldRoot.add(this.gGhost);
|
||||
this._addWorld(this.gSelection);
|
||||
this._addWorld(this.gBars);
|
||||
this._addWorld(this.gGhost);
|
||||
|
||||
this.sprites = new Map(); // entity id -> { img, turret }
|
||||
this.selection = new Set();
|
||||
|
|
@ -90,6 +90,22 @@ export default class TAWorldView {
|
|||
cam.ignore(this.uiRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a child to the world container.
|
||||
*
|
||||
* Phaser Containers render their children in INSERTION order and never sort by depth on
|
||||
* their own — `sortChildrenFlag` exists on DisplayList and Layer, not Container. So depth
|
||||
* here is inert until `sort('depth')` runs, which is why the fog image (built in the
|
||||
* constructor, long before the terrain chunks it must cover) was drawn underneath the map
|
||||
* and the whole battlefield was visible. Anything added mid-frame — a lazily painted
|
||||
* terrain chunk, a new unit sprite — re-dirties the order.
|
||||
*/
|
||||
_addWorld(obj) {
|
||||
this.worldRoot.add(obj);
|
||||
this._layerDirty = true;
|
||||
return obj;
|
||||
}
|
||||
|
||||
/** Keep screen-space extras (music controls, fullscreen button) off the world camera. */
|
||||
ignoreOnWorldCam(objs) {
|
||||
if (!objs?.length) return;
|
||||
|
|
@ -103,22 +119,50 @@ export default class TAWorldView {
|
|||
|
||||
centerOn(x, y) { this.scene.cameras.main.centerOn(x, y); }
|
||||
|
||||
/**
|
||||
* Zoom one step, keeping whatever the cursor is over pinned under the cursor.
|
||||
*
|
||||
* The scroll is solved directly from the camera's screen->world mapping rather than by
|
||||
* diffing two getWorldPoint() calls around the zoom change. getWorldPoint inverts
|
||||
* `camera.matrix`, and that matrix is only rebuilt in preRender — so the second call reads
|
||||
* the OLD zoom while the camera already holds the new one, and the correction it yields is
|
||||
* wrong. Solving it makes the anchor exact and independent of when in the frame this runs.
|
||||
*
|
||||
* At the edges of the map the scroll clamps to the camera bounds, so the anchor gives way
|
||||
* rather than letting the view slide off the world.
|
||||
*/
|
||||
zoomBy(dir, focusX, focusY) {
|
||||
const cam = this.scene.cameras.main;
|
||||
const next = Math.max(0, Math.min(ZOOMS.length - 1, this.zoomIdx + dir));
|
||||
if (next === this.zoomIdx) return;
|
||||
// Zoom toward the cursor: keep the world point under the pointer pinned.
|
||||
const before = cam.getWorldPoint(focusX, focusY);
|
||||
const anchor = this.worldPoint(focusX, focusY);
|
||||
this.zoomIdx = next;
|
||||
cam.setZoom(ZOOMS[next]);
|
||||
const after = cam.getWorldPoint(focusX, focusY);
|
||||
cam.setScroll(cam.scrollX + (before.x - after.x), cam.scrollY + (before.y - after.y));
|
||||
const z = ZOOMS[next];
|
||||
cam.setZoom(z);
|
||||
cam.setScroll(
|
||||
anchor.x - cam.width / 2 + cam.width / (2 * z) - focusX / z,
|
||||
anchor.y - cam.height / 2 + cam.height / (2 * z) - focusY / z,
|
||||
);
|
||||
}
|
||||
|
||||
get zoom() { return this.scene.cameras.main.zoom; }
|
||||
|
||||
/**
|
||||
* Screen -> world, from the camera's live scroll and zoom.
|
||||
*
|
||||
* Deliberately not Camera.getWorldPoint(): that inverts a matrix rebuilt only during
|
||||
* preRender, so it reports a stale transform for any call made after the camera has moved
|
||||
* earlier in the same frame — which is every click, since camera panning runs first in
|
||||
* update(). This mirrors Camera.preRender's own maths:
|
||||
* worldView.x = scrollX + width/2 - width/(2*zoom), worldX = worldView.x + screenX/zoom
|
||||
*/
|
||||
worldPoint(screenX, screenY) {
|
||||
return this.scene.cameras.main.getWorldPoint(screenX, screenY);
|
||||
const cam = this.scene.cameras.main;
|
||||
const z = cam.zoom;
|
||||
return {
|
||||
x: cam.scrollX + cam.width / 2 - cam.width / (2 * z) + screenX / z,
|
||||
y: cam.scrollY + cam.height / 2 - cam.height / (2 * z) + screenY / z,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -142,7 +186,7 @@ export default class TAWorldView {
|
|||
const w = Math.min(CHUNK_PX, state.worldW - ox);
|
||||
const h = Math.min(CHUNK_PX, state.worldH - oy);
|
||||
const rt = this.scene.add.renderTexture(ox, oy, w, h).setOrigin(0, 0).setDepth(DEPTHS.terrain);
|
||||
this.worldRoot.add(rt);
|
||||
this._addWorld(rt);
|
||||
|
||||
const t0 = Math.floor(ox / ts), t1 = Math.ceil((ox + w) / ts);
|
||||
const r0 = Math.floor(oy / ts), r1 = Math.ceil((oy + h) / ts);
|
||||
|
|
@ -219,7 +263,7 @@ export default class TAWorldView {
|
|||
// Linear filtering upscales the coarse grid into a soft gradient for the cost of a few
|
||||
// hundred pixels of texture — far cheaper than a per-tile fog RenderTexture.
|
||||
this.fogTex.setFilter(1); // Phaser.Textures.FilterMode.LINEAR
|
||||
this.worldRoot.add(this.fogImg);
|
||||
this._addWorld(this.fogImg);
|
||||
this.fogDirty = true;
|
||||
}
|
||||
|
||||
|
|
@ -277,14 +321,14 @@ export default class TAWorldView {
|
|||
} else {
|
||||
img.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
|
||||
}
|
||||
this.worldRoot.add(img);
|
||||
this._addWorld(img);
|
||||
|
||||
let turret = null;
|
||||
if (!def.isBuilding && def.turretFrame != null) {
|
||||
turret = this.scene.add.image(e.x, e.y, key, def.turretFrame);
|
||||
turret.setTint(sheets.color);
|
||||
turret.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
|
||||
this.worldRoot.add(turret);
|
||||
this._addWorld(turret);
|
||||
}
|
||||
s = { img, turret, defId: e.defId, site: e.site };
|
||||
this.sprites.set(e.id, s);
|
||||
|
|
@ -371,6 +415,11 @@ export default class TAWorldView {
|
|||
|
||||
this._drawPlacementGhost();
|
||||
|
||||
// Actor depth is recomputed every frame from Y, so the container has to be re-sorted
|
||||
// every frame too — not just when a child is added.
|
||||
this.worldRoot.sort('depth');
|
||||
this._layerDirty = false;
|
||||
|
||||
// Nanolathe links: builder -> whatever it is working on.
|
||||
const nanoLinks = [];
|
||||
for (const e of state.entities) {
|
||||
|
|
@ -432,7 +481,7 @@ export default class TAWorldView {
|
|||
if (!on) return;
|
||||
const { state, rules, ts } = this;
|
||||
const g = this.scene.add.graphics().setDepth(DEPTHS.decal);
|
||||
this.worldRoot.add(g);
|
||||
this._addWorld(g);
|
||||
g.lineStyle(2, 0xffd27a, 0.85);
|
||||
for (let ty = 0; ty < state.h; ty++) {
|
||||
for (let tx = 0; tx < state.w; tx++) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,665 @@
|
|||
// Total Annihilation — the Phaser scene.
|
||||
//
|
||||
// A thin coordinator: it owns the fixed-step loop, input bindings and screen routing, and
|
||||
// holds NO game rules. Every state change goes through TALogic.issueOrder, the same entry
|
||||
// point the AI uses — which is what lets tools/verifyTotalAnnihilation.js bot the human side
|
||||
// and assert a campaign mission is actually winnable.
|
||||
//
|
||||
// The loop is TALogic.step(state, rules, deltaMs): the accumulator lives inside the headless
|
||||
// state (the StarControlLogic.stepMatch idiom), so the verify script drives an identical loop
|
||||
// in Node with a synthetic delta and gets identical results.
|
||||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { getGameSoundtrack } from '../../services/soundtrack.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import { compileRules } from './TARules.js';
|
||||
import { generateMap, decodeMap } from './TAMapGen.js';
|
||||
import * as Logic from './TALogic.js';
|
||||
import { runAI } from './TAAI.js';
|
||||
import TAWorldView, { DEPTHS } from './TAWorldView.js';
|
||||
import TAFx from './TAFx.js';
|
||||
import TAHud from './TAHud.js';
|
||||
import * as Screens from './TAScreens.js';
|
||||
|
||||
const SAVE_KEY = 'totalannihilation-save';
|
||||
const SETTINGS_KEY = 'totalannihilation-settings';
|
||||
const EDGE_PAN = 26; // screen-edge scroll band, px
|
||||
const PAN_SPEED = 1100; // px/s at zoom 1
|
||||
const DRAG_MIN = 8; // px before a click becomes a drag-box
|
||||
|
||||
export default class TotalAnnihilationGame extends Phaser.Scene {
|
||||
constructor() { super('TotalAnnihilationGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game ?? { slug: 'totalannihilation', name: 'Total Annihilation' };
|
||||
this.phase = 'menu'; // menu | setup | briefing | playing | paused | result
|
||||
this.match = null;
|
||||
this.view = null;
|
||||
this.hud = null;
|
||||
this.fx = null;
|
||||
this.screen = null;
|
||||
this.selection = new Set();
|
||||
this.groups = new Map();
|
||||
this.placement = null;
|
||||
this.pendingCommand = null; // 'attackMove' | 'patrol' | 'guard' | 'attack'
|
||||
this.simSpeed = 1;
|
||||
this.settings = readJson(SETTINGS_KEY, { edgeScroll: true, fog: true });
|
||||
}
|
||||
|
||||
create() {
|
||||
try {
|
||||
const { tracks, volume } = getGameSoundtrack(this);
|
||||
if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
|
||||
} catch (_) { /* soundtrack is optional */ }
|
||||
|
||||
this.input.mouse?.disableContextMenu();
|
||||
|
||||
this.rules = compileRules(this.cache.json.get('totalannihilation-rules'));
|
||||
this.art = this.cache.json.get('totalannihilation-artwork');
|
||||
this.campaign = this.cache.json.get('totalannihilation-campaign') ?? { missions: [] };
|
||||
|
||||
this.keys = this.input.keyboard.addKeys(
|
||||
'W,A,S,D,X,H,P,G,Q,E,ESC,SPACE,UP,DOWN,LEFT,RIGHT,SHIFT,CTRL',
|
||||
);
|
||||
this._bindInput();
|
||||
|
||||
// Commander portraits come from the shared opponent roster; a fetch failure just means
|
||||
// the setup screen falls back to name-only cards.
|
||||
this.oppById = {};
|
||||
fetch('data/opponents.json')
|
||||
.then((r) => r.json())
|
||||
.then((d) => { for (const o of d.opponents ?? []) this.oppById[o.id] = o; })
|
||||
.catch(() => { /* portraits are optional */ });
|
||||
|
||||
this.events.once('shutdown', () => this._teardown());
|
||||
this.showMenu();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Screen routing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
swapScreen(next) {
|
||||
this.screen?.destroy();
|
||||
this.screen = next ?? null;
|
||||
}
|
||||
|
||||
showMenu() {
|
||||
this.phase = 'menu';
|
||||
this._endMatch();
|
||||
this.swapScreen(Screens.mainMenu(this, {
|
||||
hasSave: !!localStorage.getItem(SAVE_KEY),
|
||||
onSkirmish: () => this.showSkirmishSetup(),
|
||||
onCampaign: () => this.showCampaign(),
|
||||
onContinue: () => this.loadSave(),
|
||||
onLeave: () => this.scene.start('GameMenu'),
|
||||
}));
|
||||
}
|
||||
|
||||
showSkirmishSetup() {
|
||||
this.phase = 'setup';
|
||||
this.swapScreen(Screens.skirmishSetup(this, {
|
||||
onBack: () => this.showMenu(),
|
||||
onStart: (cfg) => this.startSkirmish(cfg),
|
||||
}));
|
||||
}
|
||||
|
||||
showCampaign() {
|
||||
this.phase = 'setup';
|
||||
api.get(`/puzzles/${this.gameDef.slug}/progress`)
|
||||
.then((p) => p?.levelsCompleted ?? 0)
|
||||
.catch(() => 0)
|
||||
.then((cleared) => {
|
||||
this.swapScreen(Screens.campaignList(this, this.campaign, cleared, {
|
||||
onBack: () => this.showMenu(),
|
||||
onPick: (idx) => this.startMission(idx),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Match lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
startSkirmish(cfg) {
|
||||
const map = generateMap(this.rules, {
|
||||
seed: cfg.seed, size: cfg.size, theme: cfg.theme, symmetry: cfg.symmetry,
|
||||
armies: cfg.armies.length,
|
||||
});
|
||||
const state = Logic.createMatch(this.rules, {
|
||||
seed: cfg.seed, map,
|
||||
armies: cfg.armies.map((a, i) => ({
|
||||
armyId: a.armyId, commanderId: a.commanderId,
|
||||
isHuman: i === 0, aiSkill: a.skill,
|
||||
aiProfile: this.rules.commanderById?.[a.commanderId]?.aiProfile ?? null,
|
||||
})),
|
||||
});
|
||||
this.meta = { mode: 'skirmish', cfg };
|
||||
this._beginMatch(state, 0);
|
||||
}
|
||||
|
||||
startMission(idx) {
|
||||
const m = this.campaign.missions[idx];
|
||||
if (!m) return this.showMenu();
|
||||
const map = decodeMap(this.rules, m.map);
|
||||
const state = Logic.createMatch(this.rules, {
|
||||
seed: m.seed ?? 1234, map,
|
||||
armies: [
|
||||
{ armyId: m.playerArmy, commanderId: m.playerCommander, isHuman: true },
|
||||
...(m.enemies ?? []).map((e) => ({
|
||||
armyId: e.army, commanderId: e.commander,
|
||||
aiSkill: e.aiProfile?.skill ?? 3, aiProfile: e.aiProfile ?? null,
|
||||
})),
|
||||
],
|
||||
});
|
||||
(m.startResources ?? []).forEach((r, i) => {
|
||||
if (!state.armies[i]) return;
|
||||
state.armies[i].mass = r.mass ?? state.armies[i].mass;
|
||||
state.armies[i].energy = r.energy ?? state.armies[i].energy;
|
||||
});
|
||||
this.meta = { mode: 'campaign', missionIdx: idx };
|
||||
this.mission = m;
|
||||
this._beginMatch(state, 0);
|
||||
}
|
||||
|
||||
_beginMatch(state, playerArmy) {
|
||||
this.swapScreen(null);
|
||||
this.match = state;
|
||||
this.playerArmy = playerArmy;
|
||||
this.selection.clear();
|
||||
this.groups.clear();
|
||||
this.placement = null;
|
||||
|
||||
this.view = new TAWorldView(this, this.rules, this.art, state, playerArmy);
|
||||
this.view.setFogEnabled(this.settings.fog !== false);
|
||||
this.fx = new TAFx(this, this.view.worldRoot, DEPTHS);
|
||||
this.hud = new TAHud(this, this.rules, state, this.view, playerArmy, {
|
||||
onBuildPick: (def) => this._beginPlacement(def),
|
||||
onProduce: (def, n) => this._enqueue(def, n),
|
||||
onMinimapJump: (x, y) => this.view.centerOn(x, y),
|
||||
});
|
||||
if (this.music?._objs?.length) this.view.ignoreOnWorldCam(this.music._objs);
|
||||
|
||||
const start = state.starts.find((s) => s.army === playerArmy);
|
||||
if (start) this.view.centerOn(start.x * state.tileSize, start.y * state.tileSize);
|
||||
|
||||
this.phase = 'playing';
|
||||
this._lastAutosave = this.time.now;
|
||||
}
|
||||
|
||||
_endMatch() {
|
||||
this.hud?.destroy(); this.hud = null;
|
||||
this.fx?.destroy(); this.fx = null;
|
||||
this.view?.destroy(); this.view = null;
|
||||
this.match = null;
|
||||
this.selection.clear();
|
||||
}
|
||||
|
||||
_teardown() {
|
||||
this._endMatch();
|
||||
this.screen?.destroy();
|
||||
this.music?.destroy?.();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The loop
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
update(time, delta) {
|
||||
if (this.phase !== 'playing' || !this.match) return;
|
||||
const st = this.match;
|
||||
|
||||
this._updateCamera(delta);
|
||||
|
||||
// The AI thinks on sim ticks, not render frames, so it is stepped inside the same
|
||||
// fixed-step loop the simulation uses — otherwise its cadence would ride framerate.
|
||||
const before = st.tick;
|
||||
const events = Logic.step(st, this.rules, delta * this.simSpeed, 4, (s) => {
|
||||
for (const a of s.armies) {
|
||||
if (!a.alive || a.isHuman) continue;
|
||||
runAI(this.rules, s, a.idx, { skill: a.aiSkill, ...(a.aiProfile ?? {}) });
|
||||
}
|
||||
});
|
||||
if (st.tick !== before) this.view.fogDirty = true;
|
||||
|
||||
for (const ev of events) this._onSimEvent(ev);
|
||||
|
||||
const { nanoLinks, projectiles } = this.view.render(st.alpha);
|
||||
this.fx.draw(delta, nanoLinks, projectiles, time);
|
||||
this._syncSelection();
|
||||
this.hud.update(time, this._selectedEntities(), this.placement?.def ?? null);
|
||||
this._drawDragBox();
|
||||
|
||||
if (this.meta?.mode === 'campaign' && time - this._lastAutosave > 60000) {
|
||||
this._lastAutosave = time;
|
||||
this.saveGame();
|
||||
}
|
||||
if (st.over) this._finish(st.over);
|
||||
}
|
||||
|
||||
_onSimEvent(ev) {
|
||||
this.fx.onEvent(ev, this.rules);
|
||||
if (ev.t === 'buildingComplete' && ev.army === this.playerArmy) {
|
||||
// Terrain under a finished structure changes, so its chunk has to be restamped.
|
||||
this.view.repaintArea(ev.x - 128, ev.y - 128, ev.x + 128, ev.y + 128);
|
||||
}
|
||||
if (ev.t === 'armyEliminated' && ev.army !== this.playerArmy) {
|
||||
this.hud.toast(`${this.rules.armies[ev.army]?.name ?? 'Enemy'} eliminated`, '#9ce6a0');
|
||||
}
|
||||
if (ev.t === 'noPath' && ev.army === this.playerArmy) {
|
||||
this.hud.toast('No route there', '#ff9a6b');
|
||||
}
|
||||
}
|
||||
|
||||
_finish(over) {
|
||||
this.phase = 'result';
|
||||
const won = over.winner === this.playerArmy;
|
||||
api.post('/history/single-player', {
|
||||
slug: this.gameDef.slug, score: Math.round(this.match.elapsedSec),
|
||||
opponentScores: [], result: won ? 'win' : 'loss',
|
||||
}).catch(() => { /* best effort */ });
|
||||
|
||||
if (won && this.meta?.mode === 'campaign') {
|
||||
api.post(`/puzzles/${this.gameDef.slug}/complete`, { level: this.meta.missionIdx + 1 })
|
||||
.catch(() => { /* best effort */ });
|
||||
}
|
||||
localStorage.removeItem(SAVE_KEY);
|
||||
|
||||
const stats = summarise(this.match, this.playerArmy);
|
||||
this.swapScreen(Screens.results(this, { won, stats, elapsed: this.match.elapsedSec }, {
|
||||
onMenu: () => this.showMenu(),
|
||||
onAgain: () => {
|
||||
if (this.meta?.mode === 'campaign') this.startMission(this.meta.missionIdx);
|
||||
else this.startSkirmish({ ...this.meta.cfg, seed: (this.meta.cfg.seed * 7919 + 13) >>> 0 });
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Camera
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_updateCamera(delta) {
|
||||
const k = this.keys;
|
||||
const dt = delta / 1000;
|
||||
let dx = 0, dy = 0;
|
||||
if (k.A.isDown || k.LEFT.isDown) dx -= 1;
|
||||
if (k.D.isDown || k.RIGHT.isDown) dx += 1;
|
||||
if (k.W.isDown || k.UP.isDown) dy -= 1;
|
||||
if (k.S.isDown || k.DOWN.isDown) dy += 1;
|
||||
|
||||
// Edge scroll stays off until the mouse has actually moved over the canvas. Phaser's
|
||||
// activePointer sits at (0,0) until its first event, which is inside the top-left edge
|
||||
// band — so without this the camera silently flies off the corner of the map the moment
|
||||
// a match starts, taking the player's Commander off screen before they touch anything.
|
||||
if (this.settings.edgeScroll !== false && this._pointerLive && !this._dragging) {
|
||||
const p = this.input.activePointer;
|
||||
if (p.x >= 0 && p.y >= 0 && p.x <= GAME_WIDTH && p.y <= GAME_HEIGHT) {
|
||||
if (p.x < EDGE_PAN) dx -= 1;
|
||||
if (p.x > GAME_WIDTH - EDGE_PAN) dx += 1;
|
||||
if (p.y < EDGE_PAN) dy -= 1;
|
||||
if (p.y > GAME_HEIGHT - EDGE_PAN) dy += 1;
|
||||
}
|
||||
}
|
||||
if (dx || dy) {
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
this.view.panBy((dx / len) * PAN_SPEED * dt, (dy / len) * PAN_SPEED * dt);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Input
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_bindInput() {
|
||||
this.input.on('pointermove', () => { this._pointerLive = true; });
|
||||
this.input.on('pointerdown', (p) => { this._pointerLive = true; this._onPointerDown(p); });
|
||||
this.input.on('pointerup', (p) => this._onPointerUp(p));
|
||||
this.input.on('wheel', (p, objs, dx, dy) => {
|
||||
if (this.phase !== 'playing') return;
|
||||
this.view.zoomBy(dy > 0 ? -1 : 1, p.x, p.y);
|
||||
});
|
||||
|
||||
this.input.keyboard.on('keydown', (ev) => {
|
||||
if (this.phase === 'playing') this._onKey(ev);
|
||||
});
|
||||
}
|
||||
|
||||
_onKey(ev) {
|
||||
const code = ev.key?.toLowerCase();
|
||||
if (ev.code === 'Escape') {
|
||||
if (this.placement) { this._cancelPlacement(); return; }
|
||||
if (this.pendingCommand) { this.pendingCommand = null; return; }
|
||||
this.togglePause();
|
||||
return;
|
||||
}
|
||||
// Control groups: Ctrl+N assigns, N recalls, N again centres on the group.
|
||||
if (/^[1-9]$/.test(code)) {
|
||||
const n = Number(code);
|
||||
if (ev.ctrlKey) {
|
||||
this.groups.set(n, [...this.selection]);
|
||||
this.hud.toast(`Group ${n} set (${this.selection.size})`);
|
||||
} else {
|
||||
const ids = (this.groups.get(n) ?? []).filter((id) => Logic.entityById(this.match, id));
|
||||
if (!ids.length) return;
|
||||
const same = ids.length === this.selection.size && ids.every((id) => this.selection.has(id));
|
||||
this.selection = new Set(ids);
|
||||
if (same) {
|
||||
const e = Logic.entityById(this.match, ids[0]);
|
||||
if (e) this.view.centerOn(e.x, e.y);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
switch (code) {
|
||||
case 'a': this.pendingCommand = 'attackMove'; this.hud.toast('Attack-move: pick a target point'); break;
|
||||
case 'p': this.pendingCommand = 'patrol'; this.hud.toast('Patrol: pick a point'); break;
|
||||
case 'g': this.pendingCommand = 'guard'; this.hud.toast('Guard: pick a friendly unit'); break;
|
||||
case 'x': this._order({ type: 'stop' }); break;
|
||||
case 'h': this._order({ type: 'hold' }); break;
|
||||
case 'q': this.view.zoomBy(-1, GAME_WIDTH / 2, GAME_HEIGHT / 2); break;
|
||||
case 'e': this.view.zoomBy(1, GAME_WIDTH / 2, GAME_HEIGHT / 2); break;
|
||||
case ' ': this._jumpToAction(); break;
|
||||
default: break;
|
||||
}
|
||||
if (ev.ctrlKey && code === 'a') this._selectAll();
|
||||
}
|
||||
|
||||
_onPointerDown(p) {
|
||||
if (this.phase !== 'playing') return;
|
||||
if (this.hud.hitsUi(p.x, p.y)) return;
|
||||
|
||||
if (p.middleButtonDown()) { this._panAnchor = { x: p.x, y: p.y }; return; }
|
||||
|
||||
const w = this.view.worldPoint(p.x, p.y);
|
||||
if (p.rightButtonDown()) { this._issueContextual(w, p.event?.shiftKey); return; }
|
||||
|
||||
if (this.placement) { this._commitPlacement(w, p.event?.shiftKey); return; }
|
||||
if (this.pendingCommand) { this._applyPendingCommand(w, p.event?.shiftKey); return; }
|
||||
|
||||
this._dragStart = { x: p.x, y: p.y, wx: w.x, wy: w.y, add: !!p.event?.shiftKey };
|
||||
this._dragging = false;
|
||||
}
|
||||
|
||||
_onPointerUp(p) {
|
||||
this._panAnchor = null;
|
||||
if (this.phase !== 'playing' || !this._dragStart) return;
|
||||
const d = this._dragStart;
|
||||
this._dragStart = null;
|
||||
const w = this.view.worldPoint(p.x, p.y);
|
||||
if (this._dragging) this._selectInBox(d.wx, d.wy, w.x, w.y, d.add);
|
||||
else this._selectAt(w.x, w.y, d.add, !!p.event?.detail && p.event.detail > 1);
|
||||
this._dragging = false;
|
||||
}
|
||||
|
||||
_drawDragBox() {
|
||||
if (!this._dragBoxG) this._dragBoxG = this.add.graphics().setDepth(2000);
|
||||
const g = this._dragBoxG;
|
||||
g.clear();
|
||||
if (!this._dragStart) return;
|
||||
const p = this.input.activePointer;
|
||||
if (Math.abs(p.x - this._dragStart.x) + Math.abs(p.y - this._dragStart.y) > DRAG_MIN) this._dragging = true;
|
||||
if (!this._dragging) return;
|
||||
this.view.uiRoot.add(g);
|
||||
const x = Math.min(p.x, this._dragStart.x), y = Math.min(p.y, this._dragStart.y);
|
||||
const w = Math.abs(p.x - this._dragStart.x), h = Math.abs(p.y - this._dragStart.y);
|
||||
g.fillStyle(0x7dff9b, 0.10); g.fillRect(x, y, w, h);
|
||||
g.lineStyle(1.5, 0x7dff9b, 0.9); g.strokeRect(x, y, w, h);
|
||||
|
||||
if (this._panAnchor) {
|
||||
this.view.panBy(this._panAnchor.x - p.x, this._panAnchor.y - p.y);
|
||||
this._panAnchor = { x: p.x, y: p.y };
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Selection
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_mine() {
|
||||
return this.match.entities.filter((e) => !e.dead && e.army === this.playerArmy);
|
||||
}
|
||||
|
||||
_selectedEntities() {
|
||||
return [...this.selection].map((id) => Logic.entityById(this.match, id)).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Drop dead entities so the HUD and orders never reference a corpse. */
|
||||
_syncSelection() {
|
||||
for (const id of [...this.selection]) {
|
||||
const e = Logic.entityById(this.match, id);
|
||||
if (!e || e.dead) this.selection.delete(id);
|
||||
}
|
||||
this.view.selection = this.selection;
|
||||
}
|
||||
|
||||
_selectAt(x, y, add, double) {
|
||||
let best = null, bestD = Infinity;
|
||||
for (const e of this.match.entities) {
|
||||
if (e.dead) continue;
|
||||
if (e.army !== this.playerArmy && !this.view.visibleToPlayer(e)) continue;
|
||||
const d = Math.hypot(e.x - x, e.y - y);
|
||||
const r = e.isBuilding
|
||||
? Math.max(e.radius, Math.max(...Object.values(this.rules.defById[e.defId].footprint)) * this.match.tileSize * 0.5)
|
||||
: e.radius + 6;
|
||||
if (d <= r && d < bestD) { best = e; bestD = d; }
|
||||
}
|
||||
if (!best) { if (!add) this.selection.clear(); return; }
|
||||
if (!add) this.selection.clear();
|
||||
if (double && best.army === this.playerArmy) {
|
||||
// Double-click grabs every on-screen unit of the same type — the standard RTS idiom.
|
||||
const cam = this.cameras.main.worldView;
|
||||
for (const e of this._mine()) {
|
||||
if (e.defId === best.defId && Phaser.Geom.Rectangle.Contains(cam, e.x, e.y)) this.selection.add(e.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.selection.add(best.id);
|
||||
}
|
||||
|
||||
_selectInBox(x0, y0, x1, y1, add) {
|
||||
if (!add) this.selection.clear();
|
||||
const lo = { x: Math.min(x0, x1), y: Math.min(y0, y1) };
|
||||
const hi = { x: Math.max(x0, x1), y: Math.max(y0, y1) };
|
||||
// A box prefers mobile units: dragging over a base to grab tanks shouldn't also
|
||||
// select the factory they came out of.
|
||||
const hits = this._mine().filter((e) => e.x >= lo.x && e.x <= hi.x && e.y >= lo.y && e.y <= hi.y);
|
||||
const mobile = hits.filter((e) => !e.isBuilding && !e.site);
|
||||
for (const e of (mobile.length ? mobile : hits)) this.selection.add(e.id);
|
||||
}
|
||||
|
||||
_selectAll() {
|
||||
this.selection.clear();
|
||||
for (const e of this._mine()) if (!e.isBuilding && !e.site) this.selection.add(e.id);
|
||||
}
|
||||
|
||||
_jumpToAction() {
|
||||
const e = this._mine().find((u) => u.targetId) ?? this._mine()[0];
|
||||
if (e) this.view.centerOn(e.x, e.y);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Orders
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_order(order, queue = false) {
|
||||
const unitIds = [...this.selection];
|
||||
if (!unitIds.length) return { ok: false };
|
||||
const r = Logic.issueOrder(this.match, this.rules, {
|
||||
army: this.playerArmy, unitIds, order, queue,
|
||||
});
|
||||
if (!r.ok && r.error) this.hud.toast(r.error, '#ff9a6b');
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Right-click: move, attack an enemy, or set a factory's rally point. */
|
||||
_issueContextual(w, queue) {
|
||||
const sel = this._selectedEntities();
|
||||
if (!sel.length) return;
|
||||
|
||||
const factories = sel.filter((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length);
|
||||
if (factories.length === sel.length) {
|
||||
for (const f of factories) {
|
||||
Logic.issueOrder(this.match, this.rules, {
|
||||
army: this.playerArmy, order: { type: 'setRally', factoryId: f.id, x: w.x, y: w.y },
|
||||
});
|
||||
}
|
||||
this.hud.toast('Rally point set');
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this._entityAt(w.x, w.y);
|
||||
if (target && target.army !== this.playerArmy && this.view.visibleToPlayer(target)) {
|
||||
this._order({ type: 'attack', targetId: target.id }, queue);
|
||||
return;
|
||||
}
|
||||
if (target && target.army === this.playerArmy && target.site) {
|
||||
this._order({ type: 'assist', targetId: target.id }, queue);
|
||||
return;
|
||||
}
|
||||
this._order({ type: 'move', x: w.x, y: w.y }, queue);
|
||||
}
|
||||
|
||||
_applyPendingCommand(w, queue) {
|
||||
const cmd = this.pendingCommand;
|
||||
this.pendingCommand = null;
|
||||
if (cmd === 'guard') {
|
||||
const t = this._entityAt(w.x, w.y);
|
||||
if (t && t.army === this.playerArmy) this._order({ type: 'guard', targetId: t.id }, queue);
|
||||
return;
|
||||
}
|
||||
this._order({ type: cmd, x: w.x, y: w.y }, queue);
|
||||
}
|
||||
|
||||
_entityAt(x, y) {
|
||||
let best = null, bestD = Infinity;
|
||||
for (const e of this.match.entities) {
|
||||
if (e.dead) continue;
|
||||
const d = Math.hypot(e.x - x, e.y - y);
|
||||
if (d <= e.radius + 8 && d < bestD) { best = e; bestD = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
_enqueue(def, count) {
|
||||
const f = this._selectedEntities().find((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.includes(def.id));
|
||||
if (!f) return;
|
||||
Logic.issueOrder(this.match, this.rules, {
|
||||
army: this.playerArmy,
|
||||
order: { type: 'factoryEnqueue', factoryId: f.id, defId: def.id, count },
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Building placement
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_beginPlacement(def) {
|
||||
const builder = this._selectedEntities().find((e) => !e.isBuilding && this.rules.defById[e.defId].builds?.includes(def.id));
|
||||
if (!builder) return;
|
||||
this.placement = { def, builderId: builder.id };
|
||||
this.view.highlightMassSpots(!!def.terrainMultiplier);
|
||||
this.input.on('pointermove', this._onPlacementMove, this);
|
||||
this._onPlacementMove(this.input.activePointer);
|
||||
}
|
||||
|
||||
_onPlacementMove(p) {
|
||||
if (!this.placement) return;
|
||||
const w = this.view.worldPoint(p.x, p.y);
|
||||
const ts = this.match.tileSize;
|
||||
const def = this.placement.def;
|
||||
// Centre the footprint on the cursor, then snap to the tile grid.
|
||||
const tx = Math.round(w.x / ts - def.footprint.w / 2);
|
||||
const ty = Math.round(w.y / ts - def.footprint.h / 2);
|
||||
const builder = Logic.entityById(this.match, this.placement.builderId);
|
||||
this.placement.tx = tx; this.placement.ty = ty;
|
||||
this.view.placement = {
|
||||
def, tx, ty,
|
||||
legal: Logic.canPlaceAt(this.match, this.rules, tx, ty, def),
|
||||
builderX: builder?.x, builderY: builder?.y,
|
||||
buildRange: this.rules.defById[builder?.defId ?? '']?.buildRange ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
_commitPlacement(w, queue) {
|
||||
const p = this.placement;
|
||||
if (!p) return;
|
||||
const r = Logic.issueOrder(this.match, this.rules, {
|
||||
army: this.playerArmy, unitIds: [p.builderId],
|
||||
order: { type: 'build', defId: p.def.id, tx: p.tx, ty: p.ty },
|
||||
queue,
|
||||
});
|
||||
if (!r.ok) { this.hud.toast(r.error ?? 'Cannot build there', '#ff9a6b'); return; }
|
||||
// Shift keeps the cursor loaded so a row of generators is one gesture.
|
||||
if (!queue) this._cancelPlacement();
|
||||
}
|
||||
|
||||
_cancelPlacement() {
|
||||
this.placement = null;
|
||||
this.view.placement = null;
|
||||
this.view.highlightMassSpots(false);
|
||||
this.input.off('pointermove', this._onPlacementMove, this);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Pause / persistence
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
togglePause() {
|
||||
if (this.phase === 'playing') {
|
||||
this.phase = 'paused';
|
||||
this.swapScreen(Screens.pause(this, {
|
||||
settings: this.settings,
|
||||
onResume: () => { this.phase = 'playing'; this.swapScreen(null); },
|
||||
onSave: () => { this.saveGame(); this.hud.toast('Saved'); },
|
||||
onQuit: () => this.showMenu(),
|
||||
onToggleFog: (on) => {
|
||||
this.settings.fog = on;
|
||||
writeJson(SETTINGS_KEY, this.settings);
|
||||
this.view.setFogEnabled(on);
|
||||
},
|
||||
onToggleEdge: (on) => { this.settings.edgeScroll = on; writeJson(SETTINGS_KEY, this.settings); },
|
||||
onSpeed: (v) => { this.simSpeed = v; },
|
||||
}));
|
||||
} else if (this.phase === 'paused') {
|
||||
this.phase = 'playing';
|
||||
this.swapScreen(null);
|
||||
}
|
||||
}
|
||||
|
||||
saveGame() {
|
||||
if (!this.match) return;
|
||||
try {
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify({
|
||||
v: Logic.SAVE_VERSION, meta: this.meta, state: Logic.serialize(this.match),
|
||||
}));
|
||||
} catch (_) { /* quota — a failed autosave must not kill the match */ }
|
||||
}
|
||||
|
||||
loadSave() {
|
||||
const raw = readJson(SAVE_KEY, null);
|
||||
if (!raw || raw.v !== Logic.SAVE_VERSION) { this.hud?.toast('No compatible save'); return; }
|
||||
const state = Logic.deserialize(this.rules, raw.state);
|
||||
this.meta = raw.meta;
|
||||
if (raw.meta?.mode === 'campaign') this.mission = this.campaign.missions[raw.meta.missionIdx];
|
||||
this._beginMatch(state, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function summarise(state, armyIdx) {
|
||||
const a = state.armies[armyIdx];
|
||||
return {
|
||||
built: a.builtEver, lost: a.lostEver, kills: a.killsEver,
|
||||
mass: Math.round(a.mass), energy: Math.round(a.energy),
|
||||
};
|
||||
}
|
||||
|
||||
function readJson(key, fallback) {
|
||||
try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch (_) { return fallback; }
|
||||
}
|
||||
|
||||
function writeJson(key, v) {
|
||||
try { localStorage.setItem(key, JSON.stringify(v)); } catch (_) { /* ignore */ }
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = {
|
|||
superkart: 'nintendo',
|
||||
advancewars: 'nintendo',
|
||||
tetrisattack: 'nintendo',
|
||||
totalannihilation: 'hacker',
|
||||
coloradodefense: 'arcadedark',
|
||||
tempest: 'arcadedark',
|
||||
mastermind: 'hacker',
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,856 @@
|
|||
#!/usr/bin/env node
|
||||
// Verifies the Total Annihilation engine, data and AI end to end.
|
||||
//
|
||||
// node tools/verifyTotalAnnihilation.js [--quick]
|
||||
//
|
||||
// Everything here runs headless: TARules/TALogic/TANav/TAMapGen/TAAI import no Phaser. The
|
||||
// campaign section is the payoff of routing every order through TALogic.issueOrder — the AI
|
||||
// plays the HUMAN side, so each mission gets an automated winnability assertion rather than a
|
||||
// promise that it is probably beatable.
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { compileRules, armorMul } from '../src/games/totalannihilation/TARules.js';
|
||||
import { generateMap, decodeMap } from '../src/games/totalannihilation/TAMapGen.js';
|
||||
import * as L from '../src/games/totalannihilation/TALogic.js';
|
||||
import { runAI } from '../src/games/totalannihilation/TAAI.js';
|
||||
import { ensureSheets } from '../src/games/totalannihilation/TAArt.js';
|
||||
import TAWorldView, { DEPTHS } from '../src/games/totalannihilation/TAWorldView.js';
|
||||
import TAFx from '../src/games/totalannihilation/TAFx.js';
|
||||
import { makeStubScene } from './lib/taStubScene.js';
|
||||
import {
|
||||
createNav, findPath, clearanceFor, segmentClear, tileOk, worldToTileX, worldToTileY,
|
||||
} from '../src/games/totalannihilation/TANav.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const QUICK = process.argv.includes('--quick');
|
||||
const rulesJson = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
|
||||
const artJson = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-artwork.json'), 'utf8'));
|
||||
const campaign = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-campaign.json'), 'utf8'));
|
||||
const opponents = JSON.parse(readFileSync(join(ROOT, 'data/opponents.json'), 'utf8'));
|
||||
const rules = compileRules(rulesJson);
|
||||
const HZ = rules.constants.tickHz;
|
||||
|
||||
let pass = 0; const failures = [];
|
||||
function check(name, cond, detail) {
|
||||
if (cond) { pass++; return true; }
|
||||
failures.push(detail ? `${name} — ${detail}` : name);
|
||||
return false;
|
||||
}
|
||||
function section(title) { console.log(`\n── ${title}`); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('1. Rules integrity');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const ids = new Set();
|
||||
for (const d of [...rules.units, ...rules.buildings]) {
|
||||
check(`unique def id ${d.id}`, !ids.has(d.id), 'duplicate');
|
||||
ids.add(d.id);
|
||||
}
|
||||
for (const d of [...rules.units, ...rules.buildings]) {
|
||||
check(`${d.id} costs are positive`, (d.cost?.mass ?? 0) >= 0 && (d.cost?.energy ?? 0) >= 0);
|
||||
check(`${d.id} has hp`, d.hp > 0);
|
||||
for (const w of d.weaponDefs ?? []) {
|
||||
for (const a of rules.armorClasses) {
|
||||
check(`${w.id} vs ${a}`, Number.isFinite(armorMul(w, a)), 'missing armorMul');
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const u of rules.units) {
|
||||
if (!u.buildTime) continue;
|
||||
const makers = rules.buildings.filter((b) => (b.builds ?? []).includes(u.id));
|
||||
check(`${u.id} is buildable by some factory`, makers.length > 0);
|
||||
}
|
||||
// Every producible thing must be reachable from the Commander's tech tree, or the player
|
||||
// can see it in the rules and never build it.
|
||||
const commander = rules.unitById.commander;
|
||||
const reach = new Set(commander.builds ?? []);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (const id of [...reach]) for (const n of rules.defById[id].builds ?? []) reach.add(n);
|
||||
}
|
||||
for (const d of [...rules.units, ...rules.buildings]) {
|
||||
if (d.id === 'commander') continue;
|
||||
check(`${d.id} reachable from the Commander`, reach.has(d.id));
|
||||
}
|
||||
for (const c of rules.commanders) {
|
||||
check(`commander ${c.id} maps to a real opponent`,
|
||||
(opponents.opponents ?? []).some((o) => o.id === c.opponentId), c.opponentId);
|
||||
check(`commander ${c.id} army exists`, !!rules.armyById[c.armyId]);
|
||||
}
|
||||
// The D-Gun must stay manual — auto-firing it turns every Commander into a base turret
|
||||
// that deletes one attacker per reload, which makes assaulting anything suicide.
|
||||
check('D-Gun is manual-fire', rules.weaponById.dgun.manual === true);
|
||||
check('Commander has a non-manual weapon',
|
||||
(commander.weaponDefs ?? []).some((w) => !w.manual), 'would be defenceless');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('2. Artwork manifest');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const sheets = artJson.sheets ?? {};
|
||||
check('artwork declares sheets', Object.keys(sheets).length > 0);
|
||||
for (const [name, s] of Object.entries(sheets)) {
|
||||
check(`sheet ${name} has a key`, !!s.key);
|
||||
check(`sheet ${name} has frame size`, s.frameWidth > 0 && s.frameHeight > 0);
|
||||
check(`sheet ${name} has a kind`, ['unit', 'structure', 'terrain', 'icon'].includes(s.kind), s.kind);
|
||||
}
|
||||
for (const a of rules.armies) {
|
||||
check(`army ${a.id} unit sheet declared`, !!sheets[a.unitSheet], a.unitSheet);
|
||||
check(`army ${a.id} structure sheet declared`, !!sheets[a.structureSheet], a.structureSheet);
|
||||
}
|
||||
for (const [id, t] of Object.entries(artJson.themes ?? {})) {
|
||||
check(`theme ${id} sheet declared`, !!sheets[t.sheet], t.sheet);
|
||||
for (const terr of rules.terrain) {
|
||||
check(`theme ${id} has a colour for ${terr.id}`, !!t.palette?.[terr.id]);
|
||||
}
|
||||
}
|
||||
// terrainFrames drives the procedural terrain sheet's size. It also carries a `_comment`
|
||||
// key, and one non-numeric value in there turns the frame count into NaN and the painted
|
||||
// sheet into a zero-height canvas — which fails in the browser as an opaque WebGL error,
|
||||
// so it gets caught here instead.
|
||||
const tf = Object.entries(artJson.terrainFrames ?? {}).filter(([k]) => !k.startsWith('_'));
|
||||
check('terrainFrames declares frames', tf.length > 0);
|
||||
for (const [name, v] of tf) {
|
||||
check(`terrainFrames.${name} is a frame index`, Number.isInteger(v) && v >= 0, String(v));
|
||||
}
|
||||
for (const terr of rules.terrain) {
|
||||
check(`terrain "${terr.id}" has a frame in terrainFrames`,
|
||||
tf.some(([name]) => name === terr.id) || Number.isInteger(terr.frame));
|
||||
}
|
||||
const maxTerrainFrame = Math.max(...tf.map(([, v]) => v)) + 1;
|
||||
for (const [name, sheet] of Object.entries(sheets)) {
|
||||
if (sheet.kind !== 'terrain') continue;
|
||||
const cols = sheet.cols ?? 8;
|
||||
check(`${name} layout is fully numeric`,
|
||||
[sheet.frameWidth, sheet.frameHeight, cols].every((v) => Number.isFinite(v) && v > 0));
|
||||
check(`${name} holds every terrain frame`, maxTerrainFrame <= cols * Math.ceil(maxTerrainFrame / cols));
|
||||
}
|
||||
|
||||
// Frame indices must exist within each sheet's declared capacity.
|
||||
for (const d of [...rules.units, ...rules.buildings]) {
|
||||
for (const a of rules.armies) {
|
||||
const sheet = sheets[d.sheetSlot === 'unitSheet' ? a.unitSheet : a.structureSheet];
|
||||
const cap = (sheet.cols ?? 8) * (sheet.rows ?? 8);
|
||||
check(`${d.id} frame ${d.frame} fits ${sheet.key}`, d.frame < cap);
|
||||
if (d.turretFrame != null) check(`${d.id} turret frame fits`, d.turretFrame < cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('2b. Procedural art actually paints');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// TAArt imports no Phaser, so the painters can be run right here against a stub canvas.
|
||||
// Checking the artwork JSON alone is not enough: the terrain sheet's frame count is DERIVED
|
||||
// from that JSON, and a bad derivation produced a zero-height texture that only failed in
|
||||
// the browser, as a WebGL error pointing nowhere near the cause. Running the painters also
|
||||
// catches NaN geometry, which paints nothing at all and is otherwise invisible until a
|
||||
// player sees a blank tank.
|
||||
const bad = [];
|
||||
const num = (where, ...vals) => {
|
||||
for (const v of vals) if (typeof v === 'number' && !Number.isFinite(v)) bad.push(where);
|
||||
};
|
||||
const mkCtx = () => new Proxy({}, {
|
||||
get: (_t, prop) => {
|
||||
if (prop === 'save' || prop === 'restore' || prop === 'beginPath' || prop === 'closePath'
|
||||
|| prop === 'fill' || prop === 'stroke') return () => {};
|
||||
if (typeof prop === 'string') return (...args) => num(prop, ...args);
|
||||
return () => {};
|
||||
},
|
||||
set: (_t, prop, value) => { num(String(prop), value); return true; },
|
||||
});
|
||||
const made = new Map();
|
||||
const scene = {
|
||||
textures: {
|
||||
exists: (k) => made.has(k),
|
||||
remove: (k) => made.delete(k),
|
||||
createCanvas(key, w, h) {
|
||||
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
|
||||
bad.push(`createCanvas(${key}, ${w}, ${h})`);
|
||||
}
|
||||
const frames = [];
|
||||
const tex = {
|
||||
width: w, height: h, frames,
|
||||
getContext: () => mkCtx(),
|
||||
refresh() {},
|
||||
add(f, _s, fx, fy, fw, fh) {
|
||||
num(`add(${key})`, fx, fy, fw, fh);
|
||||
if (fx + fw > w || fy + fh > h) bad.push(`frame ${f} outside ${key}`);
|
||||
frames.push(f);
|
||||
},
|
||||
};
|
||||
made.set(key, tex);
|
||||
return tex;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { keys, procedural } = ensureSheets(scene, rules, artJson);
|
||||
for (const name of Object.keys(artJson.sheets ?? {})) {
|
||||
check(`sheet ${name} resolves to a texture key`, !!keys[name]);
|
||||
const tex = made.get(keys[name]);
|
||||
check(`sheet ${name} painted a non-empty canvas`, !!tex && tex.width > 0 && tex.height > 0,
|
||||
tex ? `${tex.width}x${tex.height}` : 'no texture');
|
||||
check(`sheet ${name} registered frames`, !!tex && tex.frames.length > 0);
|
||||
}
|
||||
check('every sheet fell back to a painted stand-in', procedural.length === Object.keys(artJson.sheets ?? {}).length,
|
||||
`${procedural.length} procedural`);
|
||||
check('no painter emitted a non-finite value', bad.length === 0, [...new Set(bad)].slice(0, 5).join(', '));
|
||||
|
||||
// Every frame a def references must have been registered by the painter that owns it.
|
||||
for (const d of [...rules.units, ...rules.buildings]) {
|
||||
for (const a of rules.armies) {
|
||||
const sheetName = d.sheetSlot === 'unitSheet' ? a.unitSheet : a.structureSheet;
|
||||
const tex = made.get(keys[sheetName]);
|
||||
check(`${d.id} frame ${d.frame} was painted on ${sheetName}`, tex?.frames.includes(d.frame));
|
||||
if (d.turretFrame != null) {
|
||||
check(`${d.id} turret frame was painted`, tex?.frames.includes(d.turretFrame));
|
||||
}
|
||||
if (d.buildFrame != null) {
|
||||
check(`${d.id} build frame was painted`, tex?.frames.includes(d.buildFrame));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [name, sheet] of Object.entries(artJson.sheets ?? {})) {
|
||||
if (sheet.kind !== 'terrain') continue;
|
||||
const tex = made.get(keys[name]);
|
||||
for (const terr of rules.terrain) {
|
||||
check(`terrain ${terr.id} frame ${terr.frame} painted on ${name}`, tex?.frames.includes(terr.frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('2c. View layering and first frame');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// TAWorldView and TAFx import no Phaser either, so the real render path runs here against
|
||||
// a stub scene. This section exists because two bugs got past every data-level check and
|
||||
// straight into the browser: the fog sheet drew UNDER the terrain (a Phaser Container
|
||||
// renders in insertion order and never sorts by depth on its own), and the camera walked
|
||||
// off the map before the first frame. Both are invisible to any test that only inspects
|
||||
// simulation state.
|
||||
const map = generateMap(rules, { seed: 123456, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', armies: 2 });
|
||||
const st = L.createMatch(rules, { seed: 123456, map, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }] });
|
||||
const scene = makeStubScene();
|
||||
let view = null;
|
||||
try {
|
||||
view = new TAWorldView(scene, rules, artJson, st, 0);
|
||||
const fx = new TAFx(scene, view.worldRoot, DEPTHS);
|
||||
view.setFogEnabled(true);
|
||||
const start = st.starts.find((x) => x.army === 0);
|
||||
view.centerOn(start.x * st.tileSize, start.y * st.tileSize);
|
||||
const out = view.render(0);
|
||||
check('render() returns fx payloads', !!out && Array.isArray(out.projectiles) && Array.isArray(out.nanoLinks));
|
||||
|
||||
const order = view.worldRoot.list;
|
||||
const lastChunk = Math.max(-1, ...order.map((o, i) => (o.type === 'renderTexture' ? i : -1)));
|
||||
check('terrain chunks were painted', lastChunk >= 0);
|
||||
check('fog draws ON TOP of terrain', order.indexOf(view.fogImg) > lastChunk,
|
||||
`fog at ${order.indexOf(view.fogImg)}, last chunk at ${lastChunk}`);
|
||||
check('fx layers are in the world container',
|
||||
order.includes(fx.gUnder) && order.includes(fx.gOver));
|
||||
|
||||
const own = st.entities.find((e) => e.army === 0);
|
||||
const foe = st.entities.find((e) => e.army === 1);
|
||||
const ownSprite = view.sprites.get(own.id);
|
||||
check('the player\'s starting unit has a sprite', !!ownSprite);
|
||||
check('own unit draws on top of terrain', order.indexOf(ownSprite.img) > lastChunk);
|
||||
check('own unit is visible through fog', ownSprite.img.visible);
|
||||
check('the enemy is hidden at match start', !view.visibleToPlayer(foe));
|
||||
|
||||
// The Commander must be ON SCREEN when the match opens — an off-screen start reads to a
|
||||
// player as "I have no units" and there is nothing they can do about it.
|
||||
const cam = scene.cameras.main.worldView;
|
||||
const onScreen = own.x >= cam.x && own.x <= cam.x + cam.width
|
||||
&& own.y >= cam.y && own.y <= cam.y + cam.height;
|
||||
check('the opening camera frames the player\'s Commander', onScreen,
|
||||
`unit ${own.x.toFixed(0)},${own.y.toFixed(0)} vs view ${cam.x.toFixed(0)},${cam.y.toFixed(0)} ${cam.width}x${cam.height}`);
|
||||
|
||||
// Wheel zoom must keep the world point under the cursor fixed, at any cursor position —
|
||||
// including well away from the screen centre, which is where a centre-anchored zoom (or a
|
||||
// correction computed from a stale camera matrix) visibly drifts.
|
||||
for (const [fx2, fy2] of [[960, 540], [320, 220], [1700, 900], [40, 1040]]) {
|
||||
for (const dir of [1, 1, -1, -1, -1, 1]) {
|
||||
const beforePt = view.worldPoint(fx2, fy2);
|
||||
const beforeZoom = view.zoom;
|
||||
view.zoomBy(dir, fx2, fy2);
|
||||
if (view.zoom === beforeZoom) continue; // already at the end of the ladder
|
||||
const afterPt = view.worldPoint(fx2, fy2);
|
||||
const drift = Math.hypot(afterPt.x - beforePt.x, afterPt.y - beforePt.y);
|
||||
check(`zoom at (${fx2},${fy2}) keeps the cursor's world point fixed`, drift < 0.5,
|
||||
`drifted ${drift.toFixed(1)}px`);
|
||||
}
|
||||
}
|
||||
|
||||
// Depth bands must stay in the intended order.
|
||||
check('bars draw above actors', DEPTHS.bars > DEPTHS.actor);
|
||||
check('fog draws above everything', DEPTHS.fog > Math.max(DEPTHS.fxOver, DEPTHS.bars, DEPTHS.actor));
|
||||
check('selection draws below actors', DEPTHS.selection < DEPTHS.actor);
|
||||
} catch (e) {
|
||||
check('the view layer builds and renders a frame', false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('2d. Container hitbox lint');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// TAHud/TAScreens import Phaser directly, so they cannot be constructed here. This is a
|
||||
// source lint instead, guarding one specific mistake that is invisible until a human tries
|
||||
// to click something: a Phaser Container's origin is hard-coded to 0.5, so setInteractive()
|
||||
// centres the hit area on the container's position. A child drawn from a top-left origin
|
||||
// then sits half a button down-right of the region that actually receives the click.
|
||||
const files = ['src/games/totalannihilation/TAHud.js', 'src/games/totalannihilation/TAScreens.js'];
|
||||
for (const rel of files) {
|
||||
const src = readFileSync(join(ROOT, rel), 'utf8');
|
||||
const lines = src.split('\n');
|
||||
let offenders = 0;
|
||||
lines.forEach((line, i) => {
|
||||
if (!/add\.container\(/.test(line)) return;
|
||||
// Look at the block this container is built in.
|
||||
const block = lines.slice(i, i + 26).join('\n');
|
||||
if (!/setInteractive\(/.test(block)) return;
|
||||
if (/setOrigin\(\s*0\s*,\s*0\s*\)/.test(block)) offenders++;
|
||||
});
|
||||
check(`${rel.split('/').pop()} has no top-left-origin child in an interactive container`,
|
||||
offenders === 0, `${offenders} block(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('3. Economy fixtures');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const map = generateMap(rules, { seed: 7, size: 'small', symmetry: 'mirror-x' });
|
||||
const st = L.createMatch(rules, { seed: 7, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const a = st.armies[0];
|
||||
a.mass = 0; a.energy = 0;
|
||||
for (let i = 0; i < HZ; i++) L.tick(st, rules);
|
||||
check('idle army banks its commander income', a.energy > 0 && a.mass > 0);
|
||||
|
||||
// Building an Energy Generator takes exactly buildTime seconds at nominal build power,
|
||||
// given enough stored resources that nothing stalls.
|
||||
const st2 = L.createMatch(rules, { seed: 8, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const b = st2.armies[0];
|
||||
b.mass = 99999; b.energy = 99999; b.massCap = 99999; b.energyCap = 99999;
|
||||
const cmd = st2.entities.find((e) => e.army === 0);
|
||||
const gen = rules.buildingById.energygen;
|
||||
let tx = worldToTileX(st2.nav, cmd.x) + 2, ty = worldToTileY(st2.nav, cmd.y);
|
||||
while (!L.canPlaceAt(st2, rules, tx, ty, gen) && tx < st2.w - 4) tx++;
|
||||
const r = L.issueOrder(st2, rules, { army: 0, unitIds: [cmd.id], order: { type: 'build', defId: 'energygen', tx, ty } });
|
||||
check('build order accepted', r.ok, r.error);
|
||||
let ticks = 0;
|
||||
while (ticks < 200 * HZ) {
|
||||
b.mass = 99999; b.energy = 99999; // hold the economy open so only build power matters
|
||||
L.tick(st2, rules); ticks++;
|
||||
const site = st2.entities.find((e) => e.defId === 'energygen');
|
||||
if (site && !site.site) break;
|
||||
}
|
||||
const secs = ticks / HZ;
|
||||
const expected = gen.buildTime * (rules.constants.buildPowerNominal / rules.unitById.commander.buildPower);
|
||||
check('unstalled build takes buildTime seconds', Math.abs(secs - expected) <= expected * 0.35 + 2,
|
||||
`${secs.toFixed(1)}s vs ${expected}s`);
|
||||
|
||||
// Stall factor: halve the available mass flow and construction must slow, not stop.
|
||||
const st3 = L.createMatch(rules, { seed: 9, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const c = st3.armies[0];
|
||||
c.mass = 0; c.energy = 0;
|
||||
L.tick(st3, rules);
|
||||
check('stall factors stay in [0,1]', c.stallM >= 0 && c.stallM <= 1 && c.stallE >= 0 && c.stallE <= 1);
|
||||
check('buildEff is the binding factor', Math.abs(c.buildEff - Math.min(c.stallE, c.stallM)) < 1e-9);
|
||||
|
||||
// Storage is capped and the overflow is discarded rather than silently banked.
|
||||
const st4 = L.createMatch(rules, { seed: 10, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const d = st4.armies[0];
|
||||
d.energy = d.energyCap; d.mass = d.massCap;
|
||||
for (let i = 0; i < HZ * 5; i++) L.tick(st4, rules);
|
||||
check('stored energy never exceeds cap', d.energy <= d.energyCap + 1e-6);
|
||||
check('stored mass never exceeds cap', d.mass <= d.massCap + 1e-6);
|
||||
|
||||
// A Mass Generator on a metal patch must out-yield one on plain ground.
|
||||
const mgen = rules.buildingById.massgen;
|
||||
check('mass generator has a terrain multiplier', !!mgen.terrainMultiplier);
|
||||
const multTerrain = rules.terrain.find((t) => t[mgen.terrainMultiplier] > 1);
|
||||
check('some terrain carries that multiplier', !!multTerrain);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('4. Pathfinding');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const ts = rules.constants.tileSize;
|
||||
const mk = (rows) => {
|
||||
const w = rows[0].length, h = rows.length;
|
||||
const terrain = new Uint8Array(w * h);
|
||||
const wall = rules.terrainByCh['^'].index, open = rules.terrainByCh['.'].index;
|
||||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) terrain[y * w + x] = rows[y][x] === '#' ? wall : open;
|
||||
return createNav(rules, { w, h, terrain });
|
||||
};
|
||||
const nav = mk([
|
||||
'..........',
|
||||
'..######..',
|
||||
'..#....#..',
|
||||
'..#....#..',
|
||||
'..######..',
|
||||
'..........',
|
||||
]);
|
||||
const mc = 'tread';
|
||||
check('tread is a declared movement class', mc in rules.moveClasses);
|
||||
const at = (nav2, x, y) => y * nav2.w + x;
|
||||
const p = findPath(nav, mc, 1, at(nav, 0, 0), at(nav, 9, 5));
|
||||
check('A* finds a route around an obstacle', !!p && p.length > 0);
|
||||
const inside = findPath(nav, mc, 1, at(nav, 0, 0), at(nav, 4, 3));
|
||||
check('sealed region is unreachable', !inside);
|
||||
|
||||
// Clearance: a wide unit must refuse a one-tile gap but accept a wide corridor.
|
||||
const narrow = mk([
|
||||
'####.####',
|
||||
'####.####',
|
||||
'####.####',
|
||||
]);
|
||||
const wide = mk([
|
||||
'##.....##',
|
||||
'##.....##',
|
||||
'##.....##',
|
||||
]);
|
||||
const big = clearanceFor(rules.sizeClasses.large.radius, ts);
|
||||
check('large clearance requirement is > 1 tile', big > 1);
|
||||
check('large unit refuses a 1-wide corridor', !findPath(narrow, mc, big, 4, at(narrow, 4, 2)));
|
||||
check('large unit accepts a wide corridor', !!findPath(wide, mc, big, at(wide, 3, 0), at(wide, 5, 2)));
|
||||
|
||||
// String-pulling must never shortcut through a blocked tile.
|
||||
const blocked = mk(['...', '.#.', '...']);
|
||||
check('segmentClear rejects a line through a wall',
|
||||
!segmentClear(blocked, mc, 1, 0.5 * ts, 0.5 * ts, 2.5 * ts, 2.5 * ts));
|
||||
check('segmentClear accepts a clear line',
|
||||
segmentClear(blocked, mc, 1, 0.5 * ts, 0.5 * ts, 2.5 * ts, 0.5 * ts));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('5. Movement, separation and size classes');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const map = generateMap(rules, { seed: 21, size: 'small', symmetry: 'mirror-x' });
|
||||
const st = L.createMatch(rules, { seed: 21, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
// Find a genuinely open tile with open neighbours — the middle of a generated map is as
|
||||
// likely to be a lake as a field, and units shoved out of water prove nothing.
|
||||
const openTile = (() => {
|
||||
for (let y = 3; y < map.h - 3; y++) {
|
||||
for (let x = 3; x < map.w - 3; x++) {
|
||||
let ok = true;
|
||||
for (let dy = -2; dy <= 2 && ok; dy++) {
|
||||
for (let dx = -2; dx <= 2 && ok; dx++) {
|
||||
if (!tileOk(st.nav, 'foot', 1, x + dx, y + dy)) ok = false;
|
||||
}
|
||||
}
|
||||
if (ok) return { x, y };
|
||||
}
|
||||
}
|
||||
return { x: (map.w / 2) | 0, y: (map.h / 2) | 0 };
|
||||
})();
|
||||
const cx = openTile.x * st.tileSize + st.tileSize / 2;
|
||||
const cy = openTile.y * st.tileSize + st.tileSize / 2;
|
||||
check('found open ground for the packing fixture', tileOk(st.nav, 'foot', 1, openTile.x, openTile.y));
|
||||
|
||||
// The size-class requirement, asserted: three smalls share one tile, two mediums cannot.
|
||||
const packed = (defId, n) => {
|
||||
const s2 = L.createMatch(rules, { seed: 21, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const made = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const u = L.spawnUnit(s2, rules, 0, defId, cx + (i - n / 2) * 6, cy + (i % 2) * 6);
|
||||
if (u) made.push(u);
|
||||
}
|
||||
for (let i = 0; i < 60; i++) L.tick(s2, rules);
|
||||
const ts = s2.tileSize;
|
||||
return made.filter((u) => Math.abs(u.x - cx) <= ts / 2 && Math.abs(u.y - cy) <= ts / 2).length;
|
||||
};
|
||||
// The size-class contract is a statement about collision radii, so assert it there — and
|
||||
// then confirm the emergent behaviour matches, which is the part a player actually sees.
|
||||
const ts0 = rules.constants.tileSize;
|
||||
const small = rules.sizeClasses.small, medium = rules.sizeClasses.medium, large = rules.sizeClasses.large;
|
||||
check('small units are under half a tile wide', small.radius * 2 <= ts0 / 2, `${small.radius * 2}px`);
|
||||
check('medium units are about one tile wide',
|
||||
medium.radius * 2 > ts0 / 2 && medium.radius * 2 <= ts0, `${medium.radius * 2}px`);
|
||||
check('large units span more than one tile', large.radius * 2 > ts0, `${large.radius * 2}px`);
|
||||
check('large units claim a multi-tile footprint', (large.footprint ?? 1) > 1);
|
||||
check('3 small units settle inside one tile', packed('infantry', 3) >= 3, `${packed('infantry', 3)}`);
|
||||
check('3 medium units cannot settle inside one tile', packed('tank', 3) < 3, `${packed('tank', 3)}`);
|
||||
|
||||
// Settled units must not overlap.
|
||||
for (let i = 0; i < 12; i++) L.spawnUnit(st, rules, 0, 'tank', cx + (i % 4) * 30, cy + Math.floor(i / 4) * 30);
|
||||
for (let i = 0; i < 120; i++) L.tick(st, rules);
|
||||
const mine = st.entities.filter((e) => !e.dead && e.army === 0 && e.defId === 'tank');
|
||||
let worst = 0;
|
||||
for (let i = 0; i < mine.length; i++) {
|
||||
for (let j = i + 1; j < mine.length; j++) {
|
||||
const overlap = (mine[i].radius + mine[j].radius) - Math.hypot(mine[i].x - mine[j].x, mine[i].y - mine[j].y);
|
||||
worst = Math.max(worst, overlap);
|
||||
}
|
||||
}
|
||||
check('settled units barely overlap', worst < 12, `worst overlap ${worst.toFixed(1)}px`);
|
||||
check('all positions finite', st.entities.every((e) => Number.isFinite(e.x) && Number.isFinite(e.y)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('6. Combat');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
|
||||
raw.constants.eliminateWhenUnrecoverable = false; // these fixtures field no builders
|
||||
const combatRules = compileRules(raw);
|
||||
const map = generateMap(combatRules, { seed: 31, size: 'small', symmetry: 'mirror-x' });
|
||||
const cx = (map.w / 2) * combatRules.constants.tileSize, cy = (map.h / 2) * combatRules.constants.tileSize;
|
||||
|
||||
const duel = (aId, na, bId, nb, sec = 150) => {
|
||||
const st = L.createMatch(combatRules, { seed: 31, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
for (const e of st.entities) e.dead = true;
|
||||
st.entities = [];
|
||||
const line = (army, id, n, dx) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
L.spawnUnit(st, combatRules, army, id,
|
||||
cx + dx - Math.sign(dx) * Math.floor(i / 10) * 40, cy - 180 + (i % 10) * 40);
|
||||
}
|
||||
};
|
||||
line(0, aId, na, -240); line(1, bId, nb, 240);
|
||||
st.over = null;
|
||||
for (const a of st.armies) a.alive = true;
|
||||
const ids = (army) => st.entities.filter((e) => e.army === army && !e.dead).map((e) => e.id);
|
||||
L.issueOrder(st, combatRules, { army: 0, unitIds: ids(0), order: { type: 'attackMove', x: cx + 240, y: cy } });
|
||||
L.issueOrder(st, combatRules, { army: 1, unitIds: ids(1), order: { type: 'attackMove', x: cx - 240, y: cy } });
|
||||
let t = 0;
|
||||
for (; t < sec * HZ; t++) {
|
||||
L.tick(st, combatRules);
|
||||
const a0 = st.entities.some((e) => !e.dead && e.army === 0);
|
||||
const a1 = st.entities.some((e) => !e.dead && e.army === 1);
|
||||
if (!a0 || !a1) break;
|
||||
}
|
||||
const left = (a) => st.entities.filter((e) => !e.dead && e.army === a).length;
|
||||
const hp = (a) => st.entities.filter((e) => !e.dead && e.army === a).reduce((s, e) => s + e.hp, 0);
|
||||
return { a: left(0), b: left(1), hpA: hp(0), hpB: hp(1), secs: t / HZ };
|
||||
};
|
||||
const cost = (id) => combatRules.unitById[id].cost.mass + combatRules.unitById[id].cost.energy / 4;
|
||||
const equal = (a, b, n = 8) => {
|
||||
const nb = Math.max(1, Math.round(n * cost(a) / cost(b)));
|
||||
const r = duel(a, n, b, nb);
|
||||
// Score on surviving fraction of the force each side paid for, so a slow grind that the
|
||||
// tanks are clearly winning counts as a win even if the clock runs out first.
|
||||
r.fracA = r.a / n; r.fracB = r.b / nb;
|
||||
return r;
|
||||
};
|
||||
|
||||
// Ballistic shells travel 35px per 20Hz tick, so a point-sampled hit test tunnels straight
|
||||
// through its target. Tanks doing real damage is the observable proof the sweep works.
|
||||
const tvi = equal('tank', 'infantry');
|
||||
check('tanks beat equal-cost infantry', tvi.fracA > tvi.fracB,
|
||||
`${tvi.a} tanks (${(tvi.fracA * 100) | 0}%) v ${tvi.b} infantry (${(tvi.fracB * 100) | 0}%)`);
|
||||
const rvs = equal('rockettank', 'sniper');
|
||||
check('rocket tanks beat equal-cost snipers', rvs.fracA > rvs.fracB,
|
||||
`${rvs.a} (${(rvs.fracA * 100) | 0}%) v ${rvs.b} (${(rvs.fracB * 100) | 0}%)`);
|
||||
// Rockets out-range and out-damage armour; that is what makes the roster a triangle
|
||||
// rather than a ladder where one unit is simply the answer.
|
||||
const rvt = equal('rockettank', 'tank');
|
||||
check('rocket tanks beat equal-cost tanks', rvt.fracA > rvt.fracB,
|
||||
`${rvt.a} (${(rvt.fracA * 100) | 0}%) v ${rvt.b} (${(rvt.fracB * 100) | 0}%)`);
|
||||
|
||||
// Engagements must take long enough for reinforcement and composition to matter. When
|
||||
// fights resolved in 2-4 seconds the whole AI skill ladder collapsed to a coin flip.
|
||||
check('an even engagement is not instant', tvi.secs > 5, `${tvi.secs.toFixed(1)}s`);
|
||||
|
||||
// Damage fixtures.
|
||||
const st = L.createMatch(combatRules, { seed: 32, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const victim = L.spawnUnit(st, combatRules, 1, 'tank', cx, cy);
|
||||
const before = victim.hp;
|
||||
L.applyDamage(st, combatRules, victim, 100, { army: 0, id: 0 });
|
||||
check('applyDamage subtracts exactly', Math.abs((before - victim.hp) - 100) < 1e-9);
|
||||
const rifle = combatRules.weaponById.rifle;
|
||||
check('rifle is strong vs infantry, weak vs medium',
|
||||
armorMul(rifle, 'infantry') > armorMul(rifle, 'medium') * 3);
|
||||
const rocket = combatRules.weaponById.rocketpod;
|
||||
check('rockets favour armour over infantry', armorMul(rocket, 'heavy') > armorMul(rocket, 'infantry') * 3);
|
||||
|
||||
// Commander death explosion.
|
||||
const dth = combatRules.unitById.commander.deathExplosion;
|
||||
check('commander has a death explosion', dth && dth.radius > 200 && dth.damage > 500);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('7. Fog of war');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const map = generateMap(rules, { seed: 41, size: 'small', symmetry: 'mirror-x' });
|
||||
const st = L.createMatch(rules, { seed: 41, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const mine = st.entities.find((e) => e.army === 0);
|
||||
const theirs = st.entities.find((e) => e.army === 1);
|
||||
L.computeVision(st, rules);
|
||||
check('own units are always visible', L.isVisibleTo(st, 0, mine));
|
||||
check('a distant enemy starts hidden', !L.isVisibleTo(st, 0, theirs));
|
||||
|
||||
// Explored is sticky; visible is not.
|
||||
const cell = st.tileSize * 2;
|
||||
const vx = Math.floor(mine.x / cell), vy = Math.floor(mine.y / cell);
|
||||
const idx = vy * st.visW + vx;
|
||||
check('own tile is explored', st.armies[0].explored[idx] === 1);
|
||||
L.spawnUnit(st, rules, 0, 'jeep', theirs.x, theirs.y);
|
||||
L.computeVision(st, rules);
|
||||
check('scouting reveals the enemy', L.isVisibleTo(st, 0, theirs));
|
||||
|
||||
// The AI must be fog-limited too — it may not target something it cannot see.
|
||||
const st2 = L.createMatch(rules, { seed: 42, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
for (let i = 0; i < HZ * 3; i++) { runAI(rules, st2, 0, { skill: 5 }); L.tick(st2, rules); }
|
||||
const mem = st2.aiMem?.[0];
|
||||
const cheated = (mem?.knownEnemies ?? []).filter((k) => {
|
||||
const e = L.entityById(st2, k.id);
|
||||
return e && !L.isVisibleTo(st2, 0, e) && k.tick === st2.tick;
|
||||
});
|
||||
check('AI never records an unseen enemy as seen-now', cheated.length === 0, `${cheated.length} leaks`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('8. Map generation');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const seeds = QUICK ? 24 : 120;
|
||||
let symMismatch = 0, badStarts = 0, poorMetal = 0, unreachable = 0;
|
||||
const sizes = Object.keys(rules.skirmish.sizes);
|
||||
for (let i = 0; i < seeds; i++) {
|
||||
const size = sizes[i % sizes.length];
|
||||
const sym = rules.skirmish.symmetries[i % rules.skirmish.symmetries.length];
|
||||
const map = generateMap(rules, { seed: 5000 + i * 13, size, symmetry: sym, armies: 2 });
|
||||
if (map.starts.length !== 2) { badStarts++; continue; }
|
||||
|
||||
if (sym === 'mirror-x') {
|
||||
for (let y = 0; y < map.h; y++) {
|
||||
for (let x = 0; x < map.w; x++) {
|
||||
if (map.terrain[y * map.w + x] !== map.terrain[y * map.w + (map.w - 1 - x)]) { symMismatch++; y = map.h; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every start needs metal within reach or its economy can never leave the ground.
|
||||
const mgen = rules.buildingById.massgen;
|
||||
for (const s of map.starts) {
|
||||
let spots = 0;
|
||||
const R = rules.skirmish.gen.metalSpotRadiusTiles;
|
||||
for (let y = Math.max(0, s.y - R); y < Math.min(map.h, s.y + R); y++) {
|
||||
for (let x = Math.max(0, s.x - R); x < Math.min(map.w, s.x + R); x++) {
|
||||
if (rules.terrain[map.terrain[y * map.w + x]][mgen.terrainMultiplier] > 1) spots++;
|
||||
}
|
||||
}
|
||||
if (spots < 4) poorMetal++;
|
||||
}
|
||||
// And the two starts must be mutually reachable by a medium ground unit.
|
||||
const nav = createNav(rules, map);
|
||||
const clr = clearanceFor(rules.sizeClasses.medium.radius, rules.constants.tileSize);
|
||||
const path = findPath(nav, 'tread', clr,
|
||||
map.starts[0].y * map.w + map.starts[0].x, map.starts[1].y * map.w + map.starts[1].x);
|
||||
if (!path) unreachable++;
|
||||
}
|
||||
check('every generated map places both starts', badStarts === 0, `${badStarts} bad`);
|
||||
check('mirror-x maps are exactly symmetric', symMismatch === 0, `${symMismatch} mismatches`);
|
||||
check('every start has metal nearby', poorMetal === 0, `${poorMetal} starved starts`);
|
||||
check('starts are mutually reachable', unreachable === 0, `${unreachable} unreachable`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('9. Campaign data');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const missions = campaign.missions ?? [];
|
||||
check('campaign has missions', missions.length > 0);
|
||||
const OBJECTIVES = new Set(['destroyAll', 'destroyCommander', 'survive', 'holdArea', 'reachArea', 'protect', 'buildCount']);
|
||||
missions.forEach((m, i) => {
|
||||
let map = null;
|
||||
try { map = decodeMap(rules, m.map); } catch (e) { /* reported below */ }
|
||||
check(`${m.id} terrain decodes`, !!map);
|
||||
if (!map) return;
|
||||
check(`${m.id} declares both starts`, (map.starts ?? []).length >= 2);
|
||||
check(`${m.id} theme exists`, !!artJson.themes?.[m.theme], m.theme);
|
||||
check(`${m.id} player army exists`, !!rules.armyById[m.playerArmy]);
|
||||
check(`${m.id} player commander exists`, !!rules.commanderById[m.playerCommander]);
|
||||
check(`${m.id} objective is a known type`, OBJECTIVES.has(m.objective?.type), m.objective?.type);
|
||||
for (const e of m.enemies ?? []) {
|
||||
check(`${m.id} enemy commander exists`, !!rules.commanderById[e.commander], e.commander);
|
||||
const sk = e.aiProfile?.skill;
|
||||
check(`${m.id} enemy skill is 1-5`, sk >= 1 && sk <= 5, String(sk));
|
||||
}
|
||||
for (const b of map.buildings ?? []) {
|
||||
check(`${m.id} prebuilt ${b.type} exists`, !!rules.buildingById[b.type], b.type);
|
||||
check(`${m.id} prebuilt ${b.type} is on the map`, b.tx >= 0 && b.ty >= 0 && b.tx < map.w && b.ty < map.h);
|
||||
}
|
||||
for (const line of [...(m.briefing ?? []), m.victoryLine, m.defeatLine].filter(Boolean)) {
|
||||
check(`${m.id} speaker ${line.speaker} exists`,
|
||||
(opponents.opponents ?? []).some((o) => o.id === line.speaker), line.speaker);
|
||||
}
|
||||
// Missions must get harder, or the unlock order is meaningless.
|
||||
if (i > 0) {
|
||||
const prev = missions[i - 1].enemies?.[0]?.aiProfile?.skill ?? 0;
|
||||
check(`${m.id} is no easier than the one before`, (m.enemies?.[0]?.aiProfile?.skill ?? 0) >= prev);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('10. Serialization and determinism');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const map = generateMap(rules, { seed: 51, size: 'small', symmetry: 'mirror-x' });
|
||||
const st = L.createMatch(rules, { seed: 51, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
for (let i = 0; i < HZ * 20; i++) { runAI(rules, st, 0, { skill: 3 }); runAI(rules, st, 1, { skill: 3 }); L.tick(st, rules); }
|
||||
const blob = L.serialize(st);
|
||||
const back = L.deserialize(rules, blob);
|
||||
check('serialize round-trips to the same hash', L.hashState(back) === L.hashState(st));
|
||||
check('serialized save is a sane size', blob.length < 400000, `${blob.length} bytes`);
|
||||
|
||||
const run = () => {
|
||||
const m2 = generateMap(rules, { seed: 52, size: 'small', symmetry: 'mirror-x' });
|
||||
const s2 = L.createMatch(rules, { seed: 52, map: m2, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
for (let i = 0; i < HZ * 30; i++) { runAI(rules, s2, 0, { skill: 4 }); runAI(rules, s2, 1, { skill: 2 }); L.tick(s2, rules); }
|
||||
return L.hashState(s2);
|
||||
};
|
||||
check('same seed replays identically', run() === run());
|
||||
|
||||
// The step() harness must produce the same result as driving tick() directly.
|
||||
const m3 = generateMap(rules, { seed: 53, size: 'small', symmetry: 'mirror-x' });
|
||||
const byTick = L.createMatch(rules, { seed: 53, map: m3, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
for (let i = 0; i < 100; i++) L.tick(byTick, rules);
|
||||
const byStep = L.createMatch(rules, { seed: 53, map: m3, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
for (let i = 0; i < 100; i++) L.step(byStep, rules, rules.stepMs, 4);
|
||||
check('step() and tick() agree', L.hashState(byStep) === L.hashState(byTick));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('11. Skirmish soak');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const games = QUICK ? 8 : 24;
|
||||
let decided = 0, negative = 0, nonFinite = 0, overCap = 0, peakUnits = 0;
|
||||
let totalTickMs = 0, ticks = 0, worstTick = 0;
|
||||
for (let g = 0; g < games; g++) {
|
||||
const map = generateMap(rules, { seed: 6000 + g * 17, size: g % 2 ? 'medium' : 'small', symmetry: 'mirror-x' });
|
||||
const st = L.createMatch(rules, { seed: 6000 + g, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const cap = rules.constants.unitCapPerArmy;
|
||||
while (!st.over && st.tick < 1200 * HZ) {
|
||||
runAI(rules, st, 0, { skill: 3 }); runAI(rules, st, 1, { skill: 3 });
|
||||
const t0 = process.hrtime.bigint();
|
||||
L.tick(st, rules);
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
totalTickMs += ms; ticks++; worstTick = Math.max(worstTick, ms);
|
||||
for (const a of st.armies) if (a.mass < -1e-6 || a.energy < -1e-6) negative++;
|
||||
if (st.tick % 200 === 0) {
|
||||
for (const e of st.entities) if (!Number.isFinite(e.x) || !Number.isFinite(e.y)) nonFinite++;
|
||||
for (let i = 0; i < st.armies.length; i++) {
|
||||
const n = st.entities.filter((e) => !e.dead && e.army === i && !e.isBuilding).length;
|
||||
peakUnits = Math.max(peakUnits, n);
|
||||
if (n > cap) overCap++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (st.over && st.over.winner >= 0) decided++;
|
||||
}
|
||||
const avg = totalTickMs / Math.max(1, ticks);
|
||||
console.log(` ${decided}/${games} decided · peak ${peakUnits} units/army · tick avg ${avg.toFixed(2)}ms worst ${worstTick.toFixed(1)}ms`);
|
||||
check('most games reach a decision', decided >= Math.ceil(games * 0.85), `${decided}/${games}`);
|
||||
check('resources never go negative', negative === 0, `${negative}`);
|
||||
check('positions stay finite', nonFinite === 0, `${nonFinite}`);
|
||||
check('unit cap is respected', overCap === 0, `${overCap} breaches`);
|
||||
// Average is the real budget signal at 20Hz (50ms per tick). The worst case is allowed
|
||||
// more headroom because one slow tick — a burst of path requests, or a GC landing on it —
|
||||
// costs a single dropped frame, not a stall; it is printed above either way.
|
||||
check('average tick is well inside the 20Hz budget', avg < 8, `avg ${avg.toFixed(2)}ms`);
|
||||
check('no tick blows the frame budget outright', worstTick < 120, `worst ${worstTick.toFixed(1)}ms`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('12. AI skill ladder');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const pairs = QUICK ? 6 : 16;
|
||||
// Each seed is played BOTH ways round so that side advantage — turn order, spawn corner,
|
||||
// commander profile — cancels exactly instead of being assumed away. Measured over 32
|
||||
// mirrored pairs the ladder runs 5v1 ≈ 84%, 3v1 ≈ 81%, 5v3 ≈ 59%; the bars below sit far
|
||||
// enough below those to be stable at this sample size.
|
||||
const ladder = (sa, sb) => {
|
||||
let win = 0, dec = 0;
|
||||
for (let i = 0; i < pairs; i++) {
|
||||
for (const flip of [false, true]) {
|
||||
const map = generateMap(rules, { seed: 7000 + i * 7, size: 'small', symmetry: 'mirror-x' });
|
||||
const st = L.createMatch(rules, { seed: 7000 + i, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const p0 = flip ? sb : sa, p1 = flip ? sa : sb;
|
||||
while (!st.over && st.tick < 1200 * HZ) {
|
||||
runAI(rules, st, 0, { skill: p0 }); runAI(rules, st, 1, { skill: p1 });
|
||||
L.tick(st, rules);
|
||||
}
|
||||
if (st.over && st.over.winner >= 0) {
|
||||
dec++;
|
||||
if (st.over.winner === (flip ? 1 : 0)) win++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return dec ? win / dec : 0;
|
||||
};
|
||||
const hi = ladder(5, 1);
|
||||
console.log(` skill 5 vs skill 1: ${(hi * 100).toFixed(1)}%`);
|
||||
check('skill 5 beats skill 1 decisively', hi >= 0.72, `${(hi * 100).toFixed(1)}%`);
|
||||
if (!QUICK) {
|
||||
const mid = ladder(4, 2);
|
||||
console.log(` skill 4 vs skill 2: ${(mid * 100).toFixed(1)}%`);
|
||||
check('skill 4 beats skill 2', mid >= 0.52, `${(mid * 100).toFixed(1)}%`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('13. Campaign winnability');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// A skill-5 bot plays the PLAYER's side through the same issueOrder API a human uses. This
|
||||
// is the entire reason the order API is shared, and it is the only way to know a mission is
|
||||
// actually beatable rather than merely plausible.
|
||||
const runs = QUICK ? 2 : 4;
|
||||
for (const m of campaign.missions ?? []) {
|
||||
let wins = 0, played = 0;
|
||||
for (let r = 0; r < runs; r++) {
|
||||
const map = decodeMap(rules, m.map);
|
||||
const st = L.createMatch(rules, {
|
||||
seed: (m.seed ?? 1) + r * 101, map,
|
||||
armies: [
|
||||
{ armyId: m.playerArmy, commanderId: m.playerCommander, isHuman: true },
|
||||
...(m.enemies ?? []).map((e) => ({
|
||||
armyId: e.army, commanderId: e.commander,
|
||||
aiSkill: e.aiProfile?.skill ?? 3, aiProfile: e.aiProfile ?? null,
|
||||
})),
|
||||
],
|
||||
});
|
||||
(m.startResources ?? []).forEach((res, i) => {
|
||||
if (!st.armies[i]) return;
|
||||
st.armies[i].mass = res.mass ?? st.armies[i].mass;
|
||||
st.armies[i].energy = res.energy ?? st.armies[i].energy;
|
||||
});
|
||||
while (!st.over && st.tick < 1500 * HZ) {
|
||||
runAI(rules, st, 0, { skill: 5 });
|
||||
for (let i = 1; i < st.armies.length; i++) {
|
||||
const e = m.enemies?.[i - 1];
|
||||
runAI(rules, st, i, { skill: e?.aiProfile?.skill ?? 3, ...(e?.aiProfile ?? {}) });
|
||||
}
|
||||
L.tick(st, rules);
|
||||
}
|
||||
played++;
|
||||
if (st.over && st.over.winner === 0) wins++;
|
||||
}
|
||||
const rate = played ? wins / played : 0;
|
||||
console.log(` ${m.id} ${m.name.padEnd(16)} bot wins ${wins}/${played}`);
|
||||
check(`${m.id} is winnable by a skill-5 bot`, rate >= 0.5, `${wins}/${played}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log(`\n${'─'.repeat(60)}`);
|
||||
if (failures.length) {
|
||||
console.log(`FAILED — ${pass} checks passed, ${failures.length} failed:`);
|
||||
for (const f of failures) console.log(` ✗ ${f}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`OK — all ${pass} checks passed${QUICK ? ' (quick)' : ''}.`);
|
||||
Loading…
Reference in New Issue