Compare commits
5 Commits
94cc51b997
...
ddac7fcd5a
| Author | SHA1 | Date |
|---|---|---|
|
|
ddac7fcd5a | |
|
|
e7da172896 | |
|
|
ab05e2f1cd | |
|
|
79c18c595e | |
|
|
93b4877c2d |
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 331 KiB After Width: | Height: | Size: 335 KiB |
Binary file not shown.
|
|
@ -16,6 +16,8 @@
|
|||
"citySheets": {
|
||||
"classic": { "key": "civilization-cities-classic", "path": "assets/images/civilization/civilization-cities-classic.png", "frameWidth": 128, "frameHeight": 96 },
|
||||
"cyberpunk": { "key": "civilization-cities-cyberpunk", "path": "assets/images/civilization/civilization-cities-cyberpunk.png", "frameWidth": 128, "frameHeight": 96 },
|
||||
"japanese": { "key": "civilization-cities-japanese", "path": "assets/images/civilization/civilization-cities-japanese.png", "frameWidth": 128, "frameHeight": 96 }
|
||||
"japanese": { "key": "civilization-cities-japanese", "path": "assets/images/civilization/civilization-cities-japanese.png", "frameWidth": 128, "frameHeight": 96 },
|
||||
"steampunk": { "key": "civilization-cities-steampunk", "path": "assets/images/civilization/civilization-cities-steampunk.png", "frameWidth": 128, "frameHeight": 96 },
|
||||
"mayan": { "key": "civilization-cities-mayan", "path": "assets/images/civilization/civilization-cities-mayan.png", "frameWidth": 128, "frameHeight": 96 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@
|
|||
"name": "Victor",
|
||||
"trait": "pioneering",
|
||||
"startingTechs": [],
|
||||
"citySheet": "cyberpunk",
|
||||
"citySheet": "steampunk",
|
||||
"bio": "Patient and calculating. Moves are made using ancient technology.",
|
||||
"voice": "Starfield - Vasco",
|
||||
"convo": [
|
||||
|
|
@ -130,7 +130,7 @@
|
|||
"bronzeworking",
|
||||
"masonry"
|
||||
],
|
||||
"citySheet": "classic",
|
||||
"citySheet": "mayan",
|
||||
"bio": "Wassssuppp everybody! I'm here for the party!!",
|
||||
"voice": "Batman - Killer Croc",
|
||||
"convo": [
|
||||
|
|
@ -172,7 +172,7 @@
|
|||
"warriorcode",
|
||||
"thewheel"
|
||||
],
|
||||
"citySheet": "classic",
|
||||
"citySheet": "mayan",
|
||||
"bio": "Can you find your way out of my maze of puzzles, games and fun!",
|
||||
"speech": {
|
||||
"intro": [
|
||||
|
|
@ -543,7 +543,7 @@
|
|||
"warriorcode",
|
||||
"masonry"
|
||||
],
|
||||
"citySheet": "classic",
|
||||
"citySheet": "mayan",
|
||||
"bio": "Hey mon! Yous be knowin' I been wantin' ta play on da Fertig Games for a while now mon!",
|
||||
"voice": "Djed Spence",
|
||||
"convo": [
|
||||
|
|
@ -616,7 +616,7 @@
|
|||
"name": "Balam",
|
||||
"trait": "pioneering",
|
||||
"startingTechs": [],
|
||||
"citySheet": "classic",
|
||||
"citySheet": "mayan",
|
||||
"bio": "Mystical Powers and a knack for games.",
|
||||
"speech": {
|
||||
"intro": [
|
||||
|
|
@ -722,7 +722,7 @@
|
|||
"masonry",
|
||||
"alphabet"
|
||||
],
|
||||
"citySheet": "classic",
|
||||
"citySheet": "mayan",
|
||||
"bio": "Aaaaaaargh! Me be playin' these games matey!",
|
||||
"voice": "pirate",
|
||||
"speech": {
|
||||
|
|
@ -933,7 +933,7 @@
|
|||
"ceremonialburial",
|
||||
"masonry"
|
||||
],
|
||||
"citySheet": "classic",
|
||||
"citySheet": "mayan",
|
||||
"bio": "I haven't had this much fun since the Mezasoic era!",
|
||||
"speech": {
|
||||
"intro": [
|
||||
|
|
@ -968,7 +968,7 @@
|
|||
"ceremonialburial",
|
||||
"horsebackriding"
|
||||
],
|
||||
"citySheet": "classic",
|
||||
"citySheet": "steampunk",
|
||||
"bio": "Ahoy you land lovers!",
|
||||
"voice": "Defiant Calder",
|
||||
"convo": [
|
||||
|
|
@ -1010,7 +1010,7 @@
|
|||
"pottery",
|
||||
"thewheel"
|
||||
],
|
||||
"citySheet": "classic",
|
||||
"citySheet": "steampunk",
|
||||
"bio": "Bucky Beaver and I are there to TRAP all the fun and games!",
|
||||
"speech": {
|
||||
"intro": [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
{
|
||||
"_readme": [
|
||||
"Drop-in painted art for Total Annihilation (spec: src/games/totalannihilation/sprites.md).",
|
||||
"Paint a sheet, drop the PNG under assets/images/totalannihilation/, set its `path` below and",
|
||||
"reload — no code changes needed. Any sheet left path:null is rendered procedurally.",
|
||||
"",
|
||||
"`kind` selects which procedural painter stands in for an unpainted sheet, and it is the reason",
|
||||
"adding a sheet here is a JSON-only change: painters are keyed by kind, never by sheet name.",
|
||||
" unit — frameWidth == frameHeight, art drawn FACING RIGHT (+x) and CENTRED in the cell,",
|
||||
" in neutral grey so the runtime army tint multiplies correctly. The engine rotates",
|
||||
" the sprite, so keep the silhouette inside a circle of diameter frameWidth*0.7 or",
|
||||
" the corners will clip as it turns.",
|
||||
" structure — frameWidth == frameHeight, buildings are NOT rotated and NOT drawn facing right.",
|
||||
" Two frames per building: the finished one and a translucent wireframe used while",
|
||||
" it is still being nanolathed.",
|
||||
" terrain — one sheet per theme. Frame order is fixed (see `terrainFrames` below) so any new",
|
||||
" theme sheet drops straight in.",
|
||||
" icon — build-menu glyphs, full colour, never tinted.",
|
||||
"",
|
||||
"`cols` only sizes the procedural stand-in canvas; a painted sheet is measured from the PNG.",
|
||||
"Adding a THIRD ARMY = add two sheets here (kind unit + kind structure) and one `armies` entry",
|
||||
"in data/totalannihilation-rules.json. Adding a THEME = one sheet here, one `themes` entry, and",
|
||||
"the theme id in rules.skirmish.themes."
|
||||
],
|
||||
|
||||
"terrainFrames": {
|
||||
"_comment": "Fixed frame order every kind:terrain sheet must follow.",
|
||||
"ground": 0, "groundAlt1": 1, "groundAlt2": 2, "rough": 3,
|
||||
"cliff": 4, "water": 5, "waterDeep": 6, "metal": 7, "road": 8
|
||||
},
|
||||
|
||||
"sheets": {
|
||||
"arm-units": {
|
||||
"key": "ta-arm-units", "path": null, "kind": "unit",
|
||||
"frameWidth": 64, "frameHeight": 64, "cols": 8
|
||||
},
|
||||
"arm-structures": {
|
||||
"key": "ta-arm-structures", "path": null, "kind": "structure",
|
||||
"frameWidth": 128, "frameHeight": 128, "cols": 6
|
||||
},
|
||||
"core-units": {
|
||||
"key": "ta-core-units", "path": null, "kind": "unit",
|
||||
"frameWidth": 64, "frameHeight": 64, "cols": 8
|
||||
},
|
||||
"core-structures": {
|
||||
"key": "ta-core-structures", "path": null, "kind": "structure",
|
||||
"frameWidth": 128, "frameHeight": 128, "cols": 6
|
||||
},
|
||||
"terrain-grasslands": {
|
||||
"key": "ta-terrain-grasslands", "path": null, "kind": "terrain",
|
||||
"frameWidth": 64, "frameHeight": 64, "cols": 9
|
||||
},
|
||||
"terrain-snowfields": {
|
||||
"key": "ta-terrain-snowfields", "path": null, "kind": "terrain",
|
||||
"frameWidth": 64, "frameHeight": 64, "cols": 9
|
||||
},
|
||||
"terrain-tropics": {
|
||||
"key": "ta-terrain-tropics", "path": null, "kind": "terrain",
|
||||
"frameWidth": 64, "frameHeight": 64, "cols": 9
|
||||
},
|
||||
"icons": {
|
||||
"key": "ta-icons", "path": null, "kind": "icon",
|
||||
"frameWidth": 44, "frameHeight": 44, "cols": 10
|
||||
}
|
||||
},
|
||||
|
||||
"themes": {
|
||||
"grasslands": {
|
||||
"name": "Grasslands",
|
||||
"sheet": "terrain-grasslands",
|
||||
"palette": {
|
||||
"ground": "#5f7f3d", "groundAlt1": "#6b8c45", "groundAlt2": "#55743a",
|
||||
"rough": "#7d7048", "cliff": "#4c4838", "cliffTop": "#6b6552",
|
||||
"water": "#2f5f9e", "waterDeep": "#1d3f70",
|
||||
"metal": "#9aa3ab", "road": "#8b8578", "speck": "#3f5a28"
|
||||
}
|
||||
},
|
||||
"snowfields": {
|
||||
"name": "Snowfields",
|
||||
"sheet": "terrain-snowfields",
|
||||
"palette": {
|
||||
"ground": "#d6dee6", "groundAlt1": "#e4ebf1", "groundAlt2": "#c4cfda",
|
||||
"rough": "#a9b4bf", "cliff": "#6a737e", "cliffTop": "#8e99a4",
|
||||
"water": "#3d6d95", "waterDeep": "#27506f",
|
||||
"metal": "#9aa3ab", "road": "#9fa6ad", "speck": "#aebccb"
|
||||
}
|
||||
},
|
||||
"tropics": {
|
||||
"name": "Tropics",
|
||||
"sheet": "terrain-tropics",
|
||||
"palette": {
|
||||
"ground": "#2f6b3c", "groundAlt1": "#3a7f46", "groundAlt2": "#265c33",
|
||||
"rough": "#7a6b3a", "cliff": "#4a3f2e", "cliffTop": "#6d5f45",
|
||||
"water": "#1f8fa8", "waterDeep": "#12667a",
|
||||
"metal": "#9aa3ab", "road": "#9c8f6e", "speck": "#1d4a26"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,804 @@
|
|||
{
|
||||
"_readme": [
|
||||
"Engine data for Total Annihilation. Everything the simulation knows lives here \u2014 adding a unit,",
|
||||
"building, weapon, terrain type, army or map theme is a JSON-only change.",
|
||||
"After editing, run `node tools/verifyTotalAnnihilation.js` \u2014 it cross-checks every id reference,",
|
||||
"frame index and closed key set in this file and will tell you exactly what broke.",
|
||||
"",
|
||||
"Sheet resolution: a unit/building's `sheetSlot` names a field on its ARMY (unitSheet or",
|
||||
"structureSheet), so one unit definition renders correctly for every army. That is what makes a",
|
||||
"third army a two-line change: add an `armies` entry pointing at two new sheets in",
|
||||
"data/totalannihilation-artwork.json.",
|
||||
"",
|
||||
"Units are drawn once and ROTATED at runtime \u2014 there are no per-facing frames. `turretFrame` is",
|
||||
"an optional second sprite that aims independently of the hull."
|
||||
],
|
||||
"version": 1,
|
||||
"constants": {
|
||||
"tickHz": 20,
|
||||
"tileSize": 64,
|
||||
"unitCapPerArmy": 100,
|
||||
"projectileCap": 400,
|
||||
"startEnergy": 1000,
|
||||
"startMass": 1000,
|
||||
"baseEnergyCap": 500,
|
||||
"baseMassCap": 500,
|
||||
"pathBudgetPerTick": 8,
|
||||
"retargetPeriodTicks": 10,
|
||||
"separationStiffness": 0.55,
|
||||
"shoveAfterSec": 0.6,
|
||||
"stuckGiveUpSec": 3.0,
|
||||
"arriveSlackPx": 12,
|
||||
"friendlyFire": false,
|
||||
"buildPowerNominal": 100,
|
||||
"eliminateWhenUnrecoverable": true
|
||||
},
|
||||
"sizeClasses": {
|
||||
"small": {
|
||||
"radius": 16,
|
||||
"footprint": 1,
|
||||
"mass": 1
|
||||
},
|
||||
"medium": {
|
||||
"radius": 26,
|
||||
"footprint": 1,
|
||||
"mass": 4
|
||||
},
|
||||
"large": {
|
||||
"radius": 44,
|
||||
"footprint": 2,
|
||||
"mass": 16
|
||||
}
|
||||
},
|
||||
"armorClasses": [
|
||||
"infantry",
|
||||
"light",
|
||||
"medium",
|
||||
"heavy",
|
||||
"structure"
|
||||
],
|
||||
"moveClasses": {
|
||||
"foot": {
|
||||
"cost": {
|
||||
"ground": 1.0,
|
||||
"rough": 1.3,
|
||||
"road": 0.85,
|
||||
"metal": 1.0,
|
||||
"cliff": null,
|
||||
"water": null
|
||||
}
|
||||
},
|
||||
"tread": {
|
||||
"cost": {
|
||||
"ground": 1.0,
|
||||
"rough": 1.8,
|
||||
"road": 0.7,
|
||||
"metal": 1.0,
|
||||
"cliff": null,
|
||||
"water": null
|
||||
}
|
||||
},
|
||||
"wheel": {
|
||||
"cost": {
|
||||
"ground": 1.0,
|
||||
"rough": 2.4,
|
||||
"road": 0.6,
|
||||
"metal": 1.0,
|
||||
"cliff": null,
|
||||
"water": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"armies": [
|
||||
{
|
||||
"id": "arm",
|
||||
"name": "ARM",
|
||||
"color": "#4a90e2",
|
||||
"unitSheet": "arm-units",
|
||||
"structureSheet": "arm-structures"
|
||||
},
|
||||
{
|
||||
"id": "core",
|
||||
"name": "CORE",
|
||||
"color": "#e05a2f",
|
||||
"unitSheet": "core-units",
|
||||
"structureSheet": "core-structures"
|
||||
}
|
||||
],
|
||||
"commanders": [
|
||||
{
|
||||
"id": "vance",
|
||||
"armyId": "arm",
|
||||
"opponentId": "ethel",
|
||||
"name": "Commander Ethel",
|
||||
"tagline": "Holds the line, and makes you pay for every metre of it.",
|
||||
"aiProfile": {
|
||||
"aggression": 0.45,
|
||||
"expansion": 0.65
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "klaxon",
|
||||
"armyId": "core",
|
||||
"opponentId": "klaxon",
|
||||
"name": "Unit KLAXON",
|
||||
"tagline": "Efficiency is a weapon. You are an inefficiency.",
|
||||
"aiProfile": {
|
||||
"aggression": 0.7,
|
||||
"expansion": 0.45
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cybro",
|
||||
"armyId": "core",
|
||||
"opponentId": "cybro",
|
||||
"name": "Cy-Bro",
|
||||
"tagline": "Builds faster than you can knock it down.",
|
||||
"aiProfile": {
|
||||
"aggression": 0.35,
|
||||
"expansion": 0.85
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nadia",
|
||||
"armyId": "arm",
|
||||
"opponentId": "nadia",
|
||||
"name": "Commander Nadia",
|
||||
"tagline": "Hits the flank while you watch the front.",
|
||||
"aiProfile": {
|
||||
"aggression": 0.8,
|
||||
"expansion": 0.4
|
||||
}
|
||||
}
|
||||
],
|
||||
"terrain": [
|
||||
{
|
||||
"id": "ground",
|
||||
"ch": ".",
|
||||
"frame": 0,
|
||||
"buildable": true,
|
||||
"blocksMove": false,
|
||||
"elevation": 0
|
||||
},
|
||||
{
|
||||
"id": "rough",
|
||||
"ch": "r",
|
||||
"frame": 3,
|
||||
"buildable": true,
|
||||
"blocksMove": false,
|
||||
"elevation": 0
|
||||
},
|
||||
{
|
||||
"id": "road",
|
||||
"ch": "=",
|
||||
"frame": 8,
|
||||
"buildable": true,
|
||||
"blocksMove": false,
|
||||
"elevation": 0
|
||||
},
|
||||
{
|
||||
"id": "metal",
|
||||
"ch": "M",
|
||||
"frame": 7,
|
||||
"buildable": true,
|
||||
"blocksMove": false,
|
||||
"elevation": 0,
|
||||
"massMultiplier": 2.0
|
||||
},
|
||||
{
|
||||
"id": "cliff",
|
||||
"ch": "^",
|
||||
"frame": 4,
|
||||
"buildable": false,
|
||||
"blocksMove": true,
|
||||
"blocksFire": true,
|
||||
"elevation": 1
|
||||
},
|
||||
{
|
||||
"id": "water",
|
||||
"ch": "~",
|
||||
"frame": 5,
|
||||
"buildable": false,
|
||||
"blocksMove": true,
|
||||
"elevation": -1
|
||||
}
|
||||
],
|
||||
"weapons": [
|
||||
{
|
||||
"id": "rifle",
|
||||
"name": "Assault Rifle",
|
||||
"kind": "hitscan",
|
||||
"damage": 6,
|
||||
"reload": 0.5,
|
||||
"burst": 3,
|
||||
"burstDelay": 0.06,
|
||||
"range": 200,
|
||||
"spread": 0.05,
|
||||
"targets": [
|
||||
"ground"
|
||||
],
|
||||
"armorMul": {
|
||||
"infantry": 1.3,
|
||||
"light": 0.85,
|
||||
"medium": 0.3,
|
||||
"heavy": 0.12,
|
||||
"structure": 0.2
|
||||
},
|
||||
"fx": {
|
||||
"style": "tracer",
|
||||
"color": "#ffe6a0",
|
||||
"width": 1.5,
|
||||
"lifeMs": 70
|
||||
},
|
||||
"sound": "sfx-battle-gunfire-modern"
|
||||
},
|
||||
{
|
||||
"id": "sniperrifle",
|
||||
"name": "Marksman Rifle",
|
||||
"kind": "hitscan",
|
||||
"damage": 55,
|
||||
"reload": 3.0,
|
||||
"burst": 1,
|
||||
"range": 460,
|
||||
"spread": 0.0,
|
||||
"targets": [
|
||||
"ground"
|
||||
],
|
||||
"armorMul": {
|
||||
"infantry": 2.2,
|
||||
"light": 0.9,
|
||||
"medium": 0.3,
|
||||
"heavy": 0.1,
|
||||
"structure": 0.1
|
||||
},
|
||||
"fx": {
|
||||
"style": "beam",
|
||||
"color": "#d8f0ff",
|
||||
"width": 2,
|
||||
"lifeMs": 160
|
||||
},
|
||||
"sound": "sfx-laser-zap"
|
||||
},
|
||||
{
|
||||
"id": "tankgun",
|
||||
"name": "120mm Cannon",
|
||||
"kind": "ballistic",
|
||||
"damage": 95,
|
||||
"reload": 2.0,
|
||||
"burst": 1,
|
||||
"range": 350,
|
||||
"speed": 700,
|
||||
"spread": 0.015,
|
||||
"leadTarget": true,
|
||||
"aoe": 40,
|
||||
"aoeFalloff": 0.15,
|
||||
"targets": [
|
||||
"ground"
|
||||
],
|
||||
"armorMul": {
|
||||
"infantry": 1.3,
|
||||
"light": 1.15,
|
||||
"medium": 1.0,
|
||||
"heavy": 0.8,
|
||||
"structure": 1.2
|
||||
},
|
||||
"fx": {
|
||||
"style": "shell",
|
||||
"color": "#ffd27a",
|
||||
"width": 3,
|
||||
"muzzle": 18
|
||||
},
|
||||
"sound": "sfx-battle-tank"
|
||||
},
|
||||
{
|
||||
"id": "rocketpod",
|
||||
"name": "Rocket Pod",
|
||||
"kind": "guided",
|
||||
"damage": 130,
|
||||
"reload": 4.0,
|
||||
"burst": 2,
|
||||
"burstDelay": 0.25,
|
||||
"range": 440,
|
||||
"minRange": 110,
|
||||
"speed": 340,
|
||||
"turnRate": 2.2,
|
||||
"aoe": 48,
|
||||
"aoeFalloff": 0.15,
|
||||
"targets": [
|
||||
"ground"
|
||||
],
|
||||
"armorMul": {
|
||||
"infantry": 0.3,
|
||||
"light": 0.6,
|
||||
"medium": 1.0,
|
||||
"heavy": 1.4,
|
||||
"structure": 1.5
|
||||
},
|
||||
"fx": {
|
||||
"style": "rocket",
|
||||
"color": "#ff8844",
|
||||
"width": 2,
|
||||
"trail": true
|
||||
},
|
||||
"sound": "sfx-battle-missle"
|
||||
},
|
||||
{
|
||||
"id": "commanderlaser",
|
||||
"name": "Commander Laser",
|
||||
"kind": "hitscan",
|
||||
"damage": 38,
|
||||
"reload": 1.1,
|
||||
"burst": 1,
|
||||
"range": 260,
|
||||
"spread": 0.01,
|
||||
"targets": [
|
||||
"ground"
|
||||
],
|
||||
"armorMul": {
|
||||
"infantry": 1.3,
|
||||
"light": 1.0,
|
||||
"medium": 0.8,
|
||||
"heavy": 0.6,
|
||||
"structure": 0.5
|
||||
},
|
||||
"fx": {
|
||||
"style": "beam",
|
||||
"color": "#9ff0c8",
|
||||
"width": 3,
|
||||
"lifeMs": 120
|
||||
},
|
||||
"sound": "sfx-laser-zap"
|
||||
},
|
||||
{
|
||||
"id": "dgun",
|
||||
"name": "D-Gun",
|
||||
"kind": "beam",
|
||||
"damage": 2000,
|
||||
"reload": 1.5,
|
||||
"burst": 1,
|
||||
"range": 220,
|
||||
"energyPerShot": 500,
|
||||
"targets": [
|
||||
"ground"
|
||||
],
|
||||
"manual": true,
|
||||
"armorMul": {
|
||||
"infantry": 1,
|
||||
"light": 1,
|
||||
"medium": 1,
|
||||
"heavy": 1,
|
||||
"structure": 1
|
||||
},
|
||||
"fx": {
|
||||
"style": "dgun",
|
||||
"color": "#8ad4ff",
|
||||
"width": 9,
|
||||
"lifeMs": 260
|
||||
},
|
||||
"sound": "sfx-scifi-launch"
|
||||
}
|
||||
],
|
||||
"units": [
|
||||
{
|
||||
"id": "commander",
|
||||
"name": "Commander",
|
||||
"role": "builder",
|
||||
"size": "medium",
|
||||
"radius": 20,
|
||||
"hp": 7000,
|
||||
"speed": 62,
|
||||
"turnRate": 3.5,
|
||||
"moveClass": "foot",
|
||||
"armorClass": "heavy",
|
||||
"sight": 420,
|
||||
"cost": {
|
||||
"energy": 0,
|
||||
"mass": 0
|
||||
},
|
||||
"buildTime": 0,
|
||||
"buildPower": 100,
|
||||
"buildRange": 200,
|
||||
"builds": [
|
||||
"energygen",
|
||||
"massgen",
|
||||
"barracks",
|
||||
"vehicleplant"
|
||||
],
|
||||
"produce": {
|
||||
"energy": 25,
|
||||
"mass": 3.0
|
||||
},
|
||||
"storage": {
|
||||
"energy": 1000,
|
||||
"mass": 1000
|
||||
},
|
||||
"weapons": [
|
||||
"commanderlaser",
|
||||
"dgun"
|
||||
],
|
||||
"deathExplosion": {
|
||||
"radius": 280,
|
||||
"damage": 1200
|
||||
},
|
||||
"sheetSlot": "unitSheet",
|
||||
"frame": 0,
|
||||
"turretFrame": 1,
|
||||
"procShape": "commander",
|
||||
"icon": 0,
|
||||
"spritePx": 56
|
||||
},
|
||||
{
|
||||
"id": "infantry",
|
||||
"name": "Infantry",
|
||||
"role": "combat",
|
||||
"size": "small",
|
||||
"radius": 16,
|
||||
"hp": 260,
|
||||
"speed": 55,
|
||||
"turnRate": 6.0,
|
||||
"moveClass": "foot",
|
||||
"armorClass": "infantry",
|
||||
"sight": 260,
|
||||
"cost": {
|
||||
"energy": 120,
|
||||
"mass": 24
|
||||
},
|
||||
"buildTime": 6,
|
||||
"builtBy": [
|
||||
"barracks"
|
||||
],
|
||||
"weapons": [
|
||||
"rifle"
|
||||
],
|
||||
"sheetSlot": "unitSheet",
|
||||
"frame": 2,
|
||||
"procShape": "infantry",
|
||||
"icon": 1,
|
||||
"spritePx": 30,
|
||||
"moveSound": "sfx-march"
|
||||
},
|
||||
{
|
||||
"id": "sniper",
|
||||
"name": "Sniper",
|
||||
"role": "combat",
|
||||
"size": "small",
|
||||
"radius": 16,
|
||||
"hp": 200,
|
||||
"speed": 46,
|
||||
"turnRate": 5.0,
|
||||
"moveClass": "foot",
|
||||
"armorClass": "infantry",
|
||||
"sight": 500,
|
||||
"cost": {
|
||||
"energy": 260,
|
||||
"mass": 55
|
||||
},
|
||||
"buildTime": 12,
|
||||
"builtBy": [
|
||||
"barracks"
|
||||
],
|
||||
"weapons": [
|
||||
"sniperrifle"
|
||||
],
|
||||
"sheetSlot": "unitSheet",
|
||||
"frame": 3,
|
||||
"procShape": "sniper",
|
||||
"icon": 2,
|
||||
"spritePx": 30,
|
||||
"moveSound": "sfx-march"
|
||||
},
|
||||
{
|
||||
"id": "jeep",
|
||||
"name": "Jeep",
|
||||
"role": "scout",
|
||||
"size": "small",
|
||||
"radius": 20,
|
||||
"hp": 480,
|
||||
"speed": 175,
|
||||
"turnRate": 3.4,
|
||||
"moveClass": "wheel",
|
||||
"armorClass": "light",
|
||||
"sight": 520,
|
||||
"cost": {
|
||||
"energy": 320,
|
||||
"mass": 70
|
||||
},
|
||||
"buildTime": 8,
|
||||
"builtBy": [
|
||||
"vehicleplant"
|
||||
],
|
||||
"weapons": [
|
||||
"rifle"
|
||||
],
|
||||
"sheetSlot": "unitSheet",
|
||||
"frame": 4,
|
||||
"turretFrame": 5,
|
||||
"procShape": "jeep",
|
||||
"icon": 3,
|
||||
"spritePx": 40,
|
||||
"moveSound": "sfx-engine-medium"
|
||||
},
|
||||
{
|
||||
"id": "tank",
|
||||
"name": "Tank",
|
||||
"role": "combat",
|
||||
"size": "medium",
|
||||
"radius": 26,
|
||||
"hp": 1900,
|
||||
"speed": 90,
|
||||
"turnRate": 2.2,
|
||||
"moveClass": "tread",
|
||||
"armorClass": "medium",
|
||||
"sight": 300,
|
||||
"cost": {
|
||||
"energy": 700,
|
||||
"mass": 180
|
||||
},
|
||||
"buildTime": 20,
|
||||
"builtBy": [
|
||||
"vehicleplant"
|
||||
],
|
||||
"weapons": [
|
||||
"tankgun"
|
||||
],
|
||||
"sheetSlot": "unitSheet",
|
||||
"frame": 6,
|
||||
"turretFrame": 7,
|
||||
"procShape": "tank",
|
||||
"icon": 4,
|
||||
"spritePx": 54,
|
||||
"moveSound": "sfx-engine-heavy"
|
||||
},
|
||||
{
|
||||
"id": "rockettank",
|
||||
"name": "Rocket Tank",
|
||||
"role": "artillery",
|
||||
"size": "medium",
|
||||
"radius": 28,
|
||||
"hp": 1250,
|
||||
"speed": 75,
|
||||
"turnRate": 1.8,
|
||||
"moveClass": "tread",
|
||||
"armorClass": "medium",
|
||||
"sight": 320,
|
||||
"cost": {
|
||||
"energy": 1100,
|
||||
"mass": 300
|
||||
},
|
||||
"buildTime": 28,
|
||||
"builtBy": [
|
||||
"vehicleplant"
|
||||
],
|
||||
"weapons": [
|
||||
"rocketpod"
|
||||
],
|
||||
"sheetSlot": "unitSheet",
|
||||
"frame": 8,
|
||||
"turretFrame": 9,
|
||||
"procShape": "rockettank",
|
||||
"icon": 5,
|
||||
"spritePx": 56,
|
||||
"moveSound": "sfx-engine-heavy"
|
||||
}
|
||||
],
|
||||
"buildings": [
|
||||
{
|
||||
"id": "energygen",
|
||||
"name": "Energy Generator",
|
||||
"footprint": {
|
||||
"w": 2,
|
||||
"h": 2
|
||||
},
|
||||
"hp": 1440,
|
||||
"armorClass": "structure",
|
||||
"sight": 160,
|
||||
"cost": {
|
||||
"energy": 200,
|
||||
"mass": 60
|
||||
},
|
||||
"buildTime": 12,
|
||||
"produce": {
|
||||
"energy": 20
|
||||
},
|
||||
"storage": {
|
||||
"energy": 500
|
||||
},
|
||||
"deathExplosion": {
|
||||
"radius": 110,
|
||||
"damage": 160
|
||||
},
|
||||
"sheetSlot": "structureSheet",
|
||||
"frame": 0,
|
||||
"buildFrame": 1,
|
||||
"procShape": "energyGen",
|
||||
"icon": 10
|
||||
},
|
||||
{
|
||||
"id": "massgen",
|
||||
"name": "Mass Generator",
|
||||
"footprint": {
|
||||
"w": 2,
|
||||
"h": 2
|
||||
},
|
||||
"hp": 1260,
|
||||
"armorClass": "structure",
|
||||
"sight": 160,
|
||||
"cost": {
|
||||
"energy": 600,
|
||||
"mass": 50
|
||||
},
|
||||
"buildTime": 16,
|
||||
"produce": {
|
||||
"mass": 2.0
|
||||
},
|
||||
"upkeep": {
|
||||
"energy": 15
|
||||
},
|
||||
"storage": {
|
||||
"mass": 250
|
||||
},
|
||||
"terrainMultiplier": "massMultiplier",
|
||||
"sheetSlot": "structureSheet",
|
||||
"frame": 2,
|
||||
"buildFrame": 3,
|
||||
"procShape": "massGen",
|
||||
"icon": 11
|
||||
},
|
||||
{
|
||||
"id": "barracks",
|
||||
"name": "Barracks",
|
||||
"footprint": {
|
||||
"w": 3,
|
||||
"h": 3
|
||||
},
|
||||
"hp": 2520,
|
||||
"armorClass": "structure",
|
||||
"sight": 220,
|
||||
"cost": {
|
||||
"energy": 600,
|
||||
"mass": 180
|
||||
},
|
||||
"buildTime": 26,
|
||||
"buildPower": 100,
|
||||
"builds": [
|
||||
"infantry",
|
||||
"sniper"
|
||||
],
|
||||
"spawnOffset": {
|
||||
"x": 0,
|
||||
"y": 2.2
|
||||
},
|
||||
"sheetSlot": "structureSheet",
|
||||
"frame": 4,
|
||||
"buildFrame": 5,
|
||||
"procShape": "barracks",
|
||||
"icon": 12
|
||||
},
|
||||
{
|
||||
"id": "vehicleplant",
|
||||
"name": "Vehicle Plant",
|
||||
"footprint": {
|
||||
"w": 3,
|
||||
"h": 3
|
||||
},
|
||||
"hp": 3960,
|
||||
"armorClass": "structure",
|
||||
"sight": 220,
|
||||
"cost": {
|
||||
"energy": 1400,
|
||||
"mass": 520
|
||||
},
|
||||
"buildTime": 40,
|
||||
"buildPower": 100,
|
||||
"builds": [
|
||||
"jeep",
|
||||
"tank",
|
||||
"rockettank"
|
||||
],
|
||||
"spawnOffset": {
|
||||
"x": 0,
|
||||
"y": 2.2
|
||||
},
|
||||
"sheetSlot": "structureSheet",
|
||||
"frame": 6,
|
||||
"buildFrame": 7,
|
||||
"procShape": "vehiclePlant",
|
||||
"icon": 13
|
||||
}
|
||||
],
|
||||
"commandIcons": {
|
||||
"move": 20,
|
||||
"attack": 21,
|
||||
"attackMove": 22,
|
||||
"stop": 23,
|
||||
"hold": 24,
|
||||
"patrol": 25,
|
||||
"guard": 26,
|
||||
"assist": 27
|
||||
},
|
||||
"aiSkills": [
|
||||
{
|
||||
"skill": 1,
|
||||
"decisionPeriodMs": 1500,
|
||||
"apmCap": 1,
|
||||
"squadSize": 3,
|
||||
"expansion": 0.6,
|
||||
"microLevel": 0,
|
||||
"reactionMs": 4000
|
||||
},
|
||||
{
|
||||
"skill": 2,
|
||||
"decisionPeriodMs": 1100,
|
||||
"apmCap": 2,
|
||||
"squadSize": 4,
|
||||
"expansion": 1.0,
|
||||
"microLevel": 1,
|
||||
"reactionMs": 3000
|
||||
},
|
||||
{
|
||||
"skill": 3,
|
||||
"decisionPeriodMs": 800,
|
||||
"apmCap": 4,
|
||||
"squadSize": 6,
|
||||
"expansion": 1.6,
|
||||
"microLevel": 2,
|
||||
"reactionMs": 2000
|
||||
},
|
||||
{
|
||||
"skill": 4,
|
||||
"decisionPeriodMs": 500,
|
||||
"apmCap": 8,
|
||||
"squadSize": 8,
|
||||
"expansion": 2.2,
|
||||
"microLevel": 3,
|
||||
"reactionMs": 1000
|
||||
},
|
||||
{
|
||||
"skill": 5,
|
||||
"decisionPeriodMs": 300,
|
||||
"apmCap": 15,
|
||||
"squadSize": 10,
|
||||
"expansion": 3.0,
|
||||
"microLevel": 4,
|
||||
"reactionMs": 300
|
||||
}
|
||||
],
|
||||
"skirmish": {
|
||||
"sizes": {
|
||||
"small": 64,
|
||||
"medium": 96,
|
||||
"large": 128
|
||||
},
|
||||
"symmetries": [
|
||||
"mirror-x",
|
||||
"mirror-y",
|
||||
"rotational"
|
||||
],
|
||||
"themes": [
|
||||
"grasslands",
|
||||
"snowfields",
|
||||
"tropics"
|
||||
],
|
||||
"defaults": {
|
||||
"size": "medium",
|
||||
"symmetry": "mirror-x",
|
||||
"theme": "grasslands",
|
||||
"aiSkill": 3,
|
||||
"playerArmy": "arm"
|
||||
},
|
||||
"startUnits": [
|
||||
"commander"
|
||||
],
|
||||
"gen": {
|
||||
"waterFraction": 0.1,
|
||||
"roughFraction": 0.18,
|
||||
"cliffFraction": 0.1,
|
||||
"metalSpotsPerStart": 4,
|
||||
"metalSpotRadiusTiles": 14,
|
||||
"minStartSeparationTiles": 40,
|
||||
"smoothPasses": 3,
|
||||
"startClearRadiusTiles": 8,
|
||||
"noiseScale": 0.09
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -393,6 +393,11 @@ export function openCityScreen(scene, rules, state, city, onClose) {
|
|||
Logic.setBuild(rules, state, city, ch.type, ch.id);
|
||||
redraw();
|
||||
});
|
||||
rect.on('wheel', (pointer, _dx, dy) => {
|
||||
scrollY = Phaser.Math.Clamp(scrollY + dy * 0.5, 0, maxScroll);
|
||||
tooltip.hide();
|
||||
syncRows();
|
||||
});
|
||||
tooltip.attachTo(rect, () => {
|
||||
if (ch.type === 'unit') return describeUnitTooltip(rules, ch.ruleObj);
|
||||
if (ch.type === 'building') return describeBuildingTooltip(rules, ch.ruleObj);
|
||||
|
|
@ -423,7 +428,7 @@ export function openCityScreen(scene, rules, state, city, onClose) {
|
|||
if (thumb) thumb.y = listY + (listH - thumbH) * (scrollY / maxScroll);
|
||||
};
|
||||
listBg.setInteractive();
|
||||
listBg.on('wheel', (pointer, dx, dy) => {
|
||||
listBg.on('wheel', (pointer, _dx, dy) => {
|
||||
scrollY = Phaser.Math.Clamp(scrollY + dy * 0.5, 0, maxScroll);
|
||||
// A row disabled mid-hover never fires pointerout, so its tooltip would
|
||||
// hang around over the list.
|
||||
|
|
|
|||
|
|
@ -1483,6 +1483,11 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
});
|
||||
},
|
||||
});
|
||||
} else if (e.type === 'buildingSold' && e.civ === human) {
|
||||
const city = Logic.cityById(this.state, e.cityId);
|
||||
const building = this.rules.buildings[e.building];
|
||||
if (!city || !building) continue;
|
||||
this.announceStatus(`Not enough gold! ${building.name} in ${city.name} was sold to cover upkeep.`);
|
||||
} else if (e.type === 'spaceshipPart' && e.civ === human) {
|
||||
const city = Logic.cityById(this.state, e.cityId);
|
||||
const part = this.rules.units[e.part];
|
||||
|
|
|
|||
|
|
@ -916,7 +916,8 @@ export function tryMove(rules, state, unit, dx, dy) {
|
|||
}
|
||||
|
||||
if (!canOccupy(rules, state, unit, tx, ty)) return { result: 'invalid' };
|
||||
if (targetCity && targetCity.civ !== unit.civ) return { result: 'invalid' };
|
||||
if (targetCity && targetCity.civ !== unit.civ && !def.flags.includes('caravan')) return { result: 'invalid' };
|
||||
if (targetCity && targetCity.civ !== unit.civ && state.civs[unit.civ].relations[targetCity.civ] === 'war') return { result: 'invalid' };
|
||||
|
||||
const cost = moveCost(rules, state, unit, unit.x, unit.y, tx, ty);
|
||||
if (!Number.isFinite(cost)) return { result: 'invalid' };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,576 @@
|
|||
// Total Annihilation — the CPU commander.
|
||||
//
|
||||
// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js.
|
||||
//
|
||||
// Two rules this file must never break:
|
||||
// 1. It issues orders ONLY through TALogic.issueOrder(). It never mutates state directly.
|
||||
// That's what lets the verify script point this same AI at the human's side and assert
|
||||
// a campaign mission is actually winnable.
|
||||
// 2. It sees only what fog lets it see (isVisibleTo), and it never gets free resources.
|
||||
// Difficulty is decision quality, reaction time and an orders-per-second cap — not
|
||||
// income cheating. A cheating AI's win rate would tell us nothing about the balance.
|
||||
|
||||
import { issueOrder, isVisibleTo, canPlaceAt, rngNext, rngInt } from './TALogic.js';
|
||||
import { worldToTileX, worldToTileY } from './TANav.js';
|
||||
|
||||
/** Desired force mix per skill. Below skill 3 the AI just buys whatever it can afford. */
|
||||
const COMPOSITION = {
|
||||
infantry: 0.34, sniper: 0.10, jeep: 0.06, tank: 0.34, rockettank: 0.16,
|
||||
};
|
||||
|
||||
function memFor(state, armyIdx) {
|
||||
if (!state.aiMem) state.aiMem = [];
|
||||
let m = state.aiMem[armyIdx];
|
||||
if (!m) {
|
||||
m = state.aiMem[armyIdx] = {
|
||||
nextThinkTick: 0,
|
||||
ordersThisSecond: 0, secondMark: 0,
|
||||
squad: [], squadPeak: 0, phase: 'build', focusId: 0,
|
||||
scoutId: 0,
|
||||
knownEnemies: [], // [{x,y,defId,isBuilding,tick}]
|
||||
lastAttackTick: -99999,
|
||||
buildSpiral: 0,
|
||||
baseX: 0, baseY: 0, baseSet: false,
|
||||
};
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one AI slice. Call every sim tick — it throttles itself to the skill's think period.
|
||||
* @param {object} profile { skill, aggression, expansion } — mission JSON can override
|
||||
*/
|
||||
export function runAI(rules, state, armyIdx, profile = {}) {
|
||||
if (state.over) return;
|
||||
const army = state.armies[armyIdx];
|
||||
if (!army || !army.alive) return;
|
||||
|
||||
const skill = Math.max(1, Math.min(5, profile.skill ?? army.aiSkill ?? 3));
|
||||
const base = rules.aiBySkill[skill] ?? rules.aiSkills[rules.aiSkills.length - 1];
|
||||
// `tune` lets a mission (or a tuning experiment) override individual dials without
|
||||
// inventing a whole new skill row.
|
||||
const tuning = profile.tune ? { ...base, ...profile.tune } : base;
|
||||
const aggression = profile.aggression ?? army.aiProfile?.aggression ?? 0.5;
|
||||
// Economic ambition is primarily a SKILL trait — tuning.expansion runs 0.6..3.0 across
|
||||
// skills 1-5, normalised here to 0.2..1.0. A commander's own aiProfile only nudges it,
|
||||
// otherwise every skill level expands identically and the ladder collapses.
|
||||
const skillExpansion = (tuning.expansion ?? 1.6) / 3;
|
||||
const expansion = profile.expansion
|
||||
?? (army.aiProfile?.expansion != null
|
||||
? (skillExpansion * 0.6 + army.aiProfile.expansion * 0.4)
|
||||
: skillExpansion);
|
||||
|
||||
const mem = memFor(state, armyIdx);
|
||||
const periodTicks = Math.max(1, Math.round((tuning.decisionPeriodMs / 1000) * rules.constants.tickHz));
|
||||
if (state.tick < mem.nextThinkTick) return;
|
||||
mem.nextThinkTick = state.tick + periodTicks;
|
||||
|
||||
// Orders-per-second cap: the honest handicap. A skill-1 AI physically cannot micro.
|
||||
if (state.tick - mem.secondMark >= rules.constants.tickHz) {
|
||||
mem.secondMark = state.tick;
|
||||
mem.ordersThisSecond = 0;
|
||||
}
|
||||
const ctx = {
|
||||
rules, state, armyIdx, army, skill, tuning, aggression, expansion, mem, profile,
|
||||
budget: Math.max(1, tuning.apmCap),
|
||||
};
|
||||
|
||||
observe(ctx);
|
||||
const mine = gatherOwn(ctx);
|
||||
if (!mem.baseSet) {
|
||||
const seed = mine.commanders[0] ?? mine.buildings[0] ?? mine.units[0];
|
||||
if (seed) { mem.baseX = seed.x; mem.baseY = seed.y; mem.baseSet = true; }
|
||||
}
|
||||
|
||||
manageEconomyAndBase(ctx, mine);
|
||||
manageProduction(ctx, mine);
|
||||
manageScout(ctx, mine);
|
||||
manageMilitary(ctx, mine);
|
||||
}
|
||||
|
||||
function order(ctx, cmd) {
|
||||
if (ctx.mem.ordersThisSecond >= ctx.budget) return { ok: false, error: 'apm cap' };
|
||||
ctx.mem.ordersThisSecond++;
|
||||
return issueOrder(ctx.state, ctx.rules, cmd);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Perception — fog-limited, with memory of where things were last seen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function observe(ctx) {
|
||||
const { state, armyIdx, mem } = ctx;
|
||||
const seen = [];
|
||||
for (const e of state.entities) {
|
||||
if (e.dead || e.army === armyIdx) continue;
|
||||
if (!state.armies[e.army]) continue;
|
||||
if (!isVisibleTo(state, armyIdx, e)) continue;
|
||||
seen.push({ id: e.id, x: e.x, y: e.y, defId: e.defId, isBuilding: e.isBuilding, tick: state.tick });
|
||||
}
|
||||
// Merge into memory: refresh what we can see, keep stale entries as "last known".
|
||||
const byId = new Map(mem.knownEnemies.map((k) => [k.id, k]));
|
||||
for (const s of seen) byId.set(s.id, s);
|
||||
// Forget anything we haven't seen in two minutes, and anything we can see is gone.
|
||||
const cutoff = state.tick - ctx.rules.constants.tickHz * 120;
|
||||
const live = new Set(state.entities.filter((e) => !e.dead).map((e) => e.id));
|
||||
mem.knownEnemies = [...byId.values()]
|
||||
.filter((k) => k.tick >= cutoff && live.has(k.id))
|
||||
.sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
function gatherOwn(ctx) {
|
||||
const { state, armyIdx, rules } = ctx;
|
||||
const out = { commanders: [], builders: [], factories: [], buildings: [], units: [], sites: [], byDef: {} };
|
||||
for (const e of state.entities) {
|
||||
if (e.dead || e.army !== armyIdx) continue;
|
||||
const def = rules.defById[e.defId];
|
||||
out.byDef[e.defId] = (out.byDef[e.defId] ?? 0) + 1;
|
||||
if (e.site) { out.sites.push(e); continue; }
|
||||
if (e.isBuilding) {
|
||||
out.buildings.push(e);
|
||||
if (def.builds?.length) out.factories.push(e);
|
||||
} else {
|
||||
out.units.push(e);
|
||||
if (def.builds?.length) { out.builders.push(e); out.commanders.push(e); }
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Economy and base building
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function manageEconomyAndBase(ctx, mine) {
|
||||
const { state, army, rules, mem, expansion } = ctx;
|
||||
const builder = mine.builders[0];
|
||||
if (!builder) return;
|
||||
|
||||
// Build orders QUEUE on the builder rather than replacing its current job, so the
|
||||
// Commander rolls straight from one structure into the next with no idle gap. Issuing
|
||||
// these unqueued caps every skill at one building in flight, which flattens the whole
|
||||
// skill ladder — a skill-5 economy then looks identical to a skill-1 one.
|
||||
const maxQueued = Math.max(1, Math.round(1 + expansion * 3));
|
||||
const queued = builder.orders.reduce((n, o) => n + (o.type === 'build' ? 1 : 0), 0);
|
||||
if (queued >= maxQueued) return;
|
||||
|
||||
const want = chooseBuilding(ctx, mine);
|
||||
if (!want) return;
|
||||
|
||||
const spot = findPlacement(ctx, mine, want, builder);
|
||||
if (!spot) return;
|
||||
|
||||
order(ctx, {
|
||||
army: ctx.armyIdx, unitIds: [builder.id],
|
||||
order: { type: 'build', defId: want.id, tx: spot.tx, ty: spot.ty },
|
||||
queue: queued > 0 || builder.orders.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
function chooseBuilding(ctx, mine) {
|
||||
const { rules, army, mem, expansion, skill } = ctx;
|
||||
const n = (id) => (mine.byDef[id] ?? 0);
|
||||
const eGen = rules.buildingById.energygen;
|
||||
const mGen = rules.buildingById.massgen;
|
||||
const barracks = rules.buildingById.barracks;
|
||||
const plant = rules.buildingById.vehicleplant;
|
||||
|
||||
// Opening: two power, one mass, then a barracks so there's something to fight with.
|
||||
if (n('energygen') < 2) return eGen;
|
||||
if (n('massgen') < 1) return mGen;
|
||||
if (n('barracks') < 1) return barracks;
|
||||
if (n('energygen') < 3) return eGen;
|
||||
if (n('massgen') < 2) return mGen;
|
||||
if (n('vehicleplant') < 1) return plant;
|
||||
|
||||
// Skills 1-2 stop here: a short fixed ladder and no governor at all. Without this the
|
||||
// bottom of the ladder still runs the same ratio-aware economy as the top and plays a
|
||||
// perfectly respectable game, which is why difficulty barely registered.
|
||||
if (skill <= 2) {
|
||||
const lim = skill === 1 ? 1 : 2;
|
||||
if (n('energygen') < 3 + lim) return eGen;
|
||||
if (n('massgen') < 2 + lim) return mGen;
|
||||
if (n('barracks') + n('vehicleplant') < 2 + lim) {
|
||||
return n('vehicleplant') <= n('barracks') ? plant : barracks;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Steady state: chase the energy:mass income RATIO that the unit roster actually costs.
|
||||
// A Tank is 700 energy to 180 mass, so roughly 4:1 — an AI that treats the two resources
|
||||
// symmetrically ends up capping out on energy while its factories starve for mass.
|
||||
const netE = Math.max(0, army.eIncome - army.eUpkeep);
|
||||
const ratio = netE / Math.max(0.5, army.mIncome);
|
||||
const TARGET_RATIO = 4.0;
|
||||
|
||||
const cap = Math.round(3 + expansion * 7);
|
||||
const roomE = n('energygen') < cap;
|
||||
// A Mass Generator is an energy CONSUMER. Building one without the headroom to power it
|
||||
// just throttles the ones already standing, so require spare energy before adding another.
|
||||
const upkeepPer = mGen.upkeep?.energy ?? 0;
|
||||
const roomM = n('massgen') < cap && netE > upkeepPer * 1.6;
|
||||
|
||||
if (army.stallM < 0.98 && roomM) return mGen;
|
||||
if (army.stallE < 0.98 && roomE) return eGen;
|
||||
|
||||
// Production capacity is grown ALONGSIDE the economy, sized to what the income can
|
||||
// actually feed. Saturating generators first (the obvious ordering) is why a big economy
|
||||
// used to win nothing: with only two factories the surplus just floated in storage, and an
|
||||
// army that out-earned its opponent two to one still fielded the same number of tanks.
|
||||
const factories = n('barracks') + n('vehicleplant');
|
||||
const nextFactory = () => (n('vehicleplant') <= n('barracks') ? plant : barracks);
|
||||
const factoryCap = Math.round(2 + expansion * 4);
|
||||
const supported = Math.floor(army.mIncome / Math.max(0.5, factoryMassDrain(rules, plant)));
|
||||
if (factories < Math.min(factoryCap, Math.max(1, supported))) return nextFactory();
|
||||
|
||||
if (ratio > TARGET_RATIO && roomM) return mGen;
|
||||
if (ratio < TARGET_RATIO && roomE) return eGen;
|
||||
if (roomM) return mGen;
|
||||
if (roomE) return eGen;
|
||||
if (factories < factoryCap) return nextFactory();
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Mass per second a factory of this type burns when kept busy. */
|
||||
function factoryMassDrain(rules, def) {
|
||||
const units = (def.builds ?? []).map((id) => rules.unitById[id]).filter(Boolean);
|
||||
if (!units.length) return 1;
|
||||
const avg = units.reduce((s, u) => s + u.cost.mass / u.buildTime, 0) / units.length;
|
||||
return avg * (def.buildPower / rules.constants.buildPowerNominal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spiral outward from the base for a legal footprint. Mass Generators additionally hunt for
|
||||
* a metal patch, which is what makes map control matter without a reclaim economy.
|
||||
*/
|
||||
function findPlacement(ctx, mine, def, builder) {
|
||||
const { state, rules } = ctx;
|
||||
const mem = ctx.mem;
|
||||
const ts = state.tileSize;
|
||||
const bx = worldToTileX(state.nav, mem.baseSet ? mem.baseX : builder.x);
|
||||
const by = worldToTileY(state.nav, mem.baseSet ? mem.baseY : builder.y);
|
||||
|
||||
if (def.terrainMultiplier) {
|
||||
const spot = findMetalSpot(ctx, def, bx, by);
|
||||
if (spot) return spot;
|
||||
}
|
||||
|
||||
const maxR = 22;
|
||||
for (let r = 2; r < maxR; r++) {
|
||||
// Rotate the starting angle per attempt so the base doesn't grow as a solid slab —
|
||||
// AoE chains through tightly packed buildings.
|
||||
const steps = Math.max(8, r * 4);
|
||||
const off = (ctx.mem.buildSpiral++ % steps);
|
||||
for (let s = 0; s < steps; s++) {
|
||||
const a = ((s + off) / steps) * Math.PI * 2;
|
||||
const tx = bx + Math.round(Math.cos(a) * r);
|
||||
const ty = by + Math.round(Math.sin(a) * r);
|
||||
if (!spacedEnough(ctx, mine, tx, ty, def)) continue;
|
||||
if (canPlaceAt(state, rules, tx, ty, def).ok) return { tx, ty };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMetalSpot(ctx, def, bx, by) {
|
||||
const { state, rules } = ctx;
|
||||
let best = null, bestD = Infinity;
|
||||
for (let ty = 0; ty < state.h; ty++) {
|
||||
for (let tx = 0; tx < state.w; tx++) {
|
||||
const t = rules.terrain[state.terrain[ty * state.w + tx]];
|
||||
if (!t.massMultiplier) continue;
|
||||
const d = Math.hypot(tx - bx, ty - by);
|
||||
if (d > 30 || d >= bestD) continue;
|
||||
if (!canPlaceAt(state, rules, tx, ty, def).ok) continue;
|
||||
best = { tx, ty }; bestD = d;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function spacedEnough(ctx, mine, tx, ty, def) {
|
||||
const gap = 1;
|
||||
for (const b of [...mine.buildings, ...mine.sites]) {
|
||||
if (tx + def.footprint.w + gap <= b.tx) continue;
|
||||
if (b.tx + b.fw + gap <= tx) continue;
|
||||
if (ty + def.footprint.h + gap <= b.ty) continue;
|
||||
if (b.ty + b.fh + gap <= ty) continue;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Production
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function manageProduction(ctx, mine) {
|
||||
const { rules, state, skill, army } = ctx;
|
||||
for (const f of mine.factories) {
|
||||
const def = rules.defById[f.defId];
|
||||
const builds = def.builds ?? [];
|
||||
if (!builds.length) continue;
|
||||
const queued = f.queue.reduce((s, q) => s + q.count, 0);
|
||||
if (queued >= 3) continue;
|
||||
// Don't feed a factory while the economy is already choking — an over-queued plant
|
||||
// starves the base builder and the AI never gets off the ground.
|
||||
if (army.buildEff < 0.55 && queued >= 1) continue;
|
||||
|
||||
const pick = skill <= 2 ? randomPick(ctx, builds) : templatePick(ctx, mine, builds);
|
||||
if (!pick) continue;
|
||||
order(ctx, {
|
||||
army: ctx.armyIdx,
|
||||
order: { type: 'factoryEnqueue', factoryId: f.id, defId: pick, count: 1 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function randomPick(ctx, builds) {
|
||||
return builds[rngInt(ctx.state, builds.length)];
|
||||
}
|
||||
|
||||
/** Pick whichever buildable unit is furthest below its share of the desired mix. */
|
||||
function templatePick(ctx, mine, builds) {
|
||||
const { rules, skill, profile } = ctx;
|
||||
const weights = { ...COMPOSITION, ...(profile.composition ?? {}) };
|
||||
if (skill >= 4) applyCounters(ctx, weights);
|
||||
|
||||
const total = builds.reduce((s, id) => s + (mine.byDef[id] ?? 0), 0) + 1;
|
||||
let best = null, bestGap = -Infinity;
|
||||
for (const id of builds) {
|
||||
const wgt = weights[id] ?? 0.1;
|
||||
const have = (mine.byDef[id] ?? 0) / total;
|
||||
const gap = wgt - have;
|
||||
if (gap > bestGap) { bestGap = gap; best = id; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Skill 4+: bias the mix against what we've actually seen the enemy field. */
|
||||
function applyCounters(ctx, weights) {
|
||||
const { mem, rules } = ctx;
|
||||
let infantryish = 0, armour = 0, structures = 0;
|
||||
for (const k of mem.knownEnemies) {
|
||||
const def = rules.defById[k.defId];
|
||||
if (!def) continue;
|
||||
if (k.isBuilding) { structures++; continue; }
|
||||
if (def.armorClass === 'infantry' || def.armorClass === 'light') infantryish++;
|
||||
else armour++;
|
||||
}
|
||||
const seen = infantryish + armour;
|
||||
if (!seen) return;
|
||||
const armourShare = armour / seen;
|
||||
// Tanks shred infantry; rockets and snipers answer armour and buildings.
|
||||
weights.tank *= 1 + (1 - armourShare) * 0.8;
|
||||
weights.rockettank *= 1 + armourShare * 1.0 + (structures > 2 ? 0.3 : 0);
|
||||
weights.sniper *= 1 + (1 - armourShare) * 0.5;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scouting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function manageScout(ctx, mine) {
|
||||
const { state, mem, skill } = ctx;
|
||||
if (skill < 3) return;
|
||||
const scout = state.entities.find((e) => e.id === mem.scoutId && !e.dead);
|
||||
if (scout) {
|
||||
if (scout.orders.length) return;
|
||||
const t = enemyBaseGuess(ctx);
|
||||
if (t) order(ctx, { army: ctx.armyIdx, unitIds: [scout.id], order: { type: 'move', x: t.x, y: t.y } });
|
||||
return;
|
||||
}
|
||||
const jeep = mine.units.find((u) => u.defId === 'jeep' && !u.orders.length);
|
||||
if (!jeep) return;
|
||||
mem.scoutId = jeep.id;
|
||||
const t = enemyBaseGuess(ctx);
|
||||
if (t) order(ctx, { army: ctx.armyIdx, unitIds: [jeep.id], order: { type: 'attackMove', x: t.x, y: t.y } });
|
||||
}
|
||||
|
||||
function enemyBaseGuess(ctx) {
|
||||
const { state, armyIdx, mem } = ctx;
|
||||
// Prefer something we've actually seen; fall back to the enemy's start position.
|
||||
const building = mem.knownEnemies.find((k) => k.isBuilding);
|
||||
if (building) return { x: building.x, y: building.y };
|
||||
if (mem.knownEnemies.length) return { x: mem.knownEnemies[0].x, y: mem.knownEnemies[0].y };
|
||||
|
||||
// Nothing in sight. If we've already been to the enemy start, sweep the map instead of
|
||||
// parking on a stale position — otherwise a won game never actually ends, because the
|
||||
// loser's last builder is hiding in a corner we stopped looking at.
|
||||
const start = state.starts.find((s) => s.army !== armyIdx);
|
||||
if (start) {
|
||||
const sx = (start.x + 0.5) * state.tileSize, sy = (start.y + 0.5) * state.tileSize;
|
||||
if (!mem.startCleared) {
|
||||
const near = state.entities.some((e) => !e.dead && e.army === armyIdx
|
||||
&& Math.hypot(e.x - sx, e.y - sy) < state.tileSize * 8);
|
||||
if (near) mem.startCleared = true;
|
||||
else return { x: sx, y: sy };
|
||||
}
|
||||
}
|
||||
return sweepTarget(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate through map sectors, preferring ones this army has never explored. This is what
|
||||
* turns a won game into a finished game.
|
||||
*/
|
||||
function sweepTarget(ctx) {
|
||||
const { state, army, mem } = ctx;
|
||||
const SECTORS = 4;
|
||||
const cellW = state.worldW / SECTORS, cellH = state.worldH / SECTORS;
|
||||
const cell = state.tileSize * 2;
|
||||
|
||||
const candidates = [];
|
||||
for (let sy = 0; sy < SECTORS; sy++) {
|
||||
for (let sx = 0; sx < SECTORS; sx++) {
|
||||
const cx = (sx + 0.5) * cellW, cy = (sy + 0.5) * cellH;
|
||||
const gx = Math.min(state.visW - 1, Math.floor(cx / cell));
|
||||
const gy = Math.min(state.visH - 1, Math.floor(cy / cell));
|
||||
const explored = army.explored[gy * state.visW + gx] === 1;
|
||||
candidates.push({ x: cx, y: cy, explored, i: sy * SECTORS + sx });
|
||||
}
|
||||
}
|
||||
const unexplored = candidates.filter((c) => !c.explored);
|
||||
const pool = unexplored.length ? unexplored : candidates;
|
||||
const pick = pool[(mem.sweepIdx = ((mem.sweepIdx ?? 0) + 1) % pool.length)];
|
||||
return { x: pick.x, y: pick.y };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Military
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function manageMilitary(ctx, mine) {
|
||||
const { state, rules, mem, tuning, aggression, skill, armyIdx } = ctx;
|
||||
|
||||
// Anything combat-capable and idle joins the pool.
|
||||
const idle = mine.units.filter((u) => {
|
||||
if (u.id === mem.scoutId) return false;
|
||||
const def = rules.defById[u.defId];
|
||||
if (!def.weaponDefs?.length || def.builds?.length) return false;
|
||||
return u.orders.length === 0;
|
||||
});
|
||||
|
||||
mem.squad = mem.squad.filter((id) => state.entities.some((e) => e.id === id && !e.dead));
|
||||
for (const u of idle) if (!mem.squad.includes(u.id)) mem.squad.push(u.id);
|
||||
|
||||
// Home defence always wins over pushing out — skill 1 never thinks to come home.
|
||||
const threat = skill >= 2 ? nearestThreatToBase(ctx) : null;
|
||||
if (threat) {
|
||||
const defenders = mem.squad.slice(0, Math.max(3, Math.ceil(mem.squad.length * 0.7)));
|
||||
if (defenders.length) {
|
||||
order(ctx, {
|
||||
army: armyIdx, unitIds: defenders,
|
||||
order: { type: 'attackMove', x: threat.x, y: threat.y },
|
||||
});
|
||||
mem.phase = 'defend';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const target = enemyBaseGuess(ctx);
|
||||
if (!target) return;
|
||||
|
||||
const wantSize = Math.max(2, Math.round(tuning.squadSize * (0.6 + aggression * 0.8)));
|
||||
if (mem.phase !== 'attack') {
|
||||
if (mem.squad.length < wantSize) {
|
||||
// Gather near the base while we build up.
|
||||
const gathering = idle.filter((u) => Math.hypot(u.x - mem.baseX, u.y - mem.baseY) > state.tileSize * 8);
|
||||
if (gathering.length) {
|
||||
order(ctx, {
|
||||
army: armyIdx, unitIds: gathering.map((u) => u.id),
|
||||
order: { type: 'move', x: mem.baseX, y: mem.baseY },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
mem.phase = 'attack';
|
||||
mem.squadPeak = mem.squad.length;
|
||||
mem.lastAttackTick = state.tick;
|
||||
}
|
||||
|
||||
if (mem.phase === 'attack') {
|
||||
// Break off if the push has been gutted — skill 2+ knows when it's lost a fight.
|
||||
if (skill >= 2 && mem.squad.length < mem.squadPeak * 0.4) {
|
||||
mem.phase = 'build';
|
||||
order(ctx, {
|
||||
army: armyIdx, unitIds: mem.squad.slice(),
|
||||
order: { type: 'move', x: mem.baseX, y: mem.baseY },
|
||||
});
|
||||
mem.squad = [];
|
||||
return;
|
||||
}
|
||||
const pushing = mem.squad.filter((id) => {
|
||||
const e = state.entities.find((x) => x.id === id && !x.dead);
|
||||
return e && e.orders.length === 0;
|
||||
});
|
||||
if (pushing.length) {
|
||||
order(ctx, {
|
||||
army: armyIdx, unitIds: pushing,
|
||||
order: { type: 'attackMove', x: target.x, y: target.y },
|
||||
});
|
||||
}
|
||||
if (skill >= 3) focusFire(ctx, mine);
|
||||
}
|
||||
}
|
||||
|
||||
function nearestThreatToBase(ctx) {
|
||||
const { state, mem, armyIdx } = ctx;
|
||||
if (!mem.baseSet) return null;
|
||||
const radius = state.tileSize * 16;
|
||||
let best = null, bestD = Infinity;
|
||||
for (const e of state.entities) {
|
||||
if (e.dead || e.army === armyIdx || !state.armies[e.army]) continue;
|
||||
if (!isVisibleTo(state, armyIdx, e)) continue;
|
||||
if (e.isBuilding) continue;
|
||||
const d = Math.hypot(e.x - mem.baseX, e.y - mem.baseY);
|
||||
if (d < radius && d < bestD) { best = e; bestD = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Skill 3+: concentrate the squad on one target instead of spreading damage. */
|
||||
function focusFire(ctx, mine) {
|
||||
const { state, rules, mem, armyIdx } = ctx;
|
||||
const members = mem.squad
|
||||
.map((id) => state.entities.find((e) => e.id === id && !e.dead))
|
||||
.filter(Boolean);
|
||||
if (members.length < 3) return;
|
||||
|
||||
// An `attack` order REPLACES the unit's queue and pins its target, so re-picking one every
|
||||
// think interrupts the push and leaves the squad ignoring whatever is actually shooting it.
|
||||
// Focus fire is therefore sticky: hold the current victim until it dies or gets out of
|
||||
// reach, and only ever commit to something the squad can already shoot without moving.
|
||||
const focus = state.entities.find((e) => e.id === mem.focusId && !e.dead);
|
||||
if (focus && isVisibleTo(state, armyIdx, focus)) {
|
||||
const stillOn = members.filter((u) => inReach(rules, u, focus)).length;
|
||||
if (stillOn >= 2) return; // current focus is fine; don't churn orders
|
||||
}
|
||||
mem.focusId = 0;
|
||||
|
||||
let best = null, bestScore = -Infinity, bestShooters = null;
|
||||
for (const e of state.entities) {
|
||||
if (e.dead || e.army === armyIdx || !state.armies[e.army]) continue;
|
||||
if (!isVisibleTo(state, armyIdx, e)) continue;
|
||||
const shooters = members.filter((u) => inReach(rules, u, e));
|
||||
if (shooters.length < Math.max(2, Math.ceil(members.length * 0.4))) continue;
|
||||
const def = rules.defById[e.defId];
|
||||
// Prefer the closest thing to dying that can still shoot back.
|
||||
const score = (def.weaponDefs?.length ? 2 : 1) * (1.3 - e.hp / e.maxHp) * shooters.length;
|
||||
if (score > bestScore) { best = e; bestScore = score; bestShooters = shooters; }
|
||||
}
|
||||
if (!best) return;
|
||||
mem.focusId = best.id;
|
||||
order(ctx, {
|
||||
army: armyIdx, unitIds: bestShooters.map((u) => u.id),
|
||||
order: { type: 'attack', targetId: best.id },
|
||||
});
|
||||
}
|
||||
|
||||
/** True when `u` could fire on `t` from where it already stands. */
|
||||
function inReach(rules, u, t) {
|
||||
const def = rules.defById[u.defId];
|
||||
return Math.hypot(u.x - t.x, u.y - t.y) - t.radius <= def.maxRange;
|
||||
}
|
||||
|
||||
export { memFor };
|
||||
|
|
@ -0,0 +1,576 @@
|
|||
// Total Annihilation — procedural art fallback.
|
||||
//
|
||||
// Every spritesheet declared in data/totalannihilation-artwork.json is optional. If its
|
||||
// `path` is null (or the PNG 404s), we paint a canvas stand-in with the identical frame
|
||||
// layout, so the view never knows the difference and the game is fully playable with zero
|
||||
// art files present.
|
||||
//
|
||||
// The one structural change from the Advance Wars original this is modelled on
|
||||
// (AdvanceWarsMapView.js ensureSheets/PROC_PAINTERS): painters are keyed by the sheet's
|
||||
// `kind`, NOT by its name. That is what makes adding a third army or a fourth terrain theme
|
||||
// a pure JSON edit — a new sheet with kind:"unit" automatically gets the unit painter.
|
||||
|
||||
const BODY = '#dcdcdc'; // neutral grey — the runtime army tint multiplies over this
|
||||
const BODY_DARK = '#a8adb5';
|
||||
const OUTLINE = '#26262e';
|
||||
const ACCENT = '#8f939b';
|
||||
const GLASS = '#3a4450';
|
||||
const HOT = '#ffd27a';
|
||||
|
||||
/** Deterministic per-frame PRNG so speckles are stable across reloads. */
|
||||
function frameRng(seed) {
|
||||
let a = (seed * 2654435761) >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), a | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a canvas texture laid out as a spritesheet and register its frames.
|
||||
* `at(frame, draw)` translates the context to that frame's origin; `finish()` uploads.
|
||||
*/
|
||||
function mkCanvasSheet(scene, key, wantW, wantH, wantCols, wantCount) {
|
||||
// Guard every dimension. A NaN anywhere here reaches createCanvas as a zero-height texture
|
||||
// and surfaces as an opaque WebGL "no canvas" error a long way from the actual mistake.
|
||||
const size = (v, fallback) => (Number.isFinite(v) && v > 0 ? Math.floor(v) : fallback);
|
||||
const frameW = size(wantW, 64);
|
||||
const frameH = size(wantH, 64);
|
||||
const cols = size(wantCols, 8);
|
||||
const count = size(wantCount, 1);
|
||||
if (frameW !== wantW || frameH !== wantH || cols !== wantCols || count !== wantCount) {
|
||||
console.warn(`[TAArt] sheet "${key}" has a bad layout (${wantW}x${wantH}, cols ${wantCols}, `
|
||||
+ `frames ${wantCount}) — falling back to ${frameW}x${frameH}, cols ${cols}, frames ${count}`);
|
||||
}
|
||||
const rows = Math.max(1, Math.ceil(count / cols));
|
||||
const tex = scene.textures.createCanvas(key, cols * frameW, rows * frameH);
|
||||
const ctx = tex.getContext();
|
||||
return {
|
||||
ctx,
|
||||
at(frame, draw) {
|
||||
const fx = (frame % cols) * frameW;
|
||||
const fy = ((frame / cols) | 0) * frameH;
|
||||
ctx.save();
|
||||
ctx.translate(fx, fy);
|
||||
draw(ctx, frameW, frameH);
|
||||
ctx.restore();
|
||||
},
|
||||
finish() {
|
||||
tex.refresh();
|
||||
for (let f = 0; f < count; f++) {
|
||||
const fx = (f % cols) * frameW;
|
||||
const fy = ((f / cols) | 0) * frameH;
|
||||
tex.add(f, 0, fx, fy, frameW, frameH);
|
||||
}
|
||||
return tex;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function fillStroke(ctx, fill, stroke = OUTLINE, lw = 2) {
|
||||
ctx.fillStyle = fill; ctx.fill();
|
||||
if (stroke) { ctx.lineWidth = lw; ctx.strokeStyle = stroke; ctx.stroke(); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit shapes — all drawn FACING RIGHT (+x), centred, inside a rotation-safe circle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UNIT_SHAPES = {
|
||||
commander(ctx, S) {
|
||||
const c = S / 2;
|
||||
// legs behind
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
ctx.fillStyle = BODY_DARK; ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2;
|
||||
for (const sy of [-9, 9]) {
|
||||
roundRect(ctx, -14, sy - 4, 14, 8, 3); ctx.fill(); ctx.stroke();
|
||||
}
|
||||
// torso
|
||||
roundRect(ctx, -11, -11, 24, 22, 6); fillStroke(ctx, BODY);
|
||||
// shoulder pods
|
||||
for (const sy of [-12, 12]) {
|
||||
ctx.beginPath(); ctx.arc(-2, sy, 5.5, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
|
||||
}
|
||||
// visor
|
||||
ctx.fillStyle = GLASS;
|
||||
roundRect(ctx, 6, -5, 6, 10, 2); ctx.fill();
|
||||
// nanolathe arm
|
||||
ctx.strokeStyle = ACCENT; ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(10, -3); ctx.lineTo(21, -3); ctx.stroke();
|
||||
ctx.fillStyle = HOT;
|
||||
ctx.beginPath(); ctx.arc(22, -3, 2.5, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
commanderTurret(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
ctx.beginPath(); ctx.arc(0, 0, 7, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
|
||||
roundRect(ctx, 5, -4.5, 17, 9, 3); fillStroke(ctx, BODY);
|
||||
ctx.strokeStyle = '#8ad4ff'; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.arc(21, 0, 5, -0.9, 0.9); ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
infantry(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
roundRect(ctx, -8, -5, 16, 10, 5); fillStroke(ctx, BODY);
|
||||
ctx.beginPath(); ctx.arc(1, 0, 4.5, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(4, -2); ctx.lineTo(14, -2); ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
sniper(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
roundRect(ctx, -9, -4, 17, 8, 4); fillStroke(ctx, BODY);
|
||||
ctx.beginPath(); ctx.arc(0, 0, 4, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK);
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(3, -2); ctx.lineTo(20, -2); ctx.stroke();
|
||||
ctx.fillStyle = GLASS;
|
||||
ctx.beginPath(); ctx.arc(6, -4.5, 2.2, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
jeep(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
// wheels first, so the body overlaps them
|
||||
ctx.fillStyle = '#2f3238';
|
||||
for (const [wx, wy] of [[-8, -9], [8, -9], [-8, 9], [8, 9]]) {
|
||||
roundRect(ctx, wx - 4, wy - 3, 8, 6, 2.5); ctx.fill();
|
||||
}
|
||||
roundRect(ctx, -13, -8, 26, 16, 4); fillStroke(ctx, BODY);
|
||||
// windscreen wedge
|
||||
ctx.fillStyle = GLASS;
|
||||
ctx.beginPath(); ctx.moveTo(4, -6); ctx.lineTo(12, -3); ctx.lineTo(12, 3); ctx.lineTo(4, 6);
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
jeepTurret(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
ctx.beginPath(); ctx.arc(0, 0, 4.5, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2.5;
|
||||
ctx.beginPath(); ctx.moveTo(3, 0); ctx.lineTo(14, 0); ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
tank(ctx, S) { tankHull(ctx, S, 34, 24); },
|
||||
rockettank(ctx, S) { tankHull(ctx, S, 36, 24); },
|
||||
|
||||
tankTurret(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
// trapezoid turret
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-8, -8); ctx.lineTo(7, -6); ctx.lineTo(7, 6); ctx.lineTo(-8, 8);
|
||||
ctx.closePath(); fillStroke(ctx, BODY);
|
||||
ctx.beginPath(); ctx.arc(-1, 0, 4, 0, Math.PI * 2); fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 4;
|
||||
ctx.beginPath(); ctx.moveTo(5, 0); ctx.lineTo(24, 0); ctx.stroke();
|
||||
ctx.strokeStyle = BODY_DARK; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(5, 0); ctx.lineTo(24, 0); ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
rockettankTurret(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
roundRect(ctx, -8, -9, 18, 18, 3); fillStroke(ctx, BODY);
|
||||
ctx.fillStyle = '#2f3238';
|
||||
for (const [tx, ty] of [[3, -4.5], [3, 4.5], [-2, -4.5], [-2, 4.5]]) {
|
||||
ctx.beginPath(); ctx.arc(tx, ty, 2.6, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
};
|
||||
|
||||
function tankHull(ctx, S, len, wid) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
// tread rails
|
||||
ctx.fillStyle = '#33363c'; ctx.strokeStyle = OUTLINE; ctx.lineWidth = 1.5;
|
||||
for (const sy of [-wid / 2, wid / 2 - 6]) {
|
||||
roundRect(ctx, -len / 2, sy, len, 6, 2); ctx.fill(); ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = '#4a4e56';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const bx = -len / 2 + 2 + i * ((len - 4) / 6);
|
||||
ctx.fillRect(bx, -wid / 2 + 1, 3, 4);
|
||||
ctx.fillRect(bx, wid / 2 - 5, 3, 4);
|
||||
}
|
||||
// hull
|
||||
roundRect(ctx, -len / 2 + 3, -wid / 2 + 5, len - 6, wid - 10, 4); fillStroke(ctx, BODY);
|
||||
// glacis plate
|
||||
ctx.fillStyle = BODY_DARK;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(len / 2 - 3, -wid / 2 + 6); ctx.lineTo(len / 2 - 3, wid / 2 - 6);
|
||||
ctx.lineTo(len / 2 - 9, wid / 2 - 6); ctx.lineTo(len / 2 - 9, -wid / 2 + 6);
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structure shapes — NOT rotated, drawn to fill the cell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STRUCT_SHAPES = {
|
||||
energyGen(ctx, S) {
|
||||
const p = S * 0.12, d = S - p * 2;
|
||||
roundRect(ctx, p, p, d, d, S * 0.10); fillStroke(ctx, BODY, OUTLINE, 3);
|
||||
// vent slots
|
||||
ctx.fillStyle = '#3b3f47';
|
||||
for (let i = 0; i < 4; i++) ctx.fillRect(p + d * 0.14, p + d * (0.16 + i * 0.18), d * 0.24, d * 0.10);
|
||||
// lightning glyph
|
||||
ctx.fillStyle = HOT;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p + d * 0.66, p + d * 0.14);
|
||||
ctx.lineTo(p + d * 0.48, p + d * 0.52);
|
||||
ctx.lineTo(p + d * 0.62, p + d * 0.52);
|
||||
ctx.lineTo(p + d * 0.46, p + d * 0.90);
|
||||
ctx.lineTo(p + d * 0.82, p + d * 0.44);
|
||||
ctx.lineTo(p + d * 0.66, p + d * 0.44);
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 2; ctx.stroke();
|
||||
},
|
||||
|
||||
massGen(ctx, S) {
|
||||
const c = S / 2, r = S * 0.40;
|
||||
// hex pad
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = (i / 6) * Math.PI * 2 + Math.PI / 6;
|
||||
const x = c + Math.cos(a) * r, y = c + Math.sin(a) * r;
|
||||
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
|
||||
}
|
||||
ctx.closePath(); fillStroke(ctx, BODY, OUTLINE, 3);
|
||||
// concentric rings
|
||||
ctx.strokeStyle = ACCENT; ctx.lineWidth = 2.5;
|
||||
for (const rr of [r * 0.62, r * 0.44, r * 0.26]) {
|
||||
ctx.beginPath(); ctx.arc(c, c, rr, 0, Math.PI * 2); ctx.stroke();
|
||||
}
|
||||
// drill spindle
|
||||
ctx.beginPath(); ctx.arc(c, c, r * 0.16, 0, Math.PI * 2); fillStroke(ctx, '#3b3f47', OUTLINE, 2);
|
||||
// corner anchors
|
||||
ctx.fillStyle = BODY_DARK;
|
||||
for (const [ax, ay] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
|
||||
roundRect(ctx, c + ax * r * 0.74 - 5, c + ay * r * 0.74 - 5, 10, 10, 2); ctx.fill();
|
||||
}
|
||||
},
|
||||
|
||||
barracks(ctx, S) {
|
||||
const p = S * 0.10, w = S - p * 2, h = S - p * 2;
|
||||
roundRect(ctx, p, p, w, h, S * 0.06); fillStroke(ctx, BODY, OUTLINE, 3);
|
||||
// corrugated roof
|
||||
ctx.strokeStyle = BODY_DARK; ctx.lineWidth = 2;
|
||||
for (let i = 1; i < 7; i++) {
|
||||
const y = p + (h * i) / 8;
|
||||
ctx.beginPath(); ctx.moveTo(p + 4, y); ctx.lineTo(p + w - 4, y); ctx.stroke();
|
||||
}
|
||||
// arched door at the bay end
|
||||
ctx.fillStyle = '#2f3238';
|
||||
ctx.beginPath();
|
||||
const dw = w * 0.34, dx = p + w / 2 - dw / 2, dy = p + h * 0.62;
|
||||
ctx.moveTo(dx, p + h - 4);
|
||||
ctx.lineTo(dx, dy + dw / 2);
|
||||
ctx.arc(dx + dw / 2, dy + dw / 2, dw / 2, Math.PI, 0);
|
||||
ctx.lineTo(dx + dw, p + h - 4);
|
||||
ctx.closePath(); ctx.fill();
|
||||
// marching chevron over the door
|
||||
ctx.strokeStyle = HOT; ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p + w / 2 - 10, dy - 12); ctx.lineTo(p + w / 2, dy - 4); ctx.lineTo(p + w / 2 + 10, dy - 12);
|
||||
ctx.stroke();
|
||||
},
|
||||
|
||||
vehiclePlant(ctx, S) {
|
||||
const p = S * 0.07, w = S - p * 2, h = S - p * 2;
|
||||
roundRect(ctx, p, p, w, h, S * 0.05); fillStroke(ctx, BODY, OUTLINE, 3);
|
||||
// gantry rail across the roof
|
||||
ctx.fillStyle = BODY_DARK;
|
||||
ctx.fillRect(p + 4, p + h * 0.12, w - 8, h * 0.09);
|
||||
ctx.fillStyle = '#3b3f47';
|
||||
for (let i = 0; i < 5; i++) ctx.fillRect(p + 8 + i * ((w - 16) / 5), p + h * 0.12, 5, h * 0.09);
|
||||
// roll-up bay door with slats
|
||||
const dw = w * 0.62, dx = p + w / 2 - dw / 2, dy = p + h * 0.38, dh = h * 0.44;
|
||||
ctx.fillStyle = '#2f3238';
|
||||
ctx.fillRect(dx, dy, dw, dh);
|
||||
ctx.strokeStyle = '#4a4e56'; ctx.lineWidth = 2;
|
||||
for (let i = 1; i < 6; i++) {
|
||||
const y = dy + (dh * i) / 6;
|
||||
ctx.beginPath(); ctx.moveTo(dx + 2, y); ctx.lineTo(dx + dw - 2, y); ctx.stroke();
|
||||
}
|
||||
// floor chevron pointing out of the mouth — also shows where units will spawn
|
||||
ctx.strokeStyle = HOT; ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p + w / 2 - 14, p + h - 20); ctx.lineTo(p + w / 2, p + h - 8); ctx.lineTo(p + w / 2 + 14, p + h - 20);
|
||||
ctx.stroke();
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Painters, keyed by sheet kind
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROC_PAINTERS = {
|
||||
/** One frame per unit hull, plus one per turret. Driven entirely off the rules. */
|
||||
unit(scene, key, spec, rules) {
|
||||
const defs = rules.units.filter((u) => u.sheetSlot === 'unitSheet');
|
||||
let count = 0;
|
||||
for (const d of defs) count = Math.max(count, d.frame + 1, (d.turretFrame ?? -1) + 1);
|
||||
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
|
||||
for (const d of defs) {
|
||||
const hull = UNIT_SHAPES[d.procShape];
|
||||
if (hull) sh.at(d.frame, (ctx, S) => hull(ctx, S));
|
||||
if (d.turretFrame != null) {
|
||||
const turret = UNIT_SHAPES[`${d.procShape}Turret`];
|
||||
if (turret) sh.at(d.turretFrame, (ctx, S) => turret(ctx, S));
|
||||
}
|
||||
}
|
||||
return sh.finish();
|
||||
},
|
||||
|
||||
/** Two frames per building: finished, then a translucent wireframe for the build site. */
|
||||
structure(scene, key, spec, rules) {
|
||||
const defs = rules.buildings.filter((b) => b.sheetSlot === 'structureSheet');
|
||||
let count = 0;
|
||||
for (const d of defs) count = Math.max(count, d.frame + 1, (d.buildFrame ?? -1) + 1);
|
||||
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
|
||||
for (const d of defs) {
|
||||
const draw = STRUCT_SHAPES[d.procShape];
|
||||
if (!draw) continue;
|
||||
sh.at(d.frame, (ctx, S) => draw(ctx, S));
|
||||
if (d.buildFrame != null) {
|
||||
sh.at(d.buildFrame, (ctx, S) => {
|
||||
ctx.globalAlpha = 0.35;
|
||||
draw(ctx, S);
|
||||
ctx.globalAlpha = 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
return sh.finish();
|
||||
},
|
||||
|
||||
/** One sheet per theme, coloured entirely from that theme's palette. */
|
||||
terrain(scene, key, spec, rules, art, sheetName) {
|
||||
const theme = Object.values(art.themes ?? {}).find((t) => t.sheet === sheetName);
|
||||
const p = theme?.palette ?? {};
|
||||
const F = art.terrainFrames ?? {};
|
||||
// The JSON carries a `_comment` key alongside the frame numbers, so filter to actual
|
||||
// numbers — one string in this list turns the max into NaN and the sheet into a
|
||||
// zero-height canvas.
|
||||
const frameNums = Object.values(F).filter((v) => Number.isFinite(v));
|
||||
const count = Math.max(9, ...frameNums.map((v) => v + 1));
|
||||
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
|
||||
|
||||
const speckle = (ctx, S, seed, color, n, r0, r1) => {
|
||||
const rnd = frameRng(seed);
|
||||
ctx.fillStyle = color;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = rnd() * S, y = rnd() * S, r = r0 + rnd() * (r1 - r0);
|
||||
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
};
|
||||
const flat = (frame, color, seed, speckColor, n = 10) => sh.at(frame, (ctx, S) => {
|
||||
ctx.fillStyle = color; ctx.fillRect(0, 0, S, S);
|
||||
speckle(ctx, S, seed, speckColor, n, 1, 2.6);
|
||||
});
|
||||
|
||||
flat(F.ground ?? 0, p.ground ?? '#5f7f3d', 11, p.speck ?? '#000', 10);
|
||||
flat(F.groundAlt1 ?? 1, p.groundAlt1 ?? p.ground ?? '#6b8c45', 22, p.speck ?? '#000', 12);
|
||||
flat(F.groundAlt2 ?? 2, p.groundAlt2 ?? p.ground ?? '#55743a', 33, p.speck ?? '#000', 8);
|
||||
|
||||
sh.at(F.rough ?? 3, (ctx, S) => {
|
||||
ctx.fillStyle = p.rough ?? '#7d7048'; ctx.fillRect(0, 0, S, S);
|
||||
speckle(ctx, S, 44, 'rgba(0,0,0,0.30)', 22, 1.5, 3.6);
|
||||
speckle(ctx, S, 45, 'rgba(255,255,255,0.14)', 14, 1, 2.4);
|
||||
});
|
||||
|
||||
sh.at(F.cliff ?? 4, (ctx, S) => {
|
||||
ctx.fillStyle = p.cliff ?? '#4c4838'; ctx.fillRect(0, 0, S, S);
|
||||
// lit top edge sells the height difference without a real height layer
|
||||
ctx.fillStyle = p.cliffTop ?? '#6b6552';
|
||||
ctx.fillRect(0, 0, S, S * 0.30);
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.45)'; ctx.lineWidth = 2;
|
||||
const rnd = frameRng(55);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const x = rnd() * S;
|
||||
ctx.beginPath(); ctx.moveTo(x, S * 0.30); ctx.lineTo(x + (rnd() - 0.5) * 12, S); ctx.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
const wave = (frame, color, seed) => sh.at(frame, (ctx, S) => {
|
||||
ctx.fillStyle = color; ctx.fillRect(0, 0, S, S);
|
||||
const rnd = frameRng(seed);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.16)'; ctx.lineWidth = 2;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const y = rnd() * S;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.quadraticCurveTo(S / 2, y + (rnd() - 0.5) * 10, S, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
wave(F.water ?? 5, p.water ?? '#2f5f9e', 66);
|
||||
wave(F.waterDeep ?? 6, p.waterDeep ?? '#1d3f70', 77);
|
||||
|
||||
sh.at(F.metal ?? 7, (ctx, S) => {
|
||||
ctx.fillStyle = p.metal ?? '#9aa3ab'; ctx.fillRect(0, 0, S, S);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.35)';
|
||||
for (let ry = 0; ry < 3; ry++) {
|
||||
for (let rx = 0; rx < 3; rx++) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(S * (0.22 + rx * 0.28), S * (0.22 + ry * 0.28), 2.6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.20)'; ctx.lineWidth = 2;
|
||||
ctx.strokeRect(3, 3, S - 6, S - 6);
|
||||
});
|
||||
|
||||
flat(F.road ?? 8, p.road ?? '#8b8578', 88, 'rgba(0,0,0,0.25)', 6);
|
||||
return sh.finish();
|
||||
},
|
||||
|
||||
/** Build-menu glyphs: a dark plate plus a shrunken copy of the unit/structure shape. */
|
||||
icon(scene, key, spec, rules) {
|
||||
const entries = [];
|
||||
for (const d of [...rules.units, ...rules.buildings]) {
|
||||
if (d.icon != null) entries.push({ frame: d.icon, shape: d.procShape, building: !!d.isBuilding });
|
||||
}
|
||||
const cmd = rules.commandIcons ?? {};
|
||||
let count = 0;
|
||||
for (const e of entries) count = Math.max(count, e.frame + 1);
|
||||
for (const f of Object.values(cmd)) count = Math.max(count, f + 1);
|
||||
|
||||
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
|
||||
const plate = (ctx, S) => {
|
||||
roundRect(ctx, 2, 2, S - 4, S - 4, 6);
|
||||
ctx.fillStyle = '#1b2029'; ctx.fill();
|
||||
ctx.strokeStyle = '#3d4753'; ctx.lineWidth = 2; ctx.stroke();
|
||||
};
|
||||
|
||||
for (const e of entries) {
|
||||
sh.at(e.frame, (ctx, S) => {
|
||||
plate(ctx, S);
|
||||
const shape = e.building ? STRUCT_SHAPES[e.shape] : UNIT_SHAPES[e.shape];
|
||||
if (!shape) return;
|
||||
ctx.save();
|
||||
if (e.building) {
|
||||
ctx.translate(S * 0.12, S * 0.12);
|
||||
ctx.scale(0.76, 0.76);
|
||||
shape(ctx, S);
|
||||
} else {
|
||||
// Units are drawn facing right around the cell centre; turn them 45 degrees so a
|
||||
// build icon reads as a vehicle rather than as a sideways smear.
|
||||
ctx.translate(S / 2, S / 2); ctx.rotate(-Math.PI / 4); ctx.scale(0.95, 0.95);
|
||||
ctx.translate(-S / 2, -S / 2);
|
||||
shape(ctx, S);
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
|
||||
const glyph = (frame, draw) => { if (frame != null) sh.at(frame, (ctx, S) => { plate(ctx, S); draw(ctx, S); }); };
|
||||
const stroke = (ctx, color = '#cfe3ff', lw = 3) => { ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.stroke(); };
|
||||
|
||||
glyph(cmd.move, (ctx, S) => {
|
||||
ctx.beginPath(); ctx.moveTo(S * 0.28, S * 0.72); ctx.lineTo(S * 0.72, S * 0.28); stroke(ctx);
|
||||
ctx.beginPath(); ctx.moveTo(S * 0.72, S * 0.28); ctx.lineTo(S * 0.52, S * 0.30);
|
||||
ctx.moveTo(S * 0.72, S * 0.28); ctx.lineTo(S * 0.70, S * 0.48); stroke(ctx);
|
||||
});
|
||||
glyph(cmd.attack, (ctx, S) => {
|
||||
ctx.beginPath(); ctx.arc(S / 2, S / 2, S * 0.24, 0, Math.PI * 2); stroke(ctx, '#ff9a8a');
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(S / 2, S * 0.16); ctx.lineTo(S / 2, S * 0.34);
|
||||
ctx.moveTo(S / 2, S * 0.66); ctx.lineTo(S / 2, S * 0.84);
|
||||
ctx.moveTo(S * 0.16, S / 2); ctx.lineTo(S * 0.34, S / 2);
|
||||
ctx.moveTo(S * 0.66, S / 2); ctx.lineTo(S * 0.84, S / 2);
|
||||
stroke(ctx, '#ff9a8a', 2.5);
|
||||
});
|
||||
glyph(cmd.attackMove, (ctx, S) => {
|
||||
ctx.beginPath(); ctx.arc(S / 2, S / 2, S * 0.20, 0, Math.PI * 2); stroke(ctx, '#ffc98a', 2.5);
|
||||
ctx.beginPath(); ctx.moveTo(S * 0.20, S * 0.80); ctx.lineTo(S * 0.80, S * 0.20); stroke(ctx, '#ffc98a', 2.5);
|
||||
});
|
||||
glyph(cmd.stop, (ctx, S) => {
|
||||
roundRect(ctx, S * 0.30, S * 0.30, S * 0.40, S * 0.40, 3);
|
||||
ctx.fillStyle = '#ff9a8a'; ctx.fill();
|
||||
});
|
||||
glyph(cmd.hold, (ctx, S) => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(S / 2, S * 0.20); ctx.lineTo(S * 0.76, S * 0.34);
|
||||
ctx.lineTo(S * 0.76, S * 0.58); ctx.lineTo(S / 2, S * 0.80);
|
||||
ctx.lineTo(S * 0.24, S * 0.58); ctx.lineTo(S * 0.24, S * 0.34);
|
||||
ctx.closePath(); stroke(ctx, '#9ee8b0');
|
||||
});
|
||||
glyph(cmd.patrol, (ctx, S) => {
|
||||
ctx.beginPath(); ctx.arc(S / 2, S / 2, S * 0.22, 0.6, Math.PI * 1.7); stroke(ctx);
|
||||
ctx.beginPath(); ctx.moveTo(S * 0.70, S * 0.62); ctx.lineTo(S * 0.78, S * 0.72);
|
||||
ctx.lineTo(S * 0.62, S * 0.76); stroke(ctx, '#cfe3ff', 2.5);
|
||||
});
|
||||
glyph(cmd.guard, (ctx, S) => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(S / 2, S * 0.18); ctx.lineTo(S * 0.78, S * 0.32);
|
||||
ctx.lineTo(S * 0.70, S * 0.72); ctx.lineTo(S / 2, S * 0.84);
|
||||
ctx.lineTo(S * 0.30, S * 0.72); ctx.lineTo(S * 0.22, S * 0.32);
|
||||
ctx.closePath(); ctx.fillStyle = 'rgba(158,232,176,0.25)'; ctx.fill(); stroke(ctx, '#9ee8b0', 2.5);
|
||||
});
|
||||
glyph(cmd.assist, (ctx, S) => {
|
||||
ctx.strokeStyle = '#8ad4ff'; ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(S / 2, S * 0.24); ctx.lineTo(S / 2, S * 0.76);
|
||||
ctx.moveTo(S * 0.24, S / 2); ctx.lineTo(S * 0.76, S / 2);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
return sh.finish();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve every declared sheet to a usable texture key, painting stand-ins as needed.
|
||||
* @returns {{keys:Object<string,string>, procedural:string[]}} sheetName -> texture key
|
||||
*/
|
||||
export function ensureSheets(scene, rules, art) {
|
||||
const keys = Object.create(null);
|
||||
const procedural = [];
|
||||
for (const [name, spec] of Object.entries(art.sheets ?? {})) {
|
||||
if (spec.path && scene.textures.exists(spec.key)) { keys[name] = spec.key; continue; }
|
||||
const procKey = `${spec.key}-proc`;
|
||||
if (!scene.textures.exists(procKey)) {
|
||||
const painter = PROC_PAINTERS[spec.kind];
|
||||
if (!painter) {
|
||||
console.warn(`[TAArt] sheet "${name}" has unknown kind "${spec.kind}" — skipping`);
|
||||
continue;
|
||||
}
|
||||
painter(scene, procKey, spec, rules, art, name);
|
||||
}
|
||||
keys[name] = procKey;
|
||||
procedural.push(name);
|
||||
}
|
||||
return { keys, procedural };
|
||||
}
|
||||
|
||||
/** Frame dimensions of a resolved sheet, for sprite scaling. */
|
||||
export function sheetFrameSize(art, sheetName) {
|
||||
const s = art.sheets?.[sheetName];
|
||||
return s ? { w: s.frameWidth, h: s.frameHeight } : { w: 64, h: 64 };
|
||||
}
|
||||
|
||||
export { PROC_PAINTERS, UNIT_SHAPES, STRUCT_SHAPES, mkCanvasSheet, roundRect };
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
// Total Annihilation — procedural combat effects.
|
||||
//
|
||||
// Every weapon effect in this game is drawn, not sprited: gunfire tracers, tank shells,
|
||||
// rocket trails, beams, explosions, nanolathe streams and craters are all Graphics strokes.
|
||||
// Two layers are cleared and re-stroked each frame from a TTL list — the Star Control
|
||||
// `fxList` idiom (StarControlGame.js renderWorld) using the repo's glow-stroke pair: one
|
||||
// wide low-alpha pass, then a thin opaque one on top.
|
||||
|
||||
const MAX_FX = 420; // hard cap; oldest transients are dropped rather than dropping frames
|
||||
|
||||
export default class TAFx {
|
||||
constructor(scene, worldRoot, depths) {
|
||||
this.scene = scene;
|
||||
// Under the actors: trails, nanolathe streams, scorch. Over them: tracers and blasts.
|
||||
this.gUnder = scene.add.graphics().setDepth(depths.fxUnder);
|
||||
this.gOver = scene.add.graphics().setDepth(depths.fxOver);
|
||||
// Depth on a Container child does nothing until the container is sorted; TAWorldView
|
||||
// re-sorts the world container every frame, which is what puts these layers in place.
|
||||
worldRoot.add(this.gUnder);
|
||||
worldRoot.add(this.gOver);
|
||||
this.list = [];
|
||||
this.shakeUntil = 0;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.gUnder.destroy();
|
||||
this.gOver.destroy();
|
||||
this.list.length = 0;
|
||||
}
|
||||
|
||||
clear() { this.list.length = 0; }
|
||||
|
||||
push(fx) {
|
||||
if (this.list.length >= MAX_FX) this.list.shift();
|
||||
fx.age = 0;
|
||||
this.list.push(fx);
|
||||
}
|
||||
|
||||
/** Translate a simulation event into a transient effect. */
|
||||
onEvent(ev, rules) {
|
||||
switch (ev.t) {
|
||||
case 'shot':
|
||||
this.push({
|
||||
kind: ev.style === 'beam' || ev.style === 'dgun' ? 'beam' : 'tracer',
|
||||
x1: ev.x1, y1: ev.y1, x2: ev.x2, y2: ev.y2,
|
||||
color: colorInt(ev.color), width: ev.width ?? 2, ttl: ev.lifeMs ?? 80,
|
||||
});
|
||||
break;
|
||||
case 'weaponFired':
|
||||
this.push({
|
||||
kind: 'muzzle', x: ev.x, y: ev.y, heading: ev.heading,
|
||||
color: 0xffe6a0, ttl: 70,
|
||||
});
|
||||
break;
|
||||
case 'impact':
|
||||
this.push({
|
||||
kind: 'blast', x: ev.x, y: ev.y, r: Math.max(10, ev.radius),
|
||||
color: 0xffb45a, ttl: ev.radius > 30 ? 380 : 200,
|
||||
});
|
||||
break;
|
||||
case 'bigExplosion':
|
||||
this.push({ kind: 'shockwave', x: ev.x, y: ev.y, r: ev.radius, color: 0xfff0c0, ttl: 700 });
|
||||
this.push({ kind: 'blast', x: ev.x, y: ev.y, r: ev.radius * 0.55, color: 0xff8a3c, ttl: 520 });
|
||||
this.shakeUntil = this.scene.time.now + 320;
|
||||
this.scene.cameras.main.shake(300, 0.010);
|
||||
break;
|
||||
case 'unitDestroyed':
|
||||
this.push({
|
||||
kind: 'blast', x: ev.x, y: ev.y,
|
||||
r: Math.max(16, ev.radius * (ev.isBuilding ? 2.0 : 1.4)),
|
||||
color: ev.isBuilding ? 0xffa040 : 0xff9060,
|
||||
ttl: ev.isBuilding ? 460 : 300,
|
||||
});
|
||||
this.push({ kind: 'scorch', x: ev.x, y: ev.y, r: ev.radius * 1.2, ttl: 4200 });
|
||||
break;
|
||||
case 'nanolathe':
|
||||
// The stream itself is drawn live from builder->site in draw(); this is the sparkle
|
||||
// at the receiving end so a site under construction always reads as active.
|
||||
this.push({ kind: 'spark', x: ev.x, y: ev.y, color: 0x8ad4ff, ttl: 220 });
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} delta ms since last frame
|
||||
* @param {Array} nanoLinks live [{x1,y1,x2,y2,color}] builder->target streams
|
||||
* @param {Array} projectiles interpolated [{x,y,vx,vy,color,width,style,trail}]
|
||||
*/
|
||||
draw(delta, nanoLinks, projectiles, timeMs) {
|
||||
const gu = this.gUnder, go = this.gOver;
|
||||
gu.clear(); go.clear();
|
||||
|
||||
// ---- persistent-ish world marks (under actors) ----
|
||||
for (const fx of this.list) {
|
||||
if (fx.kind !== 'scorch') continue;
|
||||
const t = fx.age / fx.ttl;
|
||||
gu.fillStyle(0x1a1410, 0.5 * (1 - t));
|
||||
gu.fillCircle(fx.x, fx.y, fx.r);
|
||||
}
|
||||
|
||||
// ---- nanolathe streams (under actors) ----
|
||||
for (const link of nanoLinks) {
|
||||
const phase = (timeMs * 0.006) % 1;
|
||||
gu.lineStyle(5, link.color, 0.14);
|
||||
gu.lineBetween(link.x1, link.y1, link.x2, link.y2);
|
||||
gu.lineStyle(2, link.color, 0.9);
|
||||
// dashed, marching toward the target so it reads as material flowing
|
||||
const dx = link.x2 - link.x1, dy = link.y2 - link.y1;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / len, uy = dy / len;
|
||||
const dash = 14, gap = 10;
|
||||
for (let d = phase * (dash + gap); d < len; d += dash + gap) {
|
||||
const a = Math.min(len, d), b = Math.min(len, d + dash);
|
||||
gu.lineBetween(link.x1 + ux * a, link.y1 + uy * a, link.x1 + ux * b, link.y1 + uy * b);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- rocket trails (under actors) ----
|
||||
for (const p of projectiles) {
|
||||
if (!p.trail || p.trail.length < 4) continue;
|
||||
const n = p.trail.length / 2;
|
||||
for (let i = 1; i < n; i++) {
|
||||
const a = i / n;
|
||||
go.lineStyle(p.width * 1.6 * a, p.color, 0.30 * a);
|
||||
gu.lineStyle(p.width * 1.6 * a, p.color, 0.30 * a);
|
||||
gu.lineBetween(p.trail[(i - 1) * 2], p.trail[(i - 1) * 2 + 1], p.trail[i * 2], p.trail[i * 2 + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- live projectiles (over actors) ----
|
||||
for (const p of projectiles) {
|
||||
const ang = Math.atan2(p.vy, p.vx);
|
||||
if (p.style === 'rocket') {
|
||||
const bx = p.x - Math.cos(ang) * 9, by = p.y - Math.sin(ang) * 9;
|
||||
go.lineStyle(p.width * 2.4, p.color, 0.22);
|
||||
go.lineBetween(bx, by, p.x, p.y);
|
||||
go.fillStyle(0xfff0c0, 1);
|
||||
go.fillCircle(p.x, p.y, p.width * 0.9);
|
||||
go.fillStyle(p.color, 0.85);
|
||||
go.fillCircle(bx, by, p.width * 1.3);
|
||||
} else {
|
||||
const bx = p.x - Math.cos(ang) * 12, by = p.y - Math.sin(ang) * 12;
|
||||
go.lineStyle(p.width * 1.9, p.color, 0.20);
|
||||
go.lineBetween(bx, by, p.x, p.y);
|
||||
go.lineStyle(p.width * 0.8, p.color, 1);
|
||||
go.lineBetween(bx, by, p.x, p.y);
|
||||
go.fillStyle(0xfff6d8, 1);
|
||||
go.fillCircle(p.x, p.y, p.width * 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- transients (over actors) ----
|
||||
const keep = [];
|
||||
for (const fx of this.list) {
|
||||
fx.age += delta;
|
||||
if (fx.age >= fx.ttl) continue;
|
||||
keep.push(fx);
|
||||
const t = fx.age / fx.ttl;
|
||||
|
||||
switch (fx.kind) {
|
||||
case 'tracer': {
|
||||
const a = 1 - t;
|
||||
go.lineStyle(fx.width * 3.2, fx.color, 0.16 * a);
|
||||
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
|
||||
go.lineStyle(fx.width, fx.color, a);
|
||||
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
|
||||
break;
|
||||
}
|
||||
case 'beam': {
|
||||
const a = 1 - t * 0.75;
|
||||
go.lineStyle(fx.width * 2.6 * (1 - t) + 1, fx.color, 0.18 * a);
|
||||
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
|
||||
go.lineStyle(fx.width * (1 - t) + 1, fx.color, a);
|
||||
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
|
||||
break;
|
||||
}
|
||||
case 'muzzle': {
|
||||
const a = 1 - t;
|
||||
const len = 13 * (1 - t) + 4;
|
||||
go.fillStyle(fx.color, 0.85 * a);
|
||||
for (const spread of [-0.35, 0, 0.35]) {
|
||||
const ang = fx.heading + spread;
|
||||
go.beginPath();
|
||||
go.moveTo(fx.x, fx.y);
|
||||
go.lineTo(fx.x + Math.cos(ang - 0.13) * len, fx.y + Math.sin(ang - 0.13) * len);
|
||||
go.lineTo(fx.x + Math.cos(ang + 0.13) * len, fx.y + Math.sin(ang + 0.13) * len);
|
||||
go.closePath();
|
||||
go.fillPath();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'blast': {
|
||||
const a = 1 - t;
|
||||
const r = fx.r * (0.35 + t * 0.9);
|
||||
go.fillStyle(fx.color, 0.55 * a);
|
||||
go.fillCircle(fx.x, fx.y, r);
|
||||
go.fillStyle(0xfff6d8, 0.7 * a * a);
|
||||
go.fillCircle(fx.x, fx.y, r * 0.45);
|
||||
go.lineStyle(3 * a + 1, 0xffffff, 0.7 * a);
|
||||
go.strokeCircle(fx.x, fx.y, r);
|
||||
break;
|
||||
}
|
||||
case 'shockwave': {
|
||||
const a = 1 - t;
|
||||
go.lineStyle(9 * a + 1, fx.color, a);
|
||||
go.strokeCircle(fx.x, fx.y, fx.r * t);
|
||||
go.lineStyle(4 * a + 1, 0xff8a3c, a * 0.7);
|
||||
go.strokeCircle(fx.x, fx.y, fx.r * t * 0.72);
|
||||
break;
|
||||
}
|
||||
case 'spark': {
|
||||
const a = 1 - t;
|
||||
go.fillStyle(fx.color, a);
|
||||
go.fillCircle(fx.x, fx.y, 3 * a + 1);
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
this.list = keep;
|
||||
}
|
||||
}
|
||||
|
||||
function colorInt(v) {
|
||||
if (typeof v === 'number') return v;
|
||||
if (typeof v === 'string' && v[0] === '#') return parseInt(v.slice(1), 16);
|
||||
return 0xffffff;
|
||||
}
|
||||
|
||||
export { colorInt };
|
||||
|
|
@ -0,0 +1,408 @@
|
|||
// Total Annihilation — screen-space HUD.
|
||||
//
|
||||
// Everything here lives on `view.uiRoot` so the UI camera draws it and the world camera
|
||||
// ignores it (TAWorldView._setupCameras). The HUD owns no game rules: it reads state and
|
||||
// calls back into the scene, which is the only thing allowed to issue orders.
|
||||
//
|
||||
// The resource strip is the important piece. A flow economy is unreadable without showing
|
||||
// the RATES and the stall factor — "1200 mass" tells a player nothing, "+18 −24, STALL 74%"
|
||||
// tells them to build another Mass Generator.
|
||||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
||||
import { canAfford, buildOptionsFor } from './TALogic.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
|
||||
export const FONT = 'm6x11, "Julius Sans One"';
|
||||
|
||||
const PANEL = 0x141a24;
|
||||
const EDGE = 0x39465e;
|
||||
const TEXT = '#e8eef7';
|
||||
const DIM = '#8fa0b8';
|
||||
|
||||
const BAR_H = 62; // top resource strip
|
||||
const BOT_H = 190; // bottom command bar
|
||||
const MINI = 176; // minimap edge length
|
||||
const BTN_W = 88, BTN_H = 74; // build-menu button size
|
||||
|
||||
// CTRL is the queue modifier (as in the original game); SHIFT adds to the selection and
|
||||
// buys x5 from a factory. Kept in one place so the hint and the bindings cannot drift.
|
||||
const HINT = 'LMB select · drag box · RMB order · CTRL+RMB queue · A attack-move · X stop · H hold · WASD pan';
|
||||
|
||||
/** Build-button captions have ~7 characters of room; "Vehicle Plant" needs shortening. */
|
||||
function shortName(name) {
|
||||
const words = name.split(' ');
|
||||
if (words.length > 1) return words.map((w) => w[0]).join('').toUpperCase();
|
||||
return name.length > 8 ? name.slice(0, 8) : name;
|
||||
}
|
||||
|
||||
export default class TAHud {
|
||||
constructor(scene, rules, state, view, playerArmy, handlers) {
|
||||
this.scene = scene;
|
||||
this.rules = rules;
|
||||
this.state = state;
|
||||
this.view = view;
|
||||
this.playerArmy = playerArmy;
|
||||
this.h = handlers; // { onBuildPick, onProduce, onCommand, onMinimapJump }
|
||||
this.buildButtons = [];
|
||||
this.tooltip = new Tooltip(scene);
|
||||
this.root = scene.add.container(0, 0).setDepth(1000);
|
||||
view.uiRoot.add(this.root);
|
||||
|
||||
this._buildTopStrip();
|
||||
this._buildBottomBar();
|
||||
this._buildMinimap();
|
||||
this._pulse = 0;
|
||||
}
|
||||
|
||||
/** Screen rects the camera must NOT pan from — clicks here are UI, not world orders. */
|
||||
get blockRects() {
|
||||
return [
|
||||
new Phaser.Geom.Rectangle(0, 0, GAME_WIDTH, BAR_H),
|
||||
new Phaser.Geom.Rectangle(0, GAME_HEIGHT - BOT_H, GAME_WIDTH, BOT_H),
|
||||
];
|
||||
}
|
||||
|
||||
hitsUi(x, y) {
|
||||
return this.blockRects.some((r) => Phaser.Geom.Rectangle.Contains(r, x, y));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Top strip: MASS and ENERGY as stored/cap plus flow rates
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_buildTopStrip() {
|
||||
const s = this.scene;
|
||||
const bg = s.add.rectangle(0, 0, GAME_WIDTH, BAR_H, PANEL, 0.94).setOrigin(0, 0);
|
||||
bg.setStrokeStyle(2, EDGE);
|
||||
this.root.add(bg);
|
||||
|
||||
this.res = {};
|
||||
const mk = (key, label, x, color) => {
|
||||
const g = s.add.graphics();
|
||||
const name = s.add.text(x, 10, label, { fontFamily: FONT, fontSize: '20px', color: DIM });
|
||||
const amount = s.add.text(x, 32, '', { fontFamily: FONT, fontSize: '22px', color: TEXT });
|
||||
const flow = s.add.text(x + 300, 32, '', { fontFamily: FONT, fontSize: '22px', color: TEXT });
|
||||
this.root.add([g, name, amount, flow]);
|
||||
this.res[key] = { g, name, amount, flow, x, color };
|
||||
};
|
||||
mk('mass', 'MASS', 28, 0x9fb4cf);
|
||||
mk('energy', 'ENERGY', GAME_WIDTH / 2 - 240, 0xf2c744);
|
||||
|
||||
this.stallText = s.add.text(GAME_WIDTH - 28, 20, '', {
|
||||
fontFamily: FONT, fontSize: '26px', color: '#ff6b5a',
|
||||
}).setOrigin(1, 0);
|
||||
this.clock = s.add.text(GAME_WIDTH - 28, 4, '', {
|
||||
fontFamily: FONT, fontSize: '20px', color: DIM,
|
||||
}).setOrigin(1, 0);
|
||||
this.root.add([this.stallText, this.clock]);
|
||||
}
|
||||
|
||||
_drawResource(key, stored, cap, income, drain, stall) {
|
||||
const r = this.res[key];
|
||||
const w = 260, h = 12, y = 16;
|
||||
r.g.clear();
|
||||
r.g.fillStyle(0x0b0e14, 1);
|
||||
r.g.fillRect(r.x + 84, y, w, h);
|
||||
const frac = cap > 0 ? Math.max(0, Math.min(1, stored / cap)) : 0;
|
||||
r.g.fillStyle(r.color, 1);
|
||||
r.g.fillRect(r.x + 84, y, w * frac, h);
|
||||
r.g.lineStyle(1, EDGE, 1);
|
||||
r.g.strokeRect(r.x + 84, y, w, h);
|
||||
r.amount.setText(`${Math.round(stored)} / ${Math.round(cap)}`);
|
||||
const net = income - drain;
|
||||
r.flow.setText(`+${income.toFixed(1)} -${drain.toFixed(1)}`);
|
||||
r.flow.setColor(net < -0.05 ? '#ff9a6b' : '#9ce6a0');
|
||||
// Empty and still draining is the state the player must notice.
|
||||
const critical = stall < 0.98 && stored < cap * 0.02;
|
||||
r.amount.setColor(critical ? '#ff6b5a' : TEXT);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Bottom bar: selection roster, build/production grid, command buttons
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_buildBottomBar() {
|
||||
const s = this.scene;
|
||||
const y = GAME_HEIGHT - BOT_H;
|
||||
const bg = s.add.rectangle(0, y, GAME_WIDTH, BOT_H, PANEL, 0.94).setOrigin(0, 0);
|
||||
bg.setStrokeStyle(2, EDGE);
|
||||
this.root.add(bg);
|
||||
|
||||
this.selTitle = s.add.text(MINI + 60, y + 12, '', {
|
||||
fontFamily: FONT, fontSize: '26px', color: TEXT,
|
||||
});
|
||||
this.selDetail = s.add.text(MINI + 60, y + 44, '', {
|
||||
fontFamily: FONT, fontSize: '19px', color: DIM, lineSpacing: 3,
|
||||
});
|
||||
this.queueText = s.add.text(MINI + 60, y + 120, '', {
|
||||
fontFamily: FONT, fontSize: '19px', color: '#9ce6a0',
|
||||
});
|
||||
this.hint = s.add.text(GAME_WIDTH - 24, y + BOT_H - 26, '', {
|
||||
fontFamily: FONT, fontSize: '17px', color: DIM,
|
||||
}).setOrigin(1, 0.5);
|
||||
this.root.add([this.selTitle, this.selDetail, this.queueText, this.hint]);
|
||||
|
||||
this.gridX = GAME_WIDTH - 24 - 6 * 96;
|
||||
this.gridY = y + 16;
|
||||
}
|
||||
|
||||
_clearGrid() {
|
||||
for (const b of this.buildButtons) b.destroy();
|
||||
this.buildButtons.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repaint the build/production grid for the current selection. Buttons carry the def so
|
||||
* the scene can tell "start placing a building" from "enqueue a unit" without re-deriving.
|
||||
*/
|
||||
_drawGrid(options, kind) {
|
||||
this._clearGrid();
|
||||
const s = this.scene;
|
||||
const army = this.state.armies[this.playerArmy];
|
||||
options.forEach((def, i) => {
|
||||
const col = i % 6, row = Math.floor(i / 6);
|
||||
// The container is placed at the button's CENTRE, not its top-left — see below.
|
||||
const x = this.gridX + col * 96 + BTN_W / 2;
|
||||
const y = this.gridY + row * 82 + BTN_H / 2;
|
||||
const affordable = canAfford(this.state, this.playerArmy, def);
|
||||
const c = s.add.container(x, y);
|
||||
// A Container's origin is hard-coded to 0.5 and cannot be changed, so setInteractive()
|
||||
// always builds a hit area CENTRED on the container's position. Children drawn from a
|
||||
// top-left origin therefore sit half a button down-right of their own hitbox. Every
|
||||
// child here is centred on (0,0) so the visual and the hit area coincide.
|
||||
const box = s.add.rectangle(0, 0, BTN_W, BTN_H, affordable ? 0x22304a : 0x1a2130, 1)
|
||||
.setStrokeStyle(2, affordable ? EDGE : 0x2a3244);
|
||||
const label = s.add.text(0, -BTN_H / 2 + 12, shortName(def.name), {
|
||||
fontFamily: FONT, fontSize: '20px', color: affordable ? TEXT : '#5d6b80',
|
||||
}).setOrigin(0.5, 0);
|
||||
const cost = s.add.text(0, -BTN_H / 2 + 44, `${def.cost.mass}m ${def.cost.energy}e`, {
|
||||
fontFamily: FONT, fontSize: '15px', color: affordable ? DIM : '#4e5a6d',
|
||||
}).setOrigin(0.5, 0);
|
||||
c.add([box, label, cost]);
|
||||
c.setSize(BTN_W, BTN_H);
|
||||
c.setInteractive({ useHandCursor: true });
|
||||
c.on('pointerover', () => box.setFillStyle(affordable ? 0x2e3f5f : 0x1a2130, 1));
|
||||
c.on('pointerout', () => box.setFillStyle(affordable ? 0x22304a : 0x1a2130, 1));
|
||||
this.tooltip.attachTo(c, () => this._defTip(def));
|
||||
c.on('pointerdown', (p, lx, ly, ev) => {
|
||||
ev?.stopPropagation();
|
||||
if (kind === 'build') this.h.onBuildPick(def);
|
||||
else this.h.onProduce(def, p.event?.shiftKey ? 5 : 1);
|
||||
});
|
||||
this.root.add(c);
|
||||
this.buildButtons.push(c);
|
||||
});
|
||||
}
|
||||
|
||||
_defTip(def) {
|
||||
const lines = [`${def.cost.mass} mass ${def.cost.energy} energy ${def.buildTime}s`];
|
||||
if (def.hp) lines.push(`HP ${def.hp}`);
|
||||
if (def.speed) lines.push(`Speed ${def.speed} Sight ${def.sight}`);
|
||||
for (const w of def.weaponDefs ?? []) {
|
||||
lines.push(`${w.name}: ${w.damage} dmg / ${w.reload}s / ${w.range} range${w.manual ? ' (manual)' : ''}`);
|
||||
}
|
||||
if (def.makes) {
|
||||
const m = [];
|
||||
if (def.makes.mass) m.push(`+${def.makes.mass} mass/s`);
|
||||
if (def.makes.energy) m.push(`+${def.makes.energy} energy/s`);
|
||||
if (m.length) lines.push(m.join(' '));
|
||||
}
|
||||
if (def.upkeep?.energy) lines.push(`Upkeep ${def.upkeep.energy} energy/s`);
|
||||
if (def.terrainMultiplier) lines.push('Build on a metal patch for double yield');
|
||||
if (def.builds?.length) lines.push(`Builds: ${def.builds.join(', ')}`);
|
||||
return { title: def.name, lines: lines.map((text) => ({ text })) };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Minimap — a canvas texture rebaked a few times a second, dots stroked live
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_buildMinimap() {
|
||||
const s = this.scene;
|
||||
const x = 20, y = GAME_HEIGHT - BOT_H + 8;
|
||||
this.miniX = x; this.miniY = y;
|
||||
const key = `ta-minimap-${s.scene.key}`;
|
||||
if (s.textures.exists(key)) s.textures.remove(key);
|
||||
this.miniTex = s.textures.createCanvas(key, this.state.w, this.state.h);
|
||||
this.miniImg = s.add.image(x, y, key).setOrigin(0, 0)
|
||||
.setDisplaySize(MINI, MINI).setInteractive({ useHandCursor: true });
|
||||
this.miniImg.texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
|
||||
this.miniG = s.add.graphics();
|
||||
this.root.add([this.miniImg, this.miniG]);
|
||||
|
||||
const jump = (p) => {
|
||||
const fx = Phaser.Math.Clamp((p.x - x) / MINI, 0, 1);
|
||||
const fy = Phaser.Math.Clamp((p.y - y) / MINI, 0, 1);
|
||||
this.h.onMinimapJump(fx * this.state.worldW, fy * this.state.worldH);
|
||||
};
|
||||
this.miniImg.on('pointerdown', (p) => { this._miniDrag = true; jump(p); });
|
||||
s.input.on('pointerup', () => { this._miniDrag = false; });
|
||||
s.input.on('pointermove', (p) => { if (this._miniDrag) jump(p); });
|
||||
this._miniBakedAt = -1e9;
|
||||
}
|
||||
|
||||
_bakeMinimap(timeMs) {
|
||||
if (timeMs - this._miniBakedAt < 250) return;
|
||||
this._miniBakedAt = timeMs;
|
||||
const st = this.state, ctx = this.miniTex.context;
|
||||
const img = ctx.createImageData(st.w, st.h);
|
||||
const pal = this.rules.terrain.map((t) => {
|
||||
const hex = this.view.themePalette?.[t.id] ?? '#404040';
|
||||
const c = parseInt(String(hex).slice(1), 16);
|
||||
return [(c >> 16) & 255, (c >> 8) & 255, c & 255];
|
||||
});
|
||||
const army = st.armies[this.playerArmy];
|
||||
for (let ty = 0; ty < st.h; ty++) {
|
||||
for (let tx = 0; tx < st.w; tx++) {
|
||||
const i = ty * st.w + tx;
|
||||
const [r, g, b] = pal[st.terrain[i]] ?? [64, 64, 64];
|
||||
// The vision grid is half tile resolution, and unexplored must read as black:
|
||||
// a minimap that ignores fog is just a wallhack with extra steps.
|
||||
const v = (ty >> 1) * st.visW + (tx >> 1);
|
||||
const k = army.explored[v] ? (army.visible[v] ? 1 : 0.45) : 0;
|
||||
const o = i * 4;
|
||||
img.data[o] = r * k; img.data[o + 1] = g * k; img.data[o + 2] = b * k; img.data[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
this.miniTex.refresh();
|
||||
}
|
||||
|
||||
_drawMinimapOverlay() {
|
||||
const g = this.miniG, st = this.state;
|
||||
g.clear();
|
||||
const sx = MINI / st.worldW, sy = MINI / st.worldH;
|
||||
for (const e of st.entities) {
|
||||
if (e.dead) continue;
|
||||
if (e.army !== this.playerArmy && !this.view.visibleToPlayer(e)) continue;
|
||||
const a = this.rules.armies[e.army];
|
||||
g.fillStyle(a ? a.colorInt : 0x888888, 1);
|
||||
const r = e.isBuilding ? 2.5 : 1.6;
|
||||
g.fillRect(this.miniX + e.x * sx - r, this.miniY + e.y * sy - r, r * 2, r * 2);
|
||||
}
|
||||
const cam = this.scene.cameras.main;
|
||||
g.lineStyle(1.5, 0xffffff, 0.8);
|
||||
g.strokeRect(
|
||||
this.miniX + cam.worldView.x * sx, this.miniY + cam.worldView.y * sy,
|
||||
cam.worldView.width * sx, cam.worldView.height * sy,
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Per-frame update
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
update(timeMs, selection, placementDef) {
|
||||
const st = this.state, army = st.armies[this.playerArmy];
|
||||
if (!army) return;
|
||||
|
||||
this._drawResource('mass', army.mass, army.massCap, army.mIncome, army.mDrain * army.stallM, army.stallM);
|
||||
this._drawResource('energy', army.energy, army.energyCap, army.eIncome, (army.eDrain * army.stallE) + army.eUpkeep * army.upkeepFactor, army.stallE);
|
||||
|
||||
const eff = army.buildEff ?? 1;
|
||||
if (eff < 0.98) {
|
||||
this._pulse = (this._pulse + 0.12) % (Math.PI * 2);
|
||||
this.stallText.setText(`STALL ${Math.round(eff * 100)}%`);
|
||||
this.stallText.setAlpha(0.55 + 0.45 * Math.abs(Math.sin(this._pulse)));
|
||||
} else {
|
||||
this.stallText.setText('');
|
||||
}
|
||||
const t = Math.floor(st.elapsedSec);
|
||||
this.clock.setText(`${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`);
|
||||
|
||||
this._bakeMinimap(timeMs);
|
||||
this._drawMinimapOverlay();
|
||||
this._refreshSelection(selection, placementDef);
|
||||
}
|
||||
|
||||
_refreshSelection(selection, placementDef) {
|
||||
const sel = [...selection];
|
||||
const sig = sel.map((e) => e.id).join(',') + '|' + (placementDef?.id ?? '')
|
||||
+ '|' + sel.map((e) => e.queue?.length ?? 0).join(',')
|
||||
// Order-queue length is part of the signature, otherwise queueing a command with CTRL
|
||||
// changes nothing on screen until the selection itself changes.
|
||||
+ '|' + sel.map((e) => e.orders?.length ?? 0).join(',')
|
||||
+ '|' + Math.floor((this.state.armies[this.playerArmy]?.mass ?? 0) / 25);
|
||||
if (sig === this._selSig) { this._refreshQueue(sel); return; }
|
||||
this._selSig = sig;
|
||||
|
||||
if (!sel.length) {
|
||||
this.selTitle.setText('');
|
||||
this.selDetail.setText('');
|
||||
this.queueText.setText('');
|
||||
this.hint.setText(HINT);
|
||||
this._clearGrid();
|
||||
return;
|
||||
}
|
||||
|
||||
const counts = new Map();
|
||||
for (const e of sel) {
|
||||
const def = this.rules.defById[e.defId];
|
||||
counts.set(def.name, (counts.get(def.name) ?? 0) + 1);
|
||||
}
|
||||
this.selTitle.setText([...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(' '));
|
||||
|
||||
const lead = sel[0];
|
||||
const def = this.rules.defById[lead.defId];
|
||||
const lines = [];
|
||||
if (sel.length === 1) {
|
||||
lines.push(`HP ${Math.ceil(lead.hp)} / ${lead.maxHp}`);
|
||||
if (lead.site) lines.push(`Under construction — ${Math.round((lead.progress ?? 0) * 100)}%`);
|
||||
if (lead.orders?.length > 1) lines.push(`${lead.orders.length} orders queued`);
|
||||
if (!lead.site && lead.hasRally) lines.push('Rally point set');
|
||||
for (const w of def.weaponDefs ?? []) lines.push(`${w.name}${w.manual ? ' — manual (attack order)' : ''}`);
|
||||
} else {
|
||||
const hp = sel.reduce((s, e) => s + e.hp, 0), max = sel.reduce((s, e) => s + e.maxHp, 0);
|
||||
lines.push(`${sel.length} units HP ${Math.ceil(hp)} / ${max}`);
|
||||
const queued = sel.reduce((n, e) => Math.max(n, e.orders?.length ?? 0), 0);
|
||||
if (queued > 1) lines.push(`${queued} orders queued (hold CTRL to add more)`);
|
||||
}
|
||||
this.selDetail.setText(lines.join('\n'));
|
||||
|
||||
if (placementDef) {
|
||||
this.hint.setText(`Placing ${placementDef.name} — LMB to site it, hold CTRL to queue several, Esc to cancel`);
|
||||
} else {
|
||||
this.hint.setText(HINT);
|
||||
}
|
||||
|
||||
// Grid shows the builder's structures, or the factory's units — never both.
|
||||
const builder = sel.find((e) => !e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length);
|
||||
const factory = sel.find((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length);
|
||||
if (builder) this._drawGrid(buildOptionsFor(this.rules, builder), 'build');
|
||||
else if (factory) this._drawGrid(buildOptionsFor(this.rules, factory), 'produce');
|
||||
else this._clearGrid();
|
||||
}
|
||||
|
||||
_refreshQueue(sel) {
|
||||
const f = sel.find((e) => e.isBuilding && !e.site && e.queue?.length);
|
||||
if (!f) { this.queueText.setText(''); return; }
|
||||
const parts = f.queue.map((q, i) => {
|
||||
const name = this.rules.defById[q.defId].name;
|
||||
const pct = i === 0 ? ` ${Math.round((f.jobProgress ?? 0) * 100)}%` : '';
|
||||
return `${name}${q.count > 1 ? ` ×${q.count}` : ''}${pct}`;
|
||||
});
|
||||
this.queueText.setText(`QUEUE ${parts.join(' | ')}`);
|
||||
}
|
||||
|
||||
toast(msg, color = '#ffd27a') {
|
||||
if (this._toast) this._toast.destroy();
|
||||
this._toast = this.scene.add.text(GAME_WIDTH / 2, BAR_H + 40, msg, {
|
||||
fontFamily: FONT, fontSize: '28px', color,
|
||||
}).setOrigin(0.5, 0);
|
||||
this.view.uiRoot.add(this._toast);
|
||||
this.scene.tweens.add({
|
||||
targets: this._toast, alpha: 0, delay: 1400, duration: 600,
|
||||
onComplete: () => { this._toast?.destroy(); this._toast = null; },
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._clearGrid();
|
||||
this.tooltip?.destroy();
|
||||
this._toast?.destroy();
|
||||
this.miniG?.destroy();
|
||||
this.miniImg?.destroy();
|
||||
this.root.destroy(true);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,295 @@
|
|||
// Total Annihilation — seeded procedural skirmish map generator.
|
||||
//
|
||||
// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js.
|
||||
//
|
||||
// Produces exactly the same `map` shape a hand-authored campaign mission declares
|
||||
// ({ w, h, theme, terrain, starts }), so campaign and skirmish share one loader.
|
||||
//
|
||||
// Determinism: same seed + same options => byte-identical map. Everything draws from the
|
||||
// local mulberry32; never call Math.random() here.
|
||||
|
||||
import { createNav, findPath, clearanceFor, nearestUsableTile, tileIndex } from './TANav.js';
|
||||
|
||||
function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return function next() {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), a | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** Smooth 2D value noise: a coarse random lattice, bilinearly interpolated, two octaves. */
|
||||
function valueNoise(rnd, w, h, scale) {
|
||||
const lw = Math.max(2, Math.ceil(w * scale) + 1);
|
||||
const lh = Math.max(2, Math.ceil(h * scale) + 1);
|
||||
const lattice = new Float32Array(lw * lh);
|
||||
for (let i = 0; i < lattice.length; i++) lattice[i] = rnd();
|
||||
|
||||
const smooth = (t) => t * t * (3 - 2 * t);
|
||||
const sample = (fx, fy) => {
|
||||
const x0 = Math.min(lw - 1, Math.floor(fx)), y0 = Math.min(lh - 1, Math.floor(fy));
|
||||
const x1 = Math.min(lw - 1, x0 + 1), y1 = Math.min(lh - 1, y0 + 1);
|
||||
const tx = smooth(fx - x0), ty = smooth(fy - y0);
|
||||
const a = lattice[y0 * lw + x0], b = lattice[y0 * lw + x1];
|
||||
const c = lattice[y1 * lw + x0], d = lattice[y1 * lw + x1];
|
||||
return (a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty;
|
||||
};
|
||||
|
||||
const out = new Float32Array(w * h);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const base = sample(x * scale, y * scale);
|
||||
const detail = sample(x * scale * 2.7, y * scale * 2.7);
|
||||
out[y * w + x] = base * 0.72 + detail * 0.28;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Map (x,y) onto the canonical half/quadrant so the result is exactly symmetric. */
|
||||
function canonical(sym, x, y, w, h) {
|
||||
switch (sym) {
|
||||
case 'mirror-x': return x < w / 2 ? [x, y] : [w - 1 - x, y];
|
||||
case 'mirror-y': return y < h / 2 ? [x, y] : [x, h - 1 - y];
|
||||
case 'rotational': {
|
||||
// 180-degree rotation about the centre.
|
||||
const beforeCentre = (y * w + x) < ((h * w) / 2);
|
||||
return beforeCentre ? [x, y] : [w - 1 - x, h - 1 - y];
|
||||
}
|
||||
default: return [x, y];
|
||||
}
|
||||
}
|
||||
|
||||
function startPositions(sym, w, h, margin) {
|
||||
switch (sym) {
|
||||
case 'mirror-x': return [[margin, h >> 1], [w - 1 - margin, h >> 1]];
|
||||
case 'mirror-y': return [[w >> 1, margin], [w >> 1, h - 1 - margin]];
|
||||
case 'rotational': return [[margin, margin], [w - 1 - margin, h - 1 - margin]];
|
||||
default: return [[margin, h >> 1], [w - 1 - margin, h >> 1]];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} rules compiled rules
|
||||
* @param {object} opts { seed, size:'small'|'medium'|'large', symmetry, theme }
|
||||
* @returns {{w,h,theme,terrain:Uint8Array,starts:Array,metalSpots:Array}}
|
||||
*/
|
||||
export function generateMap(rules, opts = {}) {
|
||||
const sk = rules.skirmish;
|
||||
const size = opts.size ?? sk.defaults.size;
|
||||
const symmetry = opts.symmetry ?? sk.defaults.symmetry;
|
||||
const theme = opts.theme ?? sk.defaults.theme;
|
||||
const seed = (opts.seed ?? 1) >>> 0;
|
||||
const g = sk.gen;
|
||||
|
||||
const dim = sk.sizes[size];
|
||||
if (!dim) throw new Error(`[TAMapGen] unknown size "${size}"`);
|
||||
if (!sk.symmetries.includes(symmetry)) throw new Error(`[TAMapGen] unknown symmetry "${symmetry}"`);
|
||||
if (!sk.themes.includes(theme)) throw new Error(`[TAMapGen] unknown theme "${theme}"`);
|
||||
|
||||
const w = dim, h = dim;
|
||||
const rnd = mulberry32(seed);
|
||||
|
||||
const T = {};
|
||||
for (const t of rules.terrain) T[t.id] = t.index;
|
||||
|
||||
const noise = valueNoise(rnd, w, h, g.noiseScale);
|
||||
const detail = valueNoise(rnd, w, h, g.noiseScale * 2.2);
|
||||
|
||||
const terrain = new Uint8Array(w * h);
|
||||
// Thresholds are PERCENTILES of the actual noise, not raw values. Smoothed value noise
|
||||
// clusters hard around 0.5, so comparing it against a raw 0.10 yields almost no water —
|
||||
// quantiles make `waterFraction: 0.10` mean literally a tenth of the map.
|
||||
const quantile = (arr, q) => {
|
||||
const s = Float32Array.from(arr).sort();
|
||||
return s[Math.max(0, Math.min(s.length - 1, Math.floor(q * (s.length - 1))))];
|
||||
};
|
||||
const waterT = quantile(noise, g.waterFraction);
|
||||
const cliffT = quantile(noise, 1 - g.cliffFraction);
|
||||
const roughLo = quantile(detail, 0.5 - g.roughFraction / 2);
|
||||
const roughHi = quantile(detail, 0.5 + g.roughFraction / 2);
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const [cx, cy] = canonical(symmetry, x, y, w, h);
|
||||
const n = noise[cy * w + cx];
|
||||
const d = detail[cy * w + cx];
|
||||
let id = T.ground;
|
||||
if (n < waterT) id = T.water;
|
||||
else if (n > cliffT) id = T.cliff;
|
||||
else if (d > roughLo && d < roughHi) id = T.rough;
|
||||
terrain[y * w + x] = id;
|
||||
}
|
||||
}
|
||||
|
||||
// Majority smoothing removes single-tile speckle that would otherwise make pathing ugly.
|
||||
for (let pass = 0; pass < g.smoothPasses; pass++) {
|
||||
const src = terrain.slice();
|
||||
for (let y = 1; y < h - 1; y++) {
|
||||
for (let x = 1; x < w - 1; x++) {
|
||||
const counts = new Map();
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const v = src[(y + dy) * w + (x + dx)];
|
||||
counts.set(v, (counts.get(v) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
let bestV = src[y * w + x], bestC = 0;
|
||||
// Deterministic tie-break by terrain index — never rely on Map iteration order alone.
|
||||
for (const [v, c] of [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0])) {
|
||||
if (c > bestC) { bestV = v; bestC = c; }
|
||||
}
|
||||
if (bestC >= 6) terrain[y * w + x] = bestV;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- starts ------------------------------------------------------------
|
||||
const margin = Math.max(g.startClearRadiusTiles + 2, Math.floor(dim * 0.10));
|
||||
const [p0, p1] = startPositions(symmetry, w, h, margin);
|
||||
const starts = [
|
||||
{ army: 0, x: p0[0], y: p0[1] },
|
||||
{ army: 1, x: p1[0], y: p1[1] },
|
||||
];
|
||||
|
||||
const clearR = g.startClearRadiusTiles;
|
||||
for (const s of starts) {
|
||||
for (let y = s.y - clearR; y <= s.y + clearR; y++) {
|
||||
for (let x = s.x - clearR; x <= s.x + clearR; x++) {
|
||||
if (x < 0 || y < 0 || x >= w || y >= h) continue;
|
||||
if (Math.hypot(x - s.x, y - s.y) > clearR) continue;
|
||||
terrain[y * w + x] = T.ground;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- metal spots -------------------------------------------------------
|
||||
const metalSpots = [];
|
||||
const SPOT = 2; // spots are 2x2 tiles
|
||||
|
||||
const canPlaceSpot = (x, y) => {
|
||||
if (x < 1 || y < 1 || x + SPOT > w - 1 || y + SPOT > h - 1) return false;
|
||||
for (let dy = 0; dy < SPOT; dy++) {
|
||||
for (let dx = 0; dx < SPOT; dx++) {
|
||||
const t = terrain[(y + dy) * w + (x + dx)];
|
||||
if (t !== T.ground && t !== T.rough) return false;
|
||||
}
|
||||
}
|
||||
// Keep spots apart so a single Mass Generator can't cover two.
|
||||
for (const s of metalSpots) if (Math.hypot(s.x - x, s.y - y) < 4) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const commitSpot = (x, y) => {
|
||||
for (let dy = 0; dy < SPOT; dy++) {
|
||||
for (let dx = 0; dx < SPOT; dx++) terrain[(y + dy) * w + (x + dx)] = T.metal;
|
||||
}
|
||||
metalSpots.push({ x, y });
|
||||
};
|
||||
|
||||
// Mirror the BLOCK, not its corner: a 2x2 block starting at x mirrors to one starting at
|
||||
// w - x - SPOT. Mirroring the corner alone shifts the block one tile and breaks symmetry.
|
||||
const mirrorOf = (x, y) => {
|
||||
switch (symmetry) {
|
||||
case 'mirror-x': return [w - x - SPOT, y];
|
||||
case 'mirror-y': return [x, h - y - SPOT];
|
||||
default: return [w - x - SPOT, h - y - SPOT];
|
||||
}
|
||||
};
|
||||
|
||||
const spotR = g.metalSpotRadiusTiles;
|
||||
let guard = 0;
|
||||
while (metalSpots.length < g.metalSpotsPerStart * 2 && guard++ < 4000) {
|
||||
const a = rnd() * Math.PI * 2;
|
||||
const r = clearR * 0.5 + rnd() * (spotR - clearR * 0.5);
|
||||
const x = Math.round(starts[0].x + Math.cos(a) * r);
|
||||
const y = Math.round(starts[0].y + Math.sin(a) * r);
|
||||
const [mx, my] = mirrorOf(x, y);
|
||||
// Commit both or neither, or the two players end up with different economies.
|
||||
if (!canPlaceSpot(x, y)) continue;
|
||||
if (!canPlaceSpot(mx, my)) continue;
|
||||
commitSpot(x, y);
|
||||
commitSpot(mx, my);
|
||||
}
|
||||
|
||||
const map = { w, h, theme, terrain, starts, metalSpots, seed, size, symmetry };
|
||||
|
||||
// ---- reachability ------------------------------------------------------
|
||||
// Guarantee the starts can actually reach each other for the widest unit in the ruleset,
|
||||
// carving a corridor rather than rejecting the seed — a retry loop makes generation
|
||||
// non-deterministic in wall-clock terms and can hang on a hostile parameter set.
|
||||
const maxNeed = rules.units.reduce(
|
||||
(m, u) => Math.max(m, clearanceFor(u.radius, rules.constants.tileSize)), 1);
|
||||
const widestMoveClass = pickWidestMoveClass(rules);
|
||||
ensureConnected(rules, map, widestMoveClass, maxNeed, T);
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/** The move class with the most restrictive terrain table — if it can get through, all can. */
|
||||
function pickWidestMoveClass(rules) {
|
||||
let worst = null, worstScore = Infinity;
|
||||
for (const [mc, spec] of Object.entries(rules.moveClasses)) {
|
||||
const score = spec.costByTerrainIndex.reduce((s, c) => s + (c == null ? 0 : 1 / c), 0);
|
||||
if (score < worstScore) { worstScore = score; worst = mc; }
|
||||
}
|
||||
return worst ?? Object.keys(rules.moveClasses)[0];
|
||||
}
|
||||
|
||||
function ensureConnected(rules, map, mc, need, T) {
|
||||
const nav = createNav(rules, map);
|
||||
const [a, b] = map.starts;
|
||||
const ai = nearestUsableTile(nav, mc, need, a.x, a.y, 12);
|
||||
const bi = nearestUsableTile(nav, mc, need, b.x, b.y, 12);
|
||||
if (ai >= 0 && bi >= 0 && findPath(nav, mc, need, ai, bi)) return;
|
||||
|
||||
// Carve a straight corridor wide enough for the widest unit.
|
||||
const halfW = Math.max(1, need + 1);
|
||||
const steps = Math.ceil(Math.hypot(b.x - a.x, b.y - a.y)) * 2;
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const t = s / steps;
|
||||
const cx = Math.round(a.x + (b.x - a.x) * t);
|
||||
const cy = Math.round(a.y + (b.y - a.y) * t);
|
||||
for (let y = cy - halfW; y <= cy + halfW; y++) {
|
||||
for (let x = cx - halfW; x <= cx + halfW; x++) {
|
||||
if (x < 0 || y < 0 || x >= map.w || y >= map.h) continue;
|
||||
const cur = map.terrain[y * map.w + x];
|
||||
if (cur === T.metal) continue;
|
||||
map.terrain[y * map.w + x] = T.ground;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode a hand-authored mission map (rows of terrain chars) into the same shape. */
|
||||
export function decodeMap(rules, spec) {
|
||||
const rows = spec.rows ?? spec.tiles ?? [];
|
||||
const w = spec.w ?? (rows[0]?.length ?? 0);
|
||||
const h = spec.h ?? rows.length;
|
||||
const terrain = new Uint8Array(w * h);
|
||||
const fallback = rules.terrain[0].index;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = rows[y] ?? '';
|
||||
for (let x = 0; x < w; x++) {
|
||||
const ch = row[x];
|
||||
const t = ch != null ? rules.terrainByCh[ch] : null;
|
||||
if (ch != null && ch !== ' ' && !t) {
|
||||
throw new Error(`[TAMapGen] unknown terrain char "${ch}" at ${x},${y}`);
|
||||
}
|
||||
terrain[y * w + x] = t ? t.index : fallback;
|
||||
}
|
||||
}
|
||||
return {
|
||||
w, h,
|
||||
theme: spec.theme ?? rules.skirmish.defaults.theme,
|
||||
terrain,
|
||||
starts: spec.starts ?? [],
|
||||
units: spec.units ?? [],
|
||||
buildings: spec.buildings ?? [],
|
||||
areas: spec.areas ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export { tileIndex };
|
||||
|
|
@ -0,0 +1,482 @@
|
|||
// Total Annihilation — navigation: grid, clearance field, A*, path smoothing, spatial hash.
|
||||
//
|
||||
// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js.
|
||||
//
|
||||
// The tile grid exists ONLY for pathfinding cost and build legality. Units carry float
|
||||
// positions and a heading; they never snap to tiles. That is what lets three small units
|
||||
// share one tile while a tank takes it alone — the packing falls out of collision radii,
|
||||
// not out of a slot system.
|
||||
//
|
||||
// Two things here are load-bearing for performance:
|
||||
// 1. Units are NOT stamped into the grid. Only terrain and buildings block. If every unit
|
||||
// dirtied the grid, every path would be invalidated every tick.
|
||||
// 2. Clearance-based pathfinding: clearance[i] is the side of the largest all-passable
|
||||
// square with tile i at its top-left, so a large unit simply refuses tiles whose
|
||||
// clearance is below its need. One DP pass at load, patched over a dirty rect when a
|
||||
// building goes up or dies.
|
||||
|
||||
const SQRT2 = Math.SQRT2;
|
||||
export const MAX_CLEARANCE = 8; // clamped so clearance fits a Uint8Array and the DP stays cheap
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nav grid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the navigation grid for a map.
|
||||
* @param {object} rules compiled rules
|
||||
* @param {{w:number,h:number,terrain:Uint8Array}} map terrain indices into rules.terrain
|
||||
*/
|
||||
export function createNav(rules, map) {
|
||||
const { w, h } = map;
|
||||
const n = w * h;
|
||||
const moveClassIds = Object.keys(rules.moveClasses);
|
||||
|
||||
const nav = {
|
||||
w, h, n,
|
||||
tileSize: rules.constants.tileSize,
|
||||
terrain: map.terrain,
|
||||
blocked: new Uint8Array(n), // buildings and build sites
|
||||
moveClassIds,
|
||||
passable: Object.create(null), // mc -> Uint8Array
|
||||
cost: Object.create(null), // mc -> Float32Array (Infinity where impassable)
|
||||
clearance: Object.create(null), // mc -> Uint8Array
|
||||
// Scratch reused by every A* call so a path request allocates nothing.
|
||||
_g: new Float64Array(n),
|
||||
_came: new Int32Array(n),
|
||||
_stamp: new Int32Array(n),
|
||||
_epoch: 0,
|
||||
_heap: createHeap(Math.max(64, n >> 2)),
|
||||
};
|
||||
|
||||
for (const mc of moveClassIds) {
|
||||
nav.passable[mc] = new Uint8Array(n);
|
||||
nav.cost[mc] = new Float32Array(n);
|
||||
nav.clearance[mc] = new Uint8Array(n);
|
||||
}
|
||||
refreshRegion(nav, rules, 0, 0, w - 1, h - 1);
|
||||
return nav;
|
||||
}
|
||||
|
||||
/** Recompute passability, cost and clearance over a tile rect (inclusive). */
|
||||
export function refreshRegion(nav, rules, x0, y0, x1, y1) {
|
||||
const { w, h } = nav;
|
||||
x0 = Math.max(0, x0); y0 = Math.max(0, y0);
|
||||
x1 = Math.min(w - 1, x1); y1 = Math.min(h - 1, y1);
|
||||
if (x1 < x0 || y1 < y0) return;
|
||||
|
||||
for (const mc of nav.moveClassIds) {
|
||||
const costTable = rules.moveClasses[mc].costByTerrainIndex;
|
||||
const pass = nav.passable[mc];
|
||||
const cost = nav.cost[mc];
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
for (let x = x0; x <= x1; x++) {
|
||||
const i = y * w + x;
|
||||
const c = nav.blocked[i] ? null : costTable[nav.terrain[i]];
|
||||
pass[i] = c == null ? 0 : 1;
|
||||
cost[i] = c == null ? Infinity : c;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clearance at (x,y) reads (x+1,y), (x,y+1), (x+1,y+1), so a change at (x,y) can only
|
||||
// affect tiles up-left of it — expand the rect that way and sweep in reverse.
|
||||
const cx0 = Math.max(0, x0 - MAX_CLEARANCE);
|
||||
const cy0 = Math.max(0, y0 - MAX_CLEARANCE);
|
||||
nav._fit = null; // clearance moved, so every dilated fit mask is stale
|
||||
for (const mc of nav.moveClassIds) {
|
||||
const pass = nav.passable[mc];
|
||||
const cl = nav.clearance[mc];
|
||||
for (let y = y1; y >= cy0; y--) {
|
||||
for (let x = x1; x >= cx0; x--) {
|
||||
const i = y * w + x;
|
||||
if (!pass[i]) { cl[i] = 0; continue; }
|
||||
if (x === w - 1 || y === h - 1) { cl[i] = 1; continue; }
|
||||
const a = cl[i + 1], b = cl[i + w], d = cl[i + w + 1];
|
||||
cl[i] = Math.min(MAX_CLEARANCE, 1 + Math.min(a, b, d));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stamp (or clear) a building footprint and patch the affected clearance. */
|
||||
export function stampFootprint(nav, rules, tx, ty, fw, fh, on) {
|
||||
const { w, h } = nav;
|
||||
for (let y = ty; y < ty + fh; y++) {
|
||||
if (y < 0 || y >= h) continue;
|
||||
for (let x = tx; x < tx + fw; x++) {
|
||||
if (x < 0 || x >= w) continue;
|
||||
nav.blocked[y * w + x] = on ? 1 : 0;
|
||||
}
|
||||
}
|
||||
refreshRegion(nav, rules, tx, ty, tx + fw - 1, ty + fh - 1);
|
||||
}
|
||||
|
||||
/** Clearance a unit of this collision radius needs, in tiles. */
|
||||
export function clearanceFor(radius, tileSize) {
|
||||
return Math.max(1, Math.ceil((radius * 2) / tileSize));
|
||||
}
|
||||
|
||||
export const tileIndex = (nav, tx, ty) => ty * nav.w + tx;
|
||||
export const tileX = (nav, i) => i % nav.w;
|
||||
export const tileY = (nav, i) => (i / nav.w) | 0;
|
||||
export const worldToTileX = (nav, x) => Math.max(0, Math.min(nav.w - 1, (x / nav.tileSize) | 0));
|
||||
export const worldToTileY = (nav, y) => Math.max(0, Math.min(nav.h - 1, (y / nav.tileSize) | 0));
|
||||
export const tileCenterX = (nav, tx) => tx * nav.tileSize + nav.tileSize / 2;
|
||||
export const tileCenterY = (nav, ty) => ty * nav.tileSize + nav.tileSize / 2;
|
||||
|
||||
/**
|
||||
* Per-tile "a unit this wide can stand here" mask, cached per move class and need.
|
||||
*
|
||||
* clearance[i] is the largest all-passable square whose TOP-LEFT corner is tile i, so
|
||||
* reading it directly asks "does the square start here" — which wrongly rejects every tile
|
||||
* along the bottom and right edge of an open area, even though a unit centred there fits
|
||||
* perfectly well. A tile is genuinely usable when ANY anchor covering it qualifies, so this
|
||||
* dilates the clearance test by the need window (a separable two-pass OR).
|
||||
*/
|
||||
export function fitField(nav, mc, need) {
|
||||
if (need <= 1) return nav.clearance[mc];
|
||||
const key = `${mc}:${need}`;
|
||||
if (!nav._fit) nav._fit = Object.create(null);
|
||||
const hit = nav._fit[key];
|
||||
if (hit) return hit;
|
||||
|
||||
const { w, h } = nav;
|
||||
const cl = nav.clearance[mc];
|
||||
const rowPass = new Uint8Array(w * h);
|
||||
for (let y = 0; y < h; y++) {
|
||||
let run = 0; // how many of the last `need` tiles to the left qualify as anchors
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = y * w + x;
|
||||
run = cl[i] >= need ? need : (run > 0 ? run - 1 : 0);
|
||||
rowPass[i] = run > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
const out = new Uint8Array(w * h);
|
||||
for (let x = 0; x < w; x++) {
|
||||
let run = 0;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const i = y * w + x;
|
||||
run = rowPass[i] ? need : (run > 0 ? run - 1 : 0);
|
||||
out[i] = run > 0 ? need : 0;
|
||||
}
|
||||
}
|
||||
nav._fit[key] = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Is this tile usable by a unit with the given move class and clearance need? */
|
||||
export function tileOk(nav, mc, need, tx, ty) {
|
||||
if (tx < 0 || ty < 0 || tx >= nav.w || ty >= nav.h) return false;
|
||||
return fitField(nav, mc, need)[ty * nav.w + tx] >= need;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest tile to (tx,ty) that a unit of this move class/clearance can stand on.
|
||||
* Spiral BFS, bounded — used when an order lands on a wall or inside a building.
|
||||
*/
|
||||
export function nearestUsableTile(nav, mc, need, tx, ty, maxRings = 24) {
|
||||
if (tileOk(nav, mc, need, tx, ty)) return tileIndex(nav, tx, ty);
|
||||
for (let r = 1; r <= maxRings; r++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
for (const dy of (dx === -r || dx === r) ? range(-r, r) : [-r, r]) {
|
||||
const x = tx + dx, y = ty + dy;
|
||||
if (tileOk(nav, mc, need, x, y)) return tileIndex(nav, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function range(a, b) {
|
||||
const out = [];
|
||||
for (let v = a; v <= b; v++) out.push(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Binary min-heap (parallel arrays, grows in place, never allocates mid-search)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createHeap(cap) {
|
||||
return { f: new Float64Array(cap), k: new Int32Array(cap), size: 0, cap };
|
||||
}
|
||||
|
||||
function heapClear(hp) { hp.size = 0; }
|
||||
|
||||
function heapPush(hp, f, k) {
|
||||
if (hp.size === hp.cap) {
|
||||
const cap = hp.cap * 2;
|
||||
const nf = new Float64Array(cap); nf.set(hp.f);
|
||||
const nk = new Int32Array(cap); nk.set(hp.k);
|
||||
hp.f = nf; hp.k = nk; hp.cap = cap;
|
||||
}
|
||||
let i = hp.size++;
|
||||
hp.f[i] = f; hp.k[i] = k;
|
||||
while (i > 0) {
|
||||
const p = (i - 1) >> 1;
|
||||
if (hp.f[p] <= hp.f[i]) break;
|
||||
swapHeap(hp, i, p);
|
||||
i = p;
|
||||
}
|
||||
}
|
||||
|
||||
function heapPop(hp) {
|
||||
const top = hp.k[0];
|
||||
hp.size--;
|
||||
if (hp.size > 0) {
|
||||
hp.f[0] = hp.f[hp.size]; hp.k[0] = hp.k[hp.size];
|
||||
let i = 0;
|
||||
for (;;) {
|
||||
const l = i * 2 + 1, r = l + 1;
|
||||
let m = i;
|
||||
if (l < hp.size && hp.f[l] < hp.f[m]) m = l;
|
||||
if (r < hp.size && hp.f[r] < hp.f[m]) m = r;
|
||||
if (m === i) break;
|
||||
swapHeap(hp, i, m);
|
||||
i = m;
|
||||
}
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
function swapHeap(hp, a, b) {
|
||||
const f = hp.f[a]; hp.f[a] = hp.f[b]; hp.f[b] = f;
|
||||
const k = hp.k[a]; hp.k[a] = hp.k[b]; hp.k[b] = k;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A*
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NEIGHBOR_DX = [1, -1, 0, 0, 1, 1, -1, -1];
|
||||
const NEIGHBOR_DY = [0, 0, 1, -1, 1, -1, 1, -1];
|
||||
|
||||
/**
|
||||
* A* over the tile grid.
|
||||
* @returns {Int32Array|null} tile indices from start to goal inclusive, or null if unreachable
|
||||
*/
|
||||
export function findPath(nav, mc, need, startIdx, goalIdx, expansionCap = 20000) {
|
||||
if (startIdx === goalIdx) return Int32Array.of(startIdx);
|
||||
const cl = fitField(nav, mc, need);
|
||||
const cost = nav.cost[mc];
|
||||
if (cl[goalIdx] < need || cl[startIdx] < need) return null;
|
||||
|
||||
const { w, h } = nav;
|
||||
const g = nav._g, came = nav._came, stamp = nav._stamp, hp = nav._heap;
|
||||
const epoch = ++nav._epoch;
|
||||
heapClear(hp);
|
||||
|
||||
const gx = goalIdx % w, gy = (goalIdx / w) | 0;
|
||||
const heuristic = (i) => {
|
||||
const dx = Math.abs((i % w) - gx);
|
||||
const dy = Math.abs(((i / w) | 0) - gy);
|
||||
// Octile: exact for 8-way movement on a uniform grid, so it never over-estimates
|
||||
// once terrain cost is >= 1 — which the rules validator guarantees.
|
||||
return (dx > dy ? dx - dy + SQRT2 * dy : dy - dx + SQRT2 * dx);
|
||||
};
|
||||
|
||||
g[startIdx] = 0;
|
||||
came[startIdx] = -1;
|
||||
stamp[startIdx] = epoch;
|
||||
heapPush(hp, heuristic(startIdx), startIdx);
|
||||
|
||||
let expansions = 0;
|
||||
const closed = new Set();
|
||||
|
||||
while (hp.size > 0) {
|
||||
const cur = heapPop(hp);
|
||||
if (cur === goalIdx) return reconstruct(came, startIdx, goalIdx);
|
||||
if (closed.has(cur)) continue;
|
||||
closed.add(cur);
|
||||
if (++expansions > expansionCap) return null;
|
||||
|
||||
const cx = cur % w, cy = (cur / w) | 0;
|
||||
const gcur = g[cur];
|
||||
|
||||
for (let d = 0; d < 8; d++) {
|
||||
const nx = cx + NEIGHBOR_DX[d], ny = cy + NEIGHBOR_DY[d];
|
||||
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
|
||||
const ni = ny * w + nx;
|
||||
if (cl[ni] < need) continue;
|
||||
const diag = d >= 4;
|
||||
if (diag) {
|
||||
// No corner cutting: both orthogonal neighbours must also be usable, or a tank
|
||||
// squeezes diagonally between two buildings that visually touch.
|
||||
if (cl[cy * w + nx] < need || cl[ny * w + cx] < need) continue;
|
||||
}
|
||||
const step = cost[ni] * (diag ? SQRT2 : 1);
|
||||
const tentative = gcur + step;
|
||||
if (stamp[ni] === epoch && tentative >= g[ni]) continue;
|
||||
stamp[ni] = epoch;
|
||||
g[ni] = tentative;
|
||||
came[ni] = cur;
|
||||
heapPush(hp, tentative + heuristic(ni), ni);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function reconstruct(came, startIdx, goalIdx) {
|
||||
let len = 1;
|
||||
for (let i = goalIdx; i !== startIdx; i = came[i]) len++;
|
||||
const out = new Int32Array(len);
|
||||
let p = len - 1;
|
||||
for (let i = goalIdx; i !== -1; i = came[i]) {
|
||||
out[p--] = i;
|
||||
if (i === startIdx) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Line of sight and string pulling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Can a unit of this clearance travel the straight segment a->b without clipping a
|
||||
* blocked tile? Supercover walk (every tile the segment touches, not just Bresenham's).
|
||||
*/
|
||||
export function segmentClear(nav, mc, need, x0, y0, x1, y1) {
|
||||
const ts = nav.tileSize;
|
||||
const cl = fitField(nav, mc, need);
|
||||
let tx = Math.floor(x0 / ts), ty = Math.floor(y0 / ts);
|
||||
const tx1 = Math.floor(x1 / ts), ty1 = Math.floor(y1 / ts);
|
||||
const dx = x1 - x0, dy = y1 - y0;
|
||||
const stepX = dx > 0 ? 1 : -1, stepY = dy > 0 ? 1 : -1;
|
||||
const tDeltaX = dx === 0 ? Infinity : Math.abs(ts / dx);
|
||||
const tDeltaY = dy === 0 ? Infinity : Math.abs(ts / dy);
|
||||
let tMaxX = dx === 0 ? Infinity
|
||||
: Math.abs(((dx > 0 ? (tx + 1) * ts : tx * ts) - x0) / dx);
|
||||
let tMaxY = dy === 0 ? Infinity
|
||||
: Math.abs(((dy > 0 ? (ty + 1) * ts : ty * ts) - y0) / dy);
|
||||
|
||||
let guard = 0;
|
||||
const limit = nav.w + nav.h + 4;
|
||||
for (;;) {
|
||||
if (tx < 0 || ty < 0 || tx >= nav.w || ty >= nav.h) return false;
|
||||
if (cl[ty * nav.w + tx] < need) return false;
|
||||
if (tx === tx1 && ty === ty1) return true;
|
||||
if (++guard > limit) return false;
|
||||
if (tMaxX < tMaxY) { tx += stepX; tMaxX += tDeltaX; }
|
||||
else { ty += stepY; tMaxY += tDeltaY; }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* String-pull a tile path into a short list of world waypoints. Greedily skips any
|
||||
* waypoint the unit can reach in a straight line, so units drive smooth diagonals
|
||||
* instead of staircasing along tile centres.
|
||||
* @returns {number[]} flat [x0,y0, x1,y1, ...] world coordinates
|
||||
*/
|
||||
export function smoothPath(nav, mc, need, path, endX, endY) {
|
||||
const out = [];
|
||||
if (!path || path.length === 0) return out;
|
||||
const px = (i) => tileCenterX(nav, path[i] % nav.w);
|
||||
const py = (i) => tileCenterY(nav, (path[i] / nav.w) | 0);
|
||||
|
||||
let anchorX = px(0), anchorY = py(0);
|
||||
let i = 0;
|
||||
while (i < path.length - 1) {
|
||||
// Advance as far as we can still see from the anchor.
|
||||
let j = i + 1;
|
||||
while (j + 1 < path.length && segmentClear(nav, mc, need, anchorX, anchorY, px(j + 1), py(j + 1))) j++;
|
||||
out.push(px(j), py(j));
|
||||
anchorX = px(j); anchorY = py(j);
|
||||
i = j;
|
||||
}
|
||||
// Replace the final tile centre with the true destination when it's directly reachable.
|
||||
if (endX != null && out.length >= 2) {
|
||||
const lx = out[out.length - 2], ly = out[out.length - 1];
|
||||
if (segmentClear(nav, mc, need, lx, ly, endX, endY)) {
|
||||
out[out.length - 2] = endX;
|
||||
out[out.length - 1] = endY;
|
||||
} else {
|
||||
out.push(endX, endY);
|
||||
}
|
||||
} else if (endX != null) {
|
||||
out.push(endX, endY);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formation slots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fan a group out around a destination on a hex-ish lattice, then assign units to slots
|
||||
* greedily by distance. One shared path plus these offsets is what makes a 40-unit move
|
||||
* order cost one A* instead of forty.
|
||||
* @returns {number[]} flat [x,y] per unit, in the same order as `units`
|
||||
*/
|
||||
export function formationSlots(cx, cy, units, spacingScale = 1.15) {
|
||||
const nMax = units.length;
|
||||
let maxR = 0;
|
||||
for (const u of units) maxR = Math.max(maxR, u.radius);
|
||||
const spacing = maxR * 2 * spacingScale;
|
||||
|
||||
const slots = [[cx, cy]];
|
||||
for (let ring = 1; slots.length < nMax; ring++) {
|
||||
const count = 6 * ring;
|
||||
for (let s = 0; s < count && slots.length < nMax; s++) {
|
||||
const a = (s / count) * Math.PI * 2;
|
||||
slots.push([cx + Math.cos(a) * spacing * ring, cy + Math.sin(a) * spacing * ring]);
|
||||
}
|
||||
}
|
||||
|
||||
// Greedy nearest assignment: sort both sides by distance from the destination and pair
|
||||
// up, so the units already closest take the inner slots and nobody crosses the group.
|
||||
const order = units.map((u, i) => ({
|
||||
i, d: (u.x - cx) * (u.x - cx) + (u.y - cy) * (u.y - cy),
|
||||
})).sort((a, b) => a.d - b.d || a.i - b.i);
|
||||
|
||||
const out = new Array(nMax * 2);
|
||||
order.forEach((entry, rank) => {
|
||||
out[entry.i * 2] = slots[rank][0];
|
||||
out[entry.i * 2 + 1] = slots[rank][1];
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spatial hash — rebuilt every tick, drives separation and target acquisition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class SpatialHash {
|
||||
constructor(cellSize, worldW, worldH) {
|
||||
this.cell = cellSize;
|
||||
this.cols = Math.max(1, Math.ceil(worldW / cellSize));
|
||||
this.rows = Math.max(1, Math.ceil(worldH / cellSize));
|
||||
this.buckets = Array.from({ length: this.cols * this.rows }, () => []);
|
||||
}
|
||||
|
||||
clear() {
|
||||
for (const b of this.buckets) b.length = 0;
|
||||
}
|
||||
|
||||
_bucket(x, y) {
|
||||
const cx = Math.max(0, Math.min(this.cols - 1, (x / this.cell) | 0));
|
||||
const cy = Math.max(0, Math.min(this.rows - 1, (y / this.cell) | 0));
|
||||
return cy * this.cols + cx;
|
||||
}
|
||||
|
||||
insert(item, x, y) {
|
||||
this.buckets[this._bucket(x, y)].push(item);
|
||||
}
|
||||
|
||||
/** Append every item within `r` of (x,y) into `out`. Order is deterministic. */
|
||||
query(x, y, r, out) {
|
||||
const c0 = Math.max(0, ((x - r) / this.cell) | 0);
|
||||
const c1 = Math.min(this.cols - 1, ((x + r) / this.cell) | 0);
|
||||
const r0 = Math.max(0, ((y - r) / this.cell) | 0);
|
||||
const r1 = Math.min(this.rows - 1, ((y + r) / this.cell) | 0);
|
||||
for (let cy = r0; cy <= r1; cy++) {
|
||||
const base = cy * this.cols;
|
||||
for (let cx = c0; cx <= c1; cx++) {
|
||||
const b = this.buckets[base + cx];
|
||||
for (let i = 0; i < b.length; i++) out.push(b[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
// Total Annihilation — rules compiler and validator.
|
||||
//
|
||||
// Headless: no Phaser imports, runs in Node for tools/verifyTotalAnnihilation.js.
|
||||
//
|
||||
// compileRules() indexes data/totalannihilation-rules.json by id and validates every
|
||||
// cross-reference against a closed key set, throwing on the first problem. The point is
|
||||
// that a typo in the JSON fails loudly in the verify script rather than silently at
|
||||
// runtime three minutes into a match.
|
||||
|
||||
export const WEAPON_KINDS = new Set(['hitscan', 'ballistic', 'guided', 'beam']);
|
||||
export const FX_STYLES = new Set(['tracer', 'beam', 'shell', 'rocket', 'dgun']);
|
||||
export const UNIT_ROLES = new Set(['builder', 'combat', 'scout', 'artillery']);
|
||||
export const SHEET_SLOTS = new Set(['unitSheet', 'structureSheet']);
|
||||
export const TARGET_DOMAINS = new Set(['ground', 'air']);
|
||||
|
||||
// Procedural painter shapes. A def may only name a shape TAArt knows how to draw;
|
||||
// this set is duplicated there and the verify script asserts the two agree.
|
||||
export const PROC_SHAPES = new Set([
|
||||
'commander', 'infantry', 'sniper', 'jeep', 'tank', 'rockettank',
|
||||
'energyGen', 'massGen', 'barracks', 'vehiclePlant',
|
||||
]);
|
||||
|
||||
function fail(msg) {
|
||||
throw new Error(`[totalannihilation-rules] ${msg}`);
|
||||
}
|
||||
|
||||
function requireUnique(list, what) {
|
||||
const seen = new Set();
|
||||
for (const e of list) {
|
||||
if (!e || typeof e.id !== 'string' || !e.id) fail(`${what} entry is missing an id`);
|
||||
if (seen.has(e.id)) fail(`duplicate ${what} id "${e.id}"`);
|
||||
seen.add(e.id);
|
||||
}
|
||||
}
|
||||
|
||||
function requirePositive(obj, keys, what) {
|
||||
for (const k of keys) {
|
||||
const v = obj[k];
|
||||
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) {
|
||||
fail(`${what} "${obj.id}" needs a positive ${k} (got ${JSON.stringify(v)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquisition range. Manual weapons are excluded so a Commander doesn't chase enemies into
|
||||
* D-Gun range it will never voluntarily use; a manual-only def falls back to its full reach
|
||||
* so it still knows when something is worth shooting at.
|
||||
*/
|
||||
function autoRangeOf(weaponDefs) {
|
||||
const auto = weaponDefs.filter((w) => !w.manual);
|
||||
return (auto.length ? auto : weaponDefs).reduce((m, w) => Math.max(m, w.range), 0);
|
||||
}
|
||||
|
||||
function indexById(list) {
|
||||
const m = Object.create(null);
|
||||
for (const e of list) m[e.id] = e;
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile and validate the raw rules JSON.
|
||||
* @param {object} json parsed data/totalannihilation-rules.json
|
||||
* @returns {object} compiled rules with by-id indexes and derived tables
|
||||
*/
|
||||
export function compileRules(json) {
|
||||
if (!json || typeof json !== 'object') fail('rules JSON is empty');
|
||||
|
||||
const c = json.constants;
|
||||
if (!c) fail('missing constants');
|
||||
requirePositive(c, ['tickHz', 'tileSize', 'unitCapPerArmy', 'buildPowerNominal'], 'constants');
|
||||
if (c.tileSize % 2 !== 0) fail('constants.tileSize must be even');
|
||||
|
||||
// ---- closed sets -------------------------------------------------------
|
||||
const armorClasses = json.armorClasses ?? [];
|
||||
if (!armorClasses.length) fail('armorClasses must be a non-empty array');
|
||||
const armorSet = new Set(armorClasses);
|
||||
|
||||
const sizeClasses = json.sizeClasses ?? {};
|
||||
for (const [id, s] of Object.entries(sizeClasses)) {
|
||||
if (!(s.radius > 0)) fail(`sizeClass "${id}" needs a positive radius`);
|
||||
if (!(s.footprint >= 1)) fail(`sizeClass "${id}" needs footprint >= 1`);
|
||||
if (!(s.mass > 0)) fail(`sizeClass "${id}" needs a positive mass`);
|
||||
}
|
||||
const sizeSet = new Set(Object.keys(sizeClasses));
|
||||
|
||||
// ---- terrain -----------------------------------------------------------
|
||||
const terrain = json.terrain ?? [];
|
||||
requireUnique(terrain, 'terrain');
|
||||
const terrainById = indexById(terrain);
|
||||
const terrainByCh = Object.create(null);
|
||||
terrain.forEach((t, i) => {
|
||||
if (typeof t.ch !== 'string' || t.ch.length !== 1) fail(`terrain "${t.id}" needs a single-char ch`);
|
||||
if (terrainByCh[t.ch]) fail(`terrain char "${t.ch}" used by both "${terrainByCh[t.ch].id}" and "${t.id}"`);
|
||||
if (!Number.isInteger(t.frame) || t.frame < 0) fail(`terrain "${t.id}" needs a non-negative integer frame`);
|
||||
t.index = i;
|
||||
terrainByCh[t.ch] = t;
|
||||
});
|
||||
if (!terrain.length) fail('at least one terrain type is required');
|
||||
|
||||
// ---- move classes ------------------------------------------------------
|
||||
const moveClasses = json.moveClasses ?? {};
|
||||
for (const [mc, spec] of Object.entries(moveClasses)) {
|
||||
if (!spec.cost) fail(`moveClass "${mc}" is missing its cost table`);
|
||||
for (const t of terrain) {
|
||||
if (!(t.id in spec.cost)) fail(`moveClass "${mc}" has no cost for terrain "${t.id}"`);
|
||||
const v = spec.cost[t.id];
|
||||
if (v !== null && !(typeof v === 'number' && v > 0)) {
|
||||
fail(`moveClass "${mc}" cost for "${t.id}" must be a positive number or null (impassable)`);
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(spec.cost)) {
|
||||
if (!terrainById[key]) fail(`moveClass "${mc}" references unknown terrain "${key}"`);
|
||||
}
|
||||
// Flat per-terrain-index cost array — the pathfinder's hot path reads this.
|
||||
spec.costByTerrainIndex = terrain.map((t) => (t.blocksMove ? null : spec.cost[t.id]));
|
||||
}
|
||||
const moveClassSet = new Set(Object.keys(moveClasses));
|
||||
|
||||
// ---- armies & commanders ----------------------------------------------
|
||||
const armies = json.armies ?? [];
|
||||
requireUnique(armies, 'army');
|
||||
if (armies.length < 2) fail('at least two armies are required');
|
||||
for (const a of armies) {
|
||||
if (!a.unitSheet || !a.structureSheet) fail(`army "${a.id}" needs unitSheet and structureSheet`);
|
||||
if (!/^#[0-9a-f]{6}$/i.test(a.color ?? '')) fail(`army "${a.id}" needs a #rrggbb color`);
|
||||
a.colorInt = parseInt(a.color.slice(1), 16);
|
||||
}
|
||||
const armyById = indexById(armies);
|
||||
const armyIndexById = Object.create(null);
|
||||
armies.forEach((a, i) => { armyIndexById[a.id] = i; });
|
||||
|
||||
const commanders = json.commanders ?? [];
|
||||
requireUnique(commanders, 'commander');
|
||||
for (const cm of commanders) {
|
||||
if (!armyById[cm.armyId]) fail(`commander "${cm.id}" references unknown army "${cm.armyId}"`);
|
||||
if (!cm.opponentId) fail(`commander "${cm.id}" needs an opponentId for its portrait`);
|
||||
}
|
||||
const commanderById = indexById(commanders);
|
||||
|
||||
// ---- weapons -----------------------------------------------------------
|
||||
const weapons = json.weapons ?? [];
|
||||
requireUnique(weapons, 'weapon');
|
||||
for (const w of weapons) {
|
||||
if (!WEAPON_KINDS.has(w.kind)) {
|
||||
fail(`weapon "${w.id}" has unknown kind "${w.kind}" (expected ${[...WEAPON_KINDS].join('|')})`);
|
||||
}
|
||||
requirePositive(w, ['damage', 'reload', 'range'], 'weapon');
|
||||
if (w.kind === 'ballistic' || w.kind === 'guided') {
|
||||
requirePositive(w, ['speed'], 'weapon');
|
||||
}
|
||||
if (w.kind === 'guided' && !(w.turnRate > 0)) fail(`guided weapon "${w.id}" needs a positive turnRate`);
|
||||
if (w.minRange != null && !(w.minRange >= 0 && w.minRange < w.range)) {
|
||||
fail(`weapon "${w.id}" minRange must be >= 0 and < range`);
|
||||
}
|
||||
for (const key of Object.keys(w.armorMul ?? {})) {
|
||||
if (!armorSet.has(key)) fail(`weapon "${w.id}" armorMul references unknown armor class "${key}"`);
|
||||
}
|
||||
for (const a of armorClasses) {
|
||||
if (!(a in (w.armorMul ?? {}))) fail(`weapon "${w.id}" armorMul is missing armor class "${a}"`);
|
||||
}
|
||||
for (const d of w.targets ?? []) {
|
||||
if (!TARGET_DOMAINS.has(d)) fail(`weapon "${w.id}" targets unknown domain "${d}"`);
|
||||
}
|
||||
if (!w.fx || !FX_STYLES.has(w.fx.style)) {
|
||||
fail(`weapon "${w.id}" needs fx.style from ${[...FX_STYLES].join('|')}`);
|
||||
}
|
||||
if (w.aoe != null && !(w.aoe > 0)) fail(`weapon "${w.id}" aoe must be positive when present`);
|
||||
// Reload/burst in ticks, precomputed so the sim never divides in its hot loop.
|
||||
w.reloadTicks = Math.max(1, Math.round(w.reload * c.tickHz));
|
||||
w.burstDelayTicks = Math.max(1, Math.round((w.burstDelay ?? 0) * c.tickHz));
|
||||
w.burst = w.burst ?? 1;
|
||||
w.rangeSq = w.range * w.range;
|
||||
w.minRangeSq = (w.minRange ?? 0) * (w.minRange ?? 0);
|
||||
w.targetsAir = (w.targets ?? ['ground']).includes('air');
|
||||
// A manual weapon never auto-fires: it needs an explicit attack order on the victim.
|
||||
// The D-Gun is the reason this flag exists — left on auto-fire, a defending Commander
|
||||
// deletes one attacker every reload for free, which makes assaulting any base suicide.
|
||||
w.manual = w.manual === true;
|
||||
}
|
||||
const weaponById = indexById(weapons);
|
||||
|
||||
// ---- shared unit/building validation ----------------------------------
|
||||
const buildable = Object.create(null); // id -> def, for `builds` cross-checks
|
||||
|
||||
const units = json.units ?? [];
|
||||
requireUnique(units, 'unit');
|
||||
for (const u of units) {
|
||||
if (!sizeSet.has(u.size)) fail(`unit "${u.id}" has unknown size "${u.size}"`);
|
||||
if (!armorSet.has(u.armorClass)) fail(`unit "${u.id}" has unknown armorClass "${u.armorClass}"`);
|
||||
if (!moveClassSet.has(u.moveClass)) fail(`unit "${u.id}" has unknown moveClass "${u.moveClass}"`);
|
||||
if (u.role && !UNIT_ROLES.has(u.role)) fail(`unit "${u.id}" has unknown role "${u.role}"`);
|
||||
if (!SHEET_SLOTS.has(u.sheetSlot)) fail(`unit "${u.id}" has unknown sheetSlot "${u.sheetSlot}"`);
|
||||
if (!PROC_SHAPES.has(u.procShape)) fail(`unit "${u.id}" has unknown procShape "${u.procShape}"`);
|
||||
requirePositive(u, ['hp', 'speed', 'turnRate', 'sight'], 'unit');
|
||||
if (!Number.isInteger(u.frame) || u.frame < 0) fail(`unit "${u.id}" needs a non-negative integer frame`);
|
||||
if (u.turretFrame != null && (!Number.isInteger(u.turretFrame) || u.turretFrame < 0)) {
|
||||
fail(`unit "${u.id}" turretFrame must be a non-negative integer`);
|
||||
}
|
||||
for (const wid of u.weapons ?? []) {
|
||||
if (!weaponById[wid]) fail(`unit "${u.id}" references unknown weapon "${wid}"`);
|
||||
}
|
||||
const sc = sizeClasses[u.size];
|
||||
u.radius = u.radius ?? sc.radius;
|
||||
u.massClass = sc.mass;
|
||||
u.footprintTiles = sc.footprint;
|
||||
u.sightSq = u.sight * u.sight;
|
||||
u.isBuilding = false;
|
||||
u.weaponDefs = (u.weapons ?? []).map((wid) => weaponById[wid]);
|
||||
u.maxRange = autoRangeOf(u.weaponDefs);
|
||||
u.spritePx = u.spritePx ?? sc.radius * 2;
|
||||
buildable[u.id] = u;
|
||||
}
|
||||
const unitById = indexById(units);
|
||||
|
||||
const buildings = json.buildings ?? [];
|
||||
requireUnique(buildings, 'building');
|
||||
for (const b of buildings) {
|
||||
if (!armorSet.has(b.armorClass)) fail(`building "${b.id}" has unknown armorClass "${b.armorClass}"`);
|
||||
if (!SHEET_SLOTS.has(b.sheetSlot)) fail(`building "${b.id}" has unknown sheetSlot "${b.sheetSlot}"`);
|
||||
if (!PROC_SHAPES.has(b.procShape)) fail(`building "${b.id}" has unknown procShape "${b.procShape}"`);
|
||||
requirePositive(b, ['hp', 'buildTime'], 'building');
|
||||
if (!b.footprint || !(b.footprint.w >= 1) || !(b.footprint.h >= 1)) {
|
||||
fail(`building "${b.id}" needs a footprint of at least 1x1 tiles`);
|
||||
}
|
||||
if (!Number.isInteger(b.frame) || b.frame < 0) fail(`building "${b.id}" needs a non-negative integer frame`);
|
||||
if (b.terrainMultiplier && !terrain.some((t) => b.terrainMultiplier in t)) {
|
||||
fail(`building "${b.id}" terrainMultiplier "${b.terrainMultiplier}" is on no terrain type`);
|
||||
}
|
||||
for (const wid of b.weapons ?? []) {
|
||||
if (!weaponById[wid]) fail(`building "${b.id}" references unknown weapon "${wid}"`);
|
||||
}
|
||||
b.isBuilding = true;
|
||||
b.radius = (Math.max(b.footprint.w, b.footprint.h) * c.tileSize) / 2;
|
||||
b.sight = b.sight ?? 0;
|
||||
b.sightSq = b.sight * b.sight;
|
||||
b.weaponDefs = (b.weapons ?? []).map((wid) => weaponById[wid]);
|
||||
b.maxRange = autoRangeOf(b.weaponDefs);
|
||||
buildable[b.id] = b;
|
||||
}
|
||||
const buildingById = indexById(buildings);
|
||||
|
||||
// `builds` lists must resolve, and a factory may only build units (not buildings).
|
||||
for (const def of [...units, ...buildings]) {
|
||||
for (const id of def.builds ?? []) {
|
||||
if (!buildable[id]) fail(`"${def.id}" builds unknown def "${id}"`);
|
||||
if (def.isBuilding && !unitById[id]) fail(`factory "${def.id}" may only build units, not "${id}"`);
|
||||
if (!def.isBuilding && !buildingById[id]) fail(`mobile builder "${def.id}" may only build buildings, not "${id}"`);
|
||||
}
|
||||
if ((def.builds ?? []).length && !(def.buildPower > 0)) {
|
||||
fail(`"${def.id}" has a builds list but no positive buildPower`);
|
||||
}
|
||||
}
|
||||
// Every producible unit must be reachable from some factory, or it's dead data.
|
||||
for (const u of units) {
|
||||
if (u.buildTime === 0) continue; // starting units (the Commander) are placed, not built
|
||||
const madeBy = buildings.filter((b) => (b.builds ?? []).includes(u.id));
|
||||
if (!madeBy.length) fail(`unit "${u.id}" has a buildTime but no building builds it`);
|
||||
for (const bid of u.builtBy ?? []) {
|
||||
if (!buildingById[bid]) fail(`unit "${u.id}" builtBy references unknown building "${bid}"`);
|
||||
if (!(buildingById[bid].builds ?? []).includes(u.id)) {
|
||||
fail(`unit "${u.id}" claims builtBy "${bid}" but that building does not list it`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- AI skills ---------------------------------------------------------
|
||||
const aiSkills = json.aiSkills ?? [];
|
||||
const aiBySkill = Object.create(null);
|
||||
for (const s of aiSkills) {
|
||||
if (!Number.isInteger(s.skill) || s.skill < 1) fail('aiSkills entries need an integer skill >= 1');
|
||||
aiBySkill[s.skill] = s;
|
||||
}
|
||||
if (!aiSkills.length) fail('at least one aiSkills entry is required');
|
||||
|
||||
// ---- skirmish ----------------------------------------------------------
|
||||
const sk = json.skirmish;
|
||||
if (!sk) fail('missing skirmish config');
|
||||
for (const [name, tiles] of Object.entries(sk.sizes ?? {})) {
|
||||
if (!Number.isInteger(tiles) || tiles < 32) fail(`skirmish size "${name}" must be an integer >= 32 tiles`);
|
||||
}
|
||||
for (const id of sk.startUnits ?? []) {
|
||||
if (!unitById[id]) fail(`skirmish.startUnits references unknown unit "${id}"`);
|
||||
}
|
||||
if (!sk.sizes?.[sk.defaults?.size]) fail('skirmish.defaults.size is not one of skirmish.sizes');
|
||||
if (!(sk.symmetries ?? []).includes(sk.defaults?.symmetry)) fail('skirmish.defaults.symmetry is not one of skirmish.symmetries');
|
||||
if (!(sk.themes ?? []).includes(sk.defaults?.theme)) fail('skirmish.defaults.theme is not one of skirmish.themes');
|
||||
if (!armyById[sk.defaults?.playerArmy]) fail('skirmish.defaults.playerArmy is not a known army');
|
||||
|
||||
return {
|
||||
version: json.version ?? 1,
|
||||
constants: c,
|
||||
armorClasses, armorSet,
|
||||
sizeClasses,
|
||||
terrain, terrainById, terrainByCh,
|
||||
moveClasses,
|
||||
armies, armyById, armyIndexById,
|
||||
commanders, commanderById,
|
||||
weapons, weaponById,
|
||||
units, unitById,
|
||||
buildings, buildingById,
|
||||
defById: buildable,
|
||||
commandIcons: json.commandIcons ?? {},
|
||||
aiSkills, aiBySkill,
|
||||
skirmish: sk,
|
||||
// Derived conveniences the sim and view both want.
|
||||
stepMs: 1000 / c.tickHz,
|
||||
dt: 1 / c.tickHz,
|
||||
halfTile: c.tileSize / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/** Damage multiplier of `weapon` against a defender of `armorClass`. */
|
||||
export function armorMul(weapon, armorClass) {
|
||||
return weapon.armorMul?.[armorClass] ?? 1;
|
||||
}
|
||||
|
||||
/** Look up a def (unit or building) by id. */
|
||||
export function defOf(rules, id) {
|
||||
return rules.defById[id] ?? null;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,590 @@
|
|||
// Total Annihilation — world renderer.
|
||||
//
|
||||
// Camera model: a REAL Phaser world camera plus a second screen-space UI camera, the
|
||||
// MiniMotorwaysGame.js:213-227 pattern. A real camera buys frustum culling, setBounds,
|
||||
// zoomTo and — decisively for an RTS — pointer.worldX/worldY for click-to-order.
|
||||
//
|
||||
// Terrain bakes into chunked RenderTextures (CivilizationMapView's forEachChunk model). A
|
||||
// large map is 128 tiles = 8192px, well past a safe single GPU texture, so it tiles into
|
||||
// 1024px chunks created LAZILY as the camera reaches them — 36 chunks eagerly stamped would
|
||||
// cost several seconds and ~150MB at mission start.
|
||||
//
|
||||
// Units are plain Images, never Containers: at 300+ units the Container overhead is real.
|
||||
// Selection rings, health bars and build ghosts all share ONE Graphics each.
|
||||
|
||||
import { ensureSheets, sheetFrameSize } from './TAArt.js';
|
||||
import { colorInt } from './TAFx.js';
|
||||
|
||||
const CHUNK_PX = 1024;
|
||||
const ZOOMS = [0.5, 0.7, 1.0, 1.4];
|
||||
const CHUNK_IDLE_MS = 30000; // reclaim chunks the camera hasn't looked at in a while
|
||||
const MAX_QUEUE_LINES = 24; // selected units whose order queue is drawn
|
||||
|
||||
/** Waypoint colour per order type, so a queue reads at a glance. */
|
||||
const ORDER_COLORS = {
|
||||
move: 0x7dff9b, attackMove: 0xff8a5a, attack: 0xff5a5a, patrol: 0x8ad4ff,
|
||||
guard: 0xffd27a, assist: 0x8affd0, build: 0x8ad4ff,
|
||||
};
|
||||
|
||||
export const DEPTHS = {
|
||||
terrain: 0, decal: 5, fxUnder: 8, ghost: 12,
|
||||
selection: 15, actor: 20, bars: 55, projectile: 60, fxOver: 65, fog: 80,
|
||||
};
|
||||
|
||||
export default class TAWorldView {
|
||||
constructor(scene, rules, art, state, playerArmy) {
|
||||
this.scene = scene;
|
||||
this.rules = rules;
|
||||
this.art = art;
|
||||
this.state = state;
|
||||
this.playerArmy = playerArmy;
|
||||
this.ts = state.tileSize;
|
||||
|
||||
this.worldRoot = scene.add.container(0, 0);
|
||||
this.uiRoot = scene.add.container(0, 0);
|
||||
|
||||
const { keys, procedural } = ensureSheets(scene, rules, art);
|
||||
this.sheetKeys = keys;
|
||||
this.proceduralSheets = procedural;
|
||||
|
||||
// Per-army resolved sheet keys, so a unit def's `sheetSlot` renders correctly for any army.
|
||||
this.armySheets = rules.armies.map((a) => ({
|
||||
unitSheet: keys[a.unitSheet],
|
||||
structureSheet: keys[a.structureSheet],
|
||||
unitFrame: sheetFrameSize(art, a.unitSheet),
|
||||
structureFrame: sheetFrameSize(art, a.structureSheet),
|
||||
color: a.colorInt,
|
||||
}));
|
||||
|
||||
const themeName = state.theme;
|
||||
const theme = art.themes?.[themeName];
|
||||
this.terrainKey = keys[theme?.sheet] ?? keys[Object.keys(art.sheets)[0]];
|
||||
this.themePalette = theme?.palette ?? {};
|
||||
|
||||
this._setupCameras();
|
||||
this._setupTerrain();
|
||||
this._setupFog();
|
||||
|
||||
this.gSelection = scene.add.graphics().setDepth(DEPTHS.selection);
|
||||
this.gOrders = scene.add.graphics().setDepth(DEPTHS.selection);
|
||||
this.gBars = scene.add.graphics().setDepth(DEPTHS.bars);
|
||||
this.gGhost = scene.add.graphics().setDepth(DEPTHS.ghost);
|
||||
this._addWorld(this.gSelection);
|
||||
this._addWorld(this.gOrders);
|
||||
this._addWorld(this.gBars);
|
||||
this._addWorld(this.gGhost);
|
||||
|
||||
this.sprites = new Map(); // entity id -> { img, turret }
|
||||
this.selection = new Set();
|
||||
this.placement = null; // { def, tx, ty, legal }
|
||||
this.showAllBars = false;
|
||||
this._stamp = scene.make.image({ x: 0, y: 0, key: this.terrainKey, add: false }).setOrigin(0, 0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cameras
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_setupCameras() {
|
||||
const { scene, state } = this;
|
||||
const cam = scene.cameras.main;
|
||||
const margin = this.ts * 3;
|
||||
cam.setBounds(-margin, -margin, state.worldW + margin * 2, state.worldH + margin * 2);
|
||||
cam.setBackgroundColor(0x0b0e12);
|
||||
this.zoomIdx = 2;
|
||||
cam.setZoom(ZOOMS[this.zoomIdx]);
|
||||
|
||||
this.uiCam = scene.cameras.add(0, 0, scene.scale.width, scene.scale.height);
|
||||
this.uiCam.ignore(this.worldRoot);
|
||||
cam.ignore(this.uiRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a child to the world container.
|
||||
*
|
||||
* Phaser Containers render their children in INSERTION order and never sort by depth on
|
||||
* their own — `sortChildrenFlag` exists on DisplayList and Layer, not Container. So depth
|
||||
* here is inert until `sort('depth')` runs, which is why the fog image (built in the
|
||||
* constructor, long before the terrain chunks it must cover) was drawn underneath the map
|
||||
* and the whole battlefield was visible. Anything added mid-frame — a lazily painted
|
||||
* terrain chunk, a new unit sprite — re-dirties the order.
|
||||
*/
|
||||
_addWorld(obj) {
|
||||
this.worldRoot.add(obj);
|
||||
this._layerDirty = true;
|
||||
return obj;
|
||||
}
|
||||
|
||||
/** Keep screen-space extras (music controls, fullscreen button) off the world camera. */
|
||||
ignoreOnWorldCam(objs) {
|
||||
if (!objs?.length) return;
|
||||
this.scene.cameras.main.ignore(objs);
|
||||
}
|
||||
|
||||
panBy(dx, dy) {
|
||||
const cam = this.scene.cameras.main;
|
||||
cam.setScroll(cam.scrollX + dx / cam.zoom, cam.scrollY + dy / cam.zoom);
|
||||
}
|
||||
|
||||
centerOn(x, y) { this.scene.cameras.main.centerOn(x, y); }
|
||||
|
||||
/**
|
||||
* Zoom one step, keeping whatever the cursor is over pinned under the cursor.
|
||||
*
|
||||
* The scroll is solved directly from the camera's screen->world mapping rather than by
|
||||
* diffing two getWorldPoint() calls around the zoom change. getWorldPoint inverts
|
||||
* `camera.matrix`, and that matrix is only rebuilt in preRender — so the second call reads
|
||||
* the OLD zoom while the camera already holds the new one, and the correction it yields is
|
||||
* wrong. Solving it makes the anchor exact and independent of when in the frame this runs.
|
||||
*
|
||||
* At the edges of the map the scroll clamps to the camera bounds, so the anchor gives way
|
||||
* rather than letting the view slide off the world.
|
||||
*/
|
||||
zoomBy(dir, focusX, focusY) {
|
||||
const cam = this.scene.cameras.main;
|
||||
const next = Math.max(0, Math.min(ZOOMS.length - 1, this.zoomIdx + dir));
|
||||
if (next === this.zoomIdx) return;
|
||||
const anchor = this.worldPoint(focusX, focusY);
|
||||
this.zoomIdx = next;
|
||||
const z = ZOOMS[next];
|
||||
cam.setZoom(z);
|
||||
cam.setScroll(
|
||||
anchor.x - cam.width / 2 + cam.width / (2 * z) - focusX / z,
|
||||
anchor.y - cam.height / 2 + cam.height / (2 * z) - focusY / z,
|
||||
);
|
||||
}
|
||||
|
||||
get zoom() { return this.scene.cameras.main.zoom; }
|
||||
|
||||
/**
|
||||
* Screen -> world, from the camera's live scroll and zoom.
|
||||
*
|
||||
* Deliberately not Camera.getWorldPoint(): that inverts a matrix rebuilt only during
|
||||
* preRender, so it reports a stale transform for any call made after the camera has moved
|
||||
* earlier in the same frame — which is every click, since camera panning runs first in
|
||||
* update(). This mirrors Camera.preRender's own maths:
|
||||
* worldView.x = scrollX + width/2 - width/(2*zoom), worldX = worldView.x + screenX/zoom
|
||||
*/
|
||||
worldPoint(screenX, screenY) {
|
||||
const cam = this.scene.cameras.main;
|
||||
const z = cam.zoom;
|
||||
return {
|
||||
x: cam.scrollX + cam.width / 2 - cam.width / (2 * z) + screenX / z,
|
||||
y: cam.scrollY + cam.height / 2 - cam.height / (2 * z) + screenY / z,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Terrain (lazy chunked RenderTextures)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_setupTerrain() {
|
||||
const { state } = this;
|
||||
this.chunkCols = Math.ceil(state.worldW / CHUNK_PX);
|
||||
this.chunkRows = Math.ceil(state.worldH / CHUNK_PX);
|
||||
this.chunks = new Map(); // "cx,cy" -> { rt, ox, oy, w, h, touched }
|
||||
}
|
||||
|
||||
_paintChunk(cx, cy) {
|
||||
const key = `${cx},${cy}`;
|
||||
let chunk = this.chunks.get(key);
|
||||
if (chunk) { chunk.touched = this.scene.time.now; return chunk; }
|
||||
|
||||
const { state, rules, ts } = this;
|
||||
const ox = cx * CHUNK_PX, oy = cy * CHUNK_PX;
|
||||
const w = Math.min(CHUNK_PX, state.worldW - ox);
|
||||
const h = Math.min(CHUNK_PX, state.worldH - oy);
|
||||
const rt = this.scene.add.renderTexture(ox, oy, w, h).setOrigin(0, 0).setDepth(DEPTHS.terrain);
|
||||
this._addWorld(rt);
|
||||
|
||||
const t0 = Math.floor(ox / ts), t1 = Math.ceil((ox + w) / ts);
|
||||
const r0 = Math.floor(oy / ts), r1 = Math.ceil((oy + h) / ts);
|
||||
const img = this._stamp;
|
||||
img.setTexture(this.terrainKey);
|
||||
img.setDisplaySize(ts, ts);
|
||||
|
||||
rt.beginDraw();
|
||||
for (let ty = r0; ty < r1 && ty < state.h; ty++) {
|
||||
for (let tx = t0; tx < t1 && tx < state.w; tx++) {
|
||||
const terr = rules.terrain[state.terrain[ty * state.w + tx]];
|
||||
// Break up flat ground with the two alternate frames, deterministically per tile.
|
||||
let frame = terr.frame;
|
||||
if (terr.id === 'ground') {
|
||||
const hsh = (tx * 73856093) ^ (ty * 19349663);
|
||||
const pick = (hsh >>> 3) % 5;
|
||||
if (pick === 1) frame = this.art.terrainFrames?.groundAlt1 ?? frame;
|
||||
else if (pick === 2) frame = this.art.terrainFrames?.groundAlt2 ?? frame;
|
||||
}
|
||||
img.setFrame(frame);
|
||||
rt.batchDraw(img, tx * ts - ox, ty * ts - oy);
|
||||
}
|
||||
}
|
||||
rt.endDraw();
|
||||
|
||||
chunk = { rt, ox, oy, w, h, touched: this.scene.time.now };
|
||||
this.chunks.set(key, chunk);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
/** Create chunks the camera can see; retire ones it hasn't looked at for a while. */
|
||||
_updateChunks() {
|
||||
const cam = this.scene.cameras.main;
|
||||
const view = cam.worldView;
|
||||
const c0 = Math.max(0, Math.floor((view.x - CHUNK_PX * 0.25) / CHUNK_PX));
|
||||
const c1 = Math.min(this.chunkCols - 1, Math.floor((view.right + CHUNK_PX * 0.25) / CHUNK_PX));
|
||||
const r0 = Math.max(0, Math.floor((view.y - CHUNK_PX * 0.25) / CHUNK_PX));
|
||||
const r1 = Math.min(this.chunkRows - 1, Math.floor((view.bottom + CHUNK_PX * 0.25) / CHUNK_PX));
|
||||
|
||||
for (let cy = r0; cy <= r1; cy++) {
|
||||
for (let cx = c0; cx <= c1; cx++) this._paintChunk(cx, cy);
|
||||
}
|
||||
const now = this.scene.time.now;
|
||||
for (const [key, chunk] of this.chunks) {
|
||||
if (now - chunk.touched < CHUNK_IDLE_MS) continue;
|
||||
chunk.rt.destroy();
|
||||
this.chunks.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Repaint the chunks overlapping a world rect — used when terrain changes. */
|
||||
repaintArea(x0, y0, x1, y1) {
|
||||
for (const [key, chunk] of this.chunks) {
|
||||
if (x1 < chunk.ox || x0 > chunk.ox + chunk.w) continue;
|
||||
if (y1 < chunk.oy || y0 > chunk.oy + chunk.h) continue;
|
||||
chunk.rt.destroy();
|
||||
this.chunks.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fog of war
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_setupFog() {
|
||||
const { state, scene } = this;
|
||||
const key = 'ta-fog-canvas';
|
||||
if (scene.textures.exists(key)) scene.textures.remove(key);
|
||||
this.fogTex = scene.textures.createCanvas(key, state.visW, state.visH);
|
||||
this.fogCtx = this.fogTex.getContext();
|
||||
const cell = this.ts * 2;
|
||||
this.fogImg = scene.add.image(0, 0, key).setOrigin(0, 0).setDepth(DEPTHS.fog);
|
||||
this.fogImg.setDisplaySize(state.visW * cell, state.visH * cell);
|
||||
// Linear filtering upscales the coarse grid into a soft gradient for the cost of a few
|
||||
// hundred pixels of texture — far cheaper than a per-tile fog RenderTexture.
|
||||
this.fogTex.setFilter(1); // Phaser.Textures.FilterMode.LINEAR
|
||||
this._addWorld(this.fogImg);
|
||||
this.fogDirty = true;
|
||||
}
|
||||
|
||||
setFogEnabled(on) {
|
||||
this.fogEnabled = on;
|
||||
this.fogImg.setVisible(on);
|
||||
}
|
||||
|
||||
_redrawFog() {
|
||||
const { state } = this;
|
||||
const army = state.armies[this.playerArmy];
|
||||
if (!army) return;
|
||||
const ctx = this.fogCtx;
|
||||
ctx.clearRect(0, 0, state.visW, state.visH);
|
||||
const img = ctx.createImageData(state.visW, state.visH);
|
||||
const d = img.data;
|
||||
for (let i = 0; i < state.visW * state.visH; i++) {
|
||||
const vis = army.visible[i], exp = army.explored[i];
|
||||
const a = vis ? 0 : (exp ? 140 : 255);
|
||||
d[i * 4] = 4; d[i * 4 + 1] = 6; d[i * 4 + 2] = 10; d[i * 4 + 3] = a;
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
this.fogTex.refresh();
|
||||
}
|
||||
|
||||
/** Is this entity currently drawable for the viewing player? */
|
||||
visibleToPlayer(e) {
|
||||
if (!this.fogEnabled) return true;
|
||||
if (e.army === this.playerArmy) return true;
|
||||
const army = this.state.armies[this.playerArmy];
|
||||
if (!army) return true;
|
||||
const cell = this.ts * 2;
|
||||
const x = Math.floor(e.x / cell), y = Math.floor(e.y / cell);
|
||||
if (x < 0 || y < 0 || x >= this.state.visW || y >= this.state.visH) return false;
|
||||
return army.visible[y * this.state.visW + x] === 1;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sprites
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_ensureSprite(e) {
|
||||
let s = this.sprites.get(e.id);
|
||||
if (s) return s;
|
||||
const rules = this.rules;
|
||||
const def = rules.defById[e.defId];
|
||||
const sheets = this.armySheets[e.army];
|
||||
const key = sheets[def.sheetSlot];
|
||||
const frameSize = def.sheetSlot === 'unitSheet' ? sheets.unitFrame : sheets.structureFrame;
|
||||
|
||||
const img = this.scene.add.image(e.x, e.y, key, def.frame);
|
||||
img.setTint(sheets.color);
|
||||
if (def.isBuilding) {
|
||||
img.setDisplaySize(def.footprint.w * this.ts, def.footprint.h * this.ts);
|
||||
} else {
|
||||
img.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
|
||||
}
|
||||
this._addWorld(img);
|
||||
|
||||
let turret = null;
|
||||
if (!def.isBuilding && def.turretFrame != null) {
|
||||
turret = this.scene.add.image(e.x, e.y, key, def.turretFrame);
|
||||
turret.setTint(sheets.color);
|
||||
turret.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
|
||||
this._addWorld(turret);
|
||||
}
|
||||
s = { img, turret, defId: e.defId, site: e.site };
|
||||
this.sprites.set(e.id, s);
|
||||
return s;
|
||||
}
|
||||
|
||||
_releaseSprite(id) {
|
||||
const s = this.sprites.get(id);
|
||||
if (!s) return;
|
||||
s.img.destroy();
|
||||
s.turret?.destroy();
|
||||
this.sprites.delete(id);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Frame
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {number} alpha interpolation factor between the last two sim ticks
|
||||
* @returns {{nanoLinks:Array, projectiles:Array}} data the FX layer needs, in view space
|
||||
*/
|
||||
render(alpha) {
|
||||
const { state, rules } = this;
|
||||
this._updateChunks();
|
||||
|
||||
if (this.fogEnabled && this.fogDirty) { this._redrawFog(); this.fogDirty = false; }
|
||||
|
||||
const live = new Set();
|
||||
const gSel = this.gSelection, gBar = this.gBars;
|
||||
gSel.clear(); gBar.clear();
|
||||
|
||||
for (const e of state.entities) {
|
||||
if (e.dead) continue;
|
||||
live.add(e.id);
|
||||
const def = rules.defById[e.defId];
|
||||
const shown = this.visibleToPlayer(e);
|
||||
const s = this._ensureSprite(e);
|
||||
s.img.setVisible(shown);
|
||||
if (s.turret) s.turret.setVisible(shown);
|
||||
if (!shown) continue;
|
||||
|
||||
const x = e.px + (e.x - e.px) * alpha;
|
||||
const y = e.py + (e.y - e.py) * alpha;
|
||||
const heading = lerpAngle(e.pheading, e.heading, alpha);
|
||||
|
||||
s.img.setPosition(x, y);
|
||||
if (!def.isBuilding) s.img.setRotation(heading);
|
||||
// Y-sorted actor band; buildings sit just under units sharing a row.
|
||||
s.img.setDepth(DEPTHS.actor + (y / state.worldH) * 10 + (def.isBuilding ? 0 : 0.05));
|
||||
|
||||
// Build sites show the wireframe frame and fade in as they complete.
|
||||
if (e.site !== s.site) {
|
||||
s.img.setFrame(e.site ? (def.buildFrame ?? def.frame) : def.frame);
|
||||
s.site = e.site;
|
||||
}
|
||||
s.img.setAlpha(e.site ? 0.35 + e.progress * 0.55 : 1);
|
||||
|
||||
if (s.turret) {
|
||||
const tr = lerpAngle(e.pturretRot, e.turretRot, alpha);
|
||||
s.turret.setPosition(x, y).setRotation(tr);
|
||||
s.turret.setDepth(DEPTHS.actor + (y / state.worldH) * 10 + 0.08);
|
||||
}
|
||||
|
||||
// Selection ring
|
||||
if (this.selection.has(e.id)) {
|
||||
const r = def.isBuilding ? Math.max(def.footprint.w, def.footprint.h) * this.ts * 0.55 : e.radius + 4;
|
||||
gSel.lineStyle(2, 0x7dff9b, 0.95);
|
||||
gSel.strokeEllipse(x, y + r * 0.25, r * 2, r * 1.1);
|
||||
}
|
||||
|
||||
// Health / build bars — only when they say something, so we're not stroking 300 of them.
|
||||
const damaged = e.hp < e.maxHp - 0.5;
|
||||
if (e.site) {
|
||||
drawBar(gBar, x, y - e.radius - 10, e.radius * 1.8, e.progress, 0x8ad4ff);
|
||||
} else if (damaged || this.selection.has(e.id) || this.showAllBars) {
|
||||
const frac = Math.max(0, e.hp / e.maxHp);
|
||||
const col = frac > 0.6 ? 0x6fe27a : frac > 0.3 ? 0xe2d16f : 0xe2705f;
|
||||
drawBar(gBar, x, y - e.radius - 10, e.radius * 1.8, frac, col);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of [...this.sprites.keys()]) if (!live.has(id)) this._releaseSprite(id);
|
||||
|
||||
this._drawOrderQueues();
|
||||
this._drawPlacementGhost();
|
||||
|
||||
// Actor depth is recomputed every frame from Y, so the container has to be re-sorted
|
||||
// every frame too — not just when a child is added.
|
||||
this.worldRoot.sort('depth');
|
||||
this._layerDirty = false;
|
||||
|
||||
// Nanolathe links: builder -> whatever it is working on.
|
||||
const nanoLinks = [];
|
||||
for (const e of state.entities) {
|
||||
if (e.dead || !e.buildTargetId) continue;
|
||||
if (!this.visibleToPlayer(e)) continue;
|
||||
const target = state.entities.find((t) => t.id === e.buildTargetId && !t.dead);
|
||||
if (!target) continue;
|
||||
nanoLinks.push({
|
||||
x1: e.x, y1: e.y, x2: target.x, y2: target.y,
|
||||
color: this.armySheets[e.army].color,
|
||||
});
|
||||
}
|
||||
|
||||
// Projectiles, interpolated and pre-coloured for the FX layer.
|
||||
const projectiles = [];
|
||||
for (const p of state.projectiles) {
|
||||
const w = rules.weaponById[p.weapon];
|
||||
const px = p.px + (p.x - p.px) * alpha;
|
||||
const py = p.py + (p.y - p.py) * alpha;
|
||||
if (this.fogEnabled && p.army !== this.playerArmy) {
|
||||
const cell = this.ts * 2;
|
||||
const gx = Math.floor(px / cell), gy = Math.floor(py / cell);
|
||||
const army = state.armies[this.playerArmy];
|
||||
if (gx < 0 || gy < 0 || gx >= state.visW || gy >= state.visH) continue;
|
||||
if (!army.visible[gy * state.visW + gx]) continue;
|
||||
}
|
||||
projectiles.push({
|
||||
x: px, y: py, vx: p.vx, vy: p.vy,
|
||||
color: colorInt(w.fx.color), width: w.fx.width ?? 2,
|
||||
style: w.fx.style, trail: w.fx.trail ? p.trail : null,
|
||||
});
|
||||
}
|
||||
|
||||
return { nanoLinks, projectiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the pending order queue for every selected unit: a waypoint chain from the unit
|
||||
* through each queued order, coloured by order type.
|
||||
*
|
||||
* Queued commands are close to unusable without this — once orders stop replacing each
|
||||
* other, the player has no way to know what a unit has already been told to do, and a
|
||||
* queued build is otherwise indistinguishable from one that failed to register.
|
||||
*/
|
||||
_drawOrderQueues() {
|
||||
const g = this.gOrders;
|
||||
g.clear();
|
||||
if (!this.selection.size) return;
|
||||
|
||||
let drawn = 0;
|
||||
for (const e of this.state.entities) {
|
||||
if (e.dead || e.isBuilding || !this.selection.has(e.id)) continue;
|
||||
if (!e.orders.length) continue;
|
||||
// With a big selection every unit has near-identical orders; drawing all of them is
|
||||
// just cost and clutter.
|
||||
if (++drawn > MAX_QUEUE_LINES) break;
|
||||
|
||||
let px = e.x, py = e.y;
|
||||
for (let i = 0; i < e.orders.length; i++) {
|
||||
const o = e.orders[i];
|
||||
const pt = this._orderPoint(o);
|
||||
if (!pt) continue;
|
||||
const col = ORDER_COLORS[o.type] ?? 0x9fe8ff;
|
||||
const active = i === 0;
|
||||
g.lineStyle(active ? 2.5 : 1.5, col, active ? 0.9 : 0.5);
|
||||
g.lineBetween(px, py, pt.x, pt.y);
|
||||
if (o.type === 'build') {
|
||||
g.lineStyle(2, col, 0.9);
|
||||
g.strokeRect(pt.x - 14, pt.y - 14, 28, 28);
|
||||
} else {
|
||||
g.fillStyle(col, active ? 0.9 : 0.6);
|
||||
g.fillCircle(pt.x, pt.y, active ? 5 : 4);
|
||||
}
|
||||
px = pt.x; py = pt.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** World position an order points at, or null if it has no meaningful location. */
|
||||
_orderPoint(o) {
|
||||
if (o.type === 'move' || o.type === 'attackMove' || o.type === 'patrol') {
|
||||
const x = o.sx ?? o.x, y = o.sy ?? o.y;
|
||||
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
|
||||
}
|
||||
if (o.targetId) {
|
||||
const t = this.state.entities.find((e) => e.id === o.targetId && !e.dead);
|
||||
return t ? { x: t.x, y: t.y } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_drawPlacementGhost() {
|
||||
const g = this.gGhost;
|
||||
g.clear();
|
||||
const p = this.placement;
|
||||
if (!p) return;
|
||||
const ts = this.ts;
|
||||
const x = p.tx * ts, y = p.ty * ts;
|
||||
const w = p.def.footprint.w * ts, h = p.def.footprint.h * ts;
|
||||
g.fillStyle(p.legal ? 0x4affa0 : 0xff5a5a, 0.22);
|
||||
g.fillRect(x, y, w, h);
|
||||
g.lineStyle(2, p.legal ? 0x4affa0 : 0xff5a5a, 0.95);
|
||||
g.strokeRect(x, y, w, h);
|
||||
// Show the builder's reach so it's obvious why a distant placement won't start.
|
||||
if (p.builderX != null) {
|
||||
g.lineStyle(1, 0x8ad4ff, 0.35);
|
||||
g.strokeCircle(p.builderX, p.builderY, p.buildRange);
|
||||
}
|
||||
}
|
||||
|
||||
/** Highlight mass spots while a Mass Generator is being placed. */
|
||||
highlightMassSpots(on) {
|
||||
if (this.massSpotG) { this.massSpotG.destroy(); this.massSpotG = null; }
|
||||
if (!on) return;
|
||||
const { state, rules, ts } = this;
|
||||
const g = this.scene.add.graphics().setDepth(DEPTHS.decal);
|
||||
this._addWorld(g);
|
||||
g.lineStyle(2, 0xffd27a, 0.85);
|
||||
for (let ty = 0; ty < state.h; ty++) {
|
||||
for (let tx = 0; tx < state.w; tx++) {
|
||||
const t = rules.terrain[state.terrain[ty * state.w + tx]];
|
||||
if (!t.massMultiplier) continue;
|
||||
g.strokeRect(tx * ts + 2, ty * ts + 2, ts - 4, ts - 4);
|
||||
}
|
||||
}
|
||||
this.massSpotG = g;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const id of [...this.sprites.keys()]) this._releaseSprite(id);
|
||||
for (const [, chunk] of this.chunks) chunk.rt.destroy();
|
||||
this.chunks.clear();
|
||||
this.massSpotG?.destroy();
|
||||
this.gOrders?.destroy();
|
||||
this.fogImg?.destroy();
|
||||
if (this.scene.textures.exists('ta-fog-canvas')) this.scene.textures.remove('ta-fog-canvas');
|
||||
this._stamp?.destroy();
|
||||
this.worldRoot.destroy(true);
|
||||
this.uiRoot.destroy(true);
|
||||
if (this.uiCam) this.scene.cameras.remove(this.uiCam);
|
||||
}
|
||||
}
|
||||
|
||||
function drawBar(g, cx, y, w, frac, color) {
|
||||
const h = 4;
|
||||
const x = cx - w / 2;
|
||||
g.fillStyle(0x101418, 0.85);
|
||||
g.fillRect(x - 1, y - 1, w + 2, h + 2);
|
||||
g.fillStyle(color, 1);
|
||||
g.fillRect(x, y, w * Math.max(0, Math.min(1, frac)), h);
|
||||
}
|
||||
|
||||
function lerpAngle(a, b, t) {
|
||||
let d = (b - a) % (Math.PI * 2);
|
||||
if (d > Math.PI) d -= Math.PI * 2;
|
||||
if (d < -Math.PI) d += Math.PI * 2;
|
||||
return a + d * t;
|
||||
}
|
||||
|
|
@ -0,0 +1,673 @@
|
|||
// Total Annihilation — the Phaser scene.
|
||||
//
|
||||
// A thin coordinator: it owns the fixed-step loop, input bindings and screen routing, and
|
||||
// holds NO game rules. Every state change goes through TALogic.issueOrder, the same entry
|
||||
// point the AI uses — which is what lets tools/verifyTotalAnnihilation.js bot the human side
|
||||
// and assert a campaign mission is actually winnable.
|
||||
//
|
||||
// The loop is TALogic.step(state, rules, deltaMs): the accumulator lives inside the headless
|
||||
// state (the StarControlLogic.stepMatch idiom), so the verify script drives an identical loop
|
||||
// in Node with a synthetic delta and gets identical results.
|
||||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { getGameSoundtrack } from '../../services/soundtrack.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import { compileRules } from './TARules.js';
|
||||
import { generateMap, decodeMap } from './TAMapGen.js';
|
||||
import * as Logic from './TALogic.js';
|
||||
import { runAI } from './TAAI.js';
|
||||
import TAWorldView, { DEPTHS } from './TAWorldView.js';
|
||||
import TAFx from './TAFx.js';
|
||||
import TAHud from './TAHud.js';
|
||||
import * as Screens from './TAScreens.js';
|
||||
|
||||
const SAVE_KEY = 'totalannihilation-save';
|
||||
const SETTINGS_KEY = 'totalannihilation-settings';
|
||||
const EDGE_PAN = 26; // screen-edge scroll band, px
|
||||
const PAN_SPEED = 1100; // px/s at zoom 1
|
||||
const DRAG_MIN = 8; // px before a click becomes a drag-box
|
||||
|
||||
export default class TotalAnnihilationGame extends Phaser.Scene {
|
||||
constructor() { super('TotalAnnihilationGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game ?? { slug: 'totalannihilation', name: 'Total Annihilation' };
|
||||
this.phase = 'menu'; // menu | setup | briefing | playing | paused | result
|
||||
this.match = null;
|
||||
this.view = null;
|
||||
this.hud = null;
|
||||
this.fx = null;
|
||||
this.screen = null;
|
||||
this.selection = new Set();
|
||||
this.groups = new Map();
|
||||
this.placement = null;
|
||||
this.pendingCommand = null; // 'attackMove' | 'patrol' | 'guard' | 'attack'
|
||||
this.simSpeed = 1;
|
||||
this.settings = readJson(SETTINGS_KEY, { edgeScroll: true, fog: true });
|
||||
}
|
||||
|
||||
create() {
|
||||
try {
|
||||
const { tracks, volume } = getGameSoundtrack(this);
|
||||
if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
|
||||
} catch (_) { /* soundtrack is optional */ }
|
||||
|
||||
this.input.mouse?.disableContextMenu();
|
||||
|
||||
this.rules = compileRules(this.cache.json.get('totalannihilation-rules'));
|
||||
this.art = this.cache.json.get('totalannihilation-artwork');
|
||||
this.campaign = this.cache.json.get('totalannihilation-campaign') ?? { missions: [] };
|
||||
|
||||
this.keys = this.input.keyboard.addKeys(
|
||||
'W,A,S,D,X,H,P,G,Q,E,ESC,SPACE,UP,DOWN,LEFT,RIGHT,SHIFT,CTRL',
|
||||
);
|
||||
this._bindInput();
|
||||
|
||||
// Commander portraits come from the shared opponent roster; a fetch failure just means
|
||||
// the setup screen falls back to name-only cards.
|
||||
this.oppById = {};
|
||||
fetch('data/opponents.json')
|
||||
.then((r) => r.json())
|
||||
.then((d) => { for (const o of d.opponents ?? []) this.oppById[o.id] = o; })
|
||||
.catch(() => { /* portraits are optional */ });
|
||||
|
||||
this.events.once('shutdown', () => this._teardown());
|
||||
this.showMenu();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Screen routing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
swapScreen(next) {
|
||||
this.screen?.destroy();
|
||||
this.screen = next ?? null;
|
||||
}
|
||||
|
||||
showMenu() {
|
||||
this.phase = 'menu';
|
||||
this._endMatch();
|
||||
this.swapScreen(Screens.mainMenu(this, {
|
||||
hasSave: !!localStorage.getItem(SAVE_KEY),
|
||||
onSkirmish: () => this.showSkirmishSetup(),
|
||||
onCampaign: () => this.showCampaign(),
|
||||
onContinue: () => this.loadSave(),
|
||||
onLeave: () => this.scene.start('GameMenu'),
|
||||
}));
|
||||
}
|
||||
|
||||
showSkirmishSetup() {
|
||||
this.phase = 'setup';
|
||||
this.swapScreen(Screens.skirmishSetup(this, {
|
||||
onBack: () => this.showMenu(),
|
||||
onStart: (cfg) => this.startSkirmish(cfg),
|
||||
}));
|
||||
}
|
||||
|
||||
showCampaign() {
|
||||
this.phase = 'setup';
|
||||
api.get(`/puzzles/${this.gameDef.slug}/progress`)
|
||||
.then((p) => p?.levelsCompleted ?? 0)
|
||||
.catch(() => 0)
|
||||
.then((cleared) => {
|
||||
this.swapScreen(Screens.campaignList(this, this.campaign, cleared, {
|
||||
onBack: () => this.showMenu(),
|
||||
onPick: (idx) => this.startMission(idx),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Match lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
startSkirmish(cfg) {
|
||||
const map = generateMap(this.rules, {
|
||||
seed: cfg.seed, size: cfg.size, theme: cfg.theme, symmetry: cfg.symmetry,
|
||||
armies: cfg.armies.length,
|
||||
});
|
||||
const state = Logic.createMatch(this.rules, {
|
||||
seed: cfg.seed, map,
|
||||
armies: cfg.armies.map((a, i) => ({
|
||||
armyId: a.armyId, commanderId: a.commanderId,
|
||||
isHuman: i === 0, aiSkill: a.skill,
|
||||
aiProfile: this.rules.commanderById?.[a.commanderId]?.aiProfile ?? null,
|
||||
})),
|
||||
});
|
||||
this.meta = { mode: 'skirmish', cfg };
|
||||
this._beginMatch(state, 0);
|
||||
}
|
||||
|
||||
startMission(idx) {
|
||||
const m = this.campaign.missions[idx];
|
||||
if (!m) return this.showMenu();
|
||||
const map = decodeMap(this.rules, m.map);
|
||||
const state = Logic.createMatch(this.rules, {
|
||||
seed: m.seed ?? 1234, map,
|
||||
armies: [
|
||||
{ armyId: m.playerArmy, commanderId: m.playerCommander, isHuman: true },
|
||||
...(m.enemies ?? []).map((e) => ({
|
||||
armyId: e.army, commanderId: e.commander,
|
||||
aiSkill: e.aiProfile?.skill ?? 3, aiProfile: e.aiProfile ?? null,
|
||||
})),
|
||||
],
|
||||
});
|
||||
(m.startResources ?? []).forEach((r, i) => {
|
||||
if (!state.armies[i]) return;
|
||||
state.armies[i].mass = r.mass ?? state.armies[i].mass;
|
||||
state.armies[i].energy = r.energy ?? state.armies[i].energy;
|
||||
});
|
||||
this.meta = { mode: 'campaign', missionIdx: idx };
|
||||
this.mission = m;
|
||||
this._beginMatch(state, 0);
|
||||
}
|
||||
|
||||
_beginMatch(state, playerArmy) {
|
||||
this.swapScreen(null);
|
||||
this.match = state;
|
||||
this.playerArmy = playerArmy;
|
||||
this.selection.clear();
|
||||
this.groups.clear();
|
||||
this.placement = null;
|
||||
|
||||
this.view = new TAWorldView(this, this.rules, this.art, state, playerArmy);
|
||||
this.view.setFogEnabled(this.settings.fog !== false);
|
||||
this.fx = new TAFx(this, this.view.worldRoot, DEPTHS);
|
||||
this.hud = new TAHud(this, this.rules, state, this.view, playerArmy, {
|
||||
onBuildPick: (def) => this._beginPlacement(def),
|
||||
onProduce: (def, n) => this._enqueue(def, n),
|
||||
onMinimapJump: (x, y) => this.view.centerOn(x, y),
|
||||
});
|
||||
if (this.music?._objs?.length) this.view.ignoreOnWorldCam(this.music._objs);
|
||||
|
||||
const start = state.starts.find((s) => s.army === playerArmy);
|
||||
if (start) this.view.centerOn(start.x * state.tileSize, start.y * state.tileSize);
|
||||
|
||||
this.phase = 'playing';
|
||||
this._lastAutosave = this.time.now;
|
||||
}
|
||||
|
||||
_endMatch() {
|
||||
this.hud?.destroy(); this.hud = null;
|
||||
this.fx?.destroy(); this.fx = null;
|
||||
this.view?.destroy(); this.view = null;
|
||||
this.match = null;
|
||||
this.selection.clear();
|
||||
}
|
||||
|
||||
_teardown() {
|
||||
this._endMatch();
|
||||
this.screen?.destroy();
|
||||
this.music?.destroy?.();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The loop
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
update(time, delta) {
|
||||
if (this.phase !== 'playing' || !this.match) return;
|
||||
const st = this.match;
|
||||
|
||||
this._updateCamera(delta);
|
||||
|
||||
// The AI thinks on sim ticks, not render frames, so it is stepped inside the same
|
||||
// fixed-step loop the simulation uses — otherwise its cadence would ride framerate.
|
||||
const before = st.tick;
|
||||
const events = Logic.step(st, this.rules, delta * this.simSpeed, 4, (s) => {
|
||||
for (const a of s.armies) {
|
||||
if (!a.alive || a.isHuman) continue;
|
||||
runAI(this.rules, s, a.idx, { skill: a.aiSkill, ...(a.aiProfile ?? {}) });
|
||||
}
|
||||
});
|
||||
if (st.tick !== before) this.view.fogDirty = true;
|
||||
|
||||
for (const ev of events) this._onSimEvent(ev);
|
||||
|
||||
const { nanoLinks, projectiles } = this.view.render(st.alpha);
|
||||
this.fx.draw(delta, nanoLinks, projectiles, time);
|
||||
this._syncSelection();
|
||||
this.hud.update(time, this._selectedEntities(), this.placement?.def ?? null);
|
||||
this._drawDragBox();
|
||||
|
||||
if (this.meta?.mode === 'campaign' && time - this._lastAutosave > 60000) {
|
||||
this._lastAutosave = time;
|
||||
this.saveGame();
|
||||
}
|
||||
if (st.over) this._finish(st.over);
|
||||
}
|
||||
|
||||
_onSimEvent(ev) {
|
||||
this.fx.onEvent(ev, this.rules);
|
||||
if (ev.t === 'buildingComplete' && ev.army === this.playerArmy) {
|
||||
// Terrain under a finished structure changes, so its chunk has to be restamped.
|
||||
this.view.repaintArea(ev.x - 128, ev.y - 128, ev.x + 128, ev.y + 128);
|
||||
}
|
||||
if (ev.t === 'armyEliminated' && ev.army !== this.playerArmy) {
|
||||
this.hud.toast(`${this.rules.armies[ev.army]?.name ?? 'Enemy'} eliminated`, '#9ce6a0');
|
||||
}
|
||||
if (ev.t === 'noPath' && ev.army === this.playerArmy) {
|
||||
this.hud.toast('No route there', '#ff9a6b');
|
||||
}
|
||||
}
|
||||
|
||||
_finish(over) {
|
||||
this.phase = 'result';
|
||||
const won = over.winner === this.playerArmy;
|
||||
api.post('/history/single-player', {
|
||||
slug: this.gameDef.slug, score: Math.round(this.match.elapsedSec),
|
||||
opponentScores: [], result: won ? 'win' : 'loss',
|
||||
}).catch(() => { /* best effort */ });
|
||||
|
||||
if (won && this.meta?.mode === 'campaign') {
|
||||
api.post(`/puzzles/${this.gameDef.slug}/complete`, { level: this.meta.missionIdx + 1 })
|
||||
.catch(() => { /* best effort */ });
|
||||
}
|
||||
localStorage.removeItem(SAVE_KEY);
|
||||
|
||||
const stats = summarise(this.match, this.playerArmy);
|
||||
this.swapScreen(Screens.results(this, { won, stats, elapsed: this.match.elapsedSec }, {
|
||||
onMenu: () => this.showMenu(),
|
||||
onAgain: () => {
|
||||
if (this.meta?.mode === 'campaign') this.startMission(this.meta.missionIdx);
|
||||
else this.startSkirmish({ ...this.meta.cfg, seed: (this.meta.cfg.seed * 7919 + 13) >>> 0 });
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Camera
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_updateCamera(delta) {
|
||||
const k = this.keys;
|
||||
const dt = delta / 1000;
|
||||
let dx = 0, dy = 0;
|
||||
if (k.A.isDown || k.LEFT.isDown) dx -= 1;
|
||||
if (k.D.isDown || k.RIGHT.isDown) dx += 1;
|
||||
if (k.W.isDown || k.UP.isDown) dy -= 1;
|
||||
if (k.S.isDown || k.DOWN.isDown) dy += 1;
|
||||
|
||||
// Edge scroll stays off until the mouse has actually moved over the canvas. Phaser's
|
||||
// activePointer sits at (0,0) until its first event, which is inside the top-left edge
|
||||
// band — so without this the camera silently flies off the corner of the map the moment
|
||||
// a match starts, taking the player's Commander off screen before they touch anything.
|
||||
if (this.settings.edgeScroll !== false && this._pointerLive && !this._dragging) {
|
||||
const p = this.input.activePointer;
|
||||
if (p.x >= 0 && p.y >= 0 && p.x <= GAME_WIDTH && p.y <= GAME_HEIGHT) {
|
||||
if (p.x < EDGE_PAN) dx -= 1;
|
||||
if (p.x > GAME_WIDTH - EDGE_PAN) dx += 1;
|
||||
if (p.y < EDGE_PAN) dy -= 1;
|
||||
if (p.y > GAME_HEIGHT - EDGE_PAN) dy += 1;
|
||||
}
|
||||
}
|
||||
if (dx || dy) {
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
this.view.panBy((dx / len) * PAN_SPEED * dt, (dy / len) * PAN_SPEED * dt);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Input
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_bindInput() {
|
||||
this.input.on('pointermove', () => { this._pointerLive = true; });
|
||||
this.input.on('pointerdown', (p) => { this._pointerLive = true; this._onPointerDown(p); });
|
||||
this.input.on('pointerup', (p) => this._onPointerUp(p));
|
||||
this.input.on('wheel', (p, objs, dx, dy) => {
|
||||
if (this.phase !== 'playing') return;
|
||||
this.view.zoomBy(dy > 0 ? -1 : 1, p.x, p.y);
|
||||
});
|
||||
|
||||
this.input.keyboard.on('keydown', (ev) => {
|
||||
if (this.phase === 'playing') this._onKey(ev);
|
||||
});
|
||||
}
|
||||
|
||||
_onKey(ev) {
|
||||
const code = ev.key?.toLowerCase();
|
||||
if (ev.code === 'Escape') {
|
||||
if (this.placement) { this._cancelPlacement(); return; }
|
||||
if (this.pendingCommand) { this.pendingCommand = null; return; }
|
||||
this.togglePause();
|
||||
return;
|
||||
}
|
||||
// Control groups: Ctrl+N assigns, N recalls, N again centres on the group.
|
||||
if (/^[1-9]$/.test(code)) {
|
||||
const n = Number(code);
|
||||
if (ev.ctrlKey) {
|
||||
this.groups.set(n, [...this.selection]);
|
||||
this.hud.toast(`Group ${n} set (${this.selection.size})`);
|
||||
} else {
|
||||
const ids = (this.groups.get(n) ?? []).filter((id) => Logic.entityById(this.match, id));
|
||||
if (!ids.length) return;
|
||||
const same = ids.length === this.selection.size && ids.every((id) => this.selection.has(id));
|
||||
this.selection = new Set(ids);
|
||||
if (same) {
|
||||
const e = Logic.entityById(this.match, ids[0]);
|
||||
if (e) this.view.centerOn(e.x, e.y);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
switch (code) {
|
||||
case 'a': this.pendingCommand = 'attackMove'; this.hud.toast('Attack-move: pick a target point'); break;
|
||||
case 'p': this.pendingCommand = 'patrol'; this.hud.toast('Patrol: pick a point'); break;
|
||||
case 'g': this.pendingCommand = 'guard'; this.hud.toast('Guard: pick a friendly unit'); break;
|
||||
case 'x': this._order({ type: 'stop' }); break;
|
||||
case 'h': this._order({ type: 'hold' }); break;
|
||||
case 'q': this.view.zoomBy(-1, GAME_WIDTH / 2, GAME_HEIGHT / 2); break;
|
||||
case 'e': this.view.zoomBy(1, GAME_WIDTH / 2, GAME_HEIGHT / 2); break;
|
||||
case ' ': this._jumpToAction(); break;
|
||||
default: break;
|
||||
}
|
||||
if (ev.ctrlKey && code === 'a') this._selectAll();
|
||||
}
|
||||
|
||||
_onPointerDown(p) {
|
||||
if (this.phase !== 'playing') return;
|
||||
if (this.hud.hitsUi(p.x, p.y)) return;
|
||||
|
||||
if (p.middleButtonDown()) { this._panAnchor = { x: p.x, y: p.y }; return; }
|
||||
|
||||
const w = this.view.worldPoint(p.x, p.y);
|
||||
// CTRL is the queue modifier, as in the original: hold it and every order appends to the
|
||||
// unit's queue instead of replacing it. SHIFT stays on selection (add to selection) and
|
||||
// on the factory buttons (x5), so the two never fight over the same click.
|
||||
if (p.rightButtonDown()) { this._issueContextual(w, queueHeld(p)); return; }
|
||||
|
||||
if (this.placement) { this._commitPlacement(w, queueHeld(p)); return; }
|
||||
if (this.pendingCommand) { this._applyPendingCommand(w, queueHeld(p)); return; }
|
||||
|
||||
this._dragStart = { x: p.x, y: p.y, wx: w.x, wy: w.y, add: !!p.event?.shiftKey };
|
||||
this._dragging = false;
|
||||
}
|
||||
|
||||
_onPointerUp(p) {
|
||||
this._panAnchor = null;
|
||||
if (this.phase !== 'playing' || !this._dragStart) return;
|
||||
const d = this._dragStart;
|
||||
this._dragStart = null;
|
||||
const w = this.view.worldPoint(p.x, p.y);
|
||||
if (this._dragging) this._selectInBox(d.wx, d.wy, w.x, w.y, d.add);
|
||||
else this._selectAt(w.x, w.y, d.add, !!p.event?.detail && p.event.detail > 1);
|
||||
this._dragging = false;
|
||||
}
|
||||
|
||||
_drawDragBox() {
|
||||
if (!this._dragBoxG) this._dragBoxG = this.add.graphics().setDepth(2000);
|
||||
const g = this._dragBoxG;
|
||||
g.clear();
|
||||
if (!this._dragStart) return;
|
||||
const p = this.input.activePointer;
|
||||
if (Math.abs(p.x - this._dragStart.x) + Math.abs(p.y - this._dragStart.y) > DRAG_MIN) this._dragging = true;
|
||||
if (!this._dragging) return;
|
||||
this.view.uiRoot.add(g);
|
||||
const x = Math.min(p.x, this._dragStart.x), y = Math.min(p.y, this._dragStart.y);
|
||||
const w = Math.abs(p.x - this._dragStart.x), h = Math.abs(p.y - this._dragStart.y);
|
||||
g.fillStyle(0x7dff9b, 0.10); g.fillRect(x, y, w, h);
|
||||
g.lineStyle(1.5, 0x7dff9b, 0.9); g.strokeRect(x, y, w, h);
|
||||
|
||||
if (this._panAnchor) {
|
||||
this.view.panBy(this._panAnchor.x - p.x, this._panAnchor.y - p.y);
|
||||
this._panAnchor = { x: p.x, y: p.y };
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Selection
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_mine() {
|
||||
return this.match.entities.filter((e) => !e.dead && e.army === this.playerArmy);
|
||||
}
|
||||
|
||||
_selectedEntities() {
|
||||
return [...this.selection].map((id) => Logic.entityById(this.match, id)).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Drop dead entities so the HUD and orders never reference a corpse. */
|
||||
_syncSelection() {
|
||||
for (const id of [...this.selection]) {
|
||||
const e = Logic.entityById(this.match, id);
|
||||
if (!e || e.dead) this.selection.delete(id);
|
||||
}
|
||||
this.view.selection = this.selection;
|
||||
}
|
||||
|
||||
_selectAt(x, y, add, double) {
|
||||
let best = null, bestD = Infinity;
|
||||
for (const e of this.match.entities) {
|
||||
if (e.dead) continue;
|
||||
if (e.army !== this.playerArmy && !this.view.visibleToPlayer(e)) continue;
|
||||
const d = Math.hypot(e.x - x, e.y - y);
|
||||
const r = e.isBuilding
|
||||
? Math.max(e.radius, Math.max(...Object.values(this.rules.defById[e.defId].footprint)) * this.match.tileSize * 0.5)
|
||||
: e.radius + 6;
|
||||
if (d <= r && d < bestD) { best = e; bestD = d; }
|
||||
}
|
||||
if (!best) { if (!add) this.selection.clear(); return; }
|
||||
if (!add) this.selection.clear();
|
||||
if (double && best.army === this.playerArmy) {
|
||||
// Double-click grabs every on-screen unit of the same type — the standard RTS idiom.
|
||||
const cam = this.cameras.main.worldView;
|
||||
for (const e of this._mine()) {
|
||||
if (e.defId === best.defId && Phaser.Geom.Rectangle.Contains(cam, e.x, e.y)) this.selection.add(e.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.selection.add(best.id);
|
||||
}
|
||||
|
||||
_selectInBox(x0, y0, x1, y1, add) {
|
||||
if (!add) this.selection.clear();
|
||||
const lo = { x: Math.min(x0, x1), y: Math.min(y0, y1) };
|
||||
const hi = { x: Math.max(x0, x1), y: Math.max(y0, y1) };
|
||||
// A box prefers mobile units: dragging over a base to grab tanks shouldn't also
|
||||
// select the factory they came out of.
|
||||
const hits = this._mine().filter((e) => e.x >= lo.x && e.x <= hi.x && e.y >= lo.y && e.y <= hi.y);
|
||||
const mobile = hits.filter((e) => !e.isBuilding && !e.site);
|
||||
for (const e of (mobile.length ? mobile : hits)) this.selection.add(e.id);
|
||||
}
|
||||
|
||||
_selectAll() {
|
||||
this.selection.clear();
|
||||
for (const e of this._mine()) if (!e.isBuilding && !e.site) this.selection.add(e.id);
|
||||
}
|
||||
|
||||
_jumpToAction() {
|
||||
const e = this._mine().find((u) => u.targetId) ?? this._mine()[0];
|
||||
if (e) this.view.centerOn(e.x, e.y);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Orders
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_order(order, queue = false) {
|
||||
const unitIds = [...this.selection];
|
||||
if (!unitIds.length) return { ok: false };
|
||||
const r = Logic.issueOrder(this.match, this.rules, {
|
||||
army: this.playerArmy, unitIds, order, queue,
|
||||
});
|
||||
if (!r.ok && r.error) this.hud.toast(r.error, '#ff9a6b');
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Right-click: move, attack an enemy, or set a factory's rally point. */
|
||||
_issueContextual(w, queue) {
|
||||
const sel = this._selectedEntities();
|
||||
if (!sel.length) return;
|
||||
|
||||
const factories = sel.filter((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length);
|
||||
if (factories.length === sel.length) {
|
||||
for (const f of factories) {
|
||||
Logic.issueOrder(this.match, this.rules, {
|
||||
army: this.playerArmy, order: { type: 'setRally', factoryId: f.id, x: w.x, y: w.y },
|
||||
});
|
||||
}
|
||||
this.hud.toast('Rally point set');
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this._entityAt(w.x, w.y);
|
||||
if (target && target.army !== this.playerArmy && this.view.visibleToPlayer(target)) {
|
||||
this._order({ type: 'attack', targetId: target.id }, queue);
|
||||
return;
|
||||
}
|
||||
if (target && target.army === this.playerArmy && target.site) {
|
||||
this._order({ type: 'assist', targetId: target.id }, queue);
|
||||
return;
|
||||
}
|
||||
this._order({ type: 'move', x: w.x, y: w.y }, queue);
|
||||
}
|
||||
|
||||
_applyPendingCommand(w, queue) {
|
||||
const cmd = this.pendingCommand;
|
||||
this.pendingCommand = null;
|
||||
if (cmd === 'guard') {
|
||||
const t = this._entityAt(w.x, w.y);
|
||||
if (t && t.army === this.playerArmy) this._order({ type: 'guard', targetId: t.id }, queue);
|
||||
return;
|
||||
}
|
||||
this._order({ type: cmd, x: w.x, y: w.y }, queue);
|
||||
}
|
||||
|
||||
_entityAt(x, y) {
|
||||
let best = null, bestD = Infinity;
|
||||
for (const e of this.match.entities) {
|
||||
if (e.dead) continue;
|
||||
const d = Math.hypot(e.x - x, e.y - y);
|
||||
if (d <= e.radius + 8 && d < bestD) { best = e; bestD = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
_enqueue(def, count) {
|
||||
const f = this._selectedEntities().find((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.includes(def.id));
|
||||
if (!f) return;
|
||||
Logic.issueOrder(this.match, this.rules, {
|
||||
army: this.playerArmy,
|
||||
order: { type: 'factoryEnqueue', factoryId: f.id, defId: def.id, count },
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Building placement
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_beginPlacement(def) {
|
||||
const builder = this._selectedEntities().find((e) => !e.isBuilding && this.rules.defById[e.defId].builds?.includes(def.id));
|
||||
if (!builder) return;
|
||||
this.placement = { def, builderId: builder.id };
|
||||
this.view.highlightMassSpots(!!def.terrainMultiplier);
|
||||
this.input.on('pointermove', this._onPlacementMove, this);
|
||||
this._onPlacementMove(this.input.activePointer);
|
||||
}
|
||||
|
||||
_onPlacementMove(p) {
|
||||
if (!this.placement) return;
|
||||
const w = this.view.worldPoint(p.x, p.y);
|
||||
const ts = this.match.tileSize;
|
||||
const def = this.placement.def;
|
||||
// Centre the footprint on the cursor, then snap to the tile grid.
|
||||
const tx = Math.round(w.x / ts - def.footprint.w / 2);
|
||||
const ty = Math.round(w.y / ts - def.footprint.h / 2);
|
||||
const builder = Logic.entityById(this.match, this.placement.builderId);
|
||||
this.placement.tx = tx; this.placement.ty = ty;
|
||||
this.view.placement = {
|
||||
def, tx, ty,
|
||||
legal: Logic.canPlaceAt(this.match, this.rules, tx, ty, def),
|
||||
builderX: builder?.x, builderY: builder?.y,
|
||||
buildRange: this.rules.defById[builder?.defId ?? '']?.buildRange ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
_commitPlacement(w, queue) {
|
||||
const p = this.placement;
|
||||
if (!p) return;
|
||||
const r = Logic.issueOrder(this.match, this.rules, {
|
||||
army: this.playerArmy, unitIds: [p.builderId],
|
||||
order: { type: 'build', defId: p.def.id, tx: p.tx, ty: p.ty },
|
||||
queue,
|
||||
});
|
||||
if (!r.ok) { this.hud.toast(r.error ?? 'Cannot build there', '#ff9a6b'); return; }
|
||||
// Shift keeps the cursor loaded so a row of generators is one gesture.
|
||||
if (!queue) this._cancelPlacement();
|
||||
}
|
||||
|
||||
_cancelPlacement() {
|
||||
this.placement = null;
|
||||
this.view.placement = null;
|
||||
this.view.highlightMassSpots(false);
|
||||
this.input.off('pointermove', this._onPlacementMove, this);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Pause / persistence
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
togglePause() {
|
||||
if (this.phase === 'playing') {
|
||||
this.phase = 'paused';
|
||||
this.swapScreen(Screens.pause(this, {
|
||||
settings: this.settings,
|
||||
onResume: () => { this.phase = 'playing'; this.swapScreen(null); },
|
||||
onSave: () => { this.saveGame(); this.hud.toast('Saved'); },
|
||||
onQuit: () => this.showMenu(),
|
||||
onToggleFog: (on) => {
|
||||
this.settings.fog = on;
|
||||
writeJson(SETTINGS_KEY, this.settings);
|
||||
this.view.setFogEnabled(on);
|
||||
},
|
||||
onToggleEdge: (on) => { this.settings.edgeScroll = on; writeJson(SETTINGS_KEY, this.settings); },
|
||||
onSpeed: (v) => { this.simSpeed = v; },
|
||||
}));
|
||||
} else if (this.phase === 'paused') {
|
||||
this.phase = 'playing';
|
||||
this.swapScreen(null);
|
||||
}
|
||||
}
|
||||
|
||||
saveGame() {
|
||||
if (!this.match) return;
|
||||
try {
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify({
|
||||
v: Logic.SAVE_VERSION, meta: this.meta, state: Logic.serialize(this.match),
|
||||
}));
|
||||
} catch (_) { /* quota — a failed autosave must not kill the match */ }
|
||||
}
|
||||
|
||||
loadSave() {
|
||||
const raw = readJson(SAVE_KEY, null);
|
||||
if (!raw || raw.v !== Logic.SAVE_VERSION) { this.hud?.toast('No compatible save'); return; }
|
||||
const state = Logic.deserialize(this.rules, raw.state);
|
||||
this.meta = raw.meta;
|
||||
if (raw.meta?.mode === 'campaign') this.mission = this.campaign.missions[raw.meta.missionIdx];
|
||||
this._beginMatch(state, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function summarise(state, armyIdx) {
|
||||
const a = state.armies[armyIdx];
|
||||
return {
|
||||
built: a.builtEver, lost: a.lostEver, kills: a.killsEver,
|
||||
mass: Math.round(a.mass), energy: Math.round(a.energy),
|
||||
};
|
||||
}
|
||||
|
||||
/** Is the queue modifier down for this pointer event? */
|
||||
function queueHeld(p) {
|
||||
return !!(p.event?.ctrlKey || p.event?.metaKey);
|
||||
}
|
||||
|
||||
function readJson(key, fallback) {
|
||||
try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch (_) { return fallback; }
|
||||
}
|
||||
|
||||
function writeJson(key, v) {
|
||||
try { localStorage.setItem(key, JSON.stringify(v)); } catch (_) { /* ignore */ }
|
||||
}
|
||||
|
|
@ -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,949 @@
|
|||
#!/usr/bin/env node
|
||||
// Verifies the Total Annihilation engine, data and AI end to end.
|
||||
//
|
||||
// node tools/verifyTotalAnnihilation.js [--quick]
|
||||
//
|
||||
// Everything here runs headless: TARules/TALogic/TANav/TAMapGen/TAAI import no Phaser. The
|
||||
// campaign section is the payoff of routing every order through TALogic.issueOrder — the AI
|
||||
// plays the HUMAN side, so each mission gets an automated winnability assertion rather than a
|
||||
// promise that it is probably beatable.
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { compileRules, armorMul } from '../src/games/totalannihilation/TARules.js';
|
||||
import { generateMap, decodeMap } from '../src/games/totalannihilation/TAMapGen.js';
|
||||
import * as L from '../src/games/totalannihilation/TALogic.js';
|
||||
import { runAI } from '../src/games/totalannihilation/TAAI.js';
|
||||
import { ensureSheets } from '../src/games/totalannihilation/TAArt.js';
|
||||
import TAWorldView, { DEPTHS } from '../src/games/totalannihilation/TAWorldView.js';
|
||||
import TAFx from '../src/games/totalannihilation/TAFx.js';
|
||||
import { makeStubScene } from './lib/taStubScene.js';
|
||||
import {
|
||||
createNav, findPath, clearanceFor, segmentClear, tileOk, worldToTileX, worldToTileY,
|
||||
} from '../src/games/totalannihilation/TANav.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const QUICK = process.argv.includes('--quick');
|
||||
const rulesJson = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
|
||||
const artJson = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-artwork.json'), 'utf8'));
|
||||
const campaign = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-campaign.json'), 'utf8'));
|
||||
const opponents = JSON.parse(readFileSync(join(ROOT, 'data/opponents.json'), 'utf8'));
|
||||
const rules = compileRules(rulesJson);
|
||||
const HZ = rules.constants.tickHz;
|
||||
|
||||
let pass = 0; const failures = [];
|
||||
function check(name, cond, detail) {
|
||||
if (cond) { pass++; return true; }
|
||||
failures.push(detail ? `${name} — ${detail}` : name);
|
||||
return false;
|
||||
}
|
||||
function section(title) { console.log(`\n── ${title}`); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('1. Rules integrity');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const ids = new Set();
|
||||
for (const d of [...rules.units, ...rules.buildings]) {
|
||||
check(`unique def id ${d.id}`, !ids.has(d.id), 'duplicate');
|
||||
ids.add(d.id);
|
||||
}
|
||||
for (const d of [...rules.units, ...rules.buildings]) {
|
||||
check(`${d.id} costs are positive`, (d.cost?.mass ?? 0) >= 0 && (d.cost?.energy ?? 0) >= 0);
|
||||
check(`${d.id} has hp`, d.hp > 0);
|
||||
for (const w of d.weaponDefs ?? []) {
|
||||
for (const a of rules.armorClasses) {
|
||||
check(`${w.id} vs ${a}`, Number.isFinite(armorMul(w, a)), 'missing armorMul');
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const u of rules.units) {
|
||||
if (!u.buildTime) continue;
|
||||
const makers = rules.buildings.filter((b) => (b.builds ?? []).includes(u.id));
|
||||
check(`${u.id} is buildable by some factory`, makers.length > 0);
|
||||
}
|
||||
// Every producible thing must be reachable from the Commander's tech tree, or the player
|
||||
// can see it in the rules and never build it.
|
||||
const commander = rules.unitById.commander;
|
||||
const reach = new Set(commander.builds ?? []);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (const id of [...reach]) for (const n of rules.defById[id].builds ?? []) reach.add(n);
|
||||
}
|
||||
for (const d of [...rules.units, ...rules.buildings]) {
|
||||
if (d.id === 'commander') continue;
|
||||
check(`${d.id} reachable from the Commander`, reach.has(d.id));
|
||||
}
|
||||
for (const c of rules.commanders) {
|
||||
check(`commander ${c.id} maps to a real opponent`,
|
||||
(opponents.opponents ?? []).some((o) => o.id === c.opponentId), c.opponentId);
|
||||
check(`commander ${c.id} army exists`, !!rules.armyById[c.armyId]);
|
||||
}
|
||||
// The D-Gun must stay manual — auto-firing it turns every Commander into a base turret
|
||||
// that deletes one attacker per reload, which makes assaulting anything suicide.
|
||||
check('D-Gun is manual-fire', rules.weaponById.dgun.manual === true);
|
||||
check('Commander has a non-manual weapon',
|
||||
(commander.weaponDefs ?? []).some((w) => !w.manual), 'would be defenceless');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('2. Artwork manifest');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const sheets = artJson.sheets ?? {};
|
||||
check('artwork declares sheets', Object.keys(sheets).length > 0);
|
||||
for (const [name, s] of Object.entries(sheets)) {
|
||||
check(`sheet ${name} has a key`, !!s.key);
|
||||
check(`sheet ${name} has frame size`, s.frameWidth > 0 && s.frameHeight > 0);
|
||||
check(`sheet ${name} has a kind`, ['unit', 'structure', 'terrain', 'icon'].includes(s.kind), s.kind);
|
||||
}
|
||||
for (const a of rules.armies) {
|
||||
check(`army ${a.id} unit sheet declared`, !!sheets[a.unitSheet], a.unitSheet);
|
||||
check(`army ${a.id} structure sheet declared`, !!sheets[a.structureSheet], a.structureSheet);
|
||||
}
|
||||
for (const [id, t] of Object.entries(artJson.themes ?? {})) {
|
||||
check(`theme ${id} sheet declared`, !!sheets[t.sheet], t.sheet);
|
||||
for (const terr of rules.terrain) {
|
||||
check(`theme ${id} has a colour for ${terr.id}`, !!t.palette?.[terr.id]);
|
||||
}
|
||||
}
|
||||
// terrainFrames drives the procedural terrain sheet's size. It also carries a `_comment`
|
||||
// key, and one non-numeric value in there turns the frame count into NaN and the painted
|
||||
// sheet into a zero-height canvas — which fails in the browser as an opaque WebGL error,
|
||||
// so it gets caught here instead.
|
||||
const tf = Object.entries(artJson.terrainFrames ?? {}).filter(([k]) => !k.startsWith('_'));
|
||||
check('terrainFrames declares frames', tf.length > 0);
|
||||
for (const [name, v] of tf) {
|
||||
check(`terrainFrames.${name} is a frame index`, Number.isInteger(v) && v >= 0, String(v));
|
||||
}
|
||||
for (const terr of rules.terrain) {
|
||||
check(`terrain "${terr.id}" has a frame in terrainFrames`,
|
||||
tf.some(([name]) => name === terr.id) || Number.isInteger(terr.frame));
|
||||
}
|
||||
const maxTerrainFrame = Math.max(...tf.map(([, v]) => v)) + 1;
|
||||
for (const [name, sheet] of Object.entries(sheets)) {
|
||||
if (sheet.kind !== 'terrain') continue;
|
||||
const cols = sheet.cols ?? 8;
|
||||
check(`${name} layout is fully numeric`,
|
||||
[sheet.frameWidth, sheet.frameHeight, cols].every((v) => Number.isFinite(v) && v > 0));
|
||||
check(`${name} holds every terrain frame`, maxTerrainFrame <= cols * Math.ceil(maxTerrainFrame / cols));
|
||||
}
|
||||
|
||||
// Frame indices must exist within each sheet's declared capacity.
|
||||
for (const d of [...rules.units, ...rules.buildings]) {
|
||||
for (const a of rules.armies) {
|
||||
const sheet = sheets[d.sheetSlot === 'unitSheet' ? a.unitSheet : a.structureSheet];
|
||||
const cap = (sheet.cols ?? 8) * (sheet.rows ?? 8);
|
||||
check(`${d.id} frame ${d.frame} fits ${sheet.key}`, d.frame < cap);
|
||||
if (d.turretFrame != null) check(`${d.id} turret frame fits`, d.turretFrame < cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('2b. Procedural art actually paints');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// TAArt imports no Phaser, so the painters can be run right here against a stub canvas.
|
||||
// Checking the artwork JSON alone is not enough: the terrain sheet's frame count is DERIVED
|
||||
// from that JSON, and a bad derivation produced a zero-height texture that only failed in
|
||||
// the browser, as a WebGL error pointing nowhere near the cause. Running the painters also
|
||||
// catches NaN geometry, which paints nothing at all and is otherwise invisible until a
|
||||
// player sees a blank tank.
|
||||
const bad = [];
|
||||
const num = (where, ...vals) => {
|
||||
for (const v of vals) if (typeof v === 'number' && !Number.isFinite(v)) bad.push(where);
|
||||
};
|
||||
const mkCtx = () => new Proxy({}, {
|
||||
get: (_t, prop) => {
|
||||
if (prop === 'save' || prop === 'restore' || prop === 'beginPath' || prop === 'closePath'
|
||||
|| prop === 'fill' || prop === 'stroke') return () => {};
|
||||
if (typeof prop === 'string') return (...args) => num(prop, ...args);
|
||||
return () => {};
|
||||
},
|
||||
set: (_t, prop, value) => { num(String(prop), value); return true; },
|
||||
});
|
||||
const made = new Map();
|
||||
const scene = {
|
||||
textures: {
|
||||
exists: (k) => made.has(k),
|
||||
remove: (k) => made.delete(k),
|
||||
createCanvas(key, w, h) {
|
||||
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
|
||||
bad.push(`createCanvas(${key}, ${w}, ${h})`);
|
||||
}
|
||||
const frames = [];
|
||||
const tex = {
|
||||
width: w, height: h, frames,
|
||||
getContext: () => mkCtx(),
|
||||
refresh() {},
|
||||
add(f, _s, fx, fy, fw, fh) {
|
||||
num(`add(${key})`, fx, fy, fw, fh);
|
||||
if (fx + fw > w || fy + fh > h) bad.push(`frame ${f} outside ${key}`);
|
||||
frames.push(f);
|
||||
},
|
||||
};
|
||||
made.set(key, tex);
|
||||
return tex;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { keys, procedural } = ensureSheets(scene, rules, artJson);
|
||||
for (const name of Object.keys(artJson.sheets ?? {})) {
|
||||
check(`sheet ${name} resolves to a texture key`, !!keys[name]);
|
||||
const tex = made.get(keys[name]);
|
||||
check(`sheet ${name} painted a non-empty canvas`, !!tex && tex.width > 0 && tex.height > 0,
|
||||
tex ? `${tex.width}x${tex.height}` : 'no texture');
|
||||
check(`sheet ${name} registered frames`, !!tex && tex.frames.length > 0);
|
||||
}
|
||||
check('every sheet fell back to a painted stand-in', procedural.length === Object.keys(artJson.sheets ?? {}).length,
|
||||
`${procedural.length} procedural`);
|
||||
check('no painter emitted a non-finite value', bad.length === 0, [...new Set(bad)].slice(0, 5).join(', '));
|
||||
|
||||
// Every frame a def references must have been registered by the painter that owns it.
|
||||
for (const d of [...rules.units, ...rules.buildings]) {
|
||||
for (const a of rules.armies) {
|
||||
const sheetName = d.sheetSlot === 'unitSheet' ? a.unitSheet : a.structureSheet;
|
||||
const tex = made.get(keys[sheetName]);
|
||||
check(`${d.id} frame ${d.frame} was painted on ${sheetName}`, tex?.frames.includes(d.frame));
|
||||
if (d.turretFrame != null) {
|
||||
check(`${d.id} turret frame was painted`, tex?.frames.includes(d.turretFrame));
|
||||
}
|
||||
if (d.buildFrame != null) {
|
||||
check(`${d.id} build frame was painted`, tex?.frames.includes(d.buildFrame));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [name, sheet] of Object.entries(artJson.sheets ?? {})) {
|
||||
if (sheet.kind !== 'terrain') continue;
|
||||
const tex = made.get(keys[name]);
|
||||
for (const terr of rules.terrain) {
|
||||
check(`terrain ${terr.id} frame ${terr.frame} painted on ${name}`, tex?.frames.includes(terr.frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('2c. View layering and first frame');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// TAWorldView and TAFx import no Phaser either, so the real render path runs here against
|
||||
// a stub scene. This section exists because two bugs got past every data-level check and
|
||||
// straight into the browser: the fog sheet drew UNDER the terrain (a Phaser Container
|
||||
// renders in insertion order and never sorts by depth on its own), and the camera walked
|
||||
// off the map before the first frame. Both are invisible to any test that only inspects
|
||||
// simulation state.
|
||||
const map = generateMap(rules, { seed: 123456, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', armies: 2 });
|
||||
const st = L.createMatch(rules, { seed: 123456, map, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }] });
|
||||
const scene = makeStubScene();
|
||||
let view = null;
|
||||
try {
|
||||
view = new TAWorldView(scene, rules, artJson, st, 0);
|
||||
const fx = new TAFx(scene, view.worldRoot, DEPTHS);
|
||||
view.setFogEnabled(true);
|
||||
const start = st.starts.find((x) => x.army === 0);
|
||||
view.centerOn(start.x * st.tileSize, start.y * st.tileSize);
|
||||
const out = view.render(0);
|
||||
check('render() returns fx payloads', !!out && Array.isArray(out.projectiles) && Array.isArray(out.nanoLinks));
|
||||
|
||||
const order = view.worldRoot.list;
|
||||
const lastChunk = Math.max(-1, ...order.map((o, i) => (o.type === 'renderTexture' ? i : -1)));
|
||||
check('terrain chunks were painted', lastChunk >= 0);
|
||||
check('fog draws ON TOP of terrain', order.indexOf(view.fogImg) > lastChunk,
|
||||
`fog at ${order.indexOf(view.fogImg)}, last chunk at ${lastChunk}`);
|
||||
check('fx layers are in the world container',
|
||||
order.includes(fx.gUnder) && order.includes(fx.gOver));
|
||||
|
||||
const own = st.entities.find((e) => e.army === 0);
|
||||
const foe = st.entities.find((e) => e.army === 1);
|
||||
const ownSprite = view.sprites.get(own.id);
|
||||
check('the player\'s starting unit has a sprite', !!ownSprite);
|
||||
check('own unit draws on top of terrain', order.indexOf(ownSprite.img) > lastChunk);
|
||||
check('own unit is visible through fog', ownSprite.img.visible);
|
||||
check('the enemy is hidden at match start', !view.visibleToPlayer(foe));
|
||||
|
||||
// The Commander must be ON SCREEN when the match opens — an off-screen start reads to a
|
||||
// player as "I have no units" and there is nothing they can do about it.
|
||||
const cam = scene.cameras.main.worldView;
|
||||
const onScreen = own.x >= cam.x && own.x <= cam.x + cam.width
|
||||
&& own.y >= cam.y && own.y <= cam.y + cam.height;
|
||||
check('the opening camera frames the player\'s Commander', onScreen,
|
||||
`unit ${own.x.toFixed(0)},${own.y.toFixed(0)} vs view ${cam.x.toFixed(0)},${cam.y.toFixed(0)} ${cam.width}x${cam.height}`);
|
||||
|
||||
// The queue overlay is the only feedback a player gets that a CTRL-queued order
|
||||
// registered at all, so assert it actually strokes something for a queued unit and
|
||||
// nothing at all when the selection is empty.
|
||||
const cmdr = st.entities.find((e) => e.army === 0);
|
||||
const tsz = st.tileSize;
|
||||
L.issueOrder(st, rules, { army: 0, unitIds: [cmdr.id], order: { type: 'move', x: cmdr.x + 3 * tsz, y: cmdr.y } });
|
||||
L.issueOrder(st, rules, { army: 0, unitIds: [cmdr.id], order: { type: 'move', x: cmdr.x + 3 * tsz, y: cmdr.y + 3 * tsz }, queue: true });
|
||||
L.issueOrder(st, rules, { army: 0, unitIds: [cmdr.id], order: { type: 'attackMove', x: cmdr.x, y: cmdr.y + 3 * tsz }, queue: true });
|
||||
view.selection = new Set([cmdr.id]);
|
||||
view.render(0);
|
||||
check('the order-queue overlay draws for a queued unit', view.gOrders.ops > 0, `${view.gOrders.ops} ops`);
|
||||
view.selection = new Set();
|
||||
view.render(0);
|
||||
check('the order-queue overlay draws nothing with no selection', view.gOrders.ops === 0);
|
||||
L.issueOrder(st, rules, { army: 0, unitIds: [cmdr.id], order: { type: 'stop' } });
|
||||
|
||||
// Wheel zoom must keep the world point under the cursor fixed, at any cursor position —
|
||||
// including well away from the screen centre, which is where a centre-anchored zoom (or a
|
||||
// correction computed from a stale camera matrix) visibly drifts.
|
||||
for (const [fx2, fy2] of [[960, 540], [320, 220], [1700, 900], [40, 1040]]) {
|
||||
for (const dir of [1, 1, -1, -1, -1, 1]) {
|
||||
const beforePt = view.worldPoint(fx2, fy2);
|
||||
const beforeZoom = view.zoom;
|
||||
view.zoomBy(dir, fx2, fy2);
|
||||
if (view.zoom === beforeZoom) continue; // already at the end of the ladder
|
||||
const afterPt = view.worldPoint(fx2, fy2);
|
||||
const drift = Math.hypot(afterPt.x - beforePt.x, afterPt.y - beforePt.y);
|
||||
check(`zoom at (${fx2},${fy2}) keeps the cursor's world point fixed`, drift < 0.5,
|
||||
`drifted ${drift.toFixed(1)}px`);
|
||||
}
|
||||
}
|
||||
|
||||
// Depth bands must stay in the intended order.
|
||||
check('bars draw above actors', DEPTHS.bars > DEPTHS.actor);
|
||||
check('fog draws above everything', DEPTHS.fog > Math.max(DEPTHS.fxOver, DEPTHS.bars, DEPTHS.actor));
|
||||
check('selection draws below actors', DEPTHS.selection < DEPTHS.actor);
|
||||
} catch (e) {
|
||||
check('the view layer builds and renders a frame', false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('2d. Container hitbox lint');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// TAHud/TAScreens import Phaser directly, so they cannot be constructed here. This is a
|
||||
// source lint instead, guarding one specific mistake that is invisible until a human tries
|
||||
// to click something: a Phaser Container's origin is hard-coded to 0.5, so setInteractive()
|
||||
// centres the hit area on the container's position. A child drawn from a top-left origin
|
||||
// then sits half a button down-right of the region that actually receives the click.
|
||||
const files = ['src/games/totalannihilation/TAHud.js', 'src/games/totalannihilation/TAScreens.js'];
|
||||
for (const rel of files) {
|
||||
const src = readFileSync(join(ROOT, rel), 'utf8');
|
||||
const lines = src.split('\n');
|
||||
let offenders = 0;
|
||||
lines.forEach((line, i) => {
|
||||
if (!/add\.container\(/.test(line)) return;
|
||||
// Look at the block this container is built in.
|
||||
const block = lines.slice(i, i + 26).join('\n');
|
||||
if (!/setInteractive\(/.test(block)) return;
|
||||
if (/setOrigin\(\s*0\s*,\s*0\s*\)/.test(block)) offenders++;
|
||||
});
|
||||
check(`${rel.split('/').pop()} has no top-left-origin child in an interactive container`,
|
||||
offenders === 0, `${offenders} block(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('3. Economy fixtures');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const map = generateMap(rules, { seed: 7, size: 'small', symmetry: 'mirror-x' });
|
||||
const st = L.createMatch(rules, { seed: 7, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const a = st.armies[0];
|
||||
a.mass = 0; a.energy = 0;
|
||||
for (let i = 0; i < HZ; i++) L.tick(st, rules);
|
||||
check('idle army banks its commander income', a.energy > 0 && a.mass > 0);
|
||||
|
||||
// Building an Energy Generator takes exactly buildTime seconds at nominal build power,
|
||||
// given enough stored resources that nothing stalls.
|
||||
const st2 = L.createMatch(rules, { seed: 8, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const b = st2.armies[0];
|
||||
b.mass = 99999; b.energy = 99999; b.massCap = 99999; b.energyCap = 99999;
|
||||
const cmd = st2.entities.find((e) => e.army === 0);
|
||||
const gen = rules.buildingById.energygen;
|
||||
let tx = worldToTileX(st2.nav, cmd.x) + 2, ty = worldToTileY(st2.nav, cmd.y);
|
||||
while (!L.canPlaceAt(st2, rules, tx, ty, gen) && tx < st2.w - 4) tx++;
|
||||
const r = L.issueOrder(st2, rules, { army: 0, unitIds: [cmd.id], order: { type: 'build', defId: 'energygen', tx, ty } });
|
||||
check('build order accepted', r.ok, r.error);
|
||||
let ticks = 0;
|
||||
while (ticks < 200 * HZ) {
|
||||
b.mass = 99999; b.energy = 99999; // hold the economy open so only build power matters
|
||||
L.tick(st2, rules); ticks++;
|
||||
const site = st2.entities.find((e) => e.defId === 'energygen');
|
||||
if (site && !site.site) break;
|
||||
}
|
||||
const secs = ticks / HZ;
|
||||
const expected = gen.buildTime * (rules.constants.buildPowerNominal / rules.unitById.commander.buildPower);
|
||||
check('unstalled build takes buildTime seconds', Math.abs(secs - expected) <= expected * 0.35 + 2,
|
||||
`${secs.toFixed(1)}s vs ${expected}s`);
|
||||
|
||||
// Stall factor: halve the available mass flow and construction must slow, not stop.
|
||||
const st3 = L.createMatch(rules, { seed: 9, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const c = st3.armies[0];
|
||||
c.mass = 0; c.energy = 0;
|
||||
L.tick(st3, rules);
|
||||
check('stall factors stay in [0,1]', c.stallM >= 0 && c.stallM <= 1 && c.stallE >= 0 && c.stallE <= 1);
|
||||
check('buildEff is the binding factor', Math.abs(c.buildEff - Math.min(c.stallE, c.stallM)) < 1e-9);
|
||||
|
||||
// Storage is capped and the overflow is discarded rather than silently banked.
|
||||
const st4 = L.createMatch(rules, { seed: 10, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const d = st4.armies[0];
|
||||
d.energy = d.energyCap; d.mass = d.massCap;
|
||||
for (let i = 0; i < HZ * 5; i++) L.tick(st4, rules);
|
||||
check('stored energy never exceeds cap', d.energy <= d.energyCap + 1e-6);
|
||||
check('stored mass never exceeds cap', d.mass <= d.massCap + 1e-6);
|
||||
|
||||
// A Mass Generator on a metal patch must out-yield one on plain ground.
|
||||
const mgen = rules.buildingById.massgen;
|
||||
check('mass generator has a terrain multiplier', !!mgen.terrainMultiplier);
|
||||
const multTerrain = rules.terrain.find((t) => t[mgen.terrainMultiplier] > 1);
|
||||
check('some terrain carries that multiplier', !!multTerrain);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('4. Pathfinding');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const ts = rules.constants.tileSize;
|
||||
const mk = (rows) => {
|
||||
const w = rows[0].length, h = rows.length;
|
||||
const terrain = new Uint8Array(w * h);
|
||||
const wall = rules.terrainByCh['^'].index, open = rules.terrainByCh['.'].index;
|
||||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) terrain[y * w + x] = rows[y][x] === '#' ? wall : open;
|
||||
return createNav(rules, { w, h, terrain });
|
||||
};
|
||||
const nav = mk([
|
||||
'..........',
|
||||
'..######..',
|
||||
'..#....#..',
|
||||
'..#....#..',
|
||||
'..######..',
|
||||
'..........',
|
||||
]);
|
||||
const mc = 'tread';
|
||||
check('tread is a declared movement class', mc in rules.moveClasses);
|
||||
const at = (nav2, x, y) => y * nav2.w + x;
|
||||
const p = findPath(nav, mc, 1, at(nav, 0, 0), at(nav, 9, 5));
|
||||
check('A* finds a route around an obstacle', !!p && p.length > 0);
|
||||
const inside = findPath(nav, mc, 1, at(nav, 0, 0), at(nav, 4, 3));
|
||||
check('sealed region is unreachable', !inside);
|
||||
|
||||
// Clearance: a wide unit must refuse a one-tile gap but accept a wide corridor.
|
||||
const narrow = mk([
|
||||
'####.####',
|
||||
'####.####',
|
||||
'####.####',
|
||||
]);
|
||||
const wide = mk([
|
||||
'##.....##',
|
||||
'##.....##',
|
||||
'##.....##',
|
||||
]);
|
||||
const big = clearanceFor(rules.sizeClasses.large.radius, ts);
|
||||
check('large clearance requirement is > 1 tile', big > 1);
|
||||
check('large unit refuses a 1-wide corridor', !findPath(narrow, mc, big, 4, at(narrow, 4, 2)));
|
||||
check('large unit accepts a wide corridor', !!findPath(wide, mc, big, at(wide, 3, 0), at(wide, 5, 2)));
|
||||
|
||||
// String-pulling must never shortcut through a blocked tile.
|
||||
const blocked = mk(['...', '.#.', '...']);
|
||||
check('segmentClear rejects a line through a wall',
|
||||
!segmentClear(blocked, mc, 1, 0.5 * ts, 0.5 * ts, 2.5 * ts, 2.5 * ts));
|
||||
check('segmentClear accepts a clear line',
|
||||
segmentClear(blocked, mc, 1, 0.5 * ts, 0.5 * ts, 2.5 * ts, 0.5 * ts));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('4b. Order queueing (CTRL)');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// Holding CTRL passes `queue: true` to issueOrder. The engine contract that has to hold:
|
||||
// a queued order APPENDS and leaves the current one running, an unqueued one REPLACES the
|
||||
// lot, and the unit then works through them in order.
|
||||
const map = generateMap(rules, { seed: 61, size: 'small', symmetry: 'mirror-x' });
|
||||
const st = L.createMatch(rules, { seed: 61, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const cmd = st.entities.find((e) => e.army === 0);
|
||||
const ts = st.tileSize;
|
||||
|
||||
const pt = (dx, dy) => ({ x: cmd.x + dx * ts, y: cmd.y + dy * ts });
|
||||
const a = pt(3, 0), b = pt(3, 3), c = pt(0, 3);
|
||||
L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...a } });
|
||||
check('first order replaces an empty queue', cmd.orders.length === 1);
|
||||
L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...b }, queue: true });
|
||||
L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...c }, queue: true });
|
||||
check('queued orders append', cmd.orders.length === 3, `${cmd.orders.length}`);
|
||||
check('the queue keeps its issue order',
|
||||
Math.abs(cmd.orders[0].x - a.x) < 1 && Math.abs(cmd.orders[2].x - c.x) < 1);
|
||||
|
||||
// An unqueued order wipes the queue — the standard "plain click cancels everything" rule.
|
||||
L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...a } });
|
||||
check('an unqueued order clears the queue', cmd.orders.length === 1, `${cmd.orders.length}`);
|
||||
|
||||
// Run a three-leg queue and confirm it is actually consumed in sequence.
|
||||
L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...b }, queue: true });
|
||||
L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...c }, queue: true });
|
||||
const seen = [cmd.orders.length];
|
||||
for (let i = 0; i < 240 * HZ && cmd.orders.length; i++) {
|
||||
L.tick(st, rules);
|
||||
if (cmd.orders.length !== seen[seen.length - 1]) seen.push(cmd.orders.length);
|
||||
}
|
||||
check('a queued route is consumed one leg at a time',
|
||||
seen.join(',') === '3,2,1,0', seen.join(','));
|
||||
check('the unit ends up at the final waypoint',
|
||||
Math.hypot(cmd.x - c.x, cmd.y - c.y) < ts * 2,
|
||||
`${Math.hypot(cmd.x - c.x, cmd.y - c.y).toFixed(0)}px away`);
|
||||
|
||||
// Queued BUILD orders: each places its site immediately (so the player sees the ghosts)
|
||||
// while the builder works through them one at a time.
|
||||
const st2 = L.createMatch(rules, { seed: 62, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const b2 = st2.armies[0];
|
||||
b2.mass = 99999; b2.energy = 99999; b2.massCap = 99999; b2.energyCap = 99999;
|
||||
const builder = st2.entities.find((e) => e.army === 0);
|
||||
const gen = rules.buildingById.energygen;
|
||||
// Candidate spots must clear each other by the building's own footprint, or siting the
|
||||
// first one blocks the second and the "queue" under test never forms.
|
||||
const spots = [];
|
||||
const step = gen.footprint.w + 1;
|
||||
for (let d = 2; d < 30 && spots.length < 3; d += step) {
|
||||
const tx = worldToTileX(st2.nav, builder.x) + d;
|
||||
const ty = worldToTileY(st2.nav, builder.y);
|
||||
if (L.canPlaceAt(st2, rules, tx, ty, gen).ok) spots.push({ tx, ty });
|
||||
}
|
||||
check('found room for three queued generators', spots.length === 3, `${spots.length}`);
|
||||
let queuedOk = 0;
|
||||
spots.forEach((sp, i) => {
|
||||
const r = L.issueOrder(st2, rules, {
|
||||
army: 0, unitIds: [builder.id],
|
||||
order: { type: 'build', defId: 'energygen', tx: sp.tx, ty: sp.ty }, queue: i > 0,
|
||||
});
|
||||
if (r.ok) queuedOk++;
|
||||
});
|
||||
check('three build orders queue onto one builder', queuedOk === 3, `${queuedOk}`);
|
||||
check('every queued building is sited straight away',
|
||||
st2.entities.filter((e) => e.defId === 'energygen').length === 3);
|
||||
check('the builder holds all three in its queue', builder.orders.length === 3, `${builder.orders.length}`);
|
||||
for (let i = 0; i < 400 * HZ && builder.orders.length; i++) {
|
||||
b2.mass = 99999; b2.energy = 99999;
|
||||
L.tick(st2, rules);
|
||||
}
|
||||
const finished = st2.entities.filter((e) => e.defId === 'energygen' && !e.site && !e.dead).length;
|
||||
check('a queued build list completes', finished === 3, `${finished}/3 built`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('5. Movement, separation and size classes');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const map = generateMap(rules, { seed: 21, size: 'small', symmetry: 'mirror-x' });
|
||||
const st = L.createMatch(rules, { seed: 21, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
// Find a genuinely open tile with open neighbours — the middle of a generated map is as
|
||||
// likely to be a lake as a field, and units shoved out of water prove nothing.
|
||||
const openTile = (() => {
|
||||
for (let y = 3; y < map.h - 3; y++) {
|
||||
for (let x = 3; x < map.w - 3; x++) {
|
||||
let ok = true;
|
||||
for (let dy = -2; dy <= 2 && ok; dy++) {
|
||||
for (let dx = -2; dx <= 2 && ok; dx++) {
|
||||
if (!tileOk(st.nav, 'foot', 1, x + dx, y + dy)) ok = false;
|
||||
}
|
||||
}
|
||||
if (ok) return { x, y };
|
||||
}
|
||||
}
|
||||
return { x: (map.w / 2) | 0, y: (map.h / 2) | 0 };
|
||||
})();
|
||||
const cx = openTile.x * st.tileSize + st.tileSize / 2;
|
||||
const cy = openTile.y * st.tileSize + st.tileSize / 2;
|
||||
check('found open ground for the packing fixture', tileOk(st.nav, 'foot', 1, openTile.x, openTile.y));
|
||||
|
||||
// The size-class requirement, asserted: three smalls share one tile, two mediums cannot.
|
||||
const packed = (defId, n) => {
|
||||
const s2 = L.createMatch(rules, { seed: 21, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const made = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const u = L.spawnUnit(s2, rules, 0, defId, cx + (i - n / 2) * 6, cy + (i % 2) * 6);
|
||||
if (u) made.push(u);
|
||||
}
|
||||
for (let i = 0; i < 60; i++) L.tick(s2, rules);
|
||||
const ts = s2.tileSize;
|
||||
return made.filter((u) => Math.abs(u.x - cx) <= ts / 2 && Math.abs(u.y - cy) <= ts / 2).length;
|
||||
};
|
||||
// The size-class contract is a statement about collision radii, so assert it there — and
|
||||
// then confirm the emergent behaviour matches, which is the part a player actually sees.
|
||||
const ts0 = rules.constants.tileSize;
|
||||
const small = rules.sizeClasses.small, medium = rules.sizeClasses.medium, large = rules.sizeClasses.large;
|
||||
check('small units are under half a tile wide', small.radius * 2 <= ts0 / 2, `${small.radius * 2}px`);
|
||||
check('medium units are about one tile wide',
|
||||
medium.radius * 2 > ts0 / 2 && medium.radius * 2 <= ts0, `${medium.radius * 2}px`);
|
||||
check('large units span more than one tile', large.radius * 2 > ts0, `${large.radius * 2}px`);
|
||||
check('large units claim a multi-tile footprint', (large.footprint ?? 1) > 1);
|
||||
check('3 small units settle inside one tile', packed('infantry', 3) >= 3, `${packed('infantry', 3)}`);
|
||||
check('3 medium units cannot settle inside one tile', packed('tank', 3) < 3, `${packed('tank', 3)}`);
|
||||
|
||||
// Settled units must not overlap.
|
||||
for (let i = 0; i < 12; i++) L.spawnUnit(st, rules, 0, 'tank', cx + (i % 4) * 30, cy + Math.floor(i / 4) * 30);
|
||||
for (let i = 0; i < 120; i++) L.tick(st, rules);
|
||||
const mine = st.entities.filter((e) => !e.dead && e.army === 0 && e.defId === 'tank');
|
||||
let worst = 0;
|
||||
for (let i = 0; i < mine.length; i++) {
|
||||
for (let j = i + 1; j < mine.length; j++) {
|
||||
const overlap = (mine[i].radius + mine[j].radius) - Math.hypot(mine[i].x - mine[j].x, mine[i].y - mine[j].y);
|
||||
worst = Math.max(worst, overlap);
|
||||
}
|
||||
}
|
||||
check('settled units barely overlap', worst < 12, `worst overlap ${worst.toFixed(1)}px`);
|
||||
check('all positions finite', st.entities.every((e) => Number.isFinite(e.x) && Number.isFinite(e.y)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('6. Combat');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
|
||||
raw.constants.eliminateWhenUnrecoverable = false; // these fixtures field no builders
|
||||
const combatRules = compileRules(raw);
|
||||
const map = generateMap(combatRules, { seed: 31, size: 'small', symmetry: 'mirror-x' });
|
||||
const cx = (map.w / 2) * combatRules.constants.tileSize, cy = (map.h / 2) * combatRules.constants.tileSize;
|
||||
|
||||
const duel = (aId, na, bId, nb, sec = 150) => {
|
||||
const st = L.createMatch(combatRules, { seed: 31, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
for (const e of st.entities) e.dead = true;
|
||||
st.entities = [];
|
||||
const line = (army, id, n, dx) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
L.spawnUnit(st, combatRules, army, id,
|
||||
cx + dx - Math.sign(dx) * Math.floor(i / 10) * 40, cy - 180 + (i % 10) * 40);
|
||||
}
|
||||
};
|
||||
line(0, aId, na, -240); line(1, bId, nb, 240);
|
||||
st.over = null;
|
||||
for (const a of st.armies) a.alive = true;
|
||||
const ids = (army) => st.entities.filter((e) => e.army === army && !e.dead).map((e) => e.id);
|
||||
L.issueOrder(st, combatRules, { army: 0, unitIds: ids(0), order: { type: 'attackMove', x: cx + 240, y: cy } });
|
||||
L.issueOrder(st, combatRules, { army: 1, unitIds: ids(1), order: { type: 'attackMove', x: cx - 240, y: cy } });
|
||||
let t = 0;
|
||||
for (; t < sec * HZ; t++) {
|
||||
L.tick(st, combatRules);
|
||||
const a0 = st.entities.some((e) => !e.dead && e.army === 0);
|
||||
const a1 = st.entities.some((e) => !e.dead && e.army === 1);
|
||||
if (!a0 || !a1) break;
|
||||
}
|
||||
const left = (a) => st.entities.filter((e) => !e.dead && e.army === a).length;
|
||||
const hp = (a) => st.entities.filter((e) => !e.dead && e.army === a).reduce((s, e) => s + e.hp, 0);
|
||||
return { a: left(0), b: left(1), hpA: hp(0), hpB: hp(1), secs: t / HZ };
|
||||
};
|
||||
const cost = (id) => combatRules.unitById[id].cost.mass + combatRules.unitById[id].cost.energy / 4;
|
||||
const equal = (a, b, n = 8) => {
|
||||
const nb = Math.max(1, Math.round(n * cost(a) / cost(b)));
|
||||
const r = duel(a, n, b, nb);
|
||||
// Score on surviving fraction of the force each side paid for, so a slow grind that the
|
||||
// tanks are clearly winning counts as a win even if the clock runs out first.
|
||||
r.fracA = r.a / n; r.fracB = r.b / nb;
|
||||
return r;
|
||||
};
|
||||
|
||||
// Ballistic shells travel 35px per 20Hz tick, so a point-sampled hit test tunnels straight
|
||||
// through its target. Tanks doing real damage is the observable proof the sweep works.
|
||||
const tvi = equal('tank', 'infantry');
|
||||
check('tanks beat equal-cost infantry', tvi.fracA > tvi.fracB,
|
||||
`${tvi.a} tanks (${(tvi.fracA * 100) | 0}%) v ${tvi.b} infantry (${(tvi.fracB * 100) | 0}%)`);
|
||||
const rvs = equal('rockettank', 'sniper');
|
||||
check('rocket tanks beat equal-cost snipers', rvs.fracA > rvs.fracB,
|
||||
`${rvs.a} (${(rvs.fracA * 100) | 0}%) v ${rvs.b} (${(rvs.fracB * 100) | 0}%)`);
|
||||
// Rockets out-range and out-damage armour; that is what makes the roster a triangle
|
||||
// rather than a ladder where one unit is simply the answer.
|
||||
const rvt = equal('rockettank', 'tank');
|
||||
check('rocket tanks beat equal-cost tanks', rvt.fracA > rvt.fracB,
|
||||
`${rvt.a} (${(rvt.fracA * 100) | 0}%) v ${rvt.b} (${(rvt.fracB * 100) | 0}%)`);
|
||||
|
||||
// Engagements must take long enough for reinforcement and composition to matter. When
|
||||
// fights resolved in 2-4 seconds the whole AI skill ladder collapsed to a coin flip.
|
||||
check('an even engagement is not instant', tvi.secs > 5, `${tvi.secs.toFixed(1)}s`);
|
||||
|
||||
// Damage fixtures.
|
||||
const st = L.createMatch(combatRules, { seed: 32, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const victim = L.spawnUnit(st, combatRules, 1, 'tank', cx, cy);
|
||||
const before = victim.hp;
|
||||
L.applyDamage(st, combatRules, victim, 100, { army: 0, id: 0 });
|
||||
check('applyDamage subtracts exactly', Math.abs((before - victim.hp) - 100) < 1e-9);
|
||||
const rifle = combatRules.weaponById.rifle;
|
||||
check('rifle is strong vs infantry, weak vs medium',
|
||||
armorMul(rifle, 'infantry') > armorMul(rifle, 'medium') * 3);
|
||||
const rocket = combatRules.weaponById.rocketpod;
|
||||
check('rockets favour armour over infantry', armorMul(rocket, 'heavy') > armorMul(rocket, 'infantry') * 3);
|
||||
|
||||
// Commander death explosion.
|
||||
const dth = combatRules.unitById.commander.deathExplosion;
|
||||
check('commander has a death explosion', dth && dth.radius > 200 && dth.damage > 500);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('7. Fog of war');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const map = generateMap(rules, { seed: 41, size: 'small', symmetry: 'mirror-x' });
|
||||
const st = L.createMatch(rules, { seed: 41, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const mine = st.entities.find((e) => e.army === 0);
|
||||
const theirs = st.entities.find((e) => e.army === 1);
|
||||
L.computeVision(st, rules);
|
||||
check('own units are always visible', L.isVisibleTo(st, 0, mine));
|
||||
check('a distant enemy starts hidden', !L.isVisibleTo(st, 0, theirs));
|
||||
|
||||
// Explored is sticky; visible is not.
|
||||
const cell = st.tileSize * 2;
|
||||
const vx = Math.floor(mine.x / cell), vy = Math.floor(mine.y / cell);
|
||||
const idx = vy * st.visW + vx;
|
||||
check('own tile is explored', st.armies[0].explored[idx] === 1);
|
||||
L.spawnUnit(st, rules, 0, 'jeep', theirs.x, theirs.y);
|
||||
L.computeVision(st, rules);
|
||||
check('scouting reveals the enemy', L.isVisibleTo(st, 0, theirs));
|
||||
|
||||
// The AI must be fog-limited too — it may not target something it cannot see.
|
||||
const st2 = L.createMatch(rules, { seed: 42, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
for (let i = 0; i < HZ * 3; i++) { runAI(rules, st2, 0, { skill: 5 }); L.tick(st2, rules); }
|
||||
const mem = st2.aiMem?.[0];
|
||||
const cheated = (mem?.knownEnemies ?? []).filter((k) => {
|
||||
const e = L.entityById(st2, k.id);
|
||||
return e && !L.isVisibleTo(st2, 0, e) && k.tick === st2.tick;
|
||||
});
|
||||
check('AI never records an unseen enemy as seen-now', cheated.length === 0, `${cheated.length} leaks`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('8. Map generation');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const seeds = QUICK ? 24 : 120;
|
||||
let symMismatch = 0, badStarts = 0, poorMetal = 0, unreachable = 0;
|
||||
const sizes = Object.keys(rules.skirmish.sizes);
|
||||
for (let i = 0; i < seeds; i++) {
|
||||
const size = sizes[i % sizes.length];
|
||||
const sym = rules.skirmish.symmetries[i % rules.skirmish.symmetries.length];
|
||||
const map = generateMap(rules, { seed: 5000 + i * 13, size, symmetry: sym, armies: 2 });
|
||||
if (map.starts.length !== 2) { badStarts++; continue; }
|
||||
|
||||
if (sym === 'mirror-x') {
|
||||
for (let y = 0; y < map.h; y++) {
|
||||
for (let x = 0; x < map.w; x++) {
|
||||
if (map.terrain[y * map.w + x] !== map.terrain[y * map.w + (map.w - 1 - x)]) { symMismatch++; y = map.h; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every start needs metal within reach or its economy can never leave the ground.
|
||||
const mgen = rules.buildingById.massgen;
|
||||
for (const s of map.starts) {
|
||||
let spots = 0;
|
||||
const R = rules.skirmish.gen.metalSpotRadiusTiles;
|
||||
for (let y = Math.max(0, s.y - R); y < Math.min(map.h, s.y + R); y++) {
|
||||
for (let x = Math.max(0, s.x - R); x < Math.min(map.w, s.x + R); x++) {
|
||||
if (rules.terrain[map.terrain[y * map.w + x]][mgen.terrainMultiplier] > 1) spots++;
|
||||
}
|
||||
}
|
||||
if (spots < 4) poorMetal++;
|
||||
}
|
||||
// And the two starts must be mutually reachable by a medium ground unit.
|
||||
const nav = createNav(rules, map);
|
||||
const clr = clearanceFor(rules.sizeClasses.medium.radius, rules.constants.tileSize);
|
||||
const path = findPath(nav, 'tread', clr,
|
||||
map.starts[0].y * map.w + map.starts[0].x, map.starts[1].y * map.w + map.starts[1].x);
|
||||
if (!path) unreachable++;
|
||||
}
|
||||
check('every generated map places both starts', badStarts === 0, `${badStarts} bad`);
|
||||
check('mirror-x maps are exactly symmetric', symMismatch === 0, `${symMismatch} mismatches`);
|
||||
check('every start has metal nearby', poorMetal === 0, `${poorMetal} starved starts`);
|
||||
check('starts are mutually reachable', unreachable === 0, `${unreachable} unreachable`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('9. Campaign data');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const missions = campaign.missions ?? [];
|
||||
check('campaign has missions', missions.length > 0);
|
||||
const OBJECTIVES = new Set(['destroyAll', 'destroyCommander', 'survive', 'holdArea', 'reachArea', 'protect', 'buildCount']);
|
||||
missions.forEach((m, i) => {
|
||||
let map = null;
|
||||
try { map = decodeMap(rules, m.map); } catch (e) { /* reported below */ }
|
||||
check(`${m.id} terrain decodes`, !!map);
|
||||
if (!map) return;
|
||||
check(`${m.id} declares both starts`, (map.starts ?? []).length >= 2);
|
||||
check(`${m.id} theme exists`, !!artJson.themes?.[m.theme], m.theme);
|
||||
check(`${m.id} player army exists`, !!rules.armyById[m.playerArmy]);
|
||||
check(`${m.id} player commander exists`, !!rules.commanderById[m.playerCommander]);
|
||||
check(`${m.id} objective is a known type`, OBJECTIVES.has(m.objective?.type), m.objective?.type);
|
||||
for (const e of m.enemies ?? []) {
|
||||
check(`${m.id} enemy commander exists`, !!rules.commanderById[e.commander], e.commander);
|
||||
const sk = e.aiProfile?.skill;
|
||||
check(`${m.id} enemy skill is 1-5`, sk >= 1 && sk <= 5, String(sk));
|
||||
}
|
||||
for (const b of map.buildings ?? []) {
|
||||
check(`${m.id} prebuilt ${b.type} exists`, !!rules.buildingById[b.type], b.type);
|
||||
check(`${m.id} prebuilt ${b.type} is on the map`, b.tx >= 0 && b.ty >= 0 && b.tx < map.w && b.ty < map.h);
|
||||
}
|
||||
for (const line of [...(m.briefing ?? []), m.victoryLine, m.defeatLine].filter(Boolean)) {
|
||||
check(`${m.id} speaker ${line.speaker} exists`,
|
||||
(opponents.opponents ?? []).some((o) => o.id === line.speaker), line.speaker);
|
||||
}
|
||||
// Missions must get harder, or the unlock order is meaningless.
|
||||
if (i > 0) {
|
||||
const prev = missions[i - 1].enemies?.[0]?.aiProfile?.skill ?? 0;
|
||||
check(`${m.id} is no easier than the one before`, (m.enemies?.[0]?.aiProfile?.skill ?? 0) >= prev);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('10. Serialization and determinism');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const map = generateMap(rules, { seed: 51, size: 'small', symmetry: 'mirror-x' });
|
||||
const st = L.createMatch(rules, { seed: 51, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
for (let i = 0; i < HZ * 20; i++) { runAI(rules, st, 0, { skill: 3 }); runAI(rules, st, 1, { skill: 3 }); L.tick(st, rules); }
|
||||
const blob = L.serialize(st);
|
||||
const back = L.deserialize(rules, blob);
|
||||
check('serialize round-trips to the same hash', L.hashState(back) === L.hashState(st));
|
||||
check('serialized save is a sane size', blob.length < 400000, `${blob.length} bytes`);
|
||||
|
||||
const run = () => {
|
||||
const m2 = generateMap(rules, { seed: 52, size: 'small', symmetry: 'mirror-x' });
|
||||
const s2 = L.createMatch(rules, { seed: 52, map: m2, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
for (let i = 0; i < HZ * 30; i++) { runAI(rules, s2, 0, { skill: 4 }); runAI(rules, s2, 1, { skill: 2 }); L.tick(s2, rules); }
|
||||
return L.hashState(s2);
|
||||
};
|
||||
check('same seed replays identically', run() === run());
|
||||
|
||||
// The step() harness must produce the same result as driving tick() directly.
|
||||
const m3 = generateMap(rules, { seed: 53, size: 'small', symmetry: 'mirror-x' });
|
||||
const byTick = L.createMatch(rules, { seed: 53, map: m3, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
for (let i = 0; i < 100; i++) L.tick(byTick, rules);
|
||||
const byStep = L.createMatch(rules, { seed: 53, map: m3, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
for (let i = 0; i < 100; i++) L.step(byStep, rules, rules.stepMs, 4);
|
||||
check('step() and tick() agree', L.hashState(byStep) === L.hashState(byTick));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('11. Skirmish soak');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const games = QUICK ? 8 : 24;
|
||||
let decided = 0, negative = 0, nonFinite = 0, overCap = 0, peakUnits = 0;
|
||||
let totalTickMs = 0, ticks = 0, worstTick = 0;
|
||||
for (let g = 0; g < games; g++) {
|
||||
const map = generateMap(rules, { seed: 6000 + g * 17, size: g % 2 ? 'medium' : 'small', symmetry: 'mirror-x' });
|
||||
const st = L.createMatch(rules, { seed: 6000 + g, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const cap = rules.constants.unitCapPerArmy;
|
||||
while (!st.over && st.tick < 1200 * HZ) {
|
||||
runAI(rules, st, 0, { skill: 3 }); runAI(rules, st, 1, { skill: 3 });
|
||||
const t0 = process.hrtime.bigint();
|
||||
L.tick(st, rules);
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
totalTickMs += ms; ticks++; worstTick = Math.max(worstTick, ms);
|
||||
for (const a of st.armies) if (a.mass < -1e-6 || a.energy < -1e-6) negative++;
|
||||
if (st.tick % 200 === 0) {
|
||||
for (const e of st.entities) if (!Number.isFinite(e.x) || !Number.isFinite(e.y)) nonFinite++;
|
||||
for (let i = 0; i < st.armies.length; i++) {
|
||||
const n = st.entities.filter((e) => !e.dead && e.army === i && !e.isBuilding).length;
|
||||
peakUnits = Math.max(peakUnits, n);
|
||||
if (n > cap) overCap++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (st.over && st.over.winner >= 0) decided++;
|
||||
}
|
||||
const avg = totalTickMs / Math.max(1, ticks);
|
||||
console.log(` ${decided}/${games} decided · peak ${peakUnits} units/army · tick avg ${avg.toFixed(2)}ms worst ${worstTick.toFixed(1)}ms`);
|
||||
check('most games reach a decision', decided >= Math.ceil(games * 0.85), `${decided}/${games}`);
|
||||
check('resources never go negative', negative === 0, `${negative}`);
|
||||
check('positions stay finite', nonFinite === 0, `${nonFinite}`);
|
||||
check('unit cap is respected', overCap === 0, `${overCap} breaches`);
|
||||
// Average is the real budget signal at 20Hz (50ms per tick). The worst case is allowed
|
||||
// more headroom because one slow tick — a burst of path requests, or a GC landing on it —
|
||||
// costs a single dropped frame, not a stall; it is printed above either way.
|
||||
check('average tick is well inside the 20Hz budget', avg < 8, `avg ${avg.toFixed(2)}ms`);
|
||||
check('no tick blows the frame budget outright', worstTick < 120, `worst ${worstTick.toFixed(1)}ms`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('12. AI skill ladder');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const pairs = QUICK ? 6 : 16;
|
||||
// Each seed is played BOTH ways round so that side advantage — turn order, spawn corner,
|
||||
// commander profile — cancels exactly instead of being assumed away. Measured over 32
|
||||
// mirrored pairs the ladder runs 5v1 ≈ 84%, 3v1 ≈ 81%, 5v3 ≈ 59%; the bars below sit far
|
||||
// enough below those to be stable at this sample size.
|
||||
const ladder = (sa, sb) => {
|
||||
let win = 0, dec = 0;
|
||||
for (let i = 0; i < pairs; i++) {
|
||||
for (const flip of [false, true]) {
|
||||
const map = generateMap(rules, { seed: 7000 + i * 7, size: 'small', symmetry: 'mirror-x' });
|
||||
const st = L.createMatch(rules, { seed: 7000 + i, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const p0 = flip ? sb : sa, p1 = flip ? sa : sb;
|
||||
while (!st.over && st.tick < 1200 * HZ) {
|
||||
runAI(rules, st, 0, { skill: p0 }); runAI(rules, st, 1, { skill: p1 });
|
||||
L.tick(st, rules);
|
||||
}
|
||||
if (st.over && st.over.winner >= 0) {
|
||||
dec++;
|
||||
if (st.over.winner === (flip ? 1 : 0)) win++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return dec ? win / dec : 0;
|
||||
};
|
||||
const hi = ladder(5, 1);
|
||||
console.log(` skill 5 vs skill 1: ${(hi * 100).toFixed(1)}%`);
|
||||
check('skill 5 beats skill 1 decisively', hi >= 0.72, `${(hi * 100).toFixed(1)}%`);
|
||||
if (!QUICK) {
|
||||
const mid = ladder(4, 2);
|
||||
console.log(` skill 4 vs skill 2: ${(mid * 100).toFixed(1)}%`);
|
||||
check('skill 4 beats skill 2', mid >= 0.52, `${(mid * 100).toFixed(1)}%`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('13. Campaign winnability');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// A skill-5 bot plays the PLAYER's side through the same issueOrder API a human uses. This
|
||||
// is the entire reason the order API is shared, and it is the only way to know a mission is
|
||||
// actually beatable rather than merely plausible.
|
||||
const runs = QUICK ? 2 : 4;
|
||||
for (const m of campaign.missions ?? []) {
|
||||
let wins = 0, played = 0;
|
||||
for (let r = 0; r < runs; r++) {
|
||||
const map = decodeMap(rules, m.map);
|
||||
const st = L.createMatch(rules, {
|
||||
seed: (m.seed ?? 1) + r * 101, map,
|
||||
armies: [
|
||||
{ armyId: m.playerArmy, commanderId: m.playerCommander, isHuman: true },
|
||||
...(m.enemies ?? []).map((e) => ({
|
||||
armyId: e.army, commanderId: e.commander,
|
||||
aiSkill: e.aiProfile?.skill ?? 3, aiProfile: e.aiProfile ?? null,
|
||||
})),
|
||||
],
|
||||
});
|
||||
(m.startResources ?? []).forEach((res, i) => {
|
||||
if (!st.armies[i]) return;
|
||||
st.armies[i].mass = res.mass ?? st.armies[i].mass;
|
||||
st.armies[i].energy = res.energy ?? st.armies[i].energy;
|
||||
});
|
||||
while (!st.over && st.tick < 1500 * HZ) {
|
||||
runAI(rules, st, 0, { skill: 5 });
|
||||
for (let i = 1; i < st.armies.length; i++) {
|
||||
const e = m.enemies?.[i - 1];
|
||||
runAI(rules, st, i, { skill: e?.aiProfile?.skill ?? 3, ...(e?.aiProfile ?? {}) });
|
||||
}
|
||||
L.tick(st, rules);
|
||||
}
|
||||
played++;
|
||||
if (st.over && st.over.winner === 0) wins++;
|
||||
}
|
||||
const rate = played ? wins / played : 0;
|
||||
console.log(` ${m.id} ${m.name.padEnd(16)} bot wins ${wins}/${played}`);
|
||||
check(`${m.id} is winnable by a skill-5 bot`, rate >= 0.5, `${wins}/${played}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log(`\n${'─'.repeat(60)}`);
|
||||
if (failures.length) {
|
||||
console.log(`FAILED — ${pass} checks passed, ${failures.length} failed:`);
|
||||
for (const f of failures) console.log(` ✗ ${f}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`OK — all ${pass} checks passed${QUICK ? ' (quick)' : ''}.`);
|
||||
Loading…
Reference in New Issue