Compare commits

...

6 Commits

Author SHA1 Message Date
Brian Fertig 71e455650b feat: introduce dedicated armor classes for air and fortifications
Add `air` and `fortification` armor classes to enable more nuanced
balance. Aircraft now use `air` class instead of light/medium, and
defensive turrets use `fortification` instead of `structure`, allowing
weapons to specialize against planes and defenses without affecting
other targets.

- Add armor classes to all 12 weapons with appropriate multipliers
- Adjust weapon stats (sniperrifle, rocketpod, shoulderrocket)
- Reduce HP on several combat units for balance
- Reclassify aircraft and defensive turrets to new armor classes
2026-08-01 14:02:22 -06:00
Brian Fertig ac063d34a6 feat(total-annihilation): overhaul command system, patrol routes, and Commander death sequence
- Add repair and rally command buttons with custom glyphs and tooltips
- Refactor bottom command bar into a structured, discoverable grid with
  visibility filtering per selection type
- Replace patrol's there-and-back with a circuit-based route system;
  CTRL-clicking extends the patrol instead of stacking orders
- Builders guarding structures now repair damage first, then assist
  production — assist to producing factories now works correctly
- Add Commander death cascade: nuclear blast, outward vaporization wave,
  and screen-wide flash timed to the audio sample
- Introduce `fortification` and `air` armor classes; rebalance weapons,
  unit HP, and ranges across the roster
- Remove the static hint line; all commands now have prompts and button
  tooltips instead
- Add verification tests for patrol routes, guarding builders, vaporised
  entity rendering, and the full command bar
2026-08-01 13:56:41 -06:00
Brian Fertig f9e971b305 feat(totalannihilation): overhaul flight model to strafing behavior
Replace hover-based aircraft movement with a two-state system: LANDED
(grounded, cannot shoot, vulnerable to all weapons) and FLYING (at
cruise speed, uses air domain protection). Aircraft can no longer stop
in mid-air; armed units execute strafing passes with overshoot and
come-about logic.

Key changes:
- Add `liftFrac` simulation state for altitude, `airborne` dynamic flag
  (vs static `isAir`) so landed aircraft are ordinary ground targets
- Implement `stepStraferOrders` for aircraft that overfly targets and
  repeat passes; idle aircraft clear hostile keepout zones
- Add `overshoot` and `keepout` flight properties to fighter/bomber
- AI gains static defense (Laser Tower, Missile Launcher) at skill 4+,
  with threat-based placement and economy gating
- Fighter gains ground gun (pintlegun); air management uses `airClaimed`
  set instead of interceptor-only classification
- Update campaign m05 seed (71644) to compensate for balance shifts
- Update tooltips, documentation, and tests for new behavior

Closes #1234
2026-08-01 13:15:58 -06:00
Brian Fertig c56182bb10 Shift Artwork Additions 2026-08-01 11:52:23 -06:00
Brian Fertig 2b6132031a feat(totalannihilation): add flight animation and factory production dials
- Add `flight` blocks to Fighter, Bomber, and Hover Constructor in rules JSON
  defining height, takeoff time, and landing time
- Implement purely cosmetic altitude system: units ease upward when given
  orders and settle back down when idle, using smoothstep easing
- Shadow stays pinned to ground position, fades in and shrinks as altitude
  increases — the gap between body and shadow sells the height
- Health/build bars move with the lifted body; selection ring stays on ground
- Add production dials (progress pies) on the player's own factories:
  top-left shows whole queue progress, top-right shows unit on the bench
- Pass real delta time to render() for framerate-independent takeoff animation
- Update sprites.md with flight system docs and art implications
- Add stub scene support and comprehensive verification tests
2026-08-01 09:41:03 -06:00
Brian Fertig cff1f02d9b feat: add air domain system with fighters, bombers, and hover units
Introduce a two-layer domain system (ground/air) that separates units into
independent simulation layers. Air units ignore the nav grid, never collide
with ground units, and can only be targeted by weapons listing "air" in their
targets. Hover units gain water-crossing ability via the move class cost table.

New content:
- Fighter (air interceptor with AA cannons)
- Bomber (ground-attack aircraft with bomb rack)
- Hover Constructor (water-crossing builder)
- Airfield (produces all three air units)

AI gains air superiority management: interceptors hunt enemy aircraft instead
of joining ground pushes, and the production mix auto-counters observed enemy
air. Ground anti-air (rocket troopers/tanks) also gain targeting weight.

Rendering adds a dedicated depth band for aircraft above all ground actors,
plus a displaced shadow sprite for altitude perception. HUD tooltips display
domain and weapon targeting information.

Validation ensures domain/moveClass agreement, requires at least one anti-air
weapon when air units exist, and excludes air classes from corridor generation.
Comprehensive test fixtures cover domain separation, collision, splash, and
order handling.
2026-08-01 09:00:37 -06:00
43 changed files with 3072 additions and 289 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

View File

@ -191,6 +191,126 @@
"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": 55930,
"seed": 71644,
"playerArmy": "arm",
"playerCommander": "vance",
"enemies": [
@ -646,102 +646,102 @@
"h": 96,
"theme": "tropics",
"rows": [
"~~~..........r.....~~~~~~~~~~~~~~~.rr.rr................rr.rr.~~~~~~~~~~~~~~~.....r..........~~~",
"~~.................~~~~~~~~~~~~~~~.rrrrr................rrrrr.~~~~~~~~~~~~~~~.................~~",
"~~..................~~~~..r~~~~~~~~..........................~~~~~~~~r..~~~~..................~~",
"~.....................r........~~~~..........................~~~~........r.....................~",
"...............................~~~~~........................~~~~~...............................",
".......................r.......~~~~~........................~~~~~.......r.......................",
"......................rr.......~~~~~........................~~~~~.......rr......................",
"......................rr.......~~~~~........................~~~~~.......rr......................",
"....................rrrr.......~~~~~........................~~~~~.......rrrr....................",
"...............rrr^^^^r........~~~~~........................~~~~~........r^^^^rrr...............",
"...............r^^^^^^^^.......~~~~~~r....................r~~~~~~.......^^^^^^^^r...............",
"..............rr^^^^^^^^r......~~~~~~~~~~~~~~~~..~~~~~~~~~~~~~~~~......r^^^^^^^^rr..............",
".............rrr^^^^^^^^.......~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.......^^^^^^^^rrr.............",
".............rrr^^^^^^^........~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~........^^^^^^^rrr.............",
"................^^^^............~~~~~~~~~~~~~~~rr~~~~~~~~~~~~~~~............^^^^................",
".................r..............rr..~~~r...r........r...r~~~..rr..............r.................",
"...............................................rr...............................................",
"r..r...........................................rr...........................................r..r",
"..rrr..rr.^..........................................................................^.rr..rrr..",
"~~.r...rrrr^.................r....................................r.................^rrrr...r.~~",
"~~~.rrr..rr^^................r....................................r................^^rr..rrr.~~~",
"~~~..rr..r^^^^....................................................................^^^^r..rr..~~~",
"~~~......^^^^^....................................................................^^^^^......~~~",
"~~~.....^^^^^^^rr..............................................................rr^^^^^^^.....~~~",
"........^^^^^^^^^..............................................................^^^^^^^^^........",
"........^^^^^^^^^..............................................................^^^^^^^^^........",
".......r^^^^r.^^^..............................................................^^^.r^^^^r.......",
".......r^^^rr.r^^^............................................................^^^r.rr^^^r.......",
".......r^^rrrr..^^^^^^..r..............rr..............rr..............r..^^^^^^..rrrr^^r.......",
"........^...r...^^^^^^^^rr.............rrr.....rr.....rrr.............rr^^^^^^^^...r...^........",
"^^..............^^^^^^^^^r......~~~~...rrrrr^^.rr.^^rrrrr...~~~~......r^^^^^^^^^..............^^",
"^^^.............^^^^^^^^^......~~~~~...rrr^^^^^^^^^^^^rrr...~~~~~......^^^^^^^^^.............^^^",
"^^^^^^....rrr....^^^^^^^^^....~~~~~~.rr..^^^^^^^^^^^^^^..rr.~~~~~~....^^^^^^^^^....rrr....^^^^^^",
"^^^^^^r..rrrr....^^^^^^^^^...r~~~~~~rr...^^^^^^^^^^^^^^...rr~~~~~~r...^^^^^^^^^....rrrr..r^^^^^^",
"^^^^^^...rrrr.....^^^^^^^.....~~~~~~r....^^^^^^^^^^^^^^....r~~~~~~.....^^^^^^^.....rrrr...^^^^^^",
"^^^^r..rr..r.....rr^^^^^.......~~~~~.....^^^^^^^^^^^^^^.....~~~~~.......^^^^^rr.....r..rr..r^^^^",
"^^^r..rrr.......rrr^^^^^........~~~.......^^^^^^^^^^^^.......~~~........^^^^^rrr.......rrr..r^^^",
"^^..rrrr.........r.^^^^r.........~........^^^^^^^^^^^^........~.........r^^^^.r.........rrrr..^^",
"^r.rrrrr........rr.^^^^..........rrr.......^^^^^^^^^^.......rrr..........^^^^.rr........rrrrr.r^",
"..rrrrrr.......rrr..^^^..........rr........r........r........rr..........^^^..rrr.......rrrrrr..",
"....rrrr......rrrr..^^...........rr........rr......rr........rr...........^^..rrrr......rrrr....",
"...............rrr...............rr........rr......rr........rr...............rrr...............",
"................................................................................................",
"................................................................................................",
"................................................................................................",
"................................................................................................",
".............................rrr................................rrr.............................",
"...................MM........rrr................................rrr........MM...................",
"...................MM.........rrr..............................rrr.........MM...................",
"................................r..............................r................................",
"................................................................................................",
"................................................................................................",
".....MM.................~~................~~~~~~~~~~~~................~~.................MM.....",
".....MM................~~~~.............~~~~~~~~~~~~~~~~.............~~~~................MM.....",
".......................~~~~.............~~~~~~~~~~~~~~~~.............~~~~.......................",
"......................r~~~~............~~~~~~~~~~~~~~~~~~............~~~~r......................",
"........MM....MM......r~~~~............~~~~~~~~~~~~~~~~~~............~~~~r......MM....MM........",
"........MM....MM......r~~~~............r~~~~~~~~~~~~~~~~r............~~~~r......MM....MM........",
"r........rrr............~~~...............~~~~~~~~~~~~...............~~~............rrr........r",
".........rrr............~~............................................~~............rrr.........",
"..........r..........................................................................r..........",
".........~~~~~....................................................................~~~~~.........",
"........~~~~~~....................................................................~~~~~~........",
"........~~~~~~....r..........................................................r....~~~~~~........",
"^....rr.~~~~~~~...rr^^^..................................................^^^rr...~~~~~~~.rr....^",
"^^...rr.~~~~~~~...rr^^^...................^^^^^^^^^^^^...................^^^rr...~~~~~~~.rr...^^",
"^^......~~~~~~~...rr^^^^.................^^^^^^^^^^^^^^.................^^^^rr...~~~~~~~......^^",
"^^......~~~~~~~r....^^^^.................^^^^^^^^^^^^^^.................^^^^....r~~~~~~~......^^",
"^^......~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~......^^",
"^^r.....~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~.....r^^",
"^^......~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~......^^",
"^^.....~~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~~.....^^",
"^^....r~~~~~~~~.....^^^...................^^^^^^^^^^^^...................^^^.....~~~~~~~~r....^^",
"^.r..rr~~~~~~~~.....^^^...................^^^^^^^^^^^^...................^^^.....~~~~~~~~rr..r.^",
"^.rrr..~~~~~~~~.....^^^.............rr....^^^^^^^^^^^^....rr.............^^^.....~~~~~~~~..rrr.^",
"..rrr..~~~~~~~~....r^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^r....~~~~~~~~..rrr..",
"..rrr..~~~~~~~~.....^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^.....~~~~~~~~..rrr..",
"..rrr..~~~~~~~~~...r^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^r...~~~~~~~~~..rrr..",
"..rrr..~~~~~~~~~....^^^..............rrrrr^^^^^^^^^^^^rrrrr..............^^^....~~~~~~~~~..rrr..",
"..rrr..~~~~~~~~.....^^^...............rrrr^^^^^^^^^^^^rrrr...............^^^.....~~~~~~~~..rrr..",
"..rrr..~~~~~~~~.....^^^...............rrr.^^^^^^^^^^^^.rrr...............^^^.....~~~~~~~~..rrr..",
"^.rrr...~~~~~~~.....^^^................r..^^^^^^^^^^^^..r................^^^.....~~~~~~~...rrr.^",
"^^rrr....~~~~~......^^....................^^^^^^^^^^^^....................^^......~~~~~....rrr^^",
"^^r.......................................^^^^^^^^^^^^.......................................r^^",
"^^^.......................................^^^^^^^^^^^^.......................................^^^",
"^^^.........................................^......^.........................................^^^",
"^^^^.........................^^^^^^^^^^^................^^^^^^^^^^^.........................^^^^",
"^^^^........................^^^^^^^^^^^^^..............^^^^^^^^^^^^^........................^^^^",
"^^^^^.......................^^^^^^^^^^^^^..............^^^^^^^^^^^^^.......................^^^^^",
"^^^^^.......................^^^^^^^^^^^^^.....r..r.....^^^^^^^^^^^^^.......................^^^^^",
"^^^^^.......................^^^^^^^^^^^^^....rrrrrr....^^^^^^^^^^^^^.......................^^^^^",
"^^^^.........................^^^^^^^^^^......rrrrrr......^^^^^^^^^^.........................^^^^",
"^^r...............................r..........rrrrrr..........r...............................r^^",
".r.....................r................................................r.....................r.",
".............rr.......rr~~............................................~~rr.......rr.............",
"r........rrrrrrr.......~~~~.......r..........................r.......~~~~.......rrrrrrr........r"
"~~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.....^^"
],
"starts": [
{

View File

@ -11,7 +11,24 @@
"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."
"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."
],
"version": 1,
"constants": {
@ -26,6 +43,7 @@
"pathBudgetPerTick": 8,
"retargetPeriodTicks": 10,
"engageHoldFraction": 0.45,
"siegeHoldFraction": 0.95,
"separationStiffness": 0.55,
"shoveAfterSec": 0.6,
"stuckGiveUpSec": 3.0,
@ -70,7 +88,9 @@
"light",
"medium",
"heavy",
"structure"
"structure",
"fortification",
"air"
],
"moveClasses": {
"foot": {
@ -102,6 +122,27 @@
"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": [
@ -233,11 +274,13 @@
"ground"
],
"armorMul": {
"infantry": 1.3,
"light": 0.85,
"medium": 0.3,
"heavy": 0.12,
"structure": 0.2
"infantry": 1.0,
"light": 0.5,
"medium": 0.25,
"heavy": 0.15,
"structure": 0.7,
"fortification": 0.55,
"air": 0.3
},
"fx": {
"style": "tracer",
@ -261,11 +304,13 @@
"ground"
],
"armorMul": {
"infantry": 1.3,
"light": 0.85,
"infantry": 2.2,
"light": 0.7,
"medium": 0.3,
"heavy": 0.12,
"structure": 0.2
"heavy": 0.15,
"structure": 0.5,
"fortification": 0.4,
"air": 0.35
},
"fx": {
"style": "tracer",
@ -279,20 +324,22 @@
"id": "sniperrifle",
"name": "Marksman Rifle",
"kind": "hitscan",
"damage": 55,
"reload": 2.0,
"damage": 70,
"reload": 2.2,
"burst": 1,
"range": 460,
"range": 580,
"spread": 0.0,
"targets": [
"ground"
],
"armorMul": {
"infantry": 2.2,
"light": 0.9,
"medium": 0.3,
"heavy": 0.1,
"structure": 0.1
"infantry": 3.2,
"light": 0.35,
"medium": 0.12,
"heavy": 0.08,
"structure": 0.08,
"fortification": 0.08,
"air": 0.15
},
"fx": {
"style": "beam",
@ -323,7 +370,9 @@
"light": 1.15,
"medium": 1.0,
"heavy": 0.8,
"structure": 1.2
"structure": 1.2,
"fortification": 1.1,
"air": 1.0
},
"fx": {
"style": "shell",
@ -337,7 +386,7 @@
"id": "rocketpod",
"name": "Rocket Pod",
"kind": "guided",
"damage": 40,
"damage": 48,
"reload": 4.0,
"burst": 2,
"burstDelay": 0.25,
@ -348,14 +397,17 @@
"aoe": 48,
"aoeFalloff": 0.15,
"targets": [
"ground"
"ground",
"air"
],
"armorMul": {
"infantry": 0.3,
"light": 0.6,
"medium": 1.0,
"heavy": 1.4,
"structure": 1.5
"structure": 1.5,
"fortification": 1.6,
"air": 2.6
},
"fx": {
"style": "rocket",
@ -370,24 +422,27 @@
"id": "shoulderrocket",
"name": "Shoulder Rocket",
"kind": "guided",
"damage": 32,
"damage": 40,
"reload": 2.2,
"burst": 1,
"range": 320,
"range": 380,
"minRange": 60,
"speed": 260,
"turnRate": 2.0,
"aoe": 24,
"aoeFalloff": 0.15,
"targets": [
"ground"
"ground",
"air"
],
"armorMul": {
"infantry": 0.35,
"light": 0.8,
"medium": 1.1,
"heavy": 1.3,
"structure": 1.0
"infantry": 0.3,
"light": 0.9,
"medium": 1.3,
"heavy": 1.2,
"structure": 1.2,
"fortification": 1.9,
"air": 2.8
},
"fx": {
"style": "rocket",
@ -415,7 +470,9 @@
"light": 1.0,
"medium": 0.8,
"heavy": 0.6,
"structure": 0.5
"structure": 0.5,
"fortification": 0.45,
"air": 0.8
},
"fx": {
"style": "beam",
@ -443,7 +500,9 @@
"light": 1,
"medium": 1,
"heavy": 1,
"structure": 1
"structure": 1,
"fortification": 1,
"air": 1
},
"fx": {
"style": "dgun",
@ -470,7 +529,9 @@
"light": 1.2,
"medium": 0.9,
"heavy": 0.55,
"structure": 0.4
"structure": 0.4,
"fortification": 0.35,
"air": 1.0
},
"fx": {
"style": "beam",
@ -494,14 +555,17 @@
"aoe": 56,
"aoeFalloff": 0.15,
"targets": [
"ground"
"ground",
"air"
],
"armorMul": {
"infantry": 0.35,
"light": 0.7,
"medium": 1.1,
"heavy": 1.5,
"structure": 1.3
"structure": 1.3,
"fortification": 1.2,
"air": 2.8
},
"fx": {
"style": "rocket",
@ -510,6 +574,67 @@
"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": [
@ -543,7 +668,8 @@
"barracks",
"vehicleplant",
"lasertower",
"missilelauncher"
"missilelauncher",
"airfield"
],
"produce": {
"energy": 25,
@ -604,7 +730,7 @@
"role": "combat",
"size": "small",
"radius": 16,
"hp": 200,
"hp": 130,
"speed": 46,
"turnRate": 5.0,
"moveClass": "foot",
@ -634,7 +760,7 @@
"role": "combat",
"size": "small",
"radius": 16,
"hp": 240,
"hp": 150,
"speed": 50,
"turnRate": 5.5,
"moveClass": "foot",
@ -726,7 +852,7 @@
"role": "artillery",
"size": "medium",
"radius": 28,
"hp": 1250,
"hp": 1050,
"speed": 75,
"turnRate": 1.8,
"moveClass": "tread",
@ -780,7 +906,8 @@
"vehicleplant",
"lasertower",
"missilelauncher",
"advancedvehicleplant"
"advancedvehicleplant",
"airfield"
],
"sheetSlot": "unitSheet",
"frame": 11,
@ -788,6 +915,127 @@
"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": [
@ -925,8 +1173,8 @@
"w": 1,
"h": 1
},
"hp": 3300,
"armorClass": "structure",
"hp": 1700,
"armorClass": "fortification",
"sight": 300,
"cost": {
"energy": 420,
@ -956,8 +1204,8 @@
"w": 2,
"h": 2
},
"hp": 4700,
"armorClass": "structure",
"hp": 3100,
"armorClass": "fortification",
"sight": 420,
"cost": {
"energy": 1100,
@ -1010,6 +1258,37 @@
"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": {
@ -1020,7 +1299,9 @@
"hold": 24,
"patrol": 25,
"guard": 26,
"assist": 27
"assist": 27,
"repair": 28,
"rally": 29
},
"aiSkills": [
{

View File

@ -10,7 +10,8 @@
// Difficulty is decision quality, reaction time and an orders-per-second cap — not
// income cheating. A cheating AI's win rate would tell us nothing about the balance.
import { issueOrder, isVisibleTo, canPlaceAt, rngNext, rngInt } from './TALogic.js';
import { issueOrder, isVisibleTo, canPlaceAt, rngNext, rngInt, angleDelta } from './TALogic.js';
import { canEngage } from './TARules.js';
import { worldToTileX, worldToTileY } from './TANav.js';
/** Desired force mix per skill. Below skill 3 the AI just buys whatever it can afford. */
@ -20,6 +21,11 @@ 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) {
@ -30,7 +36,7 @@ function memFor(state, armyIdx) {
nextThinkTick: 0,
ordersThisSecond: 0, secondMark: 0,
squad: [], squadPeak: 0, phase: 'build', focusId: 0,
scoutId: 0,
scoutId: 0, airPreyId: 0, lastThreatTick: -99999,
knownEnemies: [], // [{x,y,defId,isBuilding,tick}]
lastAttackTick: -99999,
buildSpiral: 0,
@ -89,6 +95,7 @@ export function runAI(rules, state, armyIdx, profile = {}) {
manageEconomyAndBase(ctx, mine);
manageProduction(ctx, mine);
manageScout(ctx, mine);
manageAir(ctx, mine);
manageMilitary(ctx, mine);
}
@ -115,11 +122,30 @@ 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.tick >= cutoff && live.has(k.id))
.filter((k) => (k.isBuilding || 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) {
@ -169,7 +195,7 @@ function manageEconomyAndBase(ctx, mine) {
const want = chooseBuilding(ctx, mine);
if (!want) return;
const spot = findPlacement(ctx, mine, want, builder);
const spot = findPlacement(ctx, mine, want, builder, placementOptionsFor(ctx, want));
if (!spot) return;
order(ctx, {
@ -186,6 +212,7 @@ 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;
@ -195,6 +222,12 @@ 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.
@ -208,6 +241,26 @@ 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.
@ -229,12 +282,17 @@ 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');
const factories = n('barracks') + n('vehicleplant') + n('airfield');
const nextFactory = () => (n('vehicleplant') <= n('barracks') ? plant : barracks);
const factoryCap = Math.round(2 + expansion * 4);
const supported = Math.floor(army.mIncome / Math.max(0.5, factoryMassDrain(rules, plant)));
const groundDrain = Math.max(0.5, factoryMassDrain(rules, plant));
const supported = Math.floor(army.mIncome / groundDrain);
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;
@ -243,6 +301,83 @@ 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);
@ -255,10 +390,9 @@ 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) {
function findPlacement(ctx, mine, def, builder, opts = {}) {
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);
@ -267,23 +401,48 @@ function findPlacement(ctx, mine, def, builder) {
if (spot) return spot;
}
const maxR = 22;
for (let r = 2; r < maxR; r++) {
// Rotate the starting angle per attempt so the base doesn't grow as a solid slab —
// AoE chains through tightly packed buildings.
const steps = Math.max(8, r * 4);
const off = (ctx.mem.buildSpiral++ % steps);
for (let s = 0; s < steps; s++) {
const a = ((s + off) / steps) * Math.PI * 2;
const tx = bx + Math.round(Math.cos(a) * r);
const ty = by + Math.round(Math.sin(a) * r);
if (!spacedEnough(ctx, mine, tx, ty, def)) continue;
if (canPlaceAt(state, rules, tx, ty, def).ok) return { tx, ty };
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 };
}
}
}
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;
@ -361,22 +520,32 @@ 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;
let infantryish = 0, armour = 0, structures = 0, air = 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) 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;
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;
}
}
// ---------------------------------------------------------------------------
@ -449,6 +618,55 @@ 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
// ---------------------------------------------------------------------------
@ -461,6 +679,7 @@ 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;
});
@ -468,7 +687,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) : null;
const threat = skill >= 2 ? nearestThreatToBase(ctx, mine) : null;
if (threat) {
const defenders = mem.squad.slice(0, Math.max(3, Math.ceil(mem.squad.length * 0.7)));
if (defenders.length) {
@ -527,17 +746,21 @@ function manageMilitary(ctx, mine) {
}
}
function nearestThreatToBase(ctx) {
const { state, mem, armyIdx } = ctx;
function nearestThreatToBase(ctx, mine) {
const { state, rules, 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) { best = e; bestD = d; }
if (d < radius && d < bestD && canAnswer(e)) { best = e; bestD = d; }
}
return best;
}
@ -583,6 +806,7 @@ 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,6 +229,93 @@ 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) {
@ -444,6 +531,42 @@ 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();
}
},
};
// ---------------------------------------------------------------------------
@ -660,6 +783,29 @@ 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,11 +23,39 @@ const DIM = '#8fa0b8';
const BAR_H = 62; // top resource strip
const BOT_H = 190; // bottom command bar
const MINI = 176; // minimap edge length
const BTN_W = 88, BTN_H = 74; // build-menu button size
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
// 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';
/**
* 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.
/** Build-button captions have ~7 characters of room; "Vehicle Plant" needs shortening. */
function shortName(name) {
@ -129,22 +157,106 @@ 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.hint = s.add.text(GAME_WIDTH - 24, y + BOT_H - 26, '', {
fontFamily: FONT, fontSize: '17px', color: DIM,
}).setOrigin(1, 0.5);
this.root.add([this.selTitle, this.selDetail, this.queueText, this.hint]);
this.root.add([this.selTitle, this.selDetail, this.queueText]);
this._buildCommandGrid();
}
this.gridX = GAME_WIDTH - 24 - 6 * 96;
this.gridY = y + 16;
/**
* 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);
}
}
_clearGrid() {
@ -161,9 +273,9 @@ export default class TAHud {
const s = this.scene;
const army = this.state.armies[this.playerArmy];
options.forEach((def, i) => {
const col = i % 6, row = Math.floor(i / 6);
const col = i % GRID_COLS, row = Math.floor(i / GRID_COLS);
// The container is placed at the button's CENTRE, not its top-left — see below.
const x = this.gridX + col * 96 + BTN_W / 2;
const x = this.gridX + col * GRID_PITCH + 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);
@ -199,8 +311,16 @@ 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 ?? []) {
lines.push(`${w.name}: ${w.damage} dmg / ${w.reload}s / ${w.range} range${w.manual ? ' (manual)' : ''}`);
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)' : ''}`);
}
if (def.makes) {
const m = [];
@ -275,6 +395,9 @@ 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);
@ -293,7 +416,7 @@ export default class TAHud {
// Per-frame update
// -------------------------------------------------------------------------
update(timeMs, selection, placementDef) {
update(timeMs, selection, placementDef, pendingCommand = null) {
const st = this.state, army = st.armies[this.playerArmy];
if (!army) return;
@ -314,6 +437,9 @@ 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) {
@ -331,7 +457,6 @@ export default class TAHud {
this.selTitle.setText('');
this.selDetail.setText('');
this.queueText.setText('');
this.hint.setText(HINT);
this._clearGrid();
return;
}
@ -353,9 +478,10 @@ 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 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');
// 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');
}
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)' : ''}`);
@ -367,13 +493,6 @@ 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,6 +16,7 @@ import {
nearestUsableTile, tileIndex, worldToTileX, worldToTileY, tileCenterX, tileCenterY,
SpatialHash, segmentClear,
} from './TANav.js';
import { canEngage, weaponHitsDomain } from './TARules.js';
export const SAVE_VERSION = 1;
@ -142,6 +143,24 @@ 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,
@ -308,9 +327,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 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);
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);
});
return { ok: true };
}
@ -506,11 +525,24 @@ 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;
// 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.
// 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.
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;
}
}
}
@ -647,14 +679,18 @@ 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.
// 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.
const nav = state.nav;
const udef = rules.unitById[item.defId];
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); }
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 u = spawnUnit(state, rules, f.army, item.defId, sx, sy, Math.atan2(off.y, off.x));
f.jobProgress = 0;
@ -679,6 +715,17 @@ 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) {
@ -703,13 +750,66 @@ function servicePathQueue(state, rules) {
}
function orderDestination(e, 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 };
}
if (order.type === 'patrol') return patrolWaypoint(order);
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;
@ -740,12 +840,197 @@ 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) {
@ -766,7 +1051,11 @@ 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.
if (order.type !== 'build' && order.type !== 'assist' && order.type !== 'repair') {
//
// `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') {
e.buildTargetId = 0;
}
@ -816,7 +1105,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 * (rules.constants.engageHoldFraction ?? 0.85)) {
&& surfaceDist(rules, e, foe) <= def.maxRange * holdFractionFor(rules, foe)) {
e.path = null;
e.movingTo = null;
e.stuckTicks = 0;
@ -825,16 +1114,16 @@ function stepOrders(state, rules) {
}
}
const dest = orderDestination(e, order);
if (!e.path && !e.wantPath && !e.noPath) requestPath(state, e, dest.x, dest.y);
if (!e.path && !e.wantPath && !e.noPath) seekTo(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') {
order.leg = order.leg === 0 ? 1 : 0;
advancePatrol(order);
e.path = null; e.noPath = false; e.stuckTicks = 0;
requestPath(state, e, ...(order.leg === 0
? [order.sx ?? order.x, order.sy ?? order.y] : [order.fromX, order.fromY]));
const next = patrolWaypoint(order);
seekTo(state, e, next.x, next.y);
} else {
e.orders.shift();
e.path = null; e.movingTo = null; e.stuckTicks = 0; e.noPath = false;
@ -846,13 +1135,19 @@ 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 * (rules.constants.engageHoldFraction ?? 0.85);
const wantRange = def.maxRange * (target.isBuilding
? holdFractionFor(rules, target)
: (rules.constants.engageHoldFraction ?? 0.85));
if (d > wantRange) {
if (!e.path && !e.wantPath) requestPath(state, e, target.x, target.y);
if (!e.path && !e.wantPath) seekTo(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.
// 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.
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);
}
@ -865,16 +1160,28 @@ function stepOrders(state, rules) {
case 'guard': {
const target = entityById(state, order.targetId);
if (!target) { e.orders.shift(); break; }
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;
const d = Math.hypot(target.x - e.x, target.y - e.y);
if (d > ts * 3) {
if (!e.path && !e.wantPath) requestPath(state, e, target.x, target.y);
if (d > reach) {
if (!e.path && !e.wantPath) seekTo(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;
}
@ -895,7 +1202,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) requestPath(state, e, target.x, target.y);
if (!e.path && !e.wantPath && !e.noPath) seekTo(state, e, target.x, target.y);
e.movingTo = { x: target.x, y: target.y };
e.buildTargetId = 0;
} else {
@ -923,7 +1230,10 @@ function stepMovement(state, rules) {
const def = defOf(rules, e);
let tx = null, ty = null;
if (e.path && e.path.length >= 2) {
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) {
// 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];
@ -941,7 +1251,22 @@ function stepMovement(state, rules) {
tx = e.movingTo.x; ty = e.movingTo.y;
}
if (tx == null) { e.vx = 0; e.vy = 0; e.stuckTicks = 0; continue; }
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;
}
const dx = tx - e.x, dy = ty - e.y;
const dist = Math.hypot(dx, dy);
@ -950,18 +1275,35 @@ 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.
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;
// 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;
}
// 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);
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; }
e.vx = Math.cos(e.heading) * step / dt;
e.vy = Math.sin(e.heading) * step / dt;
e.x += Math.cos(e.heading) * step;
@ -1050,6 +1392,9 @@ 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;
@ -1086,6 +1431,13 @@ 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);
@ -1128,11 +1480,20 @@ 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.
const holdOrder = e.orders[0]?.type === 'attack';
//
// 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;
let target = entityById(state, e.targetId);
if (target && !inWeaponRange(rules, e, def, target)) target = null;
const due = ((state.tick + e.id) % c.retargetPeriodTicks) === 0;
@ -1166,6 +1527,7 @@ 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;
@ -1185,6 +1547,7 @@ 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]--;
@ -1225,6 +1588,7 @@ 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;
}
@ -1240,6 +1604,9 @@ 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);
@ -1360,11 +1727,14 @@ 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.
// 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.
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; }
@ -1381,6 +1751,7 @@ 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;
@ -1416,6 +1787,8 @@ 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
@ -1481,6 +1854,7 @@ 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;
@ -1588,6 +1962,7 @@ export function tick(state, rules) {
stepEconomy(state, rules);
stepOrders(state, rules);
stepFlight(state, rules);
servicePathQueue(state, rules);
stepMovement(state, rules);
stepSeparation(state, rules);
@ -1690,6 +2065,10 @@ 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.
});
@ -1737,6 +2116,10 @@ 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,10 +228,17 @@ export function generateMap(rules, opts = {}) {
return map;
}
/** The move class with the most restrictive terrain table — if it can get through, all can. */
/**
* 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.
*/
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,6 +12,9 @@ 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.
@ -21,8 +24,9 @@ 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',
'advancedVehiclePlant', 'airfield',
]);
function fail(msg) {
@ -118,7 +122,14 @@ 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.
spec.costByTerrainIndex = terrain.map((t) => (t.blocksMove ? null : spec.cost[t.id]));
//
// 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;
}
const moveClassSet = new Set(Object.keys(moveClasses));
@ -178,6 +189,8 @@ 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.
@ -194,6 +207,15 @@ 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}"`);
@ -224,6 +246,33 @@ 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.
@ -232,6 +281,10 @@ 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');
@ -251,6 +304,7 @@ 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
@ -358,6 +412,25 @@ 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,6 +14,7 @@
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];
@ -27,10 +28,35 @@ const ORDER_COLORS = {
};
export const DEPTHS = {
terrain: 0, decal: 5, fxUnder: 8, ghost: 12,
selection: 15, actor: 20, bars: 55, projectile: 60, fxOver: 65, fog: 80,
terrain: 0, decal: 5, shadow: 6, fxUnder: 8, ghost: 12,
selection: 15, actor: 20, air: 40, 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;
@ -68,13 +94,19 @@ 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 }
@ -349,7 +381,21 @@ export default class TAWorldView {
turret.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
this._addWorld(turret);
}
s = { img, turret, final, defId: e.defId };
// 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
};
this.sprites.set(e.id, s);
return s;
}
@ -360,6 +406,7 @@ export default class TAWorldView {
s.img.destroy();
s.turret?.destroy();
s.final?.destroy();
s.shadow?.destroy();
this.sprites.delete(id);
}
@ -378,11 +425,14 @@ 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;
gSel.clear(); gBar.clear();
const gSel = this.gSelection, gBar = this.gBars, gPie = this.gPies;
gSel.clear(); gBar.clear(); gPie.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);
@ -390,16 +440,37 @@ 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);
s.img.setPosition(x, y);
// 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);
if (!def.isBuilding) s.img.setRotation(heading);
// Y-sorted actor band; buildings sit just under units sharing a row.
s.img.setDepth(DEPTHS.actor + (y / state.worldH) * 10 + (def.isBuilding ? 0 : 0.05));
// 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));
}
// 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
@ -443,11 +514,13 @@ export default class TAWorldView {
if (s.turret) {
const tr = lerpAngle(e.pturretRot, e.turretRot, alpha);
s.turret.setPosition(x, y).setRotation(tr);
s.turret.setDepth(DEPTHS.actor + (y / state.worldH) * 10 + 0.08);
s.turret.setPosition(x, y - lift).setRotation(tr);
s.turret.setDepth((def.isAir ? DEPTHS.air : DEPTHS.actor) + (y / state.worldH) * 10 + 0.08);
}
// Selection ring
// 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.
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);
@ -455,14 +528,19 @@ 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, y - e.radius - 10, e.radius * 1.8, e.progress, 0x8ad4ff);
drawBar(gBar, x, barY, e.radius * 1.8, e.progress, 0x8ad4ff);
} else if (damaged || this.selection.has(e.id) || this.showAllBars) {
const frac = Math.max(0, e.hp / e.maxHp);
const col = frac > 0.6 ? 0x6fe27a : frac > 0.3 ? 0xe2d16f : 0xe2705f;
drawBar(gBar, x, y - e.radius - 10, e.radius * 1.8, frac, col);
drawBar(gBar, x, barY, 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);
@ -511,6 +589,53 @@ 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.
@ -541,13 +666,34 @@ 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);
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 {
// 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);
g.fillCircle(pt.x, pt.y, active ? 5 : 4);
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.lineStyle(2, col, 0.9);
g.strokeCircle(pt.x, pt.y, 9);
} 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);
}
}
// A clicked-and-held queue entry gets a bright ring so its selection is unambiguous
// before the player commits to deleting it.
@ -562,7 +708,14 @@ export default class TAWorldView {
/** World position an order points at, or null if it has no meaningful location. */
_orderPoint(o) {
if (o.type === 'move' || o.type === 'attackMove' || o.type === 'patrol') {
// 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') {
const x = o.sx ?? o.x, y = o.sy ?? o.y;
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
}
@ -663,6 +816,26 @@ 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,6 +30,18 @@ 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'); }
@ -44,7 +56,12 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
this.selection = new Set();
this.groups = new Map();
this.placement = null;
this.pendingCommand = null; // 'attackMove' | 'patrol' | 'guard' | 'attack'
// 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.simSpeed = 1;
this.settings = readJson(SETTINGS_KEY, { edgeScroll: true, fog: true });
this.lastSfxAt = {}; // per-sound-key gate — see _throttledSfx
@ -183,6 +200,7 @@ 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);
@ -195,6 +213,8 @@ 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;
@ -227,6 +247,12 @@ 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.
@ -244,14 +270,17 @@ 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.hud.update(time, this._selectedEntities(), this.placement?.def ?? null, this.pendingCommand);
this._drawDragBox();
if (this.meta?.mode === 'campaign' && time - this._lastAutosave > 60000) {
this._lastAutosave = time;
this.saveGame();
}
if (st.over) this._finish(st.over);
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);
}
_onSimEvent(ev) {
@ -267,7 +296,13 @@ 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);
}
}
@ -278,6 +313,9 @@ 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) {
@ -310,6 +348,91 @@ 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;
@ -406,6 +529,7 @@ 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;
@ -429,11 +553,11 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
return;
}
switch (code) {
case 'a': this.pendingCommand = 'attackMove'; this.hud.toast('Attack-move: pick a target point'); break;
case 'p': this.pendingCommand = 'patrol'; this.hud.toast('Patrol: pick a point'); break;
case 'g': this.pendingCommand = 'guard'; this.hud.toast('Guard: pick a friendly unit'); break;
case 'x': this._order({ type: 'stop' }); break;
case 'h': this._order({ type: 'hold' }); break;
case '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 '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;
@ -649,15 +773,70 @@ 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;
if (cmd === 'guard') {
// 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') {
const t = this._entityAt(w.x, w.y);
if (t && t.army === this.playerArmy) this._order({ type: 'guard', targetId: t.id }, queue);
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);
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,6 +60,53 @@ 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:
@ -67,11 +114,13 @@ 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) 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.
- 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.
Frames 1263 are free. Add a unit by appending a definition to `units[]` in
Frames 1563 are free. Add a unit by appending a definition to `units[]` in
`data/totalannihilation-rules.json` with its `frame` (and optional `turretFrame`) — no code.
---
@ -104,12 +153,14 @@ 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 1435 are free.
Frames 1635 are free.
---
@ -151,9 +202,21 @@ The theme then appears in the skirmish setup screen automatically.
## Icons (44×44 cells, 10 per row)
Command glyphs for the HUD: move, attack, attack-move, stop, hold, patrol, guard, build,
repair, rally. Purely decorative today — the build menu labels itself from unit names — so
this sheet is the lowest priority of the eight.
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 |
|---|---|
| 09 | Units: commander, infantry, sniper, jeep, tank, rocket tank, rocket trooper, construction vehicle, fighter, bomber |
| 1016 | Buildings: energy gen, mass gen, barracks, vehicle plant, laser tower, missile launcher, advanced vehicle plant |
| 1718 | Overflow: hover constructor (unit), airfield (building) |
| 2029 | 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.
---

View File

@ -31,7 +31,9 @@ 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.
// 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.
const MISSIONS = [
{
id: 'm01', name: 'Beachhead', seed: 10461, size: 'small', theme: 'grasslands', symmetry: 'mirror-x',
@ -70,7 +72,11 @@ const MISSIONS = [
defeat: 'KLAXON out-produced us and it showed.',
},
{
id: 'm05', name: 'Tropic Vice', seed: 55930, size: 'medium', theme: 'tropics', symmetry: 'mirror-x',
// 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',
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; return this; },
clear() { this.ops = 0; this.slices.length = 0; return this; },
lineStyle() { return this; }, fillStyle() { return this; },
beginPath() { return this; }, closePath() { return this; },
moveTo() { return this; }, lineTo() { return this; },
@ -45,6 +45,15 @@ 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