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:
parent
e4ad09de67
commit
325692fa35
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue