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:
parent
ddac7fcd5a
commit
23c558a5d4
|
|
@ -386,6 +386,10 @@
|
|||
"size": "medium",
|
||||
"radius": 20,
|
||||
"hp": 7000,
|
||||
"selfHeal": {
|
||||
"fractionPerMinute": 0.33,
|
||||
"pauseAfterDamageSec": 30
|
||||
},
|
||||
"speed": 62,
|
||||
"turnRate": 3.5,
|
||||
"moveClass": "foot",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue