refactor: lazy-load per-game assets and enhance Balatro visuals
Introduce lazy loading for game-specific artwork to reduce startup time. Game assets are now defined in src/data/assetManifest.js and loaded on first entry via src/services/assetLoader.js, replacing the previous approach of loading all game art in PreloadScene. Balatro improvements: - Add bump/shake animation to chips and mult counters on score changes - Animate round score flying into the left-bar readout with explosion - Fix tween cleanup during view swaps to prevent crashes - Add planetSheet artwork path to balatro-artwork.json
This commit is contained in:
parent
271cbfe3da
commit
4fe8d0861d
Binary file not shown.
|
After Width: | Height: | Size: 496 KiB |
Binary file not shown.
|
|
@ -18,7 +18,7 @@
|
|||
},
|
||||
"planetSheet": {
|
||||
"key": "balatro-planets",
|
||||
"path": null,
|
||||
"path": "assets/images/balatro-planets.png",
|
||||
"frameWidth": 150,
|
||||
"frameHeight": 200
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue