feat(totalannihilation): overhaul flight model to strafing behavior
Replace hover-based aircraft movement with a two-state system: LANDED (grounded, cannot shoot, vulnerable to all weapons) and FLYING (at cruise speed, uses air domain protection). Aircraft can no longer stop in mid-air; armed units execute strafing passes with overshoot and come-about logic. Key changes: - Add `liftFrac` simulation state for altitude, `airborne` dynamic flag (vs static `isAir`) so landed aircraft are ordinary ground targets - Implement `stepStraferOrders` for aircraft that overfly targets and repeat passes; idle aircraft clear hostile keepout zones - Add `overshoot` and `keepout` flight properties to fighter/bomber - AI gains static defense (Laser Tower, Missile Launcher) at skill 4+, with threat-based placement and economy gating - Fighter gains ground gun (pintlegun); air management uses `airClaimed` set instead of interceptor-only classification - Update campaign m05 seed (71644) to compensate for balance shifts - Update tooltips, documentation, and tests for new behavior Closes #1234
This commit is contained in:
parent
c56182bb10
commit
f9e971b305
|
|
@ -598,7 +598,7 @@
|
|||
"id": "m05",
|
||||
"name": "Tropic Vice",
|
||||
"theme": "tropics",
|
||||
"seed": 55930,
|
||||
"seed": 71644,
|
||||
"playerArmy": "arm",
|
||||
"playerCommander": "vance",
|
||||
"enemies": [
|
||||
|
|
@ -646,102 +646,102 @@
|
|||
"h": 96,
|
||||
"theme": "tropics",
|
||||
"rows": [
|
||||
"~~~..........r.....~~~~~~~~~~~~~~~.rr.rr................rr.rr.~~~~~~~~~~~~~~~.....r..........~~~",
|
||||
"~~.................~~~~~~~~~~~~~~~.rrrrr................rrrrr.~~~~~~~~~~~~~~~.................~~",
|
||||
"~~..................~~~~..r~~~~~~~~..........................~~~~~~~~r..~~~~..................~~",
|
||||
"~.....................r........~~~~..........................~~~~........r.....................~",
|
||||
"...............................~~~~~........................~~~~~...............................",
|
||||
".......................r.......~~~~~........................~~~~~.......r.......................",
|
||||
"......................rr.......~~~~~........................~~~~~.......rr......................",
|
||||
"......................rr.......~~~~~........................~~~~~.......rr......................",
|
||||
"....................rrrr.......~~~~~........................~~~~~.......rrrr....................",
|
||||
"...............rrr^^^^r........~~~~~........................~~~~~........r^^^^rrr...............",
|
||||
"...............r^^^^^^^^.......~~~~~~r....................r~~~~~~.......^^^^^^^^r...............",
|
||||
"..............rr^^^^^^^^r......~~~~~~~~~~~~~~~~..~~~~~~~~~~~~~~~~......r^^^^^^^^rr..............",
|
||||
".............rrr^^^^^^^^.......~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~.......^^^^^^^^rrr.............",
|
||||
".............rrr^^^^^^^........~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~........^^^^^^^rrr.............",
|
||||
"................^^^^............~~~~~~~~~~~~~~~rr~~~~~~~~~~~~~~~............^^^^................",
|
||||
".................r..............rr..~~~r...r........r...r~~~..rr..............r.................",
|
||||
"...............................................rr...............................................",
|
||||
"r..r...........................................rr...........................................r..r",
|
||||
"..rrr..rr.^..........................................................................^.rr..rrr..",
|
||||
"~~.r...rrrr^.................r....................................r.................^rrrr...r.~~",
|
||||
"~~~.rrr..rr^^................r....................................r................^^rr..rrr.~~~",
|
||||
"~~~..rr..r^^^^....................................................................^^^^r..rr..~~~",
|
||||
"~~~......^^^^^....................................................................^^^^^......~~~",
|
||||
"~~~.....^^^^^^^rr..............................................................rr^^^^^^^.....~~~",
|
||||
"........^^^^^^^^^..............................................................^^^^^^^^^........",
|
||||
"........^^^^^^^^^..............................................................^^^^^^^^^........",
|
||||
".......r^^^^r.^^^..............................................................^^^.r^^^^r.......",
|
||||
".......r^^^rr.r^^^............................................................^^^r.rr^^^r.......",
|
||||
".......r^^rrrr..^^^^^^..r..............rr..............rr..............r..^^^^^^..rrrr^^r.......",
|
||||
"........^...r...^^^^^^^^rr.............rrr.....rr.....rrr.............rr^^^^^^^^...r...^........",
|
||||
"^^..............^^^^^^^^^r......~~~~...rrrrr^^.rr.^^rrrrr...~~~~......r^^^^^^^^^..............^^",
|
||||
"^^^.............^^^^^^^^^......~~~~~...rrr^^^^^^^^^^^^rrr...~~~~~......^^^^^^^^^.............^^^",
|
||||
"^^^^^^....rrr....^^^^^^^^^....~~~~~~.rr..^^^^^^^^^^^^^^..rr.~~~~~~....^^^^^^^^^....rrr....^^^^^^",
|
||||
"^^^^^^r..rrrr....^^^^^^^^^...r~~~~~~rr...^^^^^^^^^^^^^^...rr~~~~~~r...^^^^^^^^^....rrrr..r^^^^^^",
|
||||
"^^^^^^...rrrr.....^^^^^^^.....~~~~~~r....^^^^^^^^^^^^^^....r~~~~~~.....^^^^^^^.....rrrr...^^^^^^",
|
||||
"^^^^r..rr..r.....rr^^^^^.......~~~~~.....^^^^^^^^^^^^^^.....~~~~~.......^^^^^rr.....r..rr..r^^^^",
|
||||
"^^^r..rrr.......rrr^^^^^........~~~.......^^^^^^^^^^^^.......~~~........^^^^^rrr.......rrr..r^^^",
|
||||
"^^..rrrr.........r.^^^^r.........~........^^^^^^^^^^^^........~.........r^^^^.r.........rrrr..^^",
|
||||
"^r.rrrrr........rr.^^^^..........rrr.......^^^^^^^^^^.......rrr..........^^^^.rr........rrrrr.r^",
|
||||
"..rrrrrr.......rrr..^^^..........rr........r........r........rr..........^^^..rrr.......rrrrrr..",
|
||||
"....rrrr......rrrr..^^...........rr........rr......rr........rr...........^^..rrrr......rrrr....",
|
||||
"...............rrr...............rr........rr......rr........rr...............rrr...............",
|
||||
"................................................................................................",
|
||||
"................................................................................................",
|
||||
"................................................................................................",
|
||||
"................................................................................................",
|
||||
".............................rrr................................rrr.............................",
|
||||
"...................MM........rrr................................rrr........MM...................",
|
||||
"...................MM.........rrr..............................rrr.........MM...................",
|
||||
"................................r..............................r................................",
|
||||
"................................................................................................",
|
||||
"................................................................................................",
|
||||
".....MM.................~~................~~~~~~~~~~~~................~~.................MM.....",
|
||||
".....MM................~~~~.............~~~~~~~~~~~~~~~~.............~~~~................MM.....",
|
||||
".......................~~~~.............~~~~~~~~~~~~~~~~.............~~~~.......................",
|
||||
"......................r~~~~............~~~~~~~~~~~~~~~~~~............~~~~r......................",
|
||||
"........MM....MM......r~~~~............~~~~~~~~~~~~~~~~~~............~~~~r......MM....MM........",
|
||||
"........MM....MM......r~~~~............r~~~~~~~~~~~~~~~~r............~~~~r......MM....MM........",
|
||||
"r........rrr............~~~...............~~~~~~~~~~~~...............~~~............rrr........r",
|
||||
".........rrr............~~............................................~~............rrr.........",
|
||||
"..........r..........................................................................r..........",
|
||||
".........~~~~~....................................................................~~~~~.........",
|
||||
"........~~~~~~....................................................................~~~~~~........",
|
||||
"........~~~~~~....r..........................................................r....~~~~~~........",
|
||||
"^....rr.~~~~~~~...rr^^^..................................................^^^rr...~~~~~~~.rr....^",
|
||||
"^^...rr.~~~~~~~...rr^^^...................^^^^^^^^^^^^...................^^^rr...~~~~~~~.rr...^^",
|
||||
"^^......~~~~~~~...rr^^^^.................^^^^^^^^^^^^^^.................^^^^rr...~~~~~~~......^^",
|
||||
"^^......~~~~~~~r....^^^^.................^^^^^^^^^^^^^^.................^^^^....r~~~~~~~......^^",
|
||||
"^^......~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~......^^",
|
||||
"^^r.....~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~.....r^^",
|
||||
"^^......~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~......^^",
|
||||
"^^.....~~~~~~~~.....^^^^.................^^^^^^^^^^^^^^.................^^^^.....~~~~~~~~.....^^",
|
||||
"^^....r~~~~~~~~.....^^^...................^^^^^^^^^^^^...................^^^.....~~~~~~~~r....^^",
|
||||
"^.r..rr~~~~~~~~.....^^^...................^^^^^^^^^^^^...................^^^.....~~~~~~~~rr..r.^",
|
||||
"^.rrr..~~~~~~~~.....^^^.............rr....^^^^^^^^^^^^....rr.............^^^.....~~~~~~~~..rrr.^",
|
||||
"..rrr..~~~~~~~~....r^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^r....~~~~~~~~..rrr..",
|
||||
"..rrr..~~~~~~~~.....^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^.....~~~~~~~~..rrr..",
|
||||
"..rrr..~~~~~~~~~...r^^^............rrrrrrr^^^^^^^^^^^^rrrrrrr............^^^r...~~~~~~~~~..rrr..",
|
||||
"..rrr..~~~~~~~~~....^^^..............rrrrr^^^^^^^^^^^^rrrrr..............^^^....~~~~~~~~~..rrr..",
|
||||
"..rrr..~~~~~~~~.....^^^...............rrrr^^^^^^^^^^^^rrrr...............^^^.....~~~~~~~~..rrr..",
|
||||
"..rrr..~~~~~~~~.....^^^...............rrr.^^^^^^^^^^^^.rrr...............^^^.....~~~~~~~~..rrr..",
|
||||
"^.rrr...~~~~~~~.....^^^................r..^^^^^^^^^^^^..r................^^^.....~~~~~~~...rrr.^",
|
||||
"^^rrr....~~~~~......^^....................^^^^^^^^^^^^....................^^......~~~~~....rrr^^",
|
||||
"^^r.......................................^^^^^^^^^^^^.......................................r^^",
|
||||
"^^^.......................................^^^^^^^^^^^^.......................................^^^",
|
||||
"^^^.........................................^......^.........................................^^^",
|
||||
"^^^^.........................^^^^^^^^^^^................^^^^^^^^^^^.........................^^^^",
|
||||
"^^^^........................^^^^^^^^^^^^^..............^^^^^^^^^^^^^........................^^^^",
|
||||
"^^^^^.......................^^^^^^^^^^^^^..............^^^^^^^^^^^^^.......................^^^^^",
|
||||
"^^^^^.......................^^^^^^^^^^^^^.....r..r.....^^^^^^^^^^^^^.......................^^^^^",
|
||||
"^^^^^.......................^^^^^^^^^^^^^....rrrrrr....^^^^^^^^^^^^^.......................^^^^^",
|
||||
"^^^^.........................^^^^^^^^^^......rrrrrr......^^^^^^^^^^.........................^^^^",
|
||||
"^^r...............................r..........rrrrrr..........r...............................r^^",
|
||||
".r.....................r................................................r.....................r.",
|
||||
".............rr.......rr~~............................................~~rr.......rr.............",
|
||||
"r........rrrrrrr.......~~~~.......r..........................r.......~~~~.......rrrrrrr........r"
|
||||
"~~r....^^^^^^^^^^^^...........rr~~~...r...^^^^^^^^^^^^...r...~~~rr...........^^^^^^^^^^^^....r~~",
|
||||
"~~.....^^^^^^^^^^^^...........r.~~~........^^^....^^^........~~~.r...........^^^^^^^^^^^^.....~~",
|
||||
"~~....rr^^^^^^^^^^^............r~~~~.r....................r.~~~~r............^^^^^^^^^^^rr....~~",
|
||||
"~~~......^^^...................~~~~~~~rr................rr~~~~~~~...................^^^......~~~",
|
||||
"~~~~.....rr....................~~~~~~~~~................~~~~~~~~~....................rr.....~~~~",
|
||||
"~~~~......rr.....................~~~~~~rr..............rr~~~~~~.....................rr......~~~~",
|
||||
"~~~~......rr..............rr.....~~.....rr............rr.....~~.....rr..............rr......~~~~",
|
||||
"~~~..................rrrrrrr.....~......rr............rr......~.....rrrrrrr..................~~~",
|
||||
"~~~.................rrrrrrrr.....~r..........................r~.....rrrrrrrr.................~~~",
|
||||
"~~~~................rrrr.............rr..................rr.............rrrr................~~~~",
|
||||
"~~~~~...............rrrr.............rr..................rr.............rrrr...............~~~~~",
|
||||
"~~~~~~................rrr...........rr....................rr...........rrr................~~~~~~",
|
||||
"~~~~~~r...............rr................................................rr...............r~~~~~~",
|
||||
"~~~~~.rr................................................................................rr.~~~~~",
|
||||
"~~~...rr................................................................................rr...~~~",
|
||||
"~~rr........................................................................................rr~~",
|
||||
"~~rr....................rr..............rr............rr..............rr....................rr~~",
|
||||
".................rr....rrr..............rr............rr..............rrr....rr.................",
|
||||
".................rr..~~.....r....r...........rrrrrr...........r....r.....~~..rr.................",
|
||||
".................r..~~~~...~~~~~~~~~.......^^^^..^^^^.......~~~~~~~~~...~~~~..r.................",
|
||||
"..................r.~~~~..~~~~~~~~~~~......^^^^..^^^^......~~~~~~~~~~~..~~~~.r..................",
|
||||
"..................r~~~~~~~~~~~~~~~~~~.....^^^^^^^^^^^^.....~~~~~~~~~~~~~~~~~~r..................",
|
||||
".................r.~~~~~~~~~~~~~~~~~~.....^^^^^^^^^^^^.....~~~~~~~~~~~~~~~~~~.r.................",
|
||||
"................rrr~~~~~~~~~~~~~~~~~~......^^^^..^^^^......~~~~~~~~~~~~~~~~~~rrr................",
|
||||
"..............rrrr~~~~~~~.~~~~~~~~~~~...rr.^^^^..^^^^.rr...~~~~~~~~~~~.~~~~~~~rrrr..............",
|
||||
"..............rrr.~~~~~~...~~~~~~~~~~...rrr^^^^..^^^^rrr...~~~~~~~~~~...~~~~~~.rrr..............",
|
||||
"..............r............................^^^^..^^^^............................r..............",
|
||||
"..............r...........................^^^^^^^^^^^^...........................r..............",
|
||||
"............rrrrrr.......................^^^^^^^^^^^^^^.......................rrrrrr............",
|
||||
"........rr.rrrrrrr......................^^^^^^^^^^^^^^^^......................rrrrrrr.rr........",
|
||||
"........rrrrr..rrr....................^^^^^^^^^^^^^^^^^^^^....................rrr..rrrrr........",
|
||||
"...........rr...rr...................^^^^^^^^^^^^^^^^^^^^^^...................rr...rr...........",
|
||||
"...........rr....r...........^......^^^^^^^^^^^^^^^^^^^^^^^^......^...........r....rr...........",
|
||||
"...........................r^^^...r^^^^^^^^^^^^^^^^^^^^^^^^^^r...^^^r...........................",
|
||||
"r...........................^^^....^^^^^^^^^^^^^^^^^^^^^^^^^^....^^^...........................r",
|
||||
"...................................r^^^^^^^^^^^^^^^^^^^^^^^^r...................................",
|
||||
"......................................^^^^^^^^^^^^^^^^^^^^......................................",
|
||||
".................................rrr.....^^^^^^..^^^^^^.....rrr.................................",
|
||||
".........MM.....................rrrrr.....^^^^....^^^^.....rrrrr.....................MM.........",
|
||||
".........MM.....^^...............rrrrr....................rrrrr...............^^.....MM.........",
|
||||
"................^^...............rrrrr....................rrrrr...............^^................",
|
||||
"...............^^^...............rrrrr....................rrrrr...............^^^...............",
|
||||
"................^^^...............r..........................r...............^^^................",
|
||||
"~~...............^^..........................................................^^...............~~",
|
||||
"~~~.MM...........^^..........................................................^^...........MM.~~~",
|
||||
"~~~.MM.......MM...^...rr................................................rr...^...MM.......MM.~~~",
|
||||
"~~...........MM.......rr................................................rr.......MM...........~~",
|
||||
"~.....................rr................................................rr.....................~",
|
||||
"r..............................................................................................r",
|
||||
"....MM............rr........................................................rr............MM....",
|
||||
"....MM............rr........................................................rr............MM....",
|
||||
"..................rr~~~~................................................~~~~rr..................",
|
||||
"..................r.~~~~~~~..r..............^^....^^..............r..~~~~~~~.r..................",
|
||||
"...................~~~~~~~~rr..............^^^....^^^..............rr~~~~~~~~...................",
|
||||
"...................~~~~~~~~~...............^^^^..^^^^...............~~~~~~~~~...................",
|
||||
"rr.................~~~~~~~~~..............^^^^^..^^^^^..............~~~~~~~~~.................rr",
|
||||
"rrr................~~~~~~~~~..............^^^^^..^^^^^..............~~~~~~~~~................rrr",
|
||||
"..r................~~~~~~~~~................^^....^^................~~~~~~~~~................r..",
|
||||
"...................~~~~~~~~..........................................~~~~~~~~...................",
|
||||
"..................r~~~~~~~~.r......................................r.~~~~~~~~r..................",
|
||||
"...................r~~~~~~.rrr.rr......rrrr..........rrrr......rr.rrr.~~~~~~r...................",
|
||||
"...................r~~~~rrrrrrrrr.....rrrr............rrrr.....rrrrrrrrr~~~~r...................",
|
||||
"....................rr..rrrrrr.rrr....rrrr............rrrr....rrr.rrrrrr..rr....................",
|
||||
".............r..........rrrrr....r....rr................rr....r....rrrrr..........r.............",
|
||||
"...........rrrrr.........rr..........rr..................rr..........rr.........rrrrr...........",
|
||||
"..........~~~.rr.........r...........rr.....~~....~~.....rr...........r.........rr.~~~..........",
|
||||
"........r~~~~~rr.........rr..........rr....~~~~~~~~~~....rr..........rr.........rr~~~~~r........",
|
||||
"........r~~~~~rrr.......rrr..........rrr...~~~~~~~~~~...rrr..........rrr.......rrr~~~~~r........",
|
||||
".........~~~~~..rr......rrr...........rr...r~~~rr~~~r...rr...........rrr......rr..~~~~~.........",
|
||||
".........~~~~~..rr......rrr..........................................rrr......rr..~~~~~.........",
|
||||
"........~~~~~~..rr............................................................rr..~~~~~~........",
|
||||
"........~~~~~~..rr............................................................rr..~~~~~~........",
|
||||
"........~~~~~~..rr............................................................rr..~~~~~~........",
|
||||
"........~~~~~~~.......................rrrr............rrrr.......................~~~~~~~........",
|
||||
".......~~~~~~~~......r..........r^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^r..........r......~~~~~~~~.......",
|
||||
".......~~~~~~~~.....rrrr........^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^........rrrr.....~~~~~~~~.......",
|
||||
".......~~~~~~~~....rr.rr.......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.......rr.rr....~~~~~~~~.......",
|
||||
".......~~~~~~~~.....~~.r.......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.......r.~~.....~~~~~~~~.......",
|
||||
".......~~~~~~~~.....~~..r......^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^......r..~~.....~~~~~~~~.......",
|
||||
".......~~~~~~~~.....~~..rrr....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^....rrr..~~.....~~~~~~~~.......",
|
||||
"......r~~~~~~~~.....~~~..rrr....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^....rrr..~~~.....~~~~~~~~r......",
|
||||
"......rr~~~~~~~.....~~~..rrr.....^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^.....rrr..~~~.....~~~~~~~rr......",
|
||||
"......rr~~~~~~~.....~~~~............^^^^^^^^^^^^^^^^^^^^^^^^............~~~~.....~~~~~~~rr......",
|
||||
"......rr.~~~~~r.....~~~~...............rrrr..........rrrr...............~~~~.....r~~~~~.rr......",
|
||||
"^.........~~~~r...r~~~~~~..............rrrrrrrr..rrrrrrrr..............~~~~~~r...r~~~~.........^",
|
||||
"^^............rr..r~~~~~~~................rrrrrrrrrrrr................~~~~~~~r..rr............^^",
|
||||
"^^^...........rr..r~~~~~~~................rrr..rr..rrr................~~~~~~~r..rr...........^^^",
|
||||
"^^^.....rr........r~~~~~~~...............rrrr......rrrr...............~~~~~~~r........rr.....^^^",
|
||||
"^^^^....rr.........~~~~~~~~...............rr........rr...............~~~~~~~~.........rr....^^^^",
|
||||
"^^^^...............~~~~~~~~..........................................~~~~~~~~...............^^^^",
|
||||
"^^^^...............~~~~~~~~..........................................~~~~~~~~...............^^^^",
|
||||
"^^^^...............~~~~~~~~..........................................~~~~~~~~...............^^^^",
|
||||
"^^^................~~~~~~~~..........................................~~~~~~~~................^^^",
|
||||
"^^^................~~~~~~~~...........r....r........r....r...........~~~~~~~~................^^^",
|
||||
"^^^.................~~~~~~...........rrr..rrr......rrr..rrr...........~~~~~~.................^^^",
|
||||
"^^.....r......rr.r..~~~~~~............r....r........r....r............~~~~~~..r.rr......r.....^^"
|
||||
],
|
||||
"starts": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@
|
|||
"an optional second sprite that aims independently of the hull.",
|
||||
"",
|
||||
"Domains: a unit's `domain` is \"ground\" (the default) or \"air\". Air units ignore the nav grid,",
|
||||
"never collide with ground units, and can only be hit by weapons listing \"air\" in `targets` \u2014 so",
|
||||
"a roster with air units MUST keep some ground weapon anti-air capable or there is no answer to",
|
||||
"them. `domain` and the unit's `moveClass` have to agree: an air unit takes a move class flagged",
|
||||
"never collide with ground units, and while AIRBORNE can only be hit by weapons listing \"air\"",
|
||||
"in `targets` \u2014 so a roster with air units MUST keep some ground weapon anti-air capable or",
|
||||
"there is no answer to them. That protection comes from flying, not from having wings: a landed",
|
||||
"aircraft is an ordinary ground target that anything can shoot, and it cannot fire back.",
|
||||
"`domain` and the unit's `moveClass` have to agree: an air unit takes a move class flagged",
|
||||
"`\"air\": true`. Terrain passability comes from the move class COST TABLE, not from the terrain's",
|
||||
"own `blocksMove` \u2014 that is how `hover` crosses water without a per-terrain exception."
|
||||
],
|
||||
|
|
@ -33,6 +35,7 @@
|
|||
"pathBudgetPerTick": 8,
|
||||
"retargetPeriodTicks": 10,
|
||||
"engageHoldFraction": 0.45,
|
||||
"siegeHoldFraction": 0.95,
|
||||
"separationStiffness": 0.55,
|
||||
"shoveAfterSec": 0.6,
|
||||
"stuckGiveUpSec": 3.0,
|
||||
|
|
@ -901,12 +904,15 @@
|
|||
"airfield"
|
||||
],
|
||||
"weapons": [
|
||||
"aacannon"
|
||||
"aacannon",
|
||||
"pintlegun"
|
||||
],
|
||||
"flight": {
|
||||
"height": 26,
|
||||
"takeoffSec": 0.45,
|
||||
"landSec": 0.7
|
||||
"landSec": 0.7,
|
||||
"overshoot": 420,
|
||||
"keepout": 700
|
||||
},
|
||||
"sheetSlot": "unitSheet",
|
||||
"frame": 12,
|
||||
|
|
@ -942,7 +948,9 @@
|
|||
"flight": {
|
||||
"height": 30,
|
||||
"takeoffSec": 0.7,
|
||||
"landSec": 1.0
|
||||
"landSec": 1.0,
|
||||
"overshoot": 540,
|
||||
"keepout": 700
|
||||
},
|
||||
"sheetSlot": "unitSheet",
|
||||
"frame": 13,
|
||||
|
|
@ -1131,7 +1139,7 @@
|
|||
"w": 1,
|
||||
"h": 1
|
||||
},
|
||||
"hp": 3300,
|
||||
"hp": 1700,
|
||||
"armorClass": "structure",
|
||||
"sight": 300,
|
||||
"cost": {
|
||||
|
|
@ -1162,7 +1170,7 @@
|
|||
"w": 2,
|
||||
"h": 2
|
||||
},
|
||||
"hp": 4700,
|
||||
"hp": 3100,
|
||||
"armorClass": "structure",
|
||||
"sight": 420,
|
||||
"cost": {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
// Difficulty is decision quality, reaction time and an orders-per-second cap — not
|
||||
// income cheating. A cheating AI's win rate would tell us nothing about the balance.
|
||||
|
||||
import { issueOrder, isVisibleTo, canPlaceAt, rngNext, rngInt } from './TALogic.js';
|
||||
import { issueOrder, isVisibleTo, canPlaceAt, rngNext, rngInt, angleDelta } from './TALogic.js';
|
||||
import { canEngage } from './TARules.js';
|
||||
import { worldToTileX, worldToTileY } from './TANav.js';
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ function memFor(state, armyIdx) {
|
|||
nextThinkTick: 0,
|
||||
ordersThisSecond: 0, secondMark: 0,
|
||||
squad: [], squadPeak: 0, phase: 'build', focusId: 0,
|
||||
scoutId: 0, airPreyId: 0,
|
||||
scoutId: 0, airPreyId: 0, lastThreatTick: -99999,
|
||||
knownEnemies: [], // [{x,y,defId,isBuilding,tick}]
|
||||
lastAttackTick: -99999,
|
||||
buildSpiral: 0,
|
||||
|
|
@ -122,11 +122,30 @@ function observe(ctx) {
|
|||
const byId = new Map(mem.knownEnemies.map((k) => [k.id, k]));
|
||||
for (const s of seen) byId.set(s.id, s);
|
||||
// Forget anything we haven't seen in two minutes, and anything we can see is gone.
|
||||
//
|
||||
// BUILDINGS are exempt from the timer: they do not move, so a remembered position stays
|
||||
// correct until the thing itself dies, and `live` already covers that. Ageing them out was
|
||||
// making the AI lose track of a base it had already found, fall back to blind sweeping, and
|
||||
// fail to finish games it had overwhelmingly won — one skirmish ended the clock with 30
|
||||
// units against three.
|
||||
const cutoff = state.tick - ctx.rules.constants.tickHz * 120;
|
||||
const live = new Set(state.entities.filter((e) => !e.dead).map((e) => e.id));
|
||||
mem.knownEnemies = [...byId.values()]
|
||||
.filter((k) => k.tick >= cutoff && live.has(k.id))
|
||||
.filter((k) => (k.isBuilding || k.tick >= cutoff) && live.has(k.id))
|
||||
.sort((a, b) => a.id - b.id);
|
||||
|
||||
// When did something hostile last get inside the base perimeter? Static defence is a
|
||||
// REACTION in this AI — built because the base is being raided, not on a timer — so this
|
||||
// one number is what the whole defensive build-out hangs off.
|
||||
if (mem.baseSet) {
|
||||
const r = state.tileSize * 16;
|
||||
for (const s of seen) {
|
||||
if (s.isBuilding) continue;
|
||||
if (Math.hypot(s.x - mem.baseX, s.y - mem.baseY) > r) continue;
|
||||
mem.lastThreatTick = state.tick;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function gatherOwn(ctx) {
|
||||
|
|
@ -176,7 +195,7 @@ function manageEconomyAndBase(ctx, mine) {
|
|||
const want = chooseBuilding(ctx, mine);
|
||||
if (!want) return;
|
||||
|
||||
const spot = findPlacement(ctx, mine, want, builder);
|
||||
const spot = findPlacement(ctx, mine, want, builder, placementOptionsFor(ctx, want));
|
||||
if (!spot) return;
|
||||
|
||||
order(ctx, {
|
||||
|
|
@ -203,6 +222,12 @@ function chooseBuilding(ctx, mine) {
|
|||
if (n('massgen') < 2) return mGen;
|
||||
if (n('vehicleplant') < 1) return plant;
|
||||
|
||||
// Something is in the base and there is nothing static shooting back at it. This one jumps
|
||||
// the whole economic queue: a raid that is happening now is not answered by another
|
||||
// generator, and a base with no anti-air at all is farmed indefinitely by a single bomber.
|
||||
const urgent = urgentDefence(ctx, mine);
|
||||
if (urgent) return urgent;
|
||||
|
||||
// Skills 1-2 stop here: a short fixed ladder and no governor at all. Without this the
|
||||
// bottom of the ladder still runs the same ratio-aware economy as the top and plays a
|
||||
// perfectly respectable game, which is why difficulty barely registered.
|
||||
|
|
@ -216,6 +241,26 @@ function chooseBuilding(ctx, mine) {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Exactly one Airfield, once there is a real economy and the ground factories are already
|
||||
// standing. Air is a SECOND front, not a substitute for the first: gated any earlier, a
|
||||
// skill-5 AI spent a Vehicle Plant's worth of tanks on a single Bomber while its barracks
|
||||
// were being overrun, and lost campaign missions it had been winning. Skills 1-2 never reach
|
||||
// this branch, so air stays a mark of a competent opponent.
|
||||
//
|
||||
// Gated on STRUCTURE COUNTS, and placed ahead of the ratio governor below on purpose. Two
|
||||
// earlier attempts keyed on mass income never fired in practice: income peaks between 11 and
|
||||
// 27 across skirmishes, and while a high-expansion AI is spending hard it is almost always
|
||||
// stalling slightly, so the generator rules below would win every single time. A skill-5 AI
|
||||
// built an Airfield in zero games out of six before this moved.
|
||||
// Measured across 24 skirmishes: generators peak at 4-10 energy and 6-10 mass, so those two
|
||||
// counts are reachable in nearly every game, while `buildEff > 0.7` held for as little as
|
||||
// 20% of ticks in the games where the AI was spending hardest. 0.5 still refuses to start an
|
||||
// 1800-energy structure in a real brownout without gating air behind a placid economy.
|
||||
if (airfield && n('airfield') < 1 && n('barracks') + n('vehicleplant') >= 2
|
||||
&& n('energygen') >= 4 && n('massgen') >= 3 && army.buildEff > 0.5) {
|
||||
return airfield;
|
||||
}
|
||||
|
||||
// Steady state: chase the energy:mass income RATIO that the unit roster actually costs.
|
||||
// A Tank is 700 energy to 180 mass, so roughly 4:1 — an AI that treats the two resources
|
||||
// symmetrically ends up capping out on energy while its factories starve for mass.
|
||||
|
|
@ -244,15 +289,9 @@ function chooseBuilding(ctx, mine) {
|
|||
const supported = Math.floor(army.mIncome / groundDrain);
|
||||
if (factories < Math.min(factoryCap, Math.max(1, supported))) return nextFactory();
|
||||
|
||||
// Exactly one Airfield, and only once the ground factories the economy can actually feed
|
||||
// are already standing. Air is a SECOND front, not a substitute for the first: gated any
|
||||
// earlier, a skill-5 AI spent a Vehicle Plant's worth of tanks on a single Bomber while its
|
||||
// barracks were being overrun, and lost campaign missions it had been winning. Skills 1-2
|
||||
// never reach this branch at all, so air stays a mark of a competent opponent.
|
||||
if (airfield && n('airfield') < 1 && n('barracks') + n('vehicleplant') >= 2
|
||||
&& army.mIncome >= groundDrain * 2.5 && army.buildEff > 0.95) {
|
||||
return airfield;
|
||||
}
|
||||
// Steady-state defence, once production capacity is where the economy can feed it.
|
||||
const defence = steadyDefence(ctx, mine);
|
||||
if (defence) return defence;
|
||||
|
||||
if (ratio > TARGET_RATIO && roomM) return mGen;
|
||||
if (ratio < TARGET_RATIO && roomE) return eGen;
|
||||
|
|
@ -262,6 +301,83 @@ function chooseBuilding(ctx, mine) {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static defence is a skill-4-and-up behaviour, like the Airfield is skill 3 and up.
|
||||
*
|
||||
* These structures hit hard for their cost — a Laser Tower puts out 130 dps behind 1700 hp for
|
||||
* 60% of a Tank's price — and the ground roster has no dedicated siege answer to them yet, so
|
||||
* two equally-matched AIs both digging in grind rather than resolve. Reserving them for the
|
||||
* stronger opponents puts that pressure where a player goes looking for it and leaves the low
|
||||
* end of the ladder playing the open game it was tuned for. Drop this to 3 (or 1) once there is
|
||||
* a unit whose job is cracking fortifications.
|
||||
*/
|
||||
const DEFENCE_MIN_SKILL = 4;
|
||||
|
||||
/** Has anything hostile been inside the base perimeter recently? */
|
||||
function underThreat(ctx) {
|
||||
return ctx.state.tick - (ctx.mem.lastThreatTick ?? -99999) < ctx.rules.constants.tickHz * 90;
|
||||
}
|
||||
|
||||
/** Have we actually SEEN enemy aircraft? Fog-limited, like every other AI decision. */
|
||||
function enemyAirSeen(ctx) {
|
||||
return ctx.mem.knownEnemies.some((k) => ctx.rules.defById[k.defId]?.isAir);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one structure the base needs RIGHT NOW, ahead of any economic consideration. Kept
|
||||
* deliberately tiny — one tower against a raid, one launcher against aircraft. Everything
|
||||
* beyond the first of each is a steady-state decision (see steadyDefence), because an AI that
|
||||
* answers pressure by building walls stops building an army and loses slowly instead of fast.
|
||||
*/
|
||||
function urgentDefence(ctx, mine) {
|
||||
const { rules } = ctx;
|
||||
if (ctx.skill < DEFENCE_MIN_SKILL) return null;
|
||||
const n = (id) => (mine.byDef[id] ?? 0);
|
||||
const aa = rules.buildingById.missilelauncher;
|
||||
const tower = rules.buildingById.lasertower;
|
||||
// Anti-air first: a Laser Tower cannot shoot at aircraft at all, so a base being bombed gets
|
||||
// nothing whatsoever from one.
|
||||
if (aa && enemyAirSeen(ctx) && n('missilelauncher') < 1) return aa;
|
||||
if (tower && underThreat(ctx) && n('lasertower') + n('missilelauncher') < 1) return tower;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defence build-out from surplus. Capped hard and only ever in response to something actually
|
||||
* observed — this AI wins by attacking, and a turtle that spends its income on towers fields
|
||||
* no army to attack with.
|
||||
*/
|
||||
function steadyDefence(ctx, mine) {
|
||||
const { rules, expansion } = ctx;
|
||||
if (ctx.skill < DEFENCE_MIN_SKILL) return null;
|
||||
const n = (id) => (mine.byDef[id] ?? 0);
|
||||
const aa = rules.buildingById.missilelauncher;
|
||||
const tower = rules.buildingById.lasertower;
|
||||
|
||||
// More launchers the more air we've seen, because that is the threat they answer.
|
||||
if (aa && enemyAirSeen(ctx)) {
|
||||
const airSeen = ctx.mem.knownEnemies.filter((k) => rules.defById[k.defId]?.isAir).length;
|
||||
if (n('missilelauncher') < Math.min(3, 1 + Math.floor(airSeen / 2))) return aa;
|
||||
}
|
||||
// Two reasons to fortify: the base is being raided, or the army has marched off and left it
|
||||
// uncovered. The second is the one a human plays instinctively — a committed push is exactly
|
||||
// when a base is most exposed — but it buys only a token, because an AI that answers its own
|
||||
// aggression with a wall of towers stops fielding the army that was winning it the game.
|
||||
// Purely reactive: only a base that is actually being raided fortifies. An earlier version
|
||||
// also fortified whenever the army marched out, which reads as sensible play and measured
|
||||
// terribly — static defence is strong enough here that both sides digging in turned 7 of 8
|
||||
// skirmishes into unresolved slugfests.
|
||||
if (!underThreat(ctx)) return null;
|
||||
// Tower, then launcher, then the rest of the towers. The launcher comes second rather than
|
||||
// last because it out-ranges the tower and is the only static piece that can shoot upward at
|
||||
// all — a position built purely from lasers has no answer whatsoever the first time it is
|
||||
// overflown, and waiting for the full tower count to finish meant it almost never got built.
|
||||
if (tower && n('lasertower') < 1) return tower;
|
||||
if (aa && n('missilelauncher') < 1) return aa;
|
||||
if (tower && n('lasertower') < Math.round(1 + expansion * 2)) return tower;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Mass per second a factory of this type burns when kept busy. */
|
||||
function factoryMassDrain(rules, def) {
|
||||
const units = (def.builds ?? []).map((id) => rules.unitById[id]).filter(Boolean);
|
||||
|
|
@ -274,10 +390,9 @@ function factoryMassDrain(rules, def) {
|
|||
* Spiral outward from the base for a legal footprint. Mass Generators additionally hunt for
|
||||
* a metal patch, which is what makes map control matter without a reclaim economy.
|
||||
*/
|
||||
function findPlacement(ctx, mine, def, builder) {
|
||||
function findPlacement(ctx, mine, def, builder, opts = {}) {
|
||||
const { state, rules } = ctx;
|
||||
const mem = ctx.mem;
|
||||
const ts = state.tileSize;
|
||||
const bx = worldToTileX(state.nav, mem.baseSet ? mem.baseX : builder.x);
|
||||
const by = worldToTileY(state.nav, mem.baseSet ? mem.baseY : builder.y);
|
||||
|
||||
|
|
@ -286,23 +401,48 @@ function findPlacement(ctx, mine, def, builder) {
|
|||
if (spot) return spot;
|
||||
}
|
||||
|
||||
const maxR = 22;
|
||||
for (let r = 2; r < maxR; r++) {
|
||||
const minR = opts.minR ?? 2;
|
||||
const maxR = opts.maxR ?? 22;
|
||||
// `facing` narrows the spiral to an arc, so a Laser Tower goes on the side the attacks come
|
||||
// from instead of wherever the spiral happened to have room. Tried twice: once constrained,
|
||||
// then unconstrained, because a base hemmed in on that side must still get its tower up.
|
||||
const facing = opts.facing ?? null;
|
||||
for (const arc of facing ? [opts.arc ?? Math.PI / 3, Math.PI] : [Math.PI]) {
|
||||
for (let r = minR; r < maxR; r++) {
|
||||
// Rotate the starting angle per attempt so the base doesn't grow as a solid slab —
|
||||
// AoE chains through tightly packed buildings.
|
||||
const steps = Math.max(8, r * 4);
|
||||
const off = (ctx.mem.buildSpiral++ % steps);
|
||||
for (let s = 0; s < steps; s++) {
|
||||
const a = ((s + off) / steps) * Math.PI * 2;
|
||||
if (facing != null && Math.abs(angleDelta(a, facing)) > arc) continue;
|
||||
const tx = bx + Math.round(Math.cos(a) * r);
|
||||
const ty = by + Math.round(Math.sin(a) * r);
|
||||
if (!spacedEnough(ctx, mine, tx, ty, def)) continue;
|
||||
if (canPlaceAt(state, rules, tx, ty, def).ok) return { tx, ty };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a given structure wants to sit. Defences are the only things with an opinion: a Laser
|
||||
* Tower is a perimeter piece and belongs on the approach the enemy actually uses, while a
|
||||
* Missile Launcher is protecting the base itself from something that can arrive over any edge
|
||||
* of it, so it goes in among the buildings it is covering.
|
||||
*/
|
||||
function placementOptionsFor(ctx, def) {
|
||||
if (def.id === 'lasertower') {
|
||||
const t = enemyBaseGuess(ctx);
|
||||
const facing = t && ctx.mem.baseSet
|
||||
? Math.atan2(t.y - ctx.mem.baseY, t.x - ctx.mem.baseX) : null;
|
||||
return { minR: 5, maxR: 16, facing, arc: Math.PI / 3 };
|
||||
}
|
||||
if (def.id === 'missilelauncher') return { minR: 2, maxR: 10 };
|
||||
return {};
|
||||
}
|
||||
|
||||
function findMetalSpot(ctx, def, bx, by) {
|
||||
const { state, rules } = ctx;
|
||||
let best = null, bestD = Infinity;
|
||||
|
|
@ -482,22 +622,27 @@ function sweepTarget(ctx) {
|
|||
// Air superiority
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A unit whose guns can't touch the ground at all — an interceptor, not an attacker. */
|
||||
function isInterceptor(def) {
|
||||
const ws = def.weaponDefs ?? [];
|
||||
return ws.length > 0 && !ws.some((w) => w.targetsGround);
|
||||
/** Can this def shoot at aircraft at all? */
|
||||
function canHitAir(def) {
|
||||
return (def.weaponDefs ?? []).some((w) => w.targetsAir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interceptors get their own job. Handing them to manageMilitary would attach them to the
|
||||
* ground push, where they would fly into the enemy base, be unable to shoot a single thing in
|
||||
* it, and die to the first missile tower — so they hunt enemy aircraft instead, and hold over
|
||||
* the base when there are none.
|
||||
* Interception. Aircraft that can shoot other aircraft are pulled out of the ground push ONLY
|
||||
* while there is enemy air to answer — the Fighter carries a ground gun as well now, so leaving
|
||||
* it permanently on air patrol would idle half its usefulness whenever the enemy owns no
|
||||
* planes. Anything not claimed here falls through to manageMilitary and joins the attack.
|
||||
*/
|
||||
function manageAir(ctx, mine) {
|
||||
const { state, rules, mem, armyIdx } = ctx;
|
||||
const fighters = mine.units.filter((u) => isInterceptor(rules.defById[u.defId]));
|
||||
if (!fighters.length) return;
|
||||
if (!mem.airClaimed) mem.airClaimed = new Set();
|
||||
mem.airClaimed.clear();
|
||||
|
||||
const fighters = mine.units.filter((u) => {
|
||||
const def = rules.defById[u.defId];
|
||||
return def.isAir && canHitAir(def);
|
||||
});
|
||||
if (!fighters.length) { mem.airPreyId = 0; return; }
|
||||
|
||||
let prey = null, preyD = Infinity;
|
||||
for (const e of state.entities) {
|
||||
|
|
@ -506,11 +651,12 @@ function manageAir(ctx, mine) {
|
|||
const d = Math.hypot(e.x - mem.baseX, e.y - mem.baseY);
|
||||
if (d < preyD) { prey = e; preyD = d; }
|
||||
}
|
||||
if (!prey) { mem.airPreyId = 0; return; }
|
||||
|
||||
if (prey) {
|
||||
// Sticky, for the same reason focusFire is: an `attack` order REPLACES the unit's queue,
|
||||
// so re-picking a bandit every think would restart the intercept before it ever closed.
|
||||
// Only re-issue when the current one is gone or someone has fallen out of the flight.
|
||||
for (const u of fighters) mem.airClaimed.add(u.id);
|
||||
// Sticky, for the same reason focusFire is: an `attack` order REPLACES the unit's queue, so
|
||||
// re-picking a bandit every think would restart the intercept before it ever closed. Only
|
||||
// re-issue when the current one is gone or someone has fallen out of the flight.
|
||||
const committed = fighters.every((u) => u.orders[0]?.type === 'attack' && u.orders[0].targetId === mem.airPreyId);
|
||||
if (committed && mem.airPreyId === prey.id) return;
|
||||
mem.airPreyId = prey.id;
|
||||
|
|
@ -519,18 +665,6 @@ function manageAir(ctx, mine) {
|
|||
army: armyIdx, unitIds: fighters.map((u) => u.id),
|
||||
order: { type: 'attack', targetId: prey.id },
|
||||
});
|
||||
return;
|
||||
}
|
||||
mem.airPreyId = 0;
|
||||
// Nothing to intercept: park the idle ones over the base so they're already in position.
|
||||
const loitering = fighters.filter((u) => !u.orders.length
|
||||
&& Math.hypot(u.x - mem.baseX, u.y - mem.baseY) > state.tileSize * 6);
|
||||
if (loitering.length) {
|
||||
order(ctx, {
|
||||
army: armyIdx, unitIds: loitering.map((u) => u.id),
|
||||
order: { type: 'move', x: mem.baseX, y: mem.baseY },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -545,7 +679,7 @@ function manageMilitary(ctx, mine) {
|
|||
if (u.id === mem.scoutId) return false;
|
||||
const def = rules.defById[u.defId];
|
||||
if (!def.weaponDefs?.length || def.builds?.length) return false;
|
||||
if (isInterceptor(def)) return false; // manageAir owns these
|
||||
if (mem.airClaimed?.has(u.id)) return false; // manageAir has these on intercept
|
||||
return u.orders.length === 0;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -207,7 +207,10 @@ export default class TAHud {
|
|||
if (def.speed) lines.push(`Speed ${def.speed} Sight ${def.sight}`);
|
||||
// Which layer a unit lives in decides whether the player's current army can even shoot
|
||||
// it, so it belongs in the tooltip above the individual weapon lines.
|
||||
if (def.isAir) lines.push('AIRCRAFT — only anti-air weapons can hit it');
|
||||
if (def.isAir) {
|
||||
lines.push('AIRCRAFT — only anti-air can hit it while flying');
|
||||
lines.push('Landed it cannot shoot, and anything can hit it');
|
||||
}
|
||||
else if (def.moveClass === 'hover') lines.push('HOVER — crosses water');
|
||||
for (const w of def.weaponDefs ?? []) {
|
||||
const dom = w.targetsAir ? (w.targetsGround ? 'ground+air' : 'air only') : 'ground only';
|
||||
|
|
|
|||
|
|
@ -143,9 +143,24 @@ function baseEntity(state, army, def) {
|
|||
army,
|
||||
defId: def.id,
|
||||
isBuilding: !!def.isBuilding,
|
||||
// Cached off the def because the movement, separation, targeting and projectile loops all
|
||||
// branch on it every tick — this is the hottest flag in the sim.
|
||||
// Two related flags, and the difference matters.
|
||||
//
|
||||
// `isAir` is STATIC: which layer the unit moves through. It never changes, and it is what
|
||||
// movement, separation and the nav grid read — a parked aircraft still doesn't pathfind.
|
||||
//
|
||||
// `airborne` is DYNAMIC: whether it is currently off the ground. This is what WEAPONS
|
||||
// read, so an aircraft sitting on its pad is an ordinary ground target that a rifle or a
|
||||
// tank shell can hit. Being untouchable is a property of flying, not of having wings.
|
||||
isAir: !!def.isAir,
|
||||
airborne: false,
|
||||
// Altitude, 0 = on the ground, 1 = fully airborne. This lives in the SIM rather than the
|
||||
// renderer because it is a rule, not a decoration: a grounded aircraft cannot fire, and
|
||||
// its speed ramps with its height. `pliftFrac` is the previous tick's value, for the same
|
||||
// reason px/py exist — the view interpolates between the two.
|
||||
liftFrac: 0, pliftFrac: 0,
|
||||
// Strafing run bookkeeping: once a pass goes overhead the unit commits to a point beyond
|
||||
// the target and flies to it before coming about.
|
||||
egressing: false, egressX: 0, egressY: 0,
|
||||
x: 0, y: 0, px: 0, py: 0,
|
||||
heading: 0, pheading: 0,
|
||||
turretRot: 0, pturretRot: 0,
|
||||
|
|
@ -759,12 +774,197 @@ function findAutoHealTarget(state, rules, e, def) {
|
|||
return best;
|
||||
}
|
||||
|
||||
const MOVE_LIKE = new Set(['move', 'attackMove', 'patrol']);
|
||||
|
||||
/**
|
||||
* How far into its own weapon envelope a unit closes before stopping to shoot a building.
|
||||
*
|
||||
* A building that SHOOTS BACK gets the near-maximum fraction, so anything out-ranging it can
|
||||
* stand off and dismantle it for free — which is the entire counterplay to a defensive
|
||||
* structure, and the reason a Rocket Tank (440) exists alongside a Tank (350) against a Laser
|
||||
* Tower (340). Everything else keeps the low fraction that was tuned for base assaults:
|
||||
* measured at 64 games, closing all the way in ran the skill ladder at 90% and holding at
|
||||
* range dropped it to 68%, so this stays narrowly scoped to targets that can hurt you.
|
||||
*/
|
||||
function holdFractionFor(rules, target) {
|
||||
const c = rules.constants;
|
||||
const def = rules.defById[target.defId];
|
||||
return def?.weaponDefs?.length ? (c.siegeHoldFraction ?? 0.95) : (c.engageHoldFraction ?? 0.85);
|
||||
}
|
||||
|
||||
/** A point `dist` ahead of where `e` is pointing. */
|
||||
function forwardPoint(e, dist) {
|
||||
return { x: e.x + Math.cos(e.heading) * dist, y: e.y + Math.sin(e.heading) * dist };
|
||||
}
|
||||
|
||||
/** Nearest live hostile building within `radius` of (x,y), or null. */
|
||||
function hostileBuildingNear(state, rules, army, x, y, radius) {
|
||||
let best = null, bestD = radius;
|
||||
for (const t of state.entities) {
|
||||
if (t.dead || !t.isBuilding || t.army === army || !state.armies[t.army]) continue;
|
||||
const d = surfaceDist(rules, { x, y }, t);
|
||||
if (d < bestD) { best = t; bestD = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a strafing aircraft should point right now, given the thing it is attacking.
|
||||
*
|
||||
* The run has two legs. INBOUND steers straight at the target and holds until the aircraft is
|
||||
* genuinely overhead; EGRESS carries on to a point `overshoot` past it, which is what gives
|
||||
* the pass its follow-through and the room to come about. The arc of the turn is not scripted
|
||||
* — it falls out of the unit's turnRate against a speed that never drops.
|
||||
*/
|
||||
function strafePoint(state, rules, e, def, t) {
|
||||
const reach = Math.max(64, e.radius * 2);
|
||||
if (e.egressing) {
|
||||
if (Math.hypot(e.egressX - e.x, e.egressY - e.y) > reach) return { x: e.egressX, y: e.egressY };
|
||||
e.egressing = false;
|
||||
}
|
||||
// Two ways to know the pass is spent: overhead, or the target has slipped behind us. The
|
||||
// second matters because a Fighter covers 15px per tick and can cross a small overhead
|
||||
// radius between two samples without ever testing inside it.
|
||||
const bearing = Math.atan2(t.y - e.y, t.x - e.x);
|
||||
const behind = Math.abs(angleDelta(e.heading, bearing)) > 2.0;
|
||||
const dist = Math.hypot(t.x - e.x, t.y - e.y);
|
||||
const overhead = surfaceDist(rules, e, t) <= Math.max(40, e.radius);
|
||||
if (e.liftFrac >= 1 && (overhead || (behind && dist < def.flight.overshoot))) {
|
||||
e.egressing = true;
|
||||
const p = forwardPoint(e, def.flight.overshoot);
|
||||
// Clamped inside the world, or a run started near the edge parks the aircraft against the
|
||||
// map boundary waiting to reach a point it can never get to.
|
||||
e.egressX = Math.max(e.radius, Math.min(state.worldW - e.radius, p.x));
|
||||
e.egressY = Math.max(e.radius, Math.min(state.worldH - e.radius, p.y));
|
||||
return { x: e.egressX, y: e.egressY };
|
||||
}
|
||||
return { x: t.x, y: t.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders for an aircraft that cannot stop in the air.
|
||||
*
|
||||
* The whole shape of this is: it is either LANDED (still, and unable to shoot) or FLYING (at
|
||||
* cruise, always going somewhere). There is no third option, so every order resolves to a
|
||||
* steering point plus a decision about whether to be off the ground at all.
|
||||
*/
|
||||
function stepStraferOrders(state, rules, e, def) {
|
||||
const keepout = def.flight.keepout;
|
||||
e.buildTargetId = 0;
|
||||
|
||||
// A move into a hostile base is not a move, it is an attack run. Landing there would put
|
||||
// the aircraft stationary and defenceless under the guns of whatever it flew to.
|
||||
const first = e.orders[0];
|
||||
if (first && MOVE_LIKE.has(first.type)) {
|
||||
const dest = orderDestination(e, first);
|
||||
const foe = hostileBuildingNear(state, rules, e.army, dest.x, dest.y, keepout);
|
||||
if (foe) e.orders[0] = { type: 'attack', targetId: foe.id };
|
||||
}
|
||||
|
||||
const order = e.orders[0];
|
||||
if (!order) {
|
||||
// Idle: put down where it stands, unless that is inside somebody's base — then clear out
|
||||
// first and land once there is room.
|
||||
const foe = hostileBuildingNear(state, rules, e.army, e.x, e.y, keepout);
|
||||
if (foe) {
|
||||
e._wantAirborne = true;
|
||||
const dx = e.x - foe.x, dy = e.y - foe.y;
|
||||
const d = Math.hypot(dx, dy) || 1;
|
||||
e.movingTo = { x: e.x + (dx / d) * keepout, y: e.y + (dy / d) * keepout };
|
||||
} else {
|
||||
e._wantAirborne = false;
|
||||
e.movingTo = e.liftFrac > 0 ? forwardPoint(e, 400) : null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (order.type) {
|
||||
case 'stop':
|
||||
e.orders.length = 0; e.targetId = 0; e.egressing = false; e.movingTo = null;
|
||||
break;
|
||||
|
||||
case 'hold':
|
||||
// For something that cannot hover, holding a position means being on the ground at it.
|
||||
e._wantAirborne = false;
|
||||
e.movingTo = e.liftFrac > 0 ? forwardPoint(e, 400) : null;
|
||||
break;
|
||||
|
||||
case 'attack':
|
||||
case 'guard': {
|
||||
const t = entityById(state, order.targetId);
|
||||
if (!t || (order.type === 'attack' && !canEngage(def, t))) {
|
||||
e.orders.shift(); e.egressing = false; e.targetId = 0;
|
||||
break;
|
||||
}
|
||||
e._wantAirborne = true;
|
||||
e.movingTo = strafePoint(state, rules, e, def, t);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'move':
|
||||
case 'attackMove':
|
||||
case 'patrol': {
|
||||
const dest = orderDestination(e, order);
|
||||
const d = Math.hypot(dest.x - e.x, dest.y - e.y);
|
||||
const arrive = Math.max(rules.constants.arriveSlackPx, e.radius * 2);
|
||||
e.movingTo = dest;
|
||||
if (d > arrive) { e._wantAirborne = true; break; }
|
||||
if (order.type === 'patrol') {
|
||||
order.leg = order.leg === 0 ? 1 : 0;
|
||||
e._wantAirborne = true;
|
||||
e.movingTo = orderDestination(e, order);
|
||||
break;
|
||||
}
|
||||
// Arrived. Settle onto the spot — speed ramps down with altitude in stepMovement, so
|
||||
// this reads as an approach rather than a stop, and the order only clears once down.
|
||||
e._wantAirborne = false;
|
||||
if (e.liftFrac <= 0) {
|
||||
e.orders.shift(); e.movingTo = null; e.egressing = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
e.orders.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance every flight unit's altitude toward where its orders want it. Runs after stepOrders
|
||||
* (which sets `_wantAirborne`) and before stepMovement (which scales speed by the result).
|
||||
*/
|
||||
function stepFlight(state, rules) {
|
||||
const dt = rules.dt;
|
||||
for (const e of state.entities) {
|
||||
if (e.dead || e.isBuilding || e.site) continue;
|
||||
const def = defOf(rules, e);
|
||||
if (!def.flight) continue;
|
||||
// Units that merely hover are up whenever they have something to do — they are allowed to
|
||||
// stop in mid-air, so their altitude is decoration rather than a rule.
|
||||
if (!def.strafes) e._wantAirborne = !!(e.orders.length || e.movingTo || e.targetId);
|
||||
e.pliftFrac = e.liftFrac;
|
||||
const want = e._wantAirborne ? 1 : 0;
|
||||
const step = dt / (want ? def.flight.takeoffSec : def.flight.landSec);
|
||||
// Note the explicit "already there" case. Folding it into the descend branch makes a unit
|
||||
// holding altitude drop a step and climb it back every other tick, which flickers both the
|
||||
// sprite and — because cruise speed is scaled by this — the unit's actual velocity.
|
||||
if (want > e.liftFrac) e.liftFrac = Math.min(want, e.liftFrac + step);
|
||||
else if (want < e.liftFrac) e.liftFrac = Math.max(want, e.liftFrac - step);
|
||||
// The instant the wheels leave the ground it becomes an air target, and the instant they
|
||||
// touch down it stops being one.
|
||||
e.airborne = e.isAir && e.liftFrac > 0;
|
||||
}
|
||||
}
|
||||
|
||||
function stepOrders(state, rules) {
|
||||
const ts = state.tileSize;
|
||||
for (const e of state.entities) {
|
||||
if (e.dead || e.site) continue;
|
||||
if (e.isBuilding) continue;
|
||||
const def = defOf(rules, e);
|
||||
// An armed aircraft plays by a different rulebook — it has no stationary state in the air,
|
||||
// so none of the arrive/hold/close-to-range logic below applies to it.
|
||||
if (def.strafes) { stepStraferOrders(state, rules, e, def); continue; }
|
||||
const order = e.orders[0];
|
||||
|
||||
if (!order) {
|
||||
|
|
@ -835,7 +1035,7 @@ function stepOrders(state, rules) {
|
|||
if (order.type === 'attackMove' && def.maxRange > 0) {
|
||||
const foe = entityById(state, e.targetId);
|
||||
if (foe && foe.isBuilding && !foe.dead && foe.army !== e.army && state.armies[foe.army]
|
||||
&& surfaceDist(rules, e, foe) <= def.maxRange * (rules.constants.engageHoldFraction ?? 0.85)) {
|
||||
&& surfaceDist(rules, e, foe) <= def.maxRange * holdFractionFor(rules, foe)) {
|
||||
e.path = null;
|
||||
e.movingTo = null;
|
||||
e.stuckTicks = 0;
|
||||
|
|
@ -870,7 +1070,9 @@ function stepOrders(state, rules) {
|
|||
if (!canEngage(def, target)) { e.orders.shift(); e.targetId = 0; e.path = null; break; }
|
||||
e.targetId = target.id;
|
||||
const d = surfaceDist(rules, e, target);
|
||||
const wantRange = def.maxRange * (rules.constants.engageHoldFraction ?? 0.85);
|
||||
const wantRange = def.maxRange * (target.isBuilding
|
||||
? holdFractionFor(rules, target)
|
||||
: (rules.constants.engageHoldFraction ?? 0.85));
|
||||
if (d > wantRange) {
|
||||
if (!e.path && !e.wantPath) seekTo(state, e, target.x, target.y);
|
||||
e.movingTo = { x: target.x, y: target.y };
|
||||
|
|
@ -967,7 +1169,22 @@ function stepMovement(state, rules) {
|
|||
tx = e.movingTo.x; ty = e.movingTo.y;
|
||||
}
|
||||
|
||||
if (tx == null) { e.vx = 0; e.vy = 0; e.stuckTicks = 0; continue; }
|
||||
if (tx == null) {
|
||||
// A stationary turretless unit can otherwise fire only within the arc it happens to be
|
||||
// facing, because nothing ever turns it. That made infantry anti-air close to
|
||||
// decorative: a strafing aircraft crosses that window in well under a second, and eight
|
||||
// rocket troopers got off four shots in a minute.
|
||||
//
|
||||
// Restricted to AIR targets on purpose. Allowing it against ground targets as well is
|
||||
// arguably more correct, but measured over the campaign it strengthens massed infantry
|
||||
// on defence enough to flip m06 from winnable to not — a ground-balance change has no
|
||||
// business riding along inside an anti-air fix.
|
||||
if (!e.isBuilding && e.targetId && def.turretFrame == null && def.maxRange > 0) {
|
||||
const t = entityById(state, e.targetId);
|
||||
if (t && t.airborne) e.heading = turnToward(e.heading, Math.atan2(t.y - e.y, t.x - e.x), def.turnRate * dt);
|
||||
}
|
||||
e.vx = 0; e.vy = 0; e.stuckTicks = 0; continue;
|
||||
}
|
||||
|
||||
const dx = tx - e.x, dy = ty - e.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
|
|
@ -985,13 +1202,26 @@ function stepMovement(state, rules) {
|
|||
terrainMul = tcost ? 1 / tcost : 1;
|
||||
}
|
||||
|
||||
let speed, step;
|
||||
if (def.strafes) {
|
||||
// A strafing aircraft holds cruise speed no matter what it is doing: the ONLY thing that
|
||||
// changes in the air is its heading, which is what turns a course correction into an arc
|
||||
// instead of a pirouette. Speed tracks altitude, so it accelerates off the pad and slows
|
||||
// into a landing, and it is never allowed to creep or hang.
|
||||
speed = def.speed * e.liftFrac;
|
||||
// No clamp to the remaining distance while airborne — clamping is what would let it stop
|
||||
// dead on top of its target instead of carrying through the pass. It does clamp on the
|
||||
// way down, so a landing settles on its spot rather than skidding past it.
|
||||
step = e.liftFrac >= 1 ? speed * dt : Math.min(speed * dt, dist);
|
||||
} else {
|
||||
// Slow down while still swinging round, and ease into the final waypoint.
|
||||
const misalign = Math.abs(angleDelta(e.heading, want));
|
||||
const turnMul = misalign > 1.2 ? 0.25 : (misalign > 0.5 ? 0.65 : 1);
|
||||
const arriveMul = dist < e.radius * 2 ? Math.max(0.25, dist / (e.radius * 2)) : 1;
|
||||
|
||||
const speed = def.speed * terrainMul * turnMul * arriveMul;
|
||||
const step = Math.min(speed * dt, dist);
|
||||
speed = def.speed * terrainMul * turnMul * arriveMul;
|
||||
step = Math.min(speed * dt, dist);
|
||||
}
|
||||
if (step <= 0) { e.vx = 0; e.vy = 0; continue; }
|
||||
e.vx = Math.cos(e.heading) * step / dt;
|
||||
e.vy = Math.sin(e.heading) * step / dt;
|
||||
e.x += Math.cos(e.heading) * step;
|
||||
|
|
@ -1168,11 +1398,20 @@ function stepCombat(state, rules) {
|
|||
const def = defOf(rules, e);
|
||||
if (!def.weaponDefs?.length) continue;
|
||||
|
||||
// Reloads run down on the pad too, so an aircraft rearms while it is grounded.
|
||||
for (let wi = 0; wi < e.reload.length; wi++) if (e.reload[wi] > 0) e.reload[wi]--;
|
||||
|
||||
// A grounded aircraft cannot shoot. This is the flip side of not being able to hover:
|
||||
// sitting still and being useful are mutually exclusive for anything with wings.
|
||||
if (def.flight && e.liftFrac < 1) { e.targetId = 0; continue; }
|
||||
|
||||
// Staggered acquisition — the single biggest CPU lever in the sim. Re-scan only every
|
||||
// retargetPeriodTicks, phase-shifted per unit so the cost spreads across ticks.
|
||||
const holdOrder = e.orders[0]?.type === 'attack';
|
||||
//
|
||||
// A strafing aircraft is deliberately NOT pinned to its ordered target. Its orders decide
|
||||
// where it flies; its guns take whatever is in range on the way past, which is the whole
|
||||
// point of a unit that crosses the battlefield rather than parking in front of one thing.
|
||||
const holdOrder = e.orders[0]?.type === 'attack' && !def.strafes;
|
||||
let target = entityById(state, e.targetId);
|
||||
if (target && !inWeaponRange(rules, e, def, target)) target = null;
|
||||
const due = ((state.tick + e.id) % c.retargetPeriodTicks) === 0;
|
||||
|
|
@ -1206,7 +1445,7 @@ function stepCombat(state, rules) {
|
|||
if (e.reload[wi] > 0) continue;
|
||||
// Manual weapons (the D-Gun) only fire when this exact target was ordered attacked.
|
||||
if (w.manual && !(holdOrder && e.orders[0].targetId === target.id)) continue;
|
||||
if (!weaponHitsDomain(w, target.isAir)) continue;
|
||||
if (!weaponHitsDomain(w, target.airborne)) continue;
|
||||
const dist = surface;
|
||||
if (dist > w.range || dist < (w.minRange ?? 0)) continue;
|
||||
if (!aligned) continue;
|
||||
|
|
@ -1226,7 +1465,7 @@ function stepCombat(state, rules) {
|
|||
for (let wi = 0; wi < def.weaponDefs.length; wi++) {
|
||||
const w = def.weaponDefs[wi];
|
||||
if (w.manual) continue;
|
||||
if (!weaponHitsDomain(w, target.isAir)) continue;
|
||||
if (!weaponHitsDomain(w, target.airborne)) continue;
|
||||
if (e.burstLeft[wi] > 0 && e.reload[wi] === 0) {
|
||||
fireWeapon(state, rules, e, w, target, facing, aimPt);
|
||||
e.burstLeft[wi]--;
|
||||
|
|
@ -1285,7 +1524,7 @@ function acquireTarget(state, rules, e, def, hash, near) {
|
|||
for (const w of def.weaponDefs) {
|
||||
// A weapon that can't reach the target's domain contributes nothing, so a unit with no
|
||||
// AA at all never scores an aircraft and simply ignores it.
|
||||
if (!weaponHitsDomain(w, t.isAir)) continue;
|
||||
if (!weaponHitsDomain(w, t.airborne)) continue;
|
||||
if (d > w.range || d < (w.minRange ?? 0)) continue;
|
||||
const mul = w.armorMul?.[rules.defById[t.defId].armorClass] ?? 1;
|
||||
const s = (w.damage * mul) / (1 + d / w.range);
|
||||
|
|
@ -1413,7 +1652,7 @@ function stepProjectiles(state, rules) {
|
|||
let bestT = Infinity, hit = null;
|
||||
for (const e of state.entities) {
|
||||
if (e.dead || e.site || e.army === p.army) continue;
|
||||
if (!weaponHitsDomain(w, e.isAir)) continue;
|
||||
if (!weaponHitsDomain(w, e.airborne)) continue;
|
||||
if (segDistSq(p.px, p.py, p.x, p.y, e.x, e.y) > e.radius * e.radius) continue;
|
||||
const s = segClosestT(p.px, p.py, p.x, p.y, e.x, e.y);
|
||||
if (s < bestT) { bestT = s; hit = e; }
|
||||
|
|
@ -1430,7 +1669,7 @@ function stepProjectiles(state, rules) {
|
|||
else {
|
||||
for (const e of state.entities) {
|
||||
if (e.dead || e.army === p.army) continue;
|
||||
if (!weaponHitsDomain(w, e.isAir)) continue;
|
||||
if (!weaponHitsDomain(w, e.airborne)) continue;
|
||||
if (Math.hypot(e.x - p.x, e.y - p.y) <= e.radius) {
|
||||
applyDamage(state, rules, e, w.damage * (w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1), { army: p.army, id: p.ownerId });
|
||||
break;
|
||||
|
|
@ -1467,7 +1706,7 @@ function applyAoe(state, rules, x, y, w, source) {
|
|||
if (e.dead) continue;
|
||||
if (e.army === source.army && !rules.constants.friendlyFire) continue;
|
||||
// Splash respects the domain too — a bomb's blast radius stays on the ground.
|
||||
if (!weaponHitsDomain(w, e.isAir)) continue;
|
||||
if (!weaponHitsDomain(w, e.airborne)) continue;
|
||||
// Falloff is measured to the SURFACE, so a shell that lands squarely on a building's
|
||||
// face counts as a direct hit wherever along that face it strikes. Measuring from the
|
||||
// centre circle instead scored an off-axis wall hit as 32px away and cut its damage by
|
||||
|
|
@ -1533,7 +1772,7 @@ function killEntity(state, rules, e, source) {
|
|||
state.events.push({ t: 'bigExplosion', x: e.x, y: e.y, radius: ex.radius });
|
||||
for (const o of state.entities) {
|
||||
if (o.dead || o.id === e.id) continue;
|
||||
if (o.isAir) continue; // a ground blast doesn't reach whatever is flying over it
|
||||
if (o.airborne) continue; // a ground blast doesn't reach whatever is flying over it
|
||||
const d = Math.hypot(o.x - e.x, o.y - e.y) - o.radius;
|
||||
if (d > ex.radius) continue;
|
||||
const scale = 1 - Math.max(0, Math.min(1, d / ex.radius)) * 0.7;
|
||||
|
|
@ -1641,6 +1880,7 @@ export function tick(state, rules) {
|
|||
|
||||
stepEconomy(state, rules);
|
||||
stepOrders(state, rules);
|
||||
stepFlight(state, rules);
|
||||
servicePathQueue(state, rules);
|
||||
stepMovement(state, rules);
|
||||
stepSeparation(state, rules);
|
||||
|
|
@ -1743,6 +1983,10 @@ export function serialize(state) {
|
|||
lastDamagedTick: e.lastDamagedTick,
|
||||
reload: e.reload, burstLeft: e.burstLeft,
|
||||
terrainBonus: e.terrainBonus,
|
||||
// Altitude decides whether a unit may fire, so a save that dropped it would resume with
|
||||
// every aircraft grounded and mute for a second.
|
||||
liftFrac: Math.round(e.liftFrac * 1000) / 1000,
|
||||
egressing: e.egressing, egressX: e.egressX, egressY: e.egressY,
|
||||
})),
|
||||
// Projectiles are sub-second transients; dropping them costs nothing and saves bytes.
|
||||
});
|
||||
|
|
@ -1790,6 +2034,10 @@ export function deserialize(rules, raw) {
|
|||
const e = baseEntity(state, s.army, def);
|
||||
Object.assign(e, s);
|
||||
e.px = e.x; e.py = e.y; e.pheading = e.heading; e.pturretRot = e.turretRot;
|
||||
e.pliftFrac = e.liftFrac;
|
||||
// Derived from the restored altitude, so a save resumes with aircraft in the same domain
|
||||
// they were in rather than every one of them briefly shootable by anything.
|
||||
e.airborne = e.isAir && e.liftFrac > 0;
|
||||
e.radius = def.isBuilding ? def.radius : (def.radius ?? 16);
|
||||
e.path = null; e.pathIdx = 0; e.wantPath = false;
|
||||
state.entities.push(e);
|
||||
|
|
|
|||
|
|
@ -251,6 +251,10 @@ export function compileRules(json) {
|
|||
// its x/y say it is for movement, collision, targeting and clicking. It is declared
|
||||
// separately from `domain` on purpose: the Hover Constructor floats visibly but is a
|
||||
// ground unit that rifles can hit and that has to path around cliffs.
|
||||
// An armed aircraft STRAFES: it cannot stop in the air, so it makes firing passes and
|
||||
// lands to sit still. An unarmed one (or a merely hovering ground unit like the Hover
|
||||
// Constructor) keeps the simple stop-where-you-are behaviour.
|
||||
u.strafes = u.isAir && (u.weapons ?? []).length > 0;
|
||||
if (u.flight) {
|
||||
for (const k of ['height', 'takeoffSec']) {
|
||||
if (!(u.flight[k] > 0)) fail(`unit "${u.id}" flight.${k} must be positive`);
|
||||
|
|
@ -259,6 +263,13 @@ export function compileRules(json) {
|
|||
fail(`unit "${u.id}" flight.landSec must be positive when present`);
|
||||
}
|
||||
u.flight.landSec = u.flight.landSec ?? u.flight.takeoffSec;
|
||||
if (u.strafes) {
|
||||
// How far past the target it carries before turning about, and how close to a hostile
|
||||
// structure it is willing to put down. Both are distances in pixels.
|
||||
for (const k of ['overshoot', 'keepout']) {
|
||||
if (!(u.flight[k] > 0)) fail(`strafing unit "${u.id}" needs a positive flight.${k}`);
|
||||
}
|
||||
}
|
||||
} else if (u.isAir) {
|
||||
fail(`air unit "${u.id}" needs a flight block, or it will render sitting on the ground`);
|
||||
}
|
||||
|
|
@ -410,9 +421,12 @@ export function weaponHitsDomain(weapon, targetIsAir) {
|
|||
* Does `def` carry ANY weapon that can reach `target`'s domain? Anything that can't should
|
||||
* never acquire, chase or fire at it — a rifleman ordered to attack a Fighter would otherwise
|
||||
* follow it across the map forever, never able to shoot.
|
||||
*
|
||||
* Reads `airborne`, not `isAir`: a landed aircraft is an ordinary ground target, so this flips
|
||||
* for the same unit as it takes off and touches down.
|
||||
*/
|
||||
export function canEngage(def, target) {
|
||||
const air = !!target.isAir;
|
||||
const air = !!target.airborne;
|
||||
for (const w of def.weaponDefs ?? []) if (weaponHitsDomain(w, air)) return true;
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,10 +33,10 @@ export const DEPTHS = {
|
|||
|
||||
// Altitude, for units whose def carries a `flight` block (everything the Airfield builds).
|
||||
//
|
||||
// Entirely view-side. The simulation has no height axis: the entity stays exactly where its
|
||||
// x/y say it is for movement, collision, targeting and clicking, and only the SPRITE is
|
||||
// lifted. That is what keeps this free — no extra sim work, no state to serialize, and a unit
|
||||
// you can still click where you see its shadow.
|
||||
// The height itself is simulation state (`e.liftFrac`) because it gates whether a unit may
|
||||
// fire; the renderer only turns that number into pixels. The entity's x/y stay on the ground
|
||||
// for movement, collision, targeting and clicking — only the SPRITE is lifted — so a unit is
|
||||
// still exactly where you see its shadow.
|
||||
//
|
||||
// The shadow stays pinned to the true ground position while the body rises off it, so the gap
|
||||
// between the two IS the altitude cue. At rest they are coincident and the shadow is fully
|
||||
|
|
@ -53,13 +53,7 @@ const PIE_QUEUE_COLOR = 0xffd27a; // amber — the batch
|
|||
const PIE_JOB_COLOR = 0x8ad4ff; // build blue, same language as sites and nanolathe
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
/** Move `cur` toward `want` by at most `step`. */
|
||||
function approach(cur, want, step) {
|
||||
if (cur < want) return Math.min(want, cur + step);
|
||||
return Math.max(want, cur - step);
|
||||
}
|
||||
|
||||
/** Smoothstep, so a takeoff eases out of the ground and into the hover instead of snapping. */
|
||||
/** Smoothstep, so a takeoff eases out of the ground and into the climb instead of snapping. */
|
||||
function smoothstep(t) { return t * t * (3 - 2 * t); }
|
||||
|
||||
export default class TAWorldView {
|
||||
|
|
@ -417,14 +411,10 @@ export default class TAWorldView {
|
|||
|
||||
/**
|
||||
* @param {number} alpha interpolation factor between the last two sim ticks
|
||||
* @param {number} [deltaMs] real frame time, for framerate-independent view animation
|
||||
* @returns {{nanoLinks:Array, projectiles:Array}} data the FX layer needs, in view space
|
||||
*/
|
||||
render(alpha, deltaMs = 16.7) {
|
||||
render(alpha) {
|
||||
const { state, rules } = this;
|
||||
// Clamped: a tab that was backgrounded for ten seconds must not teleport every aircraft
|
||||
// to full altitude in one frame.
|
||||
const dtSec = Math.min(0.1, Math.max(0, deltaMs) / 1000);
|
||||
this._updateChunks();
|
||||
|
||||
if (this.fogEnabled && this.fogDirty) { this._redrawFog(); this.fogDirty = false; }
|
||||
|
|
@ -449,15 +439,12 @@ export default class TAWorldView {
|
|||
const y = e.py + (e.y - e.py) * alpha;
|
||||
const heading = lerpAngle(e.pheading, e.heading, alpha);
|
||||
|
||||
// Altitude. The trigger is deliberately "has been given something to do" rather than
|
||||
// "is physically moving": e.orders fills the instant the order is issued, a tick before
|
||||
// stepMovement has translated the unit anywhere, so the climb visibly leads the move
|
||||
// instead of trailing it. A unit standing on a target it is shooting stays up too.
|
||||
// Altitude comes from the SIM — it decides whether a unit may fire, so the renderer must
|
||||
// not run a second copy of it that could disagree. Interpolated between ticks exactly
|
||||
// like position, then eased so a takeoff rolls off the ground instead of snapping.
|
||||
let lift = 0;
|
||||
if (def.flight) {
|
||||
const airborne = !!(e.movingTo || e.orders.length || e.targetId);
|
||||
const secs = airborne ? def.flight.takeoffSec : def.flight.landSec;
|
||||
s.alt = approach(s.alt, airborne ? 1 : 0, dtSec / secs);
|
||||
s.alt = e.pliftFrac + (e.liftFrac - e.pliftFrac) * alpha;
|
||||
lift = def.flight.height * smoothstep(s.alt);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -241,9 +241,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
|
|||
|
||||
for (const ev of events) this._onSimEvent(ev);
|
||||
|
||||
// Real frame delta, NOT delta * simSpeed — takeoff is a view animation and should look the
|
||||
// same whether the match is paused, at 1x or at 4x.
|
||||
const { nanoLinks, projectiles } = this.view.render(st.alpha, delta);
|
||||
const { nanoLinks, projectiles } = this.view.render(st.alpha);
|
||||
this.fx.draw(delta, nanoLinks, projectiles, time);
|
||||
this._syncSelection();
|
||||
this.hud.update(time, this._selectedEntities(), this.placement?.def ?? null);
|
||||
|
|
|
|||
|
|
@ -81,10 +81,20 @@ movement, collision, targeting and clicking:
|
|||
| Hover Constructor | 9px | 0.35s | 0.5s |
|
||||
|
||||
At rest the unit sits flat on the ground with no visible shadow. The moment it is given
|
||||
something to do, the renderer eases the **sprite** upward by `height` while leaving a black,
|
||||
50%-opacity copy of its own frame pinned to the true ground position — the growing gap between
|
||||
the two is the altitude. It settles back down when idle. `height` is the one number to tune if
|
||||
a unit reads as too glued down or too detached.
|
||||
something to do, the **sprite** rises by `height` while a black, 50%-opacity copy of its own
|
||||
frame stays pinned to the true ground position — the growing gap between the two is the
|
||||
altitude. It settles back down when idle. `height` is the one number to tune if a unit reads as
|
||||
too glued down or too detached.
|
||||
|
||||
The height itself is **simulation state** (`e.liftFrac`), not a render animation, because it
|
||||
decides whether a unit is allowed to fire. The renderer only interpolates it between ticks.
|
||||
|
||||
Armed aircraft additionally carry `overshoot` and `keepout` (both in pixels) and are flagged
|
||||
`strafes` by the rules compiler. They cannot stop in the air: a move order ends in a landing,
|
||||
an attack order becomes a repeating firing pass, and a landed one can neither shoot nor claim
|
||||
the protection of the air domain — on the ground it is an ordinary target that any weapon can
|
||||
hit. The Hover Constructor is exempt — it is a ground unit that merely floats, and it does not
|
||||
attack.
|
||||
|
||||
Three consequences for the art:
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ const CH = rules.terrain.map((t) => t.ch);
|
|||
//
|
||||
// Re-check these whenever combat balance moves. Making units hold at weapon range instead of
|
||||
// closing to point blank shifted attack/defence enough to turn m02's old seed from playable
|
||||
// into unwinnable (0/8), with no change to the mission's own numbers.
|
||||
// into unwinnable (0/8), with no change to the mission's own numbers. Giving armed aircraft a
|
||||
// strafing flight model did the same to m05 later on. Both times the mission was fine and the
|
||||
// battlefield was not.
|
||||
const MISSIONS = [
|
||||
{
|
||||
id: 'm01', name: 'Beachhead', seed: 10461, size: 'small', theme: 'grasslands', symmetry: 'mirror-x',
|
||||
|
|
@ -70,7 +72,11 @@ const MISSIONS = [
|
|||
defeat: 'KLAXON out-produced us and it showed.',
|
||||
},
|
||||
{
|
||||
id: 'm05', name: 'Tropic Vice', seed: 55930, size: 'medium', theme: 'tropics', symmetry: 'mirror-x',
|
||||
// Re-seeded twice as combat balance moved under it, both times measured over an 8-run
|
||||
// sweep: 55930 -> 40411 when armed aircraft stopped being able to hover, then 40411 (which
|
||||
// fell to 4/8) -> 71644 when the AI learned to build static defences. Cy-Bro is skill 4
|
||||
// here, which is exactly the level at which defences switch on.
|
||||
id: 'm05', name: 'Tropic Vice', seed: 71644, size: 'medium', theme: 'tropics', symmetry: 'mirror-x',
|
||||
enemy: { commander: 'cybro', skill: 4, aggression: 0.7 },
|
||||
startResources: [{ mass: 2600, energy: 4200 }, { mass: 1500, energy: 2600 }],
|
||||
prebuilt: [
|
||||
|
|
|
|||
|
|
@ -313,13 +313,12 @@ section('2c. View layering and first frame');
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('2e. Takeoff, hover and shadow');
|
||||
section('2e. Altitude rendering');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// Altitude is a pure view animation, so it is tested the way §2c tests layering: by driving
|
||||
// the REAL TAWorldView against the stub scene and reading the sprites back. The sim is
|
||||
// never ticked here — orders are set and frames are rendered — which is itself the point:
|
||||
// if any of this leaked into TALogic these fixtures could not work at all.
|
||||
// Height is SIMULATION state (`e.liftFrac`) because it decides whether a unit may fire. This
|
||||
// section only checks that the renderer turns that number into the right pixels — the flight
|
||||
// behaviour itself is §6g, where it can be tested headlessly like everything else.
|
||||
check('the Hover Constructor hovers without being an air unit',
|
||||
!!rules.unitById.hoverconstructor.flight && rules.unitById.hoverconstructor.isAir === false,
|
||||
'the visual and the simulation domain must stay separate');
|
||||
|
|
@ -329,92 +328,60 @@ section('2e. Takeoff, hover and shadow');
|
|||
}
|
||||
|
||||
const map = generateMap(rules, { seed: 4242, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', armies: 2 });
|
||||
const FRAME = 1000 / 60;
|
||||
|
||||
/** Fresh view + a single grounded fighter, with fog off so visibility never masks a bug. */
|
||||
const rig = () => {
|
||||
const st = L.createMatch(rules, { seed: 4242, map, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }] });
|
||||
const scene = makeStubScene();
|
||||
try {
|
||||
const view = new TAWorldView(scene, rules, artJson, st, 0);
|
||||
view.setFogEnabled(false);
|
||||
const def = rules.unitById.fighter;
|
||||
const start = st.starts.find((x) => x.army === 0);
|
||||
const e = L.spawnUnit(st, rules, 0, 'fighter', start.x * st.tileSize, start.y * st.tileSize);
|
||||
const run = (sec, frameMs = FRAME) => {
|
||||
for (let t = 0; t < sec * 1000; t += frameMs) view.render(0, frameMs);
|
||||
};
|
||||
return { st, view, e, run, spr: () => view.sprites.get(e.id) };
|
||||
};
|
||||
const run = (sec) => { for (let i = 0; i < sec * HZ; i++) L.tick(st, rules); };
|
||||
|
||||
let r = rig();
|
||||
try {
|
||||
const def = rules.unitById.fighter;
|
||||
r.run(1.0);
|
||||
let s = r.spr();
|
||||
check('an idle aircraft renders on the ground', Math.abs(s.img.y - r.e.y) < 0.01,
|
||||
`body ${(s.img.y - r.e.y).toFixed(2)}px off the ground`);
|
||||
run(0.2);
|
||||
view.render(0);
|
||||
let s = view.sprites.get(e.id);
|
||||
check('a grounded aircraft renders on the ground', Math.abs(s.img.y - e.y) < 0.01,
|
||||
`body ${(s.img.y - e.y).toFixed(2)}px off the ground`);
|
||||
check('a grounded aircraft casts no visible shadow', s.shadow.alpha < 0.001,
|
||||
`alpha ${s.shadow.alpha.toFixed(3)}`);
|
||||
|
||||
// Giving it somewhere to be is what starts the climb — orders fill a tick before the sim
|
||||
// has moved it anywhere, so the takeoff leads the movement rather than trailing it.
|
||||
L.issueOrder(r.st, rules, {
|
||||
army: 0, unitIds: [r.e.id], order: { type: 'move', x: r.e.x + 600, y: r.e.y },
|
||||
});
|
||||
r.run(0.1);
|
||||
s = r.spr();
|
||||
check('the climb starts before the unit has travelled', s.img.y < r.e.y - 0.5,
|
||||
`only ${(r.e.y - s.img.y).toFixed(2)}px up after 0.1s`);
|
||||
|
||||
r.run(2.0);
|
||||
s = r.spr();
|
||||
const lift = r.e.y - s.img.y;
|
||||
check('a moving aircraft reaches its full hover height',
|
||||
L.issueOrder(st, rules, { army: 0, unitIds: [e.id], order: { type: 'move', x: e.x + 2000, y: e.y } });
|
||||
run(2.0);
|
||||
// Rendered at alpha 1 — an airborne unit is in motion now, so anything comparing sprite
|
||||
// positions against the entity's CURRENT x/y has to sample the end of the tick, not a
|
||||
// point interpolated back toward where it was.
|
||||
view.render(1);
|
||||
s = view.sprites.get(e.id);
|
||||
const lift = e.y - s.img.y;
|
||||
check('a flying aircraft is drawn at its full hover height',
|
||||
Math.abs(lift - def.flight.height) < 0.5, `lifted ${lift.toFixed(1)} of ${def.flight.height}px`);
|
||||
check('the shadow stays pinned to the true ground position',
|
||||
Math.abs(s.shadow.x - r.e.x) < 0.01 && Math.abs(s.shadow.y - r.e.y) < 0.01);
|
||||
Math.abs(s.shadow.x - e.x) < 0.01 && Math.abs(s.shadow.y - e.y) < 0.01);
|
||||
check('the shadow separates from the body by the hover height',
|
||||
Math.abs((s.shadow.y - s.img.y) - def.flight.height) < 0.5);
|
||||
check('the airborne shadow is 50% transparent', Math.abs(s.shadow.alpha - 0.5) < 0.001,
|
||||
`alpha ${s.shadow.alpha.toFixed(3)}`);
|
||||
check('the shadow tightens as the unit climbs', s.shadow.scaleX < s.img.scaleX);
|
||||
// Lifting the sprite must not re-sort it against its neighbours, or an aircraft would
|
||||
// pop in front of things as it took off.
|
||||
const depthUp = s.img.depth;
|
||||
// Lifting the sprite must not re-sort it against its neighbours, or an aircraft would pop
|
||||
// in front of things as it took off.
|
||||
check('depth is taken from the ground position, not the lifted sprite',
|
||||
Math.abs(depthUp - (DEPTHS.air + (r.e.y / r.st.worldH) * 10 + 0.05)) < 1e-6);
|
||||
Math.abs(s.img.depth - (DEPTHS.air + (e.y / st.worldH) * 10 + 0.05)) < 1e-6);
|
||||
|
||||
// Landing: strip the orders the way arriving would, without running the sim.
|
||||
r.e.orders.length = 0; r.e.movingTo = null; r.e.targetId = 0;
|
||||
r.run(2.0);
|
||||
s = r.spr();
|
||||
check('an aircraft settles back onto the ground when idle',
|
||||
Math.abs(s.img.y - r.e.y) < 0.01 && s.shadow.alpha < 0.001,
|
||||
`body ${(s.img.y - r.e.y).toFixed(2)}px up, shadow alpha ${s.shadow.alpha.toFixed(3)}`);
|
||||
// Altitude is interpolated between ticks exactly like position, so a climb is smooth at
|
||||
// any framerate rather than stepping 20 times a second. Position is frozen here so the
|
||||
// only thing differing between the two renders is the altitude.
|
||||
e.px = e.x; e.py = e.y;
|
||||
e.pliftFrac = 0; e.liftFrac = 1;
|
||||
view.render(0);
|
||||
const low = e.y - view.sprites.get(e.id).img.y;
|
||||
view.render(1);
|
||||
const high = e.y - view.sprites.get(e.id).img.y;
|
||||
check('altitude interpolates between sim ticks', low < high - 1,
|
||||
`alpha 0 gave ${low.toFixed(1)}px, alpha 1 gave ${high.toFixed(1)}px`);
|
||||
view.destroy();
|
||||
} catch (err) {
|
||||
check('the takeoff animation runs', false, err.message);
|
||||
}
|
||||
r.view.destroy();
|
||||
|
||||
// Framerate independence. The animation advances on real elapsed time, so half a second of
|
||||
// 60fps and half a second of 120fps must arrive at the same altitude — otherwise takeoff
|
||||
// speed would silently depend on the player's hardware.
|
||||
{
|
||||
const a = rig(), b = rig();
|
||||
try {
|
||||
for (const g of [a, b]) {
|
||||
L.issueOrder(g.st, rules, {
|
||||
army: 0, unitIds: [g.e.id], order: { type: 'move', x: g.e.x + 600, y: g.e.y },
|
||||
});
|
||||
}
|
||||
a.run(0.3, 1000 / 60);
|
||||
b.run(0.3, 1000 / 120);
|
||||
const la = a.e.y - a.spr().img.y, lb = b.e.y - b.spr().img.y;
|
||||
check('takeoff is framerate independent', Math.abs(la - lb) < 1.0,
|
||||
`60fps lifted ${la.toFixed(2)}px, 120fps lifted ${lb.toFixed(2)}px`);
|
||||
} catch (err) {
|
||||
check('the framerate-independence rig runs', false, err.message);
|
||||
}
|
||||
a.view.destroy(); b.view.destroy();
|
||||
check('the altitude renderer runs', false, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1432,34 +1399,46 @@ section('6f. Air domain and hover');
|
|||
|
||||
// ---- ground weapons cannot touch aircraft, and air-only weapons cannot touch the ground ----
|
||||
{
|
||||
// The Fighter carries the Jeep's gun for ground work, so it hurts infantry — but the
|
||||
// infantry still cannot reach back, which is the asymmetry the whole domain split exists
|
||||
// to create. It has to be flying for any of this: see §6g for the grounded case.
|
||||
check('the air-to-air cannon cannot touch the ground', ar.weaponById.aacannon.targetsGround === false);
|
||||
check('the Fighter carries the Jeep\'s gun for ground targets',
|
||||
(ar.unitById.fighter.weapons ?? []).includes('pintlegun'));
|
||||
|
||||
const st = arena(6);
|
||||
const inf = [];
|
||||
for (let i = 0; i < 8; i++) inf.push(L.spawnUnit(st, ar, 0, 'infantry', px(8), px(14) + i * 30));
|
||||
const fighter = L.spawnUnit(st, ar, 1, 'fighter', px(10), px(16));
|
||||
const fighter = L.spawnUnit(st, ar, 1, 'fighter', px(14), px(16));
|
||||
L.issueOrder(st, ar, { army: 0, unitIds: inf.map((u) => u.id), order: { type: 'attackMove', x: px(12), y: px(16) } });
|
||||
L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'hold' } });
|
||||
L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'attack', targetId: inf[0].id } });
|
||||
runFor(st, 60);
|
||||
check('rifles cannot damage a Fighter', fighter.hp === fighter.maxHp,
|
||||
`${fighter.hp.toFixed(0)}/${fighter.maxHp}`);
|
||||
check('a Fighter cannot damage infantry', inf.every((u) => !u.dead && u.hp === u.maxHp));
|
||||
check('a Fighter strafing infantry does hurt them',
|
||||
inf.some((u) => u.dead || u.hp < u.maxHp),
|
||||
'the ground gun never connected');
|
||||
}
|
||||
{
|
||||
const st = arena(7);
|
||||
const troopers = [];
|
||||
for (let i = 0; i < 8; i++) troopers.push(L.spawnUnit(st, ar, 0, 'rockettrooper', px(8), px(14) + i * 30));
|
||||
const fighter = L.spawnUnit(st, ar, 1, 'fighter', px(11), px(16));
|
||||
L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'hold' } });
|
||||
L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'attack', targetId: troopers[0].id } });
|
||||
runFor(st, 60);
|
||||
check('shoulder rockets do reach a Fighter', fighter.dead || fighter.hp < fighter.maxHp,
|
||||
`${fighter.hp.toFixed(0)}/${fighter.maxHp}`);
|
||||
}
|
||||
{
|
||||
// Fighters are the answer to fighters; that is the whole point of an air-only weapon.
|
||||
// They need somewhere to be — an idle aircraft lands, and a landed one cannot shoot.
|
||||
const st = arena(8);
|
||||
const a = L.spawnUnit(st, ar, 0, 'fighter', px(10), px(20));
|
||||
const b = L.spawnUnit(st, ar, 1, 'fighter', px(13), px(20));
|
||||
const b = L.spawnUnit(st, ar, 1, 'fighter', px(16), px(20));
|
||||
L.issueOrder(st, ar, { army: 0, unitIds: [a.id], order: { type: 'attack', targetId: b.id } });
|
||||
L.issueOrder(st, ar, { army: 1, unitIds: [b.id], order: { type: 'attack', targetId: a.id } });
|
||||
runFor(st, 90);
|
||||
check('fighters can kill each other', a.dead || b.dead || a.hp < a.maxHp,
|
||||
check('fighters can kill each other', a.dead || b.dead || a.hp < a.maxHp || b.hp < b.maxHp,
|
||||
`${a.hp.toFixed(0)} v ${b.hp.toFixed(0)}`);
|
||||
}
|
||||
|
||||
|
|
@ -1468,12 +1447,17 @@ section('6f. Air domain and hover');
|
|||
const st = arena(9);
|
||||
const bomber = L.spawnUnit(st, ar, 0, 'bomber', px(8), px(20));
|
||||
const tank = L.spawnUnit(st, ar, 1, 'tank', px(13), px(20));
|
||||
// Parked directly over the tank: a 96px bomb blast covers it, and must not scratch it.
|
||||
// Kept FLYING over the tank on a short patrol: a 96px bomb blast covers it, and must not
|
||||
// scratch it while it is in the air. Idling it would land it, and a landed aircraft is
|
||||
// deliberately no longer immune — see §6g.
|
||||
const overhead = L.spawnUnit(st, ar, 1, 'fighter', px(13), px(20));
|
||||
L.issueOrder(st, ar, { army: 0, unitIds: [bomber.id], order: { type: 'attack', targetId: tank.id } });
|
||||
L.issueOrder(st, ar, { army: 1, unitIds: [overhead.id], order: { type: 'patrol', x: px(15), y: px(20) } });
|
||||
runFor(st, 60);
|
||||
check('a Bomber damages ground armour', tank.dead || tank.hp < tank.maxHp,
|
||||
`${tank.hp.toFixed(0)}/${tank.maxHp}`);
|
||||
check('the patrolling aircraft stayed airborne', overhead.dead || overhead.liftFrac === 1,
|
||||
'the fixture landed it, so it proves nothing about splash');
|
||||
check('bomb splash does not reach the aircraft above it', overhead.hp === overhead.maxHp,
|
||||
`${overhead.hp.toFixed(0)}/${overhead.maxHp}`);
|
||||
}
|
||||
|
|
@ -1483,6 +1467,10 @@ section('6f. Air domain and hover');
|
|||
const st = arena(10);
|
||||
const inf = L.spawnUnit(st, ar, 0, 'infantry', px(10), px(20));
|
||||
const fighter = L.spawnUnit(st, ar, 1, 'fighter', px(12), px(20));
|
||||
// Airborne, so the rifleman genuinely cannot reach it. (Landed it would be a legal target;
|
||||
// that is the whole point of §6g's grounded-aircraft checks.)
|
||||
L.issueOrder(st, ar, { army: 1, unitIds: [fighter.id], order: { type: 'move', x: px(30), y: px(20) } });
|
||||
L.tick(st, ar);
|
||||
L.issueOrder(st, ar, { army: 0, unitIds: [inf.id], order: { type: 'attack', targetId: fighter.id } });
|
||||
L.tick(st, ar);
|
||||
check('an attack order on an unreachable domain is dropped', inf.orders.length === 0,
|
||||
|
|
@ -1513,6 +1501,241 @@ section('6f. Air domain and hover');
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('6g. Strafing flight');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// An armed aircraft has exactly two states: LANDED (still, and unable to shoot) or FLYING (at
|
||||
// cruise, always going somewhere). Every check here is about that having no third option —
|
||||
// the failure mode this replaces is a Fighter parked in mid-air over a target, sniping it
|
||||
// from a standstill, which is what the previous stop-on-arrival movement produced.
|
||||
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
|
||||
raw.constants.eliminateWhenUnrecoverable = false;
|
||||
const sr = compileRules(raw);
|
||||
const TS = sr.constants.tileSize;
|
||||
const W = 64, H = 64;
|
||||
|
||||
check('armed aircraft are marked as strafing', sr.unitById.fighter.strafes === true
|
||||
&& sr.unitById.bomber.strafes === true);
|
||||
check('the Hover Constructor is exempt from strafing', sr.unitById.hoverconstructor.strafes === false,
|
||||
'it is a ground unit that merely floats, and it does not attack');
|
||||
|
||||
const px = (t) => t * TS + TS / 2;
|
||||
const arena = (seed = 3) => {
|
||||
const st = L.createMatch(sr, {
|
||||
seed, victory: 'annihilation',
|
||||
map: { w: W, h: H, terrain: new Uint8Array(W * H).fill(sr.terrainById.ground.index), starts: [], theme: 'grasslands' },
|
||||
armies: [{ armyId: 'arm' }, { armyId: 'core' }],
|
||||
});
|
||||
// Both armies need something alive or checkResult ends the match on tick one — see §6f.
|
||||
L.spawnUnit(st, sr, 0, 'infantry', px(1), px(1));
|
||||
L.spawnUnit(st, sr, 1, 'infantry', px(W - 2), px(H - 2));
|
||||
st.over = null;
|
||||
for (const a of st.armies) a.alive = true;
|
||||
return st;
|
||||
};
|
||||
const runFor = (st, sec) => { for (let i = 0; i < sec * HZ; i++) L.tick(st, sr); };
|
||||
const speedOf = (e) => Math.hypot(e.x - e.px, e.y - e.py) * HZ;
|
||||
|
||||
// ---- a move order ends in a landing, not a hover ----
|
||||
{
|
||||
const st = arena(31);
|
||||
const f = L.spawnUnit(st, sr, 0, 'fighter', px(10), px(30));
|
||||
check('a fresh aircraft starts on the ground', f.liftFrac === 0);
|
||||
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'move', x: px(40), y: px(30) } });
|
||||
runFor(st, 1.0);
|
||||
check('an ordered aircraft takes off', f.liftFrac === 1, `liftFrac ${f.liftFrac.toFixed(2)}`);
|
||||
runFor(st, 30);
|
||||
check('a move order ends with the aircraft landed', f.liftFrac === 0, `liftFrac ${f.liftFrac.toFixed(2)}`);
|
||||
check('it landed where it was sent', Math.hypot(f.x - px(40), f.y - px(30)) < TS * 2,
|
||||
`${(Math.hypot(f.x - px(40), f.y - px(30)) / TS).toFixed(1)} tiles away`);
|
||||
check('a landed aircraft is stationary', speedOf(f) < 0.01, `${speedOf(f).toFixed(1)} px/s`);
|
||||
check('a landed aircraft holds no orders', f.orders.length === 0);
|
||||
}
|
||||
|
||||
// ---- landed units cannot shoot ----
|
||||
{
|
||||
const st = arena(32);
|
||||
const f = L.spawnUnit(st, sr, 0, 'fighter', px(30), px(30));
|
||||
const foe = L.spawnUnit(st, sr, 1, 'infantry', px(32), px(30)); // well inside pintlegun range
|
||||
runFor(st, 20);
|
||||
check('a landed aircraft never takes off on its own', f.liftFrac === 0);
|
||||
check('a landed aircraft does not shoot', foe.hp === foe.maxHp && !foe.dead,
|
||||
`the target lost ${(foe.maxHp - foe.hp).toFixed(0)} hp`);
|
||||
check('a landed aircraft holds no target', f.targetId === 0);
|
||||
}
|
||||
|
||||
// ---- a landed aircraft is an ordinary ground target ----
|
||||
{
|
||||
// Immunity is a property of FLYING, not of having wings. A parked aircraft can be shot by
|
||||
// anything, which is what gives the keepout rule and the choice of where to land teeth.
|
||||
const st = arena(40);
|
||||
const f = L.spawnUnit(st, sr, 0, 'fighter', px(30), px(30));
|
||||
const tank = L.spawnUnit(st, sr, 1, 'tank', px(34), px(30)); // tankgun is ground-only
|
||||
L.tick(st, sr);
|
||||
check('a landed aircraft is not an air target', f.airborne === false && f.isAir === true,
|
||||
'isAir describes the layer it moves in; airborne describes where it is now');
|
||||
runFor(st, 40);
|
||||
check('a tank gun can destroy a parked aircraft', f.dead || f.hp < f.maxHp,
|
||||
`${f.hp.toFixed(0)}/${f.maxHp}`);
|
||||
}
|
||||
{
|
||||
// ...and the same aircraft is untouchable the moment it is off the ground.
|
||||
const st = arena(41);
|
||||
const f = L.spawnUnit(st, sr, 0, 'fighter', px(30), px(30));
|
||||
const tank = L.spawnUnit(st, sr, 1, 'tank', px(34), px(30));
|
||||
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: tank.id } });
|
||||
runFor(st, 1.0);
|
||||
check('taking off makes it an air target again', f.airborne === true && f.liftFrac === 1);
|
||||
const hpAirborne = f.hp;
|
||||
runFor(st, 25);
|
||||
check('a tank gun cannot touch it once flying', f.hp === hpAirborne,
|
||||
`lost ${(hpAirborne - f.hp).toFixed(0)} hp while airborne`);
|
||||
check('and it kills the tank on its passes', tank.dead || tank.hp < tank.maxHp);
|
||||
}
|
||||
{
|
||||
// Landing under guns is now a real mistake, not a free escape.
|
||||
const st = arena(42);
|
||||
const f = L.spawnUnit(st, sr, 0, 'fighter', px(30), px(30));
|
||||
const tank = L.spawnUnit(st, sr, 1, 'tank', px(33), px(30));
|
||||
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: tank.id } });
|
||||
runFor(st, 1.0);
|
||||
const wasAir = f.airborne;
|
||||
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'hold' } }); // set down
|
||||
runFor(st, 2.0);
|
||||
check('an aircraft told to hold leaves the air domain', wasAir === true && f.airborne === false,
|
||||
`liftFrac ${f.liftFrac.toFixed(2)}`);
|
||||
}
|
||||
|
||||
// ---- it never stops in the air ----
|
||||
{
|
||||
const st = arena(33);
|
||||
const f = L.spawnUnit(st, sr, 0, 'fighter', px(20), px(30));
|
||||
const foe = L.spawnUnit(st, sr, 1, 'tank', px(40), px(30));
|
||||
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: foe.id } });
|
||||
runFor(st, 3);
|
||||
let slowest = Infinity, samples = 0;
|
||||
for (let i = 0; i < 40 * HZ; i++) {
|
||||
L.tick(st, sr);
|
||||
if (f.dead || foe.dead) break;
|
||||
if (f.liftFrac < 1) continue; // ignore the takeoff ramp
|
||||
slowest = Math.min(slowest, speedOf(f));
|
||||
samples++;
|
||||
}
|
||||
const cruise = sr.unitById.fighter.speed;
|
||||
check('an attacking aircraft samples its speed', samples > 100, `${samples} samples`);
|
||||
check('an attacking aircraft never slows down', slowest > cruise * 0.98,
|
||||
`dropped to ${slowest.toFixed(0)} of ${cruise} px/s`);
|
||||
}
|
||||
|
||||
// ---- the strafing run: overfly, carry through, come about, repeat ----
|
||||
{
|
||||
const st = arena(34);
|
||||
const f = L.spawnUnit(st, sr, 0, 'fighter', px(20), px(30));
|
||||
// A building, so it cannot run away and the geometry of the pass is unambiguous.
|
||||
const tgt = L.placeBuilding(st, sr, 1, 'energygen', 40, 30);
|
||||
tgt.site = false; tgt.progress = 1; tgt.hp = sr.buildingById.energygen.hp;
|
||||
tgt.maxHp = tgt.hp;
|
||||
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: tgt.id } });
|
||||
|
||||
let closest = Infinity, farthest = 0, passes = 0, wasNear = false, egressSeen = false;
|
||||
for (let i = 0; i < 60 * HZ; i++) {
|
||||
L.tick(st, sr);
|
||||
tgt.hp = tgt.maxHp; // immortal, so the run repeats indefinitely
|
||||
if (f.liftFrac < 1) continue;
|
||||
const d = Math.hypot(f.x - tgt.x, f.y - tgt.y);
|
||||
closest = Math.min(closest, d);
|
||||
farthest = Math.max(farthest, d);
|
||||
if (f.egressing) egressSeen = true;
|
||||
const near = d < TS * 3;
|
||||
if (near && !wasNear) passes++;
|
||||
wasNear = near;
|
||||
}
|
||||
check('the aircraft actually overflies its target', closest < TS * 2,
|
||||
`closest approach ${(closest / TS).toFixed(1)} tiles`);
|
||||
check('it carries through past the target', egressSeen && farthest > sr.unitById.fighter.flight.overshoot * 0.6,
|
||||
`farthest ${farthest.toFixed(0)}px vs overshoot ${sr.unitById.fighter.flight.overshoot}`);
|
||||
check('it comes about and runs in again', passes >= 3, `${passes} passes in 60s`);
|
||||
check('it stays airborne throughout', f.liftFrac === 1);
|
||||
}
|
||||
|
||||
// ---- opportunistic fire while transiting ----
|
||||
{
|
||||
const st = arena(35);
|
||||
const f = L.spawnUnit(st, sr, 0, 'fighter', px(10), px(30));
|
||||
// Ordered at something far away, with a bystander parked on the flight path.
|
||||
const far = L.spawnUnit(st, sr, 1, 'tank', px(55), px(30));
|
||||
const bystander = L.spawnUnit(st, sr, 1, 'infantry', px(25), px(30));
|
||||
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'attack', targetId: far.id } });
|
||||
const startX = f.x;
|
||||
runFor(st, 12);
|
||||
check('a transiting aircraft shoots what it passes',
|
||||
bystander.dead || bystander.hp < bystander.maxHp, 'the bystander was never engaged');
|
||||
check('shooting a bystander does not stop the aircraft', f.x > startX + TS * 8,
|
||||
`only travelled ${((f.x - startX) / TS).toFixed(1)} tiles`);
|
||||
}
|
||||
|
||||
// ---- aircraft will not land in a hostile base ----
|
||||
{
|
||||
const st = arena(36);
|
||||
const f = L.spawnUnit(st, sr, 0, 'fighter', px(20), px(30));
|
||||
const base = L.placeBuilding(st, sr, 1, 'energygen', 40, 30);
|
||||
base.site = false; base.progress = 1; base.hp = sr.buildingById.energygen.hp;
|
||||
base.maxHp = base.hp;
|
||||
// Told to move ONTO the enemy structure. That is not a move, it is an attack run.
|
||||
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'move', x: base.x, y: base.y } });
|
||||
L.tick(st, sr);
|
||||
check('a move into a hostile base becomes an attack run',
|
||||
f.orders[0]?.type === 'attack' && f.orders[0].targetId === base.id,
|
||||
`order is ${f.orders[0]?.type}`);
|
||||
let landed = false;
|
||||
for (let i = 0; i < 40 * HZ; i++) {
|
||||
L.tick(st, sr);
|
||||
base.hp = base.maxHp;
|
||||
if (f.liftFrac === 0) { landed = true; break; }
|
||||
}
|
||||
check('it never puts down inside the keepout', !landed,
|
||||
`landed ${(Math.hypot(f.x - base.x, f.y - base.y) / TS).toFixed(1)} tiles from the base`);
|
||||
}
|
||||
|
||||
// ---- an idle aircraft inside a keepout clears out, then lands ----
|
||||
{
|
||||
const st = arena(37);
|
||||
const base = L.placeBuilding(st, sr, 1, 'energygen', 30, 30);
|
||||
base.site = false; base.progress = 1; base.hp = sr.buildingById.energygen.hp;
|
||||
const f = L.spawnUnit(st, sr, 0, 'fighter', px(32), px(30)); // parked right next to it
|
||||
runFor(st, 40);
|
||||
const away = L.surfaceDist(sr, f, base);
|
||||
check('an idle aircraft leaves a hostile base before settling',
|
||||
away >= sr.unitById.fighter.flight.keepout * 0.9,
|
||||
`only got ${away.toFixed(0)}px from the base`);
|
||||
check('and then it lands', f.liftFrac === 0, `liftFrac ${f.liftFrac.toFixed(2)}`);
|
||||
}
|
||||
|
||||
// ---- the Hover Constructor keeps the old stop-in-place behaviour ----
|
||||
{
|
||||
const st = arena(38);
|
||||
const h = L.spawnUnit(st, sr, 0, 'hoverconstructor', px(20), px(30));
|
||||
L.issueOrder(st, sr, { army: 0, unitIds: [h.id], order: { type: 'move', x: px(26), y: px(30) } });
|
||||
runFor(st, 30);
|
||||
check('a hovering builder still stops exactly on its destination',
|
||||
Math.hypot(h.x - px(26), h.y - px(30)) < TS, `${(Math.hypot(h.x - px(26), h.y - px(30)) / TS).toFixed(2)} tiles off`);
|
||||
check('and settles back down when idle', h.liftFrac === 0);
|
||||
}
|
||||
|
||||
// ---- altitude survives a save ----
|
||||
{
|
||||
const st = arena(39);
|
||||
const f = L.spawnUnit(st, sr, 0, 'fighter', px(20), px(30));
|
||||
L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'move', x: px(50), y: px(30) } });
|
||||
runFor(st, 2);
|
||||
const back = L.deserialize(sr, L.serialize(st));
|
||||
const rf = back?.entities.find((e) => e.defId === 'fighter');
|
||||
check('altitude survives serialization', !!rf && Math.abs(rf.liftFrac - f.liftFrac) < 0.01,
|
||||
`${rf?.liftFrac} vs ${f.liftFrac}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('7. Fog of war');
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1742,6 +1965,49 @@ section('12. AI skill ladder');
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('12b. AI build repertoire');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// Everything in the rules is dead data unless the AI will actually build it. Defences and
|
||||
// the whole air branch were exactly that for a while: the Airfield gate was keyed on a mass
|
||||
// income the AI never reaches (it peaks at 11-27, the bar was 24), so a skill-5 AI built one
|
||||
// in zero games out of six while the roster claimed it could.
|
||||
//
|
||||
// Counts BOTH armies across several games — these are conditional behaviours, not a fixed
|
||||
// build order, so the assertion is that each shows up across a sample rather than in any
|
||||
// particular match.
|
||||
const games = QUICK ? 6 : 12;
|
||||
const built = Object.create(null);
|
||||
for (let i = 0; i < games; i++) {
|
||||
const map = generateMap(rules, { seed: 8000 + i * 13, size: 'medium', symmetry: 'mirror-x' });
|
||||
const st = L.createMatch(rules, { seed: 8000 + i, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
while (!st.over && st.tick < 900 * HZ) {
|
||||
runAI(rules, st, 0, { skill: 4 });
|
||||
runAI(rules, st, 1, { skill: 4 });
|
||||
for (const ev of L.tick(st, rules)) {
|
||||
if (ev.t === 'buildingComplete' || ev.t === 'spawn') {
|
||||
built[ev.defId] = (built[ev.defId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const n = (id) => (built[id] ?? 0);
|
||||
console.log(` ${games} games · towers ${n('lasertower')} · launchers ${n('missilelauncher')}`
|
||||
+ ` · airfields ${n('airfield')} · fighters ${n('fighter')} · bombers ${n('bomber')}`);
|
||||
check('the AI builds Laser Towers', n('lasertower') > 0, 'never built one');
|
||||
check('the AI builds Missile Launchers', n('missilelauncher') > 0, 'never built one');
|
||||
check('the AI builds Airfields', n('airfield') > 0, 'never built one');
|
||||
check('the AI produces aircraft', n('fighter') + n('bomber') > 0, 'never built any');
|
||||
// Defence is a reaction, not a habit. An AI that answers every game with a wall of towers
|
||||
// has stopped building an army, and the skill ladder above is measuring the wrong thing.
|
||||
check('defence stays a minority of construction',
|
||||
n('lasertower') + n('missilelauncher') < n('energygen') + n('massgen'),
|
||||
`${n('lasertower') + n('missilelauncher')} defences vs ${n('energygen') + n('massgen')} generators`);
|
||||
// Decisiveness is §11's job: it uses parameters tuned for it, whereas two equal skill-4 AIs
|
||||
// on a mirrored map are supposed to be able to draw.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('13. Campaign winnability');
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue