feat(ta): add order queueing with CTRL and visual waypoint feedback

- Queue multiple orders per unit by holding CTRL (matching original game)
- Draw colored waypoint chains on the minimap/world for each queued order
- Display queued order count in selection detail panel
- Limit queue overlay to MAX_QUEUE_LINES to avoid clutter in large selections
- Use distinct colors per order type (move, attack, patrol, build, etc.)
- Update hint text and placement UI to mention queueing behavior
- Add verification tests for queue append/replace semantics, consumption order,
  and queued build orders
This commit is contained in:
Brian Fertig 2026-07-24 20:58:08 -06:00
parent e7da172896
commit ddac7fcd5a
4 changed files with 183 additions and 6 deletions

View File

@ -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.

View File

@ -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();

View File

@ -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; }
}

View File

@ -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');
// ---------------------------------------------------------------------------