feat(advancewars): add property capture cut-in animation with building squish

Introduce a cinematic capture animation when a unit captures a property:
a small window shows the capturing unit hopping atop the building, which
then squishes down to reflect remaining capture points (or pops back up
in the new owner's color if the capture completes).

- Add AdvanceWarsCaptureAnim.js with the full cut-in animation sequence
- Display remaining capture-points as a strength number on partially-
  captured buildings, tinted toward the owner's color
- Add `before` field to capturing events so the animation knows the
  starting HP
- Support partial unit sync so units are revealed action-by-action
  during turn replay instead of snapping to end-of-turn positions
- Add depth bias so units always render above buildings on the same tile
This commit is contained in:
Brian Fertig 2026-07-19 13:55:48 -06:00
parent fcc74bef28
commit a0b3b5f063
6 changed files with 238 additions and 18 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -0,0 +1,130 @@
// Advance Wars capture cut-in: a small window showing the capturing unit
// hopping atop the property, then the building squishing down to reflect
// the capture points remaining (or, if this action finished the capture,
// popping back up to full height in the new owner's color). Driven purely
// by the engine's 'capturing' (+ optional paired 'captured') events.
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import * as Logic from './AdvanceWarsLogic.js';
import { armyColorInt } from './AdvanceWarsMapView.js';
import { FONT } from './AdvanceWarsUI.js';
const W = 380;
const H = 500;
const BUILDING_SCALE = 2.6;
const UNIT_SCALE = 2.2;
const HOP = 30;
const NUMBER_OFFSET_X = 95;
// capturing: engine 'capturing' event ({unitId, x, y, before, left}).
// captured: the paired 'captured' event if this action finished the
// capture ({unitId, x, y, army, prevOwner}), else null/undefined.
export function playCaptureAnim(scene, rules, view, capturing, captured, onDone) {
const unit = Logic.unitById(view.state, capturing.unitId);
const k = Logic.tileKey(view.state, capturing.x, capturing.y);
const t = rules.terrains[view.state.terrain[k]];
if (!unit || !t) { onDone?.(); return null; }
const goal = rules.constants.captureGoal;
const beforeFrac = capturing.before / goal;
const afterFrac = capturing.left / goal;
const priorOwner = captured ? captured.prevOwner : view.state.owner[k];
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2 - 40;
const objs = [];
let finished = false;
const timers = [];
const dim = scene.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.35)
.setDepth(50).setInteractive();
const panel = scene.add.rectangle(cx, cy, W, H, 0x10141f, 0.97)
.setStrokeStyle(3, 0x3a4260).setDepth(50);
objs.push(dim, panel);
const groundCenterY = cy + H / 2 - 50;
const ground = scene.add.rectangle(cx, groundCenterY, W - 60, 80, 0x27324a, 1).setDepth(50);
objs.push(ground);
const groundY = groundCenterY - 40; // top edge of the ground strip
const label = scene.add.text(cx, cy - H / 2 + 30, (t.name ?? 'Property').toUpperCase(),
{ fontFamily: FONT, fontSize: '26px', color: '#ffffff' }).setOrigin(0.5, 0.5).setDepth(51);
objs.push(label);
const building = scene.add.image(cx, groundY, view.tex.buildings, t.frame)
.setOrigin(0.5, 1).setDepth(51)
.setTint(armyColorInt(rules, priorOwner));
building.scaleX = BUILDING_SCALE;
building.scaleY = BUILDING_SCALE * beforeFrac;
objs.push(building);
const spec = rules.unitById[unit.type];
const unitImg = scene.add.image(cx, groundY - building.displayHeight, view.tex.units, spec.frame)
.setOrigin(0.5, 1).setScale(UNIT_SCALE).setDepth(52)
.setFlipX(unit.army !== 0)
.setTint(armyColorInt(rules, unit.army));
objs.push(unitImg);
// large capture-points counter, floating to the unit's left, always
// vertically centered on it — tracked in every phase's onUpdate below.
const pointsText = scene.add.text(cx - NUMBER_OFFSET_X, unitImg.y - unitImg.displayHeight / 2,
String(capturing.before),
{ fontFamily: FONT, fontSize: '64px', color: '#ffe14d', stroke: '#000000', strokeThickness: 6 })
.setOrigin(0.5, 0.5).setDepth(52);
objs.push(pointsText);
const trackUnit = () => { pointsText.x = unitImg.x - NUMBER_OFFSET_X; pointsText.y = unitImg.y - unitImg.displayHeight / 2; };
const flip = scene.time.addEvent({
delay: 450, loop: true, callback: () => {
unitImg.setFrame(unitImg.frame.name === spec.frame ? spec.frame + 1 : spec.frame);
},
});
timers.push(flip);
const cleanup = () => {
if (finished) return;
finished = true;
for (const timer of timers) timer.remove();
for (const o of objs) o.destroy();
onDone?.();
};
dim.on('pointerdown', cleanup);
function restY() { return groundY - building.displayHeight; }
// live points value from the building's current squish fraction, so the
// counter is always exactly in sync with what's on screen.
function currentPoints() { return Math.round((building.scaleY / BUILDING_SCALE) * goal); }
function phase3raise() {
if (finished) return;
building.setTint(armyColorInt(rules, captured.army));
scene.tweens.add({
targets: building, scaleY: BUILDING_SCALE, duration: 500, ease: 'Sine.easeOut',
onUpdate: () => { unitImg.y = restY(); trackUnit(); pointsText.setText(String(currentPoints())); },
onComplete: () => timers.push(scene.time.delayedCall(300, cleanup)),
});
}
function phase2squish() {
if (finished) return;
// .75s: squish the building down to the new remaining-points fraction,
// keeping the unit glued to the shrinking top edge every frame, and
// counting the points-remaining number down in lockstep.
scene.tweens.add({
targets: building, scaleY: BUILDING_SCALE * afterFrac, duration: 750, ease: 'Sine.easeInOut',
onUpdate: () => { unitImg.y = restY(); trackUnit(); pointsText.setText(String(currentPoints())); },
onComplete: () => {
if (captured) phase3raise();
else timers.push(scene.time.delayedCall(400, cleanup));
},
});
}
// 1.5s: two hops in place atop the (pre-squish) building height.
scene.tweens.add({
targets: unitImg, y: restY() - HOP, duration: 375, yoyo: true, repeat: 1, ease: 'Sine.easeOut',
onUpdate: trackUnit,
onComplete: phase2squish,
});
return { skip: cleanup };
}

View File

@ -16,6 +16,7 @@ import { AdvanceWarsMapView, ensureSheets, armyColorInt } from './AdvanceWarsMap
import { AdvanceWarsHUD, ActionMenu, DamagePreview, ProductionMenu, TileInfo, mkButton, mkText } from './AdvanceWarsUI.js'; import { AdvanceWarsHUD, ActionMenu, DamagePreview, ProductionMenu, TileInfo, mkButton, mkText } from './AdvanceWarsUI.js';
import * as Screens from './AdvanceWarsScreens.js'; import * as Screens from './AdvanceWarsScreens.js';
import { playBattleAnim } from './AdvanceWarsBattleAnim.js'; import { playBattleAnim } from './AdvanceWarsBattleAnim.js';
import { playCaptureAnim } from './AdvanceWarsCaptureAnim.js';
const SAVE_KEY = 'advancewars-save'; const SAVE_KEY = 'advancewars-save';
const ANIM_KEY = 'advancewars-battle-anims'; const ANIM_KEY = 'advancewars-battle-anims';
@ -532,6 +533,15 @@ export default class AdvanceWarsGame extends Phaser.Scene {
} }
this.crt.pulse(0.6, 260); this.crt.pulse(0.6, 260);
} }
const capturing = events.find((e) => e.type === 'capturing');
if (capturing) {
const captured = events.find((e) => e.type === 'captured');
const visible = !run.state.fog ||
Logic.computeVision(this.rules, run.state, 0).has(Logic.tileKey(run.state, capturing.x, capturing.y));
if (this.battleAnims && visible) {
await new Promise((resolve) => playCaptureAnim(this, this.rules, run.view, capturing, captured, resolve));
}
}
for (const e of events) { for (const e of events) {
if (e.type === 'destroyed' || e.type === 'crashed') { if (e.type === 'destroyed' || e.type === 'crashed') {
run.view.boom(e.x, e.y); run.view.boom(e.x, e.y);
@ -546,10 +556,21 @@ export default class AdvanceWarsGame extends Phaser.Scene {
if (e.type === 'meteor' || e.type === 'tsunami') this.crt.pulse(1.0, 500); if (e.type === 'meteor' || e.type === 'tsunami') this.crt.pulse(1.0, 500);
if (e.type === 'dayStart' && e.army === 0) this.saveGame(); if (e.type === 'dayStart' && e.army === 0) this.saveGame();
} }
run.view.syncUnits(); // Reveal only the unit(s) this entry actually touched. `state` is
// already the whole turn's final result computed up front, so a
// blanket sync would snap every not-yet-animated unit straight to
// its end-of-turn position/hp — then animateMove would visibly jerk
// it back to its start tile to replay the path. Wide army-level
// effects (CO powers, meteor, etc.) already get their own dedicated
// full-screen reveal, so those still do a full sync.
const wideReveal = events.some((e) =>
['powerFired', 'healedAll', 'refreshed', 'tsunami', 'meteor', 'armyEliminated'].includes(e.type));
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.refreshFog(0); run.view.refreshFog(0);
run.hud.refresh(run.state, 0); run.hud.refresh(run.state, 0);
if (enemy && (moved || battles.length)) await this.wait(300); if (enemy && (moved || battles.length || capturing)) await this.wait(500);
} }
} }

View File

@ -739,9 +739,10 @@ export function applyAction(state, rules, action) {
if (state.owner[k] >= 0 && !hostile(state, state.owner[k], army)) return fail('allied property'); if (state.owner[k] >= 0 && !hostile(state, state.owner[k], army)) return fail('allied property');
const fx = coEffects(rules, state, army); const fx = coEffects(rules, state, army);
const points = Math.floor(hpDisplay(unit) * (fx.captureMult ?? 1)); const points = Math.floor(hpDisplay(unit) * (fx.captureMult ?? 1));
const before = state.captureHp[k];
state.captureHp[k] = Math.max(0, state.captureHp[k] - points); state.captureHp[k] = Math.max(0, state.captureHp[k] - points);
unit.capturing = state.captureHp[k] > 0; unit.capturing = state.captureHp[k] > 0;
events.push({ type: 'capturing', unitId: unit.id, x: unit.x, y: unit.y, left: state.captureHp[k] }); events.push({ type: 'capturing', unitId: unit.id, x: unit.x, y: unit.y, before, left: state.captureHp[k] });
if (state.captureHp[k] === 0) { if (state.captureHp[k] === 0) {
const prevOwner = state.owner[k]; const prevOwner = state.owner[k];
state.owner[k] = army; state.owner[k] = army;

View File

@ -15,6 +15,18 @@ import * as Logic from './AdvanceWarsLogic.js';
const CELL = 48; const CELL = 48;
const TALL = 64; // building/unit cells: 48×48 footprint + 16px headroom const TALL = 64; // building/unit cells: 48×48 footprint + 16px headroom
// Units and buildings share one Y-sorted depth band (actors + row), so a
// unit two rows down still draws over a building one row up, etc. This bias
// only breaks a tie — same row, same depth — in the unit's favor, so a unit
// standing on the same tile as a building always renders above it, without
// disturbing the row-vs-row ordering (it's well under the 1-per-row step).
const UNIT_DEPTH_BIAS = 0.5;
// building strength number: anchored just outside the building's left edge,
// sinking back in by this many pixels so it reads as sitting on/against the
// building rather than floating disconnected from it.
const PROP_NUMBER_OVERLAP = 5;
// Painted drop-in sheets and the layout their procedural stand-ins mirror. // Painted drop-in sheets and the layout their procedural stand-ins mirror.
export const SHEETS = { export const SHEETS = {
terrain: { key: 'advancewars-terrain', cellH: CELL, cols: 8, frames: 8 }, terrain: { key: 'advancewars-terrain', cellH: CELL, cols: 8, frames: 8 },
@ -207,7 +219,7 @@ export class AdvanceWarsMapView {
// overlay reads as a ground decal instead of washing over sprites. // overlay reads as a ground decal instead of washing over sprites.
this.depths = { terrain: 1, moveOv: 2, atkOv: 3, actors: 4, fog: 20, path: 21, cursor: 22 }; this.depths = { terrain: 1, moveOv: 2, atkOv: 3, actors: 4, fog: 20, path: 21, cursor: 22 };
this.unitViews = new Map(); // unitId -> { c, sprite, hp, badge } this.unitViews = new Map(); // unitId -> { c, sprite, hp, badge }
this.propViews = new Map(); // tileKey -> image this.propViews = new Map(); // tileKey -> { img, strength }
this.animStep = 0; this.animStep = 0;
this.buildTerrain(); this.buildTerrain();
@ -238,7 +250,7 @@ export class AdvanceWarsMapView {
this.moveG?.destroy(); this.atkG?.destroy(); this.fogG?.destroy(); this.moveG?.destroy(); this.atkG?.destroy(); this.fogG?.destroy();
this.pathG?.destroy(); this.cursor?.destroy(); this.pathG?.destroy(); this.cursor?.destroy();
for (const v of this.unitViews.values()) v.c.destroy(); for (const v of this.unitViews.values()) v.c.destroy();
for (const img of this.propViews.values()) img.destroy(); for (const v of this.propViews.values()) { v.img.destroy(); v.strength.destroy(); }
} }
// pixel center of a tile // pixel center of a tile
@ -356,26 +368,59 @@ export class AdvanceWarsMapView {
.setOrigin(0.5, 1) .setOrigin(0.5, 1)
.setScale(this.spriteScaleFor()) .setScale(this.spriteScaleFor())
.setDepth(this.depths.actors + y); .setDepth(this.depths.actors + y);
this.propViews.set(k, img); // strength readout, sitting just left of the building itself (right-
// anchored so it grows leftward with digit count, always overlapping
// the building's left edge by the same few pixels regardless of
// whether it's showing "5" or "20") — mirrors units' HP digit on the
// opposite side so the two never collide on a garrisoned property.
const strength = this.scene.add.text(
this.px(x) - this.tile / 2 + PROP_NUMBER_OVERLAP, this.oy + (y + 1) * this.tile - 2, '', {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: `${Math.max(13, this.tile * 0.34)}px`,
color: '#ffffff', stroke: '#000000', strokeThickness: 3,
}).setOrigin(1, 1).setDepth(this.depths.actors + y + 0.25);
this.propViews.set(k, { img, strength });
} }
this.refreshProps(); this.refreshProps();
} }
refreshProps() { refreshProps() {
for (const [k, img] of this.propViews) { const goal = this.rules.constants.captureGoal;
img.setTint(armyColorInt(this.rules, this.state.owner[k])); for (const [k, v] of this.propViews) {
const owner = this.state.owner[k];
v.img.setTint(armyColorInt(this.rules, owner));
const hp = this.state.captureHp[k];
// a building under partial capture stays visibly squished at rest —
// height is remaining-capture-points / captureGoal, animated live by
// playCaptureAnim while a capture action is actively resolving.
v.img.scaleY = this.spriteScaleFor() * (hp / goal);
// strength number only shows below full — tinted toward the owner's
// color (or light gray if neutral) but blended well toward white so
// it stays legible over the black stroke outline.
if (hp < goal) {
v.strength.setText(String(hp)).setColor(toHexColor(lighten(armyColorInt(this.rules, owner), 0.6))).setVisible(true);
} else {
v.strength.setVisible(false);
}
} }
} }
// ── units ──────────────────────────────────────────────────────────────── // ── units ────────────────────────────────────────────────────────────────
syncUnits() { // onlyIds, when given, restricts this pass to just those unit ids — any
// other unit (known or not-yet-created) is left exactly as last shown.
// 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) {
const { state, rules, scene } = this; const { state, rules, scene } = this;
const seen = new Set(); const present = new Set();
for (const u of state.units) { for (const u of state.units) {
seen.add(u.id); present.add(u.id);
const known = this.unitViews.has(u.id);
if (!known && onlyIds && !onlyIds.has(u.id)) continue; // not revealed yet
let v = this.unitViews.get(u.id); let v = this.unitViews.get(u.id);
if (!v) { const justCreated = !v;
if (justCreated) {
const c = scene.add.container(0, 0); const c = scene.add.container(0, 0);
const sprite = scene.add.image(0, 0, this.tex.units, rules.unitById[u.type].frame) const sprite = scene.add.image(0, 0, this.tex.units, rules.unitById[u.type].frame)
.setOrigin(0.5, 1).setScale(this.spriteScaleFor()); .setOrigin(0.5, 1).setScale(this.spriteScaleFor());
@ -383,16 +428,28 @@ export class AdvanceWarsMapView {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: `${Math.max(13, this.tile * 0.34)}px`, fontFamily: 'm6x11, "Julius Sans One"', fontSize: `${Math.max(13, this.tile * 0.34)}px`,
color: '#ffffff', stroke: '#000000', strokeThickness: 3, color: '#ffffff', stroke: '#000000', strokeThickness: 3,
}).setOrigin(0.5, 1); }).setOrigin(0.5, 1);
const badge = scene.add.image(-this.tile * 0.32, -this.tile * 0.16, this.tex.ui, UI_FRAMES.capture) // sits directly above where the building's strength number renders
// on this same tile (see refreshProps) — aligned to that number's
// fixed right-edge anchor and a few pixels of padding above its top.
const numberFontH = Math.max(13, this.tile * 0.34);
const badgeH = this.tile * 0.4;
const badge = scene.add.image(
-this.tile / 2 + PROP_NUMBER_OVERLAP, -2 - numberFontH - 3 - badgeH / 2, this.tex.ui, UI_FRAMES.capture)
.setScale(this.spriteScaleFor() * 0.4).setVisible(false); .setScale(this.spriteScaleFor() * 0.4).setVisible(false);
c.add([sprite, hp, badge]); c.add([sprite, hp, badge]);
v = { c, sprite, hp, badge }; v = { c, sprite, hp, badge };
this.unitViews.set(u.id, v); this.unitViews.set(u.id, v);
} }
this.placeUnit(u, v); 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'
// event handlers already remove views at the right moment for that.
if (!onlyIds) {
for (const [id, v] of [...this.unitViews]) { for (const [id, v] of [...this.unitViews]) {
if (!seen.has(id)) { v.c.destroy(); this.unitViews.delete(id); } if (!present.has(id)) { v.c.destroy(); this.unitViews.delete(id); }
}
} }
this.refreshProps(); this.refreshProps();
} }
@ -400,7 +457,7 @@ export class AdvanceWarsMapView {
placeUnit(u, v = this.unitViews.get(u.id)) { placeUnit(u, v = this.unitViews.get(u.id)) {
if (!v) return; if (!v) return;
const { rules } = this; const { rules } = this;
v.c.setPosition(this.px(u.x), this.oy + (u.y + 1) * this.tile).setDepth(this.depths.actors + u.y); v.c.setPosition(this.px(u.x), this.oy + (u.y + 1) * this.tile).setDepth(this.depths.actors + u.y + UNIT_DEPTH_BIAS);
v.sprite.setFrame(rules.unitById[u.type].frame + this.animStep); v.sprite.setFrame(rules.unitById[u.type].frame + this.animStep);
v.sprite.setFlipX(u.army !== 0); v.sprite.setFlipX(u.army !== 0);
const base = armyColorInt(rules, u.army); const base = armyColorInt(rules, u.army);
@ -509,7 +566,7 @@ export class AdvanceWarsMapView {
const step = () => { const step = () => {
if (i >= points.length) { onDone?.(); return; } if (i >= points.length) { onDone?.(); return; }
const p = points[i++]; const p = points[i++];
v.c.setDepth(this.depths.actors + p.row); v.c.setDepth(this.depths.actors + p.row + UNIT_DEPTH_BIAS);
this.scene.tweens.add({ this.scene.tweens.add({
targets: v.c, x: p.x, y: p.y, duration: 180, onComplete: step, targets: v.c, x: p.x, y: p.y, duration: 180, onComplete: step,
}); });
@ -524,3 +581,14 @@ function darken(color, f) {
const b = Math.floor((color & 0xff) * f); const b = Math.floor((color & 0xff) * f);
return (r << 16) | (g << 8) | b; return (r << 16) | (g << 8) | b;
} }
// blend a color toward white by f (0 = unchanged, 1 = pure white) — used to
// keep team-colored text pale enough to stay legible over its stroke.
function lighten(color, f) {
const r = Math.round(((color >> 16) & 0xff) + (255 - ((color >> 16) & 0xff)) * f);
const g = Math.round(((color >> 8) & 0xff) + (255 - ((color >> 8) & 0xff)) * f);
const b = Math.round((color & 0xff) + (255 - (color & 0xff)) * f);
return (r << 16) | (g << 8) | b;
}
function toHexColor(n) { return `#${n.toString(16).padStart(6, '0')}`; }