Compare commits
6 Commits
793cb249f9
...
71e455650b
| Author | SHA1 | Date |
|---|---|---|
|
|
71e455650b | |
|
|
ac063d34a6 | |
|
|
f9e971b305 | |
|
|
c56182bb10 | |
|
|
2b6132031a | |
|
|
cff1f02d9b |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 233 KiB |
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 12–63 are free. Add a unit by appending a definition to `units[]` in
|
||||
Frames 15–63 are free. Add a unit by appending a definition to `units[]` in
|
||||
`data/totalannihilation-rules.json` with its `frame` (and optional `turretFrame`) — no code.
|
||||
|
||||
---
|
||||
|
|
@ -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 14–35 are free.
|
||||
Frames 16–35 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 |
|
||||
|---|---|
|
||||
| 0–9 | Units: commander, infantry, sniper, jeep, tank, rocket tank, rocket trooper, construction vehicle, fighter, bomber |
|
||||
| 10–16 | Buildings: energy gen, mass gen, barracks, vehicle plant, laser tower, missile launcher, advanced vehicle plant |
|
||||
| 17–18 | Overflow: hover constructor (unit), airfield (building) |
|
||||
| 20–29 | Commands: move, attack, attack-move, stop, hold, patrol, guard, assist, repair, rally |
|
||||
|
||||
Row 0 filled up before the Airfield units were added, which is why the hover constructor sits
|
||||
next to the buildings at 17 rather than with the other units. Nothing reads a row as a
|
||||
category — only the `icon` numbers matter.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||