feat: add air domain system with fighters, bombers, and hover units

Introduce a two-layer domain system (ground/air) that separates units into
independent simulation layers. Air units ignore the nav grid, never collide
with ground units, and can only be targeted by weapons listing "air" in their
targets. Hover units gain water-crossing ability via the move class cost table.

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

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

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

Validation ensures domain/moveClass agreement, requires at least one anti-air
weapon when air units exist, and excludes air classes from corridor generation.
Comprehensive test fixtures cover domain separation, collision, splash, and
order handling.
This commit is contained in:
Brian Fertig 2026-08-01 09:00:37 -06:00
parent 793cb249f9
commit cff1f02d9b
19 changed files with 831 additions and 59 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

View File

@ -11,7 +11,14 @@
"data/totalannihilation-artwork.json.", "data/totalannihilation-artwork.json.",
"", "",
"Units are drawn once and ROTATED at runtime \u2014 there are no per-facing frames. `turretFrame` is", "Units are drawn once and ROTATED at runtime \u2014 there are no per-facing frames. `turretFrame` is",
"an optional second sprite that aims independently of the hull." "an optional second sprite that aims independently of the hull.",
"",
"Domains: a unit's `domain` is \"ground\" (the default) or \"air\". Air units ignore the nav grid,",
"never collide with ground units, and 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",
"`\"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."
], ],
"version": 1, "version": 1,
"constants": { "constants": {
@ -102,6 +109,27 @@
"cliff": null, "cliff": null,
"water": null "water": null
} }
},
"hover": {
"cost": {
"ground": 1.0,
"rough": 1.1,
"road": 0.9,
"metal": 1.0,
"cliff": null,
"water": 1.0
}
},
"air": {
"air": true,
"cost": {
"ground": 1.0,
"rough": 1.0,
"road": 1.0,
"metal": 1.0,
"cliff": 1.0,
"water": 1.0
}
} }
}, },
"armies": [ "armies": [
@ -348,7 +376,8 @@
"aoe": 48, "aoe": 48,
"aoeFalloff": 0.15, "aoeFalloff": 0.15,
"targets": [ "targets": [
"ground" "ground",
"air"
], ],
"armorMul": { "armorMul": {
"infantry": 0.3, "infantry": 0.3,
@ -380,7 +409,8 @@
"aoe": 24, "aoe": 24,
"aoeFalloff": 0.15, "aoeFalloff": 0.15,
"targets": [ "targets": [
"ground" "ground",
"air"
], ],
"armorMul": { "armorMul": {
"infantry": 0.35, "infantry": 0.35,
@ -494,7 +524,8 @@
"aoe": 56, "aoe": 56,
"aoeFalloff": 0.15, "aoeFalloff": 0.15,
"targets": [ "targets": [
"ground" "ground",
"air"
], ],
"armorMul": { "armorMul": {
"infantry": 0.35, "infantry": 0.35,
@ -510,6 +541,63 @@
"trail": true "trail": true
}, },
"sound": ["sfx-ta-rocket-1", "sfx-ta-rocket-2"] "sound": ["sfx-ta-rocket-1", "sfx-ta-rocket-2"]
},
{
"id": "aacannon",
"name": "Air-to-Air Cannon",
"kind": "hitscan",
"damage": 44,
"reload": 1.6,
"burst": 3,
"burstDelay": 0.12,
"range": 300,
"spread": 0.02,
"targets": ["air"],
"armorMul": {
"infantry": 1.0,
"light": 1.0,
"medium": 0.85,
"heavy": 0.6,
"structure": 0.2
},
"fx": {
"style": "tracer",
"color": "#ffe89a",
"width": 2,
"muzzle": 16,
"lifeMs": 70
},
"sound": "sfx-ta-50cal"
},
{
"id": "bombrack",
"name": "Bomb Rack",
"kind": "ballistic",
"damage": 190,
"reload": 6.0,
"burst": 2,
"burstDelay": 0.35,
"range": 160,
"speed": 240,
"spread": 0.05,
"aoe": 96,
"aoeFalloff": 0.3,
"targets": ["ground"],
"armorMul": {
"infantry": 1.1,
"light": 1.0,
"medium": 1.0,
"heavy": 0.85,
"structure": 1.6
},
"fx": {
"style": "shell",
"color": "#ffb15c",
"width": 3,
"muzzle": 8
},
"sound": "sfx-scifi-launch",
"impactSound": "sfx-ta-nuclear"
} }
], ],
"units": [ "units": [
@ -543,7 +631,8 @@
"barracks", "barracks",
"vehicleplant", "vehicleplant",
"lasertower", "lasertower",
"missilelauncher" "missilelauncher",
"airfield"
], ],
"produce": { "produce": {
"energy": 25, "energy": 25,
@ -780,7 +869,8 @@
"vehicleplant", "vehicleplant",
"lasertower", "lasertower",
"missilelauncher", "missilelauncher",
"advancedvehicleplant" "advancedvehicleplant",
"airfield"
], ],
"sheetSlot": "unitSheet", "sheetSlot": "unitSheet",
"frame": 11, "frame": 11,
@ -788,6 +878,107 @@
"icon": 7, "icon": 7,
"spritePx": 50, "spritePx": 50,
"moveSound": "sfx-engine-heavy" "moveSound": "sfx-engine-heavy"
},
{
"id": "fighter",
"name": "Fighter",
"role": "combat",
"domain": "air",
"size": "small",
"radius": 22,
"hp": 820,
"speed": 290,
"turnRate": 3.0,
"moveClass": "air",
"armorClass": "light",
"sight": 620,
"cost": {
"energy": 900,
"mass": 160
},
"buildTime": 16,
"builtBy": [
"airfield"
],
"weapons": [
"aacannon"
],
"sheetSlot": "unitSheet",
"frame": 12,
"procShape": "fighter",
"icon": 8,
"spritePx": 46,
"moveSound": "sfx-engine-fast"
},
{
"id": "bomber",
"name": "Bomber",
"role": "artillery",
"domain": "air",
"size": "medium",
"radius": 30,
"hp": 1350,
"speed": 215,
"turnRate": 1.7,
"moveClass": "air",
"armorClass": "medium",
"sight": 560,
"cost": {
"energy": 1700,
"mass": 440
},
"buildTime": 30,
"builtBy": [
"airfield"
],
"weapons": [
"bombrack"
],
"sheetSlot": "unitSheet",
"frame": 13,
"procShape": "bomber",
"icon": 9,
"spritePx": 60,
"moveSound": "sfx-engine-heavy"
},
{
"id": "hoverconstructor",
"name": "Hover Constructor",
"role": "builder",
"size": "medium",
"radius": 24,
"hp": 760,
"speed": 115,
"turnRate": 2.8,
"moveClass": "hover",
"armorClass": "light",
"sight": 360,
"cost": {
"energy": 640,
"mass": 175
},
"buildTime": 16,
"builtBy": [
"airfield"
],
"buildPower": 100,
"buildRange": 190,
"builds": [
"energygen",
"massgen",
"barracks",
"vehicleplant",
"lasertower",
"missilelauncher",
"advancedvehicleplant",
"airfield"
],
"sheetSlot": "unitSheet",
"frame": 14,
"procShape": "hoverConstructor",
"icon": 17,
"spritePx": 52,
"moveSound": "sfx-engine-medium"
} }
], ],
"buildings": [ "buildings": [
@ -1010,6 +1201,37 @@
"buildFrame": 13, "buildFrame": 13,
"procShape": "advancedVehiclePlant", "procShape": "advancedVehiclePlant",
"icon": 16 "icon": 16
},
{
"id": "airfield",
"name": "Airfield",
"footprint": {
"w": 3,
"h": 3
},
"hp": 4200,
"armorClass": "structure",
"sight": 260,
"cost": {
"energy": 1800,
"mass": 600
},
"buildTime": 46,
"buildPower": 110,
"builds": [
"fighter",
"bomber",
"hoverconstructor"
],
"spawnOffset": {
"x": 0,
"y": 2.2
},
"sheetSlot": "structureSheet",
"frame": 14,
"buildFrame": 15,
"procShape": "airfield",
"icon": 18
} }
], ],
"commandIcons": { "commandIcons": {

View File

@ -11,6 +11,7 @@
// income cheating. A cheating AI's win rate would tell us nothing about the balance. // 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 } from './TALogic.js';
import { canEngage } from './TARules.js';
import { worldToTileX, worldToTileY } from './TANav.js'; import { worldToTileX, worldToTileY } from './TANav.js';
/** Desired force mix per skill. Below skill 3 the AI just buys whatever it can afford. */ /** Desired force mix per skill. Below skill 3 the AI just buys whatever it can afford. */
@ -20,6 +21,11 @@ const COMPOSITION = {
// mix for the trooper, and just enough of the vehicle to occasionally replace a lost // mix for the trooper, and just enough of the vehicle to occasionally replace a lost
// Commander's construction capacity — not a real combat unit, so it stays a low priority. // Commander's construction capacity — not a real combat unit, so it stays a low priority.
rockettrooper: 0.14, constructor: 0.04, rockettrooper: 0.14, constructor: 0.04,
// Air. The weights are per-FACTORY shares, so these only compete with each other once an
// Airfield exists — the bomber is the reason to own one, the fighter is the answer to the
// enemy owning one (applyCounters pushes it hard the moment enemy air is actually seen),
// and the hover constructor is the same insurance policy as the tracked one.
bomber: 0.44, fighter: 0.30, hoverconstructor: 0.10,
}; };
function memFor(state, armyIdx) { function memFor(state, armyIdx) {
@ -30,7 +36,7 @@ function memFor(state, armyIdx) {
nextThinkTick: 0, nextThinkTick: 0,
ordersThisSecond: 0, secondMark: 0, ordersThisSecond: 0, secondMark: 0,
squad: [], squadPeak: 0, phase: 'build', focusId: 0, squad: [], squadPeak: 0, phase: 'build', focusId: 0,
scoutId: 0, scoutId: 0, airPreyId: 0,
knownEnemies: [], // [{x,y,defId,isBuilding,tick}] knownEnemies: [], // [{x,y,defId,isBuilding,tick}]
lastAttackTick: -99999, lastAttackTick: -99999,
buildSpiral: 0, buildSpiral: 0,
@ -89,6 +95,7 @@ export function runAI(rules, state, armyIdx, profile = {}) {
manageEconomyAndBase(ctx, mine); manageEconomyAndBase(ctx, mine);
manageProduction(ctx, mine); manageProduction(ctx, mine);
manageScout(ctx, mine); manageScout(ctx, mine);
manageAir(ctx, mine);
manageMilitary(ctx, mine); manageMilitary(ctx, mine);
} }
@ -186,6 +193,7 @@ function chooseBuilding(ctx, mine) {
const mGen = rules.buildingById.massgen; const mGen = rules.buildingById.massgen;
const barracks = rules.buildingById.barracks; const barracks = rules.buildingById.barracks;
const plant = rules.buildingById.vehicleplant; const plant = rules.buildingById.vehicleplant;
const airfield = rules.buildingById.airfield;
// Opening: two power, one mass, then a barracks so there's something to fight with. // Opening: two power, one mass, then a barracks so there's something to fight with.
if (n('energygen') < 2) return eGen; if (n('energygen') < 2) return eGen;
@ -229,12 +237,23 @@ function chooseBuilding(ctx, mine) {
// actually feed. Saturating generators first (the obvious ordering) is why a big economy // actually feed. Saturating generators first (the obvious ordering) is why a big economy
// used to win nothing: with only two factories the surplus just floated in storage, and an // used to win nothing: with only two factories the surplus just floated in storage, and an
// army that out-earned its opponent two to one still fielded the same number of tanks. // army that out-earned its opponent two to one still fielded the same number of tanks.
const factories = n('barracks') + n('vehicleplant'); const factories = n('barracks') + n('vehicleplant') + n('airfield');
const nextFactory = () => (n('vehicleplant') <= n('barracks') ? plant : barracks); const nextFactory = () => (n('vehicleplant') <= n('barracks') ? plant : barracks);
const factoryCap = Math.round(2 + expansion * 4); const factoryCap = Math.round(2 + expansion * 4);
const supported = Math.floor(army.mIncome / Math.max(0.5, factoryMassDrain(rules, plant))); const groundDrain = Math.max(0.5, factoryMassDrain(rules, plant));
const supported = Math.floor(army.mIncome / groundDrain);
if (factories < Math.min(factoryCap, Math.max(1, supported))) return nextFactory(); 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;
}
if (ratio > TARGET_RATIO && roomM) return mGen; if (ratio > TARGET_RATIO && roomM) return mGen;
if (ratio < TARGET_RATIO && roomE) return eGen; if (ratio < TARGET_RATIO && roomE) return eGen;
if (roomM) return mGen; if (roomM) return mGen;
@ -361,22 +380,32 @@ function templatePick(ctx, mine, builds) {
/** Skill 4+: bias the mix against what we've actually seen the enemy field. */ /** Skill 4+: bias the mix against what we've actually seen the enemy field. */
function applyCounters(ctx, weights) { function applyCounters(ctx, weights) {
const { mem, rules } = ctx; const { mem, rules } = ctx;
let infantryish = 0, armour = 0, structures = 0; let infantryish = 0, armour = 0, structures = 0, air = 0;
for (const k of mem.knownEnemies) { for (const k of mem.knownEnemies) {
const def = rules.defById[k.defId]; const def = rules.defById[k.defId];
if (!def) continue; if (!def) continue;
if (k.isBuilding) { structures++; continue; } if (k.isBuilding) { structures++; continue; }
if (def.isAir) { air++; continue; }
if (def.armorClass === 'infantry' || def.armorClass === 'light') infantryish++; if (def.armorClass === 'infantry' || def.armorClass === 'light') infantryish++;
else armour++; else armour++;
} }
const seen = infantryish + armour; const seen = infantryish + armour;
if (!seen) return; if (seen) {
const armourShare = armour / seen; const armourShare = armour / seen;
// Tanks shred infantry; rockets and snipers answer armour and buildings. // Tanks shred infantry; rockets and snipers answer armour and buildings.
weights.tank *= 1 + (1 - armourShare) * 0.8; weights.tank *= 1 + (1 - armourShare) * 0.8;
weights.rockettank *= 1 + armourShare * 1.0 + (structures > 2 ? 0.3 : 0); weights.rockettank *= 1 + armourShare * 1.0 + (structures > 2 ? 0.3 : 0);
weights.sniper *= 1 + (1 - armourShare) * 0.5; weights.sniper *= 1 + (1 - armourShare) * 0.5;
weights.rockettrooper *= 1 + armourShare * 0.6; weights.rockettrooper *= 1 + armourShare * 0.6;
}
// Enemy aircraft are answered by fighters first and by the guided-missile units second —
// those are the only ground things that can shoot up at all, so the counter is far sharper
// than any of the ground-vs-ground ones above.
if (air) {
weights.fighter *= 1 + Math.min(3, air) * 0.9;
weights.rockettank *= 1 + Math.min(3, air) * 0.25;
weights.rockettrooper *= 1 + Math.min(3, air) * 0.25;
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -449,6 +478,61 @@ function sweepTarget(ctx) {
return { x: pick.x, y: pick.y }; return { x: pick.x, y: pick.y };
} }
// ---------------------------------------------------------------------------
// 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);
}
/**
* 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.
*/
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;
let prey = null, preyD = Infinity;
for (const e of state.entities) {
if (e.dead || !e.isAir || e.army === armyIdx || !state.armies[e.army]) continue;
if (!isVisibleTo(state, armyIdx, e)) continue;
const d = Math.hypot(e.x - mem.baseX, e.y - mem.baseY);
if (d < preyD) { prey = e; preyD = d; }
}
if (prey) {
// Sticky, for the same reason focusFire is: an `attack` order REPLACES the unit's queue,
// so re-picking a bandit every think would restart the intercept before it ever closed.
// Only re-issue when the current one is gone or someone has fallen out of the flight.
const committed = fighters.every((u) => u.orders[0]?.type === 'attack' && u.orders[0].targetId === mem.airPreyId);
if (committed && mem.airPreyId === prey.id) return;
mem.airPreyId = prey.id;
// Send the whole flight at one bandit — aircraft trade far too fast to arrive piecemeal.
order(ctx, {
army: armyIdx, unitIds: fighters.map((u) => u.id),
order: { type: 'attack', targetId: prey.id },
});
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 },
});
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Military // Military
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -461,6 +545,7 @@ function manageMilitary(ctx, mine) {
if (u.id === mem.scoutId) return false; if (u.id === mem.scoutId) return false;
const def = rules.defById[u.defId]; const def = rules.defById[u.defId];
if (!def.weaponDefs?.length || def.builds?.length) return false; if (!def.weaponDefs?.length || def.builds?.length) return false;
if (isInterceptor(def)) return false; // manageAir owns these
return u.orders.length === 0; return u.orders.length === 0;
}); });
@ -468,7 +553,7 @@ function manageMilitary(ctx, mine) {
for (const u of idle) if (!mem.squad.includes(u.id)) mem.squad.push(u.id); for (const u of idle) if (!mem.squad.includes(u.id)) mem.squad.push(u.id);
// Home defence always wins over pushing out — skill 1 never thinks to come home. // Home defence always wins over pushing out — skill 1 never thinks to come home.
const threat = skill >= 2 ? nearestThreatToBase(ctx) : null; const threat = skill >= 2 ? nearestThreatToBase(ctx, mine) : null;
if (threat) { if (threat) {
const defenders = mem.squad.slice(0, Math.max(3, Math.ceil(mem.squad.length * 0.7))); const defenders = mem.squad.slice(0, Math.max(3, Math.ceil(mem.squad.length * 0.7)));
if (defenders.length) { if (defenders.length) {
@ -527,17 +612,21 @@ function manageMilitary(ctx, mine) {
} }
} }
function nearestThreatToBase(ctx) { function nearestThreatToBase(ctx, mine) {
const { state, mem, armyIdx } = ctx; const { state, rules, mem, armyIdx } = ctx;
if (!mem.baseSet) return null; if (!mem.baseSet) return null;
const radius = state.tileSize * 16; const radius = state.tileSize * 16;
// Only something we can actually shoot counts as a threat worth recalling the army for.
// A raiding Bomber over a base defended purely by tanks is a problem no amount of pulling
// the squad home solves, and answering it that way just abandons the attack for nothing.
const canAnswer = (e) => mine.units.some((u) => canEngage(rules.defById[u.defId], e));
let best = null, bestD = Infinity; let best = null, bestD = Infinity;
for (const e of state.entities) { for (const e of state.entities) {
if (e.dead || e.army === armyIdx || !state.armies[e.army]) continue; if (e.dead || e.army === armyIdx || !state.armies[e.army]) continue;
if (!isVisibleTo(state, armyIdx, e)) continue; if (!isVisibleTo(state, armyIdx, e)) continue;
if (e.isBuilding) continue; if (e.isBuilding) continue;
const d = Math.hypot(e.x - mem.baseX, e.y - mem.baseY); const d = Math.hypot(e.x - mem.baseX, e.y - mem.baseY);
if (d < radius && d < bestD) { best = e; bestD = d; } if (d < radius && d < bestD && canAnswer(e)) { best = e; bestD = d; }
} }
return best; return best;
} }
@ -583,6 +672,7 @@ function focusFire(ctx, mine) {
/** True when `u` could fire on `t` from where it already stands. */ /** True when `u` could fire on `t` from where it already stands. */
function inReach(rules, u, t) { function inReach(rules, u, t) {
const def = rules.defById[u.defId]; const def = rules.defById[u.defId];
if (!canEngage(def, t)) return false;
return Math.hypot(u.x - t.x, u.y - t.y) - t.radius <= def.maxRange; return Math.hypot(u.x - t.x, u.y - t.y) - t.radius <= def.maxRange;
} }

View File

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

View File

@ -24,6 +24,12 @@ const BAR_H = 62; // top resource strip
const BOT_H = 190; // bottom command bar const BOT_H = 190; // bottom command bar
const MINI = 176; // minimap edge length const MINI = 176; // minimap edge length
const BTN_W = 88, BTN_H = 74; // build-menu button size const BTN_W = 88, BTN_H = 74; // build-menu button size
const GRID_COLS = 8; // build-menu columns
const GRID_PITCH = 96; // column pitch (button + gutter)
// The bottom bar has room for exactly ONE row of buttons: a second row runs down into the
// hint line at the foot of the panel. GRID_COLS must therefore stay >= the longest `builds`
// list in the rules — currently 8, the Constructor and Hover Constructor. Adding a ninth
// build option to any unit means widening the grid or moving the hint, not just adding it.
// CTRL is the queue modifier (as in the original game); SHIFT adds to the selection and // CTRL is the queue modifier (as in the original game); SHIFT adds to the selection and
// buys x5 from a factory. Kept in one place so the hint and the bindings cannot drift. // buys x5 from a factory. Kept in one place so the hint and the bindings cannot drift.
@ -143,7 +149,7 @@ export default class TAHud {
}).setOrigin(1, 0.5); }).setOrigin(1, 0.5);
this.root.add([this.selTitle, this.selDetail, this.queueText, this.hint]); this.root.add([this.selTitle, this.selDetail, this.queueText, this.hint]);
this.gridX = GAME_WIDTH - 24 - 6 * 96; this.gridX = GAME_WIDTH - 24 - GRID_COLS * GRID_PITCH;
this.gridY = y + 16; this.gridY = y + 16;
} }
@ -161,9 +167,9 @@ export default class TAHud {
const s = this.scene; const s = this.scene;
const army = this.state.armies[this.playerArmy]; const army = this.state.armies[this.playerArmy];
options.forEach((def, i) => { options.forEach((def, i) => {
const col = i % 6, row = Math.floor(i / 6); const col = i % GRID_COLS, row = Math.floor(i / GRID_COLS);
// The container is placed at the button's CENTRE, not its top-left — see below. // The container is placed at the button's CENTRE, not its top-left — see below.
const x = this.gridX + col * 96 + BTN_W / 2; const x = this.gridX + col * GRID_PITCH + BTN_W / 2;
const y = this.gridY + row * 82 + BTN_H / 2; const y = this.gridY + row * 82 + BTN_H / 2;
const affordable = canAfford(this.state, this.playerArmy, def); const affordable = canAfford(this.state, this.playerArmy, def);
const c = s.add.container(x, y); const c = s.add.container(x, y);
@ -199,8 +205,13 @@ export default class TAHud {
const lines = [`${def.cost.mass} mass ${def.cost.energy} energy ${def.buildTime}s`]; const lines = [`${def.cost.mass} mass ${def.cost.energy} energy ${def.buildTime}s`];
if (def.hp) lines.push(`HP ${def.hp}`); if (def.hp) lines.push(`HP ${def.hp}`);
if (def.speed) lines.push(`Speed ${def.speed} Sight ${def.sight}`); 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');
else if (def.moveClass === 'hover') lines.push('HOVER — crosses water');
for (const w of def.weaponDefs ?? []) { for (const w of def.weaponDefs ?? []) {
lines.push(`${w.name}: ${w.damage} dmg / ${w.reload}s / ${w.range} range${w.manual ? ' (manual)' : ''}`); const dom = w.targetsAir ? (w.targetsGround ? 'ground+air' : 'air only') : 'ground only';
lines.push(`${w.name}: ${w.damage} dmg / ${w.reload}s / ${w.range} range / ${dom}${w.manual ? ' (manual)' : ''}`);
} }
if (def.makes) { if (def.makes) {
const m = []; const m = [];

View File

@ -16,6 +16,7 @@ import {
nearestUsableTile, tileIndex, worldToTileX, worldToTileY, tileCenterX, tileCenterY, nearestUsableTile, tileIndex, worldToTileX, worldToTileY, tileCenterX, tileCenterY,
SpatialHash, segmentClear, SpatialHash, segmentClear,
} from './TANav.js'; } from './TANav.js';
import { canEngage, weaponHitsDomain } from './TARules.js';
export const SAVE_VERSION = 1; export const SAVE_VERSION = 1;
@ -142,6 +143,9 @@ function baseEntity(state, army, def) {
army, army,
defId: def.id, defId: def.id,
isBuilding: !!def.isBuilding, 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.
isAir: !!def.isAir,
x: 0, y: 0, px: 0, py: 0, x: 0, y: 0, px: 0, py: 0,
heading: 0, pheading: 0, heading: 0, pheading: 0,
turretRot: 0, pturretRot: 0, turretRot: 0, pturretRot: 0,
@ -647,14 +651,18 @@ function finishFactoryJob(state, rules, f) {
const ts = state.tileSize; const ts = state.tileSize;
let sx = f.x + off.x * ts; let sx = f.x + off.x * ts;
let sy = f.y + off.y * ts; let sy = f.y + off.y * ts;
// Nudge out of the footprint if the bay mouth is somehow blocked. // Nudge out of the footprint if the bay mouth is somehow blocked. Aircraft skip this: they
// ignore the nav grid, and snapping one to the nearest "usable" tile would only shove it
// sideways off a perfectly good apron.
const nav = state.nav; const nav = state.nav;
const udef = rules.unitById[item.defId]; const udef = rules.unitById[item.defId];
const need = clearanceFor(udef.radius, ts); if (!udef.isAir) {
const mc = udef.moveClass; const need = clearanceFor(udef.radius, ts);
let tx = worldToTileX(nav, sx), ty = worldToTileY(nav, sy); const mc = udef.moveClass;
const idx = nearestUsableTile(nav, mc, need, tx, ty, 6); const tx = worldToTileX(nav, sx), ty = worldToTileY(nav, sy);
if (idx >= 0) { sx = tileCenterX(nav, idx % nav.w); sy = tileCenterY(nav, (idx / nav.w) | 0); } const idx = nearestUsableTile(nav, mc, need, tx, ty, 6);
if (idx >= 0) { sx = tileCenterX(nav, idx % nav.w); sy = tileCenterY(nav, (idx / nav.w) | 0); }
}
const u = spawnUnit(state, rules, f.army, item.defId, sx, sy, Math.atan2(off.y, off.x)); const u = spawnUnit(state, rules, f.army, item.defId, sx, sy, Math.atan2(off.y, off.x));
f.jobProgress = 0; f.jobProgress = 0;
@ -679,6 +687,17 @@ function requestPath(state, e, x, y) {
if (!state.pathQueue.includes(e.id)) state.pathQueue.push(e.id); if (!state.pathQueue.includes(e.id)) state.pathQueue.push(e.id);
} }
/**
* Head for a point. Ground units get an A* path; aircraft simply record the destination and
* fly the straight line to it no path, no queue, no clearance test. Routing air through the
* pathfinder would be both wasted A* budget and wrong, since the grid's whole job is to model
* obstacles an aircraft doesn't have.
*/
function seekTo(state, e, x, y) {
if (e.isAir) { e.destX = x; e.destY = y; return; }
requestPath(state, e, x, y);
}
function servicePathQueue(state, rules) { function servicePathQueue(state, rules) {
let budget = rules.constants.pathBudgetPerTick; let budget = rules.constants.pathBudgetPerTick;
while (budget-- > 0 && state.pathQueue.length) { while (budget-- > 0 && state.pathQueue.length) {
@ -825,7 +844,7 @@ function stepOrders(state, rules) {
} }
} }
const dest = orderDestination(e, order); const dest = orderDestination(e, order);
if (!e.path && !e.wantPath && !e.noPath) requestPath(state, e, dest.x, dest.y); if (!e.path && !e.wantPath && !e.noPath) seekTo(state, e, dest.x, dest.y);
e.movingTo = dest; e.movingTo = dest;
const d = Math.hypot(dest.x - e.x, dest.y - e.y); const d = Math.hypot(dest.x - e.x, dest.y - e.y);
const slack = Math.max(rules.constants.arriveSlackPx, e.radius * 1.2); const slack = Math.max(rules.constants.arriveSlackPx, e.radius * 1.2);
@ -833,7 +852,7 @@ function stepOrders(state, rules) {
if (order.type === 'patrol') { if (order.type === 'patrol') {
order.leg = order.leg === 0 ? 1 : 0; order.leg = order.leg === 0 ? 1 : 0;
e.path = null; e.noPath = false; e.stuckTicks = 0; e.path = null; e.noPath = false; e.stuckTicks = 0;
requestPath(state, e, ...(order.leg === 0 seekTo(state, e, ...(order.leg === 0
? [order.sx ?? order.x, order.sy ?? order.y] : [order.fromX, order.fromY])); ? [order.sx ?? order.x, order.sy ?? order.y] : [order.fromX, order.fromY]));
} else { } else {
e.orders.shift(); e.orders.shift();
@ -846,13 +865,17 @@ function stepOrders(state, rules) {
case 'attack': { case 'attack': {
const target = entityById(state, order.targetId); const target = entityById(state, order.targetId);
if (!target) { e.orders.shift(); e.targetId = 0; e.path = null; break; } if (!target) { e.orders.shift(); e.targetId = 0; e.path = null; break; }
// Drop an attack order on something this unit's guns can never reach — a ground-only
// weapon chasing a Fighter would follow it off the map without ever firing a shot.
if (!canEngage(def, target)) { e.orders.shift(); e.targetId = 0; e.path = null; break; }
e.targetId = target.id; e.targetId = target.id;
const d = surfaceDist(rules, e, target); const d = surfaceDist(rules, e, target);
const wantRange = def.maxRange * (rules.constants.engageHoldFraction ?? 0.85); const wantRange = def.maxRange * (rules.constants.engageHoldFraction ?? 0.85);
if (d > wantRange) { if (d > wantRange) {
if (!e.path && !e.wantPath) requestPath(state, e, target.x, target.y); if (!e.path && !e.wantPath) seekTo(state, e, target.x, target.y);
e.movingTo = { x: target.x, y: target.y }; e.movingTo = { x: target.x, y: target.y };
// Re-path when the target has drifted well away from where we aimed. // Re-path when the target has drifted well away from where we aimed. Aircraft have
// no path to throw away — seekTo above already retargets them every tick.
if (e.path && Math.hypot(target.x - e.destX, target.y - e.destY) > ts * 3) { if (e.path && Math.hypot(target.x - e.destX, target.y - e.destY) > ts * 3) {
e.path = null; requestPath(state, e, target.x, target.y); e.path = null; requestPath(state, e, target.x, target.y);
} }
@ -868,7 +891,7 @@ function stepOrders(state, rules) {
if (!target) { e.orders.shift(); break; } if (!target) { e.orders.shift(); break; }
const d = Math.hypot(target.x - e.x, target.y - e.y); const d = Math.hypot(target.x - e.x, target.y - e.y);
if (d > ts * 3) { if (d > ts * 3) {
if (!e.path && !e.wantPath) requestPath(state, e, target.x, target.y); if (!e.path && !e.wantPath) seekTo(state, e, target.x, target.y);
e.movingTo = { x: target.x, y: target.y }; e.movingTo = { x: target.x, y: target.y };
if (e.path && Math.hypot(target.x - e.destX, target.y - e.destY) > ts * 2) { if (e.path && Math.hypot(target.x - e.destX, target.y - e.destY) > ts * 2) {
e.path = null; requestPath(state, e, target.x, target.y); e.path = null; requestPath(state, e, target.x, target.y);
@ -895,7 +918,7 @@ function stepOrders(state, rules) {
const reach = (def.buildRange ?? 0) + target.radius; const reach = (def.buildRange ?? 0) + target.radius;
const d = Math.hypot(target.x - e.x, target.y - e.y); const d = Math.hypot(target.x - e.x, target.y - e.y);
if (d > reach * 0.9) { if (d > reach * 0.9) {
if (!e.path && !e.wantPath && !e.noPath) requestPath(state, e, target.x, target.y); if (!e.path && !e.wantPath && !e.noPath) seekTo(state, e, target.x, target.y);
e.movingTo = { x: target.x, y: target.y }; e.movingTo = { x: target.x, y: target.y };
e.buildTargetId = 0; e.buildTargetId = 0;
} else { } else {
@ -923,7 +946,10 @@ function stepMovement(state, rules) {
const def = defOf(rules, e); const def = defOf(rules, e);
let tx = null, ty = null; let tx = null, ty = null;
if (e.path && e.path.length >= 2) { if (e.isAir) {
// Aircraft fly the straight line. No waypoints to consume, nothing to route around.
if (e.movingTo) { tx = e.movingTo.x; ty = e.movingTo.y; }
} else if (e.path && e.path.length >= 2) {
// Consume waypoints we've reached. // Consume waypoints we've reached.
while (e.pathIdx * 2 + 1 < e.path.length) { while (e.pathIdx * 2 + 1 < e.path.length) {
const wx = e.path[e.pathIdx * 2], wy = e.path[e.pathIdx * 2 + 1]; const wx = e.path[e.pathIdx * 2], wy = e.path[e.pathIdx * 2 + 1];
@ -950,10 +976,14 @@ function stepMovement(state, rules) {
const want = Math.atan2(dy, dx); const want = Math.atan2(dy, dx);
e.heading = turnToward(e.heading, want, def.turnRate * dt); e.heading = turnToward(e.heading, want, def.turnRate * dt);
// Terrain slows movement: speed scales by the inverse of the tile's move cost. // Terrain slows movement: speed scales by the inverse of the tile's move cost. Aircraft
const ti = worldToTileY(nav, e.y) * state.w + worldToTileX(nav, e.x); // are over it, not on it, so they fly at a flat speed everywhere.
const tcost = rules.moveClasses[def.moveClass].costByTerrainIndex[state.terrain[ti]] ?? 1; let terrainMul = 1;
const terrainMul = tcost ? 1 / tcost : 1; if (!e.isAir) {
const ti = worldToTileY(nav, e.y) * state.w + worldToTileX(nav, e.x);
const tcost = rules.moveClasses[def.moveClass].costByTerrainIndex[state.terrain[ti]] ?? 1;
terrainMul = tcost ? 1 / tcost : 1;
}
// Slow down while still swinging round, and ease into the final waypoint. // Slow down while still swinging round, and ease into the final waypoint.
const misalign = Math.abs(angleDelta(e.heading, want)); const misalign = Math.abs(angleDelta(e.heading, want));
@ -1050,6 +1080,9 @@ function stepSeparation(state, rules) {
hash.query(a.x, a.y, a.radius * 2 + 32, near); hash.query(a.x, a.y, a.radius * 2 + 32, near);
for (const b of near) { for (const b of near) {
if (b.id <= a.id) continue; // handle each pair once, deterministically if (b.id <= a.id) continue; // handle each pair once, deterministically
// Different layers never touch. Aircraft still go into the hash so gunners can find
// them, but a Fighter overflying a tank column must not shove it apart.
if (a.isAir !== b.isAir) continue;
const dx = b.x - a.x, dy = b.y - a.y; const dx = b.x - a.x, dy = b.y - a.y;
const minD = a.radius + b.radius; const minD = a.radius + b.radius;
let d2 = dx * dx + dy * dy; let d2 = dx * dx + dy * dy;
@ -1086,6 +1119,13 @@ function stepSeparation(state, rules) {
const ts = state.tileSize; const ts = state.tileSize;
for (const e of movers) { for (const e of movers) {
const def = defOf(rules, e); const def = defOf(rules, e);
// Aircraft are only bounded by the map edge — nothing on the grid blocks them, and the
// clamp below would otherwise drag one off a cliff or a building it was legitimately over.
if (e.isAir) {
e.x = Math.max(e.radius, Math.min(state.worldW - e.radius, e.x));
e.y = Math.max(e.radius, Math.min(state.worldH - e.radius, e.y));
continue;
}
const need = clearanceFor(e.radius, ts); const need = clearanceFor(e.radius, ts);
const cl = nav.clearance[def.moveClass]; const cl = nav.clearance[def.moveClass];
const tx = worldToTileX(nav, e.x), ty = worldToTileY(nav, e.y); const tx = worldToTileX(nav, e.x), ty = worldToTileY(nav, e.y);
@ -1166,6 +1206,7 @@ function stepCombat(state, rules) {
if (e.reload[wi] > 0) continue; if (e.reload[wi] > 0) continue;
// Manual weapons (the D-Gun) only fire when this exact target was ordered attacked. // 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 (w.manual && !(holdOrder && e.orders[0].targetId === target.id)) continue;
if (!weaponHitsDomain(w, target.isAir)) continue;
const dist = surface; const dist = surface;
if (dist > w.range || dist < (w.minRange ?? 0)) continue; if (dist > w.range || dist < (w.minRange ?? 0)) continue;
if (!aligned) continue; if (!aligned) continue;
@ -1185,6 +1226,7 @@ function stepCombat(state, rules) {
for (let wi = 0; wi < def.weaponDefs.length; wi++) { for (let wi = 0; wi < def.weaponDefs.length; wi++) {
const w = def.weaponDefs[wi]; const w = def.weaponDefs[wi];
if (w.manual) continue; if (w.manual) continue;
if (!weaponHitsDomain(w, target.isAir)) continue;
if (e.burstLeft[wi] > 0 && e.reload[wi] === 0) { if (e.burstLeft[wi] > 0 && e.reload[wi] === 0) {
fireWeapon(state, rules, e, w, target, facing, aimPt); fireWeapon(state, rules, e, w, target, facing, aimPt);
e.burstLeft[wi]--; e.burstLeft[wi]--;
@ -1225,6 +1267,7 @@ export function aimPointOn(rules, from, target) {
function inWeaponRange(rules, e, def, target) { function inWeaponRange(rules, e, def, target) {
if (target.dead) return false; if (target.dead) return false;
if (!canEngage(def, target)) return false;
return surfaceDist(rules, e, target) <= def.maxRange; return surfaceDist(rules, e, target) <= def.maxRange;
} }
@ -1240,6 +1283,9 @@ function acquireTarget(state, rules, e, def, hash, near) {
if (d > def.maxRange) return; if (d > def.maxRange) return;
let score = -Infinity; let score = -Infinity;
for (const w of def.weaponDefs) { 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 (d > w.range || d < (w.minRange ?? 0)) continue; if (d > w.range || d < (w.minRange ?? 0)) continue;
const mul = w.armorMul?.[rules.defById[t.defId].armorClass] ?? 1; const mul = w.armorMul?.[rules.defById[t.defId].armorClass] ?? 1;
const s = (w.damage * mul) / (1 + d / w.range); const s = (w.damage * mul) / (1 + d / w.range);
@ -1360,11 +1406,14 @@ function stepProjectiles(state, rules) {
p.x = p.px + (p.x - p.px) * s; p.y = p.py + (p.y - p.py) * s; p.x = p.px + (p.x - p.px) * s; p.y = p.py + (p.y - p.py) * s;
} }
} }
// Direct contact with any hostile it passes through. // Direct contact with any hostile it passes through — in a domain this weapon can hit.
// Without the domain test a bomb would detonate on the first aircraft whose airspace it
// crossed, and an AA burst would stop dead on the tank underneath its target.
if (!detonate) { if (!detonate) {
let bestT = Infinity, hit = null; let bestT = Infinity, hit = null;
for (const e of state.entities) { for (const e of state.entities) {
if (e.dead || e.site || e.army === p.army) continue; if (e.dead || e.site || e.army === p.army) continue;
if (!weaponHitsDomain(w, e.isAir)) continue;
if (segDistSq(p.px, p.py, p.x, p.y, e.x, e.y) > e.radius * e.radius) 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); const s = segClosestT(p.px, p.py, p.x, p.y, e.x, e.y);
if (s < bestT) { bestT = s; hit = e; } if (s < bestT) { bestT = s; hit = e; }
@ -1381,6 +1430,7 @@ function stepProjectiles(state, rules) {
else { else {
for (const e of state.entities) { for (const e of state.entities) {
if (e.dead || e.army === p.army) continue; if (e.dead || e.army === p.army) continue;
if (!weaponHitsDomain(w, e.isAir)) continue;
if (Math.hypot(e.x - p.x, e.y - p.y) <= e.radius) { 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 }); applyDamage(state, rules, e, w.damage * (w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1), { army: p.army, id: p.ownerId });
break; break;
@ -1416,6 +1466,8 @@ function applyAoe(state, rules, x, y, w, source) {
for (const e of state.entities) { for (const e of state.entities) {
if (e.dead) continue; if (e.dead) continue;
if (e.army === source.army && !rules.constants.friendlyFire) 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;
// Falloff is measured to the SURFACE, so a shell that lands squarely on a building's // 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 // 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 // centre circle instead scored an off-axis wall hit as 32px away and cut its damage by
@ -1481,6 +1533,7 @@ function killEntity(state, rules, e, source) {
state.events.push({ t: 'bigExplosion', x: e.x, y: e.y, radius: ex.radius }); state.events.push({ t: 'bigExplosion', x: e.x, y: e.y, radius: ex.radius });
for (const o of state.entities) { for (const o of state.entities) {
if (o.dead || o.id === e.id) continue; if (o.dead || o.id === e.id) continue;
if (o.isAir) 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; const d = Math.hypot(o.x - e.x, o.y - e.y) - o.radius;
if (d > ex.radius) continue; if (d > ex.radius) continue;
const scale = 1 - Math.max(0, Math.min(1, d / ex.radius)) * 0.7; const scale = 1 - Math.max(0, Math.min(1, d / ex.radius)) * 0.7;

View File

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

View File

@ -12,6 +12,9 @@ export const FX_STYLES = new Set(['tracer', 'beam', 'shell', 'rocket', 'dgun']);
export const UNIT_ROLES = new Set(['builder', 'combat', 'scout', 'artillery']); export const UNIT_ROLES = new Set(['builder', 'combat', 'scout', 'artillery']);
export const SHEET_SLOTS = new Set(['unitSheet', 'structureSheet']); export const SHEET_SLOTS = new Set(['unitSheet', 'structureSheet']);
export const TARGET_DOMAINS = new Set(['ground', 'air']); export const TARGET_DOMAINS = new Set(['ground', 'air']);
// Which layer a unit occupies. `air` units ignore the nav grid entirely, never collide with
// ground units, and can only be shot by weapons that list `air` in their `targets`.
export const UNIT_DOMAINS = new Set(['ground', 'air']);
// A unit/building must always be able to see a bit past its own guns, or it ends up firing // A unit/building must always be able to see a bit past its own guns, or it ends up firing
// into fog it hasn't revealed. Applied as a floor over whatever `sight` the def declares. // into fog it hasn't revealed. Applied as a floor over whatever `sight` the def declares.
@ -21,8 +24,9 @@ const SIGHT_RANGE_MARGIN_TILES = 2;
// this set is duplicated there and the verify script asserts the two agree. // this set is duplicated there and the verify script asserts the two agree.
export const PROC_SHAPES = new Set([ export const PROC_SHAPES = new Set([
'commander', 'infantry', 'sniper', 'rocketTrooper', 'jeep', 'tank', 'rockettank', 'constructor', 'commander', 'infantry', 'sniper', 'rocketTrooper', 'jeep', 'tank', 'rockettank', 'constructor',
'fighter', 'bomber', 'hoverConstructor',
'energyGen', 'massGen', 'barracks', 'vehiclePlant', 'laserTower', 'missileLauncher', 'energyGen', 'massGen', 'barracks', 'vehiclePlant', 'laserTower', 'missileLauncher',
'advancedVehiclePlant', 'advancedVehiclePlant', 'airfield',
]); ]);
function fail(msg) { function fail(msg) {
@ -118,7 +122,14 @@ export function compileRules(json) {
if (!terrainById[key]) fail(`moveClass "${mc}" references unknown terrain "${key}"`); if (!terrainById[key]) fail(`moveClass "${mc}" references unknown terrain "${key}"`);
} }
// Flat per-terrain-index cost array — the pathfinder's hot path reads this. // Flat per-terrain-index cost array — the pathfinder's hot path reads this.
spec.costByTerrainIndex = terrain.map((t) => (t.blocksMove ? null : spec.cost[t.id])); //
// The COST TABLE is authoritative, not `terrain.blocksMove`: a null cost is impassable and
// a positive one is passable, whatever the terrain says. `blocksMove` describes the ordinary
// ground unit, and every one of those declares null for cliff and water anyway — but a hover
// class that crosses water, or an air class that crosses everything, is then a JSON-only
// change instead of needing a per-terrain exception flag.
spec.costByTerrainIndex = terrain.map((t) => spec.cost[t.id] ?? null);
spec.air = spec.air === true;
} }
const moveClassSet = new Set(Object.keys(moveClasses)); const moveClassSet = new Set(Object.keys(moveClasses));
@ -178,6 +189,8 @@ export function compileRules(json) {
w.rangeSq = w.range * w.range; w.rangeSq = w.range * w.range;
w.minRangeSq = (w.minRange ?? 0) * (w.minRange ?? 0); w.minRangeSq = (w.minRange ?? 0) * (w.minRange ?? 0);
w.targetsAir = (w.targets ?? ['ground']).includes('air'); w.targetsAir = (w.targets ?? ['ground']).includes('air');
w.targetsGround = (w.targets ?? ['ground']).includes('ground');
if (!w.targetsAir && !w.targetsGround) fail(`weapon "${w.id}" targets neither ground nor air`);
// A manual weapon never auto-fires: it needs an explicit attack order on the victim. // A manual weapon never auto-fires: it needs an explicit attack order on the victim.
// The D-Gun is the reason this flag exists — left on auto-fire, a defending Commander // The D-Gun is the reason this flag exists — left on auto-fire, a defending Commander
// deletes one attacker every reload for free, which makes assaulting any base suicide. // deletes one attacker every reload for free, which makes assaulting any base suicide.
@ -194,6 +207,15 @@ export function compileRules(json) {
if (!sizeSet.has(u.size)) fail(`unit "${u.id}" has unknown size "${u.size}"`); if (!sizeSet.has(u.size)) fail(`unit "${u.id}" has unknown size "${u.size}"`);
if (!armorSet.has(u.armorClass)) fail(`unit "${u.id}" has unknown armorClass "${u.armorClass}"`); if (!armorSet.has(u.armorClass)) fail(`unit "${u.id}" has unknown armorClass "${u.armorClass}"`);
if (!moveClassSet.has(u.moveClass)) fail(`unit "${u.id}" has unknown moveClass "${u.moveClass}"`); if (!moveClassSet.has(u.moveClass)) fail(`unit "${u.id}" has unknown moveClass "${u.moveClass}"`);
u.domain = u.domain ?? 'ground';
if (!UNIT_DOMAINS.has(u.domain)) fail(`unit "${u.id}" has unknown domain "${u.domain}"`);
u.isAir = u.domain === 'air';
// The domain and the move class have to agree: the sim reads `isAir` in its hot loops but
// spawning and the terrain-speed lookup still go through the move class, and a unit that
// flew while pathing on treads would be wrong in whichever of the two you didn't check.
if (u.isAir !== moveClasses[u.moveClass].air) {
fail(`unit "${u.id}" is domain "${u.domain}" but its moveClass "${u.moveClass}" is not`);
}
if (u.role && !UNIT_ROLES.has(u.role)) fail(`unit "${u.id}" has unknown role "${u.role}"`); if (u.role && !UNIT_ROLES.has(u.role)) fail(`unit "${u.id}" has unknown role "${u.role}"`);
if (!SHEET_SLOTS.has(u.sheetSlot)) fail(`unit "${u.id}" has unknown sheetSlot "${u.sheetSlot}"`); if (!SHEET_SLOTS.has(u.sheetSlot)) fail(`unit "${u.id}" has unknown sheetSlot "${u.sheetSlot}"`);
if (!PROC_SHAPES.has(u.procShape)) fail(`unit "${u.id}" has unknown procShape "${u.procShape}"`); if (!PROC_SHAPES.has(u.procShape)) fail(`unit "${u.id}" has unknown procShape "${u.procShape}"`);
@ -232,6 +254,10 @@ export function compileRules(json) {
} }
const unitById = indexById(units); const unitById = indexById(units);
const commanderUnits = units.filter((u) => u.isCommander); const commanderUnits = units.filter((u) => u.isCommander);
// An air roster with nothing able to shoot at it isn't a unit type, it's an auto-win.
if (units.some((u) => u.isAir) && !weapons.some((w) => w.targetsAir)) {
fail('air units exist but no weapon targets the air domain');
}
const buildings = json.buildings ?? []; const buildings = json.buildings ?? [];
requireUnique(buildings, 'building'); requireUnique(buildings, 'building');
@ -251,6 +277,7 @@ export function compileRules(json) {
if (!weaponById[wid]) fail(`building "${b.id}" references unknown weapon "${wid}"`); if (!weaponById[wid]) fail(`building "${b.id}" references unknown weapon "${wid}"`);
} }
b.isBuilding = true; b.isBuilding = true;
b.isAir = false;
b.radius = (Math.max(b.footprint.w, b.footprint.h) * c.tileSize) / 2; b.radius = (Math.max(b.footprint.w, b.footprint.h) * c.tileSize) / 2;
// True half-extents. `radius` is a circle around the centre, which understates a // True half-extents. `radius` is a circle around the centre, which understates a
// building's reach along its diagonals and is what made units drive into the walls of // building's reach along its diagonals and is what made units drive into the walls of
@ -358,6 +385,22 @@ export function compileRules(json) {
}; };
} }
/** Can this weapon engage something in the given domain at all? */
export function weaponHitsDomain(weapon, targetIsAir) {
return targetIsAir ? weapon.targetsAir : weapon.targetsGround;
}
/**
* Does `def` carry ANY weapon that can reach `target`'s domain? Anything that can't should
* never acquire, chase or fire at it a rifleman ordered to attack a Fighter would otherwise
* follow it across the map forever, never able to shoot.
*/
export function canEngage(def, target) {
const air = !!target.isAir;
for (const w of def.weaponDefs ?? []) if (weaponHitsDomain(w, air)) return true;
return false;
}
/** Damage multiplier of `weapon` against a defender of `armorClass`. */ /** Damage multiplier of `weapon` against a defender of `armorClass`. */
export function armorMul(weapon, armorClass) { export function armorMul(weapon, armorClass) {
return weapon.armorMul?.[armorClass] ?? 1; return weapon.armorMul?.[armorClass] ?? 1;

View File

@ -27,10 +27,15 @@ const ORDER_COLORS = {
}; };
export const DEPTHS = { export const DEPTHS = {
terrain: 0, decal: 5, fxUnder: 8, ghost: 12, terrain: 0, decal: 5, shadow: 6, fxUnder: 8, ghost: 12,
selection: 15, actor: 20, bars: 55, projectile: 60, fxOver: 65, fog: 80, selection: 15, actor: 20, air: 40, bars: 55, projectile: 60, fxOver: 65, fog: 80,
}; };
// How far down-right an aircraft's shadow falls. The aircraft itself is NOT offset: click
// selection and every order test use the entity's own position, so moving the sprite off it
// would make aircraft feel un-clickable. Moving only the shadow sells the altitude for free.
const SHADOW_OFFSET = { x: 18, y: 22 };
export default class TAWorldView { export default class TAWorldView {
constructor(scene, rules, art, state, playerArmy) { constructor(scene, rules, art, state, playerArmy) {
this.scene = scene; this.scene = scene;
@ -349,7 +354,16 @@ export default class TAWorldView {
turret.setScale((def.spritePx ?? def.radius * 2) / frameSize.w); turret.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
this._addWorld(turret); this._addWorld(turret);
} }
s = { img, turret, final, defId: e.defId };
// Aircraft get a flattened black copy of their own frame on the ground beneath them.
let shadow = null;
if (def.isAir) {
shadow = this.scene.add.image(e.x, e.y, key, def.frame);
shadow.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
shadow.setTint(0x000000).setAlpha(0.28).setDepth(DEPTHS.shadow);
this._addWorld(shadow);
}
s = { img, turret, final, shadow, defId: e.defId };
this.sprites.set(e.id, s); this.sprites.set(e.id, s);
return s; return s;
} }
@ -360,6 +374,7 @@ export default class TAWorldView {
s.img.destroy(); s.img.destroy();
s.turret?.destroy(); s.turret?.destroy();
s.final?.destroy(); s.final?.destroy();
s.shadow?.destroy();
this.sprites.delete(id); this.sprites.delete(id);
} }
@ -390,6 +405,7 @@ export default class TAWorldView {
s.img.setVisible(shown); s.img.setVisible(shown);
if (s.turret) s.turret.setVisible(shown); if (s.turret) s.turret.setVisible(shown);
if (s.final) s.final.setVisible(shown); if (s.final) s.final.setVisible(shown);
if (s.shadow) s.shadow.setVisible(shown);
if (!shown) continue; if (!shown) continue;
const x = e.px + (e.x - e.px) * alpha; const x = e.px + (e.x - e.px) * alpha;
@ -398,8 +414,13 @@ export default class TAWorldView {
s.img.setPosition(x, y); s.img.setPosition(x, y);
if (!def.isBuilding) s.img.setRotation(heading); if (!def.isBuilding) s.img.setRotation(heading);
// Y-sorted actor band; buildings sit just under units sharing a row. // Y-sorted actor band; buildings sit just under units sharing a row. Aircraft ride in
s.img.setDepth(DEPTHS.actor + (y / state.worldH) * 10 + (def.isBuilding ? 0 : 0.05)); // their own band above ALL of it — a Fighter must never disappear behind a factory.
const band = def.isAir ? DEPTHS.air : DEPTHS.actor;
s.img.setDepth(band + (y / state.worldH) * 10 + (def.isBuilding ? 0 : 0.05));
if (s.shadow) {
s.shadow.setPosition(x + SHADOW_OFFSET.x, y + SHADOW_OFFSET.y).setRotation(heading);
}
// Build sites show the wireframe frame. Queued (progress still 0) sits at 50% // Build sites show the wireframe frame. Queued (progress still 0) sits at 50%
// opacity; once work starts the wireframe fades 100%->0% over construction while // opacity; once work starts the wireframe fades 100%->0% over construction while

View File

@ -268,6 +268,8 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
// Play the nuclear explosion at full volume for maximum impact // Play the nuclear explosion at full volume for maximum impact
this._throttledSfx('sfx-ta-nuclear', 300, 1); this._throttledSfx('sfx-ta-nuclear', 300, 1);
} else if (def?.moveClass) { } else if (def?.moveClass) {
// Infantry get the flesh-and-blood cue; anything with an engine — tracked, hovering
// or flying — gets the machinery one.
this._throttledSfx(def.moveClass === 'foot' ? 'sfx-ta-unit-loss' : 'sfx-ta-vehicle-loss', 120); this._throttledSfx(def.moveClass === 'foot' ? 'sfx-ta-unit-loss' : 'sfx-ta-vehicle-loss', 120);
} }
} }

View File

@ -60,6 +60,19 @@ let the scale do the work.
| 9 | Rocket Tank turret | rocket pod | 56 | | 9 | Rocket Tank turret | rocket pod | 56 |
| 10 | Rocket Trooper | whole figure, shoulder-mounted launch tube | 30 | | 10 | Rocket Trooper | whole figure, shoulder-mounted launch tube | 30 |
| 11 | Construction Vehicle | tracked hull, folded nanolathe crane, no gun | 50 | | 11 | Construction Vehicle | tracked hull, folded nanolathe crane, no gun | 50 |
| 12 | Fighter | plan-view airframe, swept wings, no tracks or wheels | 46 |
| 13 | Bomber | plan-view airframe, long straight wing, wing-mounted engines | 60 |
| 14 | Hover Constructor | skirted hull over a plenum, nanolathe crane, no tracks | 52 |
**Aircraft** (frames 12-13) are drawn on the same sheet and rotated exactly like a ground
unit, but the renderer puts them in their own depth band above every ground actor and lays a
black, 28%-opacity copy of the frame on the ground 18px right and 22px down as a shadow. Two
consequences for the art:
- The silhouette must read as flying — wings well clear of the fuselage, nothing that looks
like a track or a wheel. It is the only cue the player gets that ground units cannot shoot it.
- The frame is reused as its own shadow, so keep the shape solid. A hollow or heavily
outlined airframe casts a shadow that reads as a smudge.
**Turret frames** are drawn as a separate image stacked on the hull and rotated **Turret frames** are drawn as a separate image stacked on the hull and rotated
independently, so the unit aims while it drives. Rules: independently, so the unit aims while it drives. Rules:
@ -67,11 +80,13 @@ independently, so the unit aims while it drives. Rules:
- The turret's **pivot is the centre of the cell** — draw the turret so its rotation point - The turret's **pivot is the centre of the cell** — draw the turret so its rotation point
sits at (32, 32), with the barrel extending to the **right**. sits at (32, 32), with the barrel extending to the **right**.
- Leave the rest of the turret cell transparent. The hull shows through it. - Leave the rest of the turret cell transparent. The hull shows through it.
- A unit with no turret (infantry, sniper, rocket trooper, construction vehicle) simply has - A unit with no turret (infantry, sniper, rocket trooper, construction vehicle, and all three
no turret frame; its whole sprite rotates to face its target. The Construction Vehicle is Airfield units) simply has no turret frame; its whole sprite rotates to face its target. The
unarmed, so it never turns to aim regardless — it just rotates to face wherever it's moving. Construction Vehicle and Hover Constructor are unarmed, so they never turn to aim regardless —
they just rotate to face wherever they're moving. Aircraft have fixed forward guns, so they
aim by pointing the whole airframe, which is why they have no turret either.
Frames 1263 are free. Add a unit by appending a definition to `units[]` in Frames 1563 are free. Add a unit by appending a definition to `units[]` in
`data/totalannihilation-rules.json` with its `frame` (and optional `turretFrame`) — no code. `data/totalannihilation-rules.json` with its `frame` (and optional `turretFrame`) — no code.
--- ---
@ -104,12 +119,14 @@ build completes; the finished frame crossfades in underneath starting at 20% pro
| 11 | Missile Launcher — under construction | 2×2 | | | 11 | Missile Launcher — under construction | 2×2 | |
| 12 | Advanced Vehicle Plant | 4×4 | wider bay, **two** doors side by side | | 12 | Advanced Vehicle Plant | 4×4 | wider bay, **two** doors side by side |
| 13 | Advanced Vehicle Plant — under construction | 4×4 | | | 13 | Advanced Vehicle Plant — under construction | 4×4 | |
| 14 | Airfield | 3×3 | runway/apron, not a shed — strip runs to the **bottom** edge |
| 15 | Airfield — under construction | 3×3 | |
Defensive structures never rotate, and their mount traverses freely in the simulation — they Defensive structures never rotate, and their mount traverses freely in the simulation — they
shoot in every direction regardless of how the art is drawn. Draw them facing **up** and do not shoot in every direction regardless of how the art is drawn. Draw them facing **up** and do not
imply a firing arc. imply a firing arc.
Frames 1435 are free. Frames 1635 are free.
--- ---
@ -151,9 +168,21 @@ The theme then appears in the skirmish setup screen automatically.
## Icons (44×44 cells, 10 per row) ## Icons (44×44 cells, 10 per row)
Command glyphs for the HUD: move, attack, attack-move, stop, hold, patrol, guard, build, Build-menu and command glyphs. Frame numbers come from each def's `icon` in the rules JSON
repair, rally. Purely decorative today — the build menu labels itself from unit names — so and from `commandIcons`, so this table is a reflection of that file, not a second source of
this sheet is the lowest priority of the eight. truth. Purely decorative today — the build menu labels itself from unit names — so this sheet
is the lowest priority of the eight.
| Frames | Contents |
|---|---|
| 09 | Units: commander, infantry, sniper, jeep, tank, rocket tank, rocket trooper, construction vehicle, fighter, bomber |
| 1016 | Buildings: energy gen, mass gen, barracks, vehicle plant, laser tower, missile launcher, advanced vehicle plant |
| 1718 | Overflow: hover constructor (unit), airfield (building) |
| 2027 | Commands: move, attack, attack-move, stop, hold, patrol, guard, assist |
Row 0 filled up before the Airfield units were added, which is why the hover constructor sits
next to the buildings at 17 rather than with the other units. Nothing reads a row as a
category — only the `icon` numbers matter.
--- ---

View File

@ -336,6 +336,19 @@ section('2d. Container hitbox lint');
check(`${rel.split('/').pop()} has no top-left-origin child in an interactive container`, check(`${rel.split('/').pop()} has no top-left-origin child in an interactive container`,
offenders === 0, `${offenders} block(s)`); offenders === 0, `${offenders} block(s)`);
} }
// The build menu is ONE row deep — the bottom bar has no vertical room for a second, which
// would run down into the hint line and bury whatever wrapped there. Adding a build option
// to a unit is otherwise a pure JSON edit, so nothing else would catch it: giving the
// Commander an Airfield pushed it to 7 options and hid that button behind the hint text.
const hudSrc = readFileSync(join(ROOT, 'src/games/totalannihilation/TAHud.js'), 'utf8');
const cols = Number(/const GRID_COLS = (\d+)/.exec(hudSrc)?.[1]);
check('the HUD declares a build-grid column count', Number.isInteger(cols) && cols > 0);
for (const d of [...rules.units, ...rules.buildings]) {
const n = (d.builds ?? []).length;
if (!n) continue;
check(`${d.id}'s build options fit one grid row`, n <= cols, `${n} options vs ${cols} columns`);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1117,6 +1130,164 @@ section('6e. Defensive structures');
} }
} }
// ---------------------------------------------------------------------------
section('6f. Air domain and hover');
// ---------------------------------------------------------------------------
{
// Air is the one thing in this game that is not simply "another unit with different numbers":
// it opts out of the nav grid, out of ground collision, and out of every weapon that doesn't
// explicitly list the air domain. Each of those three is a separate place the sim could quietly
// fall back to ground behaviour, so each gets its own fixture.
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
raw.constants.eliminateWhenUnrecoverable = false; // these fixtures field no builders
const ar = compileRules(raw);
const TS = ar.constants.tileSize;
// ---- data ----
check('the Fighter is an air unit', ar.unitById.fighter?.isAir === true);
check('the Bomber is an air unit', ar.unitById.bomber?.isAir === true);
check('the Hover Constructor stays on the ground', ar.unitById.hoverconstructor?.isAir === false);
check('the Airfield builds fighter, bomber and hover constructor',
['fighter', 'bomber', 'hoverconstructor'].every((id) => (ar.buildingById.airfield?.builds ?? []).includes(id)));
check('the Commander can build an Airfield', (ar.unitById.commander.builds ?? []).includes('airfield'));
const waterIdx = ar.terrainById.water.index;
const cliffIdx = ar.terrainById.cliff.index;
check('hover crosses water', ar.moveClasses.hover.costByTerrainIndex[waterIdx] > 0);
check('treads do not cross water', ar.moveClasses.tread.costByTerrainIndex[waterIdx] == null);
check('air crosses water and cliffs',
ar.moveClasses.air.costByTerrainIndex[waterIdx] > 0 && ar.moveClasses.air.costByTerrainIndex[cliffIdx] > 0);
// Without this a ground-only army has literally no answer to a Bomber, which is not a
// difficulty setting — it's an auto-win for whoever builds an Airfield first.
const groundAA = ar.units.filter((u) => !u.isAir && (u.weaponDefs ?? []).some((w) => w.targetsAir));
check('some ground unit can shoot at aircraft', groundAA.length > 0,
'nothing on the ground has anti-air');
// ---- a map with a full-height water channel down the middle ----
const W = 40, H = 40;
const flat = () => {
const t = new Uint8Array(W * H).fill(ar.terrainById.ground.index);
for (let y = 0; y < H; y++) for (let x = 18; x <= 21; x++) t[y * W + x] = waterIdx;
return t;
};
const px = (t) => t * TS + TS / 2;
const arena = (seed = 5) => {
const st = L.createMatch(ar, {
seed, victory: 'annihilation',
map: { w: W, h: H, terrain: flat(), starts: [], theme: 'grasslands' },
armies: [{ armyId: 'arm' }, { armyId: 'core' }],
});
// Both armies need SOMETHING alive or checkResult ends the match on tick one and every
// fixture below silently measures a frozen sim. These sit in opposite far corners, well
// outside any sight or weapon range used here, so they never touch what is being measured.
L.spawnUnit(st, ar, 0, 'infantry', px(1), px(1));
L.spawnUnit(st, ar, 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, ar); };
{
const st = arena();
const hover = L.spawnUnit(st, ar, 0, 'hoverconstructor', px(5), px(20));
const tank = L.spawnUnit(st, ar, 0, 'tank', px(5), px(24));
const fighter = L.spawnUnit(st, ar, 0, 'fighter', px(5), px(28));
for (const u of [hover, tank, fighter]) {
L.issueOrder(st, ar, { army: 0, unitIds: [u.id], order: { type: 'move', x: px(35), y: u.y } });
}
runFor(st, 120);
const crossed = (e) => e.x > px(25);
check('a hover unit crosses open water', crossed(hover), `x=${(hover.x / TS).toFixed(1)} tiles`);
check('an aircraft crosses open water', crossed(fighter), `x=${(fighter.x / TS).toFixed(1)} tiles`);
check('a tracked unit is stopped by the same water', !crossed(tank), `x=${(tank.x / TS).toFixed(1)} tiles`);
// Aircraft must never enter the pathfinder: a path costs A* budget it can't use, and a
// fighter holding a stale ground path would refuse to fly over the very water it just crossed.
check('aircraft never hold a nav path', fighter.path == null);
}
// ---- ground weapons cannot touch aircraft, and air-only weapons cannot touch the ground ----
{
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));
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' } });
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));
}
{
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' } });
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.
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));
runFor(st, 90);
check('fighters can kill each other', a.dead || b.dead || a.hp < a.maxHp,
`${a.hp.toFixed(0)} v ${b.hp.toFixed(0)}`);
}
// ---- splash and blast radii respect the domain ----
{
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.
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 } });
runFor(st, 60);
check('a Bomber damages ground armour', tank.dead || tank.hp < tank.maxHp,
`${tank.hp.toFixed(0)}/${tank.maxHp}`);
check('bomb splash does not reach the aircraft above it', overhead.hp === overhead.maxHp,
`${overhead.hp.toFixed(0)}/${overhead.maxHp}`);
}
// ---- orders and collision ----
{
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));
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,
'the rifleman would chase the aircraft forever');
}
{
const st = arena(11);
const tanks = [];
for (let i = 0; i < 5; i++) tanks.push(L.spawnUnit(st, ar, 0, 'tank', px(12) + i * 8, px(20)));
runFor(st, 5); // let the cluster settle first
const before = tanks.map((t) => ({ x: t.x, y: t.y }));
const fighter = L.spawnUnit(st, ar, 0, 'fighter', px(6), px(20));
L.issueOrder(st, ar, { army: 0, unitIds: [fighter.id], order: { type: 'move', x: px(30), y: px(20) } });
runFor(st, 15);
const moved = tanks.reduce((m, t, i) => Math.max(m, Math.hypot(t.x - before[i].x, t.y - before[i].y)), 0);
check('an aircraft flies through ground units without shoving them', moved < 0.5,
`worst displacement ${moved.toFixed(2)}px`);
check('the aircraft actually crossed them', fighter.x > px(25));
}
// ---- the flag survives a save/load round trip ----
{
const st = arena(12);
L.spawnUnit(st, ar, 0, 'fighter', px(10), px(20));
const back = L.deserialize(ar, L.serialize(st));
check('isAir survives serialization',
!!back && back.entities.find((e) => e.defId === 'fighter')?.isAir === true);
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
section('7. Fog of war'); section('7. Fog of war');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------