diff --git a/assets/images/advancewars/advancewars-units.png b/assets/images/advancewars/advancewars-units.png index 8e29f6c..1ad95f4 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 4b9c601..2742938 100644 Binary files a/assets/images/advancewars/advancewars-units.psd and b/assets/images/advancewars/advancewars-units.psd differ diff --git a/assets/images/tetrisattack/background-beth.png b/assets/images/tetrisattack/background-beth.png new file mode 100644 index 0000000..96d1204 Binary files /dev/null and b/assets/images/tetrisattack/background-beth.png differ diff --git a/assets/images/tetrisattack/background-jerry.png b/assets/images/tetrisattack/background-jerry.png new file mode 100644 index 0000000..caf8c84 Binary files /dev/null and b/assets/images/tetrisattack/background-jerry.png differ diff --git a/assets/images/tetrisattack/tetrisattack-characters.png b/assets/images/tetrisattack/tetrisattack-characters.png new file mode 100644 index 0000000..3acb7c9 Binary files /dev/null and b/assets/images/tetrisattack/tetrisattack-characters.png differ diff --git a/assets/images/tetrisattack/tetrisattack-characters.psd b/assets/images/tetrisattack/tetrisattack-characters.psd new file mode 100644 index 0000000..04f21e2 Binary files /dev/null and b/assets/images/tetrisattack/tetrisattack-characters.psd differ diff --git a/data/tetrisattack-artwork.json b/data/tetrisattack-artwork.json index afda0a2..7828903 100644 --- a/data/tetrisattack-artwork.json +++ b/data/tetrisattack-artwork.json @@ -8,7 +8,7 @@ }, "characterSheet": { "key": "tetrisattack-characters", - "path": null, + "path": "assets/images/tetrisattack/tetrisattack-characters.png", "frameWidth": 200, "frameHeight": 280 } diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index b29c916..c222726 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -47,6 +47,12 @@ function imagesFrom(scene, jsonKey) { .map((a) => ({ type: 'image', key: a.key, path: a.path })); } +// Tetris Attack Stage Clear host backgrounds — drop-in art named +// background-{hero}.png (hero = lowercase host name from data/tetrisattack.json's +// stageClear.rounds). Speculatively declared for every host; hosts without art +// yet just log a load warning and the scene's textures.exists fallback applies. +const TETRISATTACK_HEROES = ['beth', 'jerry', 'michael', 'steve', 'victor', 'klaxon']; + // Drop-in per-game soundtrack tracks declared in a cached `-music.json` // (see services/soundtrack.js). Audio bytes lazy-load only when the owning // game is entered — never part of the shared default-soundtrack preload. @@ -224,6 +230,8 @@ export const MANIFEST = { { type: 'json', key: 'tetrisattack', path: 'data/tetrisattack.json' }, { type: 'json', key: 'tetrisattack-puzzles', path: 'data/tetrisattack-puzzles.json' }, (scene) => sheetsFrom(scene, 'tetrisattack-artwork', ['panelSheet', 'characterSheet']), + ...TETRISATTACK_HEROES.map((hero) => + image(`tetrisattack-bg-${hero}`, `assets/images/tetrisattack/background-${hero}.png`)), (scene) => musicFrom(scene, 'nintendo-music'), ], coloradodefense: [ diff --git a/src/games/advancewars/AdvanceWarsGame.js b/src/games/advancewars/AdvanceWarsGame.js index e76df71..9bd1f65 100644 --- a/src/games/advancewars/AdvanceWarsGame.js +++ b/src/games/advancewars/AdvanceWarsGame.js @@ -220,6 +220,7 @@ export default class AdvanceWarsGame extends Phaser.Scene { refreshAll() { const run = this.run; if (!run) return; + run.view.clearHpOverrides(); // settle to true live HP; replay is done run.view.syncUnits(); run.view.refreshFog(0); run.hud.refresh(run.state, 0); @@ -522,6 +523,19 @@ export default class AdvanceWarsGame extends Phaser.Scene { // nudged per-event so the player can follow along. async replayEvents(log, { enemy = false } = {}) { const run = this.run; + // Seed each battle participant's shown HP at its pre-turn value so a unit + // hit later this turn keeps its current HP on the board until its own + // battle animation plays (state is already the whole turn's final result). + // defender.hp is post-hit, so pre-hit HP is defender.hp + dmg; the first + // appearance (set-if-absent) wins. Each battle then advances the shown HP + // below, as it's actually replayed. + for (const entry of log) { + for (const b of (entry.events ?? [])) { + if (b.type !== 'battle') continue; + run.view.seedHpOverride(b.defenderId, b.defender.hp + b.dmg); + run.view.seedHpOverride(b.attackerId, b.attacker.hp); + } + } for (const entry of log) { if (this.run !== run) return; const events = entry.events ?? []; @@ -548,6 +562,12 @@ export default class AdvanceWarsGame extends Phaser.Scene { } } for (const e of events) { + if (e.type === 'battle') { + // this attack has now been shown — advance both fighters' board HP + // to their post-volley values (events are ordered, so last wins). + run.view.setHpOverride(e.attackerId, e.attacker.hp); + run.view.setHpOverride(e.defenderId, e.defender.hp); + } if (e.type === 'destroyed' || e.type === 'crashed') { run.view.boom(e.x, e.y); const v = run.view.unitViews.get(e.unitId); diff --git a/src/games/advancewars/AdvanceWarsMapView.js b/src/games/advancewars/AdvanceWarsMapView.js index d8a0eac..6d4a257 100644 --- a/src/games/advancewars/AdvanceWarsMapView.js +++ b/src/games/advancewars/AdvanceWarsMapView.js @@ -250,6 +250,7 @@ export class AdvanceWarsMapView { 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 -> { img, strength } + this.hpOverride = new Map(); // unitId -> HP to show mid-replay (see seedHpOverride) this.animStep = 0; this.buildTerrain(); @@ -324,38 +325,106 @@ export class AdvanceWarsMapView { img.destroy(); } - // Graphics-drawn rivers and roads (no sprite frames): each tile strokes - // from its center toward every connected edge. + // Graphics-drawn rivers and roads (no sprite frames), baked once into the + // terrain RenderTexture. Each tile strokes a spoke from its centre toward + // every connected edge, then the two are layered for depth: rivers get a + // lighter inner channel with pale ripple crests; roads get concrete + // shoulders, a dark asphalt top and a dashed centre line. drawChannels() { const { state, rules } = this; + const T = this.tile; const g = this.scene.add.graphics(); - const draw = (kind, color, width) => { - g.lineStyle(width, color, 1); - g.fillStyle(color, 1); + + // connected edge directions for a channel tile; isolated tiles fall back + // to an east-west stub so a lone road/river square still reads as one. + const spokes = (kind, x, y) => { + const out = []; + for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) { + if (this.channelJoins(kind, x + dx, y + dy)) out.push([dx, dy]); + } + return out.length ? out : [[-1, 0], [1, 0]]; + }; + const forTiles = (kind, fn) => { for (let y = 0; y < state.h; y++) { for (let x = 0; x < state.w; x++) { - const t = rules.terrains[state.terrain[y * state.w + x]]; - if (t.id !== kind) continue; - const cx = x * this.tile + this.tile / 2; - const cy = y * this.tile + this.tile / 2; - let any = false; - for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) { - if (!this.channelJoins(kind, x + dx, y + dy)) continue; - any = true; - g.beginPath(); - g.moveTo(cx, cy); - g.lineTo(cx + dx * this.tile / 2, cy + dy * this.tile / 2); - g.strokePath(); - } - if (!any) { // isolated stub renders as an east-west segment - g.beginPath(); g.moveTo(cx - this.tile / 2, cy); g.lineTo(cx + this.tile / 2, cy); g.strokePath(); - } - g.fillRect(cx - width / 2, cy - width / 2, width, width); + if (rules.terrains[state.terrain[y * state.w + x]].id === kind) fn(x, y); } } }; - draw('river', 0x7db4e8, Math.max(5, this.tile * 0.34)); - draw('road', 0xd8cfc0, Math.max(6, this.tile * 0.42)); + // stroke a full channel at the current line/fill style, filling the + // centre joint so the spokes meet cleanly at corners and junctions. + const strokeChannel = (kind, width) => forTiles(kind, (x, y) => { + const cx = x * T + T / 2, cy = y * T + T / 2; + for (const [dx, dy] of spokes(kind, x, y)) { + g.beginPath(); g.moveTo(cx, cy); g.lineTo(cx + dx * T / 2, cy + dy * T / 2); g.strokePath(); + } + g.fillRect(cx - width / 2, cy - width / 2, width, width); + }); + // Phaser Graphics has no quadraticCurveTo, so sample the bow into a + // short polyline (used for the little ripple crests). + const quad = (x0, y0, cx, cy, x1, y1) => { + g.beginPath(); g.moveTo(x0, y0); + for (let i = 1; i <= 5; i++) { + const t = i / 5, mt = 1 - t; + g.lineTo(mt * mt * x0 + 2 * mt * t * cx + t * t * x1, + mt * mt * y0 + 2 * mt * t * cy + t * t * y1); + } + g.strokePath(); + }; + + // ── rivers: deep channel → lighter core → ripple crests ──────────────── + const riverBase = Math.max(6, T * 0.40); + g.lineStyle(riverBase, 0x3d6fd6, 1); g.fillStyle(0x3d6fd6, 1); + strokeChannel('river', riverBase); + const riverCore = Math.max(3, T * 0.22); + g.lineStyle(riverCore, 0x6f9ae8, 1); g.fillStyle(0x6f9ae8, 1); + strokeChannel('river', riverCore); + g.lineStyle(Math.max(1.5, T * 0.035), 0xcfe6fb, 0.5); + forTiles('river', (x, y) => { + const cx = x * T + T / 2, cy = y * T + T / 2; + for (const [dx, dy] of spokes('river', x, y)) { + const px = -dy, py = dx, rw = T * 0.13, bow = T * 0.06; // crest span + bow along flow + for (let s = 1; s <= 2; s++) { + const t = s / 3; + const mx = cx + dx * (T / 2) * t, my = cy + dy * (T / 2) * t; + quad(mx - px * rw, my - py * rw, mx + dx * bow, my + dy * bow, mx + px * rw, my + py * rw); + } + } + }); + + // ── roads: concrete shoulders → asphalt → dashed centre line ─────────── + const shoulder = Math.max(8, T * 0.56); + g.lineStyle(shoulder, 0xb4ab98, 1); g.fillStyle(0xb4ab98, 1); + strokeChannel('road', shoulder); + const asphalt = Math.max(5, T * 0.38); + g.lineStyle(asphalt, 0x3d4045, 1); g.fillStyle(0x3d4045, 1); + strokeChannel('road', asphalt); + // dashes are phase-locked to world coords (anchored at 0) so they stay + // continuous across tile seams; skipped at 3+ way junctions for a clean + // intersection, exactly like a real road has no centre line through one. + const dashLen = Math.max(3, T * 0.16), gap = Math.max(3, T * 0.13), period = dashLen + gap; + g.lineStyle(Math.max(1.5, T * 0.05), 0xe8d98a, 0.9); + const dashAxis = (fixed, from, to, horizontal) => { + const a = Math.min(from, to), b = Math.max(from, to); + for (let k = Math.floor(a / period); k * period <= b; k++) { + const s = Math.max(a, k * period), e = Math.min(b, k * period + dashLen); + if (e <= s) continue; + g.beginPath(); + if (horizontal) { g.moveTo(s, fixed); g.lineTo(e, fixed); } + else { g.moveTo(fixed, s); g.lineTo(fixed, e); } + g.strokePath(); + } + }; + forTiles('road', (x, y) => { + const list = spokes('road', x, y); + if (list.length >= 3) return; + const cx = x * T + T / 2, cy = y * T + T / 2; + for (const [dx, dy] of list) { + if (dy === 0) dashAxis(cy, cx, cx + dx * T / 2, true); + else dashAxis(cx, cy, cy + dy * T / 2, false); + } + }); + this.rt.draw(g, 0, 0); g.destroy(); } @@ -495,7 +564,11 @@ export class AdvanceWarsMapView { const base = unitColorInt(rules, u.army); v.sprite.setTint(u.moved && u.army === this.state.turn ? darken(base, 0.5) : base); v.sprite.setAlpha(u.dived ? 0.5 : 1); - const hpd = Logic.hpDisplay(u); + // shown HP follows any active replay override (a unit hit later this turn + // holds its earlier HP until its own battle animation plays); otherwise + // it's the live value. + const dispHp = this.hpOverride.has(u.id) ? this.hpOverride.get(u.id) : u.hp; + const hpd = Math.ceil(dispHp / 10); v.hp.setText(hpd < 10 ? String(hpd) : ''); const spec = rules.unitById[u.type]; const lowFuel = u.fuel <= spec.fuel * 0.25 && (spec.dailyFuel ?? 0) > 0; @@ -507,6 +580,16 @@ export class AdvanceWarsMapView { else if (lowAmmo) v.badge.setFrame(UI_FRAMES.ammo); } + // ── replay HP display overrides ─────────────────────────────────────────── + // During enemy-turn replay `state` already holds the whole turn's final + // result, so without this a unit hit later this turn would show its + // end-of-turn HP the moment any sync touched it. These pin the shown HP to + // the value from the last replayed battle affecting the unit — seeded to its + // pre-turn HP — so the number only drops when that unit's own battle plays. + seedHpOverride(id, hp) { if (id != null && !this.hpOverride.has(id)) this.hpOverride.set(id, hp); } + setHpOverride(id, hp) { if (id != null) this.hpOverride.set(id, hp); } + clearHpOverrides() { this.hpOverride.clear(); } + setUnitVisibility(visibleIds) { // Views whose unit is already gone from state are mid-replay: the enemy // turn is resolved up front, so a unit that dies (or loads/joins) later diff --git a/src/games/tetrisattack/TetrisAttackGame.js b/src/games/tetrisattack/TetrisAttackGame.js index f81a621..0511964 100644 --- a/src/games/tetrisattack/TetrisAttackGame.js +++ b/src/games/tetrisattack/TetrisAttackGame.js @@ -49,6 +49,7 @@ export default class TetrisAttackGame extends Phaser.Scene { this.stageIndex = 0; this.puzzleIndex = 0; this.hostPortrait = null; + this.heroBg = null; this.uiLayer = null; this.overlayObjs = []; this.best = 0; @@ -119,6 +120,7 @@ export default class TetrisAttackGame extends Phaser.Scene { this.state = newGame({ mode: 'endless', rng }); } + this.setHeroBackground(mode === 'stageclear' ? round.name : null); this.buildHUD(); this.playing = true; this.acc = 0; @@ -136,9 +138,21 @@ export default class TetrisAttackGame extends Phaser.Scene { if (this.boardWell) { this.boardWell.destroy(); this.boardWell = null; } if (this.boardFrame) { this.boardFrame.destroy(); this.boardFrame = null; } if (this.hostPortrait) { this.hostPortrait.destroy(); this.hostPortrait = null; } + this.setHeroBackground(null); this.state = null; } + // Stage Clear background art, drop-in per host (assets/images/tetrisattack/ + // background-{hero}.png, see data/assetManifest.js). Falls back to the + // procedural gradient backdrop when a host has no art yet. + setHeroBackground(heroName) { + if (this.heroBg) { this.heroBg.destroy(); this.heroBg = null; } + if (!heroName) return; + const key = `tetrisattack-bg-${heroName.toLowerCase()}`; + if (!this.textures.exists(key)) return; + this.heroBg = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, key).setDepth(D.bg + 2); + } + // ── Main loop ───────────────────────────────────────────────────────────── update(time, delta) { if (!this.playing || !this.state) return;