From 325692fa35fa0748eb35c5c61c5460d15f871391 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Wed, 15 Jul 2026 23:26:29 -0600 Subject: [PATCH] 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 --- src/games/civilization/CivilizationGame.js | 60 +++++- src/games/civilization/CivilizationLogic.js | 28 ++- src/games/civilization/CivilizationMapView.js | 193 ++++++++++++++++++ tools/verifyCivilization.js | 26 ++- 4 files changed, 299 insertions(+), 8 deletions(-) diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js index 1bc2a1c..bf8e9ec 100644 --- a/src/games/civilization/CivilizationGame.js +++ b/src/games/civilization/CivilizationGame.js @@ -565,6 +565,12 @@ export default class CivilizationGame extends Phaser.Scene { if (unit.mp <= 0 && 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]; if (!animate || !moved) { finish(); return; } 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, // 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. + // 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]]; let i = 0; let hutEvent = null; @@ -582,7 +590,7 @@ export default class CivilizationGame extends Phaser.Scene { i += 1; const out = Logic.tryMove(this.rules, this.state, unit, 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; visited.push([unit.x, unit.y]); } @@ -592,9 +600,28 @@ export default class CivilizationGame extends Phaser.Scene { this.afterAction(); 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); } + // 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` // ([[c,r], ...], already-resolved positions) over 1s, pauses 250ms at the // destination, then invokes `finish`. Falls straight through to `finish` @@ -744,11 +771,36 @@ export default class CivilizationGame extends Phaser.Scene { return; } 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); Logic.endCivTurn(this.rules, this.state, cur); - this.view.refresh(); - this.refreshHud(); - this.time.delayedCall(90, stepCiv); + // Resolve the whole civ's turn instantly (as ever). Combats play first, + // from whatever's still on screen from before this civ acted (no + // 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(); } diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js index 09e1650..144b0dc 100644 --- a/src/games/civilization/CivilizationLogic.js +++ b/src/games/civilization/CivilizationLogic.js @@ -933,6 +933,15 @@ export function resolveAttack(rules, state, attacker, tx, ty) { 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 D = defenderStrength(rules, state, defender, attacker); 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 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) { removeUnit(state, defender); if (!protectedStack) { @@ -948,6 +961,14 @@ export function resolveAttack(rules, state, attacker, tx, ty) { } attacker.hp = Math.max(1, duel.attackerHp); 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 { defender.hp = Math.max(1, duel.defenderHp); 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); state.events.push({ - type: 'combat', x: tx, y: ty, - attacker: { civ: attacker.civ, type: attacker.type }, - defender: { civ: defender.civ, type: defender.type }, + type: 'combat', x: tx, y: ty, ax, ay, + attackerId, attackerCiv, attackerType, + defenderId, defenderCiv, defenderType, attackerWon: duel.attackerWon, + advanced, }); checkVictory(rules, state); return { result: 'combat', won: duel.attackerWon }; diff --git a/src/games/civilization/CivilizationMapView.js b/src/games/civilization/CivilizationMapView.js index 3ad71f2..bcb3c84 100644 --- a/src/games/civilization/CivilizationMapView.js +++ b/src/games/civilization/CivilizationMapView.js @@ -34,6 +34,7 @@ export class CivilizationMapView { this.zoomIdx = 1; this.selectedUnitId = null; this.exploredDrawn = null; + this.unitContainers = new Map(); const { world } = state; this.originX = world.rows * (TILE_W / 2); // keeps iso x positive @@ -453,6 +454,7 @@ export class CivilizationMapView { refresh() { this.updateFog(); this.dynamic.removeAll(true); + this.unitContainers.clear(); const { state, rules } = this; const povIdx = this.humanIdx >= 0 ? this.humanIdx : state.current; const visible = computeVisible(state, povIdx); @@ -591,6 +593,7 @@ export class CivilizationMapView { } container.setDepth(y + 2); this.dynamic.add(container); + this.unitContainers.set(unit.id, container); if (unit.id === this.selectedUnitId) this.selectedContainer = container; } @@ -649,6 +652,196 @@ export class CivilizationMapView { 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) { this.pathGfx.clear(); if (!path || !path.length) return; diff --git a/tools/verifyCivilization.js b/tools/verifyCivilization.js index 3c1980d..1e53fff 100644 --- a/tools/verifyCivilization.js +++ b/tools/verifyCivilization.js @@ -601,6 +601,12 @@ if (RULES) { { const st = makeFlatState(); 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); @@ -612,7 +618,19 @@ if (RULES) { out = Logic.resolveAttack(RULES, st, tank, 5, 5); 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(); setWar(st2, 0, 1); @@ -629,6 +647,12 @@ if (RULES) { } check('city stack loses only defender', won2 && Logic.unitsAt(st2, 5, 5).length === 1, `${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.