diff --git a/assets/images/advancewars/advancewars-units.png b/assets/images/advancewars/advancewars-units.png index 331dd64..8e29f6c 100644 Binary files a/assets/images/advancewars/advancewars-units.png and b/assets/images/advancewars/advancewars-units.png differ diff --git a/assets/images/advancewars/advancewars-units.psd b/assets/images/advancewars/advancewars-units.psd index aed391b..4b9c601 100644 Binary files a/assets/images/advancewars/advancewars-units.psd and b/assets/images/advancewars/advancewars-units.psd differ diff --git a/src/games/advancewars/AdvanceWarsCaptureAnim.js b/src/games/advancewars/AdvanceWarsCaptureAnim.js new file mode 100644 index 0000000..0049451 --- /dev/null +++ b/src/games/advancewars/AdvanceWarsCaptureAnim.js @@ -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 }; +} diff --git a/src/games/advancewars/AdvanceWarsGame.js b/src/games/advancewars/AdvanceWarsGame.js index 6f74477..85169c0 100644 --- a/src/games/advancewars/AdvanceWarsGame.js +++ b/src/games/advancewars/AdvanceWarsGame.js @@ -16,6 +16,7 @@ import { AdvanceWarsMapView, ensureSheets, armyColorInt } from './AdvanceWarsMap import { AdvanceWarsHUD, ActionMenu, DamagePreview, ProductionMenu, TileInfo, mkButton, mkText } from './AdvanceWarsUI.js'; import * as Screens from './AdvanceWarsScreens.js'; import { playBattleAnim } from './AdvanceWarsBattleAnim.js'; +import { playCaptureAnim } from './AdvanceWarsCaptureAnim.js'; const SAVE_KEY = 'advancewars-save'; const ANIM_KEY = 'advancewars-battle-anims'; @@ -532,6 +533,15 @@ export default class AdvanceWarsGame extends Phaser.Scene { } 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) { if (e.type === 'destroyed' || e.type === 'crashed') { 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 === '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.hud.refresh(run.state, 0); - if (enemy && (moved || battles.length)) await this.wait(300); + if (enemy && (moved || battles.length || capturing)) await this.wait(500); } } diff --git a/src/games/advancewars/AdvanceWarsLogic.js b/src/games/advancewars/AdvanceWarsLogic.js index 7dbec3c..e6d05a7 100644 --- a/src/games/advancewars/AdvanceWarsLogic.js +++ b/src/games/advancewars/AdvanceWarsLogic.js @@ -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'); const fx = coEffects(rules, state, army); const points = Math.floor(hpDisplay(unit) * (fx.captureMult ?? 1)); + const before = state.captureHp[k]; state.captureHp[k] = Math.max(0, state.captureHp[k] - points); 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) { const prevOwner = state.owner[k]; state.owner[k] = army; diff --git a/src/games/advancewars/AdvanceWarsMapView.js b/src/games/advancewars/AdvanceWarsMapView.js index b4ae224..2418f98 100644 --- a/src/games/advancewars/AdvanceWarsMapView.js +++ b/src/games/advancewars/AdvanceWarsMapView.js @@ -15,6 +15,18 @@ import * as Logic from './AdvanceWarsLogic.js'; const CELL = 48; 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. export const SHEETS = { 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. 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.propViews = new Map(); // tileKey -> image + this.propViews = new Map(); // tileKey -> { img, strength } this.animStep = 0; this.buildTerrain(); @@ -238,7 +250,7 @@ export class AdvanceWarsMapView { this.moveG?.destroy(); this.atkG?.destroy(); this.fogG?.destroy(); this.pathG?.destroy(); this.cursor?.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 @@ -356,26 +368,59 @@ export class AdvanceWarsMapView { .setOrigin(0.5, 1) .setScale(this.spriteScaleFor()) .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(); } refreshProps() { - for (const [k, img] of this.propViews) { - img.setTint(armyColorInt(this.rules, this.state.owner[k])); + const goal = this.rules.constants.captureGoal; + 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 ──────────────────────────────────────────────────────────────── - 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 seen = new Set(); + const present = new Set(); 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); - if (!v) { + const justCreated = !v; + if (justCreated) { const c = scene.add.container(0, 0); const sprite = scene.add.image(0, 0, this.tex.units, rules.unitById[u.type].frame) .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`, color: '#ffffff', stroke: '#000000', strokeThickness: 3, }).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); c.add([sprite, hp, badge]); v = { c, sprite, hp, badge }; this.unitViews.set(u.id, v); } - this.placeUnit(u, v); + if (justCreated || !onlyIds || onlyIds.has(u.id)) this.placeUnit(u, v); } - for (const [id, v] of [...this.unitViews]) { - if (!seen.has(id)) { v.c.destroy(); this.unitViews.delete(id); } + // 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]) { + if (!present.has(id)) { v.c.destroy(); this.unitViews.delete(id); } + } } this.refreshProps(); } @@ -400,7 +457,7 @@ export class AdvanceWarsMapView { placeUnit(u, v = this.unitViews.get(u.id)) { if (!v) return; 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.setFlipX(u.army !== 0); const base = armyColorInt(rules, u.army); @@ -509,7 +566,7 @@ export class AdvanceWarsMapView { const step = () => { if (i >= points.length) { onDone?.(); return; } 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({ 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); 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')}`; }