feat(total-annihilation): overhaul command system, patrol routes, and Commander death sequence
- Add repair and rally command buttons with custom glyphs and tooltips - Refactor bottom command bar into a structured, discoverable grid with visibility filtering per selection type - Replace patrol's there-and-back with a circuit-based route system; CTRL-clicking extends the patrol instead of stacking orders - Builders guarding structures now repair damage first, then assist production — assist to producing factories now works correctly - Add Commander death cascade: nuclear blast, outward vaporization wave, and screen-wide flash timed to the audio sample - Introduce `fortification` and `air` armor classes; rebalance weapons, unit HP, and ranges across the roster - Remove the static hint line; all commands now have prompts and button tooltips instead - Add verification tests for patrol routes, guarding builders, vaporised entity rendering, and the full command bar
This commit is contained in:
parent
f9e971b305
commit
ac063d34a6
|
|
@ -1265,7 +1265,9 @@
|
|||
"hold": 24,
|
||||
"patrol": 25,
|
||||
"guard": 26,
|
||||
"assist": 27
|
||||
"assist": 27,
|
||||
"repair": 28,
|
||||
"rally": 29
|
||||
},
|
||||
"aiSkills": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -783,6 +783,29 @@ const PROC_PAINTERS = {
|
|||
ctx.moveTo(S * 0.24, S / 2); ctx.lineTo(S * 0.76, S / 2);
|
||||
ctx.stroke();
|
||||
});
|
||||
// A wrench, deliberately unlike assist's plus sign: assist pours build power into something
|
||||
// unfinished, repair puts hit points back into something already standing.
|
||||
glyph(cmd.repair, (ctx, S) => {
|
||||
ctx.strokeStyle = '#8affd0'; ctx.lineWidth = 5; ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(S * 0.32, S * 0.68); ctx.lineTo(S * 0.66, S * 0.34);
|
||||
ctx.stroke();
|
||||
ctx.lineCap = 'butt';
|
||||
ctx.beginPath(); ctx.arc(S * 0.70, S * 0.30, S * 0.13, 0.6, 5.2);
|
||||
ctx.lineWidth = 4; ctx.stroke();
|
||||
ctx.fillStyle = '#8affd0';
|
||||
ctx.beginPath(); ctx.arc(S * 0.30, S * 0.70, S * 0.06, 0, Math.PI * 2); ctx.fill();
|
||||
});
|
||||
// A pennant on a post — where finished units are told to go.
|
||||
glyph(cmd.rally, (ctx, S) => {
|
||||
ctx.strokeStyle = '#cfe3ff'; ctx.lineWidth = 3;
|
||||
ctx.beginPath(); ctx.moveTo(S * 0.34, S * 0.18); ctx.lineTo(S * 0.34, S * 0.82); ctx.stroke();
|
||||
ctx.fillStyle = '#ffd27a';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(S * 0.34, S * 0.20); ctx.lineTo(S * 0.76, S * 0.34); ctx.lineTo(S * 0.34, S * 0.48);
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.strokeStyle = '#26262e'; ctx.lineWidth = 2; ctx.stroke();
|
||||
});
|
||||
|
||||
return sh.finish();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -23,17 +23,39 @@ const DIM = '#8fa0b8';
|
|||
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 BTN_W = 88, BTN_H = 74; // button size, shared by both grids
|
||||
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.
|
||||
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
|
||||
|
||||
// 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.
|
||||
const HINT = 'LMB select · drag box · RMB order · RMB a damaged ally to repair · CTRL+RMB queue · A attack-move · X stop · H hold';
|
||||
/**
|
||||
* The order buttons, in grid order: row one is what every mobile unit can do, row two is the
|
||||
* stances plus the builder/factory verbs.
|
||||
*
|
||||
* `needs` decides whether the button is live for the current selection, and `instant` splits
|
||||
* the two interaction models: Stop and Hold apply to the selection immediately, everything
|
||||
* else arms a pending command that the next world click resolves — the same flow the A/P/G
|
||||
* hotkeys already used, which is why those hotkeys keep working unchanged.
|
||||
*
|
||||
* Reclaim is absent rather than greyed out. There are no wrecks to reclaim in this engine
|
||||
* yet, and a permanently dead button just teaches players to stop looking at that corner.
|
||||
*/
|
||||
const COMMANDS = [
|
||||
{ id: 'move', label: 'Move', key: 'RMB', needs: 'mobile', tip: 'Walk to a point without engaging on the way.' },
|
||||
{ id: 'attack', label: 'Attack', key: '', needs: 'armed', tip: 'Pick one target and commit to it.' },
|
||||
{ id: 'attackMove', label: 'Atk-Mv', key: 'A', needs: 'mobile', tip: 'Advance to a point, engaging anything met on the way.' },
|
||||
{ id: 'patrol', label: 'Patrol', key: 'P', needs: 'mobile', tip: 'Shuttle between here and a point, engaging on both legs.' },
|
||||
{ id: 'guard', label: 'Guard', key: 'G', needs: 'mobile', tip: 'Escort a friendly unit and fight what attacks it.' },
|
||||
{ id: 'stop', label: 'Stop', key: 'X', needs: 'any', instant: true, tip: 'Clear the whole order queue.' },
|
||||
{ id: 'hold', label: 'Hold', key: 'H', needs: 'mobile', instant: true, tip: 'Stay put and shoot what comes into range.' },
|
||||
{ id: 'repair', label: 'Repair', key: '', needs: 'builder', tip: 'Put hit points back into a damaged friendly.' },
|
||||
{ id: 'assist', label: 'Assist', key: '', needs: 'builder', tip: 'Add build power to a site or a factory already working.' },
|
||||
{ id: 'setRally', label: 'Rally', key: '', needs: 'factory', tip: 'Send newly built units to a point.' },
|
||||
];
|
||||
|
||||
// Modifiers, unchanged by the buttons: CTRL queues an order (as in the original game), SHIFT
|
||||
// adds to the selection and buys x5 from a factory.
|
||||
|
||||
/** Build-button captions have ~7 characters of room; "Vehicle Plant" needs shortening. */
|
||||
function shortName(name) {
|
||||
|
|
@ -135,22 +157,106 @@ export default class TAHud {
|
|||
bg.setStrokeStyle(2, EDGE);
|
||||
this.root.add(bg);
|
||||
|
||||
// Geometry first — the selection text is sized against where the command grid starts.
|
||||
this.gridX = GAME_WIDTH - 24 - GRID_COLS * GRID_PITCH;
|
||||
this.gridY = y + 16;
|
||||
// Command grid sits immediately left of the build grid, sharing its button size and pitch
|
||||
// so the two read as one control surface. Right edge lands 32px short of the build menu.
|
||||
this.cmdX = this.gridX - 32 - (CMD_COLS - 1) * GRID_PITCH - BTN_W;
|
||||
this.cmdY = this.gridY;
|
||||
|
||||
// The selection panel runs from the minimap to the command grid and no further. It used to
|
||||
// have the whole width to itself; now that the order buttons sit at 624 it has to be told,
|
||||
// or a mixed selection's title runs straight under them.
|
||||
const selW = this.cmdX - (MINI + 60) - 16;
|
||||
this.selTitle = s.add.text(MINI + 60, y + 12, '', {
|
||||
fontFamily: FONT, fontSize: '26px', color: TEXT,
|
||||
wordWrap: { width: selW }, maxLines: 1,
|
||||
});
|
||||
this.selDetail = s.add.text(MINI + 60, y + 44, '', {
|
||||
fontFamily: FONT, fontSize: '19px', color: DIM, lineSpacing: 3,
|
||||
wordWrap: { width: selW }, maxLines: 3,
|
||||
});
|
||||
this.queueText = s.add.text(MINI + 60, y + 120, '', {
|
||||
fontFamily: FONT, fontSize: '19px', color: '#9ce6a0',
|
||||
wordWrap: { width: selW }, maxLines: 2,
|
||||
});
|
||||
this.hint = s.add.text(GAME_WIDTH - 24, y + BOT_H - 26, '', {
|
||||
fontFamily: FONT, fontSize: '17px', color: DIM,
|
||||
}).setOrigin(1, 0.5);
|
||||
this.root.add([this.selTitle, this.selDetail, this.queueText, this.hint]);
|
||||
this.root.add([this.selTitle, this.selDetail, this.queueText]);
|
||||
this._buildCommandGrid();
|
||||
}
|
||||
|
||||
this.gridX = GAME_WIDTH - 24 - GRID_COLS * GRID_PITCH;
|
||||
this.gridY = y + 16;
|
||||
/**
|
||||
* The order buttons. Built ONCE and then only restyled — unlike the build grid, whose
|
||||
* contents change with the selection, this set is fixed and rebuilding ten containers every
|
||||
* frame would churn interactive objects for nothing.
|
||||
*/
|
||||
_buildCommandGrid() {
|
||||
const s = this.scene;
|
||||
this.cmdButtons = [];
|
||||
COMMANDS.forEach((cmd, i) => {
|
||||
const col = i % CMD_COLS, row = Math.floor(i / CMD_COLS);
|
||||
// Container position is its CENTRE — see the note in _drawGrid. Every child below is
|
||||
// centred on (0,0) so the visible button and its hit area coincide.
|
||||
const x = this.cmdX + col * GRID_PITCH + BTN_W / 2;
|
||||
const y = this.cmdY + row * ROW_PITCH + BTN_H / 2;
|
||||
const c = s.add.container(x, y);
|
||||
const box = s.add.rectangle(0, 0, BTN_W, BTN_H, 0x22304a, 1).setStrokeStyle(2, EDGE);
|
||||
const label = s.add.text(0, -BTN_H / 2 + 14, cmd.label, {
|
||||
fontFamily: FONT, fontSize: '20px', color: TEXT,
|
||||
}).setOrigin(0.5, 0);
|
||||
const hotkey = s.add.text(0, BTN_H / 2 - 26, cmd.key, {
|
||||
fontFamily: FONT, fontSize: '15px', color: DIM,
|
||||
}).setOrigin(0.5, 0);
|
||||
c.add([box, label, hotkey]);
|
||||
c.setSize(BTN_W, BTN_H);
|
||||
c.setInteractive({ useHandCursor: true });
|
||||
const btn = { cmd, c, box, label, hotkey, enabled: false, armed: false };
|
||||
c.on('pointerover', () => { if (btn.enabled && !btn.armed) box.setFillStyle(0x2e3f5f, 1); });
|
||||
c.on('pointerout', () => this._styleCommand(btn));
|
||||
this.tooltip.attachTo(c, () => ({
|
||||
title: cmd.label,
|
||||
lines: [{ text: cmd.tip }, ...(cmd.key ? [{ text: `Hotkey: ${cmd.key}` }] : [])],
|
||||
}));
|
||||
c.on('pointerdown', (p, lx, ly, ev) => {
|
||||
ev?.stopPropagation();
|
||||
if (btn.enabled) this.h.onCommand?.(cmd.id);
|
||||
});
|
||||
this.root.add(c);
|
||||
this.cmdButtons.push(btn);
|
||||
});
|
||||
}
|
||||
|
||||
_styleCommand(btn) {
|
||||
if (!btn.enabled) {
|
||||
btn.box.setFillStyle(0x1a2130, 1).setStrokeStyle(2, 0x2a3244);
|
||||
btn.label.setColor('#5d6b80'); btn.hotkey.setColor('#4e5a6d');
|
||||
return;
|
||||
}
|
||||
if (btn.armed) {
|
||||
btn.box.setFillStyle(0x3d6a4a, 1).setStrokeStyle(2, 0x7dff9b);
|
||||
btn.label.setColor('#dcffe6'); btn.hotkey.setColor('#9ce6a0');
|
||||
return;
|
||||
}
|
||||
btn.box.setFillStyle(0x22304a, 1).setStrokeStyle(2, EDGE);
|
||||
btn.label.setColor(TEXT); btn.hotkey.setColor(DIM);
|
||||
}
|
||||
|
||||
/** Light up the orders this selection can actually give, and mark the armed one. */
|
||||
_refreshCommands(sel, pending) {
|
||||
const R = this.rules;
|
||||
const mobile = sel.filter((e) => !e.isBuilding && !e.site);
|
||||
const can = {
|
||||
any: sel.length > 0,
|
||||
mobile: mobile.length > 0,
|
||||
armed: mobile.some((e) => (R.defById[e.defId].maxRange ?? 0) > 0),
|
||||
builder: mobile.some((e) => R.defById[e.defId].builds?.length),
|
||||
factory: sel.some((e) => e.isBuilding && !e.site && R.defById[e.defId].builds?.length),
|
||||
};
|
||||
for (const btn of this.cmdButtons) {
|
||||
btn.enabled = !!can[btn.cmd.needs];
|
||||
btn.armed = btn.enabled && pending === btn.cmd.id;
|
||||
this._styleCommand(btn);
|
||||
}
|
||||
}
|
||||
|
||||
_clearGrid() {
|
||||
|
|
@ -289,6 +395,9 @@ export default class TAHud {
|
|||
const sx = MINI / st.worldW, sy = MINI / st.worldH;
|
||||
for (const e of st.entities) {
|
||||
if (e.dead) continue;
|
||||
// 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 a = this.rules.armies[e.army];
|
||||
g.fillStyle(a ? a.colorInt : 0x888888, 1);
|
||||
|
|
@ -307,7 +416,7 @@ export default class TAHud {
|
|||
// Per-frame update
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
update(timeMs, selection, placementDef) {
|
||||
update(timeMs, selection, placementDef, pendingCommand = null) {
|
||||
const st = this.state, army = st.armies[this.playerArmy];
|
||||
if (!army) return;
|
||||
|
||||
|
|
@ -328,6 +437,9 @@ export default class TAHud {
|
|||
this._bakeMinimap(timeMs);
|
||||
this._drawMinimapOverlay();
|
||||
this._refreshSelection(selection, placementDef);
|
||||
// Outside the signature-cached selection refresh: the armed command changes on its own
|
||||
// schedule (a hotkey, a click, an Esc) with the selection untouched.
|
||||
this._refreshCommands([...selection], pendingCommand);
|
||||
}
|
||||
|
||||
_refreshSelection(selection, placementDef) {
|
||||
|
|
@ -345,7 +457,6 @@ export default class TAHud {
|
|||
this.selTitle.setText('');
|
||||
this.selDetail.setText('');
|
||||
this.queueText.setText('');
|
||||
this.hint.setText(HINT);
|
||||
this._clearGrid();
|
||||
return;
|
||||
}
|
||||
|
|
@ -367,9 +478,10 @@ export default class TAHud {
|
|||
const job = lead.orders?.[0];
|
||||
if (job?.type === 'repair') lines.push('Repairing');
|
||||
else if (job?.type === 'build' || job?.type === 'assist') lines.push('Building');
|
||||
// A builder is the only thing that can repair, so say so where the player is looking.
|
||||
if (!lead.site && (this.rules.defById[lead.defId].builds ?? []).length && !lead.isBuilding) {
|
||||
lines.push('Right-click a damaged ally to repair it');
|
||||
// A guarding builder silently switches between mending and assisting, so say which.
|
||||
else if (job?.type === 'guard' && lead.buildTargetId) {
|
||||
const t = this.state.entities.find((x) => x.id === lead.buildTargetId && !x.dead);
|
||||
lines.push(t && t.hp < t.maxHp ? 'Guarding — repairing' : 'Guarding — assisting');
|
||||
}
|
||||
if (!lead.site && lead.hasRally) lines.push('Rally point set');
|
||||
for (const w of def.weaponDefs ?? []) lines.push(`${w.name}${w.manual ? ' — manual (attack order)' : ''}`);
|
||||
|
|
@ -381,13 +493,6 @@ export default class TAHud {
|
|||
}
|
||||
this.selDetail.setText(lines.join('\n'));
|
||||
|
||||
if (placementDef) {
|
||||
this.hint.setText(`Placing ${placementDef.name} — LMB to site it, hold CTRL to queue several `
|
||||
+ '(release CTRL when done), Esc to cancel');
|
||||
} else {
|
||||
this.hint.setText(HINT);
|
||||
}
|
||||
|
||||
// Grid shows the builder's structures, or the factory's units — never both.
|
||||
const builder = sel.find((e) => !e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length);
|
||||
const factory = sel.find((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length);
|
||||
|
|
|
|||
|
|
@ -327,9 +327,9 @@ export function issueOrder(state, rules, cmd) {
|
|||
// One shared destination, fanned into formation slots — a 40-unit move order costs one A*.
|
||||
const slots = formationSlots(order.x, order.y, units);
|
||||
units.forEach((u, i) => {
|
||||
const o = { ...order, sx: slots[i * 2], sy: slots[i * 2 + 1] };
|
||||
if (order.type === 'patrol') { o.fromX = u.x; o.fromY = u.y; o.leg = 0; }
|
||||
pushOrder(u, o, cmd.queue);
|
||||
const sx = slots[i * 2], sy = slots[i * 2 + 1];
|
||||
if (order.type === 'patrol') { addPatrolWaypoint(u, sx, sy, cmd.queue); return; }
|
||||
pushOrder(u, { ...order, sx, sy }, cmd.queue);
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
@ -525,11 +525,24 @@ function stepEconomy(state, rules) {
|
|||
if (target && target.army === e.army) {
|
||||
const d = Math.hypot(target.x - e.x, target.y - e.y);
|
||||
if (d > def.buildRange + target.radius) continue;
|
||||
// Repair power is kept in its OWN accumulator. A damaged factory has build power of
|
||||
// its own for the unit it is producing; folding an incoming repair into the same
|
||||
// number would silently speed up that unit's production.
|
||||
// Three places the power can land, in priority order.
|
||||
//
|
||||
// Repair is kept in its OWN accumulator: a damaged factory has build power of its own
|
||||
// for the unit it is producing, and folding an incoming repair into the same number
|
||||
// would silently speed up that unit's production.
|
||||
//
|
||||
// DAMAGE COMES FIRST. A nanolathe pointed at a factory that is both hurt and building
|
||||
// patches it before it helps the production line — a half-dead factory that keeps
|
||||
// pumping out units while ignoring the hole in its roof is the wrong answer, and it is
|
||||
// what "guard this and keep it alive" means.
|
||||
//
|
||||
// The last branch is the one that was missing: build power aimed at a healthy factory
|
||||
// with something in its queue now speeds that job up. `assist` has always ACCEPTED a
|
||||
// producing factory as a target and stepOrders has always held the nanolathe on it,
|
||||
// but the power evaporated here, so assisting a factory did visibly nothing.
|
||||
if (target.site) target._power += def.buildPower;
|
||||
else if (target.hp < target.maxHp) target._repairPower += def.buildPower;
|
||||
else if (target.isBuilding && target.queue?.length) target._power += def.buildPower;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -737,13 +750,66 @@ function servicePathQueue(state, rules) {
|
|||
}
|
||||
|
||||
function orderDestination(e, order) {
|
||||
if (order.type === 'patrol') {
|
||||
return order.leg === 0 ? { x: order.sx ?? order.x, y: order.sy ?? order.y }
|
||||
: { x: order.fromX, y: order.fromY };
|
||||
}
|
||||
if (order.type === 'patrol') return patrolWaypoint(order);
|
||||
return { x: order.sx ?? order.x, y: order.sy ?? order.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* The waypoint a patrol is currently heading for.
|
||||
*
|
||||
* A patrol carries a `route`: a flat [x,y,x,y,…] circuit whose FIRST point is where the unit
|
||||
* stood when the order was given, followed by every point the player clicked. `leg` indexes it
|
||||
* and wraps, so the unit runs the circuit forever. One clicked point therefore still gives the
|
||||
* classic there-and-back — the two-point route is just the shortest circuit, not a special case.
|
||||
*
|
||||
* Orders restored from a save written before routes existed carry the old fromX/fromY pair
|
||||
* instead, and are read the old way rather than migrated.
|
||||
*/
|
||||
export function patrolWaypoint(order) {
|
||||
const r = order.route;
|
||||
if (!r || r.length < 4) {
|
||||
return order.leg === 0
|
||||
? { x: order.sx ?? order.x, y: order.sy ?? order.y }
|
||||
: { x: order.fromX ?? order.sx ?? order.x, y: order.fromY ?? order.sy ?? order.y };
|
||||
}
|
||||
const n = r.length >> 1;
|
||||
const i = ((order.leg ?? 0) % n + n) % n;
|
||||
return { x: r[i * 2], y: r[i * 2 + 1] };
|
||||
}
|
||||
|
||||
/** Step a patrol on to its next waypoint, wrapping round the circuit. */
|
||||
function advancePatrol(order) {
|
||||
const n = order.route && order.route.length >= 4 ? order.route.length >> 1 : 2;
|
||||
order.leg = (((order.leg ?? 0) + 1) % n + n) % n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a point to `u`'s patrol.
|
||||
*
|
||||
* Queued (CTRL held) onto a unit whose last order is already a patrol, this EXTENDS that
|
||||
* circuit rather than stacking a second patrol behind it — which is what the player means by
|
||||
* ctrl-clicking a series of points, and is also the only thing that could work: a patrol never
|
||||
* completes, so anything queued behind one would never run.
|
||||
*/
|
||||
function addPatrolWaypoint(u, x, y, queue) {
|
||||
const last = queue ? u.orders[u.orders.length - 1] : null;
|
||||
if (last && last.type === 'patrol' && last.route) {
|
||||
last.route.push(x, y);
|
||||
return;
|
||||
}
|
||||
// Point 0 is where the unit is standing, so the circuit always comes home.
|
||||
pushOrder(u, { type: 'patrol', route: [u.x, u.y, x, y], leg: 1 }, queue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is there anything for a guarding builder to do to this structure? Damage to mend, a site to
|
||||
* finish, or a queue to help along. An intact idle building needs nothing, so its guard just
|
||||
* stands watch — and picks the work up by itself the moment the building starts producing.
|
||||
*/
|
||||
function guardChore(target) {
|
||||
return !!(target.site || target.hp < target.maxHp || target.queue?.length);
|
||||
}
|
||||
|
||||
/** Auto-heal priority: lower tier heals first — defensive > factory > other building > unit. */
|
||||
function healPriorityTier(def) {
|
||||
if (!def.isBuilding) return 3;
|
||||
|
|
@ -910,7 +976,7 @@ function stepStraferOrders(state, rules, e, def) {
|
|||
e.movingTo = dest;
|
||||
if (d > arrive) { e._wantAirborne = true; break; }
|
||||
if (order.type === 'patrol') {
|
||||
order.leg = order.leg === 0 ? 1 : 0;
|
||||
advancePatrol(order);
|
||||
e._wantAirborne = true;
|
||||
e.movingTo = orderDestination(e, order);
|
||||
break;
|
||||
|
|
@ -985,7 +1051,11 @@ function stepOrders(state, rules) {
|
|||
// Without this, giving a repairing Commander a move order left buildTargetId pointing at
|
||||
// the patient, and the economy kept pouring resources into the repair — for as long as
|
||||
// the builder happened to stay in range — even though the player had cancelled it.
|
||||
if (order.type !== 'build' && order.type !== 'assist' && order.type !== 'repair') {
|
||||
//
|
||||
// `guard` is in the list because guarding a structure IS construction work for a builder;
|
||||
// that case sets and clears the link itself as the target's needs change.
|
||||
if (order.type !== 'build' && order.type !== 'assist' && order.type !== 'repair'
|
||||
&& order.type !== 'guard') {
|
||||
e.buildTargetId = 0;
|
||||
}
|
||||
|
||||
|
|
@ -1050,10 +1120,10 @@ function stepOrders(state, rules) {
|
|||
const slack = Math.max(rules.constants.arriveSlackPx, e.radius * 1.2);
|
||||
if (d <= slack || (e.noPath && e.stuckTicks > rules.constants.stuckGiveUpSec * rules.constants.tickHz)) {
|
||||
if (order.type === 'patrol') {
|
||||
order.leg = order.leg === 0 ? 1 : 0;
|
||||
advancePatrol(order);
|
||||
e.path = null; e.noPath = false; e.stuckTicks = 0;
|
||||
seekTo(state, e, ...(order.leg === 0
|
||||
? [order.sx ?? order.x, order.sy ?? order.y] : [order.fromX, order.fromY]));
|
||||
const next = patrolWaypoint(order);
|
||||
seekTo(state, e, next.x, next.y);
|
||||
} else {
|
||||
e.orders.shift();
|
||||
e.path = null; e.movingTo = null; e.stuckTicks = 0; e.noPath = false;
|
||||
|
|
@ -1090,16 +1160,28 @@ function stepOrders(state, rules) {
|
|||
|
||||
case 'guard': {
|
||||
const target = entityById(state, order.targetId);
|
||||
if (!target) { e.orders.shift(); break; }
|
||||
if (!target) { e.orders.shift(); e.buildTargetId = 0; break; }
|
||||
// A builder guarding a STRUCTURE works on it rather than standing next to it with its
|
||||
// arms folded: it mends the damage, then lends its build power to whatever the
|
||||
// structure is making. Escorting is the side effect; the labour is the point, and it
|
||||
// is what a player means by parking the Commander on a factory.
|
||||
//
|
||||
// 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.
|
||||
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);
|
||||
if (d > ts * 3) {
|
||||
if (d > reach) {
|
||||
if (!e.path && !e.wantPath) seekTo(state, e, target.x, target.y);
|
||||
e.movingTo = { x: target.x, y: target.y };
|
||||
e.buildTargetId = 0;
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
e.path = null; e.movingTo = null;
|
||||
e.buildTargetId = chore ? target.id : 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
|
||||
import { ensureSheets, sheetFrameSize } from './TAArt.js';
|
||||
import { colorInt } from './TAFx.js';
|
||||
import { patrolWaypoint } from './TALogic.js';
|
||||
|
||||
const CHUNK_PX = 1024;
|
||||
const ZOOMS = [0.5, 0.7, 1.0, 1.4];
|
||||
|
|
@ -102,6 +103,10 @@ export default class TAWorldView {
|
|||
this._addWorld(this.gGhost);
|
||||
|
||||
this.sprites = new Map(); // entity id -> { img, turret }
|
||||
// Ids the Commander-death cascade has already blown up. The entities are still in the
|
||||
// simulation — the match ended the moment the Commander died and nothing steps after that
|
||||
// — so this is the view's own record of what it has stopped drawing.
|
||||
this.vaporised = new Set();
|
||||
this.selection = new Set();
|
||||
this.queueSelection = null; // { unitId, index } — a highlighted entry in a drawn order queue
|
||||
this.placement = null; // { def, tx, ty, legal }
|
||||
|
|
@ -425,6 +430,9 @@ export default class TAWorldView {
|
|||
|
||||
for (const e of state.entities) {
|
||||
if (e.dead) continue;
|
||||
// Left out of `live`, so the reaper below destroys its sprites and everything drawn
|
||||
// inside this loop for it — rings, bars, dials — goes with them.
|
||||
if (this.vaporised.size && this.vaporised.has(e.id)) continue;
|
||||
live.add(e.id);
|
||||
const def = rules.defById[e.defId];
|
||||
const shown = this.visibleToPlayer(e);
|
||||
|
|
@ -658,13 +666,34 @@ export default class TAWorldView {
|
|||
const col = ORDER_COLORS[o.type] ?? 0x9fe8ff;
|
||||
const active = i === 0;
|
||||
g.lineStyle(active ? 2.5 : 1.5, col, active ? 0.9 : 0.5);
|
||||
g.lineBetween(px, py, pt.x, pt.y);
|
||||
if (o.type === 'build') {
|
||||
g.lineStyle(2, col, 0.9);
|
||||
g.strokeRect(pt.x - 14, pt.y - 14, 28, 28);
|
||||
} else {
|
||||
|
||||
// A patrol draws as the whole closed circuit rather than a single waypoint — the
|
||||
// player built that route point by point and needs to see it as one shape. Its first
|
||||
// point is where the unit was standing when the order was given, so the loop closing
|
||||
// back on itself is literal rather than decorative.
|
||||
const route = o.type === 'patrol' && o.route?.length >= 4 ? o.route : null;
|
||||
if (route) {
|
||||
const n = route.length >> 1;
|
||||
g.lineBetween(px, py, pt.x, pt.y);
|
||||
for (let k = 0; k < n; k++) {
|
||||
const a = k * 2, b = ((k + 1) % n) * 2;
|
||||
g.lineBetween(route[a], route[a + 1], route[b], route[b + 1]);
|
||||
}
|
||||
g.fillStyle(col, active ? 0.9 : 0.6);
|
||||
g.fillCircle(pt.x, pt.y, active ? 5 : 4);
|
||||
for (let k = 0; k < n; k++) g.fillCircle(route[k * 2], route[k * 2 + 1], active ? 5 : 4);
|
||||
// The waypoint being headed for right now gets a ring, so a long route still shows
|
||||
// which way round the unit is going.
|
||||
g.lineStyle(2, col, 0.9);
|
||||
g.strokeCircle(pt.x, pt.y, 9);
|
||||
} else {
|
||||
g.lineBetween(px, py, pt.x, pt.y);
|
||||
if (o.type === 'build') {
|
||||
g.lineStyle(2, col, 0.9);
|
||||
g.strokeRect(pt.x - 14, pt.y - 14, 28, 28);
|
||||
} else {
|
||||
g.fillStyle(col, active ? 0.9 : 0.6);
|
||||
g.fillCircle(pt.x, pt.y, active ? 5 : 4);
|
||||
}
|
||||
}
|
||||
// A clicked-and-held queue entry gets a bright ring so its selection is unambiguous
|
||||
// before the player commits to deleting it.
|
||||
|
|
@ -679,7 +708,14 @@ export default class TAWorldView {
|
|||
|
||||
/** World position an order points at, or null if it has no meaningful location. */
|
||||
_orderPoint(o) {
|
||||
if (o.type === 'move' || o.type === 'attackMove' || o.type === 'patrol') {
|
||||
// A patrol's location is whichever waypoint it is currently running to, not the point the
|
||||
// player happened to click first — the sim's own reading of the order, so the marker and
|
||||
// the unit never disagree about where it is going.
|
||||
if (o.type === 'patrol') {
|
||||
const p = patrolWaypoint(o);
|
||||
return Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null;
|
||||
}
|
||||
if (o.type === 'move' || o.type === 'attackMove') {
|
||||
const x = o.sx ?? o.x, y = o.sy ?? o.y;
|
||||
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,18 @@ const EDGE_PAN = 26; // screen-edge scroll band, px
|
|||
const PAN_SPEED = 1100; // px/s at zoom 1
|
||||
const DRAG_MIN = 8; // px before a click becomes a drag-box
|
||||
|
||||
/** What the toast says when a command is armed and waiting for a world click. */
|
||||
const COMMAND_PROMPTS = {
|
||||
move: 'Move: pick a point',
|
||||
attack: 'Attack: pick an enemy',
|
||||
attackMove: 'Attack-move: pick a target point',
|
||||
patrol: 'Patrol: pick a point — hold CTRL and keep clicking for a route',
|
||||
guard: 'Guard: pick a friendly unit',
|
||||
repair: 'Repair: pick a damaged friendly',
|
||||
assist: 'Assist: pick a site or a working factory',
|
||||
setRally: 'Rally: pick a point',
|
||||
};
|
||||
|
||||
export default class TotalAnnihilationGame extends Phaser.Scene {
|
||||
constructor() { super('TotalAnnihilationGame'); }
|
||||
|
||||
|
|
@ -44,7 +56,12 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
|
|||
this.selection = new Set();
|
||||
this.groups = new Map();
|
||||
this.placement = null;
|
||||
this.pendingCommand = null; // 'attackMove' | 'patrol' | 'guard' | 'attack'
|
||||
// An order armed by a command button (or its hotkey) and waiting for a world click to say
|
||||
// where or on what. See COMMAND_PROMPTS for the set.
|
||||
this.pendingCommand = null;
|
||||
this.chainedCommand = false;
|
||||
this._deathSeq = null;
|
||||
this._commanderDeath = null;
|
||||
this.simSpeed = 1;
|
||||
this.settings = readJson(SETTINGS_KEY, { edgeScroll: true, fog: true });
|
||||
this.lastSfxAt = {}; // per-sound-key gate — see _throttledSfx
|
||||
|
|
@ -183,6 +200,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
|
|||
this.hud = new TAHud(this, this.rules, state, this.view, playerArmy, {
|
||||
onBuildPick: (def) => this._beginPlacement(def),
|
||||
onProduce: (def, n) => this._enqueue(def, n),
|
||||
onCommand: (id) => this._pickCommand(id),
|
||||
onMinimapJump: (x, y) => this.view.centerOn(x, y),
|
||||
});
|
||||
if (this.music?._objs?.length) this.view.ignoreOnWorldCam(this.music._objs);
|
||||
|
|
@ -195,6 +213,8 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
_endMatch() {
|
||||
this._deathSeq = null;
|
||||
this._commanderDeath = null;
|
||||
this.hud?.destroy(); this.hud = null;
|
||||
this.fx?.destroy(); this.fx = null;
|
||||
this.view?.destroy(); this.view = null;
|
||||
|
|
@ -227,6 +247,12 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
|
|||
this._cancelPlacement();
|
||||
this.hud.toast('Build queue ended');
|
||||
}
|
||||
// Same for a chained order run — letting go of CTRL is how the player says "that's the
|
||||
// whole patrol route".
|
||||
if (this.chainedCommand && !this._queueModDown()) {
|
||||
this.chainedCommand = false;
|
||||
this.pendingCommand = null;
|
||||
}
|
||||
|
||||
// The AI thinks on sim ticks, not render frames, so it is stepped inside the same
|
||||
// fixed-step loop the simulation uses — otherwise its cadence would ride framerate.
|
||||
|
|
@ -244,14 +270,17 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
|
|||
const { nanoLinks, projectiles } = this.view.render(st.alpha);
|
||||
this.fx.draw(delta, nanoLinks, projectiles, time);
|
||||
this._syncSelection();
|
||||
this.hud.update(time, this._selectedEntities(), this.placement?.def ?? null);
|
||||
this.hud.update(time, this._selectedEntities(), this.placement?.def ?? null, this.pendingCommand);
|
||||
this._drawDragBox();
|
||||
|
||||
if (this.meta?.mode === 'campaign' && time - this._lastAutosave > 60000) {
|
||||
this._lastAutosave = time;
|
||||
this.saveGame();
|
||||
}
|
||||
if (st.over) this._finish(st.over);
|
||||
if (this._deathSeq) this._stepDeathSequence(time);
|
||||
// The result screen waits for the Commander's send-off. Nothing is stepping the simulation
|
||||
// by now — tick() returns immediately once state.over is set — so this only delays the UI.
|
||||
if (st.over && !this._deathSeq) this._finish(st.over);
|
||||
}
|
||||
|
||||
_onSimEvent(ev) {
|
||||
|
|
@ -267,6 +296,10 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
|
|||
if (def?.isCommander) {
|
||||
// Play the nuclear explosion at full volume for maximum impact
|
||||
this._throttledSfx('sfx-ta-nuclear', 300, 1);
|
||||
// Kept for the armyEliminated event that follows in this same tick — that is the one
|
||||
// that knows whether losing the Commander actually ended this army, which under the
|
||||
// annihilation rule it does not.
|
||||
this._commanderDeath = { army: ev.army, x: ev.x, y: ev.y };
|
||||
} else if (def?.moveClass) {
|
||||
// Infantry get the flesh-and-blood cue; anything with an engine — tracked, hovering
|
||||
// or flying — gets the machinery one.
|
||||
|
|
@ -280,6 +313,9 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
|
|||
if (ev.t === 'armyEliminated') {
|
||||
const name = this.rules.armies[ev.army]?.name ?? 'Enemy';
|
||||
const lost = ev.reason === 'commanderLost';
|
||||
if (lost && this._commanderDeath?.army === ev.army) {
|
||||
this._startDeathSequence(ev.army, this._commanderDeath.x, this._commanderDeath.y);
|
||||
}
|
||||
if (ev.army !== this.playerArmy) {
|
||||
this.hud.toast(lost ? `${name} Commander destroyed` : `${name} eliminated`, '#9ce6a0');
|
||||
} else if (lost) {
|
||||
|
|
@ -312,6 +348,91 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Commander death sequence
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The Commander going up is the loudest thing in this game, so it gets the longest cue: its
|
||||
* own detonation, then every visible thing that army owned cooking off in a wave from the
|
||||
* epicentre, then one map-wide flash. The whole thing is timed to the length of the nuclear
|
||||
* sample, so the picture and the sound finish together, and the result screen is held back
|
||||
* until it has played out.
|
||||
*
|
||||
* Purely cosmetic. checkResult already decided the match — nothing here touches state, which
|
||||
* is why it can be skipped or retimed freely without the outcome moving.
|
||||
*/
|
||||
_startDeathSequence(army, x, y) {
|
||||
if (this._deathSeq || !this.match || !this.view) return;
|
||||
const now = this.time.now;
|
||||
const total = this._sfxDurationMs('sfx-ta-nuclear', 10000);
|
||||
|
||||
// Only what the player can actually see, ordered outward from the epicentre so the cascade
|
||||
// reads as a wave leaving the Commander rather than as random popping.
|
||||
const doomed = this.match.entities
|
||||
.filter((e) => !e.dead && e.army === army && this.view.visibleToPlayer(e))
|
||||
.map((e) => ({
|
||||
id: e.id, x: e.x, y: e.y, isBuilding: e.isBuilding, radius: e.radius,
|
||||
d: Math.hypot(e.x - x, e.y - y),
|
||||
}))
|
||||
.sort((a, b) => a.d - b.d || a.id - b.id);
|
||||
|
||||
const from = now + total * 0.14;
|
||||
const span = total * 0.58;
|
||||
doomed.forEach((d, i) => {
|
||||
d.at = from + (doomed.length > 1 ? (i / (doomed.length - 1)) * span : 0);
|
||||
});
|
||||
|
||||
this._deathSeq = { x, y, doomed, next: 0, novaAt: now + total * 0.76, endAt: now + total, nova: false };
|
||||
|
||||
// Open on something clearly bigger than an ordinary death, since the def's own explosion
|
||||
// is sized for the damage it deals rather than for the drama.
|
||||
const blast = (this.rules.unitById.commander?.deathExplosion?.radius ?? 240) * 2.5;
|
||||
this.fx.onEvent({ t: 'bigExplosion', x, y, radius: blast });
|
||||
this.cameras.main.shake(700, 0.016);
|
||||
}
|
||||
|
||||
/** Advance the cascade. Synthesised FX events — none of these came from the simulation. */
|
||||
_stepDeathSequence(now) {
|
||||
const seq = this._deathSeq;
|
||||
while (seq.next < seq.doomed.length && seq.doomed[seq.next].at <= now) {
|
||||
const d = seq.doomed[seq.next++];
|
||||
this.view.vaporised.add(d.id);
|
||||
this.selection.delete(d.id);
|
||||
this.fx.onEvent({ t: 'unitDestroyed', x: d.x, y: d.y, radius: d.radius, isBuilding: d.isBuilding });
|
||||
this._throttledSfx(d.isBuilding ? 'sfx-ta-vehicle-loss' : 'sfx-ta-unit-loss', 110);
|
||||
}
|
||||
if (!seq.nova && now >= seq.novaAt) {
|
||||
seq.nova = true;
|
||||
// Sized off the WORLD, not the screen, so it swallows the view at any zoom instead of
|
||||
// being a big ring the player can see the edge of.
|
||||
this.fx.onEvent({
|
||||
t: 'bigExplosion', x: seq.x, y: seq.y,
|
||||
radius: Math.hypot(this.match.worldW, this.match.worldH),
|
||||
});
|
||||
this.cameras.main.shake(1400, 0.030);
|
||||
this.cameras.main.flash(1100, 255, 244, 214);
|
||||
this.view.uiCam?.flash(1100, 255, 244, 214);
|
||||
}
|
||||
if (now >= seq.endAt) this._deathSeq = null;
|
||||
}
|
||||
|
||||
/** Run the rest of the sequence instantly — for a player who has seen it before. */
|
||||
_skipDeathSequence() {
|
||||
if (!this._deathSeq) return;
|
||||
for (let i = this._deathSeq.next; i < this._deathSeq.doomed.length; i++) {
|
||||
this.view.vaporised.add(this._deathSeq.doomed[i].id);
|
||||
}
|
||||
this._deathSeq = null;
|
||||
this.cameras.main.resetFX();
|
||||
}
|
||||
|
||||
/** Length of a loaded sound in ms, straight off the audio cache. */
|
||||
_sfxDurationMs(key, fallbackMs) {
|
||||
const secs = this.cache.audio.get(key)?.duration ?? 0;
|
||||
return secs > 0.25 ? secs * 1000 : fallbackMs;
|
||||
}
|
||||
|
||||
_finish(over) {
|
||||
this.phase = 'result';
|
||||
const won = over.winner === this.playerArmy;
|
||||
|
|
@ -408,6 +529,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
|
|||
const code = ev.key?.toLowerCase();
|
||||
if (ev.code === 'Escape') {
|
||||
if (this.placement) { this._cancelPlacement(); return; }
|
||||
if (this._deathSeq) { this._skipDeathSequence(); return; }
|
||||
if (this.pendingCommand) { this.pendingCommand = null; return; }
|
||||
this.togglePause();
|
||||
return;
|
||||
|
|
@ -431,11 +553,11 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
|
|||
return;
|
||||
}
|
||||
switch (code) {
|
||||
case 'a': this.pendingCommand = 'attackMove'; this.hud.toast('Attack-move: pick a target point'); break;
|
||||
case 'p': this.pendingCommand = 'patrol'; this.hud.toast('Patrol: pick a point'); break;
|
||||
case 'g': this.pendingCommand = 'guard'; this.hud.toast('Guard: pick a friendly unit'); break;
|
||||
case 'x': this._order({ type: 'stop' }); break;
|
||||
case 'h': this._order({ type: 'hold' }); break;
|
||||
case 'a': this._pickCommand('attackMove'); break;
|
||||
case 'p': this._pickCommand('patrol'); break;
|
||||
case 'g': this._pickCommand('guard'); break;
|
||||
case 'x': this._pickCommand('stop'); break;
|
||||
case 'h': this._pickCommand('hold'); break;
|
||||
case 'q': this.view.zoomBy(-1, GAME_WIDTH / 2, GAME_HEIGHT / 2); break;
|
||||
case 'e': this.view.zoomBy(1, GAME_WIDTH / 2, GAME_HEIGHT / 2); break;
|
||||
case ' ': this._jumpToAction(); break;
|
||||
|
|
@ -651,15 +773,70 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
|
|||
this._cancelQueueOrder(qs);
|
||||
}
|
||||
|
||||
/**
|
||||
* A command button was pressed. Stop and Hold take effect on the spot; everything else arms
|
||||
* a pending command that the next world click resolves, which is exactly what the A/P/G
|
||||
* hotkeys have always done — the buttons are a second door onto the same mechanism, not a
|
||||
* parallel one. Pressing the armed command again disarms it.
|
||||
*/
|
||||
_pickCommand(id) {
|
||||
if (this.pendingCommand === id) { this.pendingCommand = null; return; }
|
||||
if (id === 'stop' || id === 'hold') {
|
||||
this.pendingCommand = null;
|
||||
this._order({ type: id });
|
||||
return;
|
||||
}
|
||||
this._cancelPlacement();
|
||||
this.pendingCommand = id;
|
||||
this.hud.toast(COMMAND_PROMPTS[id] ?? 'Pick a target');
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a command armed for as long as CTRL is held, so one press of the button starts a RUN
|
||||
* of clicks: a patrol route, a string of attack-move waypoints, a list of repair targets.
|
||||
* Released in update() the moment CTRL comes up, exactly like a chained build placement.
|
||||
*/
|
||||
_holdCommand(cmd, queue) {
|
||||
if (!queue) return;
|
||||
this.pendingCommand = cmd;
|
||||
this.chainedCommand = true;
|
||||
}
|
||||
|
||||
_applyPendingCommand(w, queue) {
|
||||
const cmd = this.pendingCommand;
|
||||
this.pendingCommand = null;
|
||||
if (cmd === 'guard') {
|
||||
|
||||
// Orders that name a thing rather than a place. Each one rejects the wrong kind of target
|
||||
// with a toast instead of silently doing nothing, because a command that just evaporates
|
||||
// reads as a broken button.
|
||||
if (cmd === 'guard' || cmd === 'attack' || cmd === 'repair' || cmd === 'assist') {
|
||||
const t = this._entityAt(w.x, w.y);
|
||||
if (t && t.army === this.playerArmy) this._order({ type: 'guard', targetId: t.id }, queue);
|
||||
if (!t) { this.hud.toast('Nothing there', '#ff9a6b'); return; }
|
||||
const mine = t.army === this.playerArmy;
|
||||
if (cmd === 'attack' && mine) { this.hud.toast('That is yours', '#ff9a6b'); return; }
|
||||
if (cmd !== 'attack' && !mine) { this.hud.toast('Pick one of yours', '#ff9a6b'); return; }
|
||||
if (cmd === 'attack' && !this.view.visibleToPlayer(t)) { this.hud.toast('Nothing there', '#ff9a6b'); return; }
|
||||
this._order({ type: cmd, targetId: t.id }, queue);
|
||||
this._holdCommand(cmd, queue);
|
||||
return;
|
||||
}
|
||||
|
||||
// Rally is a FACTORY command, not a unit order — it never touches an order queue.
|
||||
if (cmd === 'setRally') {
|
||||
const factories = this._selectedEntities()
|
||||
.filter((e) => e.isBuilding && !e.site && this.rules.defById[e.defId].builds?.length);
|
||||
if (!factories.length) return;
|
||||
for (const f of factories) {
|
||||
Logic.issueOrder(this.match, this.rules, {
|
||||
army: this.playerArmy, order: { type: 'setRally', factoryId: f.id, x: w.x, y: w.y },
|
||||
});
|
||||
}
|
||||
this.hud.toast('Rally point set');
|
||||
return;
|
||||
}
|
||||
|
||||
this._order({ type: cmd, x: w.x, y: w.y }, queue);
|
||||
this._holdCommand(cmd, queue);
|
||||
}
|
||||
|
||||
_entityAt(x, y) {
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ 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) |
|
||||
| 20–27 | Commands: move, attack, attack-move, stop, hold, patrol, guard, assist |
|
||||
| 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
|
||||
next to the buildings at 17 rather than with the other units. Nothing reads a row as a
|
||||
|
|
|
|||
|
|
@ -303,6 +303,21 @@ section('2c. View layering and first frame');
|
|||
}
|
||||
}
|
||||
|
||||
// The Commander-death cascade blows units up one at a time while the simulation sits
|
||||
// frozen on state.over, so the view has to be able to stop drawing something that is still
|
||||
// very much alive in state. Everything drawn for that entity has to go, not just its hull.
|
||||
{
|
||||
const victim = st.entities.find((e) => !e.isBuilding && !e.site);
|
||||
view.selection.add(victim.id);
|
||||
view.render(0);
|
||||
check('a live entity draws before it is vaporised', view.sprites.has(victim.id));
|
||||
view.vaporised.add(victim.id);
|
||||
view.render(0);
|
||||
check('a vaporised entity stops being drawn', !view.sprites.has(victim.id));
|
||||
view.vaporised.clear();
|
||||
view.selection.clear();
|
||||
}
|
||||
|
||||
// Depth bands must stay in the intended order.
|
||||
check('bars draw above actors', DEPTHS.bars > DEPTHS.actor);
|
||||
check('fog draws above everything', DEPTHS.fog > Math.max(DEPTHS.fxOver, DEPTHS.bars, DEPTHS.actor));
|
||||
|
|
@ -529,11 +544,12 @@ section('2d. Container hitbox lint');
|
|||
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 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.
|
||||
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]) {
|
||||
|
|
@ -541,6 +557,78 @@ section('2d. Container hitbox lint');
|
|||
if (!n) continue;
|
||||
check(`${d.id}'s build options fit one grid row`, n <= cols, `${n} options vs ${cols} columns`);
|
||||
}
|
||||
|
||||
// ---- command bar ----
|
||||
// The order buttons are the only way most of these verbs are discoverable now that the hint
|
||||
// string is gone, so a button naming an order the engine does not implement — or a grid that
|
||||
// silently drops its last row off the panel — is a dead end the player cannot route around.
|
||||
const cmdBlock = /const COMMANDS = \[([\s\S]*?)\n\];/.exec(hudSrc)?.[1] ?? '';
|
||||
const cmdIds = [...cmdBlock.matchAll(/\{ id: '(\w+)'/g)].map((m) => m[1]);
|
||||
const cmdCols = Number(/const CMD_COLS = (\d+)/.exec(hudSrc)?.[1]);
|
||||
check('the HUD declares command buttons', cmdIds.length > 0);
|
||||
check('the command grid fits the bar', cmdIds.length <= cmdCols * 2,
|
||||
`${cmdIds.length} buttons in ${cmdCols}x2`);
|
||||
// Every id must be something issueOrder actually accepts, or the button does nothing.
|
||||
const engineOrders = new Set([
|
||||
...(/const ORDER_TYPES = new Set\(\[([\s\S]*?)\]\)/.exec(
|
||||
readFileSync(join(ROOT, 'src/games/totalannihilation/TALogic.js'), 'utf8'))?.[1] ?? '')
|
||||
.split(',').map((t) => t.trim().replace(/'/g, '')).filter(Boolean),
|
||||
'factoryEnqueue', 'factoryCancel', 'setRally',
|
||||
]);
|
||||
for (const id of cmdIds) {
|
||||
check(`command "${id}" is an order the engine implements`, engineOrders.has(id));
|
||||
}
|
||||
for (const id of ['move', 'attack', 'attackMove', 'patrol', 'guard', 'stop', 'hold']) {
|
||||
check(`the bar offers ${id}`, cmdIds.includes(id));
|
||||
}
|
||||
for (const id of ['repair', 'assist', 'setRally']) {
|
||||
check(`the bar offers ${id} for builders and factories`, cmdIds.includes(id));
|
||||
}
|
||||
// Reclaim has no order, no wreck entity and no payout — it must not appear as a button until
|
||||
// it does, or it is a control that silently fails.
|
||||
check('no button promises an unimplemented verb',
|
||||
!cmdIds.some((id) => !engineOrders.has(id)), cmdIds.filter((id) => !engineOrders.has(id)).join(','));
|
||||
// A prompt per targeted command, so an armed button always says what it wants.
|
||||
const prompts = /const COMMAND_PROMPTS = \{([\s\S]*?)\n\};/.exec(gameSrc)?.[1] ?? '';
|
||||
for (const id of cmdIds) {
|
||||
// Instant commands (Stop, Hold) take effect on the button press and never wait for a click,
|
||||
// so they have nothing to prompt for.
|
||||
if (new RegExp(`\\{ id: '${id}'[^}]*instant: true`).test(cmdBlock)) continue;
|
||||
check(`command "${id}" has a prompt`, new RegExp(`\\b${id}:`).test(prompts));
|
||||
}
|
||||
// The old hint string is gone; nothing should still be trying to set it.
|
||||
check('the hint line is fully removed', !/this\.hint\b/.test(hudSrc));
|
||||
check('every command icon has a frame',
|
||||
cmdIds.every((id) => id === 'setRally' ? rules.commandIcons.rally != null : rules.commandIcons[id] != null),
|
||||
Object.keys(rules.commandIcons).join(','));
|
||||
|
||||
// ---- Commander death sequence ----
|
||||
// A Phaser scene cannot be built here, so this is a source lint over the four things that
|
||||
// would each break the cue silently rather than loudly.
|
||||
// Anchored on the method definition, not the call site — `\n _name(` only matches a class
|
||||
// member at this indent, where `_name(` alone finds `this._startDeathSequence(...)` first
|
||||
// and lints the wrong function body entirely.
|
||||
const seq = /\n {2}_startDeathSequence\([\s\S]*?\n {2}\}/.exec(gameSrc)?.[0] ?? '';
|
||||
check('the Commander death sequence exists', seq.length > 0);
|
||||
// Timed off the sample rather than a magic number, so re-cutting the audio retimes the cue.
|
||||
check('the cascade is timed to the nuclear sample', /_sfxDurationMs\('sfx-ta-nuclear'/.test(seq),
|
||||
'a hardcoded duration would drift from the audio');
|
||||
// Only what the player can see is blown up — an off-screen cascade is wasted work and
|
||||
// reveals an army's layout through fog.
|
||||
check('the cascade only consumes visible entities', /visibleToPlayer/.test(seq));
|
||||
// The result screen has to wait, which is the whole point of the request.
|
||||
check('the result screen waits for the sequence',
|
||||
/if \(st\.over && !this\._deathSeq\) this\._finish/.test(gameSrc),
|
||||
'the victory window would cover its own explosion');
|
||||
// It must fire on elimination, not merely on a Commander dying — under the annihilation rule
|
||||
// an army fights on without one, and detonating its whole force would be a lie.
|
||||
check('the cascade fires on elimination, not on the death alone',
|
||||
/reason === 'commanderLost'|lost && this\._commanderDeath/.test(gameSrc));
|
||||
// The sample it times against has to actually be loaded for this game.
|
||||
const manifest = readFileSync(join(ROOT, 'src/data/assetManifest.js'), 'utf8');
|
||||
const taBlock = /totalannihilation: \[([\s\S]*?)\n \],/.exec(manifest)?.[1] ?? '';
|
||||
check('the nuclear sample is in the Total Annihilation manifest', /'nuclear'/.test(taBlock),
|
||||
'the cue would silently fall back to its default length');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -729,6 +817,137 @@ section('4b. Order queueing (CTRL)');
|
|||
check('a queued build list completes', finished === 3, `${finished}/3 built`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('4c. Patrol routes');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// A patrol carries a `route`: a flat circuit whose first point is where the unit stood when
|
||||
// the order was given. Ctrl-clicking more points EXTENDS that circuit rather than queueing a
|
||||
// second patrol behind the first — which would never run, since a patrol never completes.
|
||||
const map = generateMap(rules, { seed: 909, size: 'small', symmetry: 'mirror-x' });
|
||||
const fresh = () => {
|
||||
const st = L.createMatch(rules, { seed: 909, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const s = st.starts.find((x) => x.army === 0);
|
||||
return { st, e: L.spawnUnit(st, rules, 0, 'tank', s.x * st.tileSize, s.y * st.tileSize) };
|
||||
};
|
||||
const patrol = (st, e, x, y, queue) => L.issueOrder(st, rules, {
|
||||
army: 0, unitIds: [e.id], order: { type: 'patrol', x, y }, queue,
|
||||
});
|
||||
|
||||
{
|
||||
// One click is still the classic there-and-back: a two-point circuit, home included.
|
||||
const { st, e } = fresh();
|
||||
const x0 = e.x, y0 = e.y;
|
||||
patrol(st, e, x0 + 400, y0, false);
|
||||
const o = e.orders[0];
|
||||
check('a single patrol click builds a two-point circuit',
|
||||
o?.type === 'patrol' && o.route?.length === 4, `route ${JSON.stringify(o?.route)}`);
|
||||
check('the circuit starts where the unit stood',
|
||||
Math.abs(o.route[0] - x0) < 1e-6 && Math.abs(o.route[1] - y0) < 1e-6);
|
||||
check('and heads for the clicked point first', o.leg === 1);
|
||||
}
|
||||
|
||||
{
|
||||
// Ctrl-clicking three more points extends the one order to a five-point circuit.
|
||||
const { st, e } = fresh();
|
||||
const x0 = e.x, y0 = e.y;
|
||||
patrol(st, e, x0 + 300, y0, false);
|
||||
patrol(st, e, x0 + 300, y0 + 300, true);
|
||||
patrol(st, e, x0, y0 + 300, true);
|
||||
patrol(st, e, x0 - 300, y0, true);
|
||||
check('ctrl-clicking extends one patrol order', e.orders.length === 1,
|
||||
`${e.orders.length} orders`);
|
||||
check('every clicked point joins the route', e.orders[0].route.length === 10,
|
||||
`${e.orders[0].route.length / 2} points`);
|
||||
}
|
||||
|
||||
{
|
||||
// A non-queued patrol replaces the route rather than growing it forever.
|
||||
const { st, e } = fresh();
|
||||
patrol(st, e, e.x + 300, e.y, false);
|
||||
patrol(st, e, e.x + 300, e.y + 300, true);
|
||||
patrol(st, e, e.x - 200, e.y, false);
|
||||
check('an unqueued patrol starts a fresh route',
|
||||
e.orders.length === 1 && e.orders[0].route.length === 4,
|
||||
`${e.orders.length} orders, ${e.orders[0].route.length / 2} points`);
|
||||
}
|
||||
|
||||
{
|
||||
// The circuit is actually flown: over a long run the unit must reach every corner, and the
|
||||
// order must never complete.
|
||||
const { st, e } = fresh();
|
||||
const x0 = e.x, y0 = e.y, R = 260;
|
||||
patrol(st, e, x0 + R, y0, false);
|
||||
patrol(st, e, x0 + R, y0 + R, true);
|
||||
patrol(st, e, x0, y0 + R, true);
|
||||
const route = e.orders[0].route.slice();
|
||||
const n = route.length >> 1;
|
||||
const visited = new Array(n).fill(false);
|
||||
const legsSeen = new Set();
|
||||
for (let i = 0; i < 240 * HZ; i++) {
|
||||
L.tick(st, rules);
|
||||
if (e.dead || !e.orders.length) break;
|
||||
legsSeen.add(e.orders[0].leg);
|
||||
for (let k = 0; k < n; k++) {
|
||||
if (Math.hypot(e.x - route[k * 2], e.y - route[k * 2 + 1]) < st.tileSize) visited[k] = true;
|
||||
}
|
||||
}
|
||||
check('a patrol order never completes', e.orders.length === 1 && e.orders[0].type === 'patrol');
|
||||
check('the unit visits every point on the route', visited.every(Boolean),
|
||||
`reached ${visited.filter(Boolean).length} of ${n}`);
|
||||
check('and cycles through every leg', legsSeen.size === n, `${legsSeen.size} of ${n} legs`);
|
||||
}
|
||||
|
||||
{
|
||||
// Orders written by a save from before routes existed keep working untouched.
|
||||
const { st, e } = fresh();
|
||||
const legacy = { type: 'patrol', sx: e.x + 200, sy: e.y, fromX: e.x, fromY: e.y, leg: 0 };
|
||||
e.orders.push(legacy);
|
||||
const a = L.patrolWaypoint(legacy);
|
||||
legacy.leg = 1;
|
||||
const b = L.patrolWaypoint(legacy);
|
||||
check('a legacy two-point patrol still resolves both ends',
|
||||
Math.abs(a.x - (e.x + 200)) < 1e-6 && Math.abs(b.x - e.x) < 1e-6,
|
||||
`${JSON.stringify(a)} / ${JSON.stringify(b)}`);
|
||||
}
|
||||
|
||||
{
|
||||
// Aircraft run the same routes; they just never stop at the corners.
|
||||
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
|
||||
raw.constants.eliminateWhenUnrecoverable = false;
|
||||
const ar = compileRules(raw);
|
||||
const W = 64, H = 64, TS = ar.constants.tileSize;
|
||||
const px = (t) => t * TS + TS / 2;
|
||||
const st = L.createMatch(ar, {
|
||||
seed: 5, victory: 'annihilation',
|
||||
map: { w: W, h: H, terrain: new Uint8Array(W * H).fill(ar.terrainById.ground.index), starts: [], theme: 'grasslands' },
|
||||
armies: [{ armyId: 'arm' }, { armyId: 'core' }],
|
||||
});
|
||||
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;
|
||||
const f = L.spawnUnit(st, ar, 0, 'fighter', px(30), px(30));
|
||||
const pts = [[px(40), px(30)], [px(40), px(40)], [px(30), px(40)]];
|
||||
pts.forEach(([x, y], i) => L.issueOrder(st, ar, {
|
||||
army: 0, unitIds: [f.id], order: { type: 'patrol', x, y }, queue: i > 0,
|
||||
}));
|
||||
const route = f.orders[0].route.slice();
|
||||
const n = route.length >> 1;
|
||||
const visited = new Array(n).fill(false);
|
||||
for (let i = 0; i < 180 * HZ; i++) {
|
||||
L.tick(st, ar);
|
||||
if (f.dead || !f.orders.length) break;
|
||||
for (let k = 0; k < n; k++) {
|
||||
if (Math.hypot(f.x - route[k * 2], f.y - route[k * 2 + 1]) < TS * 1.5) visited[k] = true;
|
||||
}
|
||||
}
|
||||
check('an aircraft flies a multi-point patrol route', visited.every(Boolean),
|
||||
`reached ${visited.filter(Boolean).length} of ${n}`);
|
||||
check('and stays airborne doing it', f.liftFrac === 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('5. Movement, separation and size classes');
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1112,6 +1331,125 @@ section('6c. Repair');
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('6c2. Guarding a structure');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// A builder told to guard a structure works on it: damage first, then whatever it is
|
||||
// producing. Both halves are measured against a control that has no guard, because "the
|
||||
// factory eventually finished" proves nothing on its own.
|
||||
const raw = JSON.parse(readFileSync(join(ROOT, 'data/totalannihilation-rules.json'), 'utf8'));
|
||||
raw.constants.eliminateWhenUnrecoverable = false;
|
||||
const gr = compileRules(raw);
|
||||
const map = generateMap(gr, { seed: 606, size: 'small', symmetry: 'mirror-x' });
|
||||
const plant = gr.buildingById.vehicleplant;
|
||||
|
||||
/** A finished Vehicle Plant with the Commander parked next to it (or not). */
|
||||
const rig = (withGuard, damage = 0) => {
|
||||
const st = L.createMatch(gr, { seed: 606, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] });
|
||||
const cmd = st.entities.find((e) => e.defId === 'commander' && e.army === 0);
|
||||
let f = null;
|
||||
for (let r = 3; r < 16 && !f; r++) {
|
||||
for (let a = 0; a < 24 && !f; a++) {
|
||||
const ang = (a / 24) * Math.PI * 2;
|
||||
const tx = Math.round(cmd.x / st.tileSize + Math.cos(ang) * r);
|
||||
const ty = Math.round(cmd.y / st.tileSize + Math.sin(ang) * r);
|
||||
if (L.canPlaceAt(st, gr, tx, ty, plant).ok) {
|
||||
f = L.placeBuilding(st, gr, 0, 'vehicleplant', tx, ty);
|
||||
f.site = false; f.progress = 1; f.hp = plant.hp;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (f && damage) f.hp = Math.max(1, plant.hp - damage);
|
||||
if (f && withGuard) {
|
||||
L.issueOrder(st, gr, { army: 0, unitIds: [cmd.id], order: { type: 'guard', targetId: f.id } });
|
||||
}
|
||||
return { st, cmd, f };
|
||||
};
|
||||
const run = (st, sec, onTick) => {
|
||||
for (let i = 0; i < sec * HZ; i++) {
|
||||
st.armies[0].mass = 9999; st.armies[0].energy = 9999;
|
||||
L.tick(st, gr);
|
||||
if (onTick && onTick() === false) return i / HZ;
|
||||
}
|
||||
return sec;
|
||||
};
|
||||
|
||||
{
|
||||
// Repair. The guard has to close on the plant and mend it without any further order.
|
||||
const { st, cmd, f } = rig(true, 3000);
|
||||
check('the guard rig placed a plant', !!f);
|
||||
const before = f.hp;
|
||||
run(st, 60, () => f.hp < f.maxHp);
|
||||
check('a guarding builder repairs the structure', f.hp > before + 500,
|
||||
`${before.toFixed(0)} -> ${f.hp.toFixed(0)} of ${f.maxHp}`);
|
||||
check('and holds a nanolathe link while doing it',
|
||||
cmd.buildTargetId === f.id || f.hp >= f.maxHp);
|
||||
}
|
||||
|
||||
{
|
||||
// Production assist, measured as time-to-first-tank against an unguarded control.
|
||||
const timeToTank = (withGuard) => {
|
||||
const { st, f } = rig(withGuard);
|
||||
L.issueOrder(st, gr, { army: 0, order: { type: 'factoryEnqueue', factoryId: f.id, defId: 'tank', count: 1 } });
|
||||
let made = 0;
|
||||
const t = run(st, 120, () => {
|
||||
made = st.entities.filter((e) => !e.dead && e.defId === 'tank' && e.army === 0).length;
|
||||
return made === 0;
|
||||
});
|
||||
return made ? t : Infinity;
|
||||
};
|
||||
const solo = timeToTank(false);
|
||||
const helped = timeToTank(true);
|
||||
check('an unguarded plant builds its tank', Number.isFinite(solo), `${solo}s`);
|
||||
// Commander buildPower 100 on top of the plant's 100 should roughly halve it; the bar is
|
||||
// set well short of that so a tuning change to either number doesn't make this brittle.
|
||||
check('a guarding builder speeds the factory up', helped < solo * 0.8,
|
||||
`${helped.toFixed(1)}s guarded vs ${solo.toFixed(1)}s alone`);
|
||||
}
|
||||
|
||||
{
|
||||
// Priority. While the plant is hurt the guard mends it INSTEAD of pushing the queue, so a
|
||||
// damaged-and-busy factory heals before its output accelerates.
|
||||
const { st, f } = rig(true, 3000);
|
||||
L.issueOrder(st, gr, { army: 0, order: { type: 'factoryEnqueue', factoryId: f.id, defId: 'tank', count: 1 } });
|
||||
let sawRepairFirst = true;
|
||||
for (let i = 0; i < 6 * HZ; i++) {
|
||||
st.armies[0].mass = 9999; st.armies[0].energy = 9999;
|
||||
L.tick(st, gr);
|
||||
// Any tick where the plant is still damaged must show repair power, not extra build power.
|
||||
if (f.hp < f.maxHp && f._power > (gr.buildingById.vehicleplant.buildPower ?? 0)) sawRepairFirst = false;
|
||||
}
|
||||
check('damage is mended before production is helped', sawRepairFirst,
|
||||
'the guard pushed the queue while the factory was still hurt');
|
||||
}
|
||||
|
||||
{
|
||||
// An intact, idle building needs nothing — the guard must not sit there billing the
|
||||
// economy for work that does not exist.
|
||||
const { st, cmd, f } = rig(true);
|
||||
run(st, 20);
|
||||
check('guarding an idle intact building draws no build power',
|
||||
cmd.buildTargetId === 0 && st.armies[0].mDrain < 1e-6,
|
||||
`link ${cmd.buildTargetId}, drain ${st.armies[0].mDrain.toFixed(2)}`);
|
||||
// ...and picks the work up on its own the moment there is some.
|
||||
L.issueOrder(st, gr, { army: 0, order: { type: 'factoryEnqueue', factoryId: f.id, defId: 'tank', count: 1 } });
|
||||
run(st, 3);
|
||||
check('and starts assisting as soon as the factory has a job', cmd.buildTargetId === f.id);
|
||||
}
|
||||
|
||||
{
|
||||
// Guarding a mobile unit is unchanged: escort only, no nanolathe.
|
||||
const { st, cmd } = rig(false);
|
||||
const tank = L.spawnUnit(st, gr, 0, 'tank', cmd.x + 200, cmd.y);
|
||||
tank.hp = tank.maxHp * 0.5;
|
||||
L.issueOrder(st, gr, { army: 0, unitIds: [cmd.id], order: { type: 'guard', targetId: tank.id } });
|
||||
run(st, 10);
|
||||
check('guarding a mobile unit still just escorts it', cmd.buildTargetId === 0,
|
||||
'buildings are the only guard target that implies work');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('6d. Victory conditions');
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue