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.
This commit is contained in:
parent
b479b6a21f
commit
3c053e5984
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue