From 3c053e59847ddafca3d4ac0723caaf33e2d131f2 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 25 Jul 2026 16:16:33 -0600 Subject: [PATCH] feat: add granular order queue cancellation Introduce the ability to cancel individual queued orders, addressing the previous limitation where `stop` only cleared the entire queue. - Add `cancelOrder` logic with proper cleanup for unstarted BUILD orders (site removal, footprint unstamping, freeing builders) - Implement queue hit-testing and visual selection highlighting in TAWorldView - Wire up right-click cancellation, Delete/Backspace key binding, and click-to-highlight queue entries in the game scene - Automatically invalidate queue selection when units die, are deselected, or orders are consumed Players can now precisely manage command queues without affecting active or completed constructions. --- src/games/totalannihilation/TALogic.js | 39 ++++++++++++ src/games/totalannihilation/TAWorldView.js | 33 ++++++++++ .../TotalAnnihilationGame.js | 60 ++++++++++++++++++- 3 files changed, 129 insertions(+), 3 deletions(-) diff --git a/src/games/totalannihilation/TALogic.js b/src/games/totalannihilation/TALogic.js index eda7a26..4b56e72 100644 --- a/src/games/totalannihilation/TALogic.js +++ b/src/games/totalannihilation/TALogic.js @@ -356,6 +356,45 @@ function buildCommand(state, rules, army, units, order, queue) { return { ok: true, siteId: site.id }; } +/** + * Remove one order from a unit's queue by index — the missing counterpart to `stop`, which + * only ever clears the whole queue. Cancelling the active order (index 0) gets the same state + * reset stepOrders' own completion paths already do, so nothing is left chasing an order that + * is no longer there. + * + * A queued BUILD order carries a live site entity from the moment it was placed (see + * placeBuilding) — stamped into the nav grid regardless of how deep in the queue it sits. + * Cancelling it before any progress has been made tears that site back out (footprint + * unstamped, entity removed, any other builder pointed at it freed up); once work has begun, + * only the queue slot goes away — the site, and anyone else building it, are untouched. + */ +export function cancelOrder(state, rules, army, unitId, index) { + const e = entityById(state, unitId); + if (!e || e.army !== army || !Array.isArray(e.orders) || index < 0 || index >= e.orders.length) { + return { ok: false, error: 'no such queued order' }; + } + const order = e.orders[index]; + + if (order.type === 'build') { + const site = entityById(state, order.targetId); + if (site && site.isBuilding && (site.progress ?? 0) <= 0) { + stampFootprint(state.nav, rules, site.tx, site.ty, site.fw, site.fh, false); + site.dead = true; + for (const o of state.entities) { + if (o.buildTargetId === site.id) o.buildTargetId = 0; + if (o.targetId === site.id) o.targetId = 0; + } + } + } + + e.orders.splice(index, 1); + if (index === 0) { + e.path = null; e.movingTo = null; e.targetId = 0; e.buildTargetId = 0; + e.stuckTicks = 0; e.noPath = false; + } + return { ok: true }; +} + /** Can this footprint go here? Checks bounds, terrain buildability and occupancy. */ export function canPlaceAt(state, rules, tx, ty, def) { const fw = def.footprint.w, fh = def.footprint.h; diff --git a/src/games/totalannihilation/TAWorldView.js b/src/games/totalannihilation/TAWorldView.js index e2a2107..a166335 100644 --- a/src/games/totalannihilation/TAWorldView.js +++ b/src/games/totalannihilation/TAWorldView.js @@ -76,6 +76,7 @@ export default class TAWorldView { this.sprites = new Map(); // entity id -> { img, turret } this.selection = new Set(); + this.queueSelection = null; // { unitId, index } — a highlighted entry in a drawn order queue this.placement = null; // { def, tx, ty, legal } this.showAllBars = false; this._stamp = scene.make.image({ x: 0, y: 0, key: this.terrainKey, add: false }).setOrigin(0, 0); @@ -498,6 +499,7 @@ export default class TAWorldView { const g = this.gOrders; g.clear(); if (!this.selection.size) return; + const qs = this.queueSelection; let drawn = 0; for (const e of this.state.entities) { @@ -523,6 +525,12 @@ export default class TAWorldView { 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. + if (qs && qs.unitId === e.id && qs.index === i) { + g.lineStyle(2.5, 0xffffff, 0.95); + g.strokeCircle(pt.x, pt.y, o.type === 'build' ? 24 : 11); + } px = pt.x; py = pt.y; } } @@ -541,6 +549,31 @@ export default class TAWorldView { return null; } + /** + * Hit-test a world point against the queue ghosts _drawOrderQueues just drew (same + * selected-unit iteration, same MAX_QUEUE_LINES cap, same per-type marker) so a click only + * ever grabs something the player can actually see. Returns the closest { unitId, index } + * within tolerance, or null. + */ + hitTestQueue(wx, wy) { + let best = null, bestD = Infinity; + 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; + if (++drawn > MAX_QUEUE_LINES) break; + for (let i = 0; i < e.orders.length; i++) { + const o = e.orders[i]; + const pt = this._orderPoint(o); + if (!pt) continue; + const d = Math.hypot(pt.x - wx, pt.y - wy); + const tol = o.type === 'build' ? 20 : 14; + if (d <= tol && d < bestD) { best = { unitId: e.id, index: i }; bestD = d; } + } + } + return best; + } + _drawPlacementGhost() { const g = this.gGhost; g.clear(); diff --git a/src/games/totalannihilation/TotalAnnihilationGame.js b/src/games/totalannihilation/TotalAnnihilationGame.js index 619be32..7349035 100644 --- a/src/games/totalannihilation/TotalAnnihilationGame.js +++ b/src/games/totalannihilation/TotalAnnihilationGame.js @@ -13,6 +13,7 @@ import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { getGameSoundtrack } from '../../services/soundtrack.js'; +import { playSound } from '../../ui/Sounds.js'; import { api } from '../../services/api.js'; import { compileRules } from './TARules.js'; import { generateMap, decodeMap } from './TAMapGen.js'; @@ -46,6 +47,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene { this.pendingCommand = null; // 'attackMove' | 'patrol' | 'guard' | 'attack' this.simSpeed = 1; this.settings = readJson(SETTINGS_KEY, { edgeScroll: true, fog: true }); + this.lastSfxAt = {}; // per-sound-key gate — see _throttledSfx } create() { @@ -254,6 +256,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene { _onSimEvent(ev) { this.fx.onEvent(ev, this.rules); + if (ev.t === 'weaponFired' && ev.sound) this._throttledSfx(ev.sound, 90); if (ev.t === 'buildingComplete' && ev.army === this.playerArmy) { // Terrain under a finished structure changes, so its chunk has to be restamped. this.view.repaintArea(ev.x - 128, ev.y - 128, ev.x + 128, ev.y + 128); @@ -272,6 +275,15 @@ export default class TotalAnnihilationGame extends Phaser.Scene { } } + /** Gate repeated plays of the same sound key so a volley of units firing at once doesn't + * stack a dozen overlapping instances of the same clip. */ + _throttledSfx(key, gapMs) { + const now = this.time.now; + if ((this.lastSfxAt[key] ?? -1e9) + gapMs > now) return; + this.lastSfxAt[key] = now; + playSound(this, key); + } + _finish(over) { this.phase = 'result'; const won = over.winner === this.playerArmy; @@ -399,6 +411,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene { 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; + case 'delete': case 'backspace': this._deleteQueueSelection(); break; default: break; } if (ev.ctrlKey && code === 'a') this._selectAll(); @@ -414,7 +427,14 @@ export default class TotalAnnihilationGame extends Phaser.Scene { // 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 (p.rightButtonDown()) { + // Right-clicking an existing queue ghost cancels that order instead of stacking a + // redundant new one on top of it. + const hit = this.view.hitTestQueue(w.x, w.y); + if (hit) { this._cancelQueueOrder(hit); return; } + this._issueContextual(w, queueHeld(p)); + return; + } if (this.placement) { this._commitPlacement(w, queueHeld(p)); return; } if (this.pendingCommand) { this._applyPendingCommand(w, queueHeld(p)); return; } @@ -429,8 +449,21 @@ export default class TotalAnnihilationGame extends Phaser.Scene { const d = this._dragStart; this._dragStart = null; const w = this.view.worldPoint(p.x, p.y); - if (this._dragging) this._selectInBox(d.wx, d.wy, w.x, w.y, d.add); - else this._selectAt(w.x, w.y, d.add, !!p.event?.detail && p.event.detail > 1); + if (this._dragging) { + this.view.queueSelection = null; + this._selectInBox(d.wx, d.wy, w.x, w.y, d.add); + } else { + // A plain click landing on a queue ghost just highlights it — it no longer sends the + // unit straight there / puts it straight to work, which used to be indistinguishable + // from "the order didn't register." + const hit = this.view.hitTestQueue(w.x, w.y); + if (hit) { + this.view.queueSelection = hit; + } else { + this.view.queueSelection = null; + this._selectAt(w.x, w.y, d.add, !!p.event?.detail && p.event.detail > 1); + } + } this._dragging = false; } @@ -473,6 +506,14 @@ export default class TotalAnnihilationGame extends Phaser.Scene { if (!e || e.dead) this.selection.delete(id); } this.view.selection = this.selection; + + // A highlighted queue entry goes stale the moment its unit leaves the selection, dies, or + // the order itself is consumed/cancelled out from under it (queue shrinks past its index). + const qs = this.view.queueSelection; + if (qs) { + const e = this.selection.has(qs.unitId) ? Logic.entityById(this.match, qs.unitId) : null; + if (!e || qs.index >= e.orders.length) this.view.queueSelection = null; + } } _selectAt(x, y, add, double) { @@ -569,6 +610,19 @@ export default class TotalAnnihilationGame extends Phaser.Scene { this._order({ type: 'move', x: w.x, y: w.y }, queue); } + /** Remove one highlighted/clicked entry from a unit's order queue. */ + _cancelQueueOrder(hit) { + const r = Logic.cancelOrder(this.match, this.rules, this.playerArmy, hit.unitId, hit.index); + if (r.ok) this.view.queueSelection = null; + else if (r.error) this.hud.toast(r.error, '#ff9a6b'); + } + + _deleteQueueSelection() { + const qs = this.view.queueSelection; + if (!qs) return; + this._cancelQueueOrder(qs); + } + _applyPendingCommand(w, queue) { const cmd = this.pendingCommand; this.pendingCommand = null;