Compare commits

...

4 Commits

Author SHA1 Message Date
Brian Fertig c0ad354458 feat(advance-wars): add contextual battle and movement sound effects
- Assign battle sounds to each unit type (gunfire, tank, cannon, missile, etc.)
  and play the appropriate sound on attack/counterattack in battle animations
- Add per-unit-type movement sounds (march for infantry/mech, engine-fast,
  engine-heavy) that loop during move animations and stop cleanly on completion
- Play jump sound during capture animation hops and activation sound on capture
- Play explosion sound when units are defeated in combat or crash
- Lighten unit tint relative to buildings so units remain visually distinct
  when standing on captured buildings
- Add new audio assets: battle sounds, march, 8bit-jump, and update battle-tank
2026-07-19 17:44:18 -06:00
Brian Fertig 427aacae6f feat(advancewars): show terrain backdrop in capture cut-in instead of plain rectangle
Replace the solid-color ground rectangle in the capture animation with a
proper terrain backdrop matching the ground the building sits on (plain,
shoal, etc.). Extract battleBgFrame, ensureBattleBgSheet, and add a new
groundBgFrame helper that maps building types to their underlying terrain,
avoiding duplicate building rendering since the building is already the
main squishing prop.
2026-07-19 16:31:33 -06:00
Brian Fertig a02e5a1913 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)
2026-07-19 16:22:21 -06:00
Brian Fertig 138b078dd2 feat: add pulsing animation to attack target tiles in Advance Wars
Introduce a breathing pulse effect (red fill + white border) for attack
target tiles to draw the player's eye when in "must pick a target" fire
mode. The previous static tint is preserved for the indirect-range preview
shown while a unit is merely selected.

- showAttackTiles now accepts optional { pulse: true } parameter
- Added tween-based animation with Sine.easeInOut breathing effect
- Properly clean up pulse tween in destroy() and clearOverlays()
- Updated AdvanceWarsGame to pass pulse: true when showing targets
2026-07-19 16:21:13 -06:00
18 changed files with 370 additions and 30 deletions

BIN
assets/fx/8bit-jump.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/fx/march.mp3 Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

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

View File

@ -317,6 +317,7 @@
"units": [
{
"id": "infantry",
"battleSound": "gunfire-modern",
"name": "Infantry",
"abbr": "IN",
"cost": 1000,
@ -353,6 +354,7 @@
},
{
"id": "recon",
"battleSound": "gunfire-modern",
"name": "Recon",
"abbr": "RC",
"cost": 4000,
@ -370,6 +372,7 @@
},
{
"id": "tank",
"battleSound": "tank",
"name": "Tank",
"abbr": "TK",
"cost": 7000,
@ -387,6 +390,7 @@
},
{
"id": "mdtank",
"battleSound": "cannon",
"name": "Md Tank",
"abbr": "MD",
"cost": 16000,
@ -404,6 +408,7 @@
},
{
"id": "apc",
"battleSound": "gunfire-modern",
"name": "APC",
"abbr": "AP",
"cost": 5000,
@ -426,6 +431,7 @@
},
{
"id": "artillery",
"battleSound": "cannon",
"name": "Artillery",
"abbr": "AR",
"cost": 6000,
@ -444,6 +450,7 @@
},
{
"id": "rockets",
"battleSound": "missle",
"name": "Rockets",
"abbr": "RK",
"cost": 15000,
@ -462,6 +469,7 @@
},
{
"id": "antiair",
"battleSound": "gunfire-modern",
"name": "Anti-Air",
"abbr": "AA",
"cost": 8000,
@ -479,6 +487,7 @@
},
{
"id": "missiles",
"battleSound": "missle",
"name": "Missiles",
"abbr": "MS",
"cost": 12000,
@ -497,6 +506,7 @@
},
{
"id": "fighter",
"battleSound": "gunfire-modern",
"name": "Fighter",
"abbr": "FT",
"cost": 20000,
@ -515,6 +525,7 @@
},
{
"id": "bomber",
"battleSound": "cannon",
"name": "Bomber",
"abbr": "BM",
"cost": 22000,
@ -533,6 +544,7 @@
},
{
"id": "bcopter",
"battleSound": "missle",
"name": "B-Copter",
"abbr": "BC",
"cost": 9000,
@ -551,6 +563,7 @@
},
{
"id": "tcopter",
"battleSound": "helicopter",
"name": "T-Copter",
"abbr": "TC",
"cost": 5000,
@ -573,6 +586,7 @@
},
{
"id": "battleship",
"battleSound": "cannon",
"name": "Battleship",
"abbr": "BS",
"cost": 28000,
@ -592,6 +606,7 @@
},
{
"id": "cruiser",
"battleSound": "tank",
"name": "Cruiser",
"abbr": "CR",
"cost": 18000,
@ -617,6 +632,7 @@
},
{
"id": "lander",
"battleSound": "missle",
"name": "Lander",
"abbr": "LD",
"cost": 12000,
@ -636,6 +652,7 @@
},
{
"id": "sub",
"battleSound": "submarine",
"name": "Sub",
"abbr": "SB",
"cost": 20000,

View File

@ -207,7 +207,13 @@ export const MANIFEST = {
{ type: 'json', key: 'advancewars-rules', path: 'data/advancewars-rules.json' },
{ type: 'json', key: 'advancewars-campaign', path: 'data/advancewars-campaign.json' },
(scene) => sheetsFrom(scene, 'advancewars-artwork',
['terrainSheet', 'buildingSheet', 'unitSheet', 'uiSheet']),
['terrainSheet', 'buildingSheet', 'unitSheet', 'uiSheet', 'battleBgSheet']),
// Battle sounds keyed by each unit's `battleSound` in advancewars-rules.json
// (played on attack by playBattleAnim in AdvanceWarsBattleAnim.js).
...['gunfire-modern', 'tank', 'cannon', 'helicopter', 'missle', 'submarine']
.map((n) => ({ type: 'audio', key: `sfx-battle-${n}`, path: `assets/fx/battle-${n}.mp3` })),
// Foot-unit march (infantry/mech), played on move in AdvanceWarsMapView.js.
{ type: 'audio', key: 'sfx-march', path: 'assets/fx/march.mp3' },
(scene) => musicFrom(scene, 'nintendo-music'),
],
coloradodefense: [

View File

@ -4,16 +4,190 @@
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import * as Logic from './AdvanceWarsLogic.js';
import { armyColorInt } from './AdvanceWarsMapView.js';
import { unitColorInt } from './AdvanceWarsMapView.js';
import { FONT } from './AdvanceWarsUI.js';
import { playSound } from '../../ui/Sounds.js';
const W = 760;
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' };
export 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;
}
// The engine has no separate "terrain underneath the building" value (a
// tile only ever has one terrain id), so this is an editorial pick of what
// a given building would plausibly sit on, used where a scene wants the
// bare ground rather than the building's own backdrop (e.g. the capture
// cut-in, which already shows the building itself as its squishing prop).
const BUILDING_GROUND_TERRAIN = {
city: 'plain', base: 'plain', airport: 'plain', hq: 'plain', port: 'shoal',
};
export function groundBgFrame(terrainId) {
return battleBgFrame(BUILDING_GROUND_TERRAIN[terrainId] ?? terrainId);
}
// Painted sheet if loaded, else a generated stand-in with the same frame
// map — mirrors the pattern in AdvanceWarsMapView.js's ensureSheets().
export 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).
// Shows attacker left, defender right; each volley flashes and drains HP.
// texKey is the unit sheet (painted or procedural).
export function playBattleAnim(scene, rules, texKey, battles, onDone) {
// texKey is the unit sheet (painted or procedural). state resolves the
// 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; }
const cx = GAME_WIDTH / 2 - 130, cy = GAME_HEIGHT / 2 - 40;
const objs = [];
@ -28,19 +202,26 @@ export function playBattleAnim(scene, rules, texKey, battles, onDone) {
.setDepth(50).setInteractive();
const panel = scene.add.rectangle(cx, cy, W, H, 0x10141f, 0.97)
.setStrokeStyle(3, 0x3a4260).setDepth(50);
const split = scene.add.rectangle(cx, cy, 4, H, 0x3a4260, 1).setDepth(50);
objs.push(dim, panel, split);
objs.push(dim, panel);
const ground = (side, color) => scene.add
.rectangle(cx + side * W / 4, cy + H / 2 - 40, W / 2 - 8, 80, color, 1).setDepth(50);
objs.push(ground(-1, 0x27324a), ground(1, 0x27324a));
// backdrop per side — the terrain/building each fighter is actually
// standing on (property tiles' own terrain id already IS their building
// 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 spec = rules.unitById[info.type];
const img = scene.add.image(cx + side * W / 4, cy + H / 2 - 78, texKey, spec.frame)
.setScale(3.4).setOrigin(0.5, 1).setDepth(51)
.setFlipX(side > 0)
.setTint(armyColorInt(rules, info.army));
.setTint(unitColorInt(rules, info.army));
objs.push(img);
const label = scene.add.text(cx + side * W / 4, cy - H / 2 + 26,
spec.name, { fontFamily: FONT, fontSize: '26px', color: '#ffffff' })
@ -97,6 +278,10 @@ export function playBattleAnim(scene, rules, texKey, battles, onDone) {
const victim = fromLeft ? right : left;
timers.push(scene.time.delayedCall(delay, () => {
if (finished) return;
// keyed by the shooter for THIS volley, not the initial attacker —
// a counterattack is fired by the defender's own unit type.
const battleSound = rules.unitById[b.attacker.type]?.battleSound;
if (battleSound) playSound(scene, `sfx-battle-${battleSound}`);
scene.tweens.add({
targets: shooter.img, x: shooter.img.x + (fromLeft ? 40 : -40),
duration: 150, yoyo: true,

View File

@ -6,8 +6,10 @@
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import * as Logic from './AdvanceWarsLogic.js';
import { armyColorInt } from './AdvanceWarsMapView.js';
import { armyColorInt, unitColorInt } from './AdvanceWarsMapView.js';
import { FONT } from './AdvanceWarsUI.js';
import { ensureBattleBgSheet, groundBgFrame } from './AdvanceWarsBattleAnim.js';
import { playSound, SFX } from '../../ui/Sounds.js';
const W = 380;
const H = 500;
@ -41,10 +43,17 @@ export function playCaptureAnim(scene, rules, view, capturing, captured, onDone)
.setStrokeStyle(3, 0x3a4260).setDepth(50);
objs.push(dim, panel);
// backdrop is the terrain the building itself sits on (plain/shoal/etc),
// not the building's own battle background — the building is already the
// scene's main squishing prop, so showing its backdrop too would just be
// a duplicate building rendered twice.
const bgSheet = ensureBattleBgSheet(scene);
const backdrop = scene.add.image(cx, cy, bgSheet, groundBgFrame(t.id))
.setDisplaySize(W - 6, H - 6).setDepth(50.3);
objs.push(backdrop);
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 groundY = groundCenterY - 40; // where the building's base sits
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);
@ -61,7 +70,7 @@ export function playCaptureAnim(scene, rules, view, capturing, captured, onDone)
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));
.setTint(unitColorInt(rules, unit.army));
objs.push(unitImg);
// large capture-points counter, floating to the unit's left, always
@ -97,6 +106,7 @@ export function playCaptureAnim(scene, rules, view, capturing, captured, onDone)
function phase3raise() {
if (finished) return;
building.setTint(armyColorInt(rules, captured.army));
playSound(scene, SFX.EIGHTBIT_MOVE);
scene.tweens.add({
targets: building, scaleY: BUILDING_SCALE, duration: 500, ease: 'Sine.easeOut',
onUpdate: () => { unitImg.y = restY(); trackUnit(); pointsText.setText(String(currentPoints())); },
@ -119,9 +129,14 @@ export function playCaptureAnim(scene, rules, view, capturing, captured, onDone)
});
}
// 1.5s: two hops in place atop the (pre-squish) building height.
// 1.5s: two hops in place atop the (pre-squish) building height. A jump
// sound fires as each hop leaves the ground — once on start, once again
// when the repeat kicks off the second hop.
const jumpSound = () => playSound(scene, SFX.EIGHTBIT_JUMP);
scene.tweens.add({
targets: unitImg, y: restY() - HOP, duration: 375, yoyo: true, repeat: 1, ease: 'Sine.easeOut',
onStart: jumpSound,
onRepeat: jumpSound,
onUpdate: trackUnit,
onComplete: phase2squish,
});

View File

@ -424,7 +424,7 @@ export default class AdvanceWarsGame extends Phaser.Scene {
const run = this.run;
run.mode = 'target';
run.targets = targets;
run.view.showAttackTiles(targets.map((t) => ({ x: t.x, y: t.y })));
run.view.showAttackTiles(targets.map((t) => ({ x: t.x, y: t.y })), { pulse: true });
}
onTargetClick(tile) {
@ -529,7 +529,7 @@ export default class AdvanceWarsGame extends Phaser.Scene {
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)));
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);
}

View File

@ -11,6 +11,7 @@
import * as Phaser from 'phaser';
import * as Logic from './AdvanceWarsLogic.js';
import { playSound, SFX } from '../../ui/Sounds.js';
const CELL = 48;
const TALL = 64; // building/unit cells: 48×48 footprint + 16px headroom
@ -27,6 +28,15 @@ const UNIT_DEPTH_BIAS = 0.5;
// building rather than floating disconnected from it.
const PROP_NUMBER_OVERLAP = 5;
// Per-unit-type movement sound, looped for however long the move animation
// takes and stopped the instant it ends (see animateMove).
const MOVE_SOUND_BY_TYPE = {
infantry: 'sfx-march', mech: 'sfx-march',
tank: 'sfx-engine-heavy', mdtank: 'sfx-engine-heavy', artillery: 'sfx-engine-heavy',
rockets: 'sfx-engine-heavy', missiles: 'sfx-engine-heavy', bomber: 'sfx-engine-heavy',
recon: 'sfx-engine-fast', apc: 'sfx-engine-fast', antiair: 'sfx-engine-fast', fighter: 'sfx-engine-fast',
};
// 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 },
@ -44,6 +54,12 @@ export function armyColorInt(rules, army) {
return parseInt(hex.slice(1), 16);
}
// Units render a little lighter than buildings of the same army, so the two
// stay visually distinguishable when a unit is standing on a building.
export function unitColorInt(rules, army) {
return lighten(armyColorInt(rules, army), 0.48);
}
// ---------------------------------------------------------------------------
// Procedural stand-in sheets
@ -246,6 +262,7 @@ export class AdvanceWarsMapView {
destroy() {
this.flipTimer?.remove();
this.atkPulseTween?.stop();
this.rt?.destroy();
this.moveG?.destroy(); this.atkG?.destroy(); this.fogG?.destroy();
this.pathG?.destroy(); this.cursor?.destroy();
@ -460,7 +477,7 @@ export class AdvanceWarsMapView {
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);
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);
@ -492,15 +509,42 @@ export class AdvanceWarsMapView {
}
}
showAttackTiles(tiles) {
// pulse: true for "you must pick a target now" (entering FIRE target mode)
// — red fill + white border breathing to draw the eye. false (default)
// keeps the old static tint, used for the indirect-range preview shown
// while a unit is merely selected, before a move/target is committed to.
showAttackTiles(tiles, { pulse = false } = {}) {
this.atkPulseTween?.stop();
this.atkPulseTween = null;
this.atkG.clear();
this.atkG.fillStyle(0xff3d3d, 0.42);
for (const { x, y } of tiles) {
this.atkG.fillRect(this.ox + x * this.tile + 1, this.oy + y * this.tile + 1, this.tile - 2, this.tile - 2);
if (!pulse) {
this.atkG.fillStyle(0xff3d3d, 0.42);
for (const { x, y } of tiles) {
this.atkG.fillRect(this.ox + x * this.tile + 1, this.oy + y * this.tile + 1, this.tile - 2, this.tile - 2);
}
return;
}
const draw = (fillA, lineA) => {
this.atkG.clear();
this.atkG.fillStyle(0xff3d3d, fillA);
this.atkG.lineStyle(Math.max(2, this.tile * 0.06), 0xffffff, lineA);
for (const { x, y } of tiles) {
const px = this.ox + x * this.tile + 1, py = this.oy + y * this.tile + 1, s = this.tile - 2;
this.atkG.fillRect(px, py, s, s);
this.atkG.strokeRect(px, py, s, s);
}
};
const phase = { t: 0 };
draw(0.30, 0.55);
this.atkPulseTween = this.scene.tweens.add({
targets: phase, t: 1, duration: 550, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
onUpdate: () => draw(0.28 + phase.t * 0.34, 0.5 + phase.t * 0.5),
});
}
clearOverlays() {
this.atkPulseTween?.stop();
this.atkPulseTween = null;
this.moveG.clear();
this.atkG.clear();
this.pathG.clear();
@ -549,10 +593,12 @@ export class AdvanceWarsMapView {
this.setUnitVisibility(spotted);
}
// quick explosion puff at a tile
// quick explosion puff at a tile — plays whenever a unit is defeated
// (destroyed in combat or crashed), regardless of the battleAnims toggle.
boom(x, y, onDone) {
const img = this.scene.add.image(this.px(x), this.py(y), this.tex.ui, UI_FRAMES.explosion1)
.setScale(this.spriteScaleFor()).setDepth(this.depths.cursor);
playSound(this.scene, SFX.BATTLESHIP_HIT);
this.scene.time.delayedCall(200, () => img.setFrame(UI_FRAMES.explosion2));
this.scene.time.delayedCall(450, () => { img.destroy(); onDone?.(); });
}
@ -561,10 +607,27 @@ export class AdvanceWarsMapView {
animateMove(unitId, path, onDone) {
const v = this.unitViews.get(unitId);
if (!v || !path?.length) { onDone?.(); return; }
const unit = Logic.unitById(this.state, unitId);
// loops for however long the move takes (covers paths longer than one
// clip) and is stopped the instant the path finishes — never allowed to
// ring on past the move or get cut short mid-loop.
let moveSound = null;
const moveSoundKey = unit && MOVE_SOUND_BY_TYPE[unit.type];
if (moveSoundKey) {
try {
moveSound = this.scene.sound.add(moveSoundKey, { loop: true });
moveSound.play();
} catch (_) { moveSound = null; }
}
const points = path.map((s) => ({ x: this.px(s.x), y: this.oy + (s.y + 1) * this.tile, row: s.y }));
let i = 0;
const step = () => {
if (i >= points.length) { onDone?.(); return; }
if (i >= points.length) {
moveSound?.stop();
moveSound?.destroy();
onDone?.();
return;
}
const p = points[i++];
v.c.setDepth(this.depths.actors + p.row + UNIT_DEPTH_BIAS);
this.scene.tweens.add({

View File

@ -1,13 +1,17 @@
# 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
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.
**Rivers and roads need NO art**: they are drawn in code (Graphics) from
tile connection masks, so there are no variant frames to paint. Shoreline
foam where sea meets land is also drawn in code.
**Rivers and roads need NO art on the map itself**: they are drawn in code
(Graphics) from tile connection masks, so there are no variant frames to
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)
@ -108,7 +112,49 @@ spare).** Full color, transparent background.
Icons 3–6 render at roughly 40% tile size as unit badges; keep them bold
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
col 11 of that 15-per-row sheet). Suggested motif: a small orange tank on a

View File

@ -137,6 +137,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-8bit-activate', 'assets/fx/8bit-activate.mp3');
this.load.audio('sfx-8bit-action', 'assets/fx/8bit-action.mp3');
this.load.audio('sfx-8bit-move', 'assets/fx/8bit-move.mp3');
this.load.audio('sfx-8bit-jump', 'assets/fx/8bit-jump.mp3');
this.load.audio('sfx-8bit-explode', 'assets/fx/8bit-explode.mp3');
this.load.audio('sfx-8bit-explode2', 'assets/fx/8bit-explode2.mp3');
this.load.audio('sfx-8bit-count', 'assets/fx/8bit-count.mp3');

View File

@ -44,6 +44,7 @@ export const SFX = {
EIGHTBIT_ACTIVATE: 'sfx-8bit-activate',
EIGHTBIT_ACTION: 'sfx-8bit-action',
EIGHTBIT_MOVE: 'sfx-8bit-move',
EIGHTBIT_JUMP: 'sfx-8bit-jump',
EIGHTBIT_CARD: 'sfx-card-deal-8bit',
EIGHTBIT_EXPLODE: 'sfx-8bit-explode',
EIGHTBIT_EXPLODE_2: 'sfx-8bit-explode2',