diff --git a/src/games/totalannihilation/TAHud.js b/src/games/totalannihilation/TAHud.js index a3b5726..0c33877 100644 --- a/src/games/totalannihilation/TAHud.js +++ b/src/games/totalannihilation/TAHud.js @@ -25,6 +25,10 @@ const BOT_H = 190; // bottom command bar const MINI = 176; // minimap edge length const BTN_W = 88, BTN_H = 74; // build-menu button size +// 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 · CTRL+RMB queue · A attack-move · X stop · H hold · WASD pan'; + /** Build-button captions have ~7 characters of room; "Vehicle Plant" needs shortening. */ function shortName(name) { const words = name.split(' '); @@ -316,6 +320,9 @@ export default class TAHud { const sel = [...selection]; const sig = sel.map((e) => e.id).join(',') + '|' + (placementDef?.id ?? '') + '|' + sel.map((e) => e.queue?.length ?? 0).join(',') + // Order-queue length is part of the signature, otherwise queueing a command with CTRL + // changes nothing on screen until the selection itself changes. + + '|' + sel.map((e) => e.orders?.length ?? 0).join(',') + '|' + Math.floor((this.state.armies[this.playerArmy]?.mass ?? 0) / 25); if (sig === this._selSig) { this._refreshQueue(sel); return; } this._selSig = sig; @@ -324,7 +331,7 @@ export default class TAHud { this.selTitle.setText(''); this.selDetail.setText(''); this.queueText.setText(''); - this.hint.setText('LMB select · drag box · RMB order · A attack-move · X stop · H hold · WASD pan · Ctrl+1-9 groups'); + this.hint.setText(HINT); this._clearGrid(); return; } @@ -342,18 +349,21 @@ export default class TAHud { if (sel.length === 1) { lines.push(`HP ${Math.ceil(lead.hp)} / ${lead.maxHp}`); if (lead.site) lines.push(`Under construction — ${Math.round((lead.progress ?? 0) * 100)}%`); + if (lead.orders?.length > 1) lines.push(`${lead.orders.length} orders queued`); 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)' : ''}`); } else { const hp = sel.reduce((s, e) => s + e.hp, 0), max = sel.reduce((s, e) => s + e.maxHp, 0); lines.push(`${sel.length} units HP ${Math.ceil(hp)} / ${max}`); + const queued = sel.reduce((n, e) => Math.max(n, e.orders?.length ?? 0), 0); + if (queued > 1) lines.push(`${queued} orders queued (hold CTRL to add more)`); } this.selDetail.setText(lines.join('\n')); if (placementDef) { - this.hint.setText(`Placing ${placementDef.name} — LMB to site it, Esc to cancel`); + this.hint.setText(`Placing ${placementDef.name} — LMB to site it, hold CTRL to queue several, Esc to cancel`); } else { - this.hint.setText('LMB select · drag box · RMB order · A attack-move · X stop · H hold · WASD pan · Ctrl+1-9 groups'); + this.hint.setText(HINT); } // Grid shows the builder's structures, or the factory's units — never both. diff --git a/src/games/totalannihilation/TAWorldView.js b/src/games/totalannihilation/TAWorldView.js index b49b532..ca3d9b1 100644 --- a/src/games/totalannihilation/TAWorldView.js +++ b/src/games/totalannihilation/TAWorldView.js @@ -18,6 +18,13 @@ import { colorInt } from './TAFx.js'; const CHUNK_PX = 1024; const ZOOMS = [0.5, 0.7, 1.0, 1.4]; const CHUNK_IDLE_MS = 30000; // reclaim chunks the camera hasn't looked at in a while +const MAX_QUEUE_LINES = 24; // selected units whose order queue is drawn + +/** Waypoint colour per order type, so a queue reads at a glance. */ +const ORDER_COLORS = { + move: 0x7dff9b, attackMove: 0xff8a5a, attack: 0xff5a5a, patrol: 0x8ad4ff, + guard: 0xffd27a, assist: 0x8affd0, build: 0x8ad4ff, +}; export const DEPTHS = { terrain: 0, decal: 5, fxUnder: 8, ghost: 12, @@ -59,9 +66,11 @@ export default class TAWorldView { this._setupFog(); 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.gGhost = scene.add.graphics().setDepth(DEPTHS.ghost); this._addWorld(this.gSelection); + this._addWorld(this.gOrders); this._addWorld(this.gBars); this._addWorld(this.gGhost); @@ -413,6 +422,7 @@ export default class TAWorldView { for (const id of [...this.sprites.keys()]) if (!live.has(id)) this._releaseSprite(id); + this._drawOrderQueues(); this._drawPlacementGhost(); // Actor depth is recomputed every frame from Y, so the container has to be re-sorted @@ -456,6 +466,61 @@ export default class TAWorldView { return { nanoLinks, projectiles }; } + /** + * Draw the pending order queue for every selected unit: a waypoint chain from the unit + * through each queued order, coloured by order type. + * + * Queued commands are close to unusable without this — once orders stop replacing each + * other, the player has no way to know what a unit has already been told to do, and a + * queued build is otherwise indistinguishable from one that failed to register. + */ + _drawOrderQueues() { + const g = this.gOrders; + g.clear(); + if (!this.selection.size) return; + + let drawn = 0; + for (const e of this.state.entities) { + if (e.dead || e.isBuilding || !this.selection.has(e.id)) continue; + if (!e.orders.length) continue; + // With a big selection every unit has near-identical orders; drawing all of them is + // just cost and clutter. + if (++drawn > MAX_QUEUE_LINES) break; + + let px = e.x, py = e.y; + for (let i = 0; i < e.orders.length; i++) { + const o = e.orders[i]; + const pt = this._orderPoint(o); + if (!pt) continue; + 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 { + g.fillStyle(col, active ? 0.9 : 0.6); + g.fillCircle(pt.x, pt.y, active ? 5 : 4); + } + px = pt.x; py = pt.y; + } + } + } + + /** 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') { + const x = o.sx ?? o.x, y = o.sy ?? o.y; + return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null; + } + if (o.targetId) { + const t = this.state.entities.find((e) => e.id === o.targetId && !e.dead); + return t ? { x: t.x, y: t.y } : null; + } + return null; + } + _drawPlacementGhost() { const g = this.gGhost; g.clear(); @@ -498,6 +563,7 @@ export default class TAWorldView { for (const [, chunk] of this.chunks) chunk.rt.destroy(); this.chunks.clear(); this.massSpotG?.destroy(); + this.gOrders?.destroy(); this.fogImg?.destroy(); if (this.scene.textures.exists('ta-fog-canvas')) this.scene.textures.remove('ta-fog-canvas'); this._stamp?.destroy(); diff --git a/src/games/totalannihilation/TotalAnnihilationGame.js b/src/games/totalannihilation/TotalAnnihilationGame.js index 07cf16a..d893514 100644 --- a/src/games/totalannihilation/TotalAnnihilationGame.js +++ b/src/games/totalannihilation/TotalAnnihilationGame.js @@ -374,10 +374,13 @@ export default class TotalAnnihilationGame extends Phaser.Scene { if (p.middleButtonDown()) { this._panAnchor = { x: p.x, y: p.y }; return; } const w = this.view.worldPoint(p.x, p.y); - if (p.rightButtonDown()) { this._issueContextual(w, p.event?.shiftKey); return; } + // CTRL is the queue modifier, as in the original: hold it and every order appends to the + // unit's queue instead of replacing it. SHIFT stays on selection (add to selection) and + // on the factory buttons (x5), so the two never fight over the same click. + if (p.rightButtonDown()) { this._issueContextual(w, queueHeld(p)); return; } - if (this.placement) { this._commitPlacement(w, p.event?.shiftKey); return; } - if (this.pendingCommand) { this._applyPendingCommand(w, p.event?.shiftKey); return; } + if (this.placement) { this._commitPlacement(w, queueHeld(p)); return; } + if (this.pendingCommand) { this._applyPendingCommand(w, queueHeld(p)); return; } this._dragStart = { x: p.x, y: p.y, wx: w.x, wy: w.y, add: !!p.event?.shiftKey }; this._dragging = false; @@ -656,6 +659,11 @@ function summarise(state, armyIdx) { }; } +/** Is the queue modifier down for this pointer event? */ +function queueHeld(p) { + return !!(p.event?.ctrlKey || p.event?.metaKey); +} + function readJson(key, fallback) { try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch (_) { return fallback; } } diff --git a/tools/verifyTotalAnnihilation.js b/tools/verifyTotalAnnihilation.js index 4056e89..ec46cdf 100644 --- a/tools/verifyTotalAnnihilation.js +++ b/tools/verifyTotalAnnihilation.js @@ -271,6 +271,22 @@ section('2c. View layering and first frame'); check('the opening camera frames the player\'s Commander', onScreen, `unit ${own.x.toFixed(0)},${own.y.toFixed(0)} vs view ${cam.x.toFixed(0)},${cam.y.toFixed(0)} ${cam.width}x${cam.height}`); + // The queue overlay is the only feedback a player gets that a CTRL-queued order + // registered at all, so assert it actually strokes something for a queued unit and + // nothing at all when the selection is empty. + const cmdr = st.entities.find((e) => e.army === 0); + const tsz = st.tileSize; + L.issueOrder(st, rules, { army: 0, unitIds: [cmdr.id], order: { type: 'move', x: cmdr.x + 3 * tsz, y: cmdr.y } }); + L.issueOrder(st, rules, { army: 0, unitIds: [cmdr.id], order: { type: 'move', x: cmdr.x + 3 * tsz, y: cmdr.y + 3 * tsz }, queue: true }); + L.issueOrder(st, rules, { army: 0, unitIds: [cmdr.id], order: { type: 'attackMove', x: cmdr.x, y: cmdr.y + 3 * tsz }, queue: true }); + view.selection = new Set([cmdr.id]); + view.render(0); + check('the order-queue overlay draws for a queued unit', view.gOrders.ops > 0, `${view.gOrders.ops} ops`); + view.selection = new Set(); + view.render(0); + check('the order-queue overlay draws nothing with no selection', view.gOrders.ops === 0); + L.issueOrder(st, rules, { army: 0, unitIds: [cmdr.id], order: { type: 'stop' } }); + // Wheel zoom must keep the world point under the cursor fixed, at any cursor position — // including well away from the screen centre, which is where a centre-anchored zoom (or a // correction computed from a stale camera matrix) visibly drifts. @@ -431,6 +447,83 @@ section('4. Pathfinding'); segmentClear(blocked, mc, 1, 0.5 * ts, 0.5 * ts, 2.5 * ts, 0.5 * ts)); } +// --------------------------------------------------------------------------- +section('4b. Order queueing (CTRL)'); +// --------------------------------------------------------------------------- +{ + // Holding CTRL passes `queue: true` to issueOrder. The engine contract that has to hold: + // a queued order APPENDS and leaves the current one running, an unqueued one REPLACES the + // lot, and the unit then works through them in order. + const map = generateMap(rules, { seed: 61, size: 'small', symmetry: 'mirror-x' }); + const st = L.createMatch(rules, { seed: 61, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const cmd = st.entities.find((e) => e.army === 0); + const ts = st.tileSize; + + const pt = (dx, dy) => ({ x: cmd.x + dx * ts, y: cmd.y + dy * ts }); + const a = pt(3, 0), b = pt(3, 3), c = pt(0, 3); + L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...a } }); + check('first order replaces an empty queue', cmd.orders.length === 1); + L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...b }, queue: true }); + L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...c }, queue: true }); + check('queued orders append', cmd.orders.length === 3, `${cmd.orders.length}`); + check('the queue keeps its issue order', + Math.abs(cmd.orders[0].x - a.x) < 1 && Math.abs(cmd.orders[2].x - c.x) < 1); + + // An unqueued order wipes the queue — the standard "plain click cancels everything" rule. + L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...a } }); + check('an unqueued order clears the queue', cmd.orders.length === 1, `${cmd.orders.length}`); + + // Run a three-leg queue and confirm it is actually consumed in sequence. + L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...b }, queue: true }); + L.issueOrder(st, rules, { army: 0, unitIds: [cmd.id], order: { type: 'move', ...c }, queue: true }); + const seen = [cmd.orders.length]; + for (let i = 0; i < 240 * HZ && cmd.orders.length; i++) { + L.tick(st, rules); + if (cmd.orders.length !== seen[seen.length - 1]) seen.push(cmd.orders.length); + } + check('a queued route is consumed one leg at a time', + seen.join(',') === '3,2,1,0', seen.join(',')); + check('the unit ends up at the final waypoint', + Math.hypot(cmd.x - c.x, cmd.y - c.y) < ts * 2, + `${Math.hypot(cmd.x - c.x, cmd.y - c.y).toFixed(0)}px away`); + + // Queued BUILD orders: each places its site immediately (so the player sees the ghosts) + // while the builder works through them one at a time. + const st2 = L.createMatch(rules, { seed: 62, map, armies: [{ armyId: 'arm' }, { armyId: 'core' }] }); + const b2 = st2.armies[0]; + b2.mass = 99999; b2.energy = 99999; b2.massCap = 99999; b2.energyCap = 99999; + const builder = st2.entities.find((e) => e.army === 0); + const gen = rules.buildingById.energygen; + // Candidate spots must clear each other by the building's own footprint, or siting the + // first one blocks the second and the "queue" under test never forms. + const spots = []; + const step = gen.footprint.w + 1; + for (let d = 2; d < 30 && spots.length < 3; d += step) { + const tx = worldToTileX(st2.nav, builder.x) + d; + const ty = worldToTileY(st2.nav, builder.y); + if (L.canPlaceAt(st2, rules, tx, ty, gen).ok) spots.push({ tx, ty }); + } + check('found room for three queued generators', spots.length === 3, `${spots.length}`); + let queuedOk = 0; + spots.forEach((sp, i) => { + const r = L.issueOrder(st2, rules, { + army: 0, unitIds: [builder.id], + order: { type: 'build', defId: 'energygen', tx: sp.tx, ty: sp.ty }, queue: i > 0, + }); + if (r.ok) queuedOk++; + }); + check('three build orders queue onto one builder', queuedOk === 3, `${queuedOk}`); + check('every queued building is sited straight away', + st2.entities.filter((e) => e.defId === 'energygen').length === 3); + check('the builder holds all three in its queue', builder.orders.length === 3, `${builder.orders.length}`); + for (let i = 0; i < 400 * HZ && builder.orders.length; i++) { + b2.mass = 99999; b2.energy = 99999; + L.tick(st2, rules); + } + const finished = st2.entities.filter((e) => e.defId === 'energygen' && !e.site && !e.dead).length; + check('a queued build list completes', finished === 3, `${finished}/3 built`); +} + // --------------------------------------------------------------------------- section('5. Movement, separation and size classes'); // ---------------------------------------------------------------------------