Compare commits

...

3 Commits

Author SHA1 Message Date
Brian Fertig 3c7184564a totalannihilation: add building upgrades and spinning toppers
- New `upgradesFrom` rule: a building def (Nuclear Power Plant on the
  Energy Generator, Advanced Metal Generator on the Mass Generator) may
  be placed exactly on a friendly, finished building of the named type,
  consuming it for a 50% refund of its build cost instead of needing
  clear ground (TALogic findUpgradeTarget / consumeUpgrade / buildCommand)
- New `topperFrame`: a second sprite stacked on the finished structure
  that spins continuously (the Advanced Metal Generator's centrifuge,
  frame 26), hidden while under construction; TAArt sizes the sheet for
  it and paints a generic spinner-glyph fallback
- New rules: Nuclear Power Plant (frames 22-23) and Advanced Metal
  Generator (frames 24-26) in totalannihilation-rules.json
- TARules: validate topperFrame and upgradesFrom (real building with a
  cost); TAWorldView renders/spins the topper; verifyTotalAnnihilation
  checks topper frames fit and were painted
- Test section covering upgrade placement (ownership, type match, exact
  footprint, refund amount, army-less fallback) and sprites.md updated
  for frames 22-26
2026-08-16 15:35:31 -06:00
Brian Fertig c690d80e68 feat(totalannihilation): add Megatank unit with multi-barrel weapons and rebalance combat
- Introduce Megatank: medium commander with Twin 150mm Cannon and Side Rocket Pods
- Implement multi-barrel weapon system (barrels + barrelSpacing) in TALogic and TARules
- Lower DEFENCE_MIN_SKILL from 4 to 3; make first Laser Tower proactive, not reactive
- Rebalance unit triangle: Tank now counters Rocket Tank; rockets weak vs vehicle armour
- Re-seed campaign missions m05 (67890) and m06 (80808) with updated maps and briefings
- Skirmish default theme uses rules default (tropics) instead of hardcoded grasslands
- Add intro video before main menu
- Update sprite docs and verification tests for new balance
2026-08-16 14:30:04 -06:00
Brian Fertig 045327d38a feat(totalannihilation): add Megatank unit with multi-barrel weapons and rebalance combat
- Introduce Megatank: medium commander with Twin 150mm Cannon and Side Rocket Pods
- Implement multi-barrel weapon system (barrels + barrelSpacing) in TALogic and TARules
- Lower DEFENCE_MIN_SKILL from 4 to 3; make first Laser Tower proactive, not reactive
- Rebalance unit triangle: Tank now counters Rocket Tank; rockets weak vs vehicle armour
- Re-seed campaign missions m05 (67890) and m06 (80808) with updated maps and briefings
- Skirmish default theme uses rules default (tropics) instead of hardcoded grasslands
- Add intro video before main menu
- Update sprite docs and verification tests for new balance
2026-08-16 13:42:54 -06:00
24 changed files with 988 additions and 309 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Binary file not shown.

View File

@ -38,7 +38,7 @@
"arm-structures": { "arm-structures": {
"key": "ta-arm-structures", "path": "assets/images/ta/arm-structures.png", "kind": "structure", "key": "ta-arm-structures", "path": "assets/images/ta/arm-structures.png", "kind": "structure",
"frameWidth": 192, "frameHeight": 192, "cols": 6, "frameWidth": 192, "frameHeight": 192, "cols": 6,
"rows": 4 "rows": 6
}, },
"core-units": { "core-units": {
"key": "ta-core-units", "path": "assets/images/ta/core-units.png", "kind": "unit", "key": "ta-core-units", "path": "assets/images/ta/core-units.png", "kind": "unit",
@ -48,7 +48,7 @@
"core-structures": { "core-structures": {
"key": "ta-core-structures", "path": "assets/images/ta/core-structures.png", "kind": "structure", "key": "ta-core-structures", "path": "assets/images/ta/core-structures.png", "kind": "structure",
"frameWidth": 192, "frameHeight": 192, "cols": 6, "frameWidth": 192, "frameHeight": 192, "cols": 6,
"rows": 4 "rows": 6
}, },
"terrain-grasslands": { "terrain-grasslands": {
"key": "ta-terrain-grasslands", "path": "assets/images/ta/terrain-grasslands.png", "kind": "terrain", "key": "ta-terrain-grasslands", "path": "assets/images/ta/terrain-grasslands.png", "kind": "terrain",
@ -67,7 +67,7 @@
}, },
"icons": { "icons": {
"key": "ta-icons", "path": null, "kind": "icon", "key": "ta-icons", "path": null, "kind": "icon",
"frameWidth": 44, "frameHeight": 44, "cols": 10, "frameWidth": 64, "frameHeight": 64, "cols": 10,
"rows": 4 "rows": 4
} }
}, },

View File

@ -598,7 +598,7 @@
"id": "m05", "id": "m05",
"name": "Tropic Vice", "name": "Tropic Vice",
"theme": "tropics", "theme": "tropics",
"seed": 71644, "seed": 67890,
"playerArmy": "arm", "playerArmy": "arm",
"playerCommander": "vance", "playerCommander": "vance",
"enemies": [ "enemies": [
@ -628,7 +628,7 @@
{ {
"speaker": "ethel", "speaker": "ethel",
"mood": "idle", "mood": "idle",
"text": "Cy-Bro starts ahead and pushes early. Rocket tanks out-range everything CORE fields." "text": "Cy-Bro starts ahead and pushes early. Rockets crack their base open — armour holds the line getting there."
} }
], ],
"victoryLine": { "victoryLine": {
@ -646,102 +646,102 @@
"h": 96, "h": 96,
"theme": "tropics", "theme": "tropics",
"rows": [ "rows": [
"~~r....^^^^^^^^^^^^...........rr~~~...r...^^^^^^^^^^^^...r...~~~rr...........^^^^^^^^^^^^....r~~", "...............rr.rr.............rr....rrrr~~~....~~~rrrr....rr.............rr.rr...............",
"~~.....^^^^^^^^^^^^...........r.~~~........^^^....^^^........~~~.r...........^^^^^^^^^^^^.....~~", "r.............rr..rr...................rrr~~~~~..~~~~~rrr...................rr..rr.............r",
"~~....rr^^^^^^^^^^^............r~~~~.r....................r.~~~~r............^^^^^^^^^^^rr....~~", "..........rrrrrr...rrr...................r~~~~~..~~~~~r...................rrr...rrrrrr..........",
"~~~......^^^...................~~~~~~~rr................rr~~~~~~~...................^^^......~~~", ".........rrrrr.......rr..................~~~~~~~~~~~~~~..................rr.......rrrrr.........",
"~~~~.....rr....................~~~~~~~~~................~~~~~~~~~....................rr.....~~~~", ".......rr^^^^^............................~~~~~~~~~~~~............................^^^^^rr.......",
"~~~~......rr.....................~~~~~~rr..............rr~~~~~~.....................rr......~~~~", "...r.^^^^^^^^^^..................................................................^^^^^^^^^^.r...",
"~~~~......rr..............rr.....~~.....rr............rr.....~~.....rr..............rr......~~~~", "...r^^^^^^^^^^^^................................................................^^^^^^^^^^^^r...",
"~~~..................rrrrrrr.....~......rr............rr......~.....rrrrrrr..................~~~", "..r^^^^^^^^^^^^^^^^...rr.........^r..........................r^.........rr...^^^^^^^^^^^^^^^^r..",
"~~~.................rrrrrrrr.....~r..........................r~.....rrrrrrrr.................~~~", "..^^^^^^^^^^^^^^^^^^^^^rrrr...r^^^^..........................^^^^r...rrrr^^^^^^^^^^^^^^^^^^^^^..",
"~~~~................rrrr.............rr..................rr.............rrrr................~~~~", ".^^^^^^^^^^^^^^^^^^^^^^r.rr...^^^^^..........................^^^^^...rr.r^^^^^^^^^^^^^^^^^^^^^^.",
"~~~~~...............rrrr.............rr..................rr.............rrrr...............~~~~~", "^^^^^^^^^^^^^^^^^^^^^^^^..r...^^^^^^^.r..................r.^^^^^^^...r..^^^^^^^^^^^^^^^^^^^^^^^^",
"~~~~~~................rrr...........rr....................rr...........rrr................~~~~~~", "^^^^^^^^^^^^^^^^^^^^^^^^^^^....^^^^^^^^^................^^^^^^^^^....^^^^^^^^^^^^^^^^^^^^^^^^^^^",
"~~~~~~r...............rr................................................rr...............r~~~~~~", "...^^^^..^.........^^^^^^^^.....^^^^^^^^^..............^^^^^^^^^.....^^^^^^^^.........^..^^^^...",
"~~~~~.rr................................................................................rr.~~~~~", "...rrr.............^^^^^^^^.....^^^^^^^^^..............^^^^^^^^^.....^^^^^^^^.............rrr...",
"~~~...rr................................................................................rr...~~~", "...........^^^.....^^^^^^^^....^^^^^^^^^................^^^^^^^^^....^^^^^^^^.....^^^...........",
"~~rr........................................................................................rr~~", "..........^^^^^....^^^^^^^.....^^^^^^^....................^^^^^^^.....^^^^^^^....^^^^^..........",
"~~rr....................rr..............rr............rr..............rr....................rr~~", "..........^^^^^.....^^^^rr.....^^^^^^...rr............rr...^^^^^^.....rr^^^^.....^^^^^..........",
".................rr....rrr..............rr............rr..............rrr....rr.................", "...........^^^^....r^^^rrr.....^^^^^^..rrrr..........rrrr..^^^^^^.....rrr^^^r....^^^^...........",
".................rr..~~.....r....r...........rrrrrr...........r....r.....~~..rr.................", "...........^^^^r...rr........r^^^^^^^^^rrrrr........rrrrr^^^^^^^^^r........rr...r^^^^...........",
".................r..~~~~...~~~~~~~~~.......^^^^..^^^^.......~~~~~~~~~...~~~~..r.................", "...........^^^^.r............^^^^^^^^^^^^^r..........r^^^^^^^^^^^^^............r.^^^^...........",
"..................r.~~~~..~~~~~~~~~~~......^^^^..^^^^......~~~~~~~~~~~..~~~~.r..................", "...........^^^^^rr..........^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^..........rr^^^^^...........",
"..................r~~~~~~~~~~~~~~~~~~.....^^^^^^^^^^^^.....~~~~~~~~~~~~~~~~~~r..................", "............^^..rr..........^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^..........rr..^^............",
".................r.~~~~~~~~~~~~~~~~~~.....^^^^^^^^^^^^.....~~~~~~~~~~~~~~~~~~.r.................", "....rrr.....................^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.....................rrr....",
"................rrr~~~~~~~~~~~~~~~~~~......^^^^..^^^^......~~~~~~~~~~~~~~~~~~rrr................", "....rrrr......................^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^......................rrrr....",
"..............rrrr~~~~~~~.~~~~~~~~~~~...rr.^^^^..^^^^.rr...~~~~~~~~~~~.~~~~~~~rrrr..............", "......r...........................^^^^^^^^^^^^^^^^^^^^^^^^^^^^...........................r......",
"..............rrr.~~~~~~...~~~~~~~~~~...rrr^^^^..^^^^rrr...~~~~~~~~~~...~~~~~~.rrr..............", "...................................^^^^^^^^^^^^^^^^^^^^^^^^^^...................................",
"..............r............................^^^^..^^^^............................r..............", "rrr.................................^^^^^^^^^^^^^^^^^^^^^^^^.................................rrr",
"..............r...........................^^^^^^^^^^^^...........................r..............", "..r..................................^......rrr..rrr......^..................................r..",
"............rrrrrr.......................^^^^^^^^^^^^^^.......................rrrrrr............", "........rrr..........................................................................rrr........",
"........rr.rrrrrrr......................^^^^^^^^^^^^^^^^......................rrrrrrr.rr........", "........rrr..........................................................................rrr........",
"........rrrrr..rrr....................^^^^^^^^^^^^^^^^^^^^....................rrr..rrrrr........", "........rrr......rr...........rr................................rr...........rr......rrr........",
"...........rr...rr...................^^^^^^^^^^^^^^^^^^^^^^...................rr...rr...........", "r.........r.....rr............rr...rr......................rr...rr............rr.....r.........r",
"...........rr....r...........^......^^^^^^^^^^^^^^^^^^^^^^^^......^...........r....rr...........", "................rr...........~..rrrrr......................rrrrr..~...........rr................",
"...........................r^^^...r^^^^^^^^^^^^^^^^^^^^^^^^^^r...^^^r...........................", "..................rrrr.......~~~.r.~~~~~~~............~~~~~~~.r.~~~.......rrrr..................",
"r...........................^^^....^^^^^^^^^^^^^^^^^^^^^^^^^^....^^^...........................r", "..................rrrr.......~~~...~~~~~~~~r........r~~~~~~~~...~~~.......rrrr..................",
"...................................r^^^^^^^^^^^^^^^^^^^^^^^^r...................................", "................rrrrrr.......~~......~~~~~~~~......~~~~~~~~......~~.......rrrrrr................",
"......................................^^^^^^^^^^^^^^^^^^^^......................................", "................rrrrr.................r..~~~~~....~~~~~..r.................rrrrr................",
".................................rrr.....^^^^^^..^^^^^^.....rrr.................................", ".................rrr......................~~~~....~~~~......................rrr.................",
".........MM.....................rrrrr.....^^^^....^^^^.....rrrrr.....................MM.........", "..................rr....~.................~~~~~rr~~~~~.................~....rr..................",
".........MM.....^^...............rrrrr....................rrrrr...............^^.....MM.........", "............rr....rr...r~.................~~~~~..~~~~~.................~r...rr....rr............",
"................^^...............rrrrr....................rrrrr...............^^................", "~r..........~~....rr...r~.................~~~~~..~~~~~.................~r...rr....~~..........r~",
"...............^^^...............rrrrr....................rrrrr...............^^^...............", "~~............~~~~rr...~~........^r.......~~~~~~~~~~~~.......r^........~~...rr~~~~............~~",
"................^^^...............r..........................r...............^^^................", "~~..............~~rr..r~~.......^^^.......~~~~~~~~~~~~.......^^^.......~~r..rr~~..............~~",
"~~...............^^..........................................................^^...............~~", "~~~..............~~.MMr~~.......^^^^......~~~~~~~~~~~~......^^^^.......~~rMM.~~..............~~~",
"~~~.MM...........^^..........................................................^^...........MM.~~~", "~~~r.............~~.MM.~~......^^^^^....rr~~~~~~~~~~~~rr....^^^^^......~~.MM.~~.............r~~~",
"~~~.MM.......MM...^...rr................................................rr...^...MM.......MM.~~~", "~~~...............~....~~......^^^^^....rr~~~~~~~~~~~~rr....^^^^^......~~....~...............~~~",
"~~...........MM.......rr................................................rr.......MM...........~~", "~~~..MM...........~....~~......^^^^^....rr.~~~~..~~~~.rr....^^^^^......~~....~...........MM..~~~",
"~.....................rr................................................rr.....................~", "~~r..MM...........r............^^^^^.......~~~....~~~.......^^^^^............r...........MM..r~~",
"r..............................................................................................r", "~~..............................^^^^........rr....rr........^^^^..............................~~",
"....MM............rr........................................................rr............MM....", "~~...................rr.........^^^^........................^^^^.........rr...................~~",
"....MM............rr........................................................rr............MM....", "~.............MM....rrr.........^^^^........................^^^^.........rrr....MM.............~",
"..................rr~~~~................................................~~~~rr..................", "..............MM....rrr.........^^^..........................^^^.........rrr....MM..............",
"..................r.~~~~~~~..r..............^^....^^..............r..~~~~~~~.r..................", "................................^^^..........................^^^................................",
"...................~~~~~~~~rr..............^^^....^^^..............rr~~~~~~~~...................", "................................^^^..........................^^^................................",
"...................~~~~~~~~~...............^^^^..^^^^...............~~~~~~~~~...................", ".................................^^r........................r^^.................................",
"rr.................~~~~~~~~~..............^^^^^..^^^^^..............~~~~~~~~~.................rr", "...................................rr......................rr...................................",
"rrr................~~~~~~~~~..............^^^^^..^^^^^..............~~~~~~~~~................rrr", "..........MM.......................rr......................rr.......................MM..........",
"..r................~~~~~~~~~................^^....^^................~~~~~~~~~................r..", "..........MM........................................................................MM..........",
"...................~~~~~~~~..........................................~~~~~~~~...................", ".....rr..................................................................................rr.....",
"..................r~~~~~~~~.r......................................r.~~~~~~~~r..................", ".....rr..................................................................................rr.....",
"...................r~~~~~~.rrr.rr......rrrr..........rrrr......rr.rrr.~~~~~~r...................", ".....rr..................................................................................rr.....",
"...................r~~~~rrrrrrrrr.....rrrr............rrrr.....rrrrrrrrr~~~~r...................", ".....rr..................................................................................rr.....",
"....................rr..rrrrrr.rrr....rrrr............rrrr....rrr.rrrrrr..rr....................", "....^rr.................~~~..........................................~~~.................rr^....",
".............r..........rrrrr....r....rr................rr....r....rrrrr..........r.............", "..^^^^^................~~~~~~~~....rr......................rr....~~~~~~~~................^^^^^..",
"...........rrrrr.........rr..........rr..................rr..........rr.........rrrrr...........", ".^^^^^^^r.............~~~~~~~~~~...~~~~~~r.....rr.....r~~~~~~...~~~~~~~~~~.............r^^^^^^^.",
"..........~~~.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..~~~~~~........", "..^^^^^^r....r.........r~~~~~~r....rr~~~~~~~~~~~~~~~~~~~~~~rr....r~~~~~~r.........r....r^^^^^^..",
"........~~~~~~..rr............................................................rr..~~~~~~........", "...^^^^......r...............rr....r....~~~~~~~~~~~~~~~~....r....rr...............r......^^^^...",
"........~~~~~~..rr............................................................rr..~~~~~~........", "....^^r......rr..............rr....rr.....~~~~~~~~~~~~.....rr....rr..............rr......r^^....",
"........~~~~~~~.......................rrrr............rrrr.......................~~~~~~~........", ".....rr.......rr.............rr.....rr....~~~~~~~~~~~~....rr.....rr.............rr.......rr.....",
".......~~~~~~~~......r..........r^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^r..........r......~~~~~~~~.......", ".....rr......................rr......r.....~~~~..~~~~.....r......rr......................rr.....",
".......~~~~~~~~.....rrrr........^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^........rrrr.....~~~~~~~~.......", ".....rr......................rr.^^^........~~~~rr~~~~........^^^.rr......................rr.....",
".......~~~~~~~~....rr.rr.......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.......rr.rr....~~~~~~~~.......", ".....rr......................rr^^^^^.......~~~~rr~~~~.......^^^^^rr......................rr.....",
".......~~~~~~~~.....~~.r.......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.......r.~~.....~~~~~~~~.......", ".....rr......................r^^^^^^.......~~~~..~~~~.......^^^^^^r......................rr.....",
".......~~~~~~~~.....~~..r......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^......r..~~.....~~~~~~~~.......", "..............................^^^^^^.......~~~~..~~~~.......^^^^^^..............................",
".......~~~~~~~~.....~~..rrr....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^....rrr..~~.....~~~~~~~~.......", "..............................^^^^^^.......~~~....~~~.......^^^^^^..............................",
"......r~~~~~~~~.....~~~..rrr....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^....rrr..~~~.....~~~~~~~~r......", ".....................^r.......^^^^^^r.......~~....~~.......r^^^^^^.......r^.....................",
"......rr~~~~~~~.....~~~..rrr.....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.....rrr..~~~.....~~~~~~~rr......", ".....................^r........^^^^^r.......~~....~~.......r^^^^^........r^.....................",
"......rr~~~~~~~.....~~~~............^^^^^^^^^^^^^^^^^^^^^^^^............~~~~.....~~~~~~~rr......", "...............................^^^^rr......................rr^^^^...............................",
"......rr.~~~~~r.....~~~~...............rrrr..........rrrr...............~~~~.....r~~~~~.rr......", "................................^^^..........................^^^................................",
"^.........~~~~r...r~~~~~~..............rrrrrrrr..rrrrrrrr..............~~~~~~r...r~~~~.........^", "................................^^^..........................^^^................................",
"^^............rr..r~~~~~~~................rrrrrrrrrrrr................~~~~~~~r..rr............^^", "...............................rr.......rrr..........rrr.......rr...............................",
"^^^...........rr..r~~~~~~~................rrr..rr..rrr................~~~~~~~r..rr...........^^^", "r..............................rr.......rrr..........rrr.......rr..............................r",
"^^^.....rr........r~~~~~~~...............rrrr......rrrr...............~~~~~~~r........rr.....^^^", "r..............................rr.........r..........r.........rr..............................r",
"^^^^....rr.........~~~~~~~~...............rr........rr...............~~~~~~~~.........rr....^^^^", ".....rr........................r..........rr........rr..........r........................rr.....",
"^^^^...............~~~~~~~~..........................................~~~~~~~~...............^^^^", ".....rr......................rr...........rrr......rrr...........rr......................rr.....",
"^^^^...............~~~~~~~~..........................................~~~~~~~~...............^^^^", "...........rr................rr............rrrr..rrrr............rr................rr...........",
"^^^^...............~~~~~~~~..........................................~~~~~~~~...............^^^^", "...........rr................rr............rrrr..rrrr............rr................rr...........",
"^^^................~~~~~~~~..........................................~~~~~~~~................^^^", "...........rr.....rr.........rr...........rrrr....rrrr...........rr.........rr.....rr...........",
"^^^................~~~~~~~~...........r....r........r....r...........~~~~~~~~................^^^", "..................rr........................................................rr..................",
"^^^.................~~~~~~...........rrr..rrr......rrr..rrr...........~~~~~~.................^^^", "..................rrr......................................................rrr..................",
"^^.....r......rr.r..~~~~~~............r....r........r....r............~~~~~~..r.rr......r.....^^" "..................r.rr..........r..............................r..........rr.r.................."
], ],
"starts": [ "starts": [
{ {
@ -787,7 +787,7 @@
"id": "m06", "id": "m06",
"name": "Annihilation", "name": "Annihilation",
"theme": "snowfields", "theme": "snowfields",
"seed": 63541, "seed": 80808,
"playerArmy": "arm", "playerArmy": "arm",
"playerCommander": "vance", "playerCommander": "vance",
"enemies": [ "enemies": [
@ -817,7 +817,7 @@
{ {
"speaker": "ethel", "speaker": "ethel",
"mood": "idle", "mood": "idle",
"text": "KLAXON at full strength with a base already standing. Nothing clever left — out-build it and end it." "text": "KLAXON at full strength with a base already standing, dug in behind its own defences. Nothing clever left — out-build it and end it."
} }
], ],
"victoryLine": { "victoryLine": {
@ -835,102 +835,102 @@
"h": 96, "h": 96,
"theme": "snowfields", "theme": "snowfields",
"rows": [ "rows": [
"................^^^...^^^^^.............^^^^^^.......r...rr....r.........r....r...........rrr...", "~~~r....r...rrrrrr..........r.~~~~~~...............^^^^^^^^^^^^^^..................r....r..^^^^^",
"..............MM.^^..r^^^^^..............^^^^^............................................rrr...", "~~~r..........r....MM.........~~~~~~...............^^^^^^^^^^^^^...........................^^^^^",
"..............MM......^^^^^^...............rr..............................................rr...", "~~~...............rMM.........~~~~~r................^^^^^^^^^^^............................^^^^^",
"......................^^^^^^..............r...............................................rrr...", "~~~...............rr..............r...................^^^^^^............................rr.^^^^^",
"....................................................................rrr...................rrr...", "rr................rrrr....................................r..........rr.................^^^^^^^^",
"........................rr..........................................rrrr.....................r..", "...................rrrr.r............................................rr................^^^^^^^^^",
"........................rr.........................................^..rr.......rr............rr.", "...................rrrrrrrr............................................................^^^^^^^^^",
"................................................................r^^^^^..........rr...........rr.", "....................r...rr..................r...........................................^^^^^^rr",
".......................~~~.......................................^^^^^^.......................rr", "r.....MM...................................r^^................rr........................^^^^^^..",
"......................~~~~~.....................................^^^^^^^^........................", "......MM.............^^^..................^^^^^............rrrrrrrrr...................^^^^^^^^.",
"......................~~~~~......................................^^^^^^^........................", "..MM................^^^^^^.............rr^^^^^^^...........rrrrrrrrr.................r^^^^^^^^^^",
"......................~~~~.....rr..............................rrrr...........................rr", "..MM...............^^^^^^^...rr........r^^^^^^^^^............r....^^r................^^^^^^^^^^^",
".......................~~......rr...........................rrrrr.......................rr....rr", "...................^^^^^^^..rrr..rr....r^^^^^^^^^.................^^^................^^^^^^^^^^^",
"...............................r...rr.................rr....rrrr.........................r....rr", "..................r^^^^^^^rrrrrr.rr....r^^^^^^^^^..................^........rr.......^^^^^^^^^^^",
"................................rrrrr................rrr.....rrr................................", "..................r^^^^^^...rrrr..........^^^^^^rr.....rrrrrrr..............rrrrr.....^^^^^^^^^.",
"...............MM................rrrr..............rrrr.......rrr...............................", "..................^^^^^^....rrrrr................rrrrr~~~~~rrr..............rrrrr........r...r..",
"...............MM...............rrr...............rrrrr...rr..rr................................", ".................^^^^^^r...rrr.rr..~.............rrr~~~~~~~~~.............................rrr...",
"......r..........................rr..r............rrrr....rrrrrr................rrr............r", "~..............^^^^^.^.....rr....~~~~.rrr........rrr~~~~~~~~~..............................r....",
"r....MM.............................rrr.........................................rrr.............", "~~.........r^^^^^^..............~~~~..rrr.............~~~~~~....................................",
".....MM..............................rr...........................~~~~~~~~~~~~~~~~~.............", "~~MM.......^^^^^...............~~~~........................rrrrrr...............................",
".......................^^^^..rr......rr...r~~~..................~~~~~~~~~~~~~~~~~~~~............", "~~MM.......^^^^^..............~~~~~........................rrrrrr...............................",
".........MM...........^^^^^.rrr......rr...~~~~~................~~~~~~~~~~~~~~~~~~~~~~~..........", "~~~.......r^^^^^.............~~~~~~.........................rrrr................................",
".........MM.........^^^^^^^rrrr..........~~~~~~~...............~~~~~~~~~~~~~~~~~~~~~~~~..rr....r", "~~........^^^^^^^.............~~~~~.............................................................",
"^^................r^^^^^rr...rr..........~~~~~~~...............~~~~~~~~~~~~~~~~~~~~~~~~.rrrrrrrr", "..........^^^^^^^^..............~~~.............................................rrr.....r^......",
"^^^...............^^^^^..................~~~~~~~.......rr......~~~~~~~~~~~~~~~~~~~~~~~~~.rrrr...", ".........r^^^^^^^^................r.............................................rrr....^^^^.....",
"^^^................^^^^...................~~~~~........rr.......~~~~~~~~~~~~~~~~~~~~~~~~~~......", ".........r^^^^^^^^rrrr.....r....................................................rrrr...^^^^^....",
"r..................^^^^.........................................~~~~~~~~~~~~~~~~~~~~~~~~~~~~....", "r..........^^^^^^^rrrr....rrr......................................................rr..^^^^^....",
"r..................rr^............................................~~~~~~~~~~~~~~~~~~~~~~~~~~~~..", "r.............rrr....rrrrr.rr......................................................rrrrr^^^.....",
".......................................................................~~~~~~~~~~~~~~~~~~~~~~~~.", ".rrrr........rrrr.......^^..r.......................................................rr..........",
"...rr..................rr.................................................~~~~~~~~~~~~~~~~~~~~~~", "..rrrr.......rr........^^^^^^.........................r...r...............................rrr...",
"r..rr..................rrr.......................................................~~~~~~~~~~~~~~~", "^^.rrr............rr..r^^^^^^r.......................^^^^^^^............................^^^^^^^^",
"r..rr..................rrrr........................................................~~~~~~~~~~~~~", "^^.rrrr...........rrrr.^^^^^.......................^^^^^^^^^^^^.r.......................^^^^^^^^",
"...rr....................rr..........rr....................r........................~~~~~~~~~~~~", "^^rrrrr........rrrr...^^^^^.....^^^^^.............^^^^^^^^^^^^^^^rr....................^^^^^^^^^",
"..........................rr.........rr...................rrr.......................~~~~~~~~~~~~", "^^rrr..........rrr^...^^^^.....r^^^^^.............^^^^^^^^^^^^^^^^^....................^^^^^^^^^",
"..........................rr......................rr.......r........................r~~~~~~~~~~~", "^^.rr..........rrr....^^^r.....r^^^^...............^^^^^^^^^^^^^........................^^^^^^^^",
".........................rrrr...rr................rr.................................r~~~~~~~~~~", "^..rr..rr...............r.......^^..................^^^^^^^^^..............rrr..................",
".........................rrrr...rr..r.............rr.................................rrr~~~~~~..", "....r.rrr...............r...^^^^.....................r^^^^................rrrr~.................",
".........................r.r.....rrrrrr.............................................rrrrrrrrr...", ".....rrrr..............rrr..^^^^.....................rr...................rrrr~.................",
".................................rr..rr................^^...........................rr.rr.......", "r......rr...............r...^^^^.....................rr.........................................",
"................................rr...................^^^^^..rr..................................", ".r...........................^^^^^^...r...........rrr........................................rr.",
".....rrr............~~..........rr..............^^^^^^^^^^..rr..................................", ".rr..........................^^^^^^^rrrr..........rrr.......................................rrr.",
".....rrr.......rr...~~..........rr........^^^^^^^^^^^^^^^^..rr..................................", "..r...................~~~....^^^^^^^^^^r.r..........r.......................................rrr.",
".......rr......rr..~~~..........rr.......^^^^^^^^^^^^^^^^^..rr..................................", "~~~....rrr..........~~~~~~...^^^^^^^^^^^^^^^^^^.............................................rrrr",
"........rr.........~~~..................^^^^^^^^^^^^^^^^^^..rrrr..............^.................", "~~~~...rrrr........~~~~~~~....^^^^^^^^^^^^^^^^^^..........rr..................................~~",
"........rr.........~~~~.................^^^^^^^^^^^^^^^^^^^.rrrr.............^^^................", "~~~~...rrrr........~~~~~~~....^^^^^^^^^^^^^^^^^^^.........rr......~~~........................~~~",
"...................~~~~................^^^^^^^^^^^^^^^^^^^..rrr..............^^^rrr......rrrrrrr", "~~~~...rr..........~~~~~~~....^^^^^^^^^^^^^^^^^^^..........r......~~~........................~~~",
"...................~~~..................^^^^^^^^^^^^^^^^^^..rr...............^^^rrr......rrrrrrr", "~~~~...rr..........~~~~~~~....^^^^^^^^^^^^^^^^^^.r........rr......~~..........................~~",
"....................~~..................^^^^^^^^^^^^^^^^......................^................r", "~~~~...rr..........~~~~~~~....^^^^^^^^^^^^^^^^^..rrrrr....rr..r...~~.....r....................~~",
"r................^......................^^^^^^^^^^^^^^^^..................~~....................", "~~....................r.....~~...r..rr....rrrrr..^^^^^^^^^^^^^^^^^....~~~~~~~..........rr...~~~~",
"rrrrrrr......rrr^^^...............rr..^^^^^^^^^^^^^^^^^^..................~~~...................", "~~..........................~~......rr........r.^^^^^^^^^^^^^^^^^^....~~~~~~~..........rr...~~~~",
"rrrrrrr......rrr^^^..............rrr..^^^^^^^^^^^^^^^^^^^................~~~~...................", "~~~........................~~~......r..........^^^^^^^^^^^^^^^^^^^....~~~~~~~..........rr...~~~~",
"................^^^.............rrrr.^^^^^^^^^^^^^^^^^^^.................~~~~.........rr........", "~~~........................~~~......rr.........^^^^^^^^^^^^^^^^^^^....~~~~~~~........rrrr...~~~~",
".................^..............rrrr..^^^^^^^^^^^^^^^^^^..................~~~.........rr........", "~~..................................rr..........^^^^^^^^^^^^^^^^^^....~~~~~~~........rrrr...~~~~",
"..................................rr..^^^^^^^^^^^^^^^^^.......rr..........~~~..rr......rr.......", "rrrr.............................................^^^^^^^^^^^^^^^^^^...~~~~~~..........rrr....~~~",
"..................................rr..^^^^^^^^^^^^^^^^........rr..........~~...rr.......rrr.....", ".rrr.......................................r..........r.r^^^^^^^^^^....~~~...................r..",
"..................................rr..^^^^^^^^^^..............rr..........~~............rrr.....", ".rrr.......................................rrr..........rrrr^^^^^^^..........................rr.",
"..................................rr..^^^^^...................rr................................", ".rr........................................rrr...........r...^^^^^^...........................r.",
".......rr.rr...........................^^................rr..rr.................................", ".........................................rr.....................^^^^...r...............rr......r",
"...rrrrrrrrr.............................................rrrrrr.....r.r.........................", ".................~rrrr...................rr.....................^^^^..rrr..............rrrr.....",
"..~~~~~~rrr.................................rr.............r..rr...rrrr.........................", ".................~rrrr................^^^^r.....................^^^^...r...............rrr.r....",
"~~~~~~~~~~r.................................rr................rr...rrrr.........................", "..................rrr..............^^^^^^^^^..................^^.......r...............rr..rr..^",
"~~~~~~~~~~~r........................r.......rr......................rr..........................", "^^^^^^^^........................^^^^^^^^^^^^^...............^^^^r.....r^^^....rrr..........rr.^^",
"~~~~~~~~~~~~.......................rrr...................rr.........rr..........................", "^^^^^^^^^....................^^^^^^^^^^^^^^^^^.............^^^^^r.....^^^^...^rrr..........rrr^^",
"~~~~~~~~~~~~........................r....................rr..........rr....................rr...", "^^^^^^^^^....................rr^^^^^^^^^^^^^^^.............^^^^^.....^^^^^...rrrr........rrrrr^^",
"~~~~~~~~~~~~~........................................................rrrr..................rr..r", "^^^^^^^^.......................r.^^^^^^^^^^^^.......................^^^^^.rrrr...........rrrr.^^",
"~~~~~~~~~~~~~~~.......................................................rrr..................rr..r", "^^^^^^^^............................^^^^^^^.......................r^^^^^^r..rr............rrr.^^",
"~~~~~~~~~~~~~~~~~~~~~~.................................................rr..................rr...", "...rrr...............................r...r.........................^^^^^^........rr.......rrrr..",
".~~~~~~~~~~~~~~~~~~~~~~~~.......................................................................", "..........rr.......................................................r..^^.......rrrr........rrrr.",
"..~~~~~~~~~~~~~~~~~~~~~~~~~~~~............................................^rr..................r", ".....^^^rrrrr......................................................rr.rrrrr....rrr.............r",
"....~~~~~~~~~~~~~~~~~~~~~~~~~~~~.........................................^^^^..................r", "....^^^^^..rr......................................................rrr....rrrr^^^^^^^..........r",
"......~~~~~~~~~~~~~~~~~~~~~~~~~~.......rr........~~~~~...................^^^^................^^^", "....^^^^^...rrrr....................................................r.....rrrr^^^^^^^^r.........",
"...rrrr.~~~~~~~~~~~~~~~~~~~~~~~~~......rr.......~~~~~~~..................^^^^^...............^^^", ".....^^^^....rrr.............................................r................^^^^^^^^r.........",
"rrrrrrrr.~~~~~~~~~~~~~~~~~~~~~~~~...............~~~~~~~..........rr...rr^^^^^r................^^", "......^r.....rrr.............................................~~~..............^^^^^^^^..........",
"r....rr..~~~~~~~~~~~~~~~~~~~~~~~~...............~~~~~~~..........rrrr^^^^^^^.........MM.........", ".............................................................~~~~~.............^^^^^^^........~~",
"..........~~~~~~~~~~~~~~~~~~~~~~~................~~~~~...rr......rrr.^^^^^...........MM.........", "................................rrrr.........................~~~~~~.............^^^^^r.......~~~",
"............~~~~~~~~~~~~~~~~~~~~..................~~~r...rr......rr..^^^^.......................", "...............................rrrrrr........................~~~~~..............^^^^^.......MM~~",
".............~~~~~~~~~~~~~~~~~...........................rr..............................MM.....", "...............................rrrrrr........................~~~~...............^^^^^.......MM~~",
".............rrr.........................................rrr.............................MM....r", "....................................~~~~~~.............rrr..~~~~..............^^^^^^r.........~~",
"r............rrr................rrrrrr....rrrr............r..rr..........................r......", "....r..............................~~~~~~~~~rrr........rrr.~~~~....rr.....^.^^^^^..............~",
"................................rr..rr...rrrrr...............rrr...............MM...............", "...rrr.............................~~~~~~~~~rrr.............~..rr.rrr...r^^^^^^.................",
"...............................rrr.......rrrr..............rrrr................MM...............", "..r...r........rrrrr..............rrr~~~~~rrrrr................rrrrr....^^^^^^..................",
"................................rrr.....rrr................rrrrr................................", ".^^^^^^^^^.....rrrrr..............rrrrrrr.....rr^^^^^^..........rrrr...^^^^^^r..................",
"rr....r.........................rrrr....rr.................rr...r...............................", "^^^^^^^^^^^.......rr........^..................^^^^^^^^^r....rr.rrrrrr^^^^^^^r..................",
"rr....rr.......................rrrrr...........................rr......~~.......................", "^^^^^^^^^^^................^^^.................^^^^^^^^^r....rr..rrr..^^^^^^^...................",
"rr...........................rrrr..............................rr.....~~~~......................", "^^^^^^^^^^^................r^^....r............^^^^^^^^^r........rr...^^^^^^^...............MM..",
"........................^^^^^^^......................................~~~~~......................", "^^^^^^^^^^r.................rrrrrrrrr...........^^^^^^^rr.............^^^^^^................MM..",
"........................^^^^^^^^.....................................~~~~~......................", ".^^^^^^^^...................rrrrrrrrr............^^^^^..................^^^.............MM......",
"rr.......................^^^^^^.......................................~~~.......................", "..^^^^^^........................rr................^^r...................................MM.....r",
".rr...........rr..........^^^^^r................................................................", "rr^^^^^^...........................................r..................rr...r....................",
".rr............rr.......rr..^.........................................rr........................", "^^^^^^^^^............................................................rrrrrrrr...................",
"..r.....................rrrr..........................................rr........................", "^^^^^^^^^................rr............................................r.rrrr...................",
"...rrr...................rrr....................................................................", "^^^^^^^^.................rr..........r....................................rrrr................rr",
"...rrr...............................................r..............^^^^^^......................", "^^^^^.rr............................^^^^^^...................r..............rr...............~~~",
"...rr..............................................rr...............^^^^^^......MM..............", "^^^^^............................^^^^^^^^^^^................r~~~~~.........MMr...............~~~",
"...rrr............................................^^^^^..............^^^^^r..^^.MM..............", "^^^^^...........................^^^^^^^^^^^^^...............~~~~~~.........MM....r..........r~~~",
"...rrr...........r....r.........r....rr...r.......^^^^^^.............^^^^^...^^^................" "^^^^^..r....r..................^^^^^^^^^^^^^^...............~~~~~~.r..........rrrrrr...r....r~~~"
], ],
"starts": [ "starts": [
{ {

View File

@ -402,9 +402,9 @@
], ],
"armorMul": { "armorMul": {
"infantry": 0.3, "infantry": 0.3,
"light": 0.6, "light": 0.35,
"medium": 1.0, "medium": 0.3,
"heavy": 1.4, "heavy": 0.3,
"structure": 1.5, "structure": 1.5,
"fortification": 1.6, "fortification": 1.6,
"air": 2.6 "air": 2.6
@ -418,6 +418,78 @@
"sound": "sfx-ta-rocket-1", "sound": "sfx-ta-rocket-1",
"impactSound": "sfx-ta-rocket-2" "impactSound": "sfx-ta-rocket-2"
}, },
{
"id": "megatankgun",
"name": "Twin 150mm Cannon",
"kind": "ballistic",
"damage": 145,
"reload": 2.0,
"burst": 1,
"range": 380,
"speed": 700,
"spread": 0.015,
"leadTarget": true,
"aoe": 44,
"aoeFalloff": 0.15,
"targets": [
"ground"
],
"barrels": 2,
"barrelSpacing": 9,
"armorMul": {
"infantry": 1.3,
"light": 1.15,
"medium": 1.0,
"heavy": 0.8,
"structure": 1.2,
"fortification": 1.1,
"air": 1.0
},
"fx": {
"style": "shell",
"color": "#ffcf6a",
"width": 4,
"muzzle": 22
},
"sound": "sfx-battle-tank"
},
{
"id": "megatankrockets",
"name": "Side Rocket Pods",
"kind": "guided",
"damage": 55,
"reload": 1.32,
"burst": 1,
"range": 400,
"minRange": 90,
"speed": 330,
"turnRate": 2.1,
"aoe": 42,
"aoeFalloff": 0.15,
"targets": [
"ground",
"air"
],
"barrels": 2,
"barrelSpacing": 20,
"armorMul": {
"infantry": 0.3,
"light": 0.6,
"medium": 1.0,
"heavy": 1.3,
"structure": 1.4,
"fortification": 1.5,
"air": 2.4
},
"fx": {
"style": "rocket",
"color": "#ff5c3c",
"width": 2,
"trail": true
},
"sound": "sfx-ta-rocket-1",
"impactSound": "sfx-ta-rocket-2"
},
{ {
"id": "shoulderrocket", "id": "shoulderrocket",
"name": "Shoulder Rocket", "name": "Shoulder Rocket",
@ -878,6 +950,38 @@
"spritePx": 56, "spritePx": 56,
"moveSound": "sfx-engine-heavy" "moveSound": "sfx-engine-heavy"
}, },
{
"id": "megatank",
"name": "Megatank",
"role": "combat",
"size": "medium",
"radius": 33,
"hp": 3200,
"speed": 65,
"turnRate": 1.6,
"moveClass": "tread",
"armorClass": "heavy",
"sight": 320,
"cost": {
"energy": 3200,
"mass": 1000
},
"buildTime": 80,
"builtBy": [
"advancedvehicleplant"
],
"weapons": [
"megatankgun",
"megatankrockets"
],
"sheetSlot": "unitSheet",
"frame": 15,
"turretFrame": 16,
"procShape": "tank",
"icon": 32,
"spritePx": 66,
"moveSound": "sfx-engine-heavy"
},
{ {
"id": "constructor", "id": "constructor",
"name": "Construction Vehicle", "name": "Construction Vehicle",
@ -920,6 +1024,39 @@
"spritePx": 50, "spritePx": 50,
"moveSound": "sfx-engine-heavy" "moveSound": "sfx-engine-heavy"
}, },
{
"id": "advancedconstructor",
"name": "Advanced Construction Vehicle",
"role": "builder",
"size": "medium",
"radius": 26,
"hp": 950,
"speed": 80,
"turnRate": 2.2,
"moveClass": "tread",
"armorClass": "light",
"sight": 360,
"cost": {
"energy": 900,
"mass": 220
},
"buildTime": 22,
"builtBy": [
"advancedvehicleplant"
],
"buildPower": 135,
"buildRange": 200,
"builds": [
"advancedmassgen",
"nuclearplant"
],
"sheetSlot": "unitSheet",
"frame": 17,
"procShape": "constructor",
"icon": 33,
"spritePx": 54,
"moveSound": "sfx-engine-heavy"
},
{ {
"id": "fighter", "id": "fighter",
"name": "Fighter", "name": "Fighter",
@ -1078,6 +1215,38 @@
"icon": 10, "icon": 10,
"idleAnimation": true "idleAnimation": true
}, },
{
"id": "nuclearplant",
"name": "Nuclear Power Plant",
"footprint": {
"w": 2,
"h": 2
},
"hp": 4200,
"armorClass": "structure",
"sight": 160,
"cost": {
"energy": 2200,
"mass": 260
},
"buildTime": 42,
"produce": {
"energy": 80
},
"storage": {
"energy": 900
},
"deathExplosion": {
"radius": 220,
"damage": 650
},
"upgradesFrom": "energygen",
"sheetSlot": "structureSheet",
"frame": 22,
"buildFrame": 23,
"procShape": "energyGen",
"icon": 35
},
{ {
"id": "massgen", "id": "massgen",
"name": "Mass Generator", "name": "Mass Generator",
@ -1110,6 +1279,39 @@
"icon": 11, "icon": 11,
"idleAnimation": true "idleAnimation": true
}, },
{
"id": "advancedmassgen",
"name": "Advanced Metal Generator",
"footprint": {
"w": 2,
"h": 2
},
"hp": 3200,
"armorClass": "structure",
"sight": 160,
"cost": {
"energy": 1400,
"mass": 150
},
"buildTime": 30,
"produce": {
"mass": 8.0
},
"upkeep": {
"energy": 60
},
"storage": {
"mass": 400
},
"terrainMultiplier": "massMultiplier",
"upgradesFrom": "massgen",
"sheetSlot": "structureSheet",
"frame": 24,
"buildFrame": 25,
"topperFrame": 26,
"procShape": "massGen",
"icon": 34
},
{ {
"id": "barracks", "id": "barracks",
"name": "Barracks", "name": "Barracks",
@ -1252,9 +1454,8 @@
"buildTime": 55, "buildTime": 55,
"buildPower": 140, "buildPower": 140,
"builds": [ "builds": [
"jeep", "megatank",
"tank", "advancedconstructor"
"rockettank"
], ],
"spawnOffset": { "spawnOffset": {
"x": 0, "x": 0,
@ -1451,7 +1652,7 @@
"defaults": { "defaults": {
"size": "medium", "size": "medium",
"symmetry": "mirror-x", "symmetry": "mirror-x",
"theme": "grasslands", "theme": "tropics",
"aiSkill": 3, "aiSkill": 3,
"playerArmy": "arm" "playerArmy": "arm"
}, },

View File

@ -302,16 +302,19 @@ function chooseBuilding(ctx, mine) {
} }
/** /**
* Static defence is a skill-4-and-up behaviour, like the Airfield is skill 3 and up. * Static defence is a skill-3-and-up behaviour, like the Airfield.
* *
* These structures hit hard for their cost a Laser Tower puts out 130 dps behind 1700 hp for * 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 * 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 * two equally-matched AIs both digging in grind rather than resolve. Reserving them for skill 3
* stronger opponents puts that pressure where a player goes looking for it and leaves the low * and up puts that pressure where a player goes looking for it once they're past the beginner
* end of the ladder playing the open game it was tuned for. Drop this to 3 (or 1) once there is * tier, and leaves skill 1-2 playing the open game they were tuned for: `urgentDefence` and
* a unit whose job is cracking fortifications. * `steadyDefence` are still called for every skill, but their own `ctx.skill < DEFENCE_MIN_SKILL`
* guard keeps returning null for skill 1-2 exactly as before, so lowering this constant alone
* touches only skill 3 and up. Drop it further (to 1) once there is a unit whose job is cracking
* fortifications.
*/ */
const DEFENCE_MIN_SKILL = 4; const DEFENCE_MIN_SKILL = 3;
/** Has anything hostile been inside the base perimeter recently? */ /** Has anything hostile been inside the base perimeter recently? */
function underThreat(ctx) { function underThreat(ctx) {
@ -325,9 +328,18 @@ function enemyAirSeen(ctx) {
/** /**
* The one structure the base needs RIGHT NOW, ahead of any economic consideration. Kept * 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 * deliberately tiny one tower, one launcher against aircraft. Everything beyond the first of
* beyond the first of each is a steady-state decision (see steadyDefence), because an AI that * each is a steady-state decision (see steadyDefence), because an AI that answers pressure by
* answers pressure by building walls stops building an army and loses slowly instead of fast. * building walls stops building an army and loses slowly instead of fast.
*
* The first Laser Tower is PROACTIVE, not a raid reaction: it goes up right after the opening
* economy (2 energygen/1 massgen/1 barracks/3 energygen/2 massgen/1 vehicleplant), the moment
* this function is first reachable, regardless of `underThreat`. A purely reactive first tower
* the previous behaviour never exists until the base has already been hit once, which is no
* help at all against a single decisive rush that goes straight for the Commander: the raid
* that would have triggered it IS the loss. Everything past this first one stays reactive
* (steadyDefence), which is what keeps two skill-3+ AIs from both digging in and turning a
* mirror match into a stalemate see the soak notes on that.
*/ */
function urgentDefence(ctx, mine) { function urgentDefence(ctx, mine) {
const { rules } = ctx; const { rules } = ctx;
@ -338,7 +350,7 @@ function urgentDefence(ctx, mine) {
// Anti-air first: a Laser Tower cannot shoot at aircraft at all, so a base being bombed gets // Anti-air first: a Laser Tower cannot shoot at aircraft at all, so a base being bombed gets
// nothing whatsoever from one. // nothing whatsoever from one.
if (aa && enemyAirSeen(ctx) && n('missilelauncher') < 1) return aa; if (aa && enemyAirSeen(ctx) && n('missilelauncher') < 1) return aa;
if (tower && underThreat(ctx) && n('lasertower') + n('missilelauncher') < 1) return tower; if (tower && n('lasertower') + n('missilelauncher') < 1) return tower;
return null; return null;
} }
@ -532,9 +544,11 @@ function applyCounters(ctx, weights) {
const seen = infantryish + armour; const seen = infantryish + armour;
if (seen) { if (seen) {
const armourShare = armour / seen; const armourShare = armour / seen;
// Tanks shred infantry; rockets and snipers answer armour and buildings. // The Rocket Tank's rocketpod is weak against vehicle armour (weak vs light/medium/heavy,
weights.tank *= 1 + (1 - armourShare) * 0.8; // strong vs structure/fortification/air) — Tanks are now the answer to an armour-heavy
weights.rockettank *= 1 + armourShare * 1.0 + (structures > 2 ? 0.3 : 0); // enemy, not Rocket Tanks. Rocket Tanks still lean into a base full of structures.
weights.tank *= 1 + (1 - armourShare) * 0.8 + armourShare * 0.4;
weights.rockettank *= 1 + (structures > 2 ? 0.5 : 0);
weights.sniper *= 1 + (1 - armourShare) * 0.5; weights.sniper *= 1 + (1 - armourShare) * 0.5;
weights.rockettrooper *= 1 + armourShare * 0.6; weights.rockettrooper *= 1 + armourShare * 0.6;
} }

View File

@ -671,7 +671,9 @@ const PROC_PAINTERS = {
structure(scene, key, spec, rules) { structure(scene, key, spec, rules) {
const defs = rules.buildings.filter((b) => b.sheetSlot === 'structureSheet'); const defs = rules.buildings.filter((b) => b.sheetSlot === 'structureSheet');
let count = 0; let count = 0;
for (const d of defs) count = Math.max(count, d.frame + 1, (d.buildFrame ?? -1) + 1); for (const d of defs) {
count = Math.max(count, d.frame + 1, (d.buildFrame ?? -1) + 1, (d.topperFrame ?? -1) + 1);
}
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count); const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
for (const d of defs) { for (const d of defs) {
const draw = STRUCT_SHAPES[d.procShape]; const draw = STRUCT_SHAPES[d.procShape];
@ -684,6 +686,22 @@ const PROC_PAINTERS = {
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
}); });
} }
// A generic spinner glyph — real toppers (the Advanced Metal Generator's centrifuge) get
// real art; this is just enough for the frame to register and not render blank.
if (d.topperFrame != null) {
sh.at(d.topperFrame, (ctx, S) => {
const c = S / 2, r = S * 0.3;
ctx.strokeStyle = '#cfe3ff'; ctx.lineWidth = S * 0.035;
ctx.beginPath(); ctx.arc(c, c, r, 0, Math.PI * 2); ctx.stroke();
for (let i = 0; i < 4; i++) {
const ang = (Math.PI / 2) * i;
ctx.beginPath();
ctx.moveTo(c, c);
ctx.lineTo(c + Math.cos(ang) * r, c + Math.sin(ang) * r);
ctx.stroke();
}
});
}
} }
return sh.finish(); return sh.finish();
}, },

View File

@ -273,6 +273,9 @@ export default class TAHud {
this._clearGrid(); this._clearGrid();
const s = this.scene; const s = this.scene;
const army = this.state.armies[this.playerArmy]; const army = this.state.armies[this.playerArmy];
// Real art or the procedural fallback (TAArt.js always supplies one or the other), so this
// is only null for a def that predates the `icon` field — see the fallback layout below.
const iconKey = this.view.sheetKeys?.icons;
options.forEach((def, i) => { options.forEach((def, i) => {
const col = i % GRID_COLS, row = Math.floor(i / GRID_COLS); 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. // The container is placed at the button's CENTRE, not its top-left — see below.
@ -286,13 +289,23 @@ export default class TAHud {
// child here is centred on (0,0) so the visual and the hit area coincide. // child here is centred on (0,0) so the visual and the hit area coincide.
const box = s.add.rectangle(0, 0, BTN_W, BTN_H, affordable ? 0x22304a : 0x1a2130, 1) const box = s.add.rectangle(0, 0, BTN_W, BTN_H, affordable ? 0x22304a : 0x1a2130, 1)
.setStrokeStyle(2, affordable ? EDGE : 0x2a3244); .setStrokeStyle(2, affordable ? EDGE : 0x2a3244);
const label = s.add.text(0, -BTN_H / 2 + 12, shortName(def.name), { const children = [box];
fontFamily: FONT, fontSize: '20px', color: affordable ? TEXT : '#5d6b80', const hasIcon = iconKey != null && def.icon != null;
// The icon carries the button's identity at a glance once one exists; name and cost drop
// underneath it rather than owning the whole button the way they did before icons.
if (hasIcon) {
const icon = s.add.image(0, -18, iconKey, def.icon).setDisplaySize(36, 36);
if (!affordable) icon.setAlpha(0.5);
children.push(icon);
}
const label = s.add.text(0, hasIcon ? 2 : -BTN_H / 2 + 12, shortName(def.name), {
fontFamily: FONT, fontSize: hasIcon ? '14px' : '20px', color: affordable ? TEXT : '#5d6b80',
}).setOrigin(0.5, 0); }).setOrigin(0.5, 0);
const cost = s.add.text(0, -BTN_H / 2 + 44, `${def.cost.mass}m ${def.cost.energy}e`, { const cost = s.add.text(0, hasIcon ? 18 : -BTN_H / 2 + 44, `${def.cost.mass}m ${def.cost.energy}e`, {
fontFamily: FONT, fontSize: '15px', color: affordable ? DIM : '#4e5a6d', fontFamily: FONT, fontSize: hasIcon ? '12px' : '15px', color: affordable ? DIM : '#4e5a6d',
}).setOrigin(0.5, 0); }).setOrigin(0.5, 0);
c.add([box, label, cost]); children.push(label, cost);
c.add(children);
c.setSize(BTN_W, BTN_H); c.setSize(BTN_W, BTN_H);
c.setInteractive({ useHandCursor: true }); c.setInteractive({ useHandCursor: true });
c.on('pointerover', () => box.setFillStyle(affordable ? 0x2e3f5f : 0x1a2130, 1)); c.on('pointerover', () => box.setFillStyle(affordable ? 0x2e3f5f : 0x1a2130, 1));

View File

@ -0,0 +1,140 @@
// Total Annihilation — the full-screen studio intro that plays once, ahead of the main menu,
// every time the scene starts fresh (see TotalAnnihilationGame.create — this scene is never
// resumed in place, only entered, so "fresh" is every time).
//
// The clip carries its own audio track, so it plays unmuted — autoplay-with-sound is safe here
// specifically because reaching this scene already required a user gesture (clicking the
// game's icon in the arcade), which is what the browser's autoplay gate actually checks for.
// The game's own soundtrack is paused for the duration so the two don't fight over the same
// speakers, then resumed once the clip ends or is skipped.
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH } from '../../config.js';
import { FONT } from './TAHud.js';
const INTRO_KEY = 'ta-mov-intro';
const INTRO_PATH = 'assets/videos/ta/ta_intro.mp4';
// Source resolution, used until the real texture decodes (a Video's own width/height report a
// square placeholder before its first frame).
const INTRO_SRC_W = 864;
const INTRO_SRC_H = 480;
// How long "Press any key to skip" stays up after the first press before it quietly stands
// down (rather than skipping on its own) and waits for the next one.
const SKIP_HOLD_MS = 5000;
/**
* Play the intro clip full-screen, then call `onDone`. The first key press or click surfaces a
* skip prompt; a second one inside SKIP_HOLD_MS jumps straight to `onDone`. A load failure or
* playback error also falls straight through to `onDone` a missing intro is never allowed to
* strand the player in front of a black screen.
*/
export function playIntroVideo(scene, onDone) {
let done = false;
let video = null;
let skipArmed = false;
let skipTimer = null;
let skipText = null;
scene.music?.pause();
let musicResumed = false;
const resumeMusic = () => {
if (musicResumed) return;
musicResumed = true;
scene.music?.resume();
};
const root = scene.add.container(0, 0).setDepth(1000);
root.add(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, 0x000000, 1).setOrigin(0, 0));
const clearPrompt = () => {
skipTimer?.remove(false);
skipTimer = null;
skipText?.destroy();
skipText = null;
skipArmed = false;
};
const cleanup = () => {
scene.input.keyboard?.off('keydown', onInput);
scene.input.off('pointerdown', onInput);
clearPrompt();
video?.destroy();
root.destroy();
};
const finish = () => {
if (done) return;
done = true;
cleanup();
resumeMusic();
onDone();
};
// Belt-and-braces: if the scene tears down mid-clip (e.g. a hot reload), drop our own
// listeners and the video element without firing onDone a second time behind whatever else
// shutdown is already doing.
scene.events.once('shutdown', () => { if (!done) { done = true; cleanup(); } });
const armSkipPrompt = () => {
skipArmed = true;
skipText = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 96, 'Press any key to skip', {
fontFamily: FONT, fontSize: '28px', color: '#e8f4ff',
}).setOrigin(0.5).setAlpha(0);
root.add(skipText);
scene.tweens.add({ targets: skipText, alpha: 1, duration: 200 });
skipTimer = scene.time.delayedCall(SKIP_HOLD_MS, clearPrompt);
};
function onInput() {
if (done) return;
if (skipArmed) { finish(); return; }
armSkipPrompt();
}
scene.input.keyboard?.on('keydown', onInput);
scene.input.on('pointerdown', onInput);
const startVideo = () => {
video = scene.add.video(GAME_WIDTH / 2, GAME_HEIGHT / 2, INTRO_KEY);
video.setLoop(false);
video.setMute(false);
video.setVolume(1);
// Cover, not contain: this is meant to fill the screen edge-to-edge, and the clip's own
// aspect (864x480) is within a hair of the game's (1920x1080) anyway, so the crop is
// imperceptible.
const fit = () => {
const real = !!video.videoTexture;
const w = real ? video.width : INTRO_SRC_W;
const h = real ? video.height : INTRO_SRC_H;
video.setScale(Math.max(GAME_WIDTH / w, GAME_HEIGHT / h));
};
fit();
video.on('created', fit);
video.on('playing', fit);
video.once('complete', finish);
video.once('error', finish);
// A browser that refuses to autoplay sound fires 'locked' and keeps retrying muted from
// preUpdate.
video.once('locked', () => { if (video?.scene) video.setMute(true); });
video.play(false);
root.add(video);
};
// Deliberately NOT in the asset manifest / preloaded ahead of time — this clip is loaded
// on demand, right here, the moment the game itself starts.
if (scene.cache.video?.exists(INTRO_KEY)) {
startVideo();
return;
}
scene.load.video(INTRO_KEY, INTRO_PATH, false);
scene.load.once(`filecomplete-video-${INTRO_KEY}`, () => { if (!done) startVideo(); });
scene.load.once(Phaser.Loader.Events.FILE_LOAD_ERROR, (file) => {
if (file?.key === INTRO_KEY) finish();
});
if (!scene.load.isLoading()) scene.load.start();
}

View File

@ -370,15 +370,58 @@ function buildCommand(state, rules, army, units, order, queue) {
if (!(bdef.builds ?? []).includes(order.defId)) { if (!(bdef.builds ?? []).includes(order.defId)) {
return { ok: false, error: `${bdef.name} cannot build ${def.name}` }; return { ok: false, error: `${bdef.name} cannot build ${def.name}` };
} }
const legal = canPlaceAt(state, rules, order.tx, order.ty, def); const legal = canPlaceAt(state, rules, order.tx, order.ty, def, army);
if (!legal.ok) return legal; if (!legal.ok) return legal;
// An upgrade building (Nuclear Power Plant over an Energy Generator, Advanced Metal Generator
// over a Mass Generator) may land exactly on the friendly building it names in `upgradesFrom`
// instead of needing clear ground — canPlaceAt already let this through, so re-find the same
// target here and consume it.
const upgradeTarget = findUpgradeTarget(state, army, order.tx, order.ty, def);
if (upgradeTarget) reclaimUpgradeTarget(state, rules, army, upgradeTarget);
const site = placeBuilding(state, rules, army, order.defId, order.tx, order.ty); const site = placeBuilding(state, rules, army, order.defId, order.tx, order.ty);
if (!site) return { ok: false, error: 'placement failed' }; if (!site) return { ok: false, error: 'placement failed' };
pushOrder(builder, { type: 'build', targetId: site.id }, queue); pushOrder(builder, { type: 'build', targetId: site.id }, queue);
return { ok: true, siteId: site.id }; return { ok: true, siteId: site.id };
} }
/**
* A friendly, finished building of `def.upgradesFrom`'s type sitting in EXACTLY this footprint
* the only shape of "occupied" an upgrade building is allowed to land on. Deliberately not a
* looser "overlaps anywhere" match: a 2x2 upgrade has to replace a 2x2 original one-for-one, not
* clip a neighbour.
*/
function findUpgradeTarget(state, army, tx, ty, def) {
if (!def.upgradesFrom) return null;
for (const e of state.entities) {
if (e.dead || e.site || !e.isBuilding || e.army !== army) continue;
if (e.defId !== def.upgradesFrom) continue;
if (e.tx === tx && e.ty === ty && e.fw === def.footprint.w && e.fh === def.footprint.h) return e;
}
return null;
}
/**
* Consume a building being upgraded in place: half its own build cost comes back (a genuine
* refund, not a discount on the new building's price), and it disappears quietly no death
* explosion, no kill/loss stat, no 'unitDestroyed' blast FX. This is recycling your own
* structure to make room for its replacement, not losing it to the enemy.
*/
function reclaimUpgradeTarget(state, rules, army, target) {
const tdef = rules.buildingById[target.defId];
const a = state.armies[army];
a.energy = Math.min(a.energyCap, a.energy + (tdef.cost?.energy ?? 0) * 0.5);
a.mass = Math.min(a.massCap, a.mass + (tdef.cost?.mass ?? 0) * 0.5);
target.dead = true;
stampFootprint(state.nav, rules, target.tx, target.ty, target.fw, target.fh, false);
recomputeEconomyCaps(state, rules);
for (const o of state.entities) {
if (o.buildTargetId === target.id) o.buildTargetId = 0;
if (o.targetId === target.id) o.targetId = 0;
}
}
/** /**
* Remove one order from a unit's queue by index the missing counterpart to `stop`, which * Remove one order from a unit's queue by index the missing counterpart to `stop`, which
* only ever clears the whole queue. Cancelling the active order (index 0) gets the same state * only ever clears the whole queue. Cancelling the active order (index 0) gets the same state
@ -418,12 +461,20 @@ export function cancelOrder(state, rules, army, unitId, index) {
return { ok: true }; return { ok: true };
} }
/** Can this footprint go here? Checks bounds, terrain buildability and occupancy. */ /**
export function canPlaceAt(state, rules, tx, ty, def) { * Can this footprint go here? Checks bounds, terrain buildability and occupancy.
*
* `army` is optional and only needed to recognise an upgrade placement (see `upgradesFrom`)
* every caller that doesn't pass one just gets the plain "is this ground clear" answer, which
* is what siting logic that has no notion of ownership (the AI's placement search, verify
* fixtures) actually wants anyway.
*/
export function canPlaceAt(state, rules, tx, ty, def, army = null) {
const fw = def.footprint.w, fh = def.footprint.h; const fw = def.footprint.w, fh = def.footprint.h;
if (tx < 0 || ty < 0 || tx + fw > state.w || ty + fh > state.h) { if (tx < 0 || ty < 0 || tx + fw > state.w || ty + fh > state.h) {
return { ok: false, error: 'off the map' }; return { ok: false, error: 'off the map' };
} }
if (army != null && findUpgradeTarget(state, army, tx, ty, def)) return { ok: true };
for (let y = ty; y < ty + fh; y++) { for (let y = ty; y < ty + fh; y++) {
for (let x = tx; x < tx + fw; x++) { for (let x = tx; x < tx + fw; x++) {
const i = y * state.w + x; const i = y * state.w + x;
@ -1644,59 +1695,67 @@ function acquireTarget(state, rules, e, def, hash, near) {
function fireWeapon(state, rules, e, w, target, facing, aimPt) { function fireWeapon(state, rules, e, w, target, facing, aimPt) {
const aim = aimPt ?? aimPointOn(rules, e, target); const aim = aimPt ?? aimPointOn(rules, e, target);
const muzzle = (w.fx?.muzzle ?? e.radius); const muzzle = (w.fx?.muzzle ?? e.radius);
const ox = e.x + Math.cos(facing) * muzzle; const barrels = w.barrels ?? 1;
const oy = e.y + Math.sin(facing) * muzzle; // Perpendicular to the firing line, so twin-barrel weapons (the Megatank's cannon and its
// side-mounted rockets) spawn each shot from its own barrel tip instead of the hull centre.
const perpX = -Math.sin(facing), perpY = Math.cos(facing);
state.events.push({ for (let bi = 0; bi < barrels; bi++) {
t: 'weaponFired', id: e.id, army: e.army, weapon: w.id, const lateral = barrels > 1 ? (bi - (barrels - 1) / 2) * (w.barrelSpacing / (barrels - 1)) : 0;
x: ox, y: oy, heading: facing, style: w.fx.style, sound: w.sound, const ox = e.x + Math.cos(facing) * muzzle + perpX * lateral;
}); const oy = e.y + Math.sin(facing) * muzzle + perpY * lateral;
if (w.kind === 'hitscan' || w.kind === 'beam') {
let hit = true;
if (w.spread > 0) {
// Spread becomes a miss chance that grows with range — cheap, and it means massed
// infantry still trade meaningfully at the edge of their envelope.
const d = Math.hypot(aim.x - e.x, aim.y - e.y);
const missChance = Math.min(0.6, w.spread * (d / w.range) * 3);
hit = rngNext(state) >= missChance;
}
const ex = hit ? aim.x : aim.x + rngRange(state, -24, 24);
const ey = hit ? aim.y : aim.y + rngRange(state, -24, 24);
state.events.push({ state.events.push({
t: 'shot', style: w.fx.style, x1: ox, y1: oy, x2: ex, y2: ey, t: 'weaponFired', id: e.id, army: e.army, weapon: w.id,
color: w.fx.color, width: w.fx.width, lifeMs: w.fx.lifeMs ?? 80, army: e.army, x: ox, y: oy, heading: facing, style: w.fx.style, sound: w.sound,
}); });
if (hit) {
applyDamage(state, rules, target, w.damage * (w.armorMul?.[rules.defById[target.defId].armorClass] ?? 1), e); if (w.kind === 'hitscan' || w.kind === 'beam') {
if (w.aoe) applyAoe(state, rules, aim.x, aim.y, w, e); let hit = true;
state.events.push({ t: 'impact', x: ex, y: ey, radius: w.aoe ?? 8, style: w.fx.style, sound: w.impactSound }); if (w.spread > 0) {
// Spread becomes a miss chance that grows with range — cheap, and it means massed
// infantry still trade meaningfully at the edge of their envelope.
const d = Math.hypot(aim.x - e.x, aim.y - e.y);
const missChance = Math.min(0.6, w.spread * (d / w.range) * 3);
hit = rngNext(state) >= missChance;
}
const ex = hit ? aim.x : aim.x + rngRange(state, -24, 24);
const ey = hit ? aim.y : aim.y + rngRange(state, -24, 24);
state.events.push({
t: 'shot', style: w.fx.style, x1: ox, y1: oy, x2: ex, y2: ey,
color: w.fx.color, width: w.fx.width, lifeMs: w.fx.lifeMs ?? 80, army: e.army,
});
if (hit) {
applyDamage(state, rules, target, w.damage * (w.armorMul?.[rules.defById[target.defId].armorClass] ?? 1), e);
if (w.aoe) applyAoe(state, rules, aim.x, aim.y, w, e);
state.events.push({ t: 'impact', x: ex, y: ey, radius: w.aoe ?? 8, style: w.fx.style, sound: w.impactSound });
}
continue;
} }
return;
}
// Travelling ordnance: leading matters, and a strafing target can genuinely be missed. // Travelling ordnance: leading matters, and a strafing target can genuinely be missed.
let aimX = aim.x, aimY = aim.y; let aimX = aim.x, aimY = aim.y;
if (w.leadTarget) { if (w.leadTarget) {
const d = Math.hypot(target.x - ox, target.y - oy); const d = Math.hypot(target.x - ox, target.y - oy);
const flight = d / w.speed; const flight = d / w.speed;
aimX += (target.vx ?? 0) * flight; aimX += (target.vx ?? 0) * flight;
aimY += (target.vy ?? 0) * flight; aimY += (target.vy ?? 0) * flight;
} }
let ang = Math.atan2(aimY - oy, aimX - ox); let ang = Math.atan2(aimY - oy, aimX - ox);
if (w.spread > 0) ang += rngRange(state, -w.spread, w.spread); if (w.spread > 0) ang += rngRange(state, -w.spread, w.spread);
if (state.projectiles.length >= rules.constants.projectileCap) return; if (state.projectiles.length >= rules.constants.projectileCap) break;
state.projectiles.push({ state.projectiles.push({
id: state.nextProjectileId++, id: state.nextProjectileId++,
weapon: w.id, army: e.army, ownerId: e.id, weapon: w.id, army: e.army, ownerId: e.id,
x: ox, y: oy, px: ox, py: oy, x: ox, y: oy, px: ox, py: oy,
vx: Math.cos(ang) * w.speed, vy: Math.sin(ang) * w.speed, vx: Math.cos(ang) * w.speed, vy: Math.sin(ang) * w.speed,
targetId: w.kind === 'guided' ? target.id : 0, targetId: w.kind === 'guided' ? target.id : 0,
aimX, aimY, aimX, aimY,
ttl: Math.ceil(((w.range * 1.4) / w.speed) * rules.constants.tickHz), ttl: Math.ceil(((w.range * 1.4) / w.speed) * rules.constants.tickHz),
trail: [], trail: [],
}); });
}
} }
function stepProjectiles(state, rules) { function stepProjectiles(state, rules) {

View File

@ -195,6 +195,12 @@ export function compileRules(json) {
// The D-Gun is the reason this flag exists — left on auto-fire, a defending Commander // 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. // deletes one attacker every reload for free, which makes assaulting any base suicide.
w.manual = w.manual === true; w.manual = w.manual === true;
// Multi-barrel weapons (the Megatank's twin cannon and side rockets) fire every barrel
// simultaneously on one reload, each barrel's shot spawned `barrelSpacing` px apart,
// perpendicular to the firing line, split evenly either side of the hull centreline.
w.barrels = w.barrels ?? 1;
if (!(Number.isInteger(w.barrels) && w.barrels >= 1)) fail(`weapon "${w.id}" barrels must be a positive integer`);
if (w.barrels > 1 && !(w.barrelSpacing > 0)) fail(`weapon "${w.id}" has barrels > 1 but no positive barrelSpacing`);
} }
const weaponById = indexById(weapons); const weaponById = indexById(weapons);
@ -297,6 +303,9 @@ export function compileRules(json) {
fail(`building "${b.id}" needs a footprint of at least 1x1 tiles`); fail(`building "${b.id}" needs a footprint of at least 1x1 tiles`);
} }
if (!Number.isInteger(b.frame) || b.frame < 0) fail(`building "${b.id}" needs a non-negative integer frame`); if (!Number.isInteger(b.frame) || b.frame < 0) fail(`building "${b.id}" needs a non-negative integer frame`);
if (b.topperFrame != null && (!Number.isInteger(b.topperFrame) || b.topperFrame < 0)) {
fail(`building "${b.id}" topperFrame must be a non-negative integer`);
}
if (b.terrainMultiplier && !terrain.some((t) => b.terrainMultiplier in t)) { if (b.terrainMultiplier && !terrain.some((t) => b.terrainMultiplier in t)) {
fail(`building "${b.id}" terrainMultiplier "${b.terrainMultiplier}" is on no terrain type`); fail(`building "${b.id}" terrainMultiplier "${b.terrainMultiplier}" is on no terrain type`);
} }
@ -349,6 +358,15 @@ export function compileRules(json) {
} }
} }
} }
// `upgradesFrom` names the building a def may be placed directly on top of, reclaiming half
// its build cost (see TALogic.buildCommand/findUpgradeTarget) — must be a real building, and
// one with a real cost to actually reclaim from.
for (const b of buildings) {
if (b.upgradesFrom == null) continue;
const from = buildingById[b.upgradesFrom];
if (!from) fail(`building "${b.id}" upgradesFrom references unknown building "${b.upgradesFrom}"`);
else if (!from.cost) fail(`building "${b.id}" upgradesFrom "${b.upgradesFrom}" has no cost to reclaim`);
}
// ---- victory modes ----------------------------------------------------- // ---- victory modes -----------------------------------------------------
// The engine understands two rules: "commander" (an army dies with its Commander) and // The engine understands two rules: "commander" (an army dies with its Commander) and

View File

@ -140,11 +140,12 @@ export function skirmishSetup(scene, { onBack, onStart }) {
const skills = [1, 2, 3, 4, 5].map((n) => ({ label: `SKILL ${n}`, value: n })); const skills = [1, 2, 3, 4, 5].map((n) => ({ label: `SKILL ${n}`, value: n }));
const victories = (rules.victoryModes ?? []).map((v) => ({ label: v.label, value: v.id, desc: v.desc })); const victories = (rules.victoryModes ?? []).map((v) => ({ label: v.label, value: v.id, desc: v.desc }));
const victoryStart = Math.max(0, victories.findIndex((v) => v.value === rules.constants.victoryDefault)); const victoryStart = Math.max(0, victories.findIndex((v) => v.value === rules.constants.victoryDefault));
const themeStart = Math.max(0, themes.findIndex((t) => t.value === sk.defaults?.theme));
const cfg = { const cfg = {
seed: (Math.random() * 0xffffffff) >>> 0, seed: (Math.random() * 0xffffffff) >>> 0,
size: sizes[0].value, size: sizes[0].value,
theme: themes[0]?.value ?? 'grasslands', theme: themes[themeStart]?.value ?? 'grasslands',
symmetry: syms[0].value, symmetry: syms[0].value,
victory: victories[victoryStart]?.value ?? rules.constants.victoryDefault, victory: victories[victoryStart]?.value ?? rules.constants.victoryDefault,
armies: [ armies: [
@ -155,7 +156,7 @@ export function skirmishSetup(scene, { onBack, onStart }) {
const left = 300, right = GAME_WIDTH - 700; const left = 300, right = GAME_WIDTH - 700;
cycler(scene, objs, left, 300, 'MAP SIZE', sizes, 0, (v) => { cfg.size = v; }); cycler(scene, objs, left, 300, 'MAP SIZE', sizes, 0, (v) => { cfg.size = v; });
cycler(scene, objs, left, 400, 'THEME', themes, 0, (v) => { cfg.theme = v; }); cycler(scene, objs, left, 400, 'THEME', themes, themeStart, (v) => { cfg.theme = v; });
cycler(scene, objs, left, 500, 'SYMMETRY', syms, 0, (v) => { cfg.symmetry = v; }); cycler(scene, objs, left, 500, 'SYMMETRY', syms, 0, (v) => { cfg.symmetry = v; });
cycler(scene, objs, left, 600, 'OPPONENT SKILL', skills, 2, (v) => { cfg.armies[1].skill = v; }); cycler(scene, objs, left, 600, 'OPPONENT SKILL', skills, 2, (v) => { cfg.armies[1].skill = v; });

View File

@ -384,6 +384,17 @@ export default class TAWorldView {
this._addWorld(turret); this._addWorld(turret);
} }
// A building's decorative topper (e.g. the Advanced Metal Generator's centrifuge): a second
// sprite stacked on the roof that spins continuously and fast, independent of the slow
// idleAnimation breathing/turn every other resource building gets — this is meant to read as
// active machinery, not ambient scenery. Hidden while under construction (see render()).
let topper = null;
if (def.isBuilding && def.topperFrame != null) {
topper = this.scene.add.image(e.x, e.y, key, def.topperFrame);
topper.setDisplaySize(def.footprint.w * this.ts, def.footprint.h * this.ts);
this._addWorld(topper);
}
// Anything that leaves the ground gets a flattened black copy of its own frame beneath it. // Anything that leaves the ground gets a flattened black copy of its own frame beneath it.
let shadow = null; let shadow = null;
if (def.flight) { if (def.flight) {
@ -394,7 +405,7 @@ export default class TAWorldView {
// `alt` is the current altitude as a 0..1 fraction of def.flight.height, eased toward its // `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. // target every frame in render(). Newly built units start on the ground.
s = { s = {
img, turret, final, shadow, defId: e.defId, img, turret, final, shadow, topper, defId: e.defId,
alt: 0, baseScale: img.scaleX, alt: 0, baseScale: img.scaleX,
batchDone: 0, batchPrev: 0, // production-dial accounting, see _drawProductionPies batchDone: 0, batchPrev: 0, // production-dial accounting, see _drawProductionPies
}; };
@ -409,6 +420,7 @@ export default class TAWorldView {
s.turret?.destroy(); s.turret?.destroy();
s.final?.destroy(); s.final?.destroy();
s.shadow?.destroy(); s.shadow?.destroy();
s.topper?.destroy();
this.sprites.delete(id); this.sprites.delete(id);
} }
@ -449,6 +461,7 @@ export default class TAWorldView {
if (s.turret) s.turret.setVisible(shown); if (s.turret) s.turret.setVisible(shown);
if (s.final) s.final.setVisible(shown); if (s.final) s.final.setVisible(shown);
if (s.shadow) s.shadow.setVisible(shown); if (s.shadow) s.shadow.setVisible(shown);
if (s.topper) s.topper.setVisible(shown);
if (!shown) continue; if (!shown) continue;
const x = e.px + (e.x - e.px) * alpha; const x = e.px + (e.x - e.px) * alpha;
@ -520,6 +533,21 @@ export default class TAWorldView {
s.img.setRotation((s.idleRot.turns + turnT) * (Math.PI / 2)); s.img.setRotation((s.idleRot.turns + turnT) * (Math.PI / 2));
} }
// The topper (e.g. the Advanced Metal Generator's centrifuge) spins fast and continuously
// off real time rather than sim ticks, so it stays smooth regardless of simSpeed and never
// needs its own per-entity timer state. Hidden while the site is still a wireframe skeleton
// — a spinning centrifuge on unfinished girders would read as broken, not "under construction".
if (s.topper) {
s.topper.setPosition(x, y);
s.topper.setDepth(band + (y / state.worldH) * 10 + 0.06);
if (e.site) {
s.topper.setAlpha(0);
} else {
const spinMs = 1800; // one full rotation every 1.8s — fast enough to read as active
s.topper.setAlpha(1).setRotation(((this.scene.time.now % spinMs) / spinMs) * Math.PI * 2);
}
}
if (s.turret) { if (s.turret) {
const tr = lerpAngle(e.pturretRot, e.turretRot, alpha); const tr = lerpAngle(e.pturretRot, e.turretRot, alpha);
s.turret.setPosition(x, y - lift).setRotation(tr); s.turret.setPosition(x, y - lift).setRotation(tr);

View File

@ -23,6 +23,7 @@ import TAWorldView, { DEPTHS } from './TAWorldView.js';
import TAFx from './TAFx.js'; import TAFx from './TAFx.js';
import TAHud from './TAHud.js'; import TAHud from './TAHud.js';
import * as Screens from './TAScreens.js'; import * as Screens from './TAScreens.js';
import { playIntroVideo } from './TAIntroVideo.js';
const SAVE_KEY = 'totalannihilation-save'; const SAVE_KEY = 'totalannihilation-save';
const SETTINGS_KEY = 'totalannihilation-settings'; const SETTINGS_KEY = 'totalannihilation-settings';
@ -93,7 +94,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
.catch(() => { /* portraits are optional */ }); .catch(() => { /* portraits are optional */ });
this.events.once('shutdown', () => this._teardown()); this.events.once('shutdown', () => this._teardown());
this.showMenu(); playIntroVideo(this, () => this.showMenu());
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -883,7 +884,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
this.placement.tx = tx; this.placement.ty = ty; this.placement.tx = tx; this.placement.ty = ty;
this.view.placement = { this.view.placement = {
def, tx, ty, def, tx, ty,
legal: Logic.canPlaceAt(this.match, this.rules, tx, ty, def), legal: Logic.canPlaceAt(this.match, this.rules, tx, ty, def, this.playerArmy),
builderX: builder?.x, builderY: builder?.y, builderX: builder?.x, builderY: builder?.y,
buildRange: this.rules.defById[builder?.defId ?? '']?.buildRange ?? 0, buildRange: this.rules.defById[builder?.defId ?? '']?.buildRange ?? 0,
}; };

View File

@ -30,20 +30,20 @@ capacity the verify script holds every `frame` index to, and it is measured off
| Sheet name (JSON key) | Texture key | Cell | Cols | Contents | | Sheet name (JSON key) | Texture key | Cell | Cols | Contents |
|---|---|---|---|---| |---|---|---|---|---|
| `arm-units` | `ta-arm-units` | 64×64 | 8 | ARM mobile units | | `arm-units` | `ta-arm-units` | 128×128 | 8 | ARM mobile units |
| `core-units` | `ta-core-units` | 64×64 | 8 | CORE mobile units | | `core-units` | `ta-core-units` | 128×128 | 8 | CORE mobile units |
| `arm-structures` | `ta-arm-structures` | 192×192 | 6×4 | ARM buildings | | `arm-structures` | `ta-arm-structures` | 192×192 | 6×4 | ARM buildings |
| `core-structures` | `ta-core-structures` | 192×192 | 6×4 | CORE buildings | | `core-structures` | `ta-core-structures` | 192×192 | 6×4 | CORE buildings |
| `terrain-grasslands` | `ta-terrain-grasslands` | 64×64 | 9 | Grassland tiles | | `terrain-grasslands` | `ta-terrain-grasslands` | 64×64 | 9 | Grassland tiles |
| `terrain-snowfields` | `ta-terrain-snowfields` | 64×64 | 9 | Snow tiles | | `terrain-snowfields` | `ta-terrain-snowfields` | 64×64 | 9 | Snow tiles |
| `terrain-tropics` | `ta-terrain-tropics` | 64×64 | 9 | Jungle tiles | | `terrain-tropics` | `ta-terrain-tropics` | 64×64 | 9 | Jungle tiles |
| `icons` | `ta-icons` | 44×44 | 10 | Command/build glyphs | | `icons` | `ta-icons` | 64×64 | 10 | Command/build glyphs — shared, NOT per-army |
Frames are numbered **left to right, then top to bottom**, starting at 0. Frames are numbered **left to right, then top to bottom**, starting at 0.
--- ---
## Unit sheets (64×64 cells, 8 per row) ## Unit sheets (128×128 cells, 8 per row)
Both army sheets use the identical frame layout — that is what lets one unit definition serve Both army sheets use the identical frame layout — that is what lets one unit definition serve
every army. `spritePx` is the on-screen size the frame is scaled to; draw to fill the cell and every army. `spritePx` is the on-screen size the frame is scaled to; draw to fill the cell and
@ -66,6 +66,9 @@ let the scale do the work.
| 12 | Fighter | plan-view airframe, swept wings, no tracks or wheels | 46 | | 12 | Fighter | plan-view airframe, swept wings, no tracks or wheels | 46 |
| 13 | Bomber | plan-view airframe, long straight wing, wing-mounted engines | 60 | | 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 | | 14 | Hover Constructor | skirted hull over a plenum, nanolathe crane, no tracks | 52 |
| 15 | Megatank | hull + tracks, wider than the Tank | 66 |
| 16 | Megatank turret | twin cannon barrels + side rocket pods | 66 |
| 17 | Advanced Construction Vehicle | tracked hull, twin nanolathe cranes, no gun | 54 |
### Units that leave the ground ### Units that leave the ground
@ -115,17 +118,25 @@ The Hover Constructor uses the same machinery at a much lower height — it shou
independently, so the unit aims while it drives. Rules: 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 - 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**. sits at the cell centre (64, 64 for the current 128×128 unit cells), with the barrel
extending to the **right**.
- Leave the rest of the turret cell transparent. The hull shows through it. - Leave the rest of the turret cell transparent. The hull shows through it.
- A unit with no turret (infantry, sniper, rocket trooper, construction vehicle, and all three - A unit with no turret (infantry, sniper, rocket trooper, construction vehicle, advanced
Airfield units) simply has no turret frame; its whole sprite rotates to face its target. The construction vehicle, and all three Airfield units) simply has no turret frame; its whole
Construction Vehicle and Hover Constructor are unarmed, so they never turn to aim regardless — sprite rotates to face its target. The two Construction Vehicles and the Hover Constructor are
they just rotate to face wherever they're moving. Aircraft have fixed forward guns, so they unarmed, so they never turn to aim regardless — they just rotate to face wherever they're
aim by pointing the whole airframe, which is why they have no turret either. moving. Aircraft have fixed forward guns, so they aim by pointing the whole airframe, which is
why they have no turret either.
Frames 1563 are free. Add a unit by appending a definition to `units[]` in Frames 1863 are free. Add a unit by appending a definition to `units[]` in
`data/totalannihilation-rules.json` with its `frame` (and optional `turretFrame`) — no code. `data/totalannihilation-rules.json` with its `frame` (and optional `turretFrame`) — no code.
A weapon may declare `barrels` (default 1) and, when `barrels > 1`, a `barrelSpacing` in world
px: every barrel fires at once on the same reload, each shot spawned that many px apart,
perpendicular to the firing line and centred on the hull. This is how the Megatank's twin
cannon and side rocket pods line up with the two barrels/pods painted into its turret frame —
measure the offset between them in the source PNG and scale by `spritePx / frameWidth`.
--- ---
## Structure sheets (128×128 cells, 6 per row) ## Structure sheets (128×128 cells, 6 per row)
@ -164,6 +175,11 @@ build completes; the finished frame crossfades in underneath starting at 20% pro
| 19 | Advanced Radar — under construction | 2×2 | | | 19 | Advanced Radar — under construction | 2×2 | |
| 20 | Radar Jammer | 2×2 | squat emitter, no dish, broken rings — **placeholder** | | 20 | Radar Jammer | 2×2 | squat emitter, no dish, broken rings — **placeholder** |
| 21 | Radar Jammer — under construction | 2×2 | | | 21 | Radar Jammer — under construction | 2×2 | |
| 22 | Nuclear Power Plant | 2×2 | cooling towers + reactor dome |
| 23 | Nuclear Power Plant — under construction | 2×2 | |
| 24 | Advanced Metal Generator | 2×2 | circular receptacle in the centre — the topper (frame 26) sits into it |
| 25 | Advanced Metal Generator — under construction | 2×2 | |
| 26 | Advanced Metal Generator topper — spinning centrifuge | — | **not a building**, see below |
The three sensor structures share a visual language deliberately: a low pad with corner The three sensor structures share a visual language deliberately: a low pad with corner
footings and **no armoured plinth**, so a glance separates "sensor" from "gun". They never footings and **no armoured plinth**, so a glance separates "sensor" from "gun". They never
@ -179,7 +195,36 @@ Defensive structures never rotate, and their mount traverses freely in the simul
shoot in every direction regardless of how the art is drawn. Draw them facing **up** and do not shoot in every direction regardless of how the art is drawn. Draw them facing **up** and do not
imply a firing arc. imply a firing arc.
Frames 2223 are free in the current 6×4 sheet; growing it another row adds six more. **Frame 26 is a `topperFrame`, not a second building.** A building def may name one
(`"topperFrame": 26` on `advancedmassgen`) to get a second sprite stacked on top of the finished
structure, at the same display size as the building itself, that spins **continuously and
fast** — 360° every 1.8s, real time, independent of `idleAnimation`'s slow whole-building
breathing/turn (`TAWorldView`'s `render()`, the block right after the `idleAnimation` one).
Unlike a unit's `turretFrame`, a topper never aims at anything; it just spins, and it fades in
only once the building completes (hidden throughout the wireframe/build phase — a spinning
centrifuge on unfinished girders would read as broken machinery, not construction). Draw a
topper's frame centred exactly like a turret's, with matching transparent padding to whatever
extent it should read as smaller than the building itself — the renderer scales the whole cell
uniformly, so the padding you draw is the only thing controlling how big it looks once placed.
`sh.at(frame, ...)` isn't required for the sheet to hold it — see `mkCanvasSheet.finish()` — but
`TAArt.js`'s `structure` painter does draw a generic spinning-glyph fallback for any topper.
**`upgradesFrom` lets a building be placed directly on top of a friendly building it names,
consuming it for a 50% refund of ITS OWN build cost** (not a discount on the new building's
price — a genuine resource credit) instead of requiring clear ground. Both `nuclearplant`
(`upgradesFrom: "energygen"`) and `advancedmassgen` (`upgradesFrom: "massgen"`) use it. The
match has to be exact — same tile position AND same footprint size as the building being
replaced, checked by `TALogic.findUpgradeTarget` — so this is "swap this generator for its
advanced version," not "clear whatever's nearby." The old building disappears quietly: no death
explosion, no kill/loss stat, no 'unitDestroyed' blast FX, because reclaiming your own structure
to upgrade it isn't the same event as losing it. `TALogic.canPlaceAt` takes an optional 5th
`army` argument to recognise this — omit it (as the AI's placement search and most of the verify
fixtures do) and you get the plain "is this ground clear" answer with no upgrade awareness at
all, which is the deliberately conservative default for any caller that doesn't actually know
whose building is sitting there.
**No frames remain free in the current 6×6 sheet** (grown from 4 rows to 6 for this session,
1152×1152) — growing it another row is the next move for anything new.
--- ---
@ -219,24 +264,71 @@ The theme then appears in the skirmish setup screen automatically.
--- ---
## Icons (44×44 cells, 10 per row) ## Icons (64×64 cells, 10 per row)
Build-menu and command glyphs. Frame numbers come from each def's `icon` in the rules JSON 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 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 truth.
is the lowest priority of the eight.
**Shared across both armies — this is the one sheet that does NOT get the "paint every army's
sheet in its own finished colours" treatment** from the top of this doc. There's a single
`icons` entry in the artwork JSON, not an `arm-icons`/`core-icons` pair, and the same frame
renders in either player's build menu. Paint it in a neutral palette (dark plate + light
linework is what the procedural fallback already does) rather than ARM blue or CORE red/orange.
**Wired into the build menu** (`TAHud._drawGrid`, since 2026-08-16): every button in the
build/production grid — buildings AND units — now draws this sheet's `def.icon` frame above the
shortened name and cost, at a **36×36 display size**. The command bar (Move/Attack/Patrol/…)
still renders text-only for now; its glyphs (frames 2029) only exist for the procedural
fallback painter today.
**The 64×64 source is intentionally bigger than that 36×36 display size.** Painting at exactly
the display size (the old 44×44 spec was already close to that trap) means every icon gets
upscaled a little and softens; painting bigger and letting Phaser scale down is what keeps it
crisp. Draw at the full 64×64 canvas — don't pre-shrink your art to "look right" at a smaller
preview size.
| Frames | Contents | | Frames | Contents |
|---|---| |---|---|
| 09 | Units: commander, infantry, sniper, jeep, tank, rocket tank, rocket trooper, construction vehicle, fighter, bomber | | 09 | Units: commander, infantry, sniper, jeep, tank, rocket tank, rocket trooper, construction vehicle, fighter, bomber |
| 1016 | Buildings: energy gen, mass gen, barracks, vehicle plant, laser tower, missile launcher, advanced vehicle plant | | 1016 | Buildings: energy gen, mass gen, barracks, vehicle plant, laser tower, missile launcher, advanced vehicle plant |
| 1719 | Overflow: hover constructor (unit), airfield, radar tower | | 1719 | Overflow: hover constructor (unit), airfield, radar tower |
| 3031 | Advanced radar, radar jammer |
| 2029 | Commands: move, attack, attack-move, stop, hold, patrol, guard, assist, repair, rally | | 2029 | Commands: move, attack, attack-move, stop, hold, patrol, guard, assist, repair, rally |
| 3031 | Advanced radar, radar jammer |
| 32 | Megatank (unit) |
| 33 | Advanced Construction Vehicle (unit) |
| 3435 | Advanced Metal Generator, Nuclear Power Plant |
Row 0 filled up before the Airfield units were added, which is why the hover constructor sits 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 next to the buildings at 17 rather than with the other units. Nothing reads a row as a
category — only the `icon` numbers matter. category — only the `icon` numbers matter. Frames 3639 are free.
### Painting priority: buildings the Commander and Construction Vehicle put up
This is the set that actually appears in the build-menu grid during a normal game — the union
of everything `commander`, `constructor` and `hoverconstructor` can build — in a sensible
painting order (the 8 the Commander itself can queue from turn one, then the 3 that need a
Construction Vehicle or Hover Constructor first):
| Frame | Building | Also needs a Constructor? | Footprint |
|---|---|---|---|
| 10 | Energy Generator | no — Commander builds it too | 2×2 |
| 11 | Mass Generator | no | 2×2 |
| 12 | Barracks | no | 3×3 |
| 13 | Vehicle Plant | no | 3×3 |
| 14 | Laser Tower | no | 1×1 |
| 15 | Missile Launcher | no | 2×2 |
| 18 | Airfield | no | 3×3 |
| 19 | Radar Tower | no | 2×2 |
| 16 | Advanced Vehicle Plant | **yes** | 4×4 |
| 30 | Advanced Radar | **yes** | 2×2 |
| 31 | Radar Jammer | **yes** | 2×2 |
Draw each as a simplified, high-contrast read of its full structure-sheet art (see Structure
sheets below), not a literal crop of it — a 128192px building shrunk straight down to 64px
loses its silhouette to surface detail. Keep the same silhouette and colour language (Laser
Tower's plinth-and-emitter, the Radar family's dish/mast/broken-rings distinction, etc.) so an
icon and its full-size building are still recognisably the same thing at a glance.
--- ---

View File

@ -72,11 +72,14 @@ const MISSIONS = [
defeat: 'KLAXON out-produced us and it showed.', defeat: 'KLAXON out-produced us and it showed.',
}, },
{ {
// Re-seeded twice as combat balance moved under it, both times measured over an 8-run // Re-seeded three times as combat balance moved under it, each time measured over an
// sweep: 55930 -> 40411 when armed aircraft stopped being able to hover, then 40411 (which // 8-run sweep: 55930 -> 40411 when armed aircraft stopped being able to hover, then 40411
// fell to 4/8) -> 71644 when the AI learned to build static defences. Cy-Bro is skill 4 // (which fell to 4/8) -> 71644 when the AI learned to build static defences, then 71644
// here, which is exactly the level at which defences switch on. // (which fell to 6/8, and lost both of verify §13's own fixed sample runs — seed and
id: 'm05', name: 'Tropic Vice', seed: 71644, size: 'medium', theme: 'tropics', symmetry: 'mirror-x', // seed+101 — even though the full 8-run rate was technically still at the bar) -> 67890
// (8/8) when rocketpod's armorMul was rebalanced weak-vs-vehicle/unchanged-vs-structure.
// Cy-Bro is skill 4 here, which is exactly the level at which defences switch on.
id: 'm05', name: 'Tropic Vice', seed: 67890, size: 'medium', theme: 'tropics', symmetry: 'mirror-x',
enemy: { commander: 'cybro', skill: 4, aggression: 0.7 }, enemy: { commander: 'cybro', skill: 4, aggression: 0.7 },
startResources: [{ mass: 2600, energy: 4200 }, { mass: 1500, energy: 2600 }], startResources: [{ mass: 2600, energy: 4200 }, { mass: 1500, energy: 2600 }],
prebuilt: [ prebuilt: [
@ -84,12 +87,17 @@ const MISSIONS = [
{ id: 'barracks', dx: 4, dy: 3 }, { id: 'barracks', dx: 4, dy: 3 },
{ enemy: true, id: 'energygen', dx: 3, dy: 2 }, { enemy: true, id: 'energygen', dx: 3, dy: 2 },
], ],
briefing: 'Cy-Bro starts ahead and pushes early. Rocket tanks out-range everything CORE fields.', briefing: 'Cy-Bro starts ahead and pushes early. Rockets crack their base open — armour holds the line getting there.',
victory: 'Cy-Bro is scrap on the sand.', victory: 'Cy-Bro is scrap on the sand.',
defeat: 'The push came faster than the plant did.', defeat: 'The push came faster than the plant did.',
}, },
{ {
id: 'm06', name: 'Annihilation', seed: 63541, size: 'medium', theme: 'snowfields', symmetry: 'rotational', // Re-seeded once, 8-run sweep: 63541 (fell to 0/8) -> 80808 (8/8) when static defence
// became a skill-3+ behaviour with a PROACTIVE first tower (TAAI.urgentDefence no longer
// waits for a raid to build the first one). KLAXON here is skill 5, well past that gate,
// so it now stands up a tower before the player-bot's rush can land — 90123 and 56789 also
// scored 8/8 in the same sweep if this needs revisiting again.
id: 'm06', name: 'Annihilation', seed: 80808, size: 'medium', theme: 'snowfields', symmetry: 'rotational',
enemy: { commander: 'klaxon', skill: 5, aggression: 0.85 }, enemy: { commander: 'klaxon', skill: 5, aggression: 0.85 },
startResources: [{ mass: 3200, energy: 5200 }, { mass: 1800, energy: 3000 }], startResources: [{ mass: 3200, energy: 5200 }, { mass: 1800, energy: 3000 }],
prebuilt: [ prebuilt: [
@ -97,7 +105,7 @@ const MISSIONS = [
{ id: 'barracks', dx: 4, dy: 3 }, { id: 'vehicleplant', dx: -4, dy: 3 }, { id: 'barracks', dx: 4, dy: 3 }, { id: 'vehicleplant', dx: -4, dy: 3 },
{ enemy: true, id: 'energygen', dx: 3, dy: -3 }, { enemy: true, id: 'barracks', dx: 4, dy: 3 }, { enemy: true, id: 'energygen', dx: 3, dy: -3 }, { enemy: true, id: 'barracks', dx: 4, dy: 3 },
], ],
briefing: 'KLAXON at full strength with a base already standing. Nothing clever left — out-build it and end it.', briefing: 'KLAXON at full strength with a base already standing, dug in behind its own defences. Nothing clever left — out-build it and end it.',
victory: 'Total annihilation. There is nothing left to fight.', victory: 'Total annihilation. There is nothing left to fight.',
defeat: 'KLAXON was simply better prepared.', defeat: 'KLAXON was simply better prepared.',
}, },

View File

@ -139,6 +139,8 @@ section('2. Artwork manifest');
const cap = (sheet.cols ?? 8) * (sheet.rows ?? 8); const cap = (sheet.cols ?? 8) * (sheet.rows ?? 8);
check(`${d.id} frame ${d.frame} fits ${sheet.key}`, d.frame < cap); check(`${d.id} frame ${d.frame} fits ${sheet.key}`, d.frame < cap);
if (d.turretFrame != null) check(`${d.id} turret frame fits`, d.turretFrame < cap); if (d.turretFrame != null) check(`${d.id} turret frame fits`, d.turretFrame < cap);
if (d.buildFrame != null) check(`${d.id} build frame fits`, d.buildFrame < cap);
if (d.topperFrame != null) check(`${d.id} topper frame fits`, d.topperFrame < cap);
} }
} }
} }
@ -216,6 +218,9 @@ section('2b. Procedural art actually paints');
if (d.buildFrame != null) { if (d.buildFrame != null) {
check(`${d.id} build frame was painted`, tex?.frames.includes(d.buildFrame)); check(`${d.id} build frame was painted`, tex?.frames.includes(d.buildFrame));
} }
if (d.topperFrame != null) {
check(`${d.id} topper frame was painted`, tex?.frames.includes(d.topperFrame));
}
} }
} }
for (const [name, sheet] of Object.entries(artJson.sheets ?? {})) { for (const [name, sheet] of Object.entries(artJson.sheets ?? {})) {
@ -694,6 +699,81 @@ section('3. Economy fixtures');
check('some terrain carries that multiplier', !!multTerrain); check('some terrain carries that multiplier', !!multTerrain);
} }
// ---------------------------------------------------------------------------
section('3b. Building upgrades in place');
// ---------------------------------------------------------------------------
{
// The Advanced Metal Generator / Nuclear Power Plant can be placed directly on top of a
// friendly Mass/Energy Generator instead of needing clear ground, reclaiming half the older
// building's cost. This exercises the whole path end to end: canPlaceAt(..., army) recognising
// the spot, buildCommand consuming the old building and crediting the refund, and a real site
// for the new building landing exactly where the old one stood.
const map = generateMap(rules, { seed: 40, size: 'small', symmetry: 'mirror-x' });
const cmdStart = map.starts.find((s) => s.army === 0) ?? map.starts[0];
const tx = cmdStart.x + 3, ty = cmdStart.y;
const st = L.createMatch(rules, {
seed: 40, map: { ...map, buildings: [{ army: 0, type: 'massgen', tx, ty }] },
armies: [{ armyId: 'arm' }, { armyId: 'core' }],
});
const a = st.armies[0];
// Headroom below the cap, or the refund's Math.min(cap, ...) clamp would silently eat it and
// this fixture would "pass" while testing nothing.
a.massCap = 5000; a.energyCap = 5000; a.mass = 2500; a.energy = 2500;
const oldGen = st.entities.find((e) => e.defId === 'massgen' && !e.dead);
check('fixture mass generator placed', !!oldGen);
const advDef = rules.buildingById.advancedmassgen;
check('advanced metal generator declares upgradesFrom massgen', advDef.upgradesFrom === 'massgen');
const builder = L.spawnUnit(st, rules, 0, 'advancedconstructor', oldGen.x + 300, oldGen.y);
const massBefore = a.mass;
const expectedRefund = rules.buildingById.massgen.cost.mass * 0.5;
const r = L.issueOrder(st, rules, {
army: 0, unitIds: [builder.id],
order: { type: 'build', defId: 'advancedmassgen', tx, ty },
});
check('advanced metal generator order accepted directly on the mass generator', r.ok, r.error);
check('the old mass generator is gone', !!oldGen.dead);
check('half the mass generator\'s build cost was refunded',
Math.abs((a.mass - massBefore) - expectedRefund) < 1e-6,
`+${(a.mass - massBefore).toFixed(1)} vs expected +${expectedRefund}`);
const site = st.entities.find((e) => e.id === r.siteId);
check('a new advanced metal generator site stands in its exact footprint',
!!site && site.defId === 'advancedmassgen' && site.tx === tx && site.ty === ty && site.site === true);
// Fresh ground still works — upgrading in place is an alternative, not a replacement.
let fx = tx + 6, fy = ty;
while (!L.canPlaceAt(st, rules, fx, fy, advDef, 0).ok && fx < st.w - 4) fx++;
const r2 = L.issueOrder(st, rules, {
army: 0, unitIds: [builder.id],
order: { type: 'build', defId: 'advancedmassgen', tx: fx, ty: fy },
});
check('advanced metal generator also builds on fresh ground', r2.ok, r2.error);
// Ownership: an enemy's finished mass generator must not be upgradeable.
const enemyGen = L.placeBuilding(st, rules, 1, 'massgen', tx + 20, ty);
enemyGen.site = false; enemyGen.progress = 1; enemyGen.hp = rules.buildingById.massgen.hp;
check('cannot upgrade an enemy\'s mass generator', !L.canPlaceAt(st, rules, tx + 20, ty, advDef, 0).ok);
// Type mismatch: an Advanced Metal Generator's upgradesFrom is massgen, not energygen.
const wrongType = L.placeBuilding(st, rules, 0, 'energygen', tx + 30, ty);
wrongType.site = false; wrongType.progress = 1; wrongType.hp = rules.buildingById.energygen.hp;
check('cannot upgrade a different building type', !L.canPlaceAt(st, rules, tx + 30, ty, advDef, 0).ok);
// The reverse pairing: a Nuclear Power Plant upgrades that same Energy Generator.
const nukeDef = rules.buildingById.nuclearplant;
check('nuclear power plant declares upgradesFrom energygen', nukeDef.upgradesFrom === 'energygen');
check('nuclear power plant can place directly on that energy generator',
L.canPlaceAt(st, rules, tx + 30, ty, nukeDef, 0).ok);
// Without an army argument, canPlaceAt must fall back to the plain occupancy check — callers
// that don't know ownership (the AI's placement search, most of this very test file) should
// never silently start recognising upgrade spots.
check('canPlaceAt without an army never grants an upgrade placement',
!L.canPlaceAt(st, rules, tx + 30, ty, nukeDef).ok);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
section('4. Pathfinding'); section('4. Pathfinding');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1078,11 +1158,13 @@ section('6. Combat');
const rvs = equal('rockettank', 'sniper'); const rvs = equal('rockettank', 'sniper');
check('rocket tanks beat equal-cost snipers', rvs.fracA > rvs.fracB, check('rocket tanks beat equal-cost snipers', rvs.fracA > rvs.fracB,
`${rvs.a} (${(rvs.fracA * 100) | 0}%) v ${rvs.b} (${(rvs.fracB * 100) | 0}%)`); `${rvs.a} (${(rvs.fracA * 100) | 0}%) v ${rvs.b} (${(rvs.fracB * 100) | 0}%)`);
// Rockets out-range and out-damage armour; that is what makes the roster a triangle // Rockets are the answer to buildings and air, not to armour — a straight fight against a
// rather than a ladder where one unit is simply the answer. // Tank is one the Rocket Tank should lose, which is what makes the roster a triangle
// (Tank counters Rocket Tank, Rocket Tank counters Sniper/structures) rather than a ladder
// where one unit is simply the answer.
const rvt = equal('rockettank', 'tank'); const rvt = equal('rockettank', 'tank');
check('rocket tanks beat equal-cost tanks', rvt.fracA > rvt.fracB, check('tanks beat equal-cost rocket tanks', rvt.fracB > rvt.fracA,
`${rvt.a} (${(rvt.fracA * 100) | 0}%) v ${rvt.b} (${(rvt.fracB * 100) | 0}%)`); `${rvt.b} tanks (${(rvt.fracB * 100) | 0}%) v ${rvt.a} rocket tanks (${(rvt.fracA * 100) | 0}%)`);
// Engagements must take long enough for reinforcement and composition to matter. When // Engagements must take long enough for reinforcement and composition to matter. When
// fights resolved in 2-4 seconds the whole AI skill ladder collapsed to a coin flip. // fights resolved in 2-4 seconds the whole AI skill ladder collapsed to a coin flip.
@ -1098,7 +1180,11 @@ section('6. Combat');
check('rifle is strong vs infantry, weak vs medium', check('rifle is strong vs infantry, weak vs medium',
armorMul(rifle, 'infantry') > armorMul(rifle, 'medium') * 3); armorMul(rifle, 'infantry') > armorMul(rifle, 'medium') * 3);
const rocket = combatRules.weaponById.rocketpod; const rocket = combatRules.weaponById.rocketpod;
check('rockets favour armour over infantry', armorMul(rocket, 'heavy') > armorMul(rocket, 'infantry') * 3); check('rockets favour fortifications over vehicle armour',
armorMul(rocket, 'fortification') > armorMul(rocket, 'heavy') * 3);
check('rockets are weak against vehicle armour',
armorMul(rocket, 'light') < 1 && armorMul(rocket, 'medium') < 1 && armorMul(rocket, 'heavy') < 1,
`light ${armorMul(rocket, 'light')}, medium ${armorMul(rocket, 'medium')}, heavy ${armorMul(rocket, 'heavy')}`);
// Commander death explosion. // Commander death explosion.
const dth = combatRules.unitById.commander.deathExplosion; const dth = combatRules.unitById.commander.deathExplosion;