fertig-classic-games/src/data/assetManifest.js

543 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Per-game asset manifest for lazy loading.
//
// Assets listed here are NOT loaded by PreloadScene at startup. Instead,
// GameRoomScene loads a game's entry (via services/assetLoader.js) the first
// time the user enters that game; already-loaded keys are skipped, so repeat
// visits and cross-game shared assets (e.g. the mahjong tiles) are free.
//
// Descriptor shape:
// { type: 'image'|'spritesheet'|'audio'|'json', key, path, frameWidth?, frameHeight? }
//
// A slug's entry is an array of descriptors, a resolver `(scene) => [descriptors]`,
// or a mixed array of both. Resolvers exist for the drop-in artwork sheets whose
// paths live in the (always-preloaded) data/*-artwork.json manifests — a null
// `path` there means "not painted yet" and the game renders procedurally.
// Mahjong tile-label overlays (128×178 transparent PNGs), shared by the
// traditional Mahjong game and Mahjong Match solitaire. The White Dragon has
// no art — the scenes draw its traditional empty frame.
const MAHJONG_TILES = [
...Array.from({ length: 9 }, (_, i) => `bamboo${i + 1}`),
...Array.from({ length: 9 }, (_, i) => `circle${i + 1}`),
...Array.from({ length: 15 }, (_, i) => `pinyin${i + 1}`),
'orchid', 'peony', 'chrysanthemum', 'lotus',
'spring', 'summer', 'fall', 'winter',
].map((name) => ({ type: 'image', key: `mahjong-${name}`, path: `assets/images/mahjong/${name}.png` }));
// Drop-in spritesheets declared in a cached artwork JSON. Only entries with a
// `path` set are returned (until then the game renders procedurally) — the
// same contract PreloadScene used when it loaded these at startup.
function sheetsFrom(scene, jsonKey, fields) {
const art = scene.cache.json.get(jsonKey);
if (!art) return [];
const sheets = fields.flatMap((f) => {
const v = art[f];
return v && typeof v === 'object' && !('key' in v) ? Object.values(v) : [v];
});
return sheets
.filter((s) => s && s.key && s.path)
.map((s) => ({ type: 'spritesheet', key: s.key, path: s.path, frameWidth: s.frameWidth, frameHeight: s.frameHeight }));
}
// Drop-in standalone images declared in a cached artwork JSON's `artwork` array.
// Pull video descriptors out of an artwork manifest block keyed by id, e.g.
// mastervega's portraitVideos. An entry with `path: null` is one that has not
// been recorded yet and is skipped, exactly like a sheet with no PNG.
function videosFrom(scene, jsonKey, field) {
const art = scene.cache.json.get(jsonKey);
if (!art || !art[field]) return [];
return Object.entries(art[field])
.filter(([id, v]) => !id.startsWith('_') && v && v.key && v.path)
.map(([, v]) => ({ type: 'video', key: v.key, path: v.path }));
}
// Same contract as videosFrom, one level deeper: a block keyed
// group -> member -> entry, e.g. mastervega's shipVideos (species -> hull).
// A group whose value is null has nothing recorded yet and is skipped whole,
// which is what keeps the manifest short while nine species are outstanding.
function nestedVideosFrom(scene, jsonKey, field) {
const art = scene.cache.json.get(jsonKey);
if (!art || !art[field]) return [];
const out = [];
for (const [group, members] of Object.entries(art[field])) {
if (group.startsWith('_') || !members) continue;
for (const [id, v] of Object.entries(members)) {
if (id.startsWith('_') || !v || !v.key || !v.path) continue;
out.push({ type: 'video', key: v.key, path: v.path });
}
}
return out;
}
// Same shape as videosFrom, for image entries keyed by id.
function imagesFromMap(scene, jsonKey, field) {
const art = scene.cache.json.get(jsonKey);
if (!art || !art[field]) return [];
return Object.entries(art[field])
.filter(([id, v]) => !id.startsWith('_') && v && v.key && v.path)
.map(([, v]) => ({ type: 'image', key: v.key, path: v.path }));
}
function imagesFrom(scene, jsonKey) {
const art = scene.cache.json.get(jsonKey);
return (art?.artwork ?? [])
.filter((a) => a && a.key && a.path)
.map((a) => ({ type: 'image', key: a.key, path: a.path }));
}
// 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.
function musicFrom(scene, jsonKey) {
const data = scene.cache.json.get(jsonKey);
return (data?.tracks ?? [])
.filter((t) => t && t.file)
.map((t, i) => ({ type: 'audio', key: `${jsonKey}-${i}`, path: `assets/music/${t.file}` }));
}
// Master of Vega's soundtrack is condition-driven (menu/peace/combat/one pool
// per diplomacy race — see VegaMusic.js) rather than one flat shuffled list,
// so its `<name>-music.json` nests tracks under named pools instead of a
// single top-level `tracks` array. Walk every pool and flatten them into the
// same {type:'audio', key, path} shape musicFrom produces. VegaMusic.js reads
// track file paths straight out of the cached JSON at playback time (it plays
// via plain `Audio` elements, not these cache keys) — this is purely so the
// mp3 bytes for the pools every game is guaranteed to hit soon (menu, peace,
// combat, the generic diplomacy fallback) are already warm.
//
// `diplomacy.bySpecies` is deliberately excluded, same reasoning as
// `colonyVideos` a few lines below: a given playthrough may contact only a
// handful of the game's nine species, if any, so pre-fetching all nine
// tracks at room entry pays for eight-plus that are never played. VegaMusic's
// own `new Audio(...)` at setDiplomacy() time is already the fetch — this
// manifest was only ever a race to get there first, and racing every race's
// track just to win a few of them is not worth the up-front weight.
function vegaMusicFrom(scene, jsonKey) {
const data = scene.cache.json.get(jsonKey);
if (!data) return [];
const out = [];
const addPool = (pool, keyPrefix) => {
(pool?.tracks ?? [])
.filter((t) => t && t.file)
.forEach((t, i) => out.push({ type: 'audio', key: `${jsonKey}-${keyPrefix}-${i}`, path: `assets/music/${t.file}` }));
};
addPool(data.fallback, 'fallback');
addPool(data.menu, 'menu');
addPool(data.peace, 'peace');
addPool(data.combat, 'combat');
addPool(data.diplomacy?.default, 'diplomacy-default');
return out;
}
const sheet = (key, path, frameWidth, frameHeight) => ({ type: 'spritesheet', key, path, frameWidth, frameHeight });
const image = (key, path) => ({ type: 'image', key, path });
export const MANIFEST = {
gootower: [
image('gootower-bg', 'assets/images/gootower/background-1.png'),
image('gootower-pipe', 'assets/images/gootower/pipe.png'),
image('gootower-gear', 'assets/images/gootower/gear.png'),
],
// Excitebike draws every pixel itself (see ExcitebikeRaster.js), so there is
// no art to fetch — only its soundtrack.
excitebike: [
(scene) => musicFrom(scene, 'nintendo-music'),
],
// Every sheet in mastervega-artwork.json starts with path:null and is painted
// procedurally by VegaArt.js, so this entry needs no edit when art arrives —
// only the JSON does.
mastervega: [
{ type: 'json', key: 'mastervega-rules', path: 'data/mastervega-rules.json' },
(scene) => sheetsFrom(scene, 'mastervega-artwork', ['sheets']),
(scene) => videosFrom(scene, 'mastervega-artwork', 'portraitVideos'),
(scene) => nestedVideosFrom(scene, 'mastervega-artwork', 'shipVideos'),
(scene) => imagesFromMap(scene, 'mastervega-artwork', 'portraitStills'),
(scene) => imagesFromMap(scene, 'mastervega-artwork', 'worldBackgrounds'),
// The artwork JSON's `colonyVideos` block is deliberately NOT resolved here.
// Note what that does and does not buy: Phaser's video loader downloads
// nothing (VideoFile.load() just records the URL), so this was never about
// 19 MB of bytes at game-room entry — it is about WHERE the fetch is
// decided. VegaColonyIntro.ensureColonyVideo() registers a clip and warms
// its bytes when a colony ship reaches a settleable world, which is a
// better moment than "entered the room" and a far better one than "the
// vignette is already on screen".
image('vega-menu-bg', 'assets/images/vega/background-menu.png'),
image('vega-menu-title', 'assets/images/vega/menu-title.png'),
// The Galactic News Network anchor desk loop — one global clip (not
// per-species like portraitVideos), so it's a plain literal here rather
// than a mastervega-artwork.json map entry. Eager-loaded so the first
// post-turn GNN open never stalls on a JIT fetch.
{ type: 'video', key: 'vega-gnn-anchor', path: 'assets/videos/vega/gnn.mp4' },
// The Galactic Council session ceremony's persistent anchor visual, same
// 2:3 eager-load treatment as the GNN clip above (VegaCouncilSession.js).
{ type: 'video', key: 'vega-council-anchor', path: 'assets/videos/vega/galactic-council.mp4' },
// Bombard/Invade popup clips are JIT-loaded per planet type instead —
// see bombardVideos in mastervega-artwork.json and VegaBombardScreen.js
// — same convention as colonyVideos/researchVideos/advisorVideos, none
// of which are listed here either.
// The colony-founding cue. It is not part of the shuffled soundtrack —
// VegaColonyIntro.js ducks that and plays this over the vignette instead.
// Small, and it has to be ready the instant the vignette opens.
{ type: 'audio', key: 'vega-colony-cue', path: 'assets/music/vega/colony.mp3' },
{ type: 'audio', key: 'laser-zap', path: 'assets/fx/laser-zap.mp3' },
{ type: 'audio', key: 'scifi-explode', path: 'assets/fx/scifi-explode.mp3' },
{ type: 'audio', key: 'ta-rocket-1', path: 'assets/fx/ta-rocket-1.mp3' },
{ type: 'audio', key: 'ta-rocket-2', path: 'assets/fx/ta-rocket-2.mp3' },
// UI sound effects (src/ui/Sounds.js's SFX.VEGA_*). Used to be loaded
// eagerly by PreloadScene for every game; moved here 2026-08-09 alongside
// Brian's own move of the files into assets/fx/vega/.
{ type: 'audio', key: 'sfx-vega-select', path: 'assets/fx/vega/vega-select.mp3' },
{ type: 'audio', key: 'sfx-vega-build', path: 'assets/fx/vega/vega-build.mp3' },
{ type: 'audio', key: 'sfx-vega-close', path: 'assets/fx/vega/vega-close.mp3' },
{ type: 'audio', key: 'sfx-vega-newturn', path: 'assets/fx/vega/vega-newturn.mp3' },
{ type: 'audio', key: 'sfx-vega-unit', path: 'assets/fx/vega/vega-unit.mp3' },
{ type: 'audio', key: 'sfx-vega-view', path: 'assets/fx/vega/vega-view.mp3' },
{ type: 'audio', key: 'sfx-vega-warp', path: 'assets/fx/vega/vega-warp.mp3' },
{ type: 'audio', key: 'sfx-vega-star', path: 'assets/fx/vega/vega-star.mp3' },
{ type: 'audio', key: 'sfx-vega-zoomin', path: 'assets/fx/vega/vega-zoomin.mp3' },
{ type: 'audio', key: 'sfx-vega-zoomout', path: 'assets/fx/vega/vega-zoomout.mp3' },
{ type: 'audio', key: 'sfx-vega-viewcolony', path: 'assets/fx/vega/vega-viewcolony.mp3' },
{ type: 'audio', key: 'sfx-vega-endturn', path: 'assets/fx/vega/vega-endturn.mp3' },
{ type: 'audio', key: 'sfx-vega-counter', path: 'assets/fx/vega/vega-counter.mp3' },
// GNN's opening sting, and the looping ambience that takes over once it
// finishes and plays for as long as the GNN screen stays open (see
// VegaGnnScreen.js's openGnnScreen) — ducking the regular soundtrack
// the same way VegaColonyIntro.js's founding cue already does.
{ type: 'audio', key: 'sfx-vega-gnn-sting', path: 'assets/fx/vega/vega-gnn.mp3' },
{ type: 'audio', key: 'sfx-vega-gnn-loop', path: 'assets/music/vega/vega-gnn.mp3' },
// Same sting-then-loop shape for the Council Session ceremony
// (VegaCouncilSession.js's openCouncilSessionScreen).
{ type: 'audio', key: 'sfx-vega-council-intro', path: 'assets/fx/vega/vega-council-intro.mp3' },
{ type: 'audio', key: 'sfx-vega-council-loop', path: 'assets/music/vega/vega-council.mp3' },
// Combat weapon cues, Mark-banded (see VegaCombat.js's weaponSfxKey()).
{ type: 'audio', key: 'sfx-vega-weapon-123', path: 'assets/fx/vega/vega-weapon-123.mp3' },
{ type: 'audio', key: 'sfx-vega-weapon-45', path: 'assets/fx/vega/vega-weapon-45.mp3' },
{ type: 'audio', key: 'sfx-vega-weapon-67', path: 'assets/fx/vega/vega-weapon-67.mp3' },
{ type: 'audio', key: 'sfx-vega-missle-launch-12345', path: 'assets/fx/vega/vega-missle-launch-12345.mp3' },
{ type: 'audio', key: 'sfx-vega-missle-launch-67', path: 'assets/fx/vega/vega-missle-launch-67.mp3' },
{ type: 'audio', key: 'sfx-vega-missle-hit-12345', path: 'assets/fx/vega/vega-missle-hit-12345.mp3' },
{ type: 'audio', key: 'sfx-vega-missle-hit-67', path: 'assets/fx/vega/vega-missle-hit-67.mp3' },
// Planetary defence battery fire cue — a colony has no weapon mount to
// Mark-band like a ship (VegaCombatV2.js's planet entity is a flat
// damage scalar), so this is the one cue for every planet fire event.
{ type: 'audio', key: 'sfx-vega-planet-beam', path: 'assets/fx/vega/vega-planet-beam.mp3' },
// Per-warship-class destruction cues. Filename has a typo ("friggate")
// that the cache key does not repeat — see Sounds.js.
{ type: 'audio', key: 'sfx-vega-destroy-frigate', path: 'assets/fx/vega/vega-destroy-friggate.mp3' },
{ type: 'audio', key: 'sfx-vega-destroy-destroyer', path: 'assets/fx/vega/vega-destroy-destroyer.mp3' },
{ type: 'audio', key: 'sfx-vega-destroy-cruiser', path: 'assets/fx/vega/vega-destroy-cruiser.mp3' },
{ type: 'audio', key: 'sfx-vega-destroy-battleship', path: 'assets/fx/vega/vega-destroy-battleship.mp3' },
(scene) => vegaMusicFrom(scene, 'masterofvega-music'),
],
forbiddenisland: [
// Tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame 2i, flooded
// frame 2i+1 (see IslandData.TILE_FRAME_ROW).
sheet('forbiddenisland-tiles', 'assets/images/forbiddenisland-tiles.png', 200, 200),
// Treasure-deck cards: 4 cols × 2 rows of 320×420 cells. Frame order
// documented in IslandData.CARD_FRAME.
sheet('forbiddenisland-cards', 'assets/images/forbiddenisland-cards.png', 320, 420),
],
catan: [
sheet('catan-cards', 'assets/images/catancards.png', 270, 390),
sheet('catan-tiles', 'assets/images/catantiles.png', 312, 312),
sheet('catan-special-cards', 'assets/images/catan-special-cards.png', 270, 390),
image('catan-robber', 'assets/images/catan-robber.png'),
image('catan-pirate', 'assets/images/catan-pirate.png'),
],
risk: [image('risk-board', 'assets/images/risk-board.png')],
bloxorz: [
image('bloxorz-bg-menu', 'assets/images/bloxorz/background-menu.png'),
...Array.from({ length: 6 }, (_, i) =>
image(`bloxorz-bg-${i + 1}`, `assets/images/bloxorz/background-${i + 1}.png`)),
],
jewelquest: [
image('bg-jewelquest-battle', 'assets/images/background-jewelquest.png'),
image('bg-jewelquest-menu', 'assets/images/background-jewelquest-menu.png'),
],
bejeweled: [image('bg-bejeweled', 'assets/images/background-bejeweledblitz.png')],
rushhour: [
// Carries its own title, so the level-select screen draws no heading.
// In-play art is procedural (see games/rushhour/RushHourArt.js).
image('bg-rushhour-menu', 'assets/images/background-rushhour.png'),
],
dominion: [
// Dominion card art. One 270×390 cell per card (art fills the top ~60%;
// the title/icon band is drawn at runtime). Optional — the scene falls
// back to procedural placeholders when the sheet is absent.
sheet('dominion-cards', 'assets/images/dominioncards.png', 270, 390),
// Prosperity expansion art (frame order documented in expansions/prosperity.js).
// Optional — same procedural fallback applies when the sheet is absent.
sheet('dominion-prosperity', 'assets/images/dominion-prosperity.png', 270, 390),
// Prosperity token sprites (1 VP, 5 VP, Gold) — 150×150 each.
sheet('dominion-tokens', 'assets/images/dominion-tokens.png', 150, 150),
],
splendor: [
// 28 frames at 270×390. 0-14 = tier×bonus backgrounds, 15-24 = nobles,
// 25-27 = deck backs. Frame order documented in SplendorData.js.
// Optional — vectors are drawn when absent.
sheet('splendor-cards', 'assets/images/splendor-cards.png', 270, 390),
// Gems: 6 frames at 64×64. white(0) blue(1) green(2) red(3) black(4) gold(5).
sheet('splendor-gems', 'assets/images/splendor-gems.png', 64, 64),
],
// Azul tiles: 6 frames at 96×96. blue(0) yellow(1) red(2) black(3) white(4)
// first-player(5). Frame order documented in AzulData.js. A placeholder
// sheet ships; vectors draw when absent.
azul: [sheet('azul-tiles', 'assets/images/azul-tiles.png', 96, 96)],
tickettoride: [sheet('ttr-cards', 'assets/images/tickettoride-cards.png', 270, 390)],
gofish: [sheet('gofish-cards', 'assets/images/gofish-cards.png', 270, 390)],
oldmaid: [sheet('oldmaid-cards', 'assets/images/oldmaid-cards.png', 270, 390)],
labyrinth: [
// Tile backgrounds: frame 0 = movable, 1 = fixed (corridors are drawn in
// code). Treasure overlays & cards share the treasure index as their
// frame. All optional — the scene draws vector fallbacks when absent.
sheet('labyrinth-tiles', 'assets/images/labyrinth-tiles.png', 200, 200),
sheet('labyrinth-treasures', 'assets/images/labyrinth-treasures.png', 100, 100),
sheet('labyrinth-cards', 'assets/images/labyrinth-cards.png', 270, 390),
],
// Stratego unit art: 12 transparent frames (0=Flag, 1..10=rank, 11=Bomb),
// 6 cols × 2 rows. Optional — the scene draws vector glyphs when absent.
stratego: [sheet('stratego-pieces', 'assets/images/stratego-pieces.png', 140, 140)],
monopoly: [
// Pawns: 4 frames (one per seat) at 80×80. Optional — falls back to colored circles.
sheet('monopoly-pawns', 'assets/images/monopoly-pawns.png', 80, 80),
// Card art: frame 0 = Chance, frame 1 = Community Chest, at 200×300.
sheet('monopoly-cards', 'assets/images/monopoly-cards.png', 200, 300),
],
puddingmonsters: [
sheet('jello-items', 'assets/images/jello-items.png', 132, 132),
image('bg-puddingmonsters-menu', 'assets/images/background-jello.png'),
],
mahjong: [
...MAHJONG_TILES,
// chinese soundtrack (see services/soundtrack.js) — lazy-loaded here so
// its audio only downloads once Mahjong is actually entered.
(scene) => musicFrom(scene, 'chinese-music'),
],
mahjongmatch: [
...MAHJONG_TILES,
// Carries its own title, so the layout-select screen draws no heading.
image('bg-mahjongmatch-menu', 'assets/images/background-mahjongmatch.png'),
// In-play backdrops — one is picked at random per game.
...['01', '02', '03'].map((n) => image(`bg-mm-${n}`, `assets/images/background-mm-${n}.png`)),
// chinese soundtrack (see services/soundtrack.js) — lazy-loaded here so
// its audio only downloads once Mahjong Match is actually entered.
(scene) => musicFrom(scene, 'chinese-music'),
],
spireclimb: [
image('spireclimb-act1', 'assets/images/spireclimb-act1.png'),
image('spireclimb-act2', 'assets/images/spireclimb-act2.png'),
image('spireclimb-act3', 'assets/images/spireclimb-act3.png'),
(scene) => sheetsFrom(scene, 'spireclimb-artwork', ['cardSheet', 'creatureSheet', 'playerSheets']),
],
shift: [
image('shift-background', 'assets/images/background-shift.png'),
(scene) => imagesFrom(scene, 'shift-artwork'),
],
slots: (scene) => imagesFrom(scene, 'slots-artwork'),
dungeonboss: (scene) => sheetsFrom(scene, 'dungeonboss-artwork',
['roomSheet', 'heroSheet', 'spellSheet', 'bossSheet', 'deckBackSheet', 'iconSheet']),
swdbg: (scene) => sheetsFrom(scene, 'swdbg-artwork', ['cardSheet', 'baseSheet']),
balatro: [
(scene) => sheetsFrom(scene, 'balatro-artwork',
['jokerSheet', 'tarotSheet', 'planetSheet', 'spectralSheet', 'voucherSheet', 'packSheet', 'deckBackSheet', 'bossIconSheet']),
// hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so
// its audio only downloads once Balatro is actually entered.
(scene) => musicFrom(scene, 'hacker-music'),
],
// Optional drop-in art — spec in assets/gamedata/peggle/sprites.md. Missing
// files 404 harmlessly and the game renders procedurally: circles for
// pegs/ball, gradient starfield for the board background.
// Ship rotation sheets are described by each ship's `sprite` blocks in
// data/star-control-ships.json (written by tools/fetchStarControlAssets.js).
// Ships without a sheet render as procedural vector chevrons, so a missing
// file is harmless and new JSON ships need no manifest edit.
starcontrol: (scene) => {
const cfg = scene.cache.json.get('star-control-ships');
return Object.values(cfg?.ships ?? {})
.flatMap((s) => [s.sprite, s.projectileSprite, ...(s.forms ?? []).map((f) => f.sprite)])
.filter((sp) => sp?.sheet && sp.frameWidth > 0 && sp.frameHeight > 0)
.map((sp) => sheet(sp.sheet, `assets/images/starcontrol/${sp.sheet}.png`, sp.frameWidth, sp.frameHeight));
},
peggle: [
sheet('peggle-sprites', 'assets/images/peggle-sprites.png', 64, 64),
image('bg-peggle-menu', 'assets/images/background-peggle.png'),
// One board background per master, named by opponent id
// (assets/images/peggle/<masterId>.png, 1200×900).
(scene) => {
const levels = scene.cache.json.get('peggle-levels')?.levels ?? [];
const ids = [...new Set(levels.map((l) => l.masterId).filter(Boolean))];
return ids.map((id) => image(`peggle-bg-${id}`, `assets/images/peggle/${id}.png`));
},
],
// Rules data always loads; sprite sheets are optional drop-ins declared in
// data/civilization-artwork.json (spec: src/games/civilization/sprites.md).
// Sheets with path:null stay procedural. citySheets is a map keyed by theme
// (classic today; asian etc. drop in later with no code change).
civilization: [
{ type: 'json', key: 'civilization-rules', path: 'data/civilization-rules.json' },
image('civilization-setup-bg', 'assets/images/civilization/background.png'),
// Battle sounds keyed by each unit's `battleSound` in civilization-rules.json
// (played on attack by CivilizationMapView.playCombatEvent).
...['arrows', 'cannon', 'catapult', 'gunfire-classic', 'gunfire-modern',
'helicopter', 'missle', 'submarine', 'sword', 'tank']
.map((n) => ({ type: 'audio', key: `sfx-battle-${n}`, path: `assets/fx/battle-${n}.mp3` })),
(scene) => sheetsFrom(scene, 'civilization-artwork',
['terrainSheet', 'resourceSheet', 'improvementSheet', 'unitSheet', 'iconSheet',
'barbarianSheet', 'citySheets']),
],
// Cups/tracks index always loads (individual track-NNN.json files are
// fetched lazily at race start). Racer/theme/item sheets and backdrops are
// optional drop-ins declared in data/superkart-artwork.json — path:null
// entries stay procedural (tinted kart silhouettes, painted horizon).
superkart: [
{ type: 'json', key: 'superkart-cups', path: 'assets/gamedata/superkart/cups.json' },
image('superkart-background', 'assets/images/superkart-background.png'),
(scene) => sheetsFrom(scene, 'superkart-artwork', ['racerSheets', 'themeSheets', 'itemSheet']),
(scene) => {
const art = scene.cache.json.get('superkart-artwork');
return Object.values(art?.backdrops ?? {})
.filter((b) => b && b.key && b.path)
.map((b) => image(b.key, b.path));
},
// nintendo soundtrack (see services/soundtrack.js) — lazy-loaded here so
// its audio only downloads once Super Kart is actually entered.
(scene) => musicFrom(scene, 'nintendo-music'),
],
// Rules + campaign always load; the painted sheet is an optional drop-in
// declared in data/advancewars-artwork.json (spec: src/games/advancewars/
// sprites.md) — path:null stays procedural. Shares Super Kart's nintendo
// soundtrack (see services/soundtrack.js).
advancewars: [
{ 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', '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'),
],
// Config + puzzle bank always load; panel/character sheets are optional
// drop-ins declared in data/tetrisattack-artwork.json (spec: src/games/
// tetrisattack/sprites.md) — path:null stays procedural. Shares the nintendo
// soundtrack (see services/soundtrack.js).
tetrisattack: [
{ type: 'json', key: 'tetrisattack', path: 'data/tetrisattack.json' },
{ type: 'json', key: 'tetrisattack-puzzles', path: 'data/tetrisattack-puzzles.json' },
image('tetrisattack-menu-bg', 'assets/images/tetrisattack/main-menu.png'),
(scene) => sheetsFrom(scene, 'tetrisattack-artwork', ['panelSheet', 'characterSheet']),
// Stage Clear backgrounds (background-{hero}-r{stage}.png, 6 friends × 5
// stages) and character voice clips (assets/fx/tetrisattack/{hero}-*.mp3)
// are NOT declared here — 30 full-screen images is far too much to pull on
// entering the game room, and the voice sets are still being filled in.
// TetrisAttackGame.ensureRoundAssets() loads one character's set as their
// round begins; anything missing just never plays.
(scene) => musicFrom(scene, 'nintendo-music'),
],
// Rules always load; every unit/structure/terrain sheet is an optional drop-in
// declared in data/totalannihilation-artwork.json (spec: src/games/
// totalannihilation/sprites.md) — each is path:null today and paints
// procedurally, so the game is fully playable with zero PNGs. `sheets` is a MAP
// of sheets rather than a list of field names, which is why one resolver line
// covers every sheet ever added: sheetsFrom() Object.values() anything without a
// `key`. Uses the hacker soundtrack (see services/soundtrack.js).
totalannihilation: [
{ type: 'json', key: 'totalannihilation-rules', path: 'data/totalannihilation-rules.json' },
{ type: 'json', key: 'totalannihilation-campaign', path: 'data/totalannihilation-campaign.json' },
(scene) => sheetsFrom(scene, 'totalannihilation-artwork', ['sheets']),
image('ta-background', 'assets/images/ta/background.png'),
// Weapon sounds keyed by each weapon's `sound` in totalannihilation-rules.json.
...['tank'].map((n) => ({ type: 'audio', key: `sfx-battle-${n}`, path: `assets/fx/battle-${n}.mp3` })),
{ type: 'audio', key: 'sfx-laser-zap', path: 'assets/fx/laser-zap.mp3' },
{ type: 'audio', key: 'sfx-scifi-launch', path: 'assets/fx/scifi-launch.mp3' },
// TA-specific sfx: machine guns per weapon.sound, rocket variants (random pick between the
// two on fire, see TotalAnnihilationGame._onSimEvent), and unit/vehicle death cues.
...['50cal', 'machinegun', 'rocket-1', 'rocket-2', 'nuclear', 'unit-loss', 'vehicle-loss']
.map((n) => ({ type: 'audio', key: `sfx-ta-${n}`, path: `assets/fx/ta-${n}.mp3` })),
(scene) => musicFrom(scene, 'hacker-music'),
],
wolfenstein: [
{ type: 'json', key: 'wolfenstein-rules', path: 'data/wolfenstein-rules.json' },
{ type: 'json', key: 'wolfenstein-campaigns', path: 'data/wolfenstein-campaigns.json' },
(scene) => sheetsFrom(scene, 'wolfenstein-artwork', ['sheets']),
(scene) => imagesFrom(scene, 'wolfenstein-artwork'),
// Weapon/enemy sfx (fists swing/hit, pistol shot/reload/empty, guard alert/
// melee/death, door open/close, pickup) will be added here once clips
// exist — WolfensteinGame plays no sound yet, same "add the row when the
// clip lands" approach as totalannihilation's weapon-sound block above.
],
coloradodefense: [
// arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here
// so its audio only downloads once Colorado Defense is actually entered.
(scene) => musicFrom(scene, 'arcadedark-music'),
],
tempest: [
// arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here
// so its audio only downloads once Tempest is actually entered.
(scene) => musicFrom(scene, 'arcadedark-music'),
],
mastermind: [
// hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so
// its audio only downloads once Mastermind is actually entered.
(scene) => musicFrom(scene, 'hacker-music'),
],
hexsweeper: [
// hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so
// its audio only downloads once Hexsweeper is actually entered.
(scene) => musicFrom(scene, 'hacker-music'),
],
dotlink: [
// hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so
// its audio only downloads once Dot Link is actually entered.
(scene) => musicFrom(scene, 'hacker-music'),
],
zuma: [
image('zuma-menu-bg', 'assets/images/zuma/background-menu.png'),
// In-level backgrounds, alternated per level by ZumaLogic's `background`
// field (see tools/genZuma.js) — more can be dropped in later.
image('zuma-background-1', 'assets/images/zuma/background-01.png'),
image('zuma-background-2', 'assets/images/zuma/background-02.png'),
// frame 0 = the whole stone frog, frame 1 = the same disc with the mouth
// slot cut out; the ready marble is drawn between them.
sheet('zuma-frog', 'assets/images/zuma/frog.png', 200, 200),
// zuma soundtrack (see services/soundtrack.js) — lazy-loaded here so its
// audio only downloads once Zuma is actually entered.
(scene) => musicFrom(scene, 'zuma-music'),
// Fired-shot whoosh, chain-collision hit, and the level-start fanfare
// (ZumaGame.playStartFanfare ducks the soundtrack while it plays).
{ type: 'audio', key: 'sfx-zuma-shoot', path: 'assets/fx/zuma-shoot.mp3' },
{ type: 'audio', key: 'sfx-zuma-hit', path: 'assets/fx/zuma-hit.mp3' },
{ type: 'audio', key: 'sfx-zuma-start', path: 'assets/fx/zuma-start.mp3' },
{ type: 'audio', key: 'sfx-zuma-explode', path: 'assets/fx/zuma-explode.mp3' },
{ type: 'audio', key: 'sfx-zuma-win', path: 'assets/fx/zuma-win.mp3' },
{ type: 'audio', key: 'sfx-zuma-lose', path: 'assets/fx/zuma-lose.mp3' },
],
tents: [
// Tent placement/removal cues and the win jingle (played by TentsGame
// via the SFX.TENTS_* keys in src/ui/Sounds.js).
{ type: 'audio', key: 'sfx-tents-place', path: 'assets/fx/tents-place.mp3' },
{ type: 'audio', key: 'sfx-tents-remove', path: 'assets/fx/tents-remove.mp3' },
{ type: 'audio', key: 'sfx-tents-win', path: 'assets/fx/tents-win.mp3' },
// Puzzle-start intro jingle — TentsGame.startGame ducks the soundtrack
// for its duration (same pattern as Zuma's level-start fanfare).
{ type: 'audio', key: 'sfx-tents-intro', path: 'assets/music/tents-intro.mp3' },
],
'2048': [
// hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so
// its audio only downloads once 2048 is actually entered.
(scene) => musicFrom(scene, 'hacker-music'),
],
};
// Returns the full (normalized) descriptor list for a slug — including assets
// that may already be loaded. Callers filter with the loader's cache checks.
export function resolveGameAssets(scene, slug) {
const entry = MANIFEST[slug];
if (!entry) return [];
const parts = typeof entry === 'function' ? entry(scene) : entry;
return parts.flatMap((p) => (typeof p === 'function' ? p(scene) : [p]));
}