diff --git a/assets/images/ta/arm-structures.png b/assets/images/ta/arm-structures.png index c5958d2..301b0c8 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 3cd5524..0666ad1 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/core-structures.png b/assets/images/ta/core-structures.png index 05e5166..bf4a8da 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 233eedf..f1dd286 100644 Binary files a/assets/images/ta/core-structures.psd and b/assets/images/ta/core-structures.psd differ diff --git a/data/totalannihilation-artwork.json b/data/totalannihilation-artwork.json index e38c2ff..4e674ea 100644 --- a/data/totalannihilation-artwork.json +++ b/data/totalannihilation-artwork.json @@ -32,35 +32,43 @@ "sheets": { "arm-units": { "key": "ta-arm-units", "path": "assets/images/ta/arm-units.png", "kind": "unit", - "frameWidth": 128, "frameHeight": 128, "cols": 8 + "frameWidth": 128, "frameHeight": 128, "cols": 8, + "rows": 8 }, "arm-structures": { "key": "ta-arm-structures", "path": "assets/images/ta/arm-structures.png", "kind": "structure", - "frameWidth": 192, "frameHeight": 192, "cols": 6 + "frameWidth": 192, "frameHeight": 192, "cols": 6, + "rows": 4 }, "core-units": { "key": "ta-core-units", "path": "assets/images/ta/core-units.png", "kind": "unit", - "frameWidth": 128, "frameHeight": 128, "cols": 8 + "frameWidth": 128, "frameHeight": 128, "cols": 8, + "rows": 8 }, "core-structures": { "key": "ta-core-structures", "path": "assets/images/ta/core-structures.png", "kind": "structure", - "frameWidth": 192, "frameHeight": 192, "cols": 6 + "frameWidth": 192, "frameHeight": 192, "cols": 6, + "rows": 4 }, "terrain-grasslands": { "key": "ta-terrain-grasslands", "path": "assets/images/ta/terrain-grasslands.png", "kind": "terrain", - "frameWidth": 128, "frameHeight": 128, "cols": 9 + "frameWidth": 128, "frameHeight": 128, "cols": 9, + "rows": 1 }, "terrain-snowfields": { "key": "ta-terrain-snowfields", "path": "assets/images/ta/terrain-snowfields.png", "kind": "terrain", - "frameWidth": 128, "frameHeight": 128, "cols": 9 + "frameWidth": 128, "frameHeight": 128, "cols": 9, + "rows": 1 }, "terrain-tropics": { "key": "ta-terrain-tropics", "path": "assets/images/ta/terrain-tropics.png", "kind": "terrain", - "frameWidth": 128, "frameHeight": 128, "cols": 9 + "frameWidth": 128, "frameHeight": 128, "cols": 9, + "rows": 1 }, "icons": { "key": "ta-icons", "path": null, "kind": "icon", - "frameWidth": 44, "frameHeight": 44, "cols": 10 + "frameWidth": 44, "frameHeight": 44, "cols": 10, + "rows": 4 } }, diff --git a/data/totalannihilation-rules.json b/data/totalannihilation-rules.json index b62c9a9..e3c5c7a 100644 --- a/data/totalannihilation-rules.json +++ b/data/totalannihilation-rules.json @@ -669,7 +669,8 @@ "vehicleplant", "lasertower", "missilelauncher", - "airfield" + "airfield", + "radar" ], "produce": { "energy": 25, @@ -907,7 +908,10 @@ "lasertower", "missilelauncher", "advancedvehicleplant", - "airfield" + "airfield", + "radar", + "advancedradar", + "radarjammer" ], "sheetSlot": "unitSheet", "frame": 11, @@ -1023,7 +1027,10 @@ "lasertower", "missilelauncher", "advancedvehicleplant", - "airfield" + "airfield", + "radar", + "advancedradar", + "radarjammer" ], "flight": { "height": 9, @@ -1289,6 +1296,81 @@ "buildFrame": 15, "procShape": "airfield", "icon": 18 + }, + { + "id": "radar", + "name": "Radar Tower", + "footprint": { + "w": 2, + "h": 2 + }, + "hp": 1200, + "armorClass": "structure", + "sight": 260, + "radarRange": 1240, + "cost": { + "energy": 600, + "mass": 120 + }, + "buildTime": 20, + "upkeep": { + "energy": 10 + }, + "sheetSlot": "structureSheet", + "frame": 16, + "buildFrame": 17, + "procShape": "radar", + "icon": 19 + }, + { + "id": "advancedradar", + "name": "Advanced Radar", + "footprint": { + "w": 2, + "h": 2 + }, + "hp": 1800, + "armorClass": "structure", + "sight": 1240, + "radarRange": 20000, + "cost": { + "energy": 2200, + "mass": 500 + }, + "buildTime": 45, + "upkeep": { + "energy": 40 + }, + "sheetSlot": "structureSheet", + "frame": 18, + "buildFrame": 19, + "procShape": "advancedRadar", + "icon": 30 + }, + { + "id": "radarjammer", + "name": "Radar Jammer", + "footprint": { + "w": 2, + "h": 2 + }, + "hp": 1400, + "armorClass": "structure", + "sight": 200, + "jamRange": 1240, + "cost": { + "energy": 1400, + "mass": 300 + }, + "buildTime": 35, + "upkeep": { + "energy": 30 + }, + "sheetSlot": "structureSheet", + "frame": 20, + "buildFrame": 21, + "procShape": "radarJammer", + "icon": 31 } ], "commandIcons": { diff --git a/src/games/totalannihilation/TAArt.js b/src/games/totalannihilation/TAArt.js index 1170885..6ddf759 100644 --- a/src/games/totalannihilation/TAArt.js +++ b/src/games/totalannihilation/TAArt.js @@ -318,6 +318,18 @@ const UNIT_SHAPES = { }, }; +/** Shared base for the three sensor structures: a low pad with corner footings, no armour. */ +function sensorPad(ctx, S) { + const p = S * 0.18, d = S - p * 2; + roundRect(ctx, p, p, d, d, S * 0.08); + fillStroke(ctx, BODY, OUTLINE, 3); + ctx.fillStyle = BODY_DARK; + for (const [ax, ay] of [[0, 0], [1, 0], [0, 1], [1, 1]]) { + roundRect(ctx, p + ax * (d - S * 0.11), p + ay * (d - S * 0.11), S * 0.11, S * 0.11, 2); + ctx.fill(); + } +} + function tankHull(ctx, S, len, wid) { const c = S / 2; ctx.save(); ctx.translate(c, c); @@ -532,6 +544,70 @@ const STRUCT_SHAPES = { } }, + // The three sensors share a language — a mast on a pad, with the dish on top saying which + // one it is. They never shoot, so none of them gets the squat armoured plinth the defences + // use; a player scanning a base should be able to tell "sensor" from "gun" instantly. + radar(ctx, S) { + sensorPad(ctx, S); + const c = S / 2; + // Dish: an open arc facing up-right, the classic scanner silhouette. + ctx.strokeStyle = ACCENT; ctx.lineWidth = S * 0.07; + ctx.beginPath(); ctx.arc(c, c - S * 0.06, S * 0.24, Math.PI * 0.85, Math.PI * 1.95); ctx.stroke(); + ctx.strokeStyle = OUTLINE; ctx.lineWidth = S * 0.025; + ctx.beginPath(); ctx.arc(c, c - S * 0.06, S * 0.24, Math.PI * 0.85, Math.PI * 1.95); ctx.stroke(); + // Feed horn on its boom. + ctx.strokeStyle = BODY_DARK; ctx.lineWidth = S * 0.03; + ctx.beginPath(); ctx.moveTo(c, c - S * 0.06); ctx.lineTo(c + S * 0.16, c - S * 0.20); ctx.stroke(); + ctx.fillStyle = HOT; + ctx.beginPath(); ctx.arc(c + S * 0.16, c - S * 0.20, S * 0.035, 0, Math.PI * 2); ctx.fill(); + }, + + // Same pad, but a tall lattice mast and a ring array — reads as "bigger, sees further". + advancedRadar(ctx, S) { + sensorPad(ctx, S); + const c = S / 2; + ctx.strokeStyle = BODY_DARK; ctx.lineWidth = S * 0.035; + ctx.beginPath(); ctx.moveTo(c, c + S * 0.24); ctx.lineTo(c, c - S * 0.30); ctx.stroke(); + // Lattice cross-bracing up the mast. + ctx.lineWidth = S * 0.015; + for (let i = 0; i < 4; i++) { + const y0 = c + S * (0.20 - i * 0.13), y1 = y0 - S * 0.13; + ctx.beginPath(); + ctx.moveTo(c - S * 0.05, y0); ctx.lineTo(c + S * 0.05, y1); + ctx.moveTo(c + S * 0.05, y0); ctx.lineTo(c - S * 0.05, y1); + ctx.stroke(); + } + // Two stacked rings at the head. + for (const [ry, rr] of [[-0.30, 0.20], [-0.16, 0.13]]) { + ctx.strokeStyle = ACCENT; ctx.lineWidth = S * 0.045; + ctx.beginPath(); ctx.ellipse(c, c + S * ry, S * rr, S * rr * 0.34, 0, 0, Math.PI * 2); ctx.stroke(); + ctx.strokeStyle = OUTLINE; ctx.lineWidth = S * 0.018; + ctx.beginPath(); ctx.ellipse(c, c + S * ry, S * rr, S * rr * 0.34, 0, 0, Math.PI * 2); ctx.stroke(); + } + ctx.fillStyle = HOT; + ctx.beginPath(); ctx.arc(c, c - S * 0.36, S * 0.04, 0, Math.PI * 2); ctx.fill(); + }, + + // No dish at all — a squat emitter throwing rings outward. It transmits rather than listens, + // and the outward rings are the read that separates it from the two receivers above. + radarJammer(ctx, S) { + sensorPad(ctx, S); + const c = S / 2; + roundRect(ctx, c - S * 0.11, c - S * 0.16, S * 0.22, S * 0.34, S * 0.05); + fillStroke(ctx, BODY_DARK, OUTLINE, 2.5); + ctx.fillStyle = '#2f3238'; + ctx.fillRect(c - S * 0.05, c - S * 0.22, S * 0.10, S * 0.10); + // Interference rings, drawn broken so they read as noise rather than as a scan. + ctx.strokeStyle = '#c08aff'; ctx.lineWidth = S * 0.028; + ctx.setLineDash([S * 0.06, S * 0.05]); + for (const rr of [0.20, 0.29, 0.38]) { + ctx.beginPath(); ctx.arc(c, c - S * 0.02, S * rr, 0, Math.PI * 2); ctx.stroke(); + } + ctx.setLineDash([]); + ctx.fillStyle = '#c08aff'; + ctx.beginPath(); ctx.arc(c, c - S * 0.24, S * 0.04, 0, Math.PI * 2); ctx.fill(); + }, + // 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) { diff --git a/src/games/totalannihilation/TAHud.js b/src/games/totalannihilation/TAHud.js index 841fcbf..648914e 100644 --- a/src/games/totalannihilation/TAHud.js +++ b/src/games/totalannihilation/TAHud.js @@ -10,7 +10,7 @@ import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; -import { canAfford, buildOptionsFor } from './TALogic.js'; +import { canAfford, buildOptionsFor, isDetectedBy } from './TALogic.js'; import { Tooltip } from '../../ui/Tooltip.js'; export const FONT = 'm6x11, "Julius Sans One"'; @@ -25,6 +25,7 @@ const BOT_H = 190; // bottom command bar const MINI = 176; // minimap edge length const BTN_W = 88, BTN_H = 74; // button size, shared by both grids const GRID_COLS = 8; // build-menu columns +const GRID_ROWS = 2; // ...and how many rows fit the bar const GRID_PITCH = 96; // column pitch (button + gutter) const ROW_PITCH = 82; // row pitch; two rows fit the bar, three do not const CMD_COLS = 5; // command grid is 5 wide x 2 deep @@ -276,7 +277,7 @@ export default class TAHud { 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 * GRID_PITCH + BTN_W / 2; - const y = this.gridY + row * 82 + BTN_H / 2; + const y = this.gridY + row * ROW_PITCH + BTN_H / 2; const affordable = canAfford(this.state, this.playerArmy, def); const c = s.add.container(x, y); // A Container's origin is hard-coded to 0.5 and cannot be changed, so setInteractive() @@ -398,9 +399,12 @@ export default class TAHud { // The Commander-death cascade leaves its victims in state while removing them from the // world, so the minimap has to honour it too or a wiped army lingers as a field of dots. if (this.view.vaporised.has(e.id)) continue; - if (e.army !== this.playerArmy && !this.view.visibleToPlayer(e)) continue; + const seen = e.army === this.playerArmy || this.view.visibleToPlayer(e); + // Radar contacts show as neutral green pips: the minimap is where a player watches for a + // push forming, and that is exactly what a radar net is for. + if (!seen && !isDetectedBy(st, this.playerArmy, e)) continue; const a = this.rules.armies[e.army]; - g.fillStyle(a ? a.colorInt : 0x888888, 1); + g.fillStyle(seen ? (a ? a.colorInt : 0x888888) : 0x6fe2b0, seen ? 1 : 0.8); const r = e.isBuilding ? 2.5 : 1.6; g.fillRect(this.miniX + e.x * sx - r, this.miniY + e.y * sy - r, r * 2, r * 2); } diff --git a/src/games/totalannihilation/TALogic.js b/src/games/totalannihilation/TALogic.js index 6b94404..f566302 100644 --- a/src/games/totalannihilation/TALogic.js +++ b/src/games/totalannihilation/TALogic.js @@ -98,6 +98,9 @@ export function createMatch(rules, opts) { alive: true, hadCommander: false, explored: new Uint8Array(visW * visH), visible: new Uint8Array(visW * visH), + // Radar coverage after jamming. Derived every few ticks from what is standing, so it is + // never serialized — a loaded save recomputes it before the first frame. + radar: new Uint8Array(visW * visH), builtEver: 0, lostEver: 0, killsEver: 0, }); } @@ -313,6 +316,7 @@ export function issueOrder(state, rules, cmd) { const target = entityById(state, order.targetId); if (!target || target.army !== army) return { ok: false, error: 'can only repair your own' }; if (target.site) return { ok: false, error: 'use assist on a building under construction' }; + if (target.airborne) return { ok: false, error: 'land it first' }; if (!units.some((u) => canBuild(rules, u))) return { ok: false, error: 'no builder selected' }; if (target.hp >= target.maxHp) return { ok: false, error: 'already at full health' }; } @@ -828,6 +832,9 @@ function findAutoHealTarget(state, rules, e, def) { let best = null, bestTier = Infinity, bestDSq = Infinity; for (const t of state.entities) { if (t.dead || t.id === e.id || t.army !== e.army || t.site) continue; + // Nothing in the air is a patient. A nanolathe cannot reach it, and a builder that picked + // one anyway would walk out of the base trailing a Fighter it can never catch. + if (t.airborne) continue; if (t.hp >= t.maxHp) continue; const reach = range + t.radius; const dSq = (t.x - e.x) ** 2 + (t.y - e.y) ** 2; @@ -1067,7 +1074,8 @@ function stepOrders(state, rules) { const due = ((state.tick + e.id) % rules.constants.retargetPeriodTicks) === 0; if (due) { const current = entityById(state, order.targetId); - const currentValid = !!current && !current.site && current.hp < current.maxHp + const currentValid = !!current && !current.site && !current.airborne + && current.hp < current.maxHp && Math.hypot(current.x - e.x, current.y - e.y) <= (def.buildRange ?? 0) + current.radius; const currentTier = currentValid ? healPriorityTier(defOf(rules, current)) : Infinity; const candidate = findAutoHealTarget(state, rules, e, def); @@ -1169,6 +1177,13 @@ function stepOrders(state, rules) { // Which of the two happens is decided in stepEconomy, where repair outranks // production — so "keep it alive" beats "make it faster" without needing a second // order type here. + // A builder never chases something in the air: it cannot catch a Fighter, cannot mend + // one in flight, and trailing it walks the builder clean out of the base. The order is + // KEPT rather than dropped — escorting resumes the moment the aircraft puts down. + if (def.buildPower > 0 && target.airborne) { + e.path = null; e.movingTo = null; e.buildTargetId = 0; + break; + } const chore = def.buildPower > 0 && target.isBuilding && guardChore(target); const reach = chore ? ((def.buildRange ?? 0) + target.radius) * 0.9 : ts * 3; const d = Math.hypot(target.x - e.x, target.y - e.y); @@ -1196,7 +1211,7 @@ function stepOrders(state, rules) { } // Nothing left to mend — either it was topped up by someone else or it was never // damaged by the time we arrived. - if (order.type === 'repair' && (target.hp >= target.maxHp || target.site)) { + if (order.type === 'repair' && (target.hp >= target.maxHp || target.site || target.airborne)) { e.orders.shift(); e.buildTargetId = 0; break; } const reach = (def.buildRange ?? 0) + target.radius; @@ -1878,22 +1893,84 @@ export function computeVision(state, rules) { const def = defOf(rules, e); const sight = e.site ? Math.max(64, (def.sight ?? 0) * 0.4) : (def.sight ?? 0); if (sight <= 0) continue; - const r = Math.ceil(sight / cell); - const cx = Math.floor(e.x / cell), cy = Math.floor(e.y / cell); - for (let y = cy - r; y <= cy + r; y++) { - if (y < 0 || y >= state.visH) continue; - for (let x = cx - r; x <= cx + r; x++) { - if (x < 0 || x >= state.visW) continue; - const wx = x * cell + cell / 2, wy = y * cell + cell / 2; - if (Math.hypot(wx - e.x, wy - e.y) > sight) continue; - const i = y * state.visW + x; - a.visible[i] = 1; - a.explored[i] = 1; - } + stampDisc(state, e.x, e.y, sight, cell, (i) => { a.visible[i] = 1; a.explored[i] = 1; }); + } + computeRadar(state, rules); +} + +/** + * Mark every vision cell whose centre falls within `radius` of (x,y). + * + * Bounds are clamped BEFORE the loops rather than tested inside them, which matters now that + * the Advanced Radar exists: its radius covers any map, and an unclamped sweep would spin over + * a quarter of a million cell coordinates that are not on the grid to reject them one at a time. + */ +function stampDisc(state, x, y, radius, cell, mark) { + const r = Math.ceil(radius / cell); + const cx = Math.floor(x / cell), cy = Math.floor(y / cell); + const y0 = Math.max(0, cy - r), y1 = Math.min(state.visH - 1, cy + r); + const x0 = Math.max(0, cx - r), x1 = Math.min(state.visW - 1, cx + r); + const rsq = radius * radius; + for (let gy = y0; gy <= y1; gy++) { + const wy = gy * cell + cell / 2; + const dy = wy - y; + for (let gx = x0; gx <= x1; gx++) { + const wx = gx * cell + cell / 2; + const dx = wx - x; + if (dx * dx + dy * dy > rsq) continue; + mark(gy * state.visW + gx); } } } +/** + * Radar coverage — a SECOND detection layer, independent of line of sight. + * + * Sight tells you what a thing is; radar only tells you that something is there. They are kept + * as separate grids because they answer different questions and are blocked by different + * things: a Radar Jammer deletes enemy radar around itself without dimming anyone's eyes. + * + * Jamming is applied AFTER every dish has been stamped, so a jammer suppresses coverage + * whichever radar happens to be painting that ground — including a whole-map Advanced Radar. + */ +export function computeRadar(state, rules) { + const cell = state.tileSize * 2; + for (const a of state.armies) a.radar.fill(0); + for (const e of state.entities) { + if (e.dead || e.site) continue; // a half-built dish is not yet a dish + const a = state.armies[e.army]; + if (!a) continue; + const range = defOf(rules, e).radarRange ?? 0; + if (range <= 0) continue; + stampDisc(state, e.x, e.y, range, cell, (i) => { a.radar[i] = 1; }); + } + for (const j of state.entities) { + if (j.dead || j.site) continue; + const range = defOf(rules, j).jamRange ?? 0; + if (range <= 0) continue; + for (const a of state.armies) { + if (a.idx === j.army) continue; // your own jammer never blinds you + stampDisc(state, j.x, j.y, range, cell, (i) => { a.radar[i] = 0; }); + } + } +} + +/** + * Is `e` a radar contact for `armyIdx`? A contact is a position, not an identity — the caller + * is expected to draw a blip, not a unit. Anything already in plain sight is not a contact, + * because there is nothing radar can add to looking straight at it. + */ +export function isDetectedBy(state, armyIdx, e) { + if (e.army === armyIdx) return false; + const a = state.armies[armyIdx]; + if (!a) return false; + const cell = state.tileSize * 2; + const x = Math.floor(e.x / cell), y = Math.floor(e.y / cell); + if (x < 0 || y < 0 || x >= state.visW || y >= state.visH) return false; + const i = y * state.visW + x; + return a.radar[i] === 1 && a.visible[i] !== 1; +} + /** Is `e` currently visible to `armyIdx`? The AI is held to this, same as the player. */ export function isVisibleTo(state, armyIdx, e) { if (e.army === armyIdx) return true; diff --git a/src/games/totalannihilation/TARules.js b/src/games/totalannihilation/TARules.js index 6856c61..9c7818d 100644 --- a/src/games/totalannihilation/TARules.js +++ b/src/games/totalannihilation/TARules.js @@ -26,7 +26,7 @@ export const PROC_SHAPES = new Set([ 'commander', 'infantry', 'sniper', 'rocketTrooper', 'jeep', 'tank', 'rockettank', 'constructor', 'fighter', 'bomber', 'hoverConstructor', 'energyGen', 'massGen', 'barracks', 'vehiclePlant', 'laserTower', 'missileLauncher', - 'advancedVehiclePlant', 'airfield', + 'advancedVehiclePlant', 'airfield', 'radar', 'advancedRadar', 'radarJammer', ]); function fail(msg) { @@ -303,6 +303,12 @@ export function compileRules(json) { for (const wid of b.weapons ?? []) { if (!weaponById[wid]) fail(`building "${b.id}" references unknown weapon "${wid}"`); } + // Radar is a SECOND detection layer, independent of line of sight: `radarRange` sees + // contacts through fog (position only, no identity), `jamRange` deletes enemy radar + // coverage around itself. Both are plain radii in pixels, so a new sensor is a JSON edit. + for (const k of ['radarRange', 'jamRange']) { + if (b[k] != null && !(b[k] > 0)) fail(`building "${b.id}" ${k} must be positive when present`); + } b.isBuilding = true; b.isAir = false; b.radius = (Math.max(b.footprint.w, b.footprint.h) * c.tileSize) / 2; diff --git a/src/games/totalannihilation/TAWorldView.js b/src/games/totalannihilation/TAWorldView.js index 4af64c4..b917233 100644 --- a/src/games/totalannihilation/TAWorldView.js +++ b/src/games/totalannihilation/TAWorldView.js @@ -14,7 +14,7 @@ import { ensureSheets, sheetFrameSize } from './TAArt.js'; import { colorInt } from './TAFx.js'; -import { patrolWaypoint } from './TALogic.js'; +import { patrolWaypoint, isDetectedBy } from './TALogic.js'; const CHUNK_PX = 1024; const ZOOMS = [0.5, 0.7, 1.0, 1.4]; @@ -94,11 +94,13 @@ export default class TAWorldView { this.gSelection = scene.add.graphics().setDepth(DEPTHS.selection); this.gOrders = scene.add.graphics().setDepth(DEPTHS.selection); this.gBars = scene.add.graphics().setDepth(DEPTHS.bars); + this.gRadar = scene.add.graphics().setDepth(DEPTHS.bars); this.gPies = scene.add.graphics().setDepth(DEPTHS.bars); this.gGhost = scene.add.graphics().setDepth(DEPTHS.ghost); this._addWorld(this.gSelection); this._addWorld(this.gOrders); this._addWorld(this.gBars); + this._addWorld(this.gRadar); this._addWorld(this.gPies); this._addWorld(this.gGhost); @@ -426,7 +428,7 @@ export default class TAWorldView { const live = new Set(); const gSel = this.gSelection, gBar = this.gBars, gPie = this.gPies; - gSel.clear(); gBar.clear(); gPie.clear(); + gSel.clear(); gBar.clear(); gPie.clear(); this.gRadar.clear(); for (const e of state.entities) { if (e.dead) continue; @@ -436,6 +438,12 @@ export default class TAWorldView { live.add(e.id); const def = rules.defById[e.defId]; const shown = this.visibleToPlayer(e); + // Radar sees THAT something is there, never WHAT. Anything hidden by fog but inside + // radar cover gets a blip at its position and nothing else — no sprite, no health bar, + // no army colour — so the player knows to look without being told what to expect. + if (!shown && this.fogEnabled && isDetectedBy(state, this.playerArmy, e)) { + drawBlip(this.gRadar, e.x, e.y, e.isBuilding); + } const s = this._ensureSprite(e); s.img.setVisible(shown); if (s.turret) s.turret.setVisible(shown); @@ -836,6 +844,22 @@ function drawPie(g, cx, cy, r, frac, color) { g.strokeCircle(cx, cy, r); } +/** + * A radar contact: a hollow diamond in signal green, deliberately identical for a Jeep and a + * factory apart from size. Giving it the army colour or the unit's outline would leak exactly + * the identity radar is not supposed to provide. + */ +function drawBlip(g, x, y, isBuilding) { + const r = isBuilding ? 13 : 9; + g.lineStyle(2, 0x6fe2b0, 0.85); + g.beginPath(); + g.moveTo(x, y - r); g.lineTo(x + r, y); g.lineTo(x, y + r); g.lineTo(x - r, y); + g.closePath(); + g.strokePath(); + g.fillStyle(0x6fe2b0, 0.18); + g.fillCircle(x, y, r * 0.55); +} + function drawBar(g, cx, y, w, frac, color) { const h = 4; const x = cx - w / 2; diff --git a/src/games/totalannihilation/sprites.md b/src/games/totalannihilation/sprites.md index 035bf1f..a8aa6d9 100644 --- a/src/games/totalannihilation/sprites.md +++ b/src/games/totalannihilation/sprites.md @@ -25,12 +25,15 @@ Terrain follows the same rule: tiles are drawn in full colour and are never rota ## Sheets +Each sheet declares `cols` **and `rows`** in the artwork JSON; `cols × rows` is the frame +capacity the verify script holds every `frame` index to, and it is measured off the real PNG. + | Sheet name (JSON key) | Texture key | Cell | Cols | Contents | |---|---|---|---|---| | `arm-units` | `ta-arm-units` | 64×64 | 8 | ARM mobile units | | `core-units` | `ta-core-units` | 64×64 | 8 | CORE mobile units | -| `arm-structures` | `ta-arm-structures` | 128×128 | 6 | ARM buildings | -| `core-structures` | `ta-core-structures` | 128×128 | 6 | CORE buildings | +| `arm-structures` | `ta-arm-structures` | 192×192 | 6×4 | ARM buildings | +| `core-structures` | `ta-core-structures` | 192×192 | 6×4 | CORE buildings | | `terrain-grasslands` | `ta-terrain-grasslands` | 64×64 | 9 | Grassland tiles | | `terrain-snowfields` | `ta-terrain-snowfields` | 64×64 | 9 | Snow tiles | | `terrain-tropics` | `ta-terrain-tropics` | 64×64 | 9 | Jungle tiles | @@ -155,12 +158,28 @@ build completes; the finished frame crossfades in underneath starting at 20% pro | 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 | | +| 16 | Radar Tower | 2×2 | dish on a low pad — **placeholder, needs painting** | +| 17 | Radar Tower — under construction | 2×2 | | +| 18 | Advanced Radar | 2×2 | tall lattice mast + stacked ring arrays — **placeholder** | +| 19 | Advanced Radar — under construction | 2×2 | | +| 20 | Radar Jammer | 2×2 | squat emitter, no dish, broken rings — **placeholder** | +| 21 | Radar Jammer — under construction | 2×2 | | + +The three sensor structures share a visual language deliberately: a low pad with corner +footings and **no armoured plinth**, so a glance separates "sensor" from "gun". They never +shoot. The Jammer is the odd one out — it transmits rather than listens, so it has no dish and +throws broken rings outward instead. + +> **Frames 16–21 are machine-drawn placeholders.** The structure sheets were grown from 3 rows +> to 4 (1152×768) to make room for them; the `.psd` sources were **not** touched, so re-exporting +> from Photoshop will drop back to 576px tall and those frames will vanish. Extend the PSD +> canvas to 4 rows before the next export. 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 16–35 are free. +Frames 22–23 are free in the current 6×4 sheet; growing it another row adds six more. --- @@ -211,7 +230,8 @@ is the lowest priority of the eight. |---|---| | 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) | +| 17–19 | Overflow: hover constructor (unit), airfield, radar tower | +| 30–31 | Advanced radar, radar jammer | | 20–29 | Commands: move, attack, attack-move, stop, hold, patrol, guard, assist, repair, rally | Row 0 filled up before the Airfield units were added, which is why the hover constructor sits diff --git a/tools/verifyTotalAnnihilation.js b/tools/verifyTotalAnnihilation.js index 92ae459..632feb1 100644 --- a/tools/verifyTotalAnnihilation.js +++ b/tools/verifyTotalAnnihilation.js @@ -133,6 +133,9 @@ section('2. Artwork manifest'); for (const d of [...rules.units, ...rules.buildings]) { for (const a of rules.armies) { const sheet = sheets[d.sheetSlot === 'unitSheet' ? a.unitSheet : a.structureSheet]; + // `rows` is declared per sheet and measured off the real PNG, so this is the actual + // number of frames the texture holds. Defaulting it would let a def point at a frame the + // image simply does not contain — which Phaser renders as the whole spritesheet. const cap = (sheet.cols ?? 8) * (sheet.rows ?? 8); check(`${d.id} frame ${d.frame} fits ${sheet.key}`, d.frame < cap); if (d.turretFrame != null) check(`${d.id} turret frame fits`, d.turretFrame < cap); @@ -547,15 +550,18 @@ section('2d. Container hitbox lint'); const hudSrc = readFileSync(join(ROOT, 'src/games/totalannihilation/TAHud.js'), 'utf8'); const gameSrc = readFileSync(join(ROOT, 'src/games/totalannihilation/TotalAnnihilationGame.js'), 'utf8'); - // The build menu is ONE row deep. 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 pushed that button off into the hint line that used to live under the grid. + // The build menu is two rows deep and no more — a third would run off the bottom of the bar. + // Adding a build option to a unit is otherwise a pure JSON edit, so nothing else would catch + // an overflow: giving the Commander an Airfield once pushed it to 7 options and off the end + // of the single row the grid had back then. const cols = Number(/const GRID_COLS = (\d+)/.exec(hudSrc)?.[1]); - check('the HUD declares a build-grid column count', Number.isInteger(cols) && cols > 0); + const gridRows = Number(/const GRID_ROWS = (\d+)/.exec(hudSrc)?.[1]); + check('the HUD declares a build-grid size', cols > 0 && gridRows > 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`); + check(`${d.id}'s build options fit the grid`, n <= cols * gridRows, + `${n} options vs ${cols}x${gridRows}`); } // ---- command bar ---- @@ -2061,6 +2067,63 @@ section('6g. Strafing flight'); check('and settles back down when idle', h.liftFrac === 0); } + // ---- builders leave aircraft alone until they are down ---- + { + // A nanolathe cannot reach something in flight, and a builder that tried would trail a + // Fighter across the map at a third of its speed — walking itself out of the base for + // nothing. Every route into that mistake is closed: auto-heal, an explicit order, a + // patient that takes off mid-repair, and guard. + const st = arena(50); + const cmd = L.spawnUnit(st, sr, 0, 'commander', px(30), px(30)); + const hurt = L.spawnUnit(st, sr, 0, 'fighter', px(33), px(30)); + hurt.hp = hurt.maxHp * 0.4; + + // Grounded: the ordinary case must still work, or the guard is too broad. + runFor(st, 2); + check('a builder does mend a LANDED aircraft', cmd.buildTargetId === hurt.id, + `link ${cmd.buildTargetId}`); + const mended = hurt.hp; + runFor(st, 10); + check('and its hit points actually come back', hurt.hp > mended); + + // Airborne: send it somewhere and everything must let go. + L.issueOrder(st, sr, { army: 0, unitIds: [hurt.id], order: { type: 'move', x: px(55), y: px(30) } }); + runFor(st, 2); + check('the aircraft is airborne for the rest of this fixture', hurt.airborne === true); + check('a builder drops the repair when its patient takes off', cmd.buildTargetId === 0, + 'the Commander kept nanolathing a Fighter in flight'); + const inFlight = hurt.hp; + const cmdX = cmd.x; + runFor(st, 15); + check('and does not heal it in the air', Math.abs(hurt.hp - inFlight) < 1e-6, + `${inFlight.toFixed(0)} -> ${hurt.hp.toFixed(0)}`); + check('and does not chase it', Math.abs(cmd.x - cmdX) < 1, `drifted ${(cmd.x - cmdX).toFixed(1)}px`); + + // An explicit order is refused rather than silently ignored. + const r = L.issueOrder(st, sr, { army: 0, unitIds: [cmd.id], order: { type: 'repair', targetId: hurt.id } }); + check('an explicit repair on a flying aircraft is refused', r.ok === false, JSON.stringify(r)); + } + + { + // Guard keeps the order but stops following, so it resumes when the aircraft lands. + const st = arena(51); + const cmd = L.spawnUnit(st, sr, 0, 'commander', px(30), px(30)); + const f = L.spawnUnit(st, sr, 0, 'fighter', px(33), px(30)); + L.issueOrder(st, sr, { army: 0, unitIds: [cmd.id], order: { type: 'guard', targetId: f.id } }); + // A PATROL, not a move: a move ends in a landing, and once the aircraft is down the + // Commander is supposed to start following it again — which is the behaviour below, not a + // violation of it. Patrolling keeps it in the air for the whole window. + L.issueOrder(st, sr, { army: 0, unitIds: [f.id], order: { type: 'patrol', x: px(58), y: px(30) } }); + runFor(st, 2); + const cmdX = cmd.x; + runFor(st, 12); + check('the guarded aircraft stayed airborne', f.airborne === true); + check('a builder guarding an aircraft holds station while it flies', + Math.abs(cmd.x - cmdX) < 1, `drifted ${(cmd.x - cmdX).toFixed(1)}px`); + check('and keeps the guard order for when it lands', + cmd.orders[0]?.type === 'guard' && cmd.orders[0].targetId === f.id); + } + // ---- altitude survives a save ---- { const st = arena(39); @@ -2106,6 +2169,124 @@ section('7. Fog of war'); check('AI never records an unseen enemy as seen-now', cheated.length === 0, `${cheated.length} leaks`); } +// --------------------------------------------------------------------------- +section('7b. Radar, advanced radar and jamming'); +// --------------------------------------------------------------------------- +{ + // Radar is a SECOND detection layer: sight tells you what a thing is, radar only that + // something is there. Everything here is about the two staying independent — a jammer must + // blind radar without dimming anyone's eyes, and a contact must never be confused for a + // sighting. + const radar = rules.buildingById.radar; + const adv = rules.buildingById.advancedradar; + const jam = rules.buildingById.radarjammer; + const launcher = rules.weaponById.towermissile; + + check('the Radar Tower exists', !!radar && radar.radarRange > 0); + check('its reach is about twice the Missile Launcher', + Math.abs(radar.radarRange / launcher.range - 2) < 0.15, + `${radar.radarRange} vs ${launcher.range}`); + check('the Advanced Radar covers any map', adv.radarRange > 128 * rules.constants.tileSize * Math.SQRT2, + `${adv.radarRange} vs the largest map diagonal`); + check('the Advanced Radar also sees through fog at the basic dish\'s radius', + adv.sight === radar.radarRange, `${adv.sight} vs ${radar.radarRange}`); + check('the Jammer reaches as far as the basic dish', jam.jamRange === radar.radarRange, + `${jam.jamRange} vs ${radar.radarRange}`); + check('the Jammer is not itself a radar', !jam.radarRange); + // Who may build what — the two upper tiers are the construction craft's job. + check('the Commander can build the basic Radar', rules.unitById.commander.builds.includes('radar')); + for (const id of ['advancedradar', 'radarjammer']) { + check(`the Commander cannot build the ${id}`, !rules.unitById.commander.builds.includes(id)); + check(`the Construction Vehicle can build the ${id}`, rules.unitById.constructor.builds.includes(id)); + check(`the Hover Constructor can build the ${id}`, rules.unitById.hoverconstructor.builds.includes(id)); + } + + const map = generateMap(rules, { seed: 5, size: 'small', symmetry: 'mirror-x' }); + const rig = () => { + const st = L.createMatch(rules, { seed: 5, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + return st; + }; + const put = (st, army, type, tx, ty) => { + const b = L.placeBuilding(st, rules, army, type, tx, ty); + b.site = false; b.progress = 1; b.hp = rules.buildingById[type].hp; + return b; + }; + const covered = (st, a) => st.armies[a].radar.reduce((s, v) => s + v, 0); + + { + const st = rig(); + const cells = st.visW * st.visH; + const s0 = st.starts.find((x) => x.army === 0); + L.computeVision(st, rules); + check('an army with no dish has no radar at all', covered(st, 0) === 0); + + put(st, 0, 'radar', s0.x + 2, s0.y + 2); + L.computeVision(st, rules); + const basic = covered(st, 0); + check('a Radar Tower covers ground but not the map', basic > 0 && basic < cells, + `${basic} of ${cells} cells`); + + put(st, 0, 'advancedradar', s0.x - 4, s0.y + 2); + L.computeVision(st, rules); + check('an Advanced Radar covers the whole map', covered(st, 0) === cells, + `${covered(st, 0)} of ${cells}`); + } + + { + // The jammer's whole job: punch a hole in enemy coverage, including a whole-map dish. + const st = rig(); + const s0 = st.starts.find((x) => x.army === 0); + const s1 = st.starts.find((x) => x.army === 1); + put(st, 0, 'advancedradar', s0.x + 2, s0.y + 2); + L.computeVision(st, rules); + const before = covered(st, 0); + const j = put(st, 1, 'radarjammer', s1.x, s1.y); + L.computeVision(st, rules); + const after = covered(st, 0); + check('an enemy Jammer takes coverage away', after < before, `${before} -> ${after}`); + check('it only takes a bite, not the lot', after > 0); + // ...and it must not blind the side that owns it. + put(st, 1, 'radar', s1.x + 3, s1.y); + L.computeVision(st, rules); + check('your own Jammer never blinds you', covered(st, 1) > 0, `${covered(st, 1)} cells`); + j.dead = true; + L.computeVision(st, rules); + check('killing the Jammer restores coverage', covered(st, 0) === before); + } + + { + // Contact vs sighting. A unit out in the fog but under radar is a CONTACT; the same unit + // in plain view is not, because there is nothing radar adds to looking straight at it. + const st = rig(); + const s0 = st.starts.find((x) => x.army === 0); + put(st, 0, 'radar', s0.x + 2, s0.y + 2); + const far = L.spawnUnit(st, rules, 1, 'tank', (s0.x + 12) * st.tileSize, s0.y * st.tileSize); + L.computeVision(st, rules); + check('a unit hidden by fog but under radar is a contact', + !L.isVisibleTo(st, 0, far) && L.isDetectedBy(st, 0, far)); + const near = L.spawnUnit(st, rules, 1, 'tank', s0.x * st.tileSize, s0.y * st.tileSize); + L.computeVision(st, rules); + check('a unit in plain sight is a sighting, not a contact', + L.isVisibleTo(st, 0, near) && !L.isDetectedBy(st, 0, near)); + check('your own units are never radar contacts', + !L.isDetectedBy(st, 1, far)); + + // A jammer over that unit takes the contact away again. + put(st, 1, 'radarjammer', s0.x + 12, s0.y); + L.computeVision(st, rules); + check('a Jammer hides a unit standing inside it', !L.isDetectedBy(st, 0, far)); + } + + { + // A dish still going up is not yet a dish. + const st = rig(); + const s0 = st.starts.find((x) => x.army === 0); + L.placeBuilding(st, rules, 0, 'advancedradar', s0.x + 2, s0.y + 2); // left as a site + L.computeVision(st, rules); + check('a half-built dish gives no radar', covered(st, 0) === 0); + } +} + // --------------------------------------------------------------------------- section('8. Map generation'); // ---------------------------------------------------------------------------