Compare commits

..

No commits in common. "71e455650b582e2a50192f5d0ea97e06f9f2fc4d" and "793cb249f9cfa6e45f59cd6140f6bf896efbf9bc" have entirely different histories.

43 changed files with 288 additions and 3071 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

View File

@ -191,126 +191,6 @@
"name": "Zen Buddha",
"key": "zen-budha",
"path": "assets/images/shift/zen-budha.png"
},
{
"id": "aquaroom",
"name": "Aqua Room",
"key": "aquaroom",
"path": "assets/images/shift/aquaroom.png"
},
{
"id": "aztec-warrior",
"name": "Aztec Warrior",
"key": "aztec-warrior",
"path": "assets/images/shift/aztec-warrior.png"
},
{
"id": "cockpit",
"name": "Cockpit",
"key": "cockpit",
"path": "assets/images/shift/cockpit.png"
},
{
"id": "cybercity-market",
"name": "Cybercity Market",
"key": "cybercity-market",
"path": "assets/images/shift/cybercity-market.png"
},
{
"id": "good-vs-evil",
"name": "Good vs. Evil",
"key": "good-vs-evil",
"path": "assets/images/shift/good-vs-evil.png"
},
{
"id": "haunted-rainstorm",
"name": "Haunted Rainstorm",
"key": "haunted-rainstorm",
"path": "assets/images/shift/haunted-rainstorm.png"
},
{
"id": "jeweled-pocketwatch",
"name": "Jeweled Pocketwatch",
"key": "jeweled-pocketwatch",
"path": "assets/images/shift/jeweled-pocketwatch.png"
},
{
"id": "jungle-buildings",
"name": "Jungle Buildings",
"key": "jungle-buildings",
"path": "assets/images/shift/jungle-buildings.png"
},
{
"id": "kayaker",
"name": "Kayaker",
"key": "kayaker",
"path": "assets/images/shift/kayaker.png"
},
{
"id": "miami-nights",
"name": "Miami Nights",
"key": "miami-nights",
"path": "assets/images/shift/miami-nights.png"
},
{
"id": "neon-ninja",
"name": "Neon Ninja",
"key": "neon-ninja",
"path": "assets/images/shift/neon-ninja.png"
},
{
"id": "overlooking-the-city",
"name": "Overlooking the City",
"key": "overlooking-the-city",
"path": "assets/images/shift/overlooking-the-city.png"
},
{
"id": "range",
"name": "Range",
"key": "range",
"path": "assets/images/shift/range.png"
},
{
"id": "rollercoaster-lizard",
"name": "Rollercoaster Lizard",
"key": "rollercoaster-lizard",
"path": "assets/images/shift/rollercoaster-lizard.png"
},
{
"id": "samurai-oni",
"name": "Samurai Oni",
"key": "samurai-oni",
"path": "assets/images/shift/samurai-oni.png"
},
{
"id": "sea-turtle",
"name": "Sea Turtle",
"key": "sea-turtle",
"path": "assets/images/shift/sea-turtle.png"
},
{
"id": "solarpunk-city",
"name": "Solarpunk City",
"key": "solarpunk-city",
"path": "assets/images/shift/solarpunk-city.png"
},
{
"id": "squid-woman",
"name": "Squid Woman",
"key": "squid-woman",
"path": "assets/images/shift/squid-woman.png"
},
{
"id": "steampunk-mecha",
"name": "Steampunk Mecha",
"key": "steampunk-mecha",
"path": "assets/images/shift/steampunk-mecha.png"
},
{
"id": "turtle-shaman",
"name": "Turtle Shaman",
"key": "turtle-shaman",
"path": "assets/images/shift/turtle-shaman.png"
}
]
}
}

View File

@ -598,7 +598,7 @@
"id": "m05",
"name": "Tropic Vice",
"theme": "tropics",
"seed": 71644,
"seed": 55930,
"playerArmy": "arm",
"playerCommander": "vance",
"enemies": [
@ -646,102 +646,102 @@
"h": 96,
"theme": "tropics",
"rows": [
"~~r....^^^^^^^^^^^^...........rr~~~...r...^^^^^^^^^^^^...r...~~~rr...........^^^^^^^^^^^^....r~~",
"~~.....^^^^^^^^^^^^...........r.~~~........^^^....^^^........~~~.r...........^^^^^^^^^^^^.....~~",
"~~....rr^^^^^^^^^^^............r~~~~.r....................r.~~~~r............^^^^^^^^^^^rr....~~",
"~~~......^^^...................~~~~~~~rr................rr~~~~~~~...................^^^......~~~",
"~~~~.....rr....................~~~~~~~~~................~~~~~~~~~....................rr.....~~~~",
"~~~~......rr.....................~~~~~~rr..............rr~~~~~~.....................rr......~~~~",
"~~~~......rr..............rr.....~~.....rr............rr.....~~.....rr..............rr......~~~~",
"~~~..................rrrrrrr.....~......rr............rr......~.....rrrrrrr..................~~~",
"~~~.................rrrrrrrr.....~r..........................r~.....rrrrrrrr.................~~~",
"~~~~................rrrr.............rr..................rr.............rrrr................~~~~",
"~~~~~...............rrrr.............rr..................rr.............rrrr...............~~~~~",
"~~~~~~................rrr...........rr....................rr...........rrr................~~~~~~",
"~~~~~~r...............rr................................................rr...............r~~~~~~",
"~~~~~.rr................................................................................rr.~~~~~",
"~~~...rr................................................................................rr...~~~",
"~~rr........................................................................................rr~~",
"~~rr....................rr..............rr............rr..............rr....................rr~~",
".................rr....rrr..............rr............rr..............rrr....rr.................",
".................rr..~~.....r....r...........rrrrrr...........r....r.....~~..rr.................",
".................r..~~~~...~~~~~~~~~.......^^^^..^^^^.......~~~~~~~~~...~~~~..r.................",
"..................r.~~~~..~~~~~~~~~~~......^^^^..^^^^......~~~~~~~~~~~..~~~~.r..................",
"..................r~~~~~~~~~~~~~~~~~~.....^^^^^^^^^^^^.....~~~~~~~~~~~~~~~~~~r..................",
".................r.~~~~~~~~~~~~~~~~~~.....^^^^^^^^^^^^.....~~~~~~~~~~~~~~~~~~.r.................",
"................rrr~~~~~~~~~~~~~~~~~~......^^^^..^^^^......~~~~~~~~~~~~~~~~~~rrr................",
"..............rrrr~~~~~~~.~~~~~~~~~~~...rr.^^^^..^^^^.rr...~~~~~~~~~~~.~~~~~~~rrrr..............",
"..............rrr.~~~~~~...~~~~~~~~~~...rrr^^^^..^^^^rrr...~~~~~~~~~~...~~~~~~.rrr..............",
"..............r............................^^^^..^^^^............................r..............",
"..............r...........................^^^^^^^^^^^^...........................r..............",
"............rrrrrr.......................^^^^^^^^^^^^^^.......................rrrrrr............",
"........rr.rrrrrrr......................^^^^^^^^^^^^^^^^......................rrrrrrr.rr........",
"........rrrrr..rrr....................^^^^^^^^^^^^^^^^^^^^....................rrr..rrrrr........",
"...........rr...rr...................^^^^^^^^^^^^^^^^^^^^^^...................rr...rr...........",
"...........rr....r...........^......^^^^^^^^^^^^^^^^^^^^^^^^......^...........r....rr...........",
"...........................r^^^...r^^^^^^^^^^^^^^^^^^^^^^^^^^r...^^^r...........................",
"r...........................^^^....^^^^^^^^^^^^^^^^^^^^^^^^^^....^^^...........................r",
"...................................r^^^^^^^^^^^^^^^^^^^^^^^^r...................................",
"......................................^^^^^^^^^^^^^^^^^^^^......................................",
".................................rrr.....^^^^^^..^^^^^^.....rrr.................................",
".........MM.....................rrrrr.....^^^^....^^^^.....rrrrr.....................MM.........",
".........MM.....^^...............rrrrr....................rrrrr...............^^.....MM.........",
"................^^...............rrrrr....................rrrrr...............^^................",
"...............^^^...............rrrrr....................rrrrr...............^^^...............",
"................^^^...............r..........................r...............^^^................",
"~~...............^^..........................................................^^...............~~",
"~~~.MM...........^^..........................................................^^...........MM.~~~",
"~~~.MM.......MM...^...rr................................................rr...^...MM.......MM.~~~",
"~~...........MM.......rr................................................rr.......MM...........~~",
"~.....................rr................................................rr.....................~",
"r..............................................................................................r",
"....MM............rr........................................................rr............MM....",
"....MM............rr........................................................rr............MM....",
"..................rr~~~~................................................~~~~rr..................",
"..................r.~~~~~~~..r..............^^....^^..............r..~~~~~~~.r..................",
"...................~~~~~~~~rr..............^^^....^^^..............rr~~~~~~~~...................",
"...................~~~~~~~~~...............^^^^..^^^^...............~~~~~~~~~...................",
"rr.................~~~~~~~~~..............^^^^^..^^^^^..............~~~~~~~~~.................rr",
"rrr................~~~~~~~~~..............^^^^^..^^^^^..............~~~~~~~~~................rrr",
"..r................~~~~~~~~~................^^....^^................~~~~~~~~~................r..",
"...................~~~~~~~~..........................................~~~~~~~~...................",
"..................r~~~~~~~~.r......................................r.~~~~~~~~r..................",
"...................r~~~~~~.rrr.rr......rrrr..........rrrr......rr.rrr.~~~~~~r...................",
"...................r~~~~rrrrrrrrr.....rrrr............rrrr.....rrrrrrrrr~~~~r...................",
"....................rr..rrrrrr.rrr....rrrr............rrrr....rrr.rrrrrr..rr....................",
".............r..........rrrrr....r....rr................rr....r....rrrrr..........r.............",
"...........rrrrr.........rr..........rr..................rr..........rr.........rrrrr...........",
"..........~~~.rr.........r...........rr.....~~....~~.....rr...........r.........rr.~~~..........",
"........r~~~~~rr.........rr..........rr....~~~~~~~~~~....rr..........rr.........rr~~~~~r........",
"........r~~~~~rrr.......rrr..........rrr...~~~~~~~~~~...rrr..........rrr.......rrr~~~~~r........",
".........~~~~~..rr......rrr...........rr...r~~~rr~~~r...rr...........rrr......rr..~~~~~.........",
".........~~~~~..rr......rrr..........................................rrr......rr..~~~~~.........",
"........~~~~~~..rr............................................................rr..~~~~~~........",
"........~~~~~~..rr............................................................rr..~~~~~~........",
"........~~~~~~..rr............................................................rr..~~~~~~........",
"........~~~~~~~.......................rrrr............rrrr.......................~~~~~~~........",
".......~~~~~~~~......r..........r^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^r..........r......~~~~~~~~.......",
".......~~~~~~~~.....rrrr........^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^........rrrr.....~~~~~~~~.......",
".......~~~~~~~~....rr.rr.......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.......rr.rr....~~~~~~~~.......",
".......~~~~~~~~.....~~.r.......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.......r.~~.....~~~~~~~~.......",
".......~~~~~~~~.....~~..r......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^......r..~~.....~~~~~~~~.......",
".......~~~~~~~~.....~~..rrr....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^....rrr..~~.....~~~~~~~~.......",
"......r~~~~~~~~.....~~~..rrr....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^....rrr..~~~.....~~~~~~~~r......",
"......rr~~~~~~~.....~~~..rrr.....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.....rrr..~~~.....~~~~~~~rr......",
"......rr~~~~~~~.....~~~~............^^^^^^^^^^^^^^^^^^^^^^^^............~~~~.....~~~~~~~rr......",
"......rr.~~~~~r.....~~~~...............rrrr..........rrrr...............~~~~.....r~~~~~.rr......",
"^.........~~~~r...r~~~~~~..............rrrrrrrr..rrrrrrrr..............~~~~~~r...r~~~~.........^",
"^^............rr..r~~~~~~~................rrrrrrrrrrrr................~~~~~~~r..rr............^^",
"^^^...........rr..r~~~~~~~................rrr..rr..rrr................~~~~~~~r..rr...........^^^",
"^^^.....rr........r~~~~~~~...............rrrr......rrrr...............~~~~~~~r........rr.....^^^",
"^^^^....rr.........~~~~~~~~...............rr........rr...............~~~~~~~~.........rr....^^^^",
"^^^^...............~~~~~~~~..........................................~~~~~~~~...............^^^^",
"^^^^...............~~~~~~~~..........................................~~~~~~~~...............^^^^",
"^^^^...............~~~~~~~~..........................................~~~~~~~~...............^^^^",
"^^^................~~~~~~~~..........................................~~~~~~~~................^^^",
"^^^................~~~~~~~~...........r....r........r....r...........~~~~~~~~................^^^",
"^^^.................~~~~~~...........rrr..rrr......rrr..rrr...........~~~~~~.................^^^",
"^^.....r......rr.r..~~~~~~............r....r........r....r............~~~~~~..r.rr......r.....^^"
"~~~..........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": [
{

View File

@ -11,24 +11,7 @@
"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.",
"",
"Domains: a unit's `domain` is \"ground\" (the default) or \"air\". Air units ignore the nav grid,",
"never collide with ground units, and while AIRBORNE can only be hit by weapons listing \"air\"",
"in `targets` \u2014 so a roster with air units MUST keep some ground weapon anti-air capable or",
"there is no answer to them. That protection comes from flying, not from having wings: a landed",
"aircraft is an ordinary ground target that anything can shoot, and it cannot fire back.",
"`domain` and the unit's `moveClass` have to agree: an air unit takes a move class flagged",
"`\"air\": true`. Terrain passability comes from the move class COST TABLE, not from the terrain's",
"own `blocksMove` \u2014 that is how `hover` crosses water without a per-terrain exception.",
"",
"Armor classes are the whole balance surface: every weapon lists a multiplier for EVERY class,",
"so a roster change is a table edit rather than a code change. Two of them exist purely so a",
"counter can be expressed at all \u2014 `air` (aircraft, separate from light/medium so a weapon can",
"be lethal to planes without also being lethal to jeeps and tanks) and `fortification` (Laser",
"Tower and Missile Launcher, separate from `structure` so a weapon can specialise in cracking",
"defences without flattening generators). Adding a class means adding a key to all 12 weapons;",
"compileRules refuses to start if one is missing, which is the point."
"an optional second sprite that aims independently of the hull."
],
"version": 1,
"constants": {
@ -43,7 +26,6 @@
"pathBudgetPerTick": 8,
"retargetPeriodTicks": 10,
"engageHoldFraction": 0.45,
"siegeHoldFraction": 0.95,
"separationStiffness": 0.55,
"shoveAfterSec": 0.6,
"stuckGiveUpSec": 3.0,
@ -88,9 +70,7 @@
"light",
"medium",
"heavy",
"structure",
"fortification",
"air"
"structure"
],
"moveClasses": {
"foot": {
@ -122,27 +102,6 @@
"cliff": null,
"water": null
}
},
"hover": {
"cost": {
"ground": 1.0,
"rough": 1.1,
"road": 0.9,
"metal": 1.0,
"cliff": null,
"water": 1.0
}
},
"air": {
"air": true,
"cost": {
"ground": 1.0,
"rough": 1.0,
"road": 1.0,
"metal": 1.0,
"cliff": 1.0,
"water": 1.0
}
}
},
"armies": [
@ -274,13 +233,11 @@
"ground"
],
"armorMul": {
"infantry": 1.0,
"light": 0.5,
"medium": 0.25,
"heavy": 0.15,
"structure": 0.7,
"fortification": 0.55,
"air": 0.3
"infantry": 1.3,
"light": 0.85,
"medium": 0.3,
"heavy": 0.12,
"structure": 0.2
},
"fx": {
"style": "tracer",
@ -304,13 +261,11 @@
"ground"
],
"armorMul": {
"infantry": 2.2,
"light": 0.7,
"infantry": 1.3,
"light": 0.85,
"medium": 0.3,
"heavy": 0.15,
"structure": 0.5,
"fortification": 0.4,
"air": 0.35
"heavy": 0.12,
"structure": 0.2
},
"fx": {
"style": "tracer",
@ -324,22 +279,20 @@
"id": "sniperrifle",
"name": "Marksman Rifle",
"kind": "hitscan",
"damage": 70,
"reload": 2.2,
"damage": 55,
"reload": 2.0,
"burst": 1,
"range": 580,
"range": 460,
"spread": 0.0,
"targets": [
"ground"
],
"armorMul": {
"infantry": 3.2,
"light": 0.35,
"medium": 0.12,
"heavy": 0.08,
"structure": 0.08,
"fortification": 0.08,
"air": 0.15
"infantry": 2.2,
"light": 0.9,
"medium": 0.3,
"heavy": 0.1,
"structure": 0.1
},
"fx": {
"style": "beam",
@ -370,9 +323,7 @@
"light": 1.15,
"medium": 1.0,
"heavy": 0.8,
"structure": 1.2,
"fortification": 1.1,
"air": 1.0
"structure": 1.2
},
"fx": {
"style": "shell",
@ -386,7 +337,7 @@
"id": "rocketpod",
"name": "Rocket Pod",
"kind": "guided",
"damage": 48,
"damage": 40,
"reload": 4.0,
"burst": 2,
"burstDelay": 0.25,
@ -397,17 +348,14 @@
"aoe": 48,
"aoeFalloff": 0.15,
"targets": [
"ground",
"air"
"ground"
],
"armorMul": {
"infantry": 0.3,
"light": 0.6,
"medium": 1.0,
"heavy": 1.4,
"structure": 1.5,
"fortification": 1.6,
"air": 2.6
"structure": 1.5
},
"fx": {
"style": "rocket",
@ -422,27 +370,24 @@
"id": "shoulderrocket",
"name": "Shoulder Rocket",
"kind": "guided",
"damage": 40,
"damage": 32,
"reload": 2.2,
"burst": 1,
"range": 380,
"range": 320,
"minRange": 60,
"speed": 260,
"turnRate": 2.0,
"aoe": 24,
"aoeFalloff": 0.15,
"targets": [
"ground",
"air"
"ground"
],
"armorMul": {
"infantry": 0.3,
"light": 0.9,
"medium": 1.3,
"heavy": 1.2,
"structure": 1.2,
"fortification": 1.9,
"air": 2.8
"infantry": 0.35,
"light": 0.8,
"medium": 1.1,
"heavy": 1.3,
"structure": 1.0
},
"fx": {
"style": "rocket",
@ -470,9 +415,7 @@
"light": 1.0,
"medium": 0.8,
"heavy": 0.6,
"structure": 0.5,
"fortification": 0.45,
"air": 0.8
"structure": 0.5
},
"fx": {
"style": "beam",
@ -500,9 +443,7 @@
"light": 1,
"medium": 1,
"heavy": 1,
"structure": 1,
"fortification": 1,
"air": 1
"structure": 1
},
"fx": {
"style": "dgun",
@ -529,9 +470,7 @@
"light": 1.2,
"medium": 0.9,
"heavy": 0.55,
"structure": 0.4,
"fortification": 0.35,
"air": 1.0
"structure": 0.4
},
"fx": {
"style": "beam",
@ -555,17 +494,14 @@
"aoe": 56,
"aoeFalloff": 0.15,
"targets": [
"ground",
"air"
"ground"
],
"armorMul": {
"infantry": 0.35,
"light": 0.7,
"medium": 1.1,
"heavy": 1.5,
"structure": 1.3,
"fortification": 1.2,
"air": 2.8
"structure": 1.3
},
"fx": {
"style": "rocket",
@ -574,67 +510,6 @@
"trail": true
},
"sound": ["sfx-ta-rocket-1", "sfx-ta-rocket-2"]
},
{
"id": "aacannon",
"name": "Air-to-Air Cannon",
"kind": "hitscan",
"damage": 44,
"reload": 1.6,
"burst": 3,
"burstDelay": 0.12,
"range": 300,
"spread": 0.02,
"targets": ["air"],
"armorMul": {
"infantry": 1.0,
"light": 1.0,
"medium": 0.85,
"heavy": 0.6,
"structure": 0.2,
"fortification": 0.2,
"air": 1.0
},
"fx": {
"style": "tracer",
"color": "#ffe89a",
"width": 2,
"muzzle": 16,
"lifeMs": 70
},
"sound": "sfx-ta-50cal"
},
{
"id": "bombrack",
"name": "Bomb Rack",
"kind": "ballistic",
"damage": 190,
"reload": 6.0,
"burst": 2,
"burstDelay": 0.35,
"range": 160,
"speed": 240,
"spread": 0.05,
"aoe": 96,
"aoeFalloff": 0.3,
"targets": ["ground"],
"armorMul": {
"infantry": 1.1,
"light": 1.0,
"medium": 1.0,
"heavy": 0.85,
"structure": 1.6,
"fortification": 1.5,
"air": 1.0
},
"fx": {
"style": "shell",
"color": "#ffb15c",
"width": 3,
"muzzle": 8
},
"sound": "sfx-scifi-launch",
"impactSound": "sfx-ta-nuclear"
}
],
"units": [
@ -668,8 +543,7 @@
"barracks",
"vehicleplant",
"lasertower",
"missilelauncher",
"airfield"
"missilelauncher"
],
"produce": {
"energy": 25,
@ -730,7 +604,7 @@
"role": "combat",
"size": "small",
"radius": 16,
"hp": 130,
"hp": 200,
"speed": 46,
"turnRate": 5.0,
"moveClass": "foot",
@ -760,7 +634,7 @@
"role": "combat",
"size": "small",
"radius": 16,
"hp": 150,
"hp": 240,
"speed": 50,
"turnRate": 5.5,
"moveClass": "foot",
@ -852,7 +726,7 @@
"role": "artillery",
"size": "medium",
"radius": 28,
"hp": 1050,
"hp": 1250,
"speed": 75,
"turnRate": 1.8,
"moveClass": "tread",
@ -906,8 +780,7 @@
"vehicleplant",
"lasertower",
"missilelauncher",
"advancedvehicleplant",
"airfield"
"advancedvehicleplant"
],
"sheetSlot": "unitSheet",
"frame": 11,
@ -915,127 +788,6 @@
"icon": 7,
"spritePx": 50,
"moveSound": "sfx-engine-heavy"
},
{
"id": "fighter",
"name": "Fighter",
"role": "combat",
"domain": "air",
"size": "small",
"radius": 22,
"hp": 820,
"speed": 290,
"turnRate": 3.0,
"moveClass": "air",
"armorClass": "air",
"sight": 620,
"cost": {
"energy": 900,
"mass": 160
},
"buildTime": 16,
"builtBy": [
"airfield"
],
"weapons": [
"aacannon",
"pintlegun"
],
"flight": {
"height": 26,
"takeoffSec": 0.45,
"landSec": 0.7,
"overshoot": 420,
"keepout": 700
},
"sheetSlot": "unitSheet",
"frame": 12,
"procShape": "fighter",
"icon": 8,
"spritePx": 46,
"moveSound": "sfx-engine-fast"
},
{
"id": "bomber",
"name": "Bomber",
"role": "artillery",
"domain": "air",
"size": "medium",
"radius": 30,
"hp": 1350,
"speed": 215,
"turnRate": 1.7,
"moveClass": "air",
"armorClass": "air",
"sight": 560,
"cost": {
"energy": 1700,
"mass": 440
},
"buildTime": 30,
"builtBy": [
"airfield"
],
"weapons": [
"bombrack"
],
"flight": {
"height": 30,
"takeoffSec": 0.7,
"landSec": 1.0,
"overshoot": 540,
"keepout": 700
},
"sheetSlot": "unitSheet",
"frame": 13,
"procShape": "bomber",
"icon": 9,
"spritePx": 60,
"moveSound": "sfx-engine-heavy"
},
{
"id": "hoverconstructor",
"name": "Hover Constructor",
"role": "builder",
"size": "medium",
"radius": 24,
"hp": 760,
"speed": 115,
"turnRate": 2.8,
"moveClass": "hover",
"armorClass": "light",
"sight": 360,
"cost": {
"energy": 640,
"mass": 175
},
"buildTime": 16,
"builtBy": [
"airfield"
],
"buildPower": 100,
"buildRange": 190,
"builds": [
"energygen",
"massgen",
"barracks",
"vehicleplant",
"lasertower",
"missilelauncher",
"advancedvehicleplant",
"airfield"
],
"flight": {
"height": 9,
"takeoffSec": 0.35,
"landSec": 0.5
},
"sheetSlot": "unitSheet",
"frame": 14,
"procShape": "hoverConstructor",
"icon": 17,
"spritePx": 52,
"moveSound": "sfx-engine-medium"
}
],
"buildings": [
@ -1173,8 +925,8 @@
"w": 1,
"h": 1
},
"hp": 1700,
"armorClass": "fortification",
"hp": 3300,
"armorClass": "structure",
"sight": 300,
"cost": {
"energy": 420,
@ -1204,8 +956,8 @@
"w": 2,
"h": 2
},
"hp": 3100,
"armorClass": "fortification",
"hp": 4700,
"armorClass": "structure",
"sight": 420,
"cost": {
"energy": 1100,
@ -1258,37 +1010,6 @@
"buildFrame": 13,
"procShape": "advancedVehiclePlant",
"icon": 16
},
{
"id": "airfield",
"name": "Airfield",
"footprint": {
"w": 3,
"h": 3
},
"hp": 4200,
"armorClass": "structure",
"sight": 260,
"cost": {
"energy": 1800,
"mass": 600
},
"buildTime": 46,
"buildPower": 110,
"builds": [
"fighter",
"bomber",
"hoverconstructor"
],
"spawnOffset": {
"x": 0,
"y": 2.2
},
"sheetSlot": "structureSheet",
"frame": 14,
"buildFrame": 15,
"procShape": "airfield",
"icon": 18
}
],
"commandIcons": {
@ -1299,9 +1020,7 @@
"hold": 24,
"patrol": 25,
"guard": 26,
"assist": 27,
"repair": 28,
"rally": 29
"assist": 27
},
"aiSkills": [
{

View File

@ -10,8 +10,7 @@
// 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, angleDelta } from './TALogic.js';
import { canEngage } from './TARules.js';
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. */
@ -21,11 +20,6 @@ const COMPOSITION = {
// mix for the trooper, and just enough of the vehicle to occasionally replace a lost
// Commander's construction capacity — not a real combat unit, so it stays a low priority.
rockettrooper: 0.14, constructor: 0.04,
// Air. The weights are per-FACTORY shares, so these only compete with each other once an
// Airfield exists — the bomber is the reason to own one, the fighter is the answer to the
// enemy owning one (applyCounters pushes it hard the moment enemy air is actually seen),
// and the hover constructor is the same insurance policy as the tracked one.
bomber: 0.44, fighter: 0.30, hoverconstructor: 0.10,
};
function memFor(state, armyIdx) {
@ -36,7 +30,7 @@ function memFor(state, armyIdx) {
nextThinkTick: 0,
ordersThisSecond: 0, secondMark: 0,
squad: [], squadPeak: 0, phase: 'build', focusId: 0,
scoutId: 0, airPreyId: 0, lastThreatTick: -99999,
scoutId: 0,
knownEnemies: [], // [{x,y,defId,isBuilding,tick}]
lastAttackTick: -99999,
buildSpiral: 0,
@ -95,7 +89,6 @@ export function runAI(rules, state, armyIdx, profile = {}) {
manageEconomyAndBase(ctx, mine);
manageProduction(ctx, mine);
manageScout(ctx, mine);
manageAir(ctx, mine);
manageMilitary(ctx, mine);
}
@ -122,30 +115,11 @@ function observe(ctx) {
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.
//
// BUILDINGS are exempt from the timer: they do not move, so a remembered position stays
// correct until the thing itself dies, and `live` already covers that. Ageing them out was
// making the AI lose track of a base it had already found, fall back to blind sweeping, and
// fail to finish games it had overwhelmingly won — one skirmish ended the clock with 30
// units against three.
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.isBuilding || k.tick >= cutoff) && live.has(k.id))
.filter((k) => k.tick >= cutoff && live.has(k.id))
.sort((a, b) => a.id - b.id);
// When did something hostile last get inside the base perimeter? Static defence is a
// REACTION in this AI — built because the base is being raided, not on a timer — so this
// one number is what the whole defensive build-out hangs off.
if (mem.baseSet) {
const r = state.tileSize * 16;
for (const s of seen) {
if (s.isBuilding) continue;
if (Math.hypot(s.x - mem.baseX, s.y - mem.baseY) > r) continue;
mem.lastThreatTick = state.tick;
break;
}
}
}
function gatherOwn(ctx) {
@ -195,7 +169,7 @@ function manageEconomyAndBase(ctx, mine) {
const want = chooseBuilding(ctx, mine);
if (!want) return;
const spot = findPlacement(ctx, mine, want, builder, placementOptionsFor(ctx, want));
const spot = findPlacement(ctx, mine, want, builder);
if (!spot) return;
order(ctx, {
@ -212,7 +186,6 @@ function chooseBuilding(ctx, mine) {
const mGen = rules.buildingById.massgen;
const barracks = rules.buildingById.barracks;
const plant = rules.buildingById.vehicleplant;
const airfield = rules.buildingById.airfield;
// Opening: two power, one mass, then a barracks so there's something to fight with.
if (n('energygen') < 2) return eGen;
@ -222,12 +195,6 @@ function chooseBuilding(ctx, mine) {
if (n('massgen') < 2) return mGen;
if (n('vehicleplant') < 1) return plant;
// Something is in the base and there is nothing static shooting back at it. This one jumps
// the whole economic queue: a raid that is happening now is not answered by another
// generator, and a base with no anti-air at all is farmed indefinitely by a single bomber.
const urgent = urgentDefence(ctx, mine);
if (urgent) return urgent;
// 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.
@ -241,26 +208,6 @@ function chooseBuilding(ctx, mine) {
return null;
}
// Exactly one Airfield, once there is a real economy and the ground factories are already
// standing. Air is a SECOND front, not a substitute for the first: gated any earlier, a
// skill-5 AI spent a Vehicle Plant's worth of tanks on a single Bomber while its barracks
// were being overrun, and lost campaign missions it had been winning. Skills 1-2 never reach
// this branch, so air stays a mark of a competent opponent.
//
// Gated on STRUCTURE COUNTS, and placed ahead of the ratio governor below on purpose. Two
// earlier attempts keyed on mass income never fired in practice: income peaks between 11 and
// 27 across skirmishes, and while a high-expansion AI is spending hard it is almost always
// stalling slightly, so the generator rules below would win every single time. A skill-5 AI
// built an Airfield in zero games out of six before this moved.
// Measured across 24 skirmishes: generators peak at 4-10 energy and 6-10 mass, so those two
// counts are reachable in nearly every game, while `buildEff > 0.7` held for as little as
// 20% of ticks in the games where the AI was spending hardest. 0.5 still refuses to start an
// 1800-energy structure in a real brownout without gating air behind a placid economy.
if (airfield && n('airfield') < 1 && n('barracks') + n('vehicleplant') >= 2
&& n('energygen') >= 4 && n('massgen') >= 3 && army.buildEff > 0.5) {
return airfield;
}
// 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.
@ -282,17 +229,12 @@ function chooseBuilding(ctx, mine) {
// 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') + n('airfield');
const factories = n('barracks') + n('vehicleplant');
const nextFactory = () => (n('vehicleplant') <= n('barracks') ? plant : barracks);
const factoryCap = Math.round(2 + expansion * 4);
const groundDrain = Math.max(0.5, factoryMassDrain(rules, plant));
const supported = Math.floor(army.mIncome / groundDrain);
const supported = Math.floor(army.mIncome / Math.max(0.5, factoryMassDrain(rules, plant)));
if (factories < Math.min(factoryCap, Math.max(1, supported))) return nextFactory();
// Steady-state defence, once production capacity is where the economy can feed it.
const defence = steadyDefence(ctx, mine);
if (defence) return defence;
if (ratio > TARGET_RATIO && roomM) return mGen;
if (ratio < TARGET_RATIO && roomE) return eGen;
if (roomM) return mGen;
@ -301,83 +243,6 @@ function chooseBuilding(ctx, mine) {
return null;
}
/**
* Static defence is a skill-4-and-up behaviour, like the Airfield is skill 3 and up.
*
* These structures hit hard for their cost — a Laser Tower puts out 130 dps behind 1700 hp for
* 60% of a Tank's price — and the ground roster has no dedicated siege answer to them yet, so
* two equally-matched AIs both digging in grind rather than resolve. Reserving them for the
* stronger opponents puts that pressure where a player goes looking for it and leaves the low
* end of the ladder playing the open game it was tuned for. Drop this to 3 (or 1) once there is
* a unit whose job is cracking fortifications.
*/
const DEFENCE_MIN_SKILL = 4;
/** Has anything hostile been inside the base perimeter recently? */
function underThreat(ctx) {
return ctx.state.tick - (ctx.mem.lastThreatTick ?? -99999) < ctx.rules.constants.tickHz * 90;
}
/** Have we actually SEEN enemy aircraft? Fog-limited, like every other AI decision. */
function enemyAirSeen(ctx) {
return ctx.mem.knownEnemies.some((k) => ctx.rules.defById[k.defId]?.isAir);
}
/**
* The one structure the base needs RIGHT NOW, ahead of any economic consideration. Kept
* deliberately tiny — one tower against a raid, one launcher against aircraft. Everything
* beyond the first of each is a steady-state decision (see steadyDefence), because an AI that
* answers pressure by building walls stops building an army and loses slowly instead of fast.
*/
function urgentDefence(ctx, mine) {
const { rules } = ctx;
if (ctx.skill < DEFENCE_MIN_SKILL) return null;
const n = (id) => (mine.byDef[id] ?? 0);
const aa = rules.buildingById.missilelauncher;
const tower = rules.buildingById.lasertower;
// Anti-air first: a Laser Tower cannot shoot at aircraft at all, so a base being bombed gets
// nothing whatsoever from one.
if (aa && enemyAirSeen(ctx) && n('missilelauncher') < 1) return aa;
if (tower && underThreat(ctx) && n('lasertower') + n('missilelauncher') < 1) return tower;
return null;
}
/**
* Defence build-out from surplus. Capped hard and only ever in response to something actually
* observed — this AI wins by attacking, and a turtle that spends its income on towers fields
* no army to attack with.
*/
function steadyDefence(ctx, mine) {
const { rules, expansion } = ctx;
if (ctx.skill < DEFENCE_MIN_SKILL) return null;
const n = (id) => (mine.byDef[id] ?? 0);
const aa = rules.buildingById.missilelauncher;
const tower = rules.buildingById.lasertower;
// More launchers the more air we've seen, because that is the threat they answer.
if (aa && enemyAirSeen(ctx)) {
const airSeen = ctx.mem.knownEnemies.filter((k) => rules.defById[k.defId]?.isAir).length;
if (n('missilelauncher') < Math.min(3, 1 + Math.floor(airSeen / 2))) return aa;
}
// Two reasons to fortify: the base is being raided, or the army has marched off and left it
// uncovered. The second is the one a human plays instinctively — a committed push is exactly
// when a base is most exposed — but it buys only a token, because an AI that answers its own
// aggression with a wall of towers stops fielding the army that was winning it the game.
// Purely reactive: only a base that is actually being raided fortifies. An earlier version
// also fortified whenever the army marched out, which reads as sensible play and measured
// terribly — static defence is strong enough here that both sides digging in turned 7 of 8
// skirmishes into unresolved slugfests.
if (!underThreat(ctx)) return null;
// Tower, then launcher, then the rest of the towers. The launcher comes second rather than
// last because it out-ranges the tower and is the only static piece that can shoot upward at
// all — a position built purely from lasers has no answer whatsoever the first time it is
// overflown, and waiting for the full tower count to finish meant it almost never got built.
if (tower && n('lasertower') < 1) return tower;
if (aa && n('missilelauncher') < 1) return aa;
if (tower && n('lasertower') < Math.round(1 + expansion * 2)) return tower;
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);
@ -390,9 +255,10 @@ function factoryMassDrain(rules, def) {
* 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, opts = {}) {
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);
@ -401,48 +267,23 @@ function findPlacement(ctx, mine, def, builder, opts = {}) {
if (spot) return spot;
}
const minR = opts.minR ?? 2;
const maxR = opts.maxR ?? 22;
// `facing` narrows the spiral to an arc, so a Laser Tower goes on the side the attacks come
// from instead of wherever the spiral happened to have room. Tried twice: once constrained,
// then unconstrained, because a base hemmed in on that side must still get its tower up.
const facing = opts.facing ?? null;
for (const arc of facing ? [opts.arc ?? Math.PI / 3, Math.PI] : [Math.PI]) {
for (let r = minR; 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;
if (facing != null && Math.abs(angleDelta(a, facing)) > arc) continue;
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 };
}
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;
}
/**
* Where a given structure wants to sit. Defences are the only things with an opinion: a Laser
* Tower is a perimeter piece and belongs on the approach the enemy actually uses, while a
* Missile Launcher is protecting the base itself from something that can arrive over any edge
* of it, so it goes in among the buildings it is covering.
*/
function placementOptionsFor(ctx, def) {
if (def.id === 'lasertower') {
const t = enemyBaseGuess(ctx);
const facing = t && ctx.mem.baseSet
? Math.atan2(t.y - ctx.mem.baseY, t.x - ctx.mem.baseX) : null;
return { minR: 5, maxR: 16, facing, arc: Math.PI / 3 };
}
if (def.id === 'missilelauncher') return { minR: 2, maxR: 10 };
return {};
}
function findMetalSpot(ctx, def, bx, by) {
const { state, rules } = ctx;
let best = null, bestD = Infinity;
@ -520,32 +361,22 @@ function templatePick(ctx, mine, builds) {
/** 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, air = 0;
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.isAir) { air++; continue; }
if (def.armorClass === 'infantry' || def.armorClass === 'light') infantryish++;
else armour++;
}
const seen = infantryish + armour;
if (seen) {
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;
weights.rockettrooper *= 1 + armourShare * 0.6;
}
// Enemy aircraft are answered by fighters first and by the guided-missile units second —
// those are the only ground things that can shoot up at all, so the counter is far sharper
// than any of the ground-vs-ground ones above.
if (air) {
weights.fighter *= 1 + Math.min(3, air) * 0.9;
weights.rockettank *= 1 + Math.min(3, air) * 0.25;
weights.rockettrooper *= 1 + Math.min(3, air) * 0.25;
}
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;
weights.rockettrooper *= 1 + armourShare * 0.6;
}
// ---------------------------------------------------------------------------
@ -618,55 +449,6 @@ function sweepTarget(ctx) {
return { x: pick.x, y: pick.y };
}
// ---------------------------------------------------------------------------
// Air superiority
// ---------------------------------------------------------------------------
/** Can this def shoot at aircraft at all? */
function canHitAir(def) {
return (def.weaponDefs ?? []).some((w) => w.targetsAir);
}
/**
* Interception. Aircraft that can shoot other aircraft are pulled out of the ground push ONLY
* while there is enemy air to answer — the Fighter carries a ground gun as well now, so leaving
* it permanently on air patrol would idle half its usefulness whenever the enemy owns no
* planes. Anything not claimed here falls through to manageMilitary and joins the attack.
*/
function manageAir(ctx, mine) {
const { state, rules, mem, armyIdx } = ctx;
if (!mem.airClaimed) mem.airClaimed = new Set();
mem.airClaimed.clear();
const fighters = mine.units.filter((u) => {
const def = rules.defById[u.defId];
return def.isAir && canHitAir(def);
});
if (!fighters.length) { mem.airPreyId = 0; return; }
let prey = null, preyD = Infinity;
for (const e of state.entities) {
if (e.dead || !e.isAir || e.army === armyIdx || !state.armies[e.army]) continue;
if (!isVisibleTo(state, armyIdx, e)) continue;
const d = Math.hypot(e.x - mem.baseX, e.y - mem.baseY);
if (d < preyD) { prey = e; preyD = d; }
}
if (!prey) { mem.airPreyId = 0; return; }
for (const u of fighters) mem.airClaimed.add(u.id);
// Sticky, for the same reason focusFire is: an `attack` order REPLACES the unit's queue, so
// re-picking a bandit every think would restart the intercept before it ever closed. Only
// re-issue when the current one is gone or someone has fallen out of the flight.
const committed = fighters.every((u) => u.orders[0]?.type === 'attack' && u.orders[0].targetId === mem.airPreyId);
if (committed && mem.airPreyId === prey.id) return;
mem.airPreyId = prey.id;
// Send the whole flight at one bandit — aircraft trade far too fast to arrive piecemeal.
order(ctx, {
army: armyIdx, unitIds: fighters.map((u) => u.id),
order: { type: 'attack', targetId: prey.id },
});
}
// ---------------------------------------------------------------------------
// Military
// ---------------------------------------------------------------------------
@ -679,7 +461,6 @@ function manageMilitary(ctx, mine) {
if (u.id === mem.scoutId) return false;
const def = rules.defById[u.defId];
if (!def.weaponDefs?.length || def.builds?.length) return false;
if (mem.airClaimed?.has(u.id)) return false; // manageAir has these on intercept
return u.orders.length === 0;
});
@ -687,7 +468,7 @@ function manageMilitary(ctx, mine) {
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, mine) : null;
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) {
@ -746,21 +527,17 @@ function manageMilitary(ctx, mine) {
}
}
function nearestThreatToBase(ctx, mine) {
const { state, rules, mem, armyIdx } = ctx;
function nearestThreatToBase(ctx) {
const { state, mem, armyIdx } = ctx;
if (!mem.baseSet) return null;
const radius = state.tileSize * 16;
// Only something we can actually shoot counts as a threat worth recalling the army for.
// A raiding Bomber over a base defended purely by tanks is a problem no amount of pulling
// the squad home solves, and answering it that way just abandons the attack for nothing.
const canAnswer = (e) => mine.units.some((u) => canEngage(rules.defById[u.defId], e));
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 && canAnswer(e)) { best = e; bestD = d; }
if (d < radius && d < bestD) { best = e; bestD = d; }
}
return best;
}
@ -806,7 +583,6 @@ function focusFire(ctx, mine) {
/** True when `u` could fire on `t` from where it already stands. */
function inReach(rules, u, t) {
const def = rules.defById[u.defId];
if (!canEngage(def, t)) return false;
return Math.hypot(u.x - t.x, u.y - t.y) - t.radius <= def.maxRange;
}

View File

@ -229,93 +229,6 @@ const UNIT_SHAPES = {
ctx.beginPath(); ctx.arc(16, 0, 2.5, 0, Math.PI * 2); ctx.fill();
ctx.restore();
},
// Aircraft are drawn as a plan-view airframe: swept wings well behind the nose, a tail
// plane, and no tracks or wheels anywhere. That silhouette is the only thing telling the
// player at a glance that a unit is in a layer their tanks cannot shoot at.
fighter(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
// swept main wing
ctx.beginPath();
ctx.moveTo(2, 0); ctx.lineTo(-8, -17); ctx.lineTo(-14, -17); ctx.lineTo(-6, 0);
ctx.lineTo(-14, 17); ctx.lineTo(-8, 17);
ctx.closePath(); fillStroke(ctx, BODY_DARK);
// fuselage, nose to the right
ctx.beginPath();
ctx.moveTo(21, 0); ctx.lineTo(6, -6); ctx.lineTo(-16, -5);
ctx.lineTo(-16, 5); ctx.lineTo(6, 6);
ctx.closePath(); fillStroke(ctx, BODY);
// tail plane
ctx.beginPath();
ctx.moveTo(-13, 0); ctx.lineTo(-19, -9); ctx.lineTo(-21, -9);
ctx.lineTo(-21, 9); ctx.lineTo(-19, 9);
ctx.closePath(); fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
// canopy
ctx.fillStyle = GLASS;
ctx.beginPath(); ctx.ellipse(6, 0, 5, 3.2, 0, 0, Math.PI * 2); ctx.fill();
// exhaust
ctx.fillStyle = HOT;
ctx.beginPath(); ctx.arc(-17, 0, 2.4, 0, Math.PI * 2); ctx.fill();
ctx.restore();
},
bomber(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
// long straight wing — the visual opposite of the fighter's swept one
roundRect(ctx, -8, -24, 13, 48, 4); fillStroke(ctx, BODY_DARK);
// engine nacelles out on the wing
ctx.fillStyle = '#3b3f47';
for (const sy of [-16, 16]) {
roundRect(ctx, -9, sy - 4, 16, 8, 3); ctx.fill();
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 1.5; ctx.stroke();
}
// fuselage
ctx.beginPath();
ctx.moveTo(25, 0); ctx.lineTo(12, -8); ctx.lineTo(-20, -7);
ctx.lineTo(-20, 7); ctx.lineTo(12, 8);
ctx.closePath(); fillStroke(ctx, BODY);
// tail plane
roundRect(ctx, -22, -12, 6, 24, 3); fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
// glazed nose
ctx.fillStyle = GLASS;
ctx.beginPath(); ctx.ellipse(14, 0, 6, 4.5, 0, 0, Math.PI * 2); ctx.fill();
// bomb bay hatch down the belly
ctx.fillStyle = '#2f3238';
ctx.fillRect(-6, -4, 14, 8);
ctx.strokeStyle = HOT; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(1, -4); ctx.lineTo(1, 4); ctx.stroke();
ctx.restore();
},
// A builder with no tracks: a skirted hull over a plenum, so it reads as the thing that can
// cross the water a tracked Construction Vehicle has to drive around.
hoverConstructor(ctx, S) {
const c = S / 2;
ctx.save(); ctx.translate(c, c);
// inflated skirt
ctx.beginPath(); ctx.ellipse(0, 0, 22, 15, 0, 0, Math.PI * 2);
fillStroke(ctx, '#3b3f47', OUTLINE, 2);
ctx.beginPath(); ctx.ellipse(0, 0, 18, 11.5, 0, 0, Math.PI * 2);
fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
// hull
roundRect(ctx, -13, -8, 26, 16, 5); fillStroke(ctx, BODY);
// cab
ctx.fillStyle = GLASS;
roundRect(ctx, 3, -5, 7, 10, 2); ctx.fill();
// nanolathe arm, same language as the tracked constructor
ctx.strokeStyle = ACCENT; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(8, 0); ctx.lineTo(19, 0); ctx.stroke();
ctx.fillStyle = HOT;
ctx.beginPath(); ctx.arc(20, 0, 2.5, 0, Math.PI * 2); ctx.fill();
// lift fans
ctx.fillStyle = '#4a4e56';
for (const fx of [-8, 8]) {
ctx.beginPath(); ctx.arc(fx, 0, 3, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
},
};
function tankHull(ctx, S, len, wid) {
@ -531,42 +444,6 @@ const STRUCT_SHAPES = {
ctx.stroke();
}
},
// An apron rather than a shed: the runway IS the building, so it reads as air production
// from the minimap without needing a door like the ground factories have.
airfield(ctx, S) {
const p = S * 0.06, w = S - p * 2, h = S - p * 2;
roundRect(ctx, p, p, w, h, S * 0.04); fillStroke(ctx, BODY_DARK, OUTLINE, 3);
// runway strip down the middle, pointing at the spawn edge (bottom)
const rw = w * 0.34, rx = p + w / 2 - rw / 2;
ctx.fillStyle = '#2f3238';
ctx.fillRect(rx, p + h * 0.10, rw, h * 0.90);
// centreline dashes
ctx.strokeStyle = '#d8dde4'; ctx.lineWidth = 2.5;
ctx.setLineDash([h * 0.07, h * 0.05]);
ctx.beginPath();
ctx.moveTo(p + w / 2, p + h * 0.14); ctx.lineTo(p + w / 2, p + h * 0.96);
ctx.stroke();
ctx.setLineDash([]);
// hangars flanking the strip
ctx.fillStyle = BODY;
for (const hx of [p + w * 0.06, p + w * 0.72]) {
roundRect(ctx, hx, p + h * 0.22, w * 0.22, h * 0.34, S * 0.03);
fillStroke(ctx, BODY, OUTLINE, 2);
}
// control tower, one corner
ctx.beginPath(); ctx.arc(p + w * 0.17, p + h * 0.78, S * 0.07, 0, Math.PI * 2);
fillStroke(ctx, BODY, OUTLINE, 2);
ctx.fillStyle = GLASS;
ctx.beginPath(); ctx.arc(p + w * 0.17, p + h * 0.78, S * 0.04, 0, Math.PI * 2); ctx.fill();
// approach lights along the threshold
ctx.fillStyle = HOT;
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.arc(p + w * (0.36 + i * 0.14), p + h * 0.06, S * 0.022, 0, Math.PI * 2);
ctx.fill();
}
},
};
// ---------------------------------------------------------------------------
@ -783,29 +660,6 @@ const PROC_PAINTERS = {
ctx.moveTo(S * 0.24, S / 2); ctx.lineTo(S * 0.76, S / 2);
ctx.stroke();
});
// A wrench, deliberately unlike assist's plus sign: assist pours build power into something
// unfinished, repair puts hit points back into something already standing.
glyph(cmd.repair, (ctx, S) => {
ctx.strokeStyle = '#8affd0'; ctx.lineWidth = 5; ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(S * 0.32, S * 0.68); ctx.lineTo(S * 0.66, S * 0.34);
ctx.stroke();
ctx.lineCap = 'butt';
ctx.beginPath(); ctx.arc(S * 0.70, S * 0.30, S * 0.13, 0.6, 5.2);
ctx.lineWidth = 4; ctx.stroke();
ctx.fillStyle = '#8affd0';
ctx.beginPath(); ctx.arc(S * 0.30, S * 0.70, S * 0.06, 0, Math.PI * 2); ctx.fill();
});
// A pennant on a post — where finished units are told to go.
glyph(cmd.rally, (ctx, S) => {
ctx.strokeStyle = '#cfe3ff'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(S * 0.34, S * 0.18); ctx.lineTo(S * 0.34, S * 0.82); ctx.stroke();
ctx.fillStyle = '#ffd27a';
ctx.beginPath();
ctx.moveTo(S * 0.34, S * 0.20); ctx.lineTo(S * 0.76, S * 0.34); ctx.lineTo(S * 0.34, S * 0.48);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#26262e'; ctx.lineWidth = 2; ctx.stroke();
});
return sh.finish();
},

View File

@ -23,39 +23,11 @@ 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; // button size, shared by both grids
const GRID_COLS = 8; // build-menu columns
const GRID_PITCH = 96; // column pitch (button + gutter)
const ROW_PITCH = 82; // row pitch; two rows fit the bar, three do not
const CMD_COLS = 5; // command grid is 5 wide x 2 deep
const BTN_W = 88, BTN_H = 74; // build-menu button size
/**
* The order buttons, in grid order: row one is what every mobile unit can do, row two is the
* stances plus the builder/factory verbs.
*
* `needs` decides whether the button is live for the current selection, and `instant` splits
* the two interaction models: Stop and Hold apply to the selection immediately, everything
* else arms a pending command that the next world click resolves — the same flow the A/P/G
* hotkeys already used, which is why those hotkeys keep working unchanged.
*
* Reclaim is absent rather than greyed out. There are no wrecks to reclaim in this engine
* yet, and a permanently dead button just teaches players to stop looking at that corner.
*/
const COMMANDS = [
{ id: 'move', label: 'Move', key: 'RMB', needs: 'mobile', tip: 'Walk to a point without engaging on the way.' },
{ id: 'attack', label: 'Attack', key: '', needs: 'armed', tip: 'Pick one target and commit to it.' },
{ id: 'attackMove', label: 'Atk-Mv', key: 'A', needs: 'mobile', tip: 'Advance to a point, engaging anything met on the way.' },
{ id: 'patrol', label: 'Patrol', key: 'P', needs: 'mobile', tip: 'Shuttle between here and a point, engaging on both legs.' },
{ id: 'guard', label: 'Guard', key: 'G', needs: 'mobile', tip: 'Escort a friendly unit and fight what attacks it.' },
{ id: 'stop', label: 'Stop', key: 'X', needs: 'any', instant: true, tip: 'Clear the whole order queue.' },
{ id: 'hold', label: 'Hold', key: 'H', needs: 'mobile', instant: true, tip: 'Stay put and shoot what comes into range.' },
{ id: 'repair', label: 'Repair', key: '', needs: 'builder', tip: 'Put hit points back into a damaged friendly.' },
{ id: 'assist', label: 'Assist', key: '', needs: 'builder', tip: 'Add build power to a site or a factory already working.' },
{ id: 'setRally', label: 'Rally', key: '', needs: 'factory', tip: 'Send newly built units to a point.' },
];
// Modifiers, unchanged by the buttons: CTRL queues an order (as in the original game), SHIFT
// adds to the selection and buys x5 from a factory.
// 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 · RMB a damaged ally to repair · CTRL+RMB queue · A attack-move · X stop · H hold';
/** Build-button captions have ~7 characters of room; "Vehicle Plant" needs shortening. */
function shortName(name) {
@ -157,106 +129,22 @@ export default class TAHud {
bg.setStrokeStyle(2, EDGE);
this.root.add(bg);
// Geometry first — the selection text is sized against where the command grid starts.
this.gridX = GAME_WIDTH - 24 - GRID_COLS * GRID_PITCH;
this.gridY = y + 16;
// Command grid sits immediately left of the build grid, sharing its button size and pitch
// so the two read as one control surface. Right edge lands 32px short of the build menu.
this.cmdX = this.gridX - 32 - (CMD_COLS - 1) * GRID_PITCH - BTN_W;
this.cmdY = this.gridY;
// The selection panel runs from the minimap to the command grid and no further. It used to
// have the whole width to itself; now that the order buttons sit at 624 it has to be told,
// or a mixed selection's title runs straight under them.
const selW = this.cmdX - (MINI + 60) - 16;
this.selTitle = s.add.text(MINI + 60, y + 12, '', {
fontFamily: FONT, fontSize: '26px', color: TEXT,
wordWrap: { width: selW }, maxLines: 1,
});
this.selDetail = s.add.text(MINI + 60, y + 44, '', {
fontFamily: FONT, fontSize: '19px', color: DIM, lineSpacing: 3,
wordWrap: { width: selW }, maxLines: 3,
});
this.queueText = s.add.text(MINI + 60, y + 120, '', {
fontFamily: FONT, fontSize: '19px', color: '#9ce6a0',
wordWrap: { width: selW }, maxLines: 2,
});
this.root.add([this.selTitle, this.selDetail, this.queueText]);
this._buildCommandGrid();
}
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]);
/**
* The order buttons. Built ONCE and then only restyled — unlike the build grid, whose
* contents change with the selection, this set is fixed and rebuilding ten containers every
* frame would churn interactive objects for nothing.
*/
_buildCommandGrid() {
const s = this.scene;
this.cmdButtons = [];
COMMANDS.forEach((cmd, i) => {
const col = i % CMD_COLS, row = Math.floor(i / CMD_COLS);
// Container position is its CENTRE — see the note in _drawGrid. Every child below is
// centred on (0,0) so the visible button and its hit area coincide.
const x = this.cmdX + col * GRID_PITCH + BTN_W / 2;
const y = this.cmdY + row * ROW_PITCH + BTN_H / 2;
const c = s.add.container(x, y);
const box = s.add.rectangle(0, 0, BTN_W, BTN_H, 0x22304a, 1).setStrokeStyle(2, EDGE);
const label = s.add.text(0, -BTN_H / 2 + 14, cmd.label, {
fontFamily: FONT, fontSize: '20px', color: TEXT,
}).setOrigin(0.5, 0);
const hotkey = s.add.text(0, BTN_H / 2 - 26, cmd.key, {
fontFamily: FONT, fontSize: '15px', color: DIM,
}).setOrigin(0.5, 0);
c.add([box, label, hotkey]);
c.setSize(BTN_W, BTN_H);
c.setInteractive({ useHandCursor: true });
const btn = { cmd, c, box, label, hotkey, enabled: false, armed: false };
c.on('pointerover', () => { if (btn.enabled && !btn.armed) box.setFillStyle(0x2e3f5f, 1); });
c.on('pointerout', () => this._styleCommand(btn));
this.tooltip.attachTo(c, () => ({
title: cmd.label,
lines: [{ text: cmd.tip }, ...(cmd.key ? [{ text: `Hotkey: ${cmd.key}` }] : [])],
}));
c.on('pointerdown', (p, lx, ly, ev) => {
ev?.stopPropagation();
if (btn.enabled) this.h.onCommand?.(cmd.id);
});
this.root.add(c);
this.cmdButtons.push(btn);
});
}
_styleCommand(btn) {
if (!btn.enabled) {
btn.box.setFillStyle(0x1a2130, 1).setStrokeStyle(2, 0x2a3244);
btn.label.setColor('#5d6b80'); btn.hotkey.setColor('#4e5a6d');
return;
}
if (btn.armed) {
btn.box.setFillStyle(0x3d6a4a, 1).setStrokeStyle(2, 0x7dff9b);
btn.label.setColor('#dcffe6'); btn.hotkey.setColor('#9ce6a0');
return;
}
btn.box.setFillStyle(0x22304a, 1).setStrokeStyle(2, EDGE);
btn.label.setColor(TEXT); btn.hotkey.setColor(DIM);
}
/** Light up the orders this selection can actually give, and mark the armed one. */
_refreshCommands(sel, pending) {
const R = this.rules;
const mobile = sel.filter((e) => !e.isBuilding && !e.site);
const can = {
any: sel.length > 0,
mobile: mobile.length > 0,
armed: mobile.some((e) => (R.defById[e.defId].maxRange ?? 0) > 0),
builder: mobile.some((e) => R.defById[e.defId].builds?.length),
factory: sel.some((e) => e.isBuilding && !e.site && R.defById[e.defId].builds?.length),
};
for (const btn of this.cmdButtons) {
btn.enabled = !!can[btn.cmd.needs];
btn.armed = btn.enabled && pending === btn.cmd.id;
this._styleCommand(btn);
}
this.gridX = GAME_WIDTH - 24 - 6 * 96;
this.gridY = y + 16;
}
_clearGrid() {
@ -273,9 +161,9 @@ export default class TAHud {
const s = this.scene;
const army = this.state.armies[this.playerArmy];
options.forEach((def, i) => {
const col = i % GRID_COLS, row = Math.floor(i / GRID_COLS);
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 * GRID_PITCH + BTN_W / 2;
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);
@ -311,16 +199,8 @@ export default class TAHud {
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}`);
// Which layer a unit lives in decides whether the player's current army can even shoot
// it, so it belongs in the tooltip above the individual weapon lines.
if (def.isAir) {
lines.push('AIRCRAFT — only anti-air can hit it while flying');
lines.push('Landed it cannot shoot, and anything can hit it');
}
else if (def.moveClass === 'hover') lines.push('HOVER — crosses water');
for (const w of def.weaponDefs ?? []) {
const dom = w.targetsAir ? (w.targetsGround ? 'ground+air' : 'air only') : 'ground only';
lines.push(`${w.name}: ${w.damage} dmg / ${w.reload}s / ${w.range} range / ${dom}${w.manual ? ' (manual)' : ''}`);
lines.push(`${w.name}: ${w.damage} dmg / ${w.reload}s / ${w.range} range${w.manual ? ' (manual)' : ''}`);
}
if (def.makes) {
const m = [];
@ -395,9 +275,6 @@ export default class TAHud {
const sx = MINI / st.worldW, sy = MINI / st.worldH;
for (const e of st.entities) {
if (e.dead) continue;
// The Commander-death cascade leaves its victims in state while removing them from the
// world, so the minimap has to honour it too or a wiped army lingers as a field of dots.
if (this.view.vaporised.has(e.id)) 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);
@ -416,7 +293,7 @@ export default class TAHud {
// Per-frame update
// -------------------------------------------------------------------------
update(timeMs, selection, placementDef, pendingCommand = null) {
update(timeMs, selection, placementDef) {
const st = this.state, army = st.armies[this.playerArmy];
if (!army) return;
@ -437,9 +314,6 @@ export default class TAHud {
this._bakeMinimap(timeMs);
this._drawMinimapOverlay();
this._refreshSelection(selection, placementDef);
// Outside the signature-cached selection refresh: the armed command changes on its own
// schedule (a hotkey, a click, an Esc) with the selection untouched.
this._refreshCommands([...selection], pendingCommand);
}
_refreshSelection(selection, placementDef) {
@ -457,6 +331,7 @@ export default class TAHud {
this.selTitle.setText('');
this.selDetail.setText('');
this.queueText.setText('');
this.hint.setText(HINT);
this._clearGrid();
return;
}
@ -478,10 +353,9 @@ export default class TAHud {
const job = lead.orders?.[0];
if (job?.type === 'repair') lines.push('Repairing');
else if (job?.type === 'build' || job?.type === 'assist') lines.push('Building');
// A guarding builder silently switches between mending and assisting, so say which.
else if (job?.type === 'guard' && lead.buildTargetId) {
const t = this.state.entities.find((x) => x.id === lead.buildTargetId && !x.dead);
lines.push(t && t.hp < t.maxHp ? 'Guarding — repairing' : 'Guarding — assisting');
// A builder is the only thing that can repair, so say so where the player is looking.
if (!lead.site && (this.rules.defById[lead.defId].builds ?? []).length && !lead.isBuilding) {
lines.push('Right-click a damaged ally to repair it');
}
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)' : ''}`);
@ -493,6 +367,13 @@ export default class TAHud {
}
this.selDetail.setText(lines.join('\n'));
if (placementDef) {
this.hint.setText(`Placing ${placementDef.name} — LMB to site it, hold CTRL to queue several `
+ '(release CTRL when done), 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);

View File

@ -16,7 +16,6 @@ import {
nearestUsableTile, tileIndex, worldToTileX, worldToTileY, tileCenterX, tileCenterY,
SpatialHash, segmentClear,
} from './TANav.js';
import { canEngage, weaponHitsDomain } from './TARules.js';
export const SAVE_VERSION = 1;
@ -143,24 +142,6 @@ function baseEntity(state, army, def) {
army,
defId: def.id,
isBuilding: !!def.isBuilding,
// Two related flags, and the difference matters.
//
// `isAir` is STATIC: which layer the unit moves through. It never changes, and it is what
// movement, separation and the nav grid read — a parked aircraft still doesn't pathfind.
//
// `airborne` is DYNAMIC: whether it is currently off the ground. This is what WEAPONS
// read, so an aircraft sitting on its pad is an ordinary ground target that a rifle or a
// tank shell can hit. Being untouchable is a property of flying, not of having wings.
isAir: !!def.isAir,
airborne: false,
// Altitude, 0 = on the ground, 1 = fully airborne. This lives in the SIM rather than the
// renderer because it is a rule, not a decoration: a grounded aircraft cannot fire, and
// its speed ramps with its height. `pliftFrac` is the previous tick's value, for the same
// reason px/py exist — the view interpolates between the two.
liftFrac: 0, pliftFrac: 0,
// Strafing run bookkeeping: once a pass goes overhead the unit commits to a point beyond
// the target and flies to it before coming about.
egressing: false, egressX: 0, egressY: 0,
x: 0, y: 0, px: 0, py: 0,
heading: 0, pheading: 0,
turretRot: 0, pturretRot: 0,
@ -327,9 +308,9 @@ export function issueOrder(state, rules, cmd) {
// One shared destination, fanned into formation slots — a 40-unit move order costs one A*.
const slots = formationSlots(order.x, order.y, units);
units.forEach((u, i) => {
const sx = slots[i * 2], sy = slots[i * 2 + 1];
if (order.type === 'patrol') { addPatrolWaypoint(u, sx, sy, cmd.queue); return; }
pushOrder(u, { ...order, sx, sy }, cmd.queue);
const o = { ...order, sx: slots[i * 2], sy: slots[i * 2 + 1] };
if (order.type === 'patrol') { o.fromX = u.x; o.fromY = u.y; o.leg = 0; }
pushOrder(u, o, cmd.queue);
});
return { ok: true };
}
@ -525,24 +506,11 @@ function stepEconomy(state, rules) {
if (target && target.army === e.army) {
const d = Math.hypot(target.x - e.x, target.y - e.y);
if (d > def.buildRange + target.radius) continue;
// Three places the power can land, in priority order.
//
// Repair is kept in its OWN accumulator: a damaged factory has build power of its own
// for the unit it is producing, and folding an incoming repair into the same number
// would silently speed up that unit's production.
//
// DAMAGE COMES FIRST. A nanolathe pointed at a factory that is both hurt and building
// patches it before it helps the production line — a half-dead factory that keeps
// pumping out units while ignoring the hole in its roof is the wrong answer, and it is
// what "guard this and keep it alive" means.
//
// The last branch is the one that was missing: build power aimed at a healthy factory
// with something in its queue now speeds that job up. `assist` has always ACCEPTED a
// producing factory as a target and stepOrders has always held the nanolathe on it,
// but the power evaporated here, so assisting a factory did visibly nothing.
// Repair power is kept in its OWN accumulator. A damaged factory has build power of
// its own for the unit it is producing; folding an incoming repair into the same
// number would silently speed up that unit's production.
if (target.site) target._power += def.buildPower;
else if (target.hp < target.maxHp) target._repairPower += def.buildPower;
else if (target.isBuilding && target.queue?.length) target._power += def.buildPower;
}
}
}
@ -679,18 +647,14 @@ function finishFactoryJob(state, rules, f) {
const ts = state.tileSize;
let sx = f.x + off.x * ts;
let sy = f.y + off.y * ts;
// Nudge out of the footprint if the bay mouth is somehow blocked. Aircraft skip this: they
// ignore the nav grid, and snapping one to the nearest "usable" tile would only shove it
// sideways off a perfectly good apron.
// Nudge out of the footprint if the bay mouth is somehow blocked.
const nav = state.nav;
const udef = rules.unitById[item.defId];
if (!udef.isAir) {
const need = clearanceFor(udef.radius, ts);
const mc = udef.moveClass;
const tx = worldToTileX(nav, sx), ty = worldToTileY(nav, sy);
const idx = nearestUsableTile(nav, mc, need, tx, ty, 6);
if (idx >= 0) { sx = tileCenterX(nav, idx % nav.w); sy = tileCenterY(nav, (idx / nav.w) | 0); }
}
const need = clearanceFor(udef.radius, ts);
const mc = udef.moveClass;
let tx = worldToTileX(nav, sx), ty = worldToTileY(nav, sy);
const idx = nearestUsableTile(nav, mc, need, tx, ty, 6);
if (idx >= 0) { sx = tileCenterX(nav, idx % nav.w); sy = tileCenterY(nav, (idx / nav.w) | 0); }
const u = spawnUnit(state, rules, f.army, item.defId, sx, sy, Math.atan2(off.y, off.x));
f.jobProgress = 0;
@ -715,17 +679,6 @@ function requestPath(state, e, x, y) {
if (!state.pathQueue.includes(e.id)) state.pathQueue.push(e.id);
}
/**
* Head for a point. Ground units get an A* path; aircraft simply record the destination and
* fly the straight line to it — no path, no queue, no clearance test. Routing air through the
* pathfinder would be both wasted A* budget and wrong, since the grid's whole job is to model
* obstacles an aircraft doesn't have.
*/
function seekTo(state, e, x, y) {
if (e.isAir) { e.destX = x; e.destY = y; return; }
requestPath(state, e, x, y);
}
function servicePathQueue(state, rules) {
let budget = rules.constants.pathBudgetPerTick;
while (budget-- > 0 && state.pathQueue.length) {
@ -750,66 +703,13 @@ function servicePathQueue(state, rules) {
}
function orderDestination(e, order) {
if (order.type === 'patrol') return patrolWaypoint(order);
if (order.type === 'patrol') {
return order.leg === 0 ? { x: order.sx ?? order.x, y: order.sy ?? order.y }
: { x: order.fromX, y: order.fromY };
}
return { x: order.sx ?? order.x, y: order.sy ?? order.y };
}
/**
* The waypoint a patrol is currently heading for.
*
* A patrol carries a `route`: a flat [x,y,x,y,…] circuit whose FIRST point is where the unit
* stood when the order was given, followed by every point the player clicked. `leg` indexes it
* and wraps, so the unit runs the circuit forever. One clicked point therefore still gives the
* classic there-and-back — the two-point route is just the shortest circuit, not a special case.
*
* Orders restored from a save written before routes existed carry the old fromX/fromY pair
* instead, and are read the old way rather than migrated.
*/
export function patrolWaypoint(order) {
const r = order.route;
if (!r || r.length < 4) {
return order.leg === 0
? { x: order.sx ?? order.x, y: order.sy ?? order.y }
: { x: order.fromX ?? order.sx ?? order.x, y: order.fromY ?? order.sy ?? order.y };
}
const n = r.length >> 1;
const i = ((order.leg ?? 0) % n + n) % n;
return { x: r[i * 2], y: r[i * 2 + 1] };
}
/** Step a patrol on to its next waypoint, wrapping round the circuit. */
function advancePatrol(order) {
const n = order.route && order.route.length >= 4 ? order.route.length >> 1 : 2;
order.leg = (((order.leg ?? 0) + 1) % n + n) % n;
}
/**
* Add a point to `u`'s patrol.
*
* Queued (CTRL held) onto a unit whose last order is already a patrol, this EXTENDS that
* circuit rather than stacking a second patrol behind it — which is what the player means by
* ctrl-clicking a series of points, and is also the only thing that could work: a patrol never
* completes, so anything queued behind one would never run.
*/
function addPatrolWaypoint(u, x, y, queue) {
const last = queue ? u.orders[u.orders.length - 1] : null;
if (last && last.type === 'patrol' && last.route) {
last.route.push(x, y);
return;
}
// Point 0 is where the unit is standing, so the circuit always comes home.
pushOrder(u, { type: 'patrol', route: [u.x, u.y, x, y], leg: 1 }, queue);
}
/**
* Is there anything for a guarding builder to do to this structure? Damage to mend, a site to
* finish, or a queue to help along. An intact idle building needs nothing, so its guard just
* stands watch — and picks the work up by itself the moment the building starts producing.
*/
function guardChore(target) {
return !!(target.site || target.hp < target.maxHp || target.queue?.length);
}
/** Auto-heal priority: lower tier heals first — defensive > factory > other building > unit. */
function healPriorityTier(def) {
if (!def.isBuilding) return 3;
@ -840,197 +740,12 @@ function findAutoHealTarget(state, rules, e, def) {
return best;
}
const MOVE_LIKE = new Set(['move', 'attackMove', 'patrol']);
/**
* How far into its own weapon envelope a unit closes before stopping to shoot a building.
*
* A building that SHOOTS BACK gets the near-maximum fraction, so anything out-ranging it can
* stand off and dismantle it for free — which is the entire counterplay to a defensive
* structure, and the reason a Rocket Tank (440) exists alongside a Tank (350) against a Laser
* Tower (340). Everything else keeps the low fraction that was tuned for base assaults:
* measured at 64 games, closing all the way in ran the skill ladder at 90% and holding at
* range dropped it to 68%, so this stays narrowly scoped to targets that can hurt you.
*/
function holdFractionFor(rules, target) {
const c = rules.constants;
const def = rules.defById[target.defId];
return def?.weaponDefs?.length ? (c.siegeHoldFraction ?? 0.95) : (c.engageHoldFraction ?? 0.85);
}
/** A point `dist` ahead of where `e` is pointing. */
function forwardPoint(e, dist) {
return { x: e.x + Math.cos(e.heading) * dist, y: e.y + Math.sin(e.heading) * dist };
}
/** Nearest live hostile building within `radius` of (x,y), or null. */
function hostileBuildingNear(state, rules, army, x, y, radius) {
let best = null, bestD = radius;
for (const t of state.entities) {
if (t.dead || !t.isBuilding || t.army === army || !state.armies[t.army]) continue;
const d = surfaceDist(rules, { x, y }, t);
if (d < bestD) { best = t; bestD = d; }
}
return best;
}
/**
* Where a strafing aircraft should point right now, given the thing it is attacking.
*
* The run has two legs. INBOUND steers straight at the target and holds until the aircraft is
* genuinely overhead; EGRESS carries on to a point `overshoot` past it, which is what gives
* the pass its follow-through and the room to come about. The arc of the turn is not scripted
* — it falls out of the unit's turnRate against a speed that never drops.
*/
function strafePoint(state, rules, e, def, t) {
const reach = Math.max(64, e.radius * 2);
if (e.egressing) {
if (Math.hypot(e.egressX - e.x, e.egressY - e.y) > reach) return { x: e.egressX, y: e.egressY };
e.egressing = false;
}
// Two ways to know the pass is spent: overhead, or the target has slipped behind us. The
// second matters because a Fighter covers 15px per tick and can cross a small overhead
// radius between two samples without ever testing inside it.
const bearing = Math.atan2(t.y - e.y, t.x - e.x);
const behind = Math.abs(angleDelta(e.heading, bearing)) > 2.0;
const dist = Math.hypot(t.x - e.x, t.y - e.y);
const overhead = surfaceDist(rules, e, t) <= Math.max(40, e.radius);
if (e.liftFrac >= 1 && (overhead || (behind && dist < def.flight.overshoot))) {
e.egressing = true;
const p = forwardPoint(e, def.flight.overshoot);
// Clamped inside the world, or a run started near the edge parks the aircraft against the
// map boundary waiting to reach a point it can never get to.
e.egressX = Math.max(e.radius, Math.min(state.worldW - e.radius, p.x));
e.egressY = Math.max(e.radius, Math.min(state.worldH - e.radius, p.y));
return { x: e.egressX, y: e.egressY };
}
return { x: t.x, y: t.y };
}
/**
* Orders for an aircraft that cannot stop in the air.
*
* The whole shape of this is: it is either LANDED (still, and unable to shoot) or FLYING (at
* cruise, always going somewhere). There is no third option, so every order resolves to a
* steering point plus a decision about whether to be off the ground at all.
*/
function stepStraferOrders(state, rules, e, def) {
const keepout = def.flight.keepout;
e.buildTargetId = 0;
// A move into a hostile base is not a move, it is an attack run. Landing there would put
// the aircraft stationary and defenceless under the guns of whatever it flew to.
const first = e.orders[0];
if (first && MOVE_LIKE.has(first.type)) {
const dest = orderDestination(e, first);
const foe = hostileBuildingNear(state, rules, e.army, dest.x, dest.y, keepout);
if (foe) e.orders[0] = { type: 'attack', targetId: foe.id };
}
const order = e.orders[0];
if (!order) {
// Idle: put down where it stands, unless that is inside somebody's base — then clear out
// first and land once there is room.
const foe = hostileBuildingNear(state, rules, e.army, e.x, e.y, keepout);
if (foe) {
e._wantAirborne = true;
const dx = e.x - foe.x, dy = e.y - foe.y;
const d = Math.hypot(dx, dy) || 1;
e.movingTo = { x: e.x + (dx / d) * keepout, y: e.y + (dy / d) * keepout };
} else {
e._wantAirborne = false;
e.movingTo = e.liftFrac > 0 ? forwardPoint(e, 400) : null;
}
return;
}
switch (order.type) {
case 'stop':
e.orders.length = 0; e.targetId = 0; e.egressing = false; e.movingTo = null;
break;
case 'hold':
// For something that cannot hover, holding a position means being on the ground at it.
e._wantAirborne = false;
e.movingTo = e.liftFrac > 0 ? forwardPoint(e, 400) : null;
break;
case 'attack':
case 'guard': {
const t = entityById(state, order.targetId);
if (!t || (order.type === 'attack' && !canEngage(def, t))) {
e.orders.shift(); e.egressing = false; e.targetId = 0;
break;
}
e._wantAirborne = true;
e.movingTo = strafePoint(state, rules, e, def, t);
break;
}
case 'move':
case 'attackMove':
case 'patrol': {
const dest = orderDestination(e, order);
const d = Math.hypot(dest.x - e.x, dest.y - e.y);
const arrive = Math.max(rules.constants.arriveSlackPx, e.radius * 2);
e.movingTo = dest;
if (d > arrive) { e._wantAirborne = true; break; }
if (order.type === 'patrol') {
advancePatrol(order);
e._wantAirborne = true;
e.movingTo = orderDestination(e, order);
break;
}
// Arrived. Settle onto the spot — speed ramps down with altitude in stepMovement, so
// this reads as an approach rather than a stop, and the order only clears once down.
e._wantAirborne = false;
if (e.liftFrac <= 0) {
e.orders.shift(); e.movingTo = null; e.egressing = false;
}
break;
}
default:
e.orders.shift();
}
}
/**
* Advance every flight unit's altitude toward where its orders want it. Runs after stepOrders
* (which sets `_wantAirborne`) and before stepMovement (which scales speed by the result).
*/
function stepFlight(state, rules) {
const dt = rules.dt;
for (const e of state.entities) {
if (e.dead || e.isBuilding || e.site) continue;
const def = defOf(rules, e);
if (!def.flight) continue;
// Units that merely hover are up whenever they have something to do — they are allowed to
// stop in mid-air, so their altitude is decoration rather than a rule.
if (!def.strafes) e._wantAirborne = !!(e.orders.length || e.movingTo || e.targetId);
e.pliftFrac = e.liftFrac;
const want = e._wantAirborne ? 1 : 0;
const step = dt / (want ? def.flight.takeoffSec : def.flight.landSec);
// Note the explicit "already there" case. Folding it into the descend branch makes a unit
// holding altitude drop a step and climb it back every other tick, which flickers both the
// sprite and — because cruise speed is scaled by this — the unit's actual velocity.
if (want > e.liftFrac) e.liftFrac = Math.min(want, e.liftFrac + step);
else if (want < e.liftFrac) e.liftFrac = Math.max(want, e.liftFrac - step);
// The instant the wheels leave the ground it becomes an air target, and the instant they
// touch down it stops being one.
e.airborne = e.isAir && e.liftFrac > 0;
}
}
function stepOrders(state, rules) {
const ts = state.tileSize;
for (const e of state.entities) {
if (e.dead || e.site) continue;
if (e.isBuilding) continue;
const def = defOf(rules, e);
// An armed aircraft plays by a different rulebook — it has no stationary state in the air,
// so none of the arrive/hold/close-to-range logic below applies to it.
if (def.strafes) { stepStraferOrders(state, rules, e, def); continue; }
const order = e.orders[0];
if (!order) {
@ -1051,11 +766,7 @@ function stepOrders(state, rules) {
// Without this, giving a repairing Commander a move order left buildTargetId pointing at
// the patient, and the economy kept pouring resources into the repair — for as long as
// the builder happened to stay in range — even though the player had cancelled it.
//
// `guard` is in the list because guarding a structure IS construction work for a builder;
// that case sets and clears the link itself as the target's needs change.
if (order.type !== 'build' && order.type !== 'assist' && order.type !== 'repair'
&& order.type !== 'guard') {
if (order.type !== 'build' && order.type !== 'assist' && order.type !== 'repair') {
e.buildTargetId = 0;
}
@ -1105,7 +816,7 @@ function stepOrders(state, rules) {
if (order.type === 'attackMove' && def.maxRange > 0) {
const foe = entityById(state, e.targetId);
if (foe && foe.isBuilding && !foe.dead && foe.army !== e.army && state.armies[foe.army]
&& surfaceDist(rules, e, foe) <= def.maxRange * holdFractionFor(rules, foe)) {
&& surfaceDist(rules, e, foe) <= def.maxRange * (rules.constants.engageHoldFraction ?? 0.85)) {
e.path = null;
e.movingTo = null;
e.stuckTicks = 0;
@ -1114,16 +825,16 @@ function stepOrders(state, rules) {
}
}
const dest = orderDestination(e, order);
if (!e.path && !e.wantPath && !e.noPath) seekTo(state, e, dest.x, dest.y);
if (!e.path && !e.wantPath && !e.noPath) requestPath(state, e, dest.x, dest.y);
e.movingTo = dest;
const d = Math.hypot(dest.x - e.x, dest.y - e.y);
const slack = Math.max(rules.constants.arriveSlackPx, e.radius * 1.2);
if (d <= slack || (e.noPath && e.stuckTicks > rules.constants.stuckGiveUpSec * rules.constants.tickHz)) {
if (order.type === 'patrol') {
advancePatrol(order);
order.leg = order.leg === 0 ? 1 : 0;
e.path = null; e.noPath = false; e.stuckTicks = 0;
const next = patrolWaypoint(order);
seekTo(state, e, next.x, next.y);
requestPath(state, e, ...(order.leg === 0
? [order.sx ?? order.x, order.sy ?? order.y] : [order.fromX, order.fromY]));
} else {
e.orders.shift();
e.path = null; e.movingTo = null; e.stuckTicks = 0; e.noPath = false;
@ -1135,19 +846,13 @@ function stepOrders(state, rules) {
case 'attack': {
const target = entityById(state, order.targetId);
if (!target) { e.orders.shift(); e.targetId = 0; e.path = null; break; }
// Drop an attack order on something this unit's guns can never reach — a ground-only
// weapon chasing a Fighter would follow it off the map without ever firing a shot.
if (!canEngage(def, target)) { e.orders.shift(); e.targetId = 0; e.path = null; break; }
e.targetId = target.id;
const d = surfaceDist(rules, e, target);
const wantRange = def.maxRange * (target.isBuilding
? holdFractionFor(rules, target)
: (rules.constants.engageHoldFraction ?? 0.85));
const wantRange = def.maxRange * (rules.constants.engageHoldFraction ?? 0.85);
if (d > wantRange) {
if (!e.path && !e.wantPath) seekTo(state, e, target.x, target.y);
if (!e.path && !e.wantPath) requestPath(state, e, target.x, target.y);
e.movingTo = { x: target.x, y: target.y };
// Re-path when the target has drifted well away from where we aimed. Aircraft have
// no path to throw away — seekTo above already retargets them every tick.
// Re-path when the target has drifted well away from where we aimed.
if (e.path && Math.hypot(target.x - e.destX, target.y - e.destY) > ts * 3) {
e.path = null; requestPath(state, e, target.x, target.y);
}
@ -1160,28 +865,16 @@ function stepOrders(state, rules) {
case 'guard': {
const target = entityById(state, order.targetId);
if (!target) { e.orders.shift(); e.buildTargetId = 0; break; }
// A builder guarding a STRUCTURE works on it rather than standing next to it with its
// arms folded: it mends the damage, then lends its build power to whatever the
// structure is making. Escorting is the side effect; the labour is the point, and it
// is what a player means by parking the Commander on a factory.
//
// Which of the two happens is decided in stepEconomy, where repair outranks
// production — so "keep it alive" beats "make it faster" without needing a second
// order type here.
const chore = def.buildPower > 0 && target.isBuilding && guardChore(target);
const reach = chore ? ((def.buildRange ?? 0) + target.radius) * 0.9 : ts * 3;
if (!target) { e.orders.shift(); break; }
const d = Math.hypot(target.x - e.x, target.y - e.y);
if (d > reach) {
if (!e.path && !e.wantPath) seekTo(state, e, target.x, target.y);
if (d > ts * 3) {
if (!e.path && !e.wantPath) requestPath(state, e, target.x, target.y);
e.movingTo = { x: target.x, y: target.y };
e.buildTargetId = 0;
if (e.path && Math.hypot(target.x - e.destX, target.y - e.destY) > ts * 2) {
e.path = null; requestPath(state, e, target.x, target.y);
}
} else {
e.path = null; e.movingTo = null;
e.buildTargetId = chore ? target.id : 0;
}
break;
}
@ -1202,7 +895,7 @@ function stepOrders(state, rules) {
const reach = (def.buildRange ?? 0) + target.radius;
const d = Math.hypot(target.x - e.x, target.y - e.y);
if (d > reach * 0.9) {
if (!e.path && !e.wantPath && !e.noPath) seekTo(state, e, target.x, target.y);
if (!e.path && !e.wantPath && !e.noPath) requestPath(state, e, target.x, target.y);
e.movingTo = { x: target.x, y: target.y };
e.buildTargetId = 0;
} else {
@ -1230,10 +923,7 @@ function stepMovement(state, rules) {
const def = defOf(rules, e);
let tx = null, ty = null;
if (e.isAir) {
// Aircraft fly the straight line. No waypoints to consume, nothing to route around.
if (e.movingTo) { tx = e.movingTo.x; ty = e.movingTo.y; }
} else if (e.path && e.path.length >= 2) {
if (e.path && e.path.length >= 2) {
// Consume waypoints we've reached.
while (e.pathIdx * 2 + 1 < e.path.length) {
const wx = e.path[e.pathIdx * 2], wy = e.path[e.pathIdx * 2 + 1];
@ -1251,22 +941,7 @@ function stepMovement(state, rules) {
tx = e.movingTo.x; ty = e.movingTo.y;
}
if (tx == null) {
// A stationary turretless unit can otherwise fire only within the arc it happens to be
// facing, because nothing ever turns it. That made infantry anti-air close to
// decorative: a strafing aircraft crosses that window in well under a second, and eight
// rocket troopers got off four shots in a minute.
//
// Restricted to AIR targets on purpose. Allowing it against ground targets as well is
// arguably more correct, but measured over the campaign it strengthens massed infantry
// on defence enough to flip m06 from winnable to not — a ground-balance change has no
// business riding along inside an anti-air fix.
if (!e.isBuilding && e.targetId && def.turretFrame == null && def.maxRange > 0) {
const t = entityById(state, e.targetId);
if (t && t.airborne) e.heading = turnToward(e.heading, Math.atan2(t.y - e.y, t.x - e.x), def.turnRate * dt);
}
e.vx = 0; e.vy = 0; e.stuckTicks = 0; continue;
}
if (tx == null) { e.vx = 0; e.vy = 0; e.stuckTicks = 0; continue; }
const dx = tx - e.x, dy = ty - e.y;
const dist = Math.hypot(dx, dy);
@ -1275,35 +950,18 @@ function stepMovement(state, rules) {
const want = Math.atan2(dy, dx);
e.heading = turnToward(e.heading, want, def.turnRate * dt);
// Terrain slows movement: speed scales by the inverse of the tile's move cost. Aircraft
// are over it, not on it, so they fly at a flat speed everywhere.
let terrainMul = 1;
if (!e.isAir) {
const ti = worldToTileY(nav, e.y) * state.w + worldToTileX(nav, e.x);
const tcost = rules.moveClasses[def.moveClass].costByTerrainIndex[state.terrain[ti]] ?? 1;
terrainMul = tcost ? 1 / tcost : 1;
}
// Terrain slows movement: speed scales by the inverse of the tile's move cost.
const ti = worldToTileY(nav, e.y) * state.w + worldToTileX(nav, e.x);
const tcost = rules.moveClasses[def.moveClass].costByTerrainIndex[state.terrain[ti]] ?? 1;
const terrainMul = tcost ? 1 / tcost : 1;
let speed, step;
if (def.strafes) {
// A strafing aircraft holds cruise speed no matter what it is doing: the ONLY thing that
// changes in the air is its heading, which is what turns a course correction into an arc
// instead of a pirouette. Speed tracks altitude, so it accelerates off the pad and slows
// into a landing, and it is never allowed to creep or hang.
speed = def.speed * e.liftFrac;
// No clamp to the remaining distance while airborne — clamping is what would let it stop
// dead on top of its target instead of carrying through the pass. It does clamp on the
// way down, so a landing settles on its spot rather than skidding past it.
step = e.liftFrac >= 1 ? speed * dt : Math.min(speed * dt, dist);
} else {
// Slow down while still swinging round, and ease into the final waypoint.
const misalign = Math.abs(angleDelta(e.heading, want));
const turnMul = misalign > 1.2 ? 0.25 : (misalign > 0.5 ? 0.65 : 1);
const arriveMul = dist < e.radius * 2 ? Math.max(0.25, dist / (e.radius * 2)) : 1;
speed = def.speed * terrainMul * turnMul * arriveMul;
step = Math.min(speed * dt, dist);
}
if (step <= 0) { e.vx = 0; e.vy = 0; continue; }
// Slow down while still swinging round, and ease into the final waypoint.
const misalign = Math.abs(angleDelta(e.heading, want));
const turnMul = misalign > 1.2 ? 0.25 : (misalign > 0.5 ? 0.65 : 1);
const arriveMul = dist < e.radius * 2 ? Math.max(0.25, dist / (e.radius * 2)) : 1;
const speed = def.speed * terrainMul * turnMul * arriveMul;
const step = Math.min(speed * dt, dist);
e.vx = Math.cos(e.heading) * step / dt;
e.vy = Math.sin(e.heading) * step / dt;
e.x += Math.cos(e.heading) * step;
@ -1392,9 +1050,6 @@ function stepSeparation(state, rules) {
hash.query(a.x, a.y, a.radius * 2 + 32, near);
for (const b of near) {
if (b.id <= a.id) continue; // handle each pair once, deterministically
// Different layers never touch. Aircraft still go into the hash so gunners can find
// them, but a Fighter overflying a tank column must not shove it apart.
if (a.isAir !== b.isAir) continue;
const dx = b.x - a.x, dy = b.y - a.y;
const minD = a.radius + b.radius;
let d2 = dx * dx + dy * dy;
@ -1431,13 +1086,6 @@ function stepSeparation(state, rules) {
const ts = state.tileSize;
for (const e of movers) {
const def = defOf(rules, e);
// Aircraft are only bounded by the map edge — nothing on the grid blocks them, and the
// clamp below would otherwise drag one off a cliff or a building it was legitimately over.
if (e.isAir) {
e.x = Math.max(e.radius, Math.min(state.worldW - e.radius, e.x));
e.y = Math.max(e.radius, Math.min(state.worldH - e.radius, e.y));
continue;
}
const need = clearanceFor(e.radius, ts);
const cl = nav.clearance[def.moveClass];
const tx = worldToTileX(nav, e.x), ty = worldToTileY(nav, e.y);
@ -1480,20 +1128,11 @@ function stepCombat(state, rules) {
const def = defOf(rules, e);
if (!def.weaponDefs?.length) continue;
// Reloads run down on the pad too, so an aircraft rearms while it is grounded.
for (let wi = 0; wi < e.reload.length; wi++) if (e.reload[wi] > 0) e.reload[wi]--;
// A grounded aircraft cannot shoot. This is the flip side of not being able to hover:
// sitting still and being useful are mutually exclusive for anything with wings.
if (def.flight && e.liftFrac < 1) { e.targetId = 0; continue; }
// Staggered acquisition — the single biggest CPU lever in the sim. Re-scan only every
// retargetPeriodTicks, phase-shifted per unit so the cost spreads across ticks.
//
// A strafing aircraft is deliberately NOT pinned to its ordered target. Its orders decide
// where it flies; its guns take whatever is in range on the way past, which is the whole
// point of a unit that crosses the battlefield rather than parking in front of one thing.
const holdOrder = e.orders[0]?.type === 'attack' && !def.strafes;
const holdOrder = e.orders[0]?.type === 'attack';
let target = entityById(state, e.targetId);
if (target && !inWeaponRange(rules, e, def, target)) target = null;
const due = ((state.tick + e.id) % c.retargetPeriodTicks) === 0;
@ -1527,7 +1166,6 @@ function stepCombat(state, rules) {
if (e.reload[wi] > 0) continue;
// Manual weapons (the D-Gun) only fire when this exact target was ordered attacked.
if (w.manual && !(holdOrder && e.orders[0].targetId === target.id)) continue;
if (!weaponHitsDomain(w, target.airborne)) continue;
const dist = surface;
if (dist > w.range || dist < (w.minRange ?? 0)) continue;
if (!aligned) continue;
@ -1547,7 +1185,6 @@ function stepCombat(state, rules) {
for (let wi = 0; wi < def.weaponDefs.length; wi++) {
const w = def.weaponDefs[wi];
if (w.manual) continue;
if (!weaponHitsDomain(w, target.airborne)) continue;
if (e.burstLeft[wi] > 0 && e.reload[wi] === 0) {
fireWeapon(state, rules, e, w, target, facing, aimPt);
e.burstLeft[wi]--;
@ -1588,7 +1225,6 @@ export function aimPointOn(rules, from, target) {
function inWeaponRange(rules, e, def, target) {
if (target.dead) return false;
if (!canEngage(def, target)) return false;
return surfaceDist(rules, e, target) <= def.maxRange;
}
@ -1604,9 +1240,6 @@ function acquireTarget(state, rules, e, def, hash, near) {
if (d > def.maxRange) return;
let score = -Infinity;
for (const w of def.weaponDefs) {
// A weapon that can't reach the target's domain contributes nothing, so a unit with no
// AA at all never scores an aircraft and simply ignores it.
if (!weaponHitsDomain(w, t.airborne)) continue;
if (d > w.range || d < (w.minRange ?? 0)) continue;
const mul = w.armorMul?.[rules.defById[t.defId].armorClass] ?? 1;
const s = (w.damage * mul) / (1 + d / w.range);
@ -1727,14 +1360,11 @@ function stepProjectiles(state, rules) {
p.x = p.px + (p.x - p.px) * s; p.y = p.py + (p.y - p.py) * s;
}
}
// Direct contact with any hostile it passes through — in a domain this weapon can hit.
// Without the domain test a bomb would detonate on the first aircraft whose airspace it
// crossed, and an AA burst would stop dead on the tank underneath its target.
// Direct contact with any hostile it passes through.
if (!detonate) {
let bestT = Infinity, hit = null;
for (const e of state.entities) {
if (e.dead || e.site || e.army === p.army) continue;
if (!weaponHitsDomain(w, e.airborne)) continue;
if (segDistSq(p.px, p.py, p.x, p.y, e.x, e.y) > e.radius * e.radius) continue;
const s = segClosestT(p.px, p.py, p.x, p.y, e.x, e.y);
if (s < bestT) { bestT = s; hit = e; }
@ -1751,7 +1381,6 @@ function stepProjectiles(state, rules) {
else {
for (const e of state.entities) {
if (e.dead || e.army === p.army) continue;
if (!weaponHitsDomain(w, e.airborne)) continue;
if (Math.hypot(e.x - p.x, e.y - p.y) <= e.radius) {
applyDamage(state, rules, e, w.damage * (w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1), { army: p.army, id: p.ownerId });
break;
@ -1787,8 +1416,6 @@ function applyAoe(state, rules, x, y, w, source) {
for (const e of state.entities) {
if (e.dead) continue;
if (e.army === source.army && !rules.constants.friendlyFire) continue;
// Splash respects the domain too — a bomb's blast radius stays on the ground.
if (!weaponHitsDomain(w, e.airborne)) continue;
// Falloff is measured to the SURFACE, so a shell that lands squarely on a building's
// face counts as a direct hit wherever along that face it strikes. Measuring from the
// centre circle instead scored an off-axis wall hit as 32px away and cut its damage by
@ -1854,7 +1481,6 @@ function killEntity(state, rules, e, source) {
state.events.push({ t: 'bigExplosion', x: e.x, y: e.y, radius: ex.radius });
for (const o of state.entities) {
if (o.dead || o.id === e.id) continue;
if (o.airborne) continue; // a ground blast doesn't reach whatever is flying over it
const d = Math.hypot(o.x - e.x, o.y - e.y) - o.radius;
if (d > ex.radius) continue;
const scale = 1 - Math.max(0, Math.min(1, d / ex.radius)) * 0.7;
@ -1962,7 +1588,6 @@ export function tick(state, rules) {
stepEconomy(state, rules);
stepOrders(state, rules);
stepFlight(state, rules);
servicePathQueue(state, rules);
stepMovement(state, rules);
stepSeparation(state, rules);
@ -2065,10 +1690,6 @@ export function serialize(state) {
lastDamagedTick: e.lastDamagedTick,
reload: e.reload, burstLeft: e.burstLeft,
terrainBonus: e.terrainBonus,
// Altitude decides whether a unit may fire, so a save that dropped it would resume with
// every aircraft grounded and mute for a second.
liftFrac: Math.round(e.liftFrac * 1000) / 1000,
egressing: e.egressing, egressX: e.egressX, egressY: e.egressY,
})),
// Projectiles are sub-second transients; dropping them costs nothing and saves bytes.
});
@ -2116,10 +1737,6 @@ export function deserialize(rules, raw) {
const e = baseEntity(state, s.army, def);
Object.assign(e, s);
e.px = e.x; e.py = e.y; e.pheading = e.heading; e.pturretRot = e.turretRot;
e.pliftFrac = e.liftFrac;
// Derived from the restored altitude, so a save resumes with aircraft in the same domain
// they were in rather than every one of them briefly shootable by anything.
e.airborne = e.isAir && e.liftFrac > 0;
e.radius = def.isBuilding ? def.radius : (def.radius ?? 16);
e.path = null; e.pathIdx = 0; e.wantPath = false;
state.entities.push(e);

View File

@ -228,17 +228,10 @@ export function generateMap(rules, opts = {}) {
return map;
}
/**
* The move class with the most restrictive terrain table — if it can get through, all can.
*
* Air classes are excluded outright. They pass everything by definition, so the corridor this
* picks for them would be no corridor at all, and the connectivity guarantee — the whole point
* of the call — would silently become vacuous for every ground unit on the 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)) {
if (spec.air) continue;
const score = spec.costByTerrainIndex.reduce((s, c) => s + (c == null ? 0 : 1 / c), 0);
if (score < worstScore) { worstScore = score; worst = mc; }
}

View File

@ -12,9 +12,6 @@ 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']);
// Which layer a unit occupies. `air` units ignore the nav grid entirely, never collide with
// ground units, and can only be shot by weapons that list `air` in their `targets`.
export const UNIT_DOMAINS = new Set(['ground', 'air']);
// A unit/building must always be able to see a bit past its own guns, or it ends up firing
// into fog it hasn't revealed. Applied as a floor over whatever `sight` the def declares.
@ -24,9 +21,8 @@ const SIGHT_RANGE_MARGIN_TILES = 2;
// this set is duplicated there and the verify script asserts the two agree.
export const PROC_SHAPES = new Set([
'commander', 'infantry', 'sniper', 'rocketTrooper', 'jeep', 'tank', 'rockettank', 'constructor',
'fighter', 'bomber', 'hoverConstructor',
'energyGen', 'massGen', 'barracks', 'vehiclePlant', 'laserTower', 'missileLauncher',
'advancedVehiclePlant', 'airfield',
'advancedVehiclePlant',
]);
function fail(msg) {
@ -122,14 +118,7 @@ export function compileRules(json) {
if (!terrainById[key]) fail(`moveClass "${mc}" references unknown terrain "${key}"`);
}
// Flat per-terrain-index cost array — the pathfinder's hot path reads this.
//
// The COST TABLE is authoritative, not `terrain.blocksMove`: a null cost is impassable and
// a positive one is passable, whatever the terrain says. `blocksMove` describes the ordinary
// ground unit, and every one of those declares null for cliff and water anyway — but a hover
// class that crosses water, or an air class that crosses everything, is then a JSON-only
// change instead of needing a per-terrain exception flag.
spec.costByTerrainIndex = terrain.map((t) => spec.cost[t.id] ?? null);
spec.air = spec.air === true;
spec.costByTerrainIndex = terrain.map((t) => (t.blocksMove ? null : spec.cost[t.id]));
}
const moveClassSet = new Set(Object.keys(moveClasses));
@ -189,8 +178,6 @@ export function compileRules(json) {
w.rangeSq = w.range * w.range;
w.minRangeSq = (w.minRange ?? 0) * (w.minRange ?? 0);
w.targetsAir = (w.targets ?? ['ground']).includes('air');
w.targetsGround = (w.targets ?? ['ground']).includes('ground');
if (!w.targetsAir && !w.targetsGround) fail(`weapon "${w.id}" targets neither ground nor 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.
@ -207,15 +194,6 @@ export function compileRules(json) {
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}"`);
u.domain = u.domain ?? 'ground';
if (!UNIT_DOMAINS.has(u.domain)) fail(`unit "${u.id}" has unknown domain "${u.domain}"`);
u.isAir = u.domain === 'air';
// The domain and the move class have to agree: the sim reads `isAir` in its hot loops but
// spawning and the terrain-speed lookup still go through the move class, and a unit that
// flew while pathing on treads would be wrong in whichever of the two you didn't check.
if (u.isAir !== moveClasses[u.moveClass].air) {
fail(`unit "${u.id}" is domain "${u.domain}" but its moveClass "${u.moveClass}" is not`);
}
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}"`);
@ -246,33 +224,6 @@ export function compileRules(json) {
u.selfHeal.hpPerTick = (u.hp * frac) / 60 / c.tickHz;
u.selfHeal.pauseTicks = Math.round(pause * c.tickHz);
}
// Purely cosmetic: how far the renderer lifts this unit off the ground and how long it
// takes. The simulation has NO notion of height — a `flight` unit is still exactly where
// its x/y say it is for movement, collision, targeting and clicking. It is declared
// separately from `domain` on purpose: the Hover Constructor floats visibly but is a
// ground unit that rifles can hit and that has to path around cliffs.
// An armed aircraft STRAFES: it cannot stop in the air, so it makes firing passes and
// lands to sit still. An unarmed one (or a merely hovering ground unit like the Hover
// Constructor) keeps the simple stop-where-you-are behaviour.
u.strafes = u.isAir && (u.weapons ?? []).length > 0;
if (u.flight) {
for (const k of ['height', 'takeoffSec']) {
if (!(u.flight[k] > 0)) fail(`unit "${u.id}" flight.${k} must be positive`);
}
if (u.flight.landSec != null && !(u.flight.landSec > 0)) {
fail(`unit "${u.id}" flight.landSec must be positive when present`);
}
u.flight.landSec = u.flight.landSec ?? u.flight.takeoffSec;
if (u.strafes) {
// How far past the target it carries before turning about, and how close to a hostile
// structure it is willing to put down. Both are distances in pixels.
for (const k of ['overshoot', 'keepout']) {
if (!(u.flight[k] > 0)) fail(`strafing unit "${u.id}" needs a positive flight.${k}`);
}
}
} else if (u.isAir) {
fail(`air unit "${u.id}" needs a flight block, or it will render sitting on the ground`);
}
u.spritePx = u.spritePx ?? sc.radius * 2;
// Losing one of these ends the match under the default victory rule, so it is def data
// rather than a hardcoded "commander" id — a second Commander-class unit is a JSON change.
@ -281,10 +232,6 @@ export function compileRules(json) {
}
const unitById = indexById(units);
const commanderUnits = units.filter((u) => u.isCommander);
// An air roster with nothing able to shoot at it isn't a unit type, it's an auto-win.
if (units.some((u) => u.isAir) && !weapons.some((w) => w.targetsAir)) {
fail('air units exist but no weapon targets the air domain');
}
const buildings = json.buildings ?? [];
requireUnique(buildings, 'building');
@ -304,7 +251,6 @@ export function compileRules(json) {
if (!weaponById[wid]) fail(`building "${b.id}" references unknown weapon "${wid}"`);
}
b.isBuilding = true;
b.isAir = false;
b.radius = (Math.max(b.footprint.w, b.footprint.h) * c.tileSize) / 2;
// True half-extents. `radius` is a circle around the centre, which understates a
// building's reach along its diagonals and is what made units drive into the walls of
@ -412,25 +358,6 @@ export function compileRules(json) {
};
}
/** Can this weapon engage something in the given domain at all? */
export function weaponHitsDomain(weapon, targetIsAir) {
return targetIsAir ? weapon.targetsAir : weapon.targetsGround;
}
/**
* Does `def` carry ANY weapon that can reach `target`'s domain? Anything that can't should
* never acquire, chase or fire at it — a rifleman ordered to attack a Fighter would otherwise
* follow it across the map forever, never able to shoot.
*
* Reads `airborne`, not `isAir`: a landed aircraft is an ordinary ground target, so this flips
* for the same unit as it takes off and touches down.
*/
export function canEngage(def, target) {
const air = !!target.airborne;
for (const w of def.weaponDefs ?? []) if (weaponHitsDomain(w, air)) return true;
return false;
}
/** Damage multiplier of `weapon` against a defender of `armorClass`. */
export function armorMul(weapon, armorClass) {
return weapon.armorMul?.[armorClass] ?? 1;

View File

@ -14,7 +14,6 @@
import { ensureSheets, sheetFrameSize } from './TAArt.js';
import { colorInt } from './TAFx.js';
import { patrolWaypoint } from './TALogic.js';
const CHUNK_PX = 1024;
const ZOOMS = [0.5, 0.7, 1.0, 1.4];
@ -28,35 +27,10 @@ const ORDER_COLORS = {
};
export const DEPTHS = {
terrain: 0, decal: 5, shadow: 6, fxUnder: 8, ghost: 12,
selection: 15, actor: 20, air: 40, bars: 55, projectile: 60, fxOver: 65, fog: 80,
terrain: 0, decal: 5, fxUnder: 8, ghost: 12,
selection: 15, actor: 20, bars: 55, projectile: 60, fxOver: 65, fog: 80,
};
// Altitude, for units whose def carries a `flight` block (everything the Airfield builds).
//
// The height itself is simulation state (`e.liftFrac`) because it gates whether a unit may
// fire; the renderer only turns that number into pixels. The entity's x/y stay on the ground
// for movement, collision, targeting and clicking — only the SPRITE is lifted — so a unit is
// still exactly where you see its shadow.
//
// The shadow stays pinned to the true ground position while the body rises off it, so the gap
// between the two IS the altitude cue. At rest they are coincident and the shadow is fully
// transparent, which reads as the unit sitting on the ground.
const SHADOW_ALPHA = 0.5; // opacity at full altitude
const SHADOW_SHRINK = 0.14; // how much the shadow tightens as the unit climbs
// Production dials on a factory's top corners: LEFT is the whole queue, RIGHT is the unit
// currently on the bench. Sized to read at a glance from a normal camera height without
// covering the building art — a 30px disc on a 192px structure.
const PIE_R = 15;
const PIE_INSET = 6; // gap from the footprint corner
const PIE_QUEUE_COLOR = 0xffd27a; // amber — the batch
const PIE_JOB_COLOR = 0x8ad4ff; // build blue, same language as sites and nanolathe
const TAU = Math.PI * 2;
/** Smoothstep, so a takeoff eases out of the ground and into the climb instead of snapping. */
function smoothstep(t) { return t * t * (3 - 2 * t); }
export default class TAWorldView {
constructor(scene, rules, art, state, playerArmy) {
this.scene = scene;
@ -94,19 +68,13 @@ export default class TAWorldView {
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.gPies = 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.gPies);
this._addWorld(this.gGhost);
this.sprites = new Map(); // entity id -> { img, turret }
// Ids the Commander-death cascade has already blown up. The entities are still in the
// simulation — the match ended the moment the Commander died and nothing steps after that
// — so this is the view's own record of what it has stopped drawing.
this.vaporised = new Set();
this.selection = new Set();
this.queueSelection = null; // { unitId, index } — a highlighted entry in a drawn order queue
this.placement = null; // { def, tx, ty, legal }
@ -381,21 +349,7 @@ export default class TAWorldView {
turret.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
this._addWorld(turret);
}
// Anything that leaves the ground gets a flattened black copy of its own frame beneath it.
let shadow = null;
if (def.flight) {
shadow = this.scene.add.image(e.x, e.y, key, def.frame);
shadow.setTint(0x000000).setAlpha(0).setDepth(DEPTHS.shadow);
this._addWorld(shadow);
}
// `alt` is the current altitude as a 0..1 fraction of def.flight.height, eased toward its
// target every frame in render(). Newly built units start on the ground.
s = {
img, turret, final, shadow, defId: e.defId,
alt: 0, baseScale: img.scaleX,
batchDone: 0, batchPrev: 0, // production-dial accounting, see _drawProductionPies
};
s = { img, turret, final, defId: e.defId };
this.sprites.set(e.id, s);
return s;
}
@ -406,7 +360,6 @@ export default class TAWorldView {
s.img.destroy();
s.turret?.destroy();
s.final?.destroy();
s.shadow?.destroy();
this.sprites.delete(id);
}
@ -425,14 +378,11 @@ export default class TAWorldView {
if (this.fogEnabled && this.fogDirty) { this._redrawFog(); this.fogDirty = false; }
const live = new Set();
const gSel = this.gSelection, gBar = this.gBars, gPie = this.gPies;
gSel.clear(); gBar.clear(); gPie.clear();
const gSel = this.gSelection, gBar = this.gBars;
gSel.clear(); gBar.clear();
for (const e of state.entities) {
if (e.dead) continue;
// Left out of `live`, so the reaper below destroys its sprites and everything drawn
// inside this loop for it — rings, bars, dials — goes with them.
if (this.vaporised.size && this.vaporised.has(e.id)) continue;
live.add(e.id);
const def = rules.defById[e.defId];
const shown = this.visibleToPlayer(e);
@ -440,37 +390,16 @@ export default class TAWorldView {
s.img.setVisible(shown);
if (s.turret) s.turret.setVisible(shown);
if (s.final) s.final.setVisible(shown);
if (s.shadow) s.shadow.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);
// Altitude comes from the SIM — it decides whether a unit may fire, so the renderer must
// not run a second copy of it that could disagree. Interpolated between ticks exactly
// like position, then eased so a takeoff rolls off the ground instead of snapping.
let lift = 0;
if (def.flight) {
s.alt = e.pliftFrac + (e.liftFrac - e.pliftFrac) * alpha;
lift = def.flight.height * smoothstep(s.alt);
}
s.img.setPosition(x, y - lift);
s.img.setPosition(x, y);
if (!def.isBuilding) s.img.setRotation(heading);
// Y-sorted actor band; buildings sit just under units sharing a row. Aircraft ride in
// their own band above ALL of it — a Fighter must never disappear behind a factory.
// Depth comes from the GROUND y, not the lifted one, so climbing never re-sorts a unit
// against its neighbours.
const band = def.isAir ? DEPTHS.air : DEPTHS.actor;
s.img.setDepth(band + (y / state.worldH) * 10 + (def.isBuilding ? 0 : 0.05));
if (s.shadow) {
// Pinned to the true position and fading in as the body leaves it — the separation
// between the two sprites is the whole effect.
s.shadow.setPosition(x, y).setRotation(heading);
s.shadow.setAlpha(SHADOW_ALPHA * s.alt);
s.shadow.setScale(s.baseScale * (1 - SHADOW_SHRINK * s.alt));
}
// 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. Queued (progress still 0) sits at 50%
// opacity; once work starts the wireframe fades 100%->0% over construction while
@ -514,13 +443,11 @@ export default class TAWorldView {
if (s.turret) {
const tr = lerpAngle(e.pturretRot, e.turretRot, alpha);
s.turret.setPosition(x, y - lift).setRotation(tr);
s.turret.setDepth((def.isAir ? DEPTHS.air : DEPTHS.actor) + (y / state.worldH) * 10 + 0.08);
s.turret.setPosition(x, y).setRotation(tr);
s.turret.setDepth(DEPTHS.actor + (y / state.worldH) * 10 + 0.08);
}
// Selection ring — drawn on the GROUND, under a hovering unit rather than around it.
// The ring is what tells the player where a unit actually is for ordering purposes, and
// for anything airborne that is its shadow's position, not its sprite's.
// 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);
@ -528,19 +455,14 @@ export default class TAWorldView {
}
// Health / build bars — only when they say something, so we're not stroking 300 of them.
// These ride up with the body: a bar left on the ground reads as belonging to whatever
// the aircraft is flying over.
const barY = y - lift - e.radius - 10;
const damaged = e.hp < e.maxHp - 0.5;
if (e.site) {
drawBar(gBar, x, barY, e.radius * 1.8, e.progress, 0x8ad4ff);
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, barY, e.radius * 1.8, frac, col);
drawBar(gBar, x, y - e.radius - 10, e.radius * 1.8, frac, col);
}
this._drawProductionPies(gPie, e, def, x, y);
}
for (const id of [...this.sprites.keys()]) if (!live.has(id)) this._releaseSprite(id);
@ -589,53 +511,6 @@ export default class TAWorldView {
return { nanoLinks, projectiles };
}
/**
* Production dials on a working factory: the whole queue on the top-LEFT corner, the unit
* currently on the bench on the top-RIGHT.
*
* Only the viewing player's own factories get these. They are a production-management
* affordance, and putting them on enemy structures would hand out exact intel on what the
* opponent is building and how close it is — information no other part of the HUD gives.
* Drop the `e.army` test if you'd rather see them on everything.
*/
_drawProductionPies(g, e, def, x, y) {
if (!e.isBuilding || e.army !== this.playerArmy) return;
const s = this.sprites.get(e.id);
// An idle factory shows nothing, and forgets the batch it just finished so the next one
// starts its dial from zero.
if (e.site || !e.queue?.length) { if (s) { s.batchDone = 0; s.batchPrev = 0; } return; }
if (!s) return;
// Queue progress is measured in BUILD SECONDS, not items: three Jeeps and three Bombers
// are not the same amount of work, and a dial that treated them as equal would crawl and
// then leap.
const workOf = (id) => (this.rules.unitById[id]?.buildTime ?? 0);
let remaining = 0;
for (const item of e.queue) remaining += workOf(item.defId) * item.count;
remaining -= (e.jobProgress ?? 0) * workOf(e.queue[0].defId);
remaining = Math.max(0, remaining);
// Accumulate work COMPLETED, view-side, rather than deriving progress from the queue as
// it stands. The queue array SHRINKS as units pop out of it, so "done / still queued"
// would snap the dial back to zero on every completion; and remembering the batch's
// original size instead would throw that progress away the moment the player queued more.
// Tracking the drop in remaining work handles both: finished work is never lost, and new
// work honestly enlarges the denominator.
//
// A batch already in flight when a save is loaded re-baselines to zero here, since the
// sim doesn't record how much of it was done. It corrects itself as the batch finishes.
const prev = s.batchPrev ?? 0;
s.batchDone = (s.batchDone ?? 0) + Math.max(0, prev - remaining);
s.batchPrev = remaining;
const total = s.batchDone + remaining;
if (!(total > 0)) return;
const hw = def.halfW ?? e.radius, hh = def.halfH ?? e.radius;
const cy = y - hh + PIE_INSET + PIE_R;
drawPie(g, x - hw + PIE_INSET + PIE_R, cy, PIE_R, s.batchDone / total, PIE_QUEUE_COLOR);
drawPie(g, x + hw - PIE_INSET - PIE_R, cy, PIE_R, e.jobProgress ?? 0, PIE_JOB_COLOR);
}
/**
* Draw the pending order queue for every selected unit: a waypoint chain from the unit
* through each queued order, coloured by order type.
@ -666,34 +541,13 @@ export default class TAWorldView {
const col = ORDER_COLORS[o.type] ?? 0x9fe8ff;
const active = i === 0;
g.lineStyle(active ? 2.5 : 1.5, col, active ? 0.9 : 0.5);
// A patrol draws as the whole closed circuit rather than a single waypoint — the
// player built that route point by point and needs to see it as one shape. Its first
// point is where the unit was standing when the order was given, so the loop closing
// back on itself is literal rather than decorative.
const route = o.type === 'patrol' && o.route?.length >= 4 ? o.route : null;
if (route) {
const n = route.length >> 1;
g.lineBetween(px, py, pt.x, pt.y);
for (let k = 0; k < n; k++) {
const a = k * 2, b = ((k + 1) % n) * 2;
g.lineBetween(route[a], route[a + 1], route[b], route[b + 1]);
}
g.fillStyle(col, active ? 0.9 : 0.6);
for (let k = 0; k < n; k++) g.fillCircle(route[k * 2], route[k * 2 + 1], active ? 5 : 4);
// The waypoint being headed for right now gets a ring, so a long route still shows
// which way round the unit is going.
g.lineBetween(px, py, pt.x, pt.y);
if (o.type === 'build') {
g.lineStyle(2, col, 0.9);
g.strokeCircle(pt.x, pt.y, 9);
g.strokeRect(pt.x - 14, pt.y - 14, 28, 28);
} else {
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);
}
g.fillStyle(col, active ? 0.9 : 0.6);
g.fillCircle(pt.x, pt.y, active ? 5 : 4);
}
// A clicked-and-held queue entry gets a bright ring so its selection is unambiguous
// before the player commits to deleting it.
@ -708,14 +562,7 @@ export default class TAWorldView {
/** World position an order points at, or null if it has no meaningful location. */
_orderPoint(o) {
// A patrol's location is whichever waypoint it is currently running to, not the point the
// player happened to click first — the sim's own reading of the order, so the marker and
// the unit never disagree about where it is going.
if (o.type === 'patrol') {
const p = patrolWaypoint(o);
return Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null;
}
if (o.type === 'move' || o.type === 'attackMove') {
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;
}
@ -816,26 +663,6 @@ export default class TAWorldView {
}
}
/**
* A progress dial: dark disc, filled wedge sweeping clockwise from 12 o'clock, thin rim.
*
* The backing disc is what makes this legible over any building art — without it the wedge
* competes with whatever is painted underneath and reads as noise rather than as a gauge.
*/
function drawPie(g, cx, cy, r, frac, color) {
const t = Math.max(0, Math.min(1, frac));
g.fillStyle(0x0b0e12, 0.62);
g.fillCircle(cx, cy, r);
if (t > 0.001) {
const start = -Math.PI / 2;
g.fillStyle(color, 0.92);
g.slice(cx, cy, r - 2.5, start, start + t * TAU, false);
g.fillPath();
}
g.lineStyle(1.5, color, 0.75);
g.strokeCircle(cx, cy, r);
}
function drawBar(g, cx, y, w, frac, color) {
const h = 4;
const x = cx - w / 2;

View File

@ -30,18 +30,6 @@ 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
/** What the toast says when a command is armed and waiting for a world click. */
const COMMAND_PROMPTS = {
move: 'Move: pick a point',
attack: 'Attack: pick an enemy',
attackMove: 'Attack-move: pick a target point',
patrol: 'Patrol: pick a point — hold CTRL and keep clicking for a route',
guard: 'Guard: pick a friendly unit',
repair: 'Repair: pick a damaged friendly',
assist: 'Assist: pick a site or a working factory',
setRally: 'Rally: pick a point',
};
export default class TotalAnnihilationGame extends Phaser.Scene {
constructor() { super('TotalAnnihilationGame'); }
@ -56,12 +44,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
this.selection = new Set();
this.groups = new Map();
this.placement = null;
// An order armed by a command button (or its hotkey) and waiting for a world click to say
// where or on what. See COMMAND_PROMPTS for the set.
this.pendingCommand = null;
this.chainedCommand = false;
this._deathSeq = null;
this._commanderDeath = null;
this.pendingCommand = null; // 'attackMove' | 'patrol' | 'guard' | 'attack'
this.simSpeed = 1;
this.settings = readJson(SETTINGS_KEY, { edgeScroll: true, fog: true });
this.lastSfxAt = {}; // per-sound-key gate — see _throttledSfx
@ -200,7 +183,6 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
this.hud = new TAHud(this, this.rules, state, this.view, playerArmy, {
onBuildPick: (def) => this._beginPlacement(def),
onProduce: (def, n) => this._enqueue(def, n),
onCommand: (id) => this._pickCommand(id),
onMinimapJump: (x, y) => this.view.centerOn(x, y),
});
if (this.music?._objs?.length) this.view.ignoreOnWorldCam(this.music._objs);
@ -213,8 +195,6 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
}
_endMatch() {
this._deathSeq = null;
this._commanderDeath = null;
this.hud?.destroy(); this.hud = null;
this.fx?.destroy(); this.fx = null;
this.view?.destroy(); this.view = null;
@ -247,12 +227,6 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
this._cancelPlacement();
this.hud.toast('Build queue ended');
}
// Same for a chained order run — letting go of CTRL is how the player says "that's the
// whole patrol route".
if (this.chainedCommand && !this._queueModDown()) {
this.chainedCommand = false;
this.pendingCommand = null;
}
// 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.
@ -270,17 +244,14 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
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.pendingCommand);
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 (this._deathSeq) this._stepDeathSequence(time);
// The result screen waits for the Commander's send-off. Nothing is stepping the simulation
// by now — tick() returns immediately once state.over is set — so this only delays the UI.
if (st.over && !this._deathSeq) this._finish(st.over);
if (st.over) this._finish(st.over);
}
_onSimEvent(ev) {
@ -296,13 +267,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
if (def?.isCommander) {
// Play the nuclear explosion at full volume for maximum impact
this._throttledSfx('sfx-ta-nuclear', 300, 1);
// Kept for the armyEliminated event that follows in this same tick — that is the one
// that knows whether losing the Commander actually ended this army, which under the
// annihilation rule it does not.
this._commanderDeath = { army: ev.army, x: ev.x, y: ev.y };
} else if (def?.moveClass) {
// Infantry get the flesh-and-blood cue; anything with an engine — tracked, hovering
// or flying — gets the machinery one.
this._throttledSfx(def.moveClass === 'foot' ? 'sfx-ta-unit-loss' : 'sfx-ta-vehicle-loss', 120);
}
}
@ -313,9 +278,6 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
if (ev.t === 'armyEliminated') {
const name = this.rules.armies[ev.army]?.name ?? 'Enemy';
const lost = ev.reason === 'commanderLost';
if (lost && this._commanderDeath?.army === ev.army) {
this._startDeathSequence(ev.army, this._commanderDeath.x, this._commanderDeath.y);
}
if (ev.army !== this.playerArmy) {
this.hud.toast(lost ? `${name} Commander destroyed` : `${name} eliminated`, '#9ce6a0');
} else if (lost) {
@ -348,91 +310,6 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
}
}
// -------------------------------------------------------------------------
// Commander death sequence
// -------------------------------------------------------------------------
/**
* The Commander going up is the loudest thing in this game, so it gets the longest cue: its
* own detonation, then every visible thing that army owned cooking off in a wave from the
* epicentre, then one map-wide flash. The whole thing is timed to the length of the nuclear
* sample, so the picture and the sound finish together, and the result screen is held back
* until it has played out.
*
* Purely cosmetic. checkResult already decided the match — nothing here touches state, which
* is why it can be skipped or retimed freely without the outcome moving.
*/
_startDeathSequence(army, x, y) {
if (this._deathSeq || !this.match || !this.view) return;
const now = this.time.now;
const total = this._sfxDurationMs('sfx-ta-nuclear', 10000);
// Only what the player can actually see, ordered outward from the epicentre so the cascade
// reads as a wave leaving the Commander rather than as random popping.
const doomed = this.match.entities
.filter((e) => !e.dead && e.army === army && this.view.visibleToPlayer(e))
.map((e) => ({
id: e.id, x: e.x, y: e.y, isBuilding: e.isBuilding, radius: e.radius,
d: Math.hypot(e.x - x, e.y - y),
}))
.sort((a, b) => a.d - b.d || a.id - b.id);
const from = now + total * 0.14;
const span = total * 0.58;
doomed.forEach((d, i) => {
d.at = from + (doomed.length > 1 ? (i / (doomed.length - 1)) * span : 0);
});
this._deathSeq = { x, y, doomed, next: 0, novaAt: now + total * 0.76, endAt: now + total, nova: false };
// Open on something clearly bigger than an ordinary death, since the def's own explosion
// is sized for the damage it deals rather than for the drama.
const blast = (this.rules.unitById.commander?.deathExplosion?.radius ?? 240) * 2.5;
this.fx.onEvent({ t: 'bigExplosion', x, y, radius: blast });
this.cameras.main.shake(700, 0.016);
}
/** Advance the cascade. Synthesised FX events — none of these came from the simulation. */
_stepDeathSequence(now) {
const seq = this._deathSeq;
while (seq.next < seq.doomed.length && seq.doomed[seq.next].at <= now) {
const d = seq.doomed[seq.next++];
this.view.vaporised.add(d.id);
this.selection.delete(d.id);
this.fx.onEvent({ t: 'unitDestroyed', x: d.x, y: d.y, radius: d.radius, isBuilding: d.isBuilding });
this._throttledSfx(d.isBuilding ? 'sfx-ta-vehicle-loss' : 'sfx-ta-unit-loss', 110);
}
if (!seq.nova && now >= seq.novaAt) {
seq.nova = true;
// Sized off the WORLD, not the screen, so it swallows the view at any zoom instead of
// being a big ring the player can see the edge of.
this.fx.onEvent({
t: 'bigExplosion', x: seq.x, y: seq.y,
radius: Math.hypot(this.match.worldW, this.match.worldH),
});
this.cameras.main.shake(1400, 0.030);
this.cameras.main.flash(1100, 255, 244, 214);
this.view.uiCam?.flash(1100, 255, 244, 214);
}
if (now >= seq.endAt) this._deathSeq = null;
}
/** Run the rest of the sequence instantly — for a player who has seen it before. */
_skipDeathSequence() {
if (!this._deathSeq) return;
for (let i = this._deathSeq.next; i < this._deathSeq.doomed.length; i++) {
this.view.vaporised.add(this._deathSeq.doomed[i].id);
}
this._deathSeq = null;
this.cameras.main.resetFX();
}
/** Length of a loaded sound in ms, straight off the audio cache. */
_sfxDurationMs(key, fallbackMs) {
const secs = this.cache.audio.get(key)?.duration ?? 0;
return secs > 0.25 ? secs * 1000 : fallbackMs;
}
_finish(over) {
this.phase = 'result';
const won = over.winner === this.playerArmy;
@ -529,7 +406,6 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
const code = ev.key?.toLowerCase();
if (ev.code === 'Escape') {
if (this.placement) { this._cancelPlacement(); return; }
if (this._deathSeq) { this._skipDeathSequence(); return; }
if (this.pendingCommand) { this.pendingCommand = null; return; }
this.togglePause();
return;
@ -553,11 +429,11 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
return;
}
switch (code) {
case 'a': this._pickCommand('attackMove'); break;
case 'p': this._pickCommand('patrol'); break;
case 'g': this._pickCommand('guard'); break;
case 'x': this._pickCommand('stop'); break;
case 'h': this._pickCommand('hold'); break;
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;
@ -773,70 +649,15 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
this._cancelQueueOrder(qs);
}
/**
* A command button was pressed. Stop and Hold take effect on the spot; everything else arms
* a pending command that the next world click resolves, which is exactly what the A/P/G
* hotkeys have always done — the buttons are a second door onto the same mechanism, not a
* parallel one. Pressing the armed command again disarms it.
*/
_pickCommand(id) {
if (this.pendingCommand === id) { this.pendingCommand = null; return; }
if (id === 'stop' || id === 'hold') {
this.pendingCommand = null;
this._order({ type: id });
return;
}
this._cancelPlacement();
this.pendingCommand = id;
this.hud.toast(COMMAND_PROMPTS[id] ?? 'Pick a target');
}
/**
* Keep a command armed for as long as CTRL is held, so one press of the button starts a RUN
* of clicks: a patrol route, a string of attack-move waypoints, a list of repair targets.
* Released in update() the moment CTRL comes up, exactly like a chained build placement.
*/
_holdCommand(cmd, queue) {
if (!queue) return;
this.pendingCommand = cmd;
this.chainedCommand = true;
}
_applyPendingCommand(w, queue) {
const cmd = this.pendingCommand;
this.pendingCommand = null;
// Orders that name a thing rather than a place. Each one rejects the wrong kind of target
// with a toast instead of silently doing nothing, because a command that just evaporates
// reads as a broken button.
if (cmd === 'guard' || cmd === 'attack' || cmd === 'repair' || cmd === 'assist') {
if (cmd === 'guard') {
const t = this._entityAt(w.x, w.y);
if (!t) { this.hud.toast('Nothing there', '#ff9a6b'); return; }
const mine = t.army === this.playerArmy;
if (cmd === 'attack' && mine) { this.hud.toast('That is yours', '#ff9a6b'); return; }
if (cmd !== 'attack' && !mine) { this.hud.toast('Pick one of yours', '#ff9a6b'); return; }
if (cmd === 'attack' && !this.view.visibleToPlayer(t)) { this.hud.toast('Nothing there', '#ff9a6b'); return; }
this._order({ type: cmd, targetId: t.id }, queue);
this._holdCommand(cmd, queue);
if (t && t.army === this.playerArmy) this._order({ type: 'guard', targetId: t.id }, queue);
return;
}
// Rally is a FACTORY command, not a unit order — it never touches an order queue.
if (cmd === 'setRally') {
const factories = this._selectedEntities()
.filter((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length);
if (!factories.length) return;
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;
}
this._order({ type: cmd, x: w.x, y: w.y }, queue);
this._holdCommand(cmd, queue);
}
_entityAt(x, y) {

View File

@ -60,53 +60,6 @@ let the scale do the work.
| 9 | Rocket Tank turret | rocket pod | 56 |
| 10 | Rocket Trooper | whole figure, shoulder-mounted launch tube | 30 |
| 11 | Construction Vehicle | tracked hull, folded nanolathe crane, no gun | 50 |
| 12 | Fighter | plan-view airframe, swept wings, no tracks or wheels | 46 |
| 13 | Bomber | plan-view airframe, long straight wing, wing-mounted engines | 60 |
| 14 | Hover Constructor | skirted hull over a plenum, nanolathe crane, no tracks | 52 |
### Units that leave the ground
All three Airfield units carry a `flight` block in the rules JSON. It is **purely cosmetic** —
the simulation has no height axis, and the unit stays exactly where its x/y say it is for
movement, collision, targeting and clicking:
```json
"flight": { "height": 26, "takeoffSec": 0.45, "landSec": 0.7 }
```
| Unit | height | takeoff | land |
|---|---|---|---|
| Fighter | 26px | 0.45s | 0.7s |
| Bomber | 30px | 0.7s | 1.0s |
| Hover Constructor | 9px | 0.35s | 0.5s |
At rest the unit sits flat on the ground with no visible shadow. The moment it is given
something to do, the **sprite** rises by `height` while a black, 50%-opacity copy of its own
frame stays pinned to the true ground position — the growing gap between the two is the
altitude. It settles back down when idle. `height` is the one number to tune if a unit reads as
too glued down or too detached.
The height itself is **simulation state** (`e.liftFrac`), not a render animation, because it
decides whether a unit is allowed to fire. The renderer only interpolates it between ticks.
Armed aircraft additionally carry `overshoot` and `keepout` (both in pixels) and are flagged
`strafes` by the rules compiler. They cannot stop in the air: a move order ends in a landing,
an attack order becomes a repeating firing pass, and a landed one can neither shoot nor claim
the protection of the air domain — on the ground it is an ordinary target that any weapon can
hit. The Hover Constructor is exempt — it is a ground unit that merely floats, and it does not
attack.
Three consequences for the art:
- The silhouette must read as flying — wings well clear of the fuselage, nothing that looks
like a track or a wheel. It is the only cue the player gets that ground units cannot shoot it.
- **The frame is reused as its own shadow**, so keep the shape solid. A hollow or heavily
outlined airframe casts a shadow that reads as a smudge.
- Draw the unit as if seen from directly above, not at a three-quarter angle. The body and its
shadow are the same image separated vertically, so any built-in perspective fights the effect.
The Hover Constructor uses the same machinery at a much lower height — it should read as
*floating*, not flying, which is why its shadow never gets far from it.
**Turret frames** are drawn as a separate image stacked on the hull and rotated
independently, so the unit aims while it drives. Rules:
@ -114,13 +67,11 @@ 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, rocket trooper, construction vehicle, and all three
Airfield units) simply has no turret frame; its whole sprite rotates to face its target. The
Construction Vehicle and Hover Constructor are unarmed, so they never turn to aim regardless —
they just rotate to face wherever they're moving. Aircraft have fixed forward guns, so they
aim by pointing the whole airframe, which is why they have no turret either.
- A unit with no turret (infantry, sniper, rocket trooper, construction vehicle) simply has
no turret frame; its whole sprite rotates to face its target. The Construction Vehicle is
unarmed, so it never turns to aim regardless — it just rotates to face wherever it's moving.
Frames 15–63 are free. Add a unit by appending a definition to `units[]` in
Frames 12–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.
---
@ -153,14 +104,12 @@ build completes; the finished frame crossfades in underneath starting at 20% pro
| 11 | Missile Launcher — under construction | 2×2 | |
| 12 | Advanced Vehicle Plant | 4×4 | wider bay, **two** doors side by side |
| 13 | Advanced Vehicle Plant — under construction | 4×4 | |
| 14 | Airfield | 3×3 | runway/apron, not a shed — strip runs to the **bottom** edge |
| 15 | Airfield — under construction | 3×3 | |
Defensive structures never rotate, and their mount traverses freely in the simulation — they
shoot in every direction regardless of how the art is drawn. Draw them facing **up** and do not
imply a firing arc.
Frames 16–35 are free.
Frames 14–35 are free.
---
@ -202,21 +151,9 @@ The theme then appears in the skirmish setup screen automatically.
## Icons (44×44 cells, 10 per row)
Build-menu and command glyphs. Frame numbers come from each def's `icon` in the rules JSON
and from `commandIcons`, so this table is a reflection of that file, not a second source of
truth. Purely decorative today — the build menu labels itself from unit names — so this sheet
is the lowest priority of the eight.
| Frames | Contents |
|---|---|
| 0–9 | Units: commander, infantry, sniper, jeep, tank, rocket tank, rocket trooper, construction vehicle, fighter, bomber |
| 10–16 | Buildings: energy gen, mass gen, barracks, vehicle plant, laser tower, missile launcher, advanced vehicle plant |
| 17–18 | Overflow: hover constructor (unit), airfield (building) |
| 20–29 | Commands: move, attack, attack-move, stop, hold, patrol, guard, assist, repair, rally |
Row 0 filled up before the Airfield units were added, which is why the hover constructor sits
next to the buildings at 17 rather than with the other units. Nothing reads a row as a
category — only the `icon` numbers matter.
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.
---

View File

@ -31,9 +31,7 @@ const CH = rules.terrain.map((t) => t.ch);
//
// Re-check these whenever combat balance moves. Making units hold at weapon range instead of
// closing to point blank shifted attack/defence enough to turn m02's old seed from playable
// into unwinnable (0/8), with no change to the mission's own numbers. Giving armed aircraft a
// strafing flight model did the same to m05 later on. Both times the mission was fine and the
// battlefield was not.
// into unwinnable (0/8), with no change to the mission's own numbers.
const MISSIONS = [
{
id: 'm01', name: 'Beachhead', seed: 10461, size: 'small', theme: 'grasslands', symmetry: 'mirror-x',
@ -72,11 +70,7 @@ const MISSIONS = [
defeat: 'KLAXON out-produced us and it showed.',
},
{
// Re-seeded twice as combat balance moved under it, both times measured over an 8-run
// sweep: 55930 -> 40411 when armed aircraft stopped being able to hover, then 40411 (which
// fell to 4/8) -> 71644 when the AI learned to build static defences. Cy-Bro is skill 4
// here, which is exactly the level at which defences switch on.
id: 'm05', name: 'Tropic Vice', seed: 71644, size: 'medium', theme: 'tropics', symmetry: 'mirror-x',
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: [

View File

@ -35,7 +35,7 @@ export function makeStubScene(w = 1920, h = 1080) {
};
const gfx = () => base('graphics', {
ops: 0,
clear() { this.ops = 0; this.slices.length = 0; return this; },
clear() { this.ops = 0; return this; },
lineStyle() { return this; }, fillStyle() { return this; },
beginPath() { return this; }, closePath() { return this; },
moveTo() { return this; }, lineTo() { return this; },
@ -45,15 +45,6 @@ export function makeStubScene(w = 1920, h = 1080) {
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; },
// Pie slices record their geometry: the production overlays are only meaningful if the
// ARC they sweep matches the progress fraction, which a bare call counter cannot show.
slices: [],
slice(x, y, radius, startAngle, endAngle) {
this.ops++;
this.slices.push({ x, y, radius, startAngle, endAngle });
return this;
},
arc() { this.ops++; return this; },
});
const container = () => base('container', {
list: [],

File diff suppressed because it is too large Load Diff