feat: add per-game soundtrack system and Nintendo music for Super Kart

- Introduce soundtrack override system allowing games to define custom music
- Add Nintendo-themed soundtrack for Super Kart (2 tracks with metadata)
- Make main menu track configurable via data/music.json instead of hardcoded
- Add MusicPlayer volume parameter for per-soundtrack volume control
- Lazy-load per-game audio assets via assetManifest on game entry
- Add SPACE/X keyboard shortcuts to advance Super Kart menus
- Play kart-spinner SFX when player picks up an item in Super Kart
- Increase speech queue volume to 1.0
- Add Super Kart icons artwork and additional music assets
This commit is contained in:
Brian Fertig 2026-07-18 16:53:49 -06:00
parent 0109d33f02
commit e1a5c21180
21 changed files with 99 additions and 10 deletions

BIN
assets/fx/kart-spinner.mp3 Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -100,5 +100,10 @@
"artist": "Relaxin' Playing Fertig Games", "artist": "Relaxin' Playing Fertig Games",
"title": "Sublime" "title": "Sublime"
} }
] ],
"soundtracks": {
"main_menu": [
{ "file": "mainMenu.mp3" }
]
}
} }

15
data/nintendo-music.json Normal file
View File

@ -0,0 +1,15 @@
{
"tracks": [
{
"file": "nintendo-track01.mp3",
"artist": "Fireball",
"title": "Supernova Around the Sun"
},
{
"file": "nintendo-track02.mp3",
"artist": "Kona",
"title": "Pet me in the Scruffy"
}
],
"volume": 0.8
}

View File

@ -56,5 +56,5 @@
"speedway": { "key": "superkart-backdrop-speedway", "path": "assets/images/superkart/backdrop-speedway.png" }, "speedway": { "key": "superkart-backdrop-speedway", "path": "assets/images/superkart/backdrop-speedway.png" },
"nightcity": { "key": "superkart-backdrop-nightcity", "path": "assets/images/superkart/backdrop-nightcity.png" } "nightcity": { "key": "superkart-backdrop-nightcity", "path": "assets/images/superkart/backdrop-nightcity.png" }
}, },
"itemSheet": { "key": "superkart-items", "path": null, "frameWidth": 48, "frameHeight": 48 } "itemSheet": { "key": "superkart-items", "path": "assets/images/superkart/icons.png", "frameWidth": 48, "frameHeight": 48 }
} }

View File

@ -47,6 +47,16 @@ function imagesFrom(scene, jsonKey) {
.map((a) => ({ type: 'image', key: a.key, path: 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}` }));
}
const sheet = (key, path, frameWidth, frameHeight) => ({ type: 'spritesheet', key, path, frameWidth, frameHeight }); const sheet = (key, path, frameWidth, frameHeight) => ({ type: 'spritesheet', key, path, frameWidth, frameHeight });
const image = (key, path) => ({ type: 'image', key, path }); const image = (key, path) => ({ type: 'image', key, path });
@ -180,6 +190,9 @@ export const MANIFEST = {
.filter((b) => b && b.key && b.path) .filter((b) => b && b.key && b.path)
.map((b) => image(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'),
], ],
}; };

View File

@ -14,6 +14,7 @@ import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js'; import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { getGameSoundtrack } from '../../services/soundtrack.js';
import { playSound, SFX } from '../../ui/Sounds.js'; import { playSound, SFX } from '../../ui/Sounds.js';
import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js'; import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js';
import { enqueue as enqueueSpeech, resetQueue } from '../../ui/SpeechQueue.js'; import { enqueue as enqueueSpeech, resetQueue } from '../../ui/SpeechQueue.js';
@ -83,6 +84,7 @@ export default class SuperKartGame extends Phaser.Scene {
this.engineStartSound = null; this.engineStartSound = null;
this.engineTopSpeed = 0; this.engineTopSpeed = 0;
this.starSound = null; this.starSound = null;
this.advanceBtn = null;
} }
create() { create() {
@ -92,8 +94,8 @@ export default class SuperKartGame extends Phaser.Scene {
this.cups = this.cache.json.get('superkart-cups') ?? { cups: [], tracks: [] }; this.cups = this.cache.json.get('superkart-cups') ?? { cups: [], tracks: [] };
try { try {
const music = this.cache.json.get('music'); const { tracks, volume } = getGameSoundtrack(this);
if (music?.tracks) this.music = new MusicPlayer(this, music.tracks); if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
} catch (_) { /* optional */ } } catch (_) { /* optional */ }
this.crt = applyArcadeCRTOverlay(this, { accentTint: 0xffd028, curveAmount: 0.35 }); this.crt = applyArcadeCRTOverlay(this, { accentTint: 0xffd028, curveAmount: 0.35 });
@ -235,6 +237,8 @@ export default class SuperKartGame extends Phaser.Scene {
this.input.keyboard.on('keydown-ENTER', () => { this.input.keyboard.on('keydown-ENTER', () => {
if (this.victoryCamActive) this.skipVictoryCam(); if (this.victoryCamActive) this.skipVictoryCam();
}); });
this.input.keyboard.on('keydown-SPACE', () => this.advanceBtn?.emit('pointerup'));
this.input.keyboard.on('keydown-X', () => this.advanceBtn?.emit('pointerup'));
} }
readInputs() { readInputs() {
@ -253,6 +257,7 @@ export default class SuperKartGame extends Phaser.Scene {
clearView() { clearView() {
for (const o of this.viewObjs) o?.destroy(); for (const o of this.viewObjs) o?.destroy();
this.viewObjs = []; this.viewObjs = [];
this.advanceBtn = null;
} }
vAdd(obj) { vAdd(obj) {
@ -1024,7 +1029,9 @@ export default class SuperKartGame extends Phaser.Scene {
for (const ev of events) { for (const ev of events) {
switch (ev.type) { switch (ev.type) {
case 'go': playSound(this, SFX.COUNTDOWN_GO); break; case 'go': playSound(this, SFX.COUNTDOWN_GO); break;
case 'item-box': if (ev.kart === pIdx) playSound(this, SFX.UI_FLIP); break; case 'item-box':
if (ev.kart === pIdx) { playSound(this, SFX.UI_FLIP); playSound(this, SFX.KART_SPINNER); }
break;
case 'item-get': if (ev.kart === pIdx) playSound(this, SFX.UI_CHIME); break; case 'item-get': if (ev.kart === pIdx) playSound(this, SFX.UI_CHIME); break;
case 'item-use': case 'item-use':
if (ev.kart === pIdx) { if (ev.kart === pIdx) {
@ -1298,11 +1305,13 @@ export default class SuperKartGame extends Phaser.Scene {
const lastRace = this.gp.raceIdx >= this.gp.cup.tracks.length - 1; const lastRace = this.gp.raceIdx >= this.gp.cup.tracks.length - 1;
const btn = this.vAdd(new Button(this, GAME_WIDTH / 2, GAME_HEIGHT - 90, const btn = this.vAdd(new Button(this, GAME_WIDTH / 2, GAME_HEIGHT - 90,
lastRace ? 'Final Standings' : 'Next Race', () => { lastRace ? 'Final Standings' : 'Next Race', () => {
this.advanceBtn = null;
if (lastRace) this.showPodium(); if (lastRace) this.showPodium();
else { this.gp.raceIdx += 1; this.startCupRace(); } else { this.gp.raceIdx += 1; this.startCupRace(); }
}, { width: 340 })); }, { width: 340 }));
btn.setAlpha(0); btn.setAlpha(0);
this.tweens.add({ targets: btn, alpha: 1, duration: 300 }); this.tweens.add({ targets: btn, alpha: 1, duration: 300 });
this.advanceBtn = btn;
} }
// Small outward-bursting rectangle particles — shared by showCupStandings' // Small outward-bursting rectangle particles — shared by showCupStandings'

View File

@ -2,7 +2,7 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js'; import { auth } from '../services/auth.js';
import { getGame } from '../data/gamesRegistry.js'; import { getGame } from '../data/gamesRegistry.js';
import { stopMenuMusic } from '../ui/MenuMusic.js'; import { stopMenuMusic, configureMainMenuTrack } from '../ui/MenuMusic.js';
export default class PreloadScene extends Phaser.Scene { export default class PreloadScene extends Phaser.Scene {
constructor() { super('Preload'); } constructor() { super('Preload'); }
@ -43,6 +43,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('colored-playfields', 'data/colored-playfields.json'); this.load.json('colored-playfields', 'data/colored-playfields.json');
this.load.json('card-backs', 'data/card-backs.json'); this.load.json('card-backs', 'data/card-backs.json');
this.load.json('music', 'data/music.json'); this.load.json('music', 'data/music.json');
this.load.json('nintendo-music', 'data/nintendo-music.json');
this.load.json('rushhour', 'data/rushhour.json'); this.load.json('rushhour', 'data/rushhour.json');
this.load.json('puddingmonsters', 'data/puddingmonsters.json'); this.load.json('puddingmonsters', 'data/puddingmonsters.json');
this.load.json('shift-artwork', 'data/shift-artwork.json'); this.load.json('shift-artwork', 'data/shift-artwork.json');
@ -80,6 +81,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.audio('sfx-kart-hit', 'assets/fx/kart-hit.mp3'); this.load.audio('sfx-kart-hit', 'assets/fx/kart-hit.mp3');
this.load.audio('sfx-kart-shell', 'assets/fx/kart-shell.mp3'); this.load.audio('sfx-kart-shell', 'assets/fx/kart-shell.mp3');
this.load.audio('sfx-kart-star', 'assets/fx/kart-star.mp3'); this.load.audio('sfx-kart-star', 'assets/fx/kart-star.mp3');
this.load.audio('sfx-kart-spinner', 'assets/fx/kart-spinner.mp3');
this.load.audio('sfx-water-splash', 'assets/fx/water-splash.mp3'); this.load.audio('sfx-water-splash', 'assets/fx/water-splash.mp3');
this.load.audio('sfx-water-sink', 'assets/fx/water-sink.mp3'); this.load.audio('sfx-water-sink', 'assets/fx/water-sink.mp3');
this.load.audio('sfx-water-raise', 'assets/fx/water-raise.mp3'); this.load.audio('sfx-water-raise', 'assets/fx/water-raise.mp3');
@ -174,6 +176,10 @@ export default class PreloadScene extends Phaser.Scene {
// thumbnails in OpponentSelect, so they load at startup. Per-game drop-in // thumbnails in OpponentSelect, so they load at startup. Per-game drop-in
// artwork (shift/slots/spireclimb/dungeonboss/swdbg/balatro) is handled by // artwork (shift/slots/spireclimb/dungeonboss/swdbg/balatro) is handled by
// data/assetManifest.js and lazy-loaded on game entry instead. // data/assetManifest.js and lazy-loaded on game entry instead.
const musicData = this.cache.json.get('music');
const menuTrack = musicData?.soundtracks?.main_menu?.[0]?.file;
if (menuTrack) configureMainMenuTrack(menuTrack);
const pfd = this.cache.json.get('playfields'); const pfd = this.cache.json.get('playfields');
const cbd = this.cache.json.get('card-backs'); const cbd = this.cache.json.get('card-backs');
const toLoad = [ const toLoad = [

View File

@ -0,0 +1,31 @@
// Per-game music soundtrack overrides. Empty by default — every game plays
// the shared `default` soundtrack (data/music.json's `tracks` array),
// preloaded eagerly in PreloadScene exactly as before. To give a specific
// game its own soundtrack instead:
// 1. Add data/<name>-music.json: { "tracks": [{file, artist, title}, ...],
// "volume": 0.15 } — "volume" is optional (0-1, MusicPlayer's own
// default applies if omitted) and only affects this named soundtrack;
// the default soundtrack's volume is never touched by this mechanism.
// Actual mp3 files go at assets/music/<name>/...
// 2. Eager-load that JSON in PreloadScene (tiny metadata, mirrors the
// existing *-artwork.json convention) under cache key `<name>-music`.
// 3. In src/data/assetManifest.js, add `musicFrom(scene, '<name>-music')`
// to that game's MANIFEST entry so its audio bytes lazy-load only when
// the game is entered — never part of the shared default preload.
// 4. Add `<slug>: '<name>'` to GAME_SOUNDTRACK_OVERRIDES below.
// 5. In that game's scene, swap its MusicPlayer track-source line for:
// const { tracks, volume } = getGameSoundtrack(this);
// if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
export const GAME_SOUNDTRACK_OVERRIDES = {
superkart: 'nintendo',
};
// Resolve the track list (and optional volume override) a game scene's
// MusicPlayer should shuffle through. `volume` is undefined for the default
// soundtrack, so MusicPlayer's own default kicks in unchanged.
export function getGameSoundtrack(scene) {
const name = GAME_SOUNDTRACK_OVERRIDES[scene.gameDef?.slug];
if (!name) return { tracks: scene.cache.json.get('music')?.tracks ?? [], volume: undefined };
const data = scene.cache.json.get(`${name}-music`);
return { tracks: data?.tracks ?? [], volume: data?.volume };
}

View File

@ -1,8 +1,16 @@
let _audio = null; let _audio = null;
let _trackFile = 'mainMenu.mp3';
// Sourced from data/music.json's `soundtracks.main_menu` entry (see
// PreloadScene) so the menu theme is a real, named soundtrack rather than a
// hardcoded path. Falls back to the historical default if never configured.
export function configureMainMenuTrack(file) {
_trackFile = file;
}
function _getAudio() { function _getAudio() {
if (!_audio) { if (!_audio) {
_audio = new Audio('assets/music/mainMenu.mp3'); _audio = new Audio(`assets/music/${_trackFile}`);
_audio.loop = true; _audio.loop = true;
_audio.volume = 0.6; _audio.volume = 0.6;
} }

View File

@ -16,11 +16,12 @@ function shuffle(arr) {
} }
export class MusicPlayer { export class MusicPlayer {
constructor(scene, tracks) { constructor(scene, tracks, volume = 0.15) {
this.scene = scene; this.scene = scene;
this.tracks = shuffle(tracks); this.tracks = shuffle(tracks);
this.idx = 0; this.idx = 0;
this.muted = false; this.muted = false;
this.volume = volume;
this._audio = null; this._audio = null;
this._objs = []; this._objs = [];
@ -98,7 +99,7 @@ export class MusicPlayer {
const track = this.tracks[idx]; const track = this.tracks[idx];
this._audio = new Audio(`assets/music/${track.file}`); this._audio = new Audio(`assets/music/${track.file}`);
this._audio.muted = this.muted; this._audio.muted = this.muted;
this._audio.volume = 0.15; this._audio.volume = this.volume;
this._audio.play().catch(() => { }); this._audio.play().catch(() => { });
this._audio.onended = () => this.next(); this._audio.onended = () => this.next();
this._updateInfo(); this._updateInfo();

View File

@ -89,6 +89,7 @@ export const SFX = {
KART_HIT: 'sfx-kart-hit', KART_HIT: 'sfx-kart-hit',
KART_SHELL: 'sfx-kart-shell', KART_SHELL: 'sfx-kart-shell',
KART_STAR: 'sfx-kart-star', KART_STAR: 'sfx-kart-star',
KART_SPINNER: 'sfx-kart-spinner',
}; };
export function playSound(scene, key) { export function playSound(scene, key) {

View File

@ -16,7 +16,7 @@ function _playNext() {
const entry = _queue.shift(); const entry = _queue.shift();
_currentCbs = entry; _currentCbs = entry;
_currentAudio = new Audio(`assets/speech/${entry.filename}.mp3`); _currentAudio = new Audio(`assets/speech/${entry.filename}.mp3`);
_currentAudio.volume = 0.8; _currentAudio.volume = 1;
const done = () => { const done = () => {
entry.onEnd?.(); entry.onEnd?.();
_playing = false; _playing = false;