feat: add radar detection system with jamming and contacts
Introduce a second detection layer independent of line-of-sight vision. Radar buildings (Radar Tower, Advanced Radar) provide coverage that reveals unit positions through fog as contacts, while Radar Jammers can blind enemy radar within range without affecting normal sight. Key changes: - Add three sensor buildings: Radar Tower, Advanced Radar, Radar Jammer - Implement computeRadar() with jamming logic and stampDisc() optimization - Add isDetectedBy() for radar contact detection - Draw radar contacts as green diamond blips in WorldView - Show radar contacts as neutral green pips on minimap - Expand build grid to 2 rows and artwork sheets to 4 rows - Refuse repair orders on airborne units; builders stop chasing airborne targets - Add validation for radarRange and jamRange in rules compiler - Update sprites.md documentation and add comprehensive tests
This commit is contained in:
parent
71e455650b
commit
b42a71bf2f
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
|
@ -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
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue