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.
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 233 KiB |
|
|
@ -11,7 +11,14 @@
|
|||
"data/totalannihilation-artwork.json.",
|
||||
"",
|
||||
"Units are drawn once and ROTATED at runtime \u2014 there are no per-facing frames. `turretFrame` is",
|
||||
"an optional second sprite that aims independently of the hull."
|
||||
"an optional second sprite that aims independently of the hull.",
|
||||
"",
|
||||
"Domains: a unit's `domain` is \"ground\" (the default) or \"air\". Air units ignore the nav grid,",
|
||||
"never collide with ground units, and 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,
|
||||
"constants": {
|
||||
|
|
@ -102,6 +109,27 @@
|
|||
"cliff": null,
|
||||
"water": null
|
||||
}
|
||||
},
|
||||
"hover": {
|
||||
"cost": {
|
||||
"ground": 1.0,
|
||||
"rough": 1.1,
|
||||
"road": 0.9,
|
||||
"metal": 1.0,
|
||||
"cliff": null,
|
||||
"water": 1.0
|
||||
}
|
||||
},
|
||||
"air": {
|
||||
"air": true,
|
||||
"cost": {
|
||||
"ground": 1.0,
|
||||
"rough": 1.0,
|
||||
"road": 1.0,
|
||||
"metal": 1.0,
|
||||
"cliff": 1.0,
|
||||
"water": 1.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"armies": [
|
||||
|
|
@ -348,7 +376,8 @@
|
|||
"aoe": 48,
|
||||
"aoeFalloff": 0.15,
|
||||
"targets": [
|
||||
"ground"
|
||||
"ground",
|
||||
"air"
|
||||
],
|
||||
"armorMul": {
|
||||
"infantry": 0.3,
|
||||
|
|
@ -380,7 +409,8 @@
|
|||
"aoe": 24,
|
||||
"aoeFalloff": 0.15,
|
||||
"targets": [
|
||||
"ground"
|
||||
"ground",
|
||||
"air"
|
||||
],
|
||||
"armorMul": {
|
||||
"infantry": 0.35,
|
||||
|
|
@ -494,7 +524,8 @@
|
|||
"aoe": 56,
|
||||
"aoeFalloff": 0.15,
|
||||
"targets": [
|
||||
"ground"
|
||||
"ground",
|
||||
"air"
|
||||
],
|
||||
"armorMul": {
|
||||
"infantry": 0.35,
|
||||
|
|
@ -510,6 +541,63 @@
|
|||
"trail": true
|
||||
},
|
||||
"sound": ["sfx-ta-rocket-1", "sfx-ta-rocket-2"]
|
||||
},
|
||||
{
|
||||
"id": "aacannon",
|
||||
"name": "Air-to-Air Cannon",
|
||||
"kind": "hitscan",
|
||||
"damage": 44,
|
||||
"reload": 1.6,
|
||||
"burst": 3,
|
||||
"burstDelay": 0.12,
|
||||
"range": 300,
|
||||
"spread": 0.02,
|
||||
"targets": ["air"],
|
||||
"armorMul": {
|
||||
"infantry": 1.0,
|
||||
"light": 1.0,
|
||||
"medium": 0.85,
|
||||
"heavy": 0.6,
|
||||
"structure": 0.2
|
||||
},
|
||||
"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": [
|
||||
|
|
@ -543,7 +631,8 @@
|
|||
"barracks",
|
||||
"vehicleplant",
|
||||
"lasertower",
|
||||
"missilelauncher"
|
||||
"missilelauncher",
|
||||
"airfield"
|
||||
],
|
||||
"produce": {
|
||||
"energy": 25,
|
||||
|
|
@ -780,7 +869,8 @@
|
|||
"vehicleplant",
|
||||
"lasertower",
|
||||
"missilelauncher",
|
||||
"advancedvehicleplant"
|
||||
"advancedvehicleplant",
|
||||
"airfield"
|
||||
],
|
||||
"sheetSlot": "unitSheet",
|
||||
"frame": 11,
|
||||
|
|
@ -788,6 +878,107 @@
|
|||
"icon": 7,
|
||||
"spritePx": 50,
|
||||
"moveSound": "sfx-engine-heavy"
|
||||
},
|
||||
{
|
||||
"id": "fighter",
|
||||
"name": "Fighter",
|
||||
"role": "combat",
|
||||
"domain": "air",
|
||||
"size": "small",
|
||||
"radius": 22,
|
||||
"hp": 820,
|
||||
"speed": 290,
|
||||
"turnRate": 3.0,
|
||||
"moveClass": "air",
|
||||
"armorClass": "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": [
|
||||
|
|
@ -1010,6 +1201,37 @@
|
|||
"buildFrame": 13,
|
||||
"procShape": "advancedVehiclePlant",
|
||||
"icon": 16
|
||||
},
|
||||
{
|
||||
"id": "airfield",
|
||||
"name": "Airfield",
|
||||
"footprint": {
|
||||
"w": 3,
|
||||
"h": 3
|
||||
},
|
||||
"hp": 4200,
|
||||
"armorClass": "structure",
|
||||
"sight": 260,
|
||||
"cost": {
|
||||
"energy": 1800,
|
||||
"mass": 600
|
||||
},
|
||||
"buildTime": 46,
|
||||
"buildPower": 110,
|
||||
"builds": [
|
||||
"fighter",
|
||||
"bomber",
|
||||
"hoverconstructor"
|
||||
],
|
||||
"spawnOffset": {
|
||||
"x": 0,
|
||||
"y": 2.2
|
||||
},
|
||||
"sheetSlot": "structureSheet",
|
||||
"frame": 14,
|
||||
"buildFrame": 15,
|
||||
"procShape": "airfield",
|
||||
"icon": 18
|
||||
}
|
||||
],
|
||||
"commandIcons": {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
// 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 { canEngage } from './TARules.js';
|
||||
import { worldToTileX, worldToTileY } from './TANav.js';
|
||||
|
||||
/** Desired force mix per skill. Below skill 3 the AI just buys whatever it can afford. */
|
||||
|
|
@ -20,6 +21,11 @@ const COMPOSITION = {
|
|||
// mix for the trooper, and just enough of the vehicle to occasionally replace a lost
|
||||
// Commander's construction capacity — not a real combat unit, so it stays a low priority.
|
||||
rockettrooper: 0.14, constructor: 0.04,
|
||||
// Air. The weights are per-FACTORY shares, so these only compete with each other once an
|
||||
// Airfield exists — the bomber is the reason to own one, the fighter is the answer to the
|
||||
// enemy owning one (applyCounters pushes it hard the moment enemy air is actually seen),
|
||||
// and the hover constructor is the same insurance policy as the tracked one.
|
||||
bomber: 0.44, fighter: 0.30, hoverconstructor: 0.10,
|
||||
};
|
||||
|
||||
function memFor(state, armyIdx) {
|
||||
|
|
@ -30,7 +36,7 @@ function memFor(state, armyIdx) {
|
|||
nextThinkTick: 0,
|
||||
ordersThisSecond: 0, secondMark: 0,
|
||||
squad: [], squadPeak: 0, phase: 'build', focusId: 0,
|
||||
scoutId: 0,
|
||||
scoutId: 0, airPreyId: 0,
|
||||
knownEnemies: [], // [{x,y,defId,isBuilding,tick}]
|
||||
lastAttackTick: -99999,
|
||||
buildSpiral: 0,
|
||||
|
|
@ -89,6 +95,7 @@ export function runAI(rules, state, armyIdx, profile = {}) {
|
|||
manageEconomyAndBase(ctx, mine);
|
||||
manageProduction(ctx, mine);
|
||||
manageScout(ctx, mine);
|
||||
manageAir(ctx, mine);
|
||||
manageMilitary(ctx, mine);
|
||||
}
|
||||
|
||||
|
|
@ -186,6 +193,7 @@ function chooseBuilding(ctx, mine) {
|
|||
const mGen = rules.buildingById.massgen;
|
||||
const barracks = rules.buildingById.barracks;
|
||||
const plant = rules.buildingById.vehicleplant;
|
||||
const airfield = rules.buildingById.airfield;
|
||||
|
||||
// Opening: two power, one mass, then a barracks so there's something to fight with.
|
||||
if (n('energygen') < 2) return eGen;
|
||||
|
|
@ -229,12 +237,23 @@ function chooseBuilding(ctx, mine) {
|
|||
// actually feed. Saturating generators first (the obvious ordering) is why a big economy
|
||||
// used to win nothing: with only two factories the surplus just floated in storage, and an
|
||||
// army that out-earned its opponent two to one still fielded the same number of tanks.
|
||||
const factories = n('barracks') + n('vehicleplant');
|
||||
const factories = n('barracks') + n('vehicleplant') + n('airfield');
|
||||
const nextFactory = () => (n('vehicleplant') <= n('barracks') ? plant : barracks);
|
||||
const factoryCap = Math.round(2 + expansion * 4);
|
||||
const supported = Math.floor(army.mIncome / Math.max(0.5, factoryMassDrain(rules, plant)));
|
||||
const groundDrain = Math.max(0.5, factoryMassDrain(rules, plant));
|
||||
const supported = Math.floor(army.mIncome / groundDrain);
|
||||
if (factories < Math.min(factoryCap, Math.max(1, supported))) return nextFactory();
|
||||
|
||||
// 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 && roomE) return eGen;
|
||||
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. */
|
||||
function applyCounters(ctx, weights) {
|
||||
const { mem, rules } = ctx;
|
||||
let infantryish = 0, armour = 0, structures = 0;
|
||||
let infantryish = 0, armour = 0, structures = 0, air = 0;
|
||||
for (const k of mem.knownEnemies) {
|
||||
const def = rules.defById[k.defId];
|
||||
if (!def) continue;
|
||||
if (k.isBuilding) { structures++; continue; }
|
||||
if (def.isAir) { air++; continue; }
|
||||
if (def.armorClass === 'infantry' || def.armorClass === 'light') infantryish++;
|
||||
else armour++;
|
||||
}
|
||||
const seen = infantryish + armour;
|
||||
if (!seen) return;
|
||||
const armourShare = armour / seen;
|
||||
// Tanks shred infantry; rockets and snipers answer armour and buildings.
|
||||
weights.tank *= 1 + (1 - armourShare) * 0.8;
|
||||
weights.rockettank *= 1 + armourShare * 1.0 + (structures > 2 ? 0.3 : 0);
|
||||
weights.sniper *= 1 + (1 - armourShare) * 0.5;
|
||||
weights.rockettrooper *= 1 + armourShare * 0.6;
|
||||
if (seen) {
|
||||
const armourShare = armour / seen;
|
||||
// Tanks shred infantry; rockets and snipers answer armour and buildings.
|
||||
weights.tank *= 1 + (1 - armourShare) * 0.8;
|
||||
weights.rockettank *= 1 + armourShare * 1.0 + (structures > 2 ? 0.3 : 0);
|
||||
weights.sniper *= 1 + (1 - armourShare) * 0.5;
|
||||
weights.rockettrooper *= 1 + armourShare * 0.6;
|
||||
}
|
||||
// Enemy aircraft are answered by fighters first and by the guided-missile units second —
|
||||
// those are the only ground things that can shoot up at all, so the counter is far sharper
|
||||
// than any of the ground-vs-ground ones above.
|
||||
if (air) {
|
||||
weights.fighter *= 1 + Math.min(3, air) * 0.9;
|
||||
weights.rockettank *= 1 + Math.min(3, air) * 0.25;
|
||||
weights.rockettrooper *= 1 + Math.min(3, air) * 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -449,6 +478,61 @@ function sweepTarget(ctx) {
|
|||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -461,6 +545,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
|
||||
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);
|
||||
|
||||
// Home defence always wins over pushing out — skill 1 never thinks to come home.
|
||||
const threat = skill >= 2 ? nearestThreatToBase(ctx) : null;
|
||||
const threat = skill >= 2 ? nearestThreatToBase(ctx, mine) : null;
|
||||
if (threat) {
|
||||
const defenders = mem.squad.slice(0, Math.max(3, Math.ceil(mem.squad.length * 0.7)));
|
||||
if (defenders.length) {
|
||||
|
|
@ -527,17 +612,21 @@ function manageMilitary(ctx, mine) {
|
|||
}
|
||||
}
|
||||
|
||||
function nearestThreatToBase(ctx) {
|
||||
const { state, mem, armyIdx } = ctx;
|
||||
function nearestThreatToBase(ctx, mine) {
|
||||
const { state, rules, mem, armyIdx } = ctx;
|
||||
if (!mem.baseSet) return null;
|
||||
const radius = state.tileSize * 16;
|
||||
// Only something we can actually shoot counts as a threat worth recalling the army for.
|
||||
// A raiding Bomber over a base defended purely by tanks is a problem no amount of pulling
|
||||
// the squad home solves, and answering it that way just abandons the attack for nothing.
|
||||
const canAnswer = (e) => mine.units.some((u) => canEngage(rules.defById[u.defId], e));
|
||||
let best = null, bestD = Infinity;
|
||||
for (const e of state.entities) {
|
||||
if (e.dead || e.army === armyIdx || !state.armies[e.army]) continue;
|
||||
if (!isVisibleTo(state, armyIdx, e)) continue;
|
||||
if (e.isBuilding) continue;
|
||||
const d = Math.hypot(e.x - mem.baseX, e.y - mem.baseY);
|
||||
if (d < radius && d < bestD) { best = e; bestD = d; }
|
||||
if (d < radius && d < bestD && canAnswer(e)) { best = e; bestD = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
|
@ -583,6 +672,7 @@ function focusFire(ctx, mine) {
|
|||
/** True when `u` could fire on `t` from where it already stands. */
|
||||
function inReach(rules, u, t) {
|
||||
const def = rules.defById[u.defId];
|
||||
if (!canEngage(def, t)) return false;
|
||||
return Math.hypot(u.x - t.x, u.y - t.y) - t.radius <= def.maxRange;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -229,6 +229,93 @@ const UNIT_SHAPES = {
|
|||
ctx.beginPath(); ctx.arc(16, 0, 2.5, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
// Aircraft are drawn as a plan-view airframe: swept wings well behind the nose, a tail
|
||||
// plane, and no tracks or wheels anywhere. That silhouette is the only thing telling the
|
||||
// player at a glance that a unit is in a layer their tanks cannot shoot at.
|
||||
fighter(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
// swept main wing
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(2, 0); ctx.lineTo(-8, -17); ctx.lineTo(-14, -17); ctx.lineTo(-6, 0);
|
||||
ctx.lineTo(-14, 17); ctx.lineTo(-8, 17);
|
||||
ctx.closePath(); fillStroke(ctx, BODY_DARK);
|
||||
// fuselage, nose to the right
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(21, 0); ctx.lineTo(6, -6); ctx.lineTo(-16, -5);
|
||||
ctx.lineTo(-16, 5); ctx.lineTo(6, 6);
|
||||
ctx.closePath(); fillStroke(ctx, BODY);
|
||||
// tail plane
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-13, 0); ctx.lineTo(-19, -9); ctx.lineTo(-21, -9);
|
||||
ctx.lineTo(-21, 9); ctx.lineTo(-19, 9);
|
||||
ctx.closePath(); fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
|
||||
// canopy
|
||||
ctx.fillStyle = GLASS;
|
||||
ctx.beginPath(); ctx.ellipse(6, 0, 5, 3.2, 0, 0, Math.PI * 2); ctx.fill();
|
||||
// exhaust
|
||||
ctx.fillStyle = HOT;
|
||||
ctx.beginPath(); ctx.arc(-17, 0, 2.4, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
bomber(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
// long straight wing — the visual opposite of the fighter's swept one
|
||||
roundRect(ctx, -8, -24, 13, 48, 4); fillStroke(ctx, BODY_DARK);
|
||||
// engine nacelles out on the wing
|
||||
ctx.fillStyle = '#3b3f47';
|
||||
for (const sy of [-16, 16]) {
|
||||
roundRect(ctx, -9, sy - 4, 16, 8, 3); ctx.fill();
|
||||
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 1.5; ctx.stroke();
|
||||
}
|
||||
// fuselage
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(25, 0); ctx.lineTo(12, -8); ctx.lineTo(-20, -7);
|
||||
ctx.lineTo(-20, 7); ctx.lineTo(12, 8);
|
||||
ctx.closePath(); fillStroke(ctx, BODY);
|
||||
// tail plane
|
||||
roundRect(ctx, -22, -12, 6, 24, 3); fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
|
||||
// glazed nose
|
||||
ctx.fillStyle = GLASS;
|
||||
ctx.beginPath(); ctx.ellipse(14, 0, 6, 4.5, 0, 0, Math.PI * 2); ctx.fill();
|
||||
// bomb bay hatch down the belly
|
||||
ctx.fillStyle = '#2f3238';
|
||||
ctx.fillRect(-6, -4, 14, 8);
|
||||
ctx.strokeStyle = HOT; ctx.lineWidth = 1.5;
|
||||
ctx.beginPath(); ctx.moveTo(1, -4); ctx.lineTo(1, 4); ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
|
||||
// A builder with no tracks: a skirted hull over a plenum, so it reads as the thing that can
|
||||
// cross the water a tracked Construction Vehicle has to drive around.
|
||||
hoverConstructor(ctx, S) {
|
||||
const c = S / 2;
|
||||
ctx.save(); ctx.translate(c, c);
|
||||
// inflated skirt
|
||||
ctx.beginPath(); ctx.ellipse(0, 0, 22, 15, 0, 0, Math.PI * 2);
|
||||
fillStroke(ctx, '#3b3f47', OUTLINE, 2);
|
||||
ctx.beginPath(); ctx.ellipse(0, 0, 18, 11.5, 0, 0, Math.PI * 2);
|
||||
fillStroke(ctx, BODY_DARK, OUTLINE, 1.5);
|
||||
// hull
|
||||
roundRect(ctx, -13, -8, 26, 16, 5); fillStroke(ctx, BODY);
|
||||
// cab
|
||||
ctx.fillStyle = GLASS;
|
||||
roundRect(ctx, 3, -5, 7, 10, 2); ctx.fill();
|
||||
// nanolathe arm, same language as the tracked constructor
|
||||
ctx.strokeStyle = ACCENT; ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(8, 0); ctx.lineTo(19, 0); ctx.stroke();
|
||||
ctx.fillStyle = HOT;
|
||||
ctx.beginPath(); ctx.arc(20, 0, 2.5, 0, Math.PI * 2); ctx.fill();
|
||||
// lift fans
|
||||
ctx.fillStyle = '#4a4e56';
|
||||
for (const fx of [-8, 8]) {
|
||||
ctx.beginPath(); ctx.arc(fx, 0, 3, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
};
|
||||
|
||||
function tankHull(ctx, S, len, wid) {
|
||||
|
|
@ -444,6 +531,42 @@ const STRUCT_SHAPES = {
|
|||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
|
||||
// An apron rather than a shed: the runway IS the building, so it reads as air production
|
||||
// from the minimap without needing a door like the ground factories have.
|
||||
airfield(ctx, S) {
|
||||
const p = S * 0.06, w = S - p * 2, h = S - p * 2;
|
||||
roundRect(ctx, p, p, w, h, S * 0.04); fillStroke(ctx, BODY_DARK, OUTLINE, 3);
|
||||
// runway strip down the middle, pointing at the spawn edge (bottom)
|
||||
const rw = w * 0.34, rx = p + w / 2 - rw / 2;
|
||||
ctx.fillStyle = '#2f3238';
|
||||
ctx.fillRect(rx, p + h * 0.10, rw, h * 0.90);
|
||||
// centreline dashes
|
||||
ctx.strokeStyle = '#d8dde4'; ctx.lineWidth = 2.5;
|
||||
ctx.setLineDash([h * 0.07, h * 0.05]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p + w / 2, p + h * 0.14); ctx.lineTo(p + w / 2, p + h * 0.96);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
// hangars flanking the strip
|
||||
ctx.fillStyle = BODY;
|
||||
for (const hx of [p + w * 0.06, p + w * 0.72]) {
|
||||
roundRect(ctx, hx, p + h * 0.22, w * 0.22, h * 0.34, S * 0.03);
|
||||
fillStroke(ctx, BODY, OUTLINE, 2);
|
||||
}
|
||||
// control tower, one corner
|
||||
ctx.beginPath(); ctx.arc(p + w * 0.17, p + h * 0.78, S * 0.07, 0, Math.PI * 2);
|
||||
fillStroke(ctx, BODY, OUTLINE, 2);
|
||||
ctx.fillStyle = GLASS;
|
||||
ctx.beginPath(); ctx.arc(p + w * 0.17, p + h * 0.78, S * 0.04, 0, Math.PI * 2); ctx.fill();
|
||||
// approach lights along the threshold
|
||||
ctx.fillStyle = HOT;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(p + w * (0.36 + i * 0.14), p + h * 0.06, S * 0.022, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ const BAR_H = 62; // top resource strip
|
|||
const BOT_H = 190; // bottom command bar
|
||||
const MINI = 176; // minimap edge length
|
||||
const BTN_W = 88, BTN_H = 74; // build-menu button size
|
||||
const 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
|
||||
// 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);
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -161,9 +167,9 @@ export default class TAHud {
|
|||
const s = this.scene;
|
||||
const army = this.state.armies[this.playerArmy];
|
||||
options.forEach((def, i) => {
|
||||
const col = i % 6, row = Math.floor(i / 6);
|
||||
const col = i % GRID_COLS, row = Math.floor(i / GRID_COLS);
|
||||
// The container is placed at the button's CENTRE, not its top-left — see below.
|
||||
const x = this.gridX + col * 96 + BTN_W / 2;
|
||||
const x = this.gridX + col * GRID_PITCH + BTN_W / 2;
|
||||
const y = this.gridY + row * 82 + BTN_H / 2;
|
||||
const affordable = canAfford(this.state, this.playerArmy, def);
|
||||
const c = s.add.container(x, y);
|
||||
|
|
@ -199,8 +205,13 @@ export default class TAHud {
|
|||
const lines = [`${def.cost.mass} mass ${def.cost.energy} energy ${def.buildTime}s`];
|
||||
if (def.hp) lines.push(`HP ${def.hp}`);
|
||||
if (def.speed) lines.push(`Speed ${def.speed} Sight ${def.sight}`);
|
||||
// Which layer a unit lives in decides whether the player's current army can even shoot
|
||||
// it, so it belongs in the tooltip above the individual weapon lines.
|
||||
if (def.isAir) lines.push('AIRCRAFT — only anti-air weapons can hit it');
|
||||
else if (def.moveClass === 'hover') lines.push('HOVER — crosses water');
|
||||
for (const w of def.weaponDefs ?? []) {
|
||||
lines.push(`${w.name}: ${w.damage} dmg / ${w.reload}s / ${w.range} range${w.manual ? ' (manual)' : ''}`);
|
||||
const dom = w.targetsAir ? (w.targetsGround ? 'ground+air' : 'air only') : 'ground only';
|
||||
lines.push(`${w.name}: ${w.damage} dmg / ${w.reload}s / ${w.range} range / ${dom}${w.manual ? ' (manual)' : ''}`);
|
||||
}
|
||||
if (def.makes) {
|
||||
const m = [];
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
nearestUsableTile, tileIndex, worldToTileX, worldToTileY, tileCenterX, tileCenterY,
|
||||
SpatialHash, segmentClear,
|
||||
} from './TANav.js';
|
||||
import { canEngage, weaponHitsDomain } from './TARules.js';
|
||||
|
||||
export const SAVE_VERSION = 1;
|
||||
|
||||
|
|
@ -142,6 +143,9 @@ 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.
|
||||
isAir: !!def.isAir,
|
||||
x: 0, y: 0, px: 0, py: 0,
|
||||
heading: 0, pheading: 0,
|
||||
turretRot: 0, pturretRot: 0,
|
||||
|
|
@ -647,14 +651,18 @@ function finishFactoryJob(state, rules, f) {
|
|||
const ts = state.tileSize;
|
||||
let sx = f.x + off.x * ts;
|
||||
let sy = f.y + off.y * ts;
|
||||
// Nudge out of the footprint if the bay mouth is somehow blocked.
|
||||
// Nudge out of the footprint if the bay mouth is somehow blocked. Aircraft skip this: they
|
||||
// ignore the nav grid, and snapping one to the nearest "usable" tile would only shove it
|
||||
// sideways off a perfectly good apron.
|
||||
const nav = state.nav;
|
||||
const udef = rules.unitById[item.defId];
|
||||
const need = clearanceFor(udef.radius, ts);
|
||||
const mc = udef.moveClass;
|
||||
let tx = worldToTileX(nav, sx), ty = worldToTileY(nav, sy);
|
||||
const idx = nearestUsableTile(nav, mc, need, tx, ty, 6);
|
||||
if (idx >= 0) { sx = tileCenterX(nav, idx % nav.w); sy = tileCenterY(nav, (idx / nav.w) | 0); }
|
||||
if (!udef.isAir) {
|
||||
const need = clearanceFor(udef.radius, ts);
|
||||
const mc = udef.moveClass;
|
||||
const tx = worldToTileX(nav, sx), ty = worldToTileY(nav, sy);
|
||||
const idx = nearestUsableTile(nav, mc, need, tx, ty, 6);
|
||||
if (idx >= 0) { sx = tileCenterX(nav, idx % nav.w); sy = tileCenterY(nav, (idx / nav.w) | 0); }
|
||||
}
|
||||
|
||||
const u = spawnUnit(state, rules, f.army, item.defId, sx, sy, Math.atan2(off.y, off.x));
|
||||
f.jobProgress = 0;
|
||||
|
|
@ -679,6 +687,17 @@ function requestPath(state, e, x, y) {
|
|||
if (!state.pathQueue.includes(e.id)) state.pathQueue.push(e.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Head for a point. Ground units get an A* path; aircraft simply record the destination and
|
||||
* fly the straight line to it — no path, no queue, no clearance test. Routing air through the
|
||||
* pathfinder would be both wasted A* budget and wrong, since the grid's whole job is to model
|
||||
* obstacles an aircraft doesn't have.
|
||||
*/
|
||||
function seekTo(state, e, x, y) {
|
||||
if (e.isAir) { e.destX = x; e.destY = y; return; }
|
||||
requestPath(state, e, x, y);
|
||||
}
|
||||
|
||||
function servicePathQueue(state, rules) {
|
||||
let budget = rules.constants.pathBudgetPerTick;
|
||||
while (budget-- > 0 && state.pathQueue.length) {
|
||||
|
|
@ -825,7 +844,7 @@ function stepOrders(state, rules) {
|
|||
}
|
||||
}
|
||||
const dest = orderDestination(e, order);
|
||||
if (!e.path && !e.wantPath && !e.noPath) requestPath(state, e, dest.x, dest.y);
|
||||
if (!e.path && !e.wantPath && !e.noPath) seekTo(state, e, dest.x, dest.y);
|
||||
e.movingTo = dest;
|
||||
const d = Math.hypot(dest.x - e.x, dest.y - e.y);
|
||||
const slack = Math.max(rules.constants.arriveSlackPx, e.radius * 1.2);
|
||||
|
|
@ -833,7 +852,7 @@ function stepOrders(state, rules) {
|
|||
if (order.type === 'patrol') {
|
||||
order.leg = order.leg === 0 ? 1 : 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]));
|
||||
} else {
|
||||
e.orders.shift();
|
||||
|
|
@ -846,13 +865,17 @@ function stepOrders(state, rules) {
|
|||
case 'attack': {
|
||||
const target = entityById(state, order.targetId);
|
||||
if (!target) { e.orders.shift(); e.targetId = 0; e.path = null; break; }
|
||||
// Drop an attack order on something this unit's guns can never reach — a ground-only
|
||||
// weapon chasing a Fighter would follow it off the map without ever firing a shot.
|
||||
if (!canEngage(def, target)) { e.orders.shift(); e.targetId = 0; e.path = null; break; }
|
||||
e.targetId = target.id;
|
||||
const d = surfaceDist(rules, e, target);
|
||||
const wantRange = def.maxRange * (rules.constants.engageHoldFraction ?? 0.85);
|
||||
if (d > wantRange) {
|
||||
if (!e.path && !e.wantPath) requestPath(state, e, target.x, target.y);
|
||||
if (!e.path && !e.wantPath) seekTo(state, e, target.x, target.y);
|
||||
e.movingTo = { x: target.x, y: target.y };
|
||||
// Re-path when the target has drifted well away from where we aimed.
|
||||
// Re-path when the target has drifted well away from where we aimed. Aircraft have
|
||||
// no path to throw away — seekTo above already retargets them every tick.
|
||||
if (e.path && Math.hypot(target.x - e.destX, target.y - e.destY) > ts * 3) {
|
||||
e.path = null; requestPath(state, e, target.x, target.y);
|
||||
}
|
||||
|
|
@ -868,7 +891,7 @@ function stepOrders(state, rules) {
|
|||
if (!target) { e.orders.shift(); break; }
|
||||
const d = Math.hypot(target.x - e.x, target.y - e.y);
|
||||
if (d > ts * 3) {
|
||||
if (!e.path && !e.wantPath) requestPath(state, e, target.x, target.y);
|
||||
if (!e.path && !e.wantPath) seekTo(state, e, target.x, 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) {
|
||||
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 d = Math.hypot(target.x - e.x, target.y - e.y);
|
||||
if (d > reach * 0.9) {
|
||||
if (!e.path && !e.wantPath && !e.noPath) requestPath(state, e, target.x, target.y);
|
||||
if (!e.path && !e.wantPath && !e.noPath) seekTo(state, e, target.x, target.y);
|
||||
e.movingTo = { x: target.x, y: target.y };
|
||||
e.buildTargetId = 0;
|
||||
} else {
|
||||
|
|
@ -923,7 +946,10 @@ function stepMovement(state, rules) {
|
|||
const def = defOf(rules, e);
|
||||
let tx = null, ty = null;
|
||||
|
||||
if (e.path && e.path.length >= 2) {
|
||||
if (e.isAir) {
|
||||
// Aircraft fly the straight line. No waypoints to consume, nothing to route around.
|
||||
if (e.movingTo) { tx = e.movingTo.x; ty = e.movingTo.y; }
|
||||
} else if (e.path && e.path.length >= 2) {
|
||||
// Consume waypoints we've reached.
|
||||
while (e.pathIdx * 2 + 1 < e.path.length) {
|
||||
const wx = e.path[e.pathIdx * 2], wy = e.path[e.pathIdx * 2 + 1];
|
||||
|
|
@ -950,10 +976,14 @@ function stepMovement(state, rules) {
|
|||
const want = Math.atan2(dy, dx);
|
||||
e.heading = turnToward(e.heading, want, def.turnRate * dt);
|
||||
|
||||
// Terrain slows movement: speed scales by the inverse of the tile's move cost.
|
||||
const ti = worldToTileY(nav, e.y) * state.w + worldToTileX(nav, e.x);
|
||||
const tcost = rules.moveClasses[def.moveClass].costByTerrainIndex[state.terrain[ti]] ?? 1;
|
||||
const terrainMul = tcost ? 1 / tcost : 1;
|
||||
// Terrain slows movement: speed scales by the inverse of the tile's move cost. Aircraft
|
||||
// are over it, not on it, so they fly at a flat speed everywhere.
|
||||
let terrainMul = 1;
|
||||
if (!e.isAir) {
|
||||
const ti = worldToTileY(nav, e.y) * state.w + worldToTileX(nav, e.x);
|
||||
const tcost = rules.moveClasses[def.moveClass].costByTerrainIndex[state.terrain[ti]] ?? 1;
|
||||
terrainMul = tcost ? 1 / tcost : 1;
|
||||
}
|
||||
|
||||
// Slow down while still swinging round, and ease into the final waypoint.
|
||||
const misalign = Math.abs(angleDelta(e.heading, want));
|
||||
|
|
@ -1050,6 +1080,9 @@ function stepSeparation(state, rules) {
|
|||
hash.query(a.x, a.y, a.radius * 2 + 32, near);
|
||||
for (const b of near) {
|
||||
if (b.id <= a.id) continue; // handle each pair once, deterministically
|
||||
// Different layers never touch. Aircraft still go into the hash so gunners can find
|
||||
// them, but a Fighter overflying a tank column must not shove it apart.
|
||||
if (a.isAir !== b.isAir) continue;
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const minD = a.radius + b.radius;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
|
|
@ -1086,6 +1119,13 @@ function stepSeparation(state, rules) {
|
|||
const ts = state.tileSize;
|
||||
for (const e of movers) {
|
||||
const def = defOf(rules, e);
|
||||
// Aircraft are only bounded by the map edge — nothing on the grid blocks them, and the
|
||||
// clamp below would otherwise drag one off a cliff or a building it was legitimately over.
|
||||
if (e.isAir) {
|
||||
e.x = Math.max(e.radius, Math.min(state.worldW - e.radius, e.x));
|
||||
e.y = Math.max(e.radius, Math.min(state.worldH - e.radius, e.y));
|
||||
continue;
|
||||
}
|
||||
const need = clearanceFor(e.radius, ts);
|
||||
const cl = nav.clearance[def.moveClass];
|
||||
const tx = worldToTileX(nav, e.x), ty = worldToTileY(nav, e.y);
|
||||
|
|
@ -1166,6 +1206,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;
|
||||
const dist = surface;
|
||||
if (dist > w.range || dist < (w.minRange ?? 0)) continue;
|
||||
if (!aligned) continue;
|
||||
|
|
@ -1185,6 +1226,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 (e.burstLeft[wi] > 0 && e.reload[wi] === 0) {
|
||||
fireWeapon(state, rules, e, w, target, facing, aimPt);
|
||||
e.burstLeft[wi]--;
|
||||
|
|
@ -1225,6 +1267,7 @@ export function aimPointOn(rules, from, target) {
|
|||
|
||||
function inWeaponRange(rules, e, def, target) {
|
||||
if (target.dead) return false;
|
||||
if (!canEngage(def, target)) return false;
|
||||
return surfaceDist(rules, e, target) <= def.maxRange;
|
||||
}
|
||||
|
||||
|
|
@ -1240,6 +1283,9 @@ function acquireTarget(state, rules, e, def, hash, near) {
|
|||
if (d > def.maxRange) return;
|
||||
let score = -Infinity;
|
||||
for (const w of def.weaponDefs) {
|
||||
// A weapon that can't reach the target's domain contributes nothing, so a unit with no
|
||||
// AA at all never scores an aircraft and simply ignores it.
|
||||
if (!weaponHitsDomain(w, t.isAir)) continue;
|
||||
if (d > w.range || d < (w.minRange ?? 0)) continue;
|
||||
const mul = w.armorMul?.[rules.defById[t.defId].armorClass] ?? 1;
|
||||
const s = (w.damage * mul) / (1 + d / w.range);
|
||||
|
|
@ -1360,11 +1406,14 @@ function stepProjectiles(state, rules) {
|
|||
p.x = p.px + (p.x - p.px) * s; p.y = p.py + (p.y - p.py) * s;
|
||||
}
|
||||
}
|
||||
// Direct contact with any hostile it passes through.
|
||||
// Direct contact with any hostile it passes through — in a domain this weapon can hit.
|
||||
// Without the domain test a bomb would detonate on the first aircraft whose airspace it
|
||||
// crossed, and an AA burst would stop dead on the tank underneath its target.
|
||||
if (!detonate) {
|
||||
let bestT = Infinity, hit = null;
|
||||
for (const e of state.entities) {
|
||||
if (e.dead || e.site || e.army === p.army) continue;
|
||||
if (!weaponHitsDomain(w, e.isAir)) continue;
|
||||
if (segDistSq(p.px, p.py, p.x, p.y, e.x, e.y) > e.radius * e.radius) continue;
|
||||
const s = segClosestT(p.px, p.py, p.x, p.y, e.x, e.y);
|
||||
if (s < bestT) { bestT = s; hit = e; }
|
||||
|
|
@ -1381,6 +1430,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 (Math.hypot(e.x - p.x, e.y - p.y) <= e.radius) {
|
||||
applyDamage(state, rules, e, w.damage * (w.armorMul?.[rules.defById[e.defId].armorClass] ?? 1), { army: p.army, id: p.ownerId });
|
||||
break;
|
||||
|
|
@ -1416,6 +1466,8 @@ function applyAoe(state, rules, x, y, w, source) {
|
|||
for (const e of state.entities) {
|
||||
if (e.dead) continue;
|
||||
if (e.army === source.army && !rules.constants.friendlyFire) continue;
|
||||
// Splash respects the domain too — a bomb's blast radius stays on the ground.
|
||||
if (!weaponHitsDomain(w, e.isAir)) continue;
|
||||
// Falloff is measured to the SURFACE, so a shell that lands squarely on a building's
|
||||
// face counts as a direct hit wherever along that face it strikes. Measuring from the
|
||||
// centre circle instead scored an off-axis wall hit as 32px away and cut its damage by
|
||||
|
|
@ -1481,6 +1533,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
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -228,10 +228,17 @@ export function generateMap(rules, opts = {}) {
|
|||
return map;
|
||||
}
|
||||
|
||||
/** The move class with the most restrictive terrain table — if it can get through, all can. */
|
||||
/**
|
||||
* The move class with the most restrictive terrain table — if it can get through, all can.
|
||||
*
|
||||
* Air classes are excluded outright. They pass everything by definition, so the corridor this
|
||||
* picks for them would be no corridor at all, and the connectivity guarantee — the whole point
|
||||
* of the call — would silently become vacuous for every ground unit on the map.
|
||||
*/
|
||||
function pickWidestMoveClass(rules) {
|
||||
let worst = null, worstScore = Infinity;
|
||||
for (const [mc, spec] of Object.entries(rules.moveClasses)) {
|
||||
if (spec.air) continue;
|
||||
const score = spec.costByTerrainIndex.reduce((s, c) => s + (c == null ? 0 : 1 / c), 0);
|
||||
if (score < worstScore) { worstScore = score; worst = mc; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ export const FX_STYLES = new Set(['tracer', 'beam', 'shell', 'rocket', 'dgun']);
|
|||
export const UNIT_ROLES = new Set(['builder', 'combat', 'scout', 'artillery']);
|
||||
export const SHEET_SLOTS = new Set(['unitSheet', 'structureSheet']);
|
||||
export const TARGET_DOMAINS = new Set(['ground', 'air']);
|
||||
// Which layer a unit occupies. `air` units ignore the nav grid entirely, never collide with
|
||||
// ground units, and can only be shot by weapons that list `air` in their `targets`.
|
||||
export const UNIT_DOMAINS = new Set(['ground', 'air']);
|
||||
|
||||
// A unit/building must always be able to see a bit past its own guns, or it ends up firing
|
||||
// into fog it hasn't revealed. Applied as a floor over whatever `sight` the def declares.
|
||||
|
|
@ -21,8 +24,9 @@ const SIGHT_RANGE_MARGIN_TILES = 2;
|
|||
// this set is duplicated there and the verify script asserts the two agree.
|
||||
export const PROC_SHAPES = new Set([
|
||||
'commander', 'infantry', 'sniper', 'rocketTrooper', 'jeep', 'tank', 'rockettank', 'constructor',
|
||||
'fighter', 'bomber', 'hoverConstructor',
|
||||
'energyGen', 'massGen', 'barracks', 'vehiclePlant', 'laserTower', 'missileLauncher',
|
||||
'advancedVehiclePlant',
|
||||
'advancedVehiclePlant', 'airfield',
|
||||
]);
|
||||
|
||||
function fail(msg) {
|
||||
|
|
@ -118,7 +122,14 @@ export function compileRules(json) {
|
|||
if (!terrainById[key]) fail(`moveClass "${mc}" references unknown terrain "${key}"`);
|
||||
}
|
||||
// Flat per-terrain-index cost array — the pathfinder's hot path reads this.
|
||||
spec.costByTerrainIndex = terrain.map((t) => (t.blocksMove ? null : spec.cost[t.id]));
|
||||
//
|
||||
// The COST TABLE is authoritative, not `terrain.blocksMove`: a null cost is impassable and
|
||||
// a positive one is passable, whatever the terrain says. `blocksMove` describes the ordinary
|
||||
// ground unit, and every one of those declares null for cliff and water anyway — but a hover
|
||||
// class that crosses water, or an air class that crosses everything, is then a JSON-only
|
||||
// change instead of needing a per-terrain exception flag.
|
||||
spec.costByTerrainIndex = terrain.map((t) => spec.cost[t.id] ?? null);
|
||||
spec.air = spec.air === true;
|
||||
}
|
||||
const moveClassSet = new Set(Object.keys(moveClasses));
|
||||
|
||||
|
|
@ -178,6 +189,8 @@ export function compileRules(json) {
|
|||
w.rangeSq = w.range * w.range;
|
||||
w.minRangeSq = (w.minRange ?? 0) * (w.minRange ?? 0);
|
||||
w.targetsAir = (w.targets ?? ['ground']).includes('air');
|
||||
w.targetsGround = (w.targets ?? ['ground']).includes('ground');
|
||||
if (!w.targetsAir && !w.targetsGround) fail(`weapon "${w.id}" targets neither ground nor air`);
|
||||
// A manual weapon never auto-fires: it needs an explicit attack order on the victim.
|
||||
// The D-Gun is the reason this flag exists — left on auto-fire, a defending Commander
|
||||
// deletes one attacker every reload for free, which makes assaulting any base suicide.
|
||||
|
|
@ -194,6 +207,15 @@ export function compileRules(json) {
|
|||
if (!sizeSet.has(u.size)) fail(`unit "${u.id}" has unknown size "${u.size}"`);
|
||||
if (!armorSet.has(u.armorClass)) fail(`unit "${u.id}" has unknown armorClass "${u.armorClass}"`);
|
||||
if (!moveClassSet.has(u.moveClass)) fail(`unit "${u.id}" has unknown moveClass "${u.moveClass}"`);
|
||||
u.domain = u.domain ?? 'ground';
|
||||
if (!UNIT_DOMAINS.has(u.domain)) fail(`unit "${u.id}" has unknown domain "${u.domain}"`);
|
||||
u.isAir = u.domain === 'air';
|
||||
// The domain and the move class have to agree: the sim reads `isAir` in its hot loops but
|
||||
// spawning and the terrain-speed lookup still go through the move class, and a unit that
|
||||
// flew while pathing on treads would be wrong in whichever of the two you didn't check.
|
||||
if (u.isAir !== moveClasses[u.moveClass].air) {
|
||||
fail(`unit "${u.id}" is domain "${u.domain}" but its moveClass "${u.moveClass}" is not`);
|
||||
}
|
||||
if (u.role && !UNIT_ROLES.has(u.role)) fail(`unit "${u.id}" has unknown role "${u.role}"`);
|
||||
if (!SHEET_SLOTS.has(u.sheetSlot)) fail(`unit "${u.id}" has unknown sheetSlot "${u.sheetSlot}"`);
|
||||
if (!PROC_SHAPES.has(u.procShape)) fail(`unit "${u.id}" has unknown procShape "${u.procShape}"`);
|
||||
|
|
@ -232,6 +254,10 @@ export function compileRules(json) {
|
|||
}
|
||||
const unitById = indexById(units);
|
||||
const commanderUnits = units.filter((u) => u.isCommander);
|
||||
// An air roster with nothing able to shoot at it isn't a unit type, it's an auto-win.
|
||||
if (units.some((u) => u.isAir) && !weapons.some((w) => w.targetsAir)) {
|
||||
fail('air units exist but no weapon targets the air domain');
|
||||
}
|
||||
|
||||
const buildings = json.buildings ?? [];
|
||||
requireUnique(buildings, 'building');
|
||||
|
|
@ -251,6 +277,7 @@ export function compileRules(json) {
|
|||
if (!weaponById[wid]) fail(`building "${b.id}" references unknown weapon "${wid}"`);
|
||||
}
|
||||
b.isBuilding = true;
|
||||
b.isAir = false;
|
||||
b.radius = (Math.max(b.footprint.w, b.footprint.h) * c.tileSize) / 2;
|
||||
// True half-extents. `radius` is a circle around the centre, which understates a
|
||||
// building's reach along its diagonals and is what made units drive into the walls of
|
||||
|
|
@ -358,6 +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`. */
|
||||
export function armorMul(weapon, armorClass) {
|
||||
return weapon.armorMul?.[armorClass] ?? 1;
|
||||
|
|
|
|||
|
|
@ -27,10 +27,15 @@ const ORDER_COLORS = {
|
|||
};
|
||||
|
||||
export const DEPTHS = {
|
||||
terrain: 0, decal: 5, fxUnder: 8, ghost: 12,
|
||||
selection: 15, actor: 20, bars: 55, projectile: 60, fxOver: 65, fog: 80,
|
||||
terrain: 0, decal: 5, shadow: 6, fxUnder: 8, ghost: 12,
|
||||
selection: 15, actor: 20, air: 40, bars: 55, projectile: 60, fxOver: 65, fog: 80,
|
||||
};
|
||||
|
||||
// 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 {
|
||||
constructor(scene, rules, art, state, playerArmy) {
|
||||
this.scene = scene;
|
||||
|
|
@ -349,7 +354,16 @@ export default class TAWorldView {
|
|||
turret.setScale((def.spritePx ?? def.radius * 2) / frameSize.w);
|
||||
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);
|
||||
return s;
|
||||
}
|
||||
|
|
@ -360,6 +374,7 @@ export default class TAWorldView {
|
|||
s.img.destroy();
|
||||
s.turret?.destroy();
|
||||
s.final?.destroy();
|
||||
s.shadow?.destroy();
|
||||
this.sprites.delete(id);
|
||||
}
|
||||
|
||||
|
|
@ -390,6 +405,7 @@ export default class TAWorldView {
|
|||
s.img.setVisible(shown);
|
||||
if (s.turret) s.turret.setVisible(shown);
|
||||
if (s.final) s.final.setVisible(shown);
|
||||
if (s.shadow) s.shadow.setVisible(shown);
|
||||
if (!shown) continue;
|
||||
|
||||
const x = e.px + (e.x - e.px) * alpha;
|
||||
|
|
@ -398,8 +414,13 @@ export default class TAWorldView {
|
|||
|
||||
s.img.setPosition(x, y);
|
||||
if (!def.isBuilding) s.img.setRotation(heading);
|
||||
// Y-sorted actor band; buildings sit just under units sharing a row.
|
||||
s.img.setDepth(DEPTHS.actor + (y / state.worldH) * 10 + (def.isBuilding ? 0 : 0.05));
|
||||
// Y-sorted actor band; buildings sit just under units sharing a row. Aircraft ride in
|
||||
// their own band above ALL of it — a Fighter must never disappear behind a factory.
|
||||
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%
|
||||
// opacity; once work starts the wireframe fades 100%->0% over construction while
|
||||
|
|
|
|||
|
|
@ -268,6 +268,8 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
|
|||
// Play the nuclear explosion at full volume for maximum impact
|
||||
this._throttledSfx('sfx-ta-nuclear', 300, 1);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,19 @@ let the scale do the work.
|
|||
| 9 | Rocket Tank turret | rocket pod | 56 |
|
||||
| 10 | Rocket Trooper | whole figure, shoulder-mounted launch tube | 30 |
|
||||
| 11 | Construction Vehicle | tracked hull, folded nanolathe crane, no gun | 50 |
|
||||
| 12 | Fighter | plan-view airframe, swept wings, no tracks or wheels | 46 |
|
||||
| 13 | Bomber | plan-view airframe, long straight wing, wing-mounted engines | 60 |
|
||||
| 14 | Hover Constructor | skirted hull over a plenum, nanolathe crane, no tracks | 52 |
|
||||
|
||||
**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
|
||||
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
|
||||
sits at (32, 32), with the barrel extending to the **right**.
|
||||
- Leave the rest of the turret cell transparent. The hull shows through it.
|
||||
- A unit with no turret (infantry, sniper, rocket trooper, construction vehicle) simply has
|
||||
no turret frame; its whole sprite rotates to face its target. The Construction Vehicle is
|
||||
unarmed, so it never turns to aim regardless — it just rotates to face wherever it's moving.
|
||||
- A unit with no turret (infantry, sniper, rocket trooper, construction vehicle, and all three
|
||||
Airfield units) simply has no turret frame; its whole sprite rotates to face its target. The
|
||||
Construction Vehicle and Hover Constructor are unarmed, so they never turn to aim regardless —
|
||||
they just rotate to face wherever they're moving. Aircraft have fixed forward guns, so they
|
||||
aim by pointing the whole airframe, which is why they have no turret either.
|
||||
|
||||
Frames 12–63 are free. Add a unit by appending a definition to `units[]` in
|
||||
Frames 15–63 are free. Add a unit by appending a definition to `units[]` in
|
||||
`data/totalannihilation-rules.json` with its `frame` (and optional `turretFrame`) — no code.
|
||||
|
||||
---
|
||||
|
|
@ -104,12 +119,14 @@ build completes; the finished frame crossfades in underneath starting at 20% pro
|
|||
| 11 | Missile Launcher — under construction | 2×2 | |
|
||||
| 12 | Advanced Vehicle Plant | 4×4 | wider bay, **two** doors side by side |
|
||||
| 13 | Advanced Vehicle Plant — under construction | 4×4 | |
|
||||
| 14 | Airfield | 3×3 | runway/apron, not a shed — strip runs to the **bottom** edge |
|
||||
| 15 | Airfield — under construction | 3×3 | |
|
||||
|
||||
Defensive structures never rotate, and their mount traverses freely in the simulation — they
|
||||
shoot in every direction regardless of how the art is drawn. Draw them facing **up** and do not
|
||||
imply a firing arc.
|
||||
|
||||
Frames 14–35 are free.
|
||||
Frames 16–35 are free.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -151,9 +168,21 @@ The theme then appears in the skirmish setup screen automatically.
|
|||
|
||||
## Icons (44×44 cells, 10 per row)
|
||||
|
||||
Command glyphs for the HUD: move, attack, attack-move, stop, hold, patrol, guard, build,
|
||||
repair, rally. Purely decorative today — the build menu labels itself from unit names — so
|
||||
this sheet is the lowest priority of the eight.
|
||||
Build-menu and command glyphs. Frame numbers come from each def's `icon` in the rules JSON
|
||||
and from `commandIcons`, so this table is a reflection of that file, not a second source of
|
||||
truth. Purely decorative today — the build menu labels itself from unit names — so this sheet
|
||||
is the lowest priority of the eight.
|
||||
|
||||
| Frames | Contents |
|
||||
|---|---|
|
||||
| 0–9 | Units: commander, infantry, sniper, jeep, tank, rocket tank, rocket trooper, construction vehicle, fighter, bomber |
|
||||
| 10–16 | Buildings: energy gen, mass gen, barracks, vehicle plant, laser tower, missile launcher, advanced vehicle plant |
|
||||
| 17–18 | Overflow: hover constructor (unit), airfield (building) |
|
||||
| 20–27 | 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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -336,6 +336,19 @@ section('2d. Container hitbox lint');
|
|||
check(`${rel.split('/').pop()} has no top-left-origin child in an interactive container`,
|
||||
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');
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||