feat(totalannihilation): add commander self-healing and improved build queue UX

- Add self-healing system for units with `selfHeal` definition (e.g. Commander)
  - Healing pauses for 30 seconds after taking damage to prevent out-healing fights
  - Rates converted to per-tick at load time for efficient simulation
- Improve build queue interaction: use CTRL (or Cmd) to chain multiple builds,
  release to cancel placement automatically — no need to press Esc or click again
- Track queue modifier state from DOM events for robustness across focus loss
This commit is contained in:
Brian Fertig 2026-07-24 21:25:22 -06:00
parent ddac7fcd5a
commit 23c558a5d4
5 changed files with 68 additions and 4 deletions

View File

@ -386,6 +386,10 @@
"size": "medium",
"radius": 20,
"hp": 7000,
"selfHeal": {
"fractionPerMinute": 0.33,
"pauseAfterDamageSec": 30
},
"speed": 62,
"turnRate": 3.5,
"moveClass": "foot",

View File

@ -361,7 +361,8 @@ 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, Esc to cancel`);
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);
}

View File

@ -1169,10 +1169,27 @@ function applyAoe(state, rules, x, y, w, source) {
export function applyDamage(state, rules, target, amount, source) {
if (target.dead || amount <= 0) return;
target.hp -= amount;
target.lastDamagedTick = state.tick;
state.events.push({ t: 'damaged', id: target.id, amount, x: target.x, y: target.y });
if (target.hp <= 0) killEntity(state, rules, target, source);
}
/**
* Self-repair. Only defs that declare `selfHeal` regenerate, and the clock restarts on every
* hit so a Commander recovers between engagements but never out-heals incoming fire, and
* cannot be used to tank a fight it is losing.
*/
function stepRegen(state, rules) {
for (const e of state.entities) {
if (e.dead || e.site) continue;
const heal = defOf(rules, e).selfHeal;
if (!heal) continue;
if (e.hp >= e.maxHp) continue;
if (state.tick - e.lastDamagedTick < heal.pauseTicks) continue;
e.hp = Math.min(e.maxHp, e.hp + heal.hpPerTick);
}
}
function killEntity(state, rules, e, source) {
if (e.dead) return;
e.dead = true;
@ -1303,6 +1320,7 @@ export function tick(state, rules) {
stepSeparation(state, rules);
stepCombat(state, rules);
stepProjectiles(state, rules);
stepRegen(state, rules);
if (state.tick % 4 === 0) computeVision(state, rules);

View File

@ -208,6 +208,16 @@ export function compileRules(json) {
u.isBuilding = false;
u.weaponDefs = (u.weapons ?? []).map((wid) => weaponById[wid]);
u.maxRange = autoRangeOf(u.weaponDefs);
// Regeneration is per-def data, so any unit can be given it later without code. Rates
// are converted to per-tick here so the sim never divides in its hot loop.
if (u.selfHeal) {
const frac = u.selfHeal.fractionPerMinute;
if (!(frac > 0)) fail(`unit "${u.id}" selfHeal.fractionPerMinute must be positive`);
const pause = u.selfHeal.pauseAfterDamageSec ?? 0;
if (!(pause >= 0)) fail(`unit "${u.id}" selfHeal.pauseAfterDamageSec must be >= 0`);
u.selfHeal.hpPerTick = (u.hp * frac) / 60 / c.tickHz;
u.selfHeal.pauseTicks = Math.round(pause * c.tickHz);
}
u.spritePx = u.spritePx ?? sc.radius * 2;
buildable[u.id] = u;
}

View File

@ -198,6 +198,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
}
_teardown() {
if (this._onBlur) this.game.events.off('blur', this._onBlur);
this._endMatch();
this.screen?.destroy();
this.music?.destroy?.();
@ -213,6 +214,15 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
this._updateCamera(delta);
// Releasing the queue modifier ends a chained build run, so the player is not forced to
// place one more building (or reach for Esc) just to put the cursor down. Only a run that
// was actually chained with CTRL is cancelled this way — picking a building from the menu
// and never touching CTRL leaves the placement alone.
if (this.placement?.chained && !this._queueModDown()) {
this._cancelPlacement();
this.hud.toast('Build queue ended');
}
// 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.
const before = st.tick;
@ -313,7 +323,25 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
// Input
// -------------------------------------------------------------------------
/**
* Is the queue modifier held right now? Tracked from the DOM events Phaser forwards rather
* than polled off a Key object, so it covers Cmd as well as either Ctrl key, and reads
* false again after a focus loss swallows the keyup.
*/
_queueModDown() {
return !!this._queueMod;
}
_bindInput() {
const trackMod = (ev) => { this._queueMod = !!(ev?.ctrlKey || ev?.metaKey); };
this.input.keyboard.on('keydown', trackMod);
this.input.keyboard.on('keyup', trackMod);
this.input.on('pointermove', (p) => trackMod(p.event));
this.input.on('pointerdown', (p) => trackMod(p.event));
// A blurred window never delivers the keyup, which would otherwise strand the cursor.
this._onBlur = () => { this._queueMod = false; };
this.game.events.on('blur', this._onBlur);
this.input.on('pointermove', () => { this._pointerLive = true; });
this.input.on('pointerdown', (p) => { this._pointerLive = true; this._onPointerDown(p); });
this.input.on('pointerup', (p) => this._onPointerUp(p));
@ -562,7 +590,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
_beginPlacement(def) {
const builder = this._selectedEntities().find((e) => !e.isBuilding && this.rules.defById[e.defId].builds?.includes(def.id));
if (!builder) return;
this.placement = { def, builderId: builder.id };
this.placement = { def, builderId: builder.id, chained: false };
this.view.highlightMassSpots(!!def.terrainMultiplier);
this.input.on('pointermove', this._onPlacementMove, this);
this._onPlacementMove(this.input.activePointer);
@ -595,8 +623,11 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
queue,
});
if (!r.ok) { this.hud.toast(r.error ?? 'Cannot build there', '#ff9a6b'); return; }
// Shift keeps the cursor loaded so a row of generators is one gesture.
if (!queue) this._cancelPlacement();
// A plain click places one and puts the cursor down. Holding the queue modifier keeps the
// building loaded so a row of generators is one gesture — and `chained` records that the
// player got there by holding CTRL, so releasing it can end the run (see update()).
if (queue) p.chained = true;
else this._cancelPlacement();
}
_cancelPlacement() {