feat(advancewars): add terrain-based battle scene backdrops

Introduce a new battle background spritesheet (advancewars-battlebg) that
displays context-appropriate backdrops behind each fighter during battle
cut-ins. The backdrop is selected based on the terrain/building type the
fighter is standing on (city, base, airport, port, plain, wood, mountain,
sea, reef, shoal, river, bridge, road, hq).

- Add battleBgSheet to artwork.json and asset manifest
- Implement playBattleAnim() backdrop system with terrain lookup
- Provide procedural fallback matching the real sheet's frame map for
  art-free playability
- Mirror defender backdrops to match mirrored defender sprites
- Document the 14-frame sheet spec in sprites.md (256×256 cells, 5 cols)
This commit is contained in:
Brian Fertig 2026-07-19 16:22:21 -06:00
parent 138b078dd2
commit a02e5a1913
7 changed files with 233 additions and 14 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

View File

@ -23,5 +23,11 @@
"path": null, "path": null,
"frameWidth": 48, "frameWidth": 48,
"frameHeight": 48 "frameHeight": 48
},
"battleBgSheet": {
"key": "advancewars-battlebg",
"path": "assets/images/advancewars/advancewars-battlebg.png",
"frameWidth": 256,
"frameHeight": 256
} }
} }

View File

@ -207,7 +207,7 @@ export const MANIFEST = {
{ type: 'json', key: 'advancewars-rules', path: 'data/advancewars-rules.json' }, { type: 'json', key: 'advancewars-rules', path: 'data/advancewars-rules.json' },
{ type: 'json', key: 'advancewars-campaign', path: 'data/advancewars-campaign.json' }, { type: 'json', key: 'advancewars-campaign', path: 'data/advancewars-campaign.json' },
(scene) => sheetsFrom(scene, 'advancewars-artwork', (scene) => sheetsFrom(scene, 'advancewars-artwork',
['terrainSheet', 'buildingSheet', 'unitSheet', 'uiSheet']), ['terrainSheet', 'buildingSheet', 'unitSheet', 'uiSheet', 'battleBgSheet']),
(scene) => musicFrom(scene, 'nintendo-music'), (scene) => musicFrom(scene, 'nintendo-music'),
], ],
coloradodefense: [ coloradodefense: [

View File

@ -10,10 +10,170 @@ import { FONT } from './AdvanceWarsUI.js';
const W = 760; const W = 760;
const H = 380; const H = 380;
// ---------------------------------------------------------------------------
// Battle backgrounds: one backdrop image per terrain/building a fighter can
// stand on. Frame order (index i is frame i in the sheet, left-to-right,
// top-to-bottom) — see sprites.md "Battle background sheet".
export const BATTLE_BG_KEYS = [
'plain', 'wood', 'mountain', 'sea', 'reef',
'shoal', 'river', 'bridge', 'road',
'city', 'base', 'airport', 'port', 'hq',
];
const BG_FRAME = 256;
const BG_COLS = 5;
const BG_SHEET_KEY = 'advancewars-battlebg';
// A property tile's own terrain id (city/base/airport/port/hq) already IS
// its building type, so a unit standing on a building needs no separate
// "terrain underneath" lookup — the tile only ever has one terrain id, and
// that id already picks the right (building) background automatically.
// bridgeh/bridgev share the single 'bridge' backdrop — orientation doesn't
// matter for a static background behind a fighter.
const TERRAIN_TO_BG = { bridgeh: 'bridge', bridgev: 'bridge' };
function battleBgFrame(terrainId) {
const key = TERRAIN_TO_BG[terrainId] ?? terrainId;
const i = BATTLE_BG_KEYS.indexOf(key);
return i < 0 ? BATTLE_BG_KEYS.indexOf('plain') : i;
}
// Painted sheet if loaded, else a generated stand-in with the same frame
// map — mirrors the pattern in AdvanceWarsMapView.js's ensureSheets().
function ensureBattleBgSheet(scene) {
if (scene.textures.exists(BG_SHEET_KEY)) return BG_SHEET_KEY;
const procKey = `${BG_SHEET_KEY}-proc`;
if (!scene.textures.exists(procKey)) paintProceduralBattleBg(scene, procKey);
return procKey;
}
function paintProceduralBattleBg(scene, key) {
const cols = BG_COLS;
const rows = Math.ceil(BATTLE_BG_KEYS.length / cols);
const tex = scene.textures.createCanvas(key, cols * BG_FRAME, rows * BG_FRAME);
const ctx = tex.getContext();
const at = (i, draw) => {
ctx.save();
ctx.translate((i % cols) * BG_FRAME, Math.floor(i / cols) * BG_FRAME);
draw(ctx);
ctx.restore();
};
// simple sky/ground split backdrop, tinted per terrain; groundY is where
// the horizon sits (higher groundY = more sky visible, e.g. for water).
const skyGround = (sky, ground, groundY = 150) => (c) => {
c.fillStyle = sky; c.fillRect(0, 0, BG_FRAME, BG_FRAME);
c.fillStyle = ground; c.fillRect(0, groundY, BG_FRAME, BG_FRAME - groundY);
};
const SKY = '#bfe0f0';
at(BATTLE_BG_KEYS.indexOf('plain'), skyGround(SKY, '#a8d060'));
at(BATTLE_BG_KEYS.indexOf('wood'), (c) => {
skyGround(SKY, '#8cc456')(c);
c.fillStyle = '#2f6b22';
for (const [tx, ty] of [[60, 150], [150, 130], [200, 160], [90, 190]]) {
c.beginPath(); c.moveTo(tx - 30, ty + 60); c.lineTo(tx + 30, ty + 60); c.lineTo(tx, ty - 30); c.fill();
c.fillRect(tx - 8, ty + 60, 16, 20);
}
});
at(BATTLE_BG_KEYS.indexOf('mountain'), (c) => {
skyGround(SKY, '#b5d16b')(c);
c.fillStyle = '#8a6a3c';
c.beginPath(); c.moveTo(20, 240); c.lineTo(128, 40); c.lineTo(236, 240); c.fill();
c.fillStyle = '#f0ede2';
c.beginPath(); c.moveTo(100, 90); c.lineTo(128, 40); c.lineTo(156, 90); c.lineTo(128, 108); c.fill();
});
at(BATTLE_BG_KEYS.indexOf('sea'), (c) => {
skyGround(SKY, '#3d6fd6', 60)(c);
c.strokeStyle = '#6f9ae8'; c.lineWidth = 4;
for (let i = 0; i < 4; i++) {
c.beginPath(); c.moveTo(10, 110 + i * 36); c.quadraticCurveTo(70, 90 + i * 36, 130, 110 + i * 36);
c.quadraticCurveTo(190, 130 + i * 36, 246, 110 + i * 36); c.stroke();
}
});
at(BATTLE_BG_KEYS.indexOf('reef'), (c) => {
skyGround(SKY, '#3d6fd6', 60)(c);
c.fillStyle = '#24488f';
for (const [px, py] of [[60, 140], [150, 110], [110, 190], [200, 170], [180, 220]]) {
c.beginPath(); c.arc(px, py, 20, 0, 7); c.fill();
}
});
at(BATTLE_BG_KEYS.indexOf('shoal'), (c) => {
c.fillStyle = SKY; c.fillRect(0, 0, BG_FRAME, BG_FRAME);
c.fillStyle = '#8fb8e8'; c.fillRect(0, 90, BG_FRAME, 40);
c.fillStyle = '#e8d9a0'; c.fillRect(0, 130, BG_FRAME, BG_FRAME - 130);
});
at(BATTLE_BG_KEYS.indexOf('river'), (c) => {
skyGround(SKY, '#3d6fd6', 130)(c);
c.strokeStyle = '#6f9ae8'; c.lineWidth = 4;
for (let i = 0; i < 3; i++) {
c.beginPath(); c.moveTo(20, 170 + i * 32); c.quadraticCurveTo(128, 150 + i * 32, 236, 170 + i * 32); c.stroke();
}
});
at(BATTLE_BG_KEYS.indexOf('bridge'), (c) => {
skyGround(SKY, '#3d6fd6', 60)(c);
c.fillStyle = '#b0a89a'; c.fillRect(0, 150, BG_FRAME, 60);
c.fillStyle = '#8a8276';
for (let x = 10; x < BG_FRAME; x += 26) c.fillRect(x, 150, 6, 60);
});
at(BATTLE_BG_KEYS.indexOf('road'), (c) => {
skyGround(SKY, '#d8cfc0')(c);
c.fillStyle = '#a89c88'; c.fillRect(0, 150, BG_FRAME, 8);
});
const building = (icon) => (c) => {
skyGround(SKY, '#a8d060')(c);
c.fillStyle = '#ececec'; c.strokeStyle = '#3a3a3a'; c.lineWidth = 3;
c.fillRect(48, 30, 160, 190); c.strokeRect(48, 30, 160, 190);
icon(c);
};
at(BATTLE_BG_KEYS.indexOf('city'), building((c) => {
c.fillStyle = '#8a8a8a';
c.fillRect(72, 60, 42, 130); c.fillRect(140, 90, 42, 100);
c.fillStyle = '#3a3a3a';
for (let i = 0; i < 4; i++) { c.fillRect(80, 70 + i * 28, 26, 12); c.fillRect(148, 100 + i * 22, 26, 10); }
}));
at(BATTLE_BG_KEYS.indexOf('base'), building((c) => {
c.fillStyle = '#8a8a8a';
c.fillRect(64, 100, 128, 80);
c.beginPath(); c.moveTo(64, 100); c.lineTo(96, 40); c.lineTo(128, 100); c.lineTo(160, 40); c.lineTo(192, 100); c.fill();
c.fillStyle = '#3a3a3a'; c.fillRect(112, 130, 32, 50);
}));
at(BATTLE_BG_KEYS.indexOf('airport'), building((c) => {
c.fillStyle = '#8a8a8a';
c.beginPath();
c.moveTo(128, 30); c.lineTo(148, 70); c.lineTo(200, 90); c.lineTo(148, 110);
c.lineTo(128, 170); c.lineTo(108, 110); c.lineTo(56, 90); c.lineTo(108, 70); c.fill();
}));
at(BATTLE_BG_KEYS.indexOf('port'), building((c) => {
c.strokeStyle = '#8a8a8a'; c.lineWidth = 18;
c.beginPath(); c.arc(128, 100, 46, 0.5, Math.PI - 0.5, false); c.stroke();
c.beginPath(); c.moveTo(128, 30); c.lineTo(128, 130); c.stroke();
c.beginPath(); c.moveTo(96, 55); c.lineTo(160, 55); c.stroke();
}));
at(BATTLE_BG_KEYS.indexOf('hq'), building((c) => {
c.fillStyle = '#8a8a8a'; c.fillRect(108, 20, 18, 170);
c.fillStyle = '#e05050';
c.beginPath(); c.moveTo(126, 24); c.lineTo(200, 50); c.lineTo(126, 76); c.fill();
}));
tex.refresh();
for (let i = 0; i < BATTLE_BG_KEYS.length; i++) {
tex.add(i, 0, (i % cols) * BG_FRAME, Math.floor(i / cols) * BG_FRAME, BG_FRAME, BG_FRAME);
}
}
// battles: array of engine 'battle' events (1 or 2 volleys). // battles: array of engine 'battle' events (1 or 2 volleys).
// Shows attacker left, defender right; each volley flashes and drains HP. // Shows attacker left, defender right; each volley flashes and drains HP.
// texKey is the unit sheet (painted or procedural). // texKey is the unit sheet (painted or procedural). state resolves the
export function playBattleAnim(scene, rules, texKey, battles, onDone) { // terrain each fighter is standing on, to pick their backdrop image.
export function playBattleAnim(scene, rules, state, texKey, battles, onDone) {
if (!battles.length) { onDone?.(); return null; } if (!battles.length) { onDone?.(); return null; }
const cx = GAME_WIDTH / 2 - 130, cy = GAME_HEIGHT / 2 - 40; const cx = GAME_WIDTH / 2 - 130, cy = GAME_HEIGHT / 2 - 40;
const objs = []; const objs = [];
@ -28,12 +188,19 @@ export function playBattleAnim(scene, rules, texKey, battles, onDone) {
.setDepth(50).setInteractive(); .setDepth(50).setInteractive();
const panel = scene.add.rectangle(cx, cy, W, H, 0x10141f, 0.97) const panel = scene.add.rectangle(cx, cy, W, H, 0x10141f, 0.97)
.setStrokeStyle(3, 0x3a4260).setDepth(50); .setStrokeStyle(3, 0x3a4260).setDepth(50);
const split = scene.add.rectangle(cx, cy, 4, H, 0x3a4260, 1).setDepth(50); objs.push(dim, panel);
objs.push(dim, panel, split);
const ground = (side, color) => scene.add // backdrop per side — the terrain/building each fighter is actually
.rectangle(cx + side * W / 4, cy + H / 2 - 40, W / 2 - 8, 80, color, 1).setDepth(50); // standing on (property tiles' own terrain id already IS their building
objs.push(ground(-1, 0x27324a), ground(1, 0x27324a)); // type, so no separate override lookup is needed, see battleBgFrame).
const bgSheet = ensureBattleBgSheet(scene);
const backdrop = (side, info) => scene.add
.image(cx + side * W / 4, cy, bgSheet, battleBgFrame(Logic.terrainAt(rules, state, info.x, info.y).id))
.setDisplaySize(W / 2 - 6, H - 6).setFlipX(side > 0).setDepth(50.3);
objs.push(backdrop(-1, leftSide), backdrop(1, rightSide));
const split = scene.add.rectangle(cx, cy, 4, H, 0x3a4260, 1).setDepth(50.6);
objs.push(split);
const mkFighter = (side, info) => { const mkFighter = (side, info) => {
const spec = rules.unitById[info.type]; const spec = rules.unitById[info.type];

View File

@ -529,7 +529,7 @@ export default class AdvanceWarsGame extends Phaser.Scene {
const anyVisible = !run.state.fog || battles.some((b) => const anyVisible = !run.state.fog || battles.some((b) =>
Logic.computeVision(this.rules, run.state, 0).has(Logic.tileKey(run.state, b.defender.x, b.defender.y))); Logic.computeVision(this.rules, run.state, 0).has(Logic.tileKey(run.state, b.defender.x, b.defender.y)));
if (this.battleAnims && anyVisible) { if (this.battleAnims && anyVisible) {
await new Promise((resolve) => playBattleAnim(this, this.rules, run.view.tex.units, battles, resolve)); await new Promise((resolve) => playBattleAnim(this, this.rules, run.state, run.view.tex.units, battles, resolve));
} }
this.crt.pulse(0.6, 260); this.crt.pulse(0.6, 260);
} }

View File

@ -1,13 +1,17 @@
# Advance Wars — spritesheet spec # Advance Wars — spritesheet spec
Art is split into **four independent sheets**, one per category, so each can Art is split into **five independent sheets**, one per category, so each can
be painted and dropped in separately. Until a sheet is painted, the game be painted and dropped in separately. Until a sheet is painted, the game
renders a procedural stand-in with the exact same frame map — everything is renders a procedural stand-in with the exact same frame map — everything is
fully playable art-free, and you can drop in one sheet at a time. fully playable art-free, and you can drop in one sheet at a time.
**Rivers and roads need NO art**: they are drawn in code (Graphics) from **Rivers and roads need NO art on the map itself**: they are drawn in code
tile connection masks, so there are no variant frames to paint. Shoreline (Graphics) from tile connection masks, so there are no variant frames to
foam where sea meets land is also drawn in code. paint for the *terrain sheet*. Shoreline foam where sea meets land is also
drawn in code. This exemption does **not** extend to the battle background
sheet (§5) — that one needs a full backdrop scene for every occupiable
terrain, river/road/bridge included, since it's a standalone illustration
rather than a tileable ground texture.
## Drop-in procedure (no code changes) ## Drop-in procedure (no code changes)
@ -108,7 +112,49 @@ spare).** Full color, transparent background.
Icons 36 render at roughly 40% tile size as unit badges; keep them bold Icons 36 render at roughly 40% tile size as unit badges; keep them bold
and readable when small. and readable when small.
## Game icon ## 5. Battle background sheet — `advancewars-battlebg.png`
**256×256 cells, 5 columns × 3 rows = 1280×768 px (14 frames used, last cell
spare).** Full color, full-bleed illustration — no transparency needed,
these fill the entire backdrop behind each fighter in the battle cut-in
window (`AdvanceWarsBattleAnim.js`). Landscape/scene paintings, not tile
icons: think "diorama behind the two combatants," not "48×48 ground swatch."
One frame per terrain type a unit can actually stand on, **plus** one per
building type. A property tile's own terrain id (city/base/airport/port/hq)
*is* its building type — there's no separate "terrain underneath the
building" to paint over, since a tile only ever has one terrain value at a
time. So each frame below is a straight lookup, not a layered composite.
| frame | content |
|-------|---------|
| 0 | Plain (grass field) |
| 1 | Wood (forest clearing) |
| 2 | Mountain |
| 3 | Sea (open water) |
| 4 | Reef (rocky shallows) |
| 5 | Shoal (beach) |
| 6 | River |
| 7 | Bridge — used for **both** horizontal and vertical bridge tiles; orientation doesn't matter for a static backdrop, so this one frame covers both `bridgeh` and `bridgev` |
| 8 | Road |
| 9 | City |
| 10 | Base (land factory) |
| 11 | Airport |
| 12 | Port |
| 13 | HQ |
Each fighter's backdrop is picked by looking up the terrain tile they're
actually standing on when the battle happens (from the engine's `battle`
event coordinates) — so, e.g., a tank parked on a captured city shows the
City background, not Plain, even though a tank could also be fought on open
grass elsewhere. The defender's backdrop is horizontally mirrored (matching
the mirrored defender unit sprite) — avoid art with strong left/right
asymmetry (e.g. a building silhouette that visibly leans one way) unless
you're fine with it flipping on the right-hand side.
Unlike the other four sheets, this one is painted in full color and is
**not tinted** — buildings here don't need to react to owner army color
the way the small map-icon building sheet does.
Also paint **frame 86 of `assets/images/game-icons.png`** (44×44, row 5 Also paint **frame 86 of `assets/images/game-icons.png`** (44×44, row 5
col 11 of that 15-per-row sheet). Suggested motif: a small orange tank on a col 11 of that 15-per-row sheet). Suggested motif: a small orange tank on a