feat(civ): animate combat events with attacker/defender ghosts and victory advance

- Capture attacker/defender snapshot (id, civ, type, start position) in
  combat events so the UI can reconstruct them after resolution
- Add `animateCombat()` that plays sequential lunge/retreat animations for
  each combat, death flash/shrink for the loser, and optional victory glide
  for unopposed winners stepping onto the cleared tile
- Add `animateUnitsAlong()` to glide AI civ units between before/after
  snapshots, enabling the same "resolve now, animate after" pattern for
  AI turns
- Track unit containers in `unitContainers` map for lookup by unitId
- When player unit encounters combat mid-path, animate the peaceful leg
  first then play the clash
- Add `collectNewCombatEvents()` to pick up and mark consumed combat events
- Add `isTileVisible()` helper for filtering AI combat to visible tiles
- Update tests to verify advance-after-kill and combat event shape
This commit is contained in:
Brian Fertig 2026-07-15 23:26:29 -06:00
parent e4ad09de67
commit 325692fa35
4 changed files with 299 additions and 8 deletions

View File

@ -565,6 +565,12 @@ export default class CivilizationGame extends Phaser.Scene {
if (unit.mp <= 0 && this.state.units.includes(unit)) this.selectNextUnit(); if (unit.mp <= 0 && this.state.units.includes(unit)) this.selectNextUnit();
if (!this.state.units.includes(unit)) this.selectNextUnit(); if (!this.state.units.includes(unit)) this.selectNextUnit();
}; };
const combatEvents = this.collectNewCombatEvents();
if (combatEvents.length) {
this.busy = true;
this.view.animateCombat(combatEvents, () => { this.busy = false; finish(); });
return;
}
const moved = unit.x !== from[0] || unit.y !== from[1]; const moved = unit.x !== from[0] || unit.y !== from[1];
if (!animate || !moved) { finish(); return; } if (!animate || !moved) { finish(); return; }
this.animateMoveThen([from, [unit.x, unit.y]], finish); this.animateMoveThen([from, [unit.x, unit.y]], finish);
@ -574,6 +580,8 @@ export default class CivilizationGame extends Phaser.Scene {
// Resolve the whole turn's worth of movement instantly (combat, huts, // Resolve the whole turn's worth of movement instantly (combat, huts,
// etc. all need the real logic), then replay the tiles actually crossed // etc. all need the real logic), then replay the tiles actually crossed
// as a smooth glide so the player sees continuous motion, not a snap. // as a smooth glide so the player sees continuous motion, not a snap.
// A combat step always ends the walk — the rest of the original path
// no longer applies once a fight breaks out.
const visited = [[unit.x, unit.y]]; const visited = [[unit.x, unit.y]];
let i = 0; let i = 0;
let hutEvent = null; let hutEvent = null;
@ -582,7 +590,7 @@ export default class CivilizationGame extends Phaser.Scene {
i += 1; i += 1;
const out = Logic.tryMove(this.rules, this.state, unit, const out = Logic.tryMove(this.rules, this.state, unit,
Math.sign(nx - unit.x), Math.sign(ny - unit.y)); Math.sign(nx - unit.x), Math.sign(ny - unit.y));
if (out.result === 'invalid' || out.result === 'blocked') break; if (out.result === 'invalid' || out.result === 'blocked' || out.result === 'combat') break;
if (out.hut) hutEvent = out.hut; if (out.hut) hutEvent = out.hut;
visited.push([unit.x, unit.y]); visited.push([unit.x, unit.y]);
} }
@ -592,9 +600,28 @@ export default class CivilizationGame extends Phaser.Scene {
this.afterAction(); this.afterAction();
if (!this.state.units.includes(unit) || unit.mp <= 0) this.selectNextUnit(); if (!this.state.units.includes(unit) || unit.mp <= 0) this.selectNextUnit();
}; };
const combatEvents = this.collectNewCombatEvents();
if (combatEvents.length) {
// Walk the peaceful leg of the path first, then play the clash.
this.animateMoveThen(visited, () => {
this.busy = true;
this.view.animateCombat(combatEvents, () => { this.busy = false; finish(); });
});
return;
}
this.animateMoveThen(visited, finish); this.animateMoveThen(visited, finish);
} }
// Picks up any 'combat' events pushed since the last time this ran
// (marking them consumed so they never replay), for the caller to animate.
collectNewCombatEvents() {
const found = [];
for (const e of this.state.events) {
if (e.type === 'combat' && !e.animated) { e.animated = true; found.push(e); }
}
return found;
}
// Glides the selected unit's marker smoothly across `tiles` // Glides the selected unit's marker smoothly across `tiles`
// ([[c,r], ...], already-resolved positions) over 1s, pauses 250ms at the // ([[c,r], ...], already-resolved positions) over 1s, pauses 250ms at the
// destination, then invokes `finish`. Falls straight through to `finish` // destination, then invokes `finish`. Falls straight through to `finish`
@ -744,11 +771,36 @@ export default class CivilizationGame extends Phaser.Scene {
return; return;
} }
Logic.beginCivTurn(this.rules, this.state, cur); Logic.beginCivTurn(this.rules, this.state, cur);
const before = new Map(Logic.civUnits(this.state, cur).map((u) => [u.id, [u.x, u.y]]));
runAITurn(this.rules, this.state, cur); runAITurn(this.rules, this.state, cur);
Logic.endCivTurn(this.rules, this.state, cur); Logic.endCivTurn(this.rules, this.state, cur);
this.view.refresh(); // Resolve the whole civ's turn instantly (as ever). Combats play first,
this.refreshHud(); // from whatever's still on screen from before this civ acted (no
this.time.delayedCall(90, stepCiv); // refresh yet, so a ghost can stand in for a unit that's already dead
// or already moved); then one refresh syncs everyone else, and the
// remaining units' start/end tiles are diffed and glided — same
// "resolve now, animate after" trick as the player's own moves, just
// via a before/after snapshot instead of a waypoint path.
const combatEvents = this.collectNewCombatEvents();
const visibleCombat = combatEvents.filter((e) => this.view.isTileVisible(e.ax, e.ay)
|| this.view.isTileVisible(e.x, e.y));
const combatUnitIds = new Set(combatEvents.flatMap((e) => [e.attackerId, e.defenderId]));
const moves = [];
for (const [id, from] of before) {
if (combatUnitIds.has(id)) continue;
const u = Logic.unitById(this.state, id);
if (!u || u.carriedBy) continue;
if (u.x !== from[0] || u.y !== from[1]) moves.push({ unitId: id, from, to: [u.x, u.y] });
}
const afterCombat = () => {
this.view.refresh();
this.refreshHud();
this.view.animateUnitsAlong(moves, 1000, 250, () => {
this.time.delayedCall(90, stepCiv);
});
};
if (visibleCombat.length) this.view.animateCombat(visibleCombat, afterCombat);
else afterCombat();
}; };
stepCiv(); stepCiv();
} }

View File

@ -933,6 +933,15 @@ export function resolveAttack(rules, state, attacker, tx, ty) {
return { result: 'nuked' }; return { result: 'nuked' };
} }
const ax = attacker.x;
const ay = attacker.y;
const attackerId = attacker.id;
const attackerCiv = attacker.civ;
const attackerType = attacker.type;
const defenderId = defender.id;
const defenderCiv = defender.civ;
const defenderType = defender.type;
const A = attackerStrength(rules, state, attacker); const A = attackerStrength(rules, state, attacker);
const D = defenderStrength(rules, state, defender, attacker); const D = defenderStrength(rules, state, defender, attacker);
const duel = simulateDuel(state, A, attDef.hp, attDef.fp, D, defDef.hp, defDef.fp, attacker.hp); const duel = simulateDuel(state, A, attDef.hp, attDef.fp, D, defDef.hp, defDef.fp, attacker.hp);
@ -941,6 +950,10 @@ export function resolveAttack(rules, state, attacker, tx, ty) {
const idx = tileIndex(state.world, tx, ty); const idx = tileIndex(state.world, tx, ty);
const protectedStack = !!city || !!(state.world.improvements[idx] & IMP.FORTRESS); const protectedStack = !!city || !!(state.world.improvements[idx] & IMP.FORTRESS);
// Advance-after-kill: an unopposed winner (not a self-destructing missile)
// steps onto the tile it just cleared, matching the victory-glide the UI
// plays for it.
let advanced = false;
if (duel.attackerWon) { if (duel.attackerWon) {
removeUnit(state, defender); removeUnit(state, defender);
if (!protectedStack) { if (!protectedStack) {
@ -948,6 +961,14 @@ export function resolveAttack(rules, state, attacker, tx, ty) {
} }
attacker.hp = Math.max(1, duel.attackerHp); attacker.hp = Math.max(1, duel.attackerHp);
if (!attacker.vet && rand(state) < 0.5) attacker.vet = true; if (!attacker.vet && rand(state) < 0.5) attacker.vet = true;
if (!attDef.flags.includes('missile') && unitsAt(state, tx, ty).length === 0) {
attacker.x = tx;
attacker.y = ty;
dropCarried(rules, state, attacker, tx, ty);
exploreAround(state, attacker.civ, tx, ty, 2);
makeContacts(rules, state, attacker.civ, tx, ty);
advanced = true;
}
} else { } else {
defender.hp = Math.max(1, duel.defenderHp); defender.hp = Math.max(1, duel.defenderHp);
if (!defender.vet && rand(state) < 0.5) defender.vet = true; if (!defender.vet && rand(state) < 0.5) defender.vet = true;
@ -957,10 +978,11 @@ export function resolveAttack(rules, state, attacker, tx, ty) {
if (attDef.flags.includes('missile') && duel.attackerWon) removeUnit(state, attacker); if (attDef.flags.includes('missile') && duel.attackerWon) removeUnit(state, attacker);
state.events.push({ state.events.push({
type: 'combat', x: tx, y: ty, type: 'combat', x: tx, y: ty, ax, ay,
attacker: { civ: attacker.civ, type: attacker.type }, attackerId, attackerCiv, attackerType,
defender: { civ: defender.civ, type: defender.type }, defenderId, defenderCiv, defenderType,
attackerWon: duel.attackerWon, attackerWon: duel.attackerWon,
advanced,
}); });
checkVictory(rules, state); checkVictory(rules, state);
return { result: 'combat', won: duel.attackerWon }; return { result: 'combat', won: duel.attackerWon };

View File

@ -34,6 +34,7 @@ export class CivilizationMapView {
this.zoomIdx = 1; this.zoomIdx = 1;
this.selectedUnitId = null; this.selectedUnitId = null;
this.exploredDrawn = null; this.exploredDrawn = null;
this.unitContainers = new Map();
const { world } = state; const { world } = state;
this.originX = world.rows * (TILE_W / 2); // keeps iso x positive this.originX = world.rows * (TILE_W / 2); // keeps iso x positive
@ -453,6 +454,7 @@ export class CivilizationMapView {
refresh() { refresh() {
this.updateFog(); this.updateFog();
this.dynamic.removeAll(true); this.dynamic.removeAll(true);
this.unitContainers.clear();
const { state, rules } = this; const { state, rules } = this;
const povIdx = this.humanIdx >= 0 ? this.humanIdx : state.current; const povIdx = this.humanIdx >= 0 ? this.humanIdx : state.current;
const visible = computeVisible(state, povIdx); const visible = computeVisible(state, povIdx);
@ -591,6 +593,7 @@ export class CivilizationMapView {
} }
container.setDepth(y + 2); container.setDepth(y + 2);
this.dynamic.add(container); this.dynamic.add(container);
this.unitContainers.set(unit.id, container);
if (unit.id === this.selectedUnitId) this.selectedContainer = container; if (unit.id === this.selectedUnitId) this.selectedContainer = container;
} }
@ -649,6 +652,196 @@ export class CivilizationMapView {
nextSeg(); nextSeg();
} }
// Glides any number of already-drawn units' markers from their turn-start
// tile to their turn-end tile simultaneously — used for AI civs, whose
// moves are only known as a before/after snapshot (no waypoint list like
// the human's click-to-move path), so each glide is a straight line.
// `moves` is [{ unitId, from: [c,r], to: [c,r] }, ...]. Units with no
// on-screen marker (not currently visible, stacked under another unit,
// etc.) are silently skipped. Fires `onComplete` once, after `pauseMs`.
animateUnitsAlong(moves, duration, pauseMs, onComplete) {
const live = moves.filter((m) => this.unitContainers.has(m.unitId));
if (!live.length) { onComplete?.(); return; }
for (const { unitId, from, to } of live) {
const container = this.unitContainers.get(unitId);
const start = { x: this.isoX(from[0], from[1]), y: this.isoY(from[0], from[1]) + TILE_H / 2 };
const end = { x: this.isoX(to[0], to[1]), y: this.isoY(to[0], to[1]) + TILE_H / 2 };
container.setPosition(start.x, start.y);
container.setDepth(start.y + 2);
this.scene.tweens.add({
targets: container,
x: end.x,
y: end.y,
duration,
ease: 'Sine.easeInOut',
onUpdate: () => container.setDepth(container.y + 2),
});
}
this.scene.time.delayedCall(duration + pauseMs, () => onComplete?.());
}
isTileVisible(c, r) {
const povIdx = this.humanIdx >= 0 ? this.humanIdx : this.state.current;
return computeVisible(this.state, povIdx).has(tileIndex(this.state.world, c, r));
}
// Standalone unit visual for combat animation. Not tracked in
// unitContainers and not tied to live state — combat has already resolved
// by the time this plays (the loser may already be gone, the winner may
// already have moved), so ghosts are built fresh from the event's
// civ/type/tile snapshot and discarded when the sequence finishes.
buildCombatGhost(civIdx, unitType, c, r) {
const { scene, rules } = this;
const def = rules.units[unitType];
const civ = this.state.civs[civIdx];
const color = Phaser.Display.Color.HexStringToColor(civ.color).color;
const x = this.isoX(c, r);
const y = this.isoY(c, r) + TILE_H / 2;
const container = scene.add.container(x, y);
const spriteMode = scene.textures.exists('civilization-units');
let img = null;
let roundel = null;
if (spriteMode) {
const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1);
img = scene.add.image(0, TILE_H / 2 - UNIT_FRAME_H / 2, 'civilization-units', def.frame);
container.add([ring, img]);
} else {
roundel = scene.add.graphics();
roundel.fillStyle(0x000000, 0.35);
roundel.fillEllipse(0, 12, 40, 14);
roundel.fillStyle(color, 1);
roundel.fillCircle(0, -2, 17);
roundel.lineStyle(2, 0xffffff, 0.5);
roundel.strokeCircle(0, -2, 17);
container.add(roundel);
const label = scene.add.text(0, -2, def.abbr, {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5);
container.add(label);
}
container.setDepth(y + 2);
this.dynamic.add(container);
return { container, img, roundel, color };
}
// Tints the loser bright red (setTint on sprite art; a manual red redraw
// for the procedural-roundel fallback, which has no Tint component),
// flashes it a few times, then shrinks it away and destroys it.
playDeathFlash(ghost, onDone) {
const { container, img, roundel, color } = ghost;
const setRed = (on) => {
if (img) { if (on) img.setTint(0xff0000); else img.clearTint(); return; }
if (!roundel) return;
roundel.clear();
roundel.fillStyle(0x000000, 0.35);
roundel.fillEllipse(0, 12, 40, 14);
roundel.fillStyle(on ? 0xff0000 : color, 1);
roundel.fillCircle(0, -2, 17);
roundel.lineStyle(2, 0xffffff, 0.5);
roundel.strokeCircle(0, -2, 17);
};
const FLASH_MS = 110;
const FLASHES = 3;
let n = 0;
const flash = () => {
setRed(true);
this.scene.time.delayedCall(FLASH_MS, () => {
setRed(false);
n += 1;
if (n < FLASHES) { this.scene.time.delayedCall(FLASH_MS, flash); return; }
this.scene.tweens.add({
targets: container, scale: 0, alpha: 0, duration: 300, ease: 'Cubic.easeIn',
onComplete: () => { container.destroy(); onDone?.(); },
});
});
};
flash();
}
// Plays a queue of combat events sequentially: attacker lunges onto the
// defender's tile and back, the loser tints/flashes/shrinks away, then —
// only if the attacker won and the tile ended up empty — the attacker
// glides into it, followed by a short pause. `events` come from the
// engine's `combat`-type entries in state.events (already fully resolved;
// this just replays what happened for the player to see).
animateCombat(events, onComplete) {
const queue = events.slice();
const playNext = () => {
const e = queue.shift();
if (!e) { onComplete?.(); return; }
this.playCombatEvent(e, playNext);
};
playNext();
}
playCombatEvent(e, onDone) {
// Defends against stale-shape combat events — e.g. a save (or a live
// session carrying pre-existing state.events) written before this
// snapshot shape existed — rather than throwing and wedging the turn.
const civOk = (idx) => Number.isInteger(idx) && !!this.state.civs[idx];
const typeOk = (t) => !!this.rules.units[t];
if (!civOk(e.attackerCiv) || !civOk(e.defenderCiv) || !typeOk(e.attackerType) || !typeOk(e.defenderType)
|| !Number.isInteger(e.ax) || !Number.isInteger(e.ay) || !Number.isInteger(e.x) || !Number.isInteger(e.y)) {
onDone?.();
return;
}
this.unitContainers.get(e.attackerId)?.setVisible(false);
this.unitContainers.get(e.defenderId)?.setVisible(false);
const attackerGhost = this.buildCombatGhost(e.attackerCiv, e.attackerType, e.ax, e.ay);
const defenderGhost = this.buildCombatGhost(e.defenderCiv, e.defenderType, e.x, e.y);
const home = { x: this.isoX(e.ax, e.ay), y: this.isoY(e.ax, e.ay) + TILE_H / 2 };
const target = { x: this.isoX(e.x, e.y), y: this.isoY(e.x, e.y) + TILE_H / 2 };
const LUNGE_MS = 250;
// Lunge onto the defender's tile...
this.scene.tweens.add({
targets: attackerGhost.container,
x: target.x,
y: target.y,
duration: LUNGE_MS,
ease: 'Sine.easeIn',
onUpdate: () => attackerGhost.container.setDepth(attackerGhost.container.y + 3),
onComplete: () => {
// ...then retreat back to where the attack came from.
this.scene.tweens.add({
targets: attackerGhost.container,
x: home.x,
y: home.y,
duration: LUNGE_MS,
ease: 'Sine.easeOut',
onUpdate: () => attackerGhost.container.setDepth(attackerGhost.container.y + 2),
onComplete: () => this.finishCombatEvent(e, attackerGhost, defenderGhost, home, target, onDone),
});
},
});
}
finishCombatEvent(e, attackerGhost, defenderGhost, home, target, onDone) {
const loser = e.attackerWon ? defenderGhost : attackerGhost;
const winner = e.attackerWon ? attackerGhost : defenderGhost;
this.playDeathFlash(loser, () => {
if (e.attackerWon && e.advanced) {
this.scene.tweens.add({
targets: winner.container,
x: target.x,
y: target.y,
duration: 1000,
ease: 'Sine.easeInOut',
onUpdate: () => winner.container.setDepth(winner.container.y + 2),
onComplete: () => {
winner.container.destroy();
this.scene.time.delayedCall(250, onDone);
},
});
return;
}
winner.container.destroy();
this.scene.time.delayedCall(250, onDone);
});
}
showPath(path) { showPath(path) {
this.pathGfx.clear(); this.pathGfx.clear();
if (!path || !path.length) return; if (!path || !path.length) return;

View File

@ -601,6 +601,12 @@ if (RULES) {
{ {
const st = makeFlatState(); const st = makeFlatState();
setWar(st, 0, 1); setWar(st, 0, 1);
// Cityless civs with no settler are auto-eliminated by checkVictory (run
// at the end of resolveAttack) — give each side a settler elsewhere so
// wiping out the other's stack doesn't also eliminate the attacker's own
// civ (and remove the tank) as a side effect of this fixture.
Logic.spawnUnit(RULES, st, 0, 'settlers', 0, 0, null);
Logic.spawnUnit(RULES, st, 1, 'settlers', 15, 15, null);
Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null); Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null);
Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null); Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null);
Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null); Logic.spawnUnit(RULES, st, 1, 'warriors', 5, 5, null);
@ -612,7 +618,19 @@ if (RULES) {
out = Logic.resolveAttack(RULES, st, tank, 5, 5); out = Logic.resolveAttack(RULES, st, tank, 5, 5);
if (out.won) break; if (out.won) break;
} }
check('stack dies on open ground', out.won === true && Logic.unitsAt(st, 5, 5).length === 0); // Winner advances onto an open-ground tile it just cleared entirely
// (matches the victory-glide the UI plays for an unopposed win).
check('stack dies on open ground', out.won === true);
check('attacker advances onto the now-empty tile',
tank.x === 5 && tank.y === 5 && Logic.unitsAt(st, 5, 5).length === 1
&& Logic.unitsAt(st, 5, 5)[0].id === tank.id);
const combatEvt = st.events.filter((e) => e.type === 'combat').pop();
check('combat event carries attacker/defender snapshot for the UI',
!!combatEvt && combatEvt.ax === 4 && combatEvt.ay === 5 && combatEvt.x === 5 && combatEvt.y === 5
&& combatEvt.attackerId === tank.id && combatEvt.attackerCiv === 0 && combatEvt.attackerType === 'armor'
&& combatEvt.defenderCiv === 1 && combatEvt.defenderType === 'warriors'
&& combatEvt.attackerWon === true && combatEvt.advanced === true);
const st2 = makeFlatState(); const st2 = makeFlatState();
setWar(st2, 0, 1); setWar(st2, 0, 1);
@ -629,6 +647,12 @@ if (RULES) {
} }
check('city stack loses only defender', won2 && Logic.unitsAt(st2, 5, 5).length === 1, check('city stack loses only defender', won2 && Logic.unitsAt(st2, 5, 5).length === 1,
`${Logic.unitsAt(st2, 5, 5).length} left, city ${!!city}`); `${Logic.unitsAt(st2, 5, 5).length} left, city ${!!city}`);
// A win against a protected (city) stack that still has defenders left
// does NOT advance the attacker onto the tile.
check('attacker does not advance into a still-defended city',
!(tank2.x === 5 && tank2.y === 5));
const combatEvt2 = st2.events.filter((e) => e.type === 'combat').pop();
check('non-advancing combat event says so', !!combatEvt2 && combatEvt2.advanced === false);
} }
// City capture: pop loss, loot, palace relocation, elimination. // City capture: pop loss, loot, palace relocation, elimination.