diff --git a/data/totalannihilation-rules.json b/data/totalannihilation-rules.json index f7aa190..92bd1ed 100644 --- a/data/totalannihilation-rules.json +++ b/data/totalannihilation-rules.json @@ -386,6 +386,10 @@ "size": "medium", "radius": 20, "hp": 7000, + "selfHeal": { + "fractionPerMinute": 0.33, + "pauseAfterDamageSec": 30 + }, "speed": 62, "turnRate": 3.5, "moveClass": "foot", diff --git a/src/games/totalannihilation/TAHud.js b/src/games/totalannihilation/TAHud.js index 0c33877..330751a 100644 --- a/src/games/totalannihilation/TAHud.js +++ b/src/games/totalannihilation/TAHud.js @@ -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); } diff --git a/src/games/totalannihilation/TALogic.js b/src/games/totalannihilation/TALogic.js index 57f515f..c0e037b 100644 --- a/src/games/totalannihilation/TALogic.js +++ b/src/games/totalannihilation/TALogic.js @@ -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); diff --git a/src/games/totalannihilation/TARules.js b/src/games/totalannihilation/TARules.js index 97d57e6..2cfea91 100644 --- a/src/games/totalannihilation/TARules.js +++ b/src/games/totalannihilation/TARules.js @@ -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; } diff --git a/src/games/totalannihilation/TotalAnnihilationGame.js b/src/games/totalannihilation/TotalAnnihilationGame.js index d893514..fac1107 100644 --- a/src/games/totalannihilation/TotalAnnihilationGame.js +++ b/src/games/totalannihilation/TotalAnnihilationGame.js @@ -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() {