fix: preserve unit sprites until their death animation plays in pre-resolved turns

Units doomed to die later in a pre-resolved enemy turn were being removed
from the board prematurely during sync, before their death animation could
play. Now:

- syncUnits only prunes dead views on actual army elimination, not on
  mid-turn CO powers that would kill units later in the same turn
- Added 'loaded'/'joined' event handlers to remove views when units merge
  into transports at the correct animated moment
- setUnitVisibility skips views whose units are already gone from state,
  preserving mid-replay sprites until their event fires
This commit is contained in:
Brian Fertig 2026-07-20 17:40:53 -06:00
parent 85996ad02c
commit 07a16f295d
2 changed files with 31 additions and 6 deletions

View File

@ -553,6 +553,15 @@ export default class AdvanceWarsGame extends Phaser.Scene {
const v = run.view.unitViews.get(e.unitId);
if (v) { v.c.destroy(); run.view.unitViews.delete(e.unitId); }
}
if (e.type === 'loaded' || e.type === 'joined') {
// unit merged into a transport / another unit — remove its now
// defunct board sprite once its move-in has been animated (no
// explosion). Reaped here rather than by a state-diff sync so it
// vanishes at this exact moment, not early (state is already the
// whole turn's final result — see syncUnits' prune note).
const v = run.view.unitViews.get(e.unitId);
if (v) { v.c.destroy(); run.view.unitViews.delete(e.unitId); }
}
if (e.type === 'powerFired') {
await new Promise((resolve) => Screens.powerCutIn(this, this.rules, this.oppById, run.state.armies[e.army].co, e.army, resolve));
run.hud.refresh(run.state, 0);
@ -570,9 +579,16 @@ export default class AdvanceWarsGame extends Phaser.Scene {
// full-screen reveal, so those still do a full sync.
const wideReveal = events.some((e) =>
['powerFired', 'healedAll', 'refreshed', 'tsunami', 'meteor', 'armyEliminated'].includes(e.type));
// A wide effect snaps every unit to its (final) state, but only an
// actual army elimination may *prune* dead views here — otherwise a
// mid-turn CO power would reap units merely doomed to die later in this
// same pre-resolved turn, yanking them off the board before their own
// death animation plays. Non-elimination deaths are reaped by the
// 'destroyed'/'crashed' handlers above, at the moment they animate.
const eliminated = events.some((e) => e.type === 'armyEliminated');
const touchedIds = wideReveal ? null : new Set(events.flatMap((e) =>
[e.unitId, e.attackerId, e.defenderId, e.transportId, e.intoId, e.byId].filter((id) => id != null)));
run.view.syncUnits(touchedIds);
run.view.syncUnits(touchedIds, { prune: eliminated });
run.view.refreshFog(0);
run.hud.refresh(run.state, 0);
if (enemy && (moved || battles.length || capturing)) await this.wait(500);

View File

@ -442,7 +442,7 @@ export class AdvanceWarsMapView {
// Used by replayEvents to reveal a turn's units one action at a time
// instead of snapping everyone straight to the turn's final result. Pass
// nothing (or null) for a full sync — creates/updates/prunes everyone.
syncUnits(onlyIds = null) {
syncUnits(onlyIds = null, { prune = !onlyIds } = {}) {
const { state, rules, scene } = this;
const present = new Set();
for (const u of state.units) {
@ -473,11 +473,12 @@ export class AdvanceWarsMapView {
}
if (justCreated || !onlyIds || onlyIds.has(u.id)) this.placeUnit(u, v);
}
// Stale-view cleanup only runs on a full sync — a scoped pass can't
// tell a genuinely-dead unit from one whose own (not yet replayed)
// death is later in this same turn's log; the 'destroyed'/'crashed'
// Stale-view cleanup only runs when explicitly pruning (a full sync, by
// default) — a mid-replay pass can't tell a genuinely-dead unit from one
// whose own (not yet replayed) death/load/join is later in this same
// pre-resolved turn's log; the 'destroyed'/'crashed'/'loaded'/'joined'
// event handlers already remove views at the right moment for that.
if (!onlyIds) {
if (prune) {
for (const [id, v] of [...this.unitViews]) {
if (!present.has(id)) { v.c.destroy(); this.unitViews.delete(id); }
}
@ -507,7 +508,15 @@ export class AdvanceWarsMapView {
}
setUnitVisibility(visibleIds) {
// Views whose unit is already gone from state are mid-replay: the enemy
// turn is resolved up front, so a unit that dies (or loads/joins) later
// this turn vanishes from state while its board sprite is deliberately
// held until its own event is replayed (see syncUnits / replayEvents).
// Never force-hide those here — leave them exactly as last shown, so a
// doomed unit stays put until its death animation actually plays.
const alive = new Set(this.state.units.map((u) => u.id));
for (const [id, v] of this.unitViews) {
if (!alive.has(id)) continue;
v.c.setVisible(visibleIds.has(id));
}
}