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:
parent
0109d33f02
commit
e1a5c21180
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.
|
|
@ -100,5 +100,10 @@
|
|||
"artist": "Relaxin' Playing Fertig Games",
|
||||
"title": "Sublime"
|
||||
}
|
||||
]
|
||||
],
|
||||
"soundtracks": {
|
||||
"main_menu": [
|
||||
{ "file": "mainMenu.mp3" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -56,5 +56,5 @@
|
|||
"speedway": { "key": "superkart-backdrop-speedway", "path": "assets/images/superkart/backdrop-speedway.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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,16 @@ function imagesFrom(scene, jsonKey) {
|
|||
.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 image = (key, path) => ({ type: 'image', key, path });
|
||||
|
||||
|
|
@ -180,6 +190,9 @@ export const MANIFEST = {
|
|||
.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'),
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { getGameSoundtrack } from '../../services/soundtrack.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.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.engineTopSpeed = 0;
|
||||
this.starSound = null;
|
||||
this.advanceBtn = null;
|
||||
}
|
||||
|
||||
create() {
|
||||
|
|
@ -92,8 +94,8 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
this.cups = this.cache.json.get('superkart-cups') ?? { cups: [], tracks: [] };
|
||||
|
||||
try {
|
||||
const music = this.cache.json.get('music');
|
||||
if (music?.tracks) this.music = new MusicPlayer(this, music.tracks);
|
||||
const { tracks, volume } = getGameSoundtrack(this);
|
||||
if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
|
||||
} catch (_) { /* optional */ }
|
||||
|
||||
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', () => {
|
||||
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() {
|
||||
|
|
@ -253,6 +257,7 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
clearView() {
|
||||
for (const o of this.viewObjs) o?.destroy();
|
||||
this.viewObjs = [];
|
||||
this.advanceBtn = null;
|
||||
}
|
||||
|
||||
vAdd(obj) {
|
||||
|
|
@ -1024,7 +1029,9 @@ export default class SuperKartGame extends Phaser.Scene {
|
|||
for (const ev of events) {
|
||||
switch (ev.type) {
|
||||
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-use':
|
||||
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 btn = this.vAdd(new Button(this, GAME_WIDTH / 2, GAME_HEIGHT - 90,
|
||||
lastRace ? 'Final Standings' : 'Next Race', () => {
|
||||
this.advanceBtn = null;
|
||||
if (lastRace) this.showPodium();
|
||||
else { this.gp.raceIdx += 1; this.startCupRace(); }
|
||||
}, { width: 340 }));
|
||||
btn.setAlpha(0);
|
||||
this.tweens.add({ targets: btn, alpha: 1, duration: 300 });
|
||||
this.advanceBtn = btn;
|
||||
}
|
||||
|
||||
// Small outward-bursting rectangle particles — shared by showCupStandings'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.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 {
|
||||
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('card-backs', 'data/card-backs.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('puddingmonsters', 'data/puddingmonsters.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-shell', 'assets/fx/kart-shell.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-sink', 'assets/fx/water-sink.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
|
||||
// artwork (shift/slots/spireclimb/dungeonboss/swdbg/balatro) is handled by
|
||||
// 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 cbd = this.cache.json.get('card-backs');
|
||||
const toLoad = [
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -1,8 +1,16 @@
|
|||
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() {
|
||||
if (!_audio) {
|
||||
_audio = new Audio('assets/music/mainMenu.mp3');
|
||||
_audio = new Audio(`assets/music/${_trackFile}`);
|
||||
_audio.loop = true;
|
||||
_audio.volume = 0.6;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,12 @@ function shuffle(arr) {
|
|||
}
|
||||
|
||||
export class MusicPlayer {
|
||||
constructor(scene, tracks) {
|
||||
constructor(scene, tracks, volume = 0.15) {
|
||||
this.scene = scene;
|
||||
this.tracks = shuffle(tracks);
|
||||
this.idx = 0;
|
||||
this.muted = false;
|
||||
this.volume = volume;
|
||||
this._audio = null;
|
||||
this._objs = [];
|
||||
|
||||
|
|
@ -98,7 +99,7 @@ export class MusicPlayer {
|
|||
const track = this.tracks[idx];
|
||||
this._audio = new Audio(`assets/music/${track.file}`);
|
||||
this._audio.muted = this.muted;
|
||||
this._audio.volume = 0.15;
|
||||
this._audio.volume = this.volume;
|
||||
this._audio.play().catch(() => { });
|
||||
this._audio.onended = () => this.next();
|
||||
this._updateInfo();
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export const SFX = {
|
|||
KART_HIT: 'sfx-kart-hit',
|
||||
KART_SHELL: 'sfx-kart-shell',
|
||||
KART_STAR: 'sfx-kart-star',
|
||||
KART_SPINNER: 'sfx-kart-spinner',
|
||||
};
|
||||
|
||||
export function playSound(scene, key) {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ function _playNext() {
|
|||
const entry = _queue.shift();
|
||||
_currentCbs = entry;
|
||||
_currentAudio = new Audio(`assets/speech/${entry.filename}.mp3`);
|
||||
_currentAudio.volume = 0.8;
|
||||
_currentAudio.volume = 1;
|
||||
const done = () => {
|
||||
entry.onEnd?.();
|
||||
_playing = false;
|
||||
|
|
|
|||
Loading…
Reference in New Issue