diff --git a/assets/images/ta/arm-structures.png b/assets/images/ta/arm-structures.png index 2aba0e3..c5958d2 100644 Binary files a/assets/images/ta/arm-structures.png and b/assets/images/ta/arm-structures.png differ diff --git a/assets/images/ta/arm-structures.psd b/assets/images/ta/arm-structures.psd index 2b30ec6..3cd5524 100644 Binary files a/assets/images/ta/arm-structures.psd and b/assets/images/ta/arm-structures.psd differ diff --git a/assets/images/ta/arm-units.png b/assets/images/ta/arm-units.png index 3783add..5e512e9 100644 Binary files a/assets/images/ta/arm-units.png and b/assets/images/ta/arm-units.png differ diff --git a/assets/images/ta/arm-units.psd b/assets/images/ta/arm-units.psd index dc75469..4273887 100644 Binary files a/assets/images/ta/arm-units.psd and b/assets/images/ta/arm-units.psd differ diff --git a/assets/images/ta/core-structures.png b/assets/images/ta/core-structures.png index 034fc34..05e5166 100644 Binary files a/assets/images/ta/core-structures.png and b/assets/images/ta/core-structures.png differ diff --git a/assets/images/ta/core-structures.psd b/assets/images/ta/core-structures.psd index 3cce1e0..233eedf 100644 Binary files a/assets/images/ta/core-structures.psd and b/assets/images/ta/core-structures.psd differ diff --git a/assets/images/ta/core-units.png b/assets/images/ta/core-units.png index f068234..1d49b75 100644 Binary files a/assets/images/ta/core-units.png and b/assets/images/ta/core-units.png differ diff --git a/assets/images/ta/core-units.psd b/assets/images/ta/core-units.psd index 2e8acc1..ed241f1 100644 Binary files a/assets/images/ta/core-units.psd and b/assets/images/ta/core-units.psd differ diff --git a/data/totalannihilation-rules.json b/data/totalannihilation-rules.json index 6465b3e..910609d 100644 --- a/data/totalannihilation-rules.json +++ b/data/totalannihilation-rules.json @@ -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": { diff --git a/src/games/totalannihilation/TAAI.js b/src/games/totalannihilation/TAAI.js index 7f21125..60f5cbe 100644 --- a/src/games/totalannihilation/TAAI.js +++ b/src/games/totalannihilation/TAAI.js @@ -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; } diff --git a/src/games/totalannihilation/TAArt.js b/src/games/totalannihilation/TAArt.js index a800760..e92dd23 100644 --- a/src/games/totalannihilation/TAArt.js +++ b/src/games/totalannihilation/TAArt.js @@ -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(); + } + }, }; // --------------------------------------------------------------------------- diff --git a/src/games/totalannihilation/TAHud.js b/src/games/totalannihilation/TAHud.js index 8d04cdd..2f83720 100644 --- a/src/games/totalannihilation/TAHud.js +++ b/src/games/totalannihilation/TAHud.js @@ -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 = []; diff --git a/src/games/totalannihilation/TALogic.js b/src/games/totalannihilation/TALogic.js index b9eaee7..d9479f0 100644 --- a/src/games/totalannihilation/TALogic.js +++ b/src/games/totalannihilation/TALogic.js @@ -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; diff --git a/src/games/totalannihilation/TAMapGen.js b/src/games/totalannihilation/TAMapGen.js index 25d1766..f1fe7eb 100644 --- a/src/games/totalannihilation/TAMapGen.js +++ b/src/games/totalannihilation/TAMapGen.js @@ -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; } } diff --git a/src/games/totalannihilation/TARules.js b/src/games/totalannihilation/TARules.js index 10f6de8..ecd0263 100644 --- a/src/games/totalannihilation/TARules.js +++ b/src/games/totalannihilation/TARules.js @@ -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; diff --git a/src/games/totalannihilation/TAWorldView.js b/src/games/totalannihilation/TAWorldView.js index 24cba9e..4754e60 100644 --- a/src/games/totalannihilation/TAWorldView.js +++ b/src/games/totalannihilation/TAWorldView.js @@ -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 diff --git a/src/games/totalannihilation/TotalAnnihilationGame.js b/src/games/totalannihilation/TotalAnnihilationGame.js index a07ed07..82ac67b 100644 --- a/src/games/totalannihilation/TotalAnnihilationGame.js +++ b/src/games/totalannihilation/TotalAnnihilationGame.js @@ -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); } } diff --git a/src/games/totalannihilation/sprites.md b/src/games/totalannihilation/sprites.md index d22f8fb..8cc33c5 100644 --- a/src/games/totalannihilation/sprites.md +++ b/src/games/totalannihilation/sprites.md @@ -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. --- diff --git a/tools/verifyTotalAnnihilation.js b/tools/verifyTotalAnnihilation.js index b52c9ea..41f64d5 100644 --- a/tools/verifyTotalAnnihilation.js +++ b/tools/verifyTotalAnnihilation.js @@ -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'); // ---------------------------------------------------------------------------