diff --git a/assets/images/balatro-planets.png b/assets/images/balatro-planets.png new file mode 100644 index 0000000..3cbe072 Binary files /dev/null and b/assets/images/balatro-planets.png differ diff --git a/assets/images/balatro-planets.psd b/assets/images/balatro-planets.psd new file mode 100644 index 0000000..816d733 Binary files /dev/null and b/assets/images/balatro-planets.psd differ diff --git a/data/balatro-artwork.json b/data/balatro-artwork.json index 6b473e6..ecb0b8d 100644 --- a/data/balatro-artwork.json +++ b/data/balatro-artwork.json @@ -18,7 +18,7 @@ }, "planetSheet": { "key": "balatro-planets", - "path": null, + "path": "assets/images/balatro-planets.png", "frameWidth": 150, "frameHeight": 200 }, diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js new file mode 100644 index 0000000..d1819d9 --- /dev/null +++ b/src/data/assetManifest.js @@ -0,0 +1,139 @@ +// 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. +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 })); +} + +const sheet = (key, path, frameWidth, frameHeight) => ({ type: 'spritesheet', key, path, frameWidth, frameHeight }); +const image = (key, path) => ({ type: 'image', key, path }); + +export const MANIFEST = { + 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')], + jewelquest: [image('bg-jewelquest-battle', 'assets/images/background-jewelquest.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)], + mahjong: MAHJONG_TILES, + mahjongmatch: MAHJONG_TILES, + 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: (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']), +}; + +// 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])); +} diff --git a/src/games/balatro/BalatroGame.js b/src/games/balatro/BalatroGame.js index 0e8b0b5..983d39a 100644 --- a/src/games/balatro/BalatroGame.js +++ b/src/games/balatro/BalatroGame.js @@ -190,6 +190,12 @@ export default class BalatroGame extends Phaser.Scene { // ── view plumbing ───────────────────────────────────────────────────────── clearView() { + // Some fx (explosion bursts, floaters) can still be mid-tween when a + // view swap tears the layers down; destroying their target out from + // under a running tween throws, so stop those tweens first. + this.tweens.killTweensOf(this.viewLayer.list); + this.tweens.killTweensOf(this.handLayer.list); + this.tweens.killTweensOf(this.fxLayer.list); this.viewLayer.removeAll(true); this.handLayer.removeAll(true); this.fxLayer.removeAll(true); @@ -925,7 +931,7 @@ export default class BalatroGame extends Phaser.Scene { renderDeckThumb() { const run = this.run; - const x = 1840, y = 150, w = 110, h = 148; + const x = 1840, y = 880, w = 110, h = 148; const deck = DECK_BY_ID[run.deckId]; const g = this.add.graphics(); g.fillStyle(deck.color, 0.9); g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 8); @@ -1227,6 +1233,22 @@ export default class BalatroGame extends Phaser.Scene { this.tweens.add({ targets: sprite, scale: big ? 1.2 : 1.1, duration: 90, yoyo: true }); }; + // Chips/mult counters grow and shake as they take a hit, settling back + // to normal size once the bump passes. Uses angle (not x/y) for the + // shake so it doesn't fight the ambient hum-driven position jitter from + // update()/_shakeTargets. + const bumpStat = (textObj, big = false) => { + if (!textObj || !textObj.active) return; + this.tweens.killTweensOf(textObj); + textObj.angle = 0; + textObj.setScale(1); + this.tweens.add({ targets: textObj, scale: big ? 2.6 : 2.15, duration: 130, ease: 'Back.easeOut', yoyo: true, hold: 110 }); + this.tweens.add({ + targets: textObj, angle: big ? 20 : 15, duration: 35, yoyo: true, repeat: 8, ease: 'Sine.easeInOut', + onComplete: () => { if (textObj.active) textObj.angle = 0; }, + }); + }; + const applyEvent = (ev, silent = false) => { if (this._chipsText && this._chipsText.active) this._chipsText.setText(fmtChips(ev.chipsAfter)); if (this._multText && this._multText.active) this._multText.setText(String(Math.round(ev.multAfter * 100) / 100)); @@ -1236,12 +1258,14 @@ export default class BalatroGame extends Phaser.Scene { switch (ev.t) { case 'base': if (this._handNameText && this._handNameText.active) this._handNameText.setText(`${HAND_BY_ID[ev.handType].name} lvl.${ev.level}`); + bumpStat(this._chipsText); bumpStat(this._multText); playSound(this, SFX.CARD_SHOW); break; - case 'chips': floater(sprite, `+${fmtChips(ev.v)}`, C.chipsHex); pop(sprite); playSound(this, SFX.PIECE_CLICK); break; - case 'mult': floater(sprite, `+${ev.v} Mult`, C.multHex); pop(sprite); playSound(this, SFX.PIECE_CLICK); break; + case 'chips': floater(sprite, `+${fmtChips(ev.v)}`, C.chipsHex); pop(sprite); bumpStat(this._chipsText); playSound(this, SFX.PIECE_CLICK); break; + case 'mult': floater(sprite, `+${ev.v} Mult`, C.multHex); pop(sprite); bumpStat(this._multText); playSound(this, SFX.PIECE_CLICK); break; case 'xmult': floater(sprite, `×${ev.v}`, '#ffa24f'); pop(sprite, true); + bumpStat(this._multText, ev.v >= 2); playSound(this, SFX.SCIFI_PLINK); if (ev.v >= 2) { this.cameras.main.shake(120, 0.004); this.crt.pulse(0.5); } break; @@ -1270,27 +1294,60 @@ export default class BalatroGame extends Phaser.Scene { this.cameras.main.shake(160, score >= this.run.blindChips ? 0.008 : 0.004); this.crt.pulse(score >= this.run.blindChips ? 0.8 : 0.4); playSound(this, score >= 10000 ? SFX.SCIFI_EXPLODE : SFX.CASINO_BLACKJACK); - this._roundScoreOverride = null; - if (this._roundScoreText && this._roundScoreText.active) this._roundScoreText.setText(fmtChips(this.run.roundScore)); - this.time.delayedCall(this._skipAnim ? 250 : 900, () => { - this._hideUids = null; - if (res.destroyedJokers && res.destroyedJokers.length) { - this.toast(`${res.destroyedJokers.map((id) => JOKER_BY_ID[id].name).join(', ')} destroyed`); - } - if (res.outcome === 'blindWon') { - this.animating = false; - playSound(this, SFX.CASINO_WIN); - this.setView('cashout'); - } else if (res.outcome === 'runLost') { - this.animating = false; - playSound(this, SFX.CASINO_LOSE); - this.onRunOver(); - this.setView('gameover'); + const skip = this._skipAnim; + // Hold the tally at the played row, then fly it into the round-score + // readout on the left bar and detonate on arrival. The new total is + // only revealed once the explosion lands. + const dwell = skip ? 60 : 550; + const flightDur = skip ? 100 : 380; + this.time.delayedCall(dwell, () => { + const targetX = this._roundScoreText && this._roundScoreText.active ? this._roundScoreText.x : centerX; + const targetY = this._roundScoreText && this._roundScoreText.active ? this._roundScoreText.y : py - 170; + + const detonate = () => { + this.explodeBurst(targetX, targetY, score >= this.run.blindChips ? 0xffd873 : 0xffe9a8); + this.cameras.main.shake(110, 0.006); + this.crt.pulse(0.5); + playSound(this, SFX.SCIFI_PLONK); + this._roundScoreOverride = null; + if (this._roundScoreText && this._roundScoreText.active) { + this._roundScoreText.setText(fmtChips(this.run.roundScore)); + this._roundScoreText.setScale(1); + this.tweens.add({ targets: this._roundScoreText, scale: 1.5, duration: 140, ease: 'Back.easeOut', yoyo: true, hold: 90 }); + } + + this.time.delayedCall(skip ? 60 : 250, () => { + this._hideUids = null; + if (res.destroyedJokers && res.destroyedJokers.length) { + this.toast(`${res.destroyedJokers.map((id) => JOKER_BY_ID[id].name).join(', ')} destroyed`); + } + if (res.outcome === 'blindWon') { + this.animating = false; + playSound(this, SFX.CASINO_WIN); + this.setView('cashout'); + } else if (res.outcome === 'runLost') { + this.animating = false; + playSound(this, SFX.CASINO_LOSE); + this.onRunOver(); + this.setView('gameover'); + } else { + // animating stays true; the new deal-in sequence clears it once cards land. + this._pendingDeal = new Set(res.drawn); + this.setView('play'); + } + }); + }; + + if (slam.active) { + this.tweens.killTweensOf(slam); + this.tweens.add({ + targets: slam, x: targetX, y: targetY, scale: 0.3, alpha: 0.7, + duration: flightDur, ease: 'Cubic.easeIn', + onComplete: () => { slam.destroy(); detonate(); }, + }); } else { - // animating stays true; the new deal-in sequence clears it once cards land. - this._pendingDeal = new Set(res.drawn); - this.setView('play'); + detonate(); } }); }; @@ -1408,6 +1465,25 @@ export default class BalatroGame extends Phaser.Scene { this.tweens.add({ targets: ring, radius: 110, alpha: 0, duration: 380, onComplete: () => ring.destroy() }); } + explodeBurst(x, y, color = 0xffe9a8) { + const flash = this.add.circle(x, y, 10, 0xffffff, 0.9).setDepth(87); + this.fxLayer.add(flash); + this.tweens.add({ targets: flash, radius: 70, alpha: 0, duration: 260, ease: 'Cubic.easeOut', onComplete: () => flash.destroy() }); + const ring = this.add.circle(x, y, 20, 0xffffff, 0).setStrokeStyle(5, color, 0.95).setDepth(86); + this.fxLayer.add(ring); + this.tweens.add({ targets: ring, radius: 130, alpha: 0, duration: 420, ease: 'Cubic.easeOut', onComplete: () => ring.destroy() }); + for (let i = 0; i < 10; i++) { + const shard = this.add.rectangle(x, y, 12, 18, color, 0.95).setDepth(85).setAngle(Phaser.Math.Between(0, 360)); + this.fxLayer.add(shard); + this.tweens.add({ + targets: shard, + x: x + Phaser.Math.Between(-90, 90), y: y + Phaser.Math.Between(-70, 90), + angle: shard.angle + Phaser.Math.Between(-260, 260), alpha: 0, + duration: 460, ease: 'Cubic.easeOut', onComplete: () => shard.destroy(), + }); + } + } + shatter(sprite) { const x = this.fxX(sprite), y = this.fxY(sprite); if (sprite.active) sprite.setAlpha(0.25); diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index df8c79b..df25d50 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -1,6 +1,7 @@ import * as Phaser from 'phaser'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { Button } from '../ui/Button.js'; +import { missingGameAssets, ensureGameAssets } from '../services/assetLoader.js'; // Generic room shell. Dispatches to the concrete game scene // (Backgammon, Blackjack, etc.) based on `data.game.slug`. @@ -24,7 +25,8 @@ export default class GameRoomScene extends Phaser.Scene { create() { const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame' }; if (slugDispatch[this.game.slug]) { - this.scene.start(slugDispatch[this.game.slug], { + const sceneKey = slugDispatch[this.game.slug]; + const startData = { game: this.game, opponents: this.opponents, playfield: this.playfield, @@ -36,7 +38,30 @@ export default class GameRoomScene extends Phaser.Scene { wordLength: this.wordLength, secretRevealType: this.secretRevealType, difficulty: this.difficulty, - }); + }; + + // Game art is lazy-loaded on first entry (see data/assetManifest.js). + // Anything already cached makes this a synchronous fast path. + if (missingGameAssets(this, this.game.slug).length === 0) { + this.scene.start(sceneKey, startData); + return; + } + + const barWidth = 600; + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, barWidth + 8, 28, COLORS.panel) + .setStrokeStyle(2, COLORS.accent); + const bar = this.add.rectangle(GAME_WIDTH / 2 - barWidth / 2, GAME_HEIGHT / 2, 0, 20, COLORS.accent) + .setOrigin(0, 0.5); + this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 60, `Loading ${this.game.name}…`, { + fontFamily: '"Julius Sans One"', + fontSize: '32px', + color: COLORS.textHex, + }).setOrigin(0.5); + + let gone = false; + this.events.once('shutdown', () => { gone = true; }); + ensureGameAssets(this, this.game.slug, { onProgress: (p) => { bar.width = barWidth * p; } }) + .then(() => { if (!gone) this.scene.start(sceneKey, startData); }); return; } const cx = GAME_WIDTH / 2; diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 99558c7..8169709 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -22,41 +22,20 @@ export default class PreloadScene extends Phaser.Scene { this.load.on('progress', (p) => bar.width = barWidth * p); this.load.on('complete', () => { bg.destroy(); bar.destroy(); }); + // NOTE: game-specific art lives in src/data/assetManifest.js and is + // lazy-loaded by GameRoomScene the first time a game is entered. Only + // shared assets (menus, opponents, card backs, SFX, JSON) load here. this.load.spritesheet('opponents', 'assets/images/opponents.png', { frameWidth: 300, frameHeight: 300, }); - // Forbidden Island tiles: 2 cols (dry, flooded) × 24 rows. Row i → dry frame - // 2i, flooded frame 2i+1 (see IslandData.TILE_FRAME_ROW). - this.load.spritesheet('forbiddenisland-tiles', 'assets/images/forbiddenisland-tiles.png', { - frameWidth: 200, - frameHeight: 200, - }); - // Forbidden Island Treasure-deck cards: 4 cols × 2 rows of 320×420 cells. - // Frame order documented in IslandData.CARD_FRAME. - this.load.spritesheet('forbiddenisland-cards', 'assets/images/forbiddenisland-cards.png', { - frameWidth: 320, - frameHeight: 420, - }); this.load.spritesheet('cardbacks', 'assets/images/cardbacks.png', { frameWidth: 320, frameHeight: 420, }); - this.load.spritesheet('catan-cards', 'assets/images/catancards.png', { - frameWidth: 270, - frameHeight: 390, - }); - this.load.spritesheet('catan-tiles', 'assets/images/catantiles.png', { - frameWidth: 312, - frameHeight: 312, - }); - this.load.image('risk-board', 'assets/images/risk-board.png'); - this.load.image('catan-robber', 'assets/images/catan-robber.png'); - this.load.image('catan-pirate', 'assets/images/catan-pirate.png'); this.load.image('bg-menu', 'assets/images/background-menu.png'); this.load.image('bg-room', 'assets/images/background-room.png'); this.load.image('bg-casino', 'assets/images/background-casino.png'); - this.load.image('bg-jewelquest-battle', 'assets/images/background-jewelquest.png'); this.load.image('main-title', 'assets/images/main-title.png'); this.load.json('playfields', 'data/playfields.json'); this.load.json('colored-playfields', 'data/colored-playfields.json'); @@ -78,9 +57,6 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('swdbg-artwork', 'data/swdbg-artwork.json'); this.load.json('jumble', 'data/jumble.json'); this.load.json('balatro-artwork', 'data/balatro-artwork.json'); - this.load.image('spireclimb-act1', 'assets/images/spireclimb-act1.png'); - this.load.image('spireclimb-act2', 'assets/images/spireclimb-act2.png'); - this.load.image('spireclimb-act3', 'assets/images/spireclimb-act3.png'); this.load.audio('sfx-water-splash', 'assets/fx/water-splash.mp3'); this.load.audio('sfx-water-sink', 'assets/fx/water-sink.mp3'); @@ -134,7 +110,6 @@ export default class PreloadScene extends Phaser.Scene { this.load.audio('sfx-squish', 'assets/fx/squish.mp3'); this.load.audio('sfx-squash', 'assets/fx/squash.mp3'); this.load.audio('sfx-woosh', 'assets/fx/woosh.mp3'); - this.load.spritesheet('jello-items', 'assets/images/jello-items.png', { frameWidth: 132, frameHeight: 132 }); this.load.audio('sfx-jello-monsters','assets/fx/jello-monsters.mp3'); this.load.audio('sfx-countdown-tick', 'assets/fx/countdown-01.mp3'); this.load.audio('sfx-countdown-go', 'assets/fx/countdown-02.mp3'); @@ -158,57 +133,9 @@ export default class PreloadScene extends Phaser.Scene { this.load.audio('sfx-sw-dark-2', 'assets/fx/sw-dark-2.mp3'); this.load.audio('sfx-sw-dark-3', 'assets/fx/sw-dark-3.mp3'); - this.load.spritesheet('catan-special-cards', 'assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 }); - - // 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. - this.load.spritesheet('dominion-cards', 'assets/images/dominioncards.png', { frameWidth: 270, frameHeight: 390 }); - // Prosperity expansion art (frame order documented in expansions/prosperity.js). - // Optional — same procedural fallback applies when the sheet is absent. - this.load.spritesheet('dominion-prosperity', 'assets/images/dominion-prosperity.png', { frameWidth: 270, frameHeight: 390 }); - // Prosperity token sprites (1 VP, 5 VP, Gold) — 150×150 each. - this.load.spritesheet('dominion-tokens', 'assets/images/dominion-tokens.png', { frameWidth: 150, frameHeight: 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. - this.load.spritesheet('splendor-cards', 'assets/images/splendor-cards.png', { frameWidth: 270, frameHeight: 390 }); - // Splendor gems: 6 frames at 64×64. white(0) blue(1) green(2) red(3) black(4) gold(5). - this.load.spritesheet('splendor-gems', 'assets/images/splendor-gems.png', { frameWidth: 64, frameHeight: 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. - this.load.spritesheet('azul-tiles', 'assets/images/azul-tiles.png', { frameWidth: 96, frameHeight: 96 }); - this.load.spritesheet('ttr-cards', 'assets/images/tickettoride-cards.png', { frameWidth: 270, frameHeight: 390 }); - this.load.spritesheet('gofish-cards', 'assets/images/gofish-cards.png', { frameWidth: 270, frameHeight: 390 }); - this.load.spritesheet('oldmaid-cards', 'assets/images/oldmaid-cards.png', { frameWidth: 270, frameHeight: 390 }); this.load.spritesheet('tab-icons', 'assets/images/tab-icons.png', { frameWidth: 128, frameHeight: 128 }); this.load.spritesheet('game-icons', 'assets/images/game-icons.png', { frameWidth: 44, frameHeight: 44 }); - // 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. - this.load.spritesheet('labyrinth-tiles', 'assets/images/labyrinth-tiles.png', { frameWidth: 200, frameHeight: 200 }); - this.load.spritesheet('labyrinth-treasures', 'assets/images/labyrinth-treasures.png', { frameWidth: 100, frameHeight: 100 }); - this.load.spritesheet('labyrinth-cards', 'assets/images/labyrinth-cards.png', { frameWidth: 270, frameHeight: 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. - this.load.spritesheet('stratego-pieces', 'assets/images/stratego-pieces.png', { frameWidth: 140, frameHeight: 140 }); - // Monopoly pawns: 4 frames (one per seat) at 80×80. Optional — falls back to colored circles. - this.load.spritesheet('monopoly-pawns', 'assets/images/monopoly-pawns.png', { frameWidth: 80, frameHeight: 80 }); - // Monopoly card art: frame 0 = Chance, frame 1 = Community Chest, at 200×300. - this.load.spritesheet('monopoly-cards', 'assets/images/monopoly-cards.png', { frameWidth: 200, frameHeight: 300 }); - // Mahjong Match tile-label overlays (128×178 transparent PNGs). The White - // Dragon has no art — the scene draws its traditional empty frame. - const mahjongLabels = [ - ...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', - ]; - for (const name of mahjongLabels) { - this.load.image(`mahjong-${name}`, `assets/images/mahjong/${name}.png`); - } - this.load.audio('sfx-monopoly-purchase', 'assets/fx/monopoly-purchase.mp3'); this.load.audio('sfx-monopoly-expense', 'assets/fx/monopoly-expense.mp3'); this.load.audio('sfx-monopoly-pay', 'assets/fx/monopoly-pay.mp3'); @@ -216,45 +143,19 @@ export default class PreloadScene extends Phaser.Scene { } async create() { - // Collect all image assets that need loading from JSON configs + // Playfields and card backs are shared across games and shown as + // thumbnails in OpponentSelect, so they load at startup. Per-game drop-in + // artwork (shift/slots/spireclimb/dungeonboss/swdbg/balatro) is handled by + // data/assetManifest.js and lazy-loaded on game entry instead. const pfd = this.cache.json.get('playfields'); const cbd = this.cache.json.get('card-backs'); - - const shiftArt = this.cache.json.get('shift-artwork'); - const slotsArt = this.cache.json.get('slots-artwork'); const toLoad = [ ...(pfd?.playfields ?? []).filter((pf) => pf.path && !this.textures.exists(pf.key)), ...(cbd?.cardBacks ?? []).filter((cb) => cb.path && !this.textures.exists(cb.key)), - ...(shiftArt?.artwork ?? []).filter((a) => a.path && a.key && !this.textures.exists(a.key)), - ...(slotsArt?.artwork ?? []).filter((a) => a.path && a.key && !this.textures.exists(a.key)), ]; - // Spire Climb drop-in spritesheets (card art + creature art). Only loaded - // once the artist sets a `path` in /data/spireclimb-artwork.json; until then - // the game renders cards/creatures procedurally. - const scArt = this.cache.json.get('spireclimb-artwork'); - const scSheets = [scArt?.cardSheet, scArt?.creatureSheet, ...Object.values(scArt?.playerSheets || {})] - .filter((s) => s && s.path && s.key && !this.textures.exists(s.key)); - - // Dungeon Boss drop-in spritesheets, same contract as Spire Climb's. - const dbArt = this.cache.json.get('dungeonboss-artwork'); - const dbSheets = [dbArt?.roomSheet, dbArt?.heroSheet, dbArt?.spellSheet, dbArt?.bossSheet, dbArt?.deckBackSheet, dbArt?.iconSheet] - .filter((s) => s && s.path && s.key && !this.textures.exists(s.key)); - - // Star Wars Deckbuilder drop-in spritesheets, same contract. - const swArt = this.cache.json.get('swdbg-artwork'); - const swSheets = [swArt?.cardSheet, swArt?.baseSheet] - .filter((s) => s && s.path && s.key && !this.textures.exists(s.key)); - - // Balatro drop-in spritesheets, same contract. - const balArt = this.cache.json.get('balatro-artwork'); - const balSheets = [balArt?.jokerSheet, balArt?.tarotSheet, balArt?.planetSheet, balArt?.spectralSheet, - balArt?.voucherSheet, balArt?.packSheet, balArt?.deckBackSheet, balArt?.bossIconSheet] - .filter((s) => s && s.path && s.key && !this.textures.exists(s.key)); - - if (toLoad.length > 0 || scSheets.length > 0 || dbSheets.length > 0 || swSheets.length > 0 || balSheets.length > 0) { + if (toLoad.length > 0) { for (const asset of toLoad) this.load.image(asset.key, asset.path); - for (const s of [...scSheets, ...dbSheets, ...swSheets, ...balSheets]) this.load.spritesheet(s.key, s.path, { frameWidth: s.frameWidth, frameHeight: s.frameHeight }); await new Promise((resolve) => { this.load.once('complete', resolve); this.load.start(); diff --git a/src/services/assetLoader.js b/src/services/assetLoader.js new file mode 100644 index 0000000..263a978 --- /dev/null +++ b/src/services/assetLoader.js @@ -0,0 +1,48 @@ +import { resolveGameAssets } from '../data/assetManifest.js'; + +// Lazy per-game asset loading. PreloadScene only loads shared assets at +// startup; each game's own art (see data/assetManifest.js) is loaded here, +// from GameRoomScene, the first time the user enters that game. + +function isLoaded(scene, d) { + if (d.type === 'audio') return scene.cache.audio.exists(d.key); + if (d.type === 'json') return scene.cache.json.exists(d.key); + return scene.textures.exists(d.key); +} + +export function missingGameAssets(scene, slug) { + return resolveGameAssets(scene, slug).filter((d) => !isLoaded(scene, d)); +} + +// Queue and load every missing asset for `slug` on the scene's loader. Call +// from create() only (the loader is idle there). Resolves once the load pass +// completes; a failed file logs a warning and the game's usual +// textures.exists fallback applies. +export function ensureGameAssets(scene, slug, { onProgress } = {}) { + const missing = missingGameAssets(scene, slug); + if (missing.length === 0) return Promise.resolve(false); + + for (const d of missing) { + if (d.type === 'spritesheet') { + scene.load.spritesheet(d.key, d.path, { frameWidth: d.frameWidth, frameHeight: d.frameHeight }); + } else if (d.type === 'audio') { + scene.load.audio(d.key, d.path); + } else if (d.type === 'json') { + scene.load.json(d.key, d.path); + } else { + scene.load.image(d.key, d.path); + } + } + + return new Promise((resolve) => { + const onError = (file) => console.warn(`[assets] failed to load ${file.key} (${file.src})`); + if (onProgress) scene.load.on('progress', onProgress); + scene.load.on('loaderror', onError); + scene.load.once('complete', () => { + if (onProgress) scene.load.off('progress', onProgress); + scene.load.off('loaderror', onError); + resolve(true); + }); + scene.load.start(); + }); +}