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:
parent
ed2a60fa06
commit
18412e34f9
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 88 KiB |
Binary file not shown.
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.
|
|
@ -8,7 +8,7 @@
|
|||
},
|
||||
"characterSheet": {
|
||||
"key": "tetrisattack-characters",
|
||||
"path": null,
|
||||
"path": "assets/images/tetrisattack/tetrisattack-characters.png",
|
||||
"frameWidth": 200,
|
||||
"frameHeight": 280
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue