feat: enhance Advance Wars replay HP display and terrain rendering, add Tetris Attack host backgrounds

Advance Wars:
- Add HP override system (seed/set/clearHpOverride) so units show correct
  HP during enemy-turn replay until their battle animation plays
- Redesign river/road channel rendering with layered depth: rivers get
  deep channel, light core, and ripple crests; roads get concrete shoulders,
  asphalt, and dashed centre lines phase-locked to tile seams

Tetris Attack:
- Add character sprite sheet and per-host background images (beth, jerry,
  michael, steve, victor, klaxon)
- Declare hero background assets in manifest; scene falls back gracefully
  when art is missing
- Update characterSheet path in tetrisattack-artwork.json
This commit is contained in:
Brian Fertig 2026-07-20 23:20:58 -06:00
parent ed2a60fa06
commit 18412e34f9
11 changed files with 151 additions and 26 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

View File

@ -8,7 +8,7 @@
},
"characterSheet": {
"key": "tetrisattack-characters",
"path": null,
"path": "assets/images/tetrisattack/tetrisattack-characters.png",
"frameWidth": 200,
"frameHeight": 280
}

View File

@ -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 `<name>-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: [

View File

@ -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);

View File

@ -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

View File

@ -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;