feat: add background music system and improve card game interactions

- Introduce `MusicPlayer` class for in-game music with shuffle, skip, and mute controls
- Add `MenuMusic` utility for background music in menu scenes
- Load music tracks and metadata via `music.json`
- Implement drag-and-drop for SkipBo cards with visual feedback
- Add card flight animations for Phase 10 AI actions and player draws
- Update Phase 10 AI turn timing and reduce delays for smoother gameplay
This commit is contained in:
Brian Fertig 2026-05-17 16:38:36 -06:00
parent 731937bb82
commit 366a6b095b
20 changed files with 493 additions and 12 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

29
public/data/music.json Normal file
View File

@ -0,0 +1,29 @@
{
"tracks": [
{
"file": "track01.mp3",
"artist": "Earthless",
"title": "Endless Spiral"
},
{
"file": "track02.mp3",
"artist": "Dire Straits",
"title": "Floating on the Waves"
},
{
"file": "track03.mp3",
"artist": "Tame Impala",
"title": "Dimensional Transition"
},
{
"file": "track04.mp3",
"artist": "Com Truise",
"title": "The Galaxy Arm"
},
{
"file": "track05.mp3",
"artist": "Guns 'n Roses",
"title": "Thunderbird"
}
]
}

View File

@ -9,6 +9,7 @@ import {
} from './BackgammonLogic.js';
import { chooseMoves } from './BackgammonAI.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
// ── Layout constants ──────────────────────────────────────────────────────────
const BX = 300; // board outer left
@ -72,6 +73,7 @@ export default class BackgammonGame extends Phaser.Scene {
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildParticleTexture();
this.buildPlayfield();
this.buildBoard();

View File

@ -5,6 +5,7 @@ import { Modal } from '../../ui/Modal.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { api } from '../../services/api.js';
import { playSound, playChipBet, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
buildShoe, handValue, isBlackjack, isBust, canDouble, canSplit,
createInitialState, prepareRound, applyBet, dealAllCards,
@ -54,6 +55,7 @@ export default class BlackjackGame extends Phaser.Scene {
}
async create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.shoe = buildShoe();
this.gs = null;
this.animating = false;

View File

@ -11,6 +11,7 @@ import {
import { chooseAction } from './HoldemAI.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { playSound, playChipBet, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
// ── Layout constants ──────────────────────────────────────────────────────────
const CX = GAME_WIDTH / 2; // 960
@ -74,6 +75,7 @@ export default class HoldemGame extends Phaser.Scene {
// ── Lifecycle ───────────────────────────────────────────────────────────────
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildPlayfield();
this.buildTable();
this.buildCommunitySlots();

View File

@ -10,6 +10,7 @@ import {
} from './ParchisiLogic.js';
import { chooseMoves } from './ParchisiAI.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
// ── Layout constants ────────────────────────────────────────────────────────
const CELL = 50;
@ -153,6 +154,7 @@ export default class ParchisiGame extends Phaser.Scene {
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildPlayfield();
this.buildBoard();
this.buildDice();

View File

@ -5,6 +5,7 @@ import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.
import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { PHASES, getPhase, validateLaydown } from './PhaseSpec.js';
import {
applyDrawFromDeck,
@ -157,6 +158,7 @@ export default class Phase10Game extends Phaser.Scene {
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildPlayfield();
this.assignSeats();
this.buildSeatAreas();
@ -1286,8 +1288,17 @@ export default class Phase10Game extends Phaser.Scene {
const next = applyDrawFromDeck(this.gs);
if (next === this.gs) return;
this.gs = next;
this.animating = true;
playSound(this, SFX.CARD_DEAL);
const layout = slotLayout('bottom');
const handLen = this.gs.players[0].hand.length;
const targetX = layout.handStart.x + (handLen - 1) * HAND_SPREAD;
this._flyCard(DRAW_POS.x, DRAW_POS.y, targetX, layout.handStart.y,
{ value: 'back', id: -1 }, { faceUp: false, duration: 450 }, () => {
this.animating = false;
this.renderAll();
this.setStatus('Drew from deck. Lay down, hit, or discard.');
});
}
onDiscardPileClick() {
@ -1308,8 +1319,17 @@ export default class Phase10Game extends Phaser.Scene {
const next = applyDrawFromDiscard(this.gs);
if (next === this.gs) return;
this.gs = next;
this.animating = true;
playSound(this, SFX.CARD_DEAL);
const layout = slotLayout('bottom');
const handLen = this.gs.players[0].hand.length;
const targetX = layout.handStart.x + (handLen - 1) * HAND_SPREAD;
this._flyCard(DISCARD_POS.x, DISCARD_POS.y, targetX, layout.handStart.y,
top, { faceUp: true, duration: 450 }, () => {
this.animating = false;
this.renderAll();
this.setStatus('Took from discard. Lay down, hit, or discard.');
});
}
}
@ -1490,7 +1510,7 @@ export default class Phase10Game extends Phaser.Scene {
const seat = this.gs.currentPlayer;
const name = this.opponents[seat - 1]?.name ?? `Player ${seat + 1}`;
this.setStatus(`${name}'s turn…`);
this.time.delayedCall(550, () => this.applyAIAction());
this.time.delayedCall(250, () => this.applyAIAction());
}
applyAIAction() {
@ -1498,7 +1518,13 @@ export default class Phase10Game extends Phaser.Scene {
if (this.gs.roundPhase !== 'play') { this.handleRoundEnd(); return; }
const action = chooseAction(this.gs);
if (!action) { this.runAIStep(); return; }
const seat = this.gs.currentPlayer;
const slot = this.slotForSeat[seat];
const layout = slotLayout(slot);
const apply = () => {
if (this.matchOver) return;
let next;
if (action.type === 'drawDeck') next = applyDrawFromDeck(this.gs);
else if (action.type === 'drawDiscard') next = applyDrawFromDiscard(this.gs);
@ -1507,7 +1533,6 @@ export default class Phase10Game extends Phaser.Scene {
else if (action.type === 'discard') next = applyDiscard(this.gs, action.handIdx, action.skipTargetSeat ?? null);
else next = this.gs;
if (next === this.gs) {
// Defensive — avoid infinite loop. Force discard the first card.
next = applyDiscard(this.gs, 0);
}
this.gs = next;
@ -1520,11 +1545,37 @@ export default class Phase10Game extends Phaser.Scene {
this.setStatus('Your turn — draw from deck or discard.');
return;
}
// Continue AI loop for the same player until they discard.
this.time.delayedCall(360, () => this.applyAIAction());
this.time.delayedCall(150, () => this.applyAIAction());
};
if (action.type === 'drawDeck') {
playSound(this, SFX.CARD_DEAL);
this._flyCard(DRAW_POS.x, DRAW_POS.y, layout.handStart.x, layout.handStart.y,
{ value: 'back', id: -1 }, { faceUp: false, rotation: layout.rotateCards, duration: 450 }, apply);
} else if (action.type === 'drawDiscard') {
const top = discardTop(this.gs);
playSound(this, SFX.CARD_DEAL);
this._flyCard(DISCARD_POS.x, DISCARD_POS.y, layout.handStart.x, layout.handStart.y,
top, { faceUp: true, rotation: layout.rotateCards, duration: 450 }, apply);
} else if (action.type === 'discard') {
const card = this.gs.players[seat].hand[action.handIdx];
playSound(this, SFX.CARD_PLACE);
this._flyCard(layout.handStart.x, layout.handStart.y, DISCARD_POS.x, DISCARD_POS.y,
card, { faceUp: true, rotation: layout.rotateCards, duration: 380 }, apply);
} else if (action.type === 'laydown') {
playSound(this, SFX.CARD_SHOW);
this._animateAILaydown(seat, layout, action.groups, apply);
} else if (action.type === 'hit') {
const card = this.gs.players[seat].hand[action.handIdx];
const gc = this._getGroupCenter({ targetSeat: action.targetSeat, groupIdx: action.groupIdx });
playSound(this, SFX.CARD_PLACE);
this._flyCard(layout.handStart.x, layout.handStart.y,
gc ? gc.x : CX, gc ? gc.y : CY,
card, { faceUp: true, rotation: layout.rotateCards, duration: 380 }, apply);
} else {
apply();
}
}
// ── Round / match end ───────────────────────────────────────────────────
@ -1646,6 +1697,39 @@ export default class Phase10Game extends Phaser.Scene {
}
}
// ── Card flight animation ────────────────────────────────────────────────
_flyCard(fromX, fromY, toX, toY, card, opts = {}, onComplete) {
const { faceUp = false, rotation = 0, duration = 420 } = opts;
const c = this.makeCardSprite(card, fromX, fromY, { faceUp, rotation });
c.setDepth(D.card + 5);
this.tweens.add({
targets: c, x: toX, y: toY, duration,
ease: 'Cubic.easeInOut',
onComplete: () => { c.destroy(); if (onComplete) onComplete(); },
});
}
_animateAILaydown(seat, layout, groups, onComplete) {
if (groups.length === 0) { onComplete(); return; }
const hand = this.gs.players[seat].hand;
let remaining = groups.length;
groups.forEach((group, gi) => {
const repCard = hand.find(c => c.id === group.cardIds[0]) ?? { value: 'back', id: -1 };
let tx = layout.laidStart.x;
let ty = layout.laidStart.y;
if (layout.laidAxis === 'x') tx += gi * 60;
else ty += gi * 60;
this.time.delayedCall(gi * 130, () => {
this._flyCard(layout.handStart.x, layout.handStart.y, tx, ty,
repCard, { faceUp: true, rotation: layout.rotateCards, duration: 380 }, () => {
remaining--;
if (remaining === 0) onComplete();
});
});
});
}
// ── HUD helpers ─────────────────────────────────────────────────────────
setStatus(s) {

View File

@ -5,6 +5,7 @@ import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.
import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
BUILD_PILE_COUNT,
DISCARD_PILE_COUNT,
@ -160,6 +161,8 @@ export default class SkipBoGame extends Phaser.Scene {
// Local interaction
this.selectedSource = null; // { kind: 'hand'|'stock'|'discard', idx? }
this.highlightObjs = [];
this.potentialDrag = null;
this.dragState = null;
this.handObjs = []; // seat → [container] for arranging
this.opponentPortraits = []; // seat → portrait controller (or null)
@ -168,11 +171,13 @@ export default class SkipBoGame extends Phaser.Scene {
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildPlayfield();
this.assignSeats();
this.buildSeatAreas();
this.buildCenter();
this.buildHUD();
this.setupDragHandlers();
this.startNewGame();
}
@ -294,6 +299,7 @@ export default class SkipBoGame extends Phaser.Scene {
// Clicking empty space deselects the current card
this.input.on('pointerdown', (pointer) => {
if (!this.selectedSource) return;
if (this.potentialDrag || this.dragState) return;
if (this.input.hitTestPointer(pointer).length === 0) {
this.selectedSource = null;
this.clearHighlights();
@ -306,6 +312,7 @@ export default class SkipBoGame extends Phaser.Scene {
startNewGame() {
if (this.animating) return;
this._clearDragState();
this.gameOver = false;
this.clearAllCardObjs();
this.clearHighlights();
@ -434,6 +441,7 @@ export default class SkipBoGame extends Phaser.Scene {
// ── Rendering ────────────────────────────────────────────────────────────
renderAll() {
this._clearDragState();
this.clearAllCardObjs();
this.renderCenter();
for (let seat = 0; seat < this.gs.players.length; seat++) {
@ -483,7 +491,7 @@ export default class SkipBoGame extends Phaser.Scene {
if (seat === 0) {
c.setInteractive(new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
c.input.cursor = 'pointer';
c.on('pointerdown', () => this.onStockClick());
c.on('pointerdown', (pointer) => this._onCardPointerDown('stock', 0, c, pointer));
}
}
@ -500,7 +508,7 @@ export default class SkipBoGame extends Phaser.Scene {
if (seat === 0) {
c.setInteractive(new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
c.input.cursor = 'pointer';
c.on('pointerdown', () => this.onDiscardClick(d));
c.on('pointerdown', (pointer) => this._onCardPointerDown('discard', d, c, pointer));
}
// Pile-depth chip if more than one card
if (pile.length > 1) {
@ -540,7 +548,7 @@ export default class SkipBoGame extends Phaser.Scene {
if (isLocal) {
c.setInteractive(new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H), Phaser.Geom.Rectangle.Contains);
c.input.cursor = 'pointer';
c.on('pointerdown', () => this.onHandClick(i));
c.on('pointerdown', (pointer) => this._onCardPointerDown('hand', i, c, pointer));
}
}
}
@ -660,6 +668,183 @@ export default class SkipBoGame extends Phaser.Scene {
this.highlightObjs = [];
}
// ── Drag and drop ────────────────────────────────────────────────────────
setupDragHandlers() {
this.input.on('pointermove', (pointer) => {
if (!pointer.isDown) return;
if (this.dragState) {
this._updateCardDrag(pointer);
} else if (this.potentialDrag) {
const dx = pointer.x - this.potentialDrag.startX;
const dy = pointer.y - this.potentialDrag.startY;
if (dx * dx + dy * dy > 64) this._promoteToDrag();
}
});
this.input.on('pointerup', () => {
if (this.dragState) {
this._endCardDrag();
} else if (this.potentialDrag) {
const pd = this.potentialDrag;
this.potentialDrag = null;
if (pd.kind === 'hand') this.onHandClick(pd.idx);
else if (pd.kind === 'stock') this.onStockClick();
else if (pd.kind === 'discard') this.onDiscardClick(pd.idx);
}
});
}
_onCardPointerDown(kind, idx, cardObj, pointer) {
if (!this.isLocalTurn() || this.animating || this.dragState) return;
this.potentialDrag = {
kind, idx, cardObj,
origX: cardObj.x, origY: cardObj.y,
startX: pointer.x, startY: pointer.y,
offsetX: pointer.x - cardObj.x,
offsetY: pointer.y - cardObj.y,
};
}
_promoteToDrag() {
const pd = this.potentialDrag;
this.potentialDrag = null;
this.selectedSource = null;
this.clearHighlights();
const { cardObj, origX, origY, offsetX, offsetY, kind, idx } = pd;
cardObj.setDepth(D.card + 10);
this.tweens.add({ targets: cardObj, scaleX: 1.08, scaleY: 1.08, duration: 100, ease: 'Cubic.easeOut' });
const shadow = this.add.ellipse(cardObj.x + 6, cardObj.y + 18, CARD_W * 1.05, 26, 0x000000, 0.35)
.setDepth(D.card + 9);
this.transientObjs.push(shadow);
this.dragState = { kind, idx, cardObj, origX, origY, offsetX, offsetY, shadow, dropTarget: null, highlight: null };
}
_updateCardDrag(pointer) {
const ds = this.dragState;
ds.cardObj.x = pointer.x - ds.offsetX;
ds.cardObj.y = pointer.y - ds.offsetY;
ds.shadow.setPosition(ds.cardObj.x + 6, ds.cardObj.y + 18);
const newTarget = this._getDropTargetAt(ds.cardObj.x, ds.cardObj.y, ds.kind, this._cardForDragState());
if (!this._dropTargetsEqual(newTarget, ds.dropTarget)) {
ds.dropTarget = newTarget;
this._updateDropHighlight(newTarget);
}
}
_cardForDragState() {
const ds = this.dragState;
if (!ds) return null;
if (ds.kind === 'hand') return this.gs.players[0].hand[ds.idx];
if (ds.kind === 'stock') return stockTop(this.gs, 0);
if (ds.kind === 'discard') return discardTop(this.gs, 0, ds.idx);
return null;
}
_getDropTargetAt(x, y, kind, card) {
if (!this.isLocalTurn() || this.animating || !card) return null;
for (let i = 0; i < BUILD_PILE_COUNT; i++) {
const obj = this.buildPileObjs[i];
if (Math.abs(x - obj.x) < CARD_W * 0.8 && Math.abs(y - obj.y) < CARD_H * 0.8) {
if (canPlayOnBuild(this.gs, card, i)) return { type: 'build', idx: i };
}
}
if (kind === 'hand') {
const discards = slotLayout(this.slotForSeat[0]).discards;
for (let d = 0; d < DISCARD_PILE_COUNT; d++) {
const pos = discards[d];
if (Math.abs(x - pos.x) < CARD_W * 0.8 && Math.abs(y - pos.y) < CARD_H * 0.8) {
return { type: 'discard', idx: d };
}
}
}
return null;
}
_dropTargetsEqual(a, b) {
if (!a && !b) return true;
if (!a || !b) return false;
return a.type === b.type && a.idx === b.idx;
}
_updateDropHighlight(dropTarget) {
const ds = this.dragState;
if (ds.highlight) {
const i = this.transientObjs.indexOf(ds.highlight);
if (i >= 0) this.transientObjs.splice(i, 1);
ds.highlight.destroy();
ds.highlight = null;
}
if (!dropTarget) return;
let x, y, color;
if (dropTarget.type === 'build') {
({ x, y } = this.buildPileObjs[dropTarget.idx]);
color = 0xffd700;
} else {
const pos = slotLayout(this.slotForSeat[0]).discards[dropTarget.idx];
x = pos.x; y = pos.y;
color = 0x4dabf7;
}
const h = this.add.rectangle(x, y, CARD_W + 20, CARD_H + 20, color, 0.22)
.setStrokeStyle(4, color, 1).setDepth(D.highlight);
ds.highlight = h;
this.transientObjs.push(h);
}
_endCardDrag() {
const ds = this.dragState;
this.dragState = null;
if (ds.highlight) { ds.highlight.destroy(); ds.highlight = null; }
if (ds.shadow) { ds.shadow.destroy(); ds.shadow = null; }
ds.cardObj.setDepth(D.card);
this.tweens.add({ targets: ds.cardObj, scaleX: 1, scaleY: 1, duration: 80 });
const card = (() => {
if (ds.kind === 'hand') return this.gs.players[0].hand[ds.idx];
if (ds.kind === 'stock') return stockTop(this.gs, 0);
if (ds.kind === 'discard') return discardTop(this.gs, 0, ds.idx);
return null;
})();
const target = this._getDropTargetAt(ds.cardObj.x, ds.cardObj.y, ds.kind, card);
if (target?.type === 'build') {
const action = { type: 'play', source: ds.kind, sourceIdx: ds.idx, buildIdx: target.idx };
if (card?.value === 'wild') action.asNumber = nextRequired(this.gs, target.idx);
this.commitPlay(action);
} else if (target?.type === 'discard') {
this.commitDiscard(ds.idx, target.idx);
} else {
this._returnCardToOrigin(ds.cardObj, ds.origX, ds.origY);
}
}
_returnCardToOrigin(cardObj, origX, origY) {
this.tweens.killTweensOf(cardObj);
this.tweens.add({
targets: cardObj, x: origX, y: origY, scaleX: 1, scaleY: 1,
duration: 280, ease: 'Back.easeOut',
});
}
_clearDragState() {
if (this.dragState) {
if (this.dragState.shadow) this.dragState.shadow.destroy();
if (this.dragState.highlight) this.dragState.highlight.destroy();
this.dragState = null;
}
this.potentialDrag = null;
}
// ── Action commit ───────────────────────────────────────────────────────
commitPlay(action) {

View File

@ -12,6 +12,7 @@ import {
} from './YatziLogic.js';
import { chooseDiceToHold, chooseCategory, shouldKeepRolling } from './YatziAI.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
// ─── Layout ───────────────────────────────────────────────────────────────
const LEFT_CX = 480;
@ -92,6 +93,7 @@ export default class YatziGame extends Phaser.Scene {
}
async create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(DEPTH.bg);
// Compose players: human first, then AI opponents

View File

@ -3,11 +3,13 @@ import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { api } from '../services/api.js';
import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js';
import { playMenuMusic } from '../ui/MenuMusic.js';
export default class GameMenuScene extends Phaser.Scene {
constructor() { super('GameMenu'); }
async create() {
playMenuMusic();
const cx = GAME_WIDTH / 2;
this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT);

View File

@ -2,11 +2,13 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js';
import { playMenuMusic } from '../ui/MenuMusic.js';
export default class LandingScene extends Phaser.Scene {
constructor() { super('Landing'); }
create() {
playMenuMusic();
const cx = GAME_WIDTH / 2;
this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT);

View File

@ -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 { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js';
// Option tile dimensions — playfield rows
const TILE_W = 190;
@ -32,6 +33,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
}
async create() {
playMenuMusic();
const cx = GAME_WIDTH / 2;
const bgKey = this.gameDef.category === 'casino' ? 'bg-casino' : 'bg-room';
@ -420,6 +422,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
startGame() {
if (this.selected.size === 0) return;
stopMenuMusic();
const opponents = this.cards
.filter(({ opp }) => this.selected.has(opp.id))
.map(({ opp }) => opp);

View File

@ -36,6 +36,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.image('main-title', '/assets/images/main-title.png');
this.load.json('playfields', '/data/playfields.json');
this.load.json('card-backs', '/data/card-backs.json');
this.load.json('music', '/data/music.json');
this.load.audio('sfx-card-deal', '/assets/fx/card-deal.mp3');
this.load.audio('sfx-card-place', '/assets/fx/card-place.mp3');

View File

@ -0,0 +1,20 @@
let _audio = null;
function _getAudio() {
if (!_audio) {
_audio = new Audio('/assets/music/mainMenu.mp3');
_audio.loop = true;
_audio.volume = 0.6;
}
return _audio;
}
export function playMenuMusic() {
const a = _getAudio();
if (!a.paused) return;
a.play().catch(() => {});
}
export function stopMenuMusic() {
if (_audio) _audio.pause();
}

View File

@ -0,0 +1,145 @@
import * as Phaser from 'phaser';
import { COLORS, GAME_WIDTH } from '../config.js';
const BTN = 32;
const GAP = 6;
const PAD = 12;
const DEPTH = 90;
function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
export class MusicPlayer {
constructor(scene, tracks) {
this.scene = scene;
this.tracks = shuffle(tracks);
this.idx = 0;
this.muted = false;
this._audio = null;
this._objs = [];
this._buildUI();
this._play(0);
scene.events.once('shutdown', () => this.destroy());
scene.events.once('destroy', () => this.destroy());
}
_buildUI() {
const s = this.scene;
const muteX = GAME_WIDTH - PAD - BTN / 2;
const skipX = muteX - GAP - BTN;
const btnY = PAD + BTN / 2;
const infoY = PAD + BTN + GAP + 8;
this._muteX = muteX;
this._muteY = btnY;
const skipBg = s.add.graphics().setDepth(DEPTH);
this._drawBg(skipBg, skipX, btnY, false);
skipBg.setInteractive(
new Phaser.Geom.Rectangle(skipX - BTN / 2, btnY - BTN / 2, BTN, BTN),
Phaser.Geom.Rectangle.Contains,
);
skipBg.input.cursor = 'pointer';
skipBg.on('pointerover', () => this._drawBg(skipBg, skipX, btnY, true));
skipBg.on('pointerout', () => this._drawBg(skipBg, skipX, btnY, false));
skipBg.on('pointerdown', () => this.next());
const skipIcon = s.add.text(skipX, btnY, '⏭', {
fontFamily: 'sans-serif', fontSize: '17px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH + 1);
const muteBg = s.add.graphics().setDepth(DEPTH);
this._drawBg(muteBg, muteX, btnY, false);
muteBg.setInteractive(
new Phaser.Geom.Rectangle(muteX - BTN / 2, btnY - BTN / 2, BTN, BTN),
Phaser.Geom.Rectangle.Contains,
);
muteBg.input.cursor = 'pointer';
muteBg.on('pointerover', () => this._drawBg(muteBg, muteX, btnY, true));
muteBg.on('pointerout', () => this._drawBg(muteBg, muteX, btnY, false));
muteBg.on('pointerdown', () => this.toggleMute());
this._muteIcon = s.add.text(muteX, btnY, '♪', {
fontFamily: 'sans-serif', fontSize: '17px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH + 1);
this._muteLine = s.add.graphics().setDepth(DEPTH + 2);
this._infoText = s.add.text(GAME_WIDTH - PAD, infoY, '', {
fontFamily: 'Righteous', fontSize: '13px', color: COLORS.mutedHex,
}).setOrigin(1, 0).setDepth(DEPTH);
this._objs.push(skipBg, skipIcon, muteBg, this._muteIcon, this._muteLine, this._infoText);
}
_drawBg(g, cx, cy, hover) {
g.clear();
g.fillStyle(COLORS.panel, 0.88);
g.fillRoundedRect(cx - BTN / 2, cy - BTN / 2, BTN, BTN, 6);
if (hover) {
g.lineStyle(1.5, COLORS.accent, 0.8);
g.strokeRoundedRect(cx - BTN / 2, cy - BTN / 2, BTN, BTN, 6);
}
}
_play(idx) {
if (this._audio) {
this._audio.pause();
this._audio.onended = null;
}
const track = this.tracks[idx];
this._audio = new Audio(`/assets/music/${track.file}`);
this._audio.muted = this.muted;
this._audio.volume = 0.7;
this._audio.play().catch(() => {});
this._audio.onended = () => this.next();
this._updateInfo();
}
_updateInfo() {
const t = this.tracks[this.idx];
this._infoText?.setText(`${t.artist}${t.title}`);
}
_updateMuteVisual() {
this._muteIcon?.setColor(this.muted ? COLORS.mutedHex : COLORS.textHex);
this._muteLine?.clear();
if (this.muted) {
const r = BTN / 2 - 5;
this._muteLine.lineStyle(2, COLORS.danger, 0.9);
this._muteLine.beginPath();
this._muteLine.moveTo(this._muteX + r, this._muteY - r);
this._muteLine.lineTo(this._muteX - r, this._muteY + r);
this._muteLine.strokePath();
}
}
next() {
this.idx = (this.idx + 1) % this.tracks.length;
this._play(this.idx);
}
toggleMute() {
this.muted = !this.muted;
if (this._audio) this._audio.muted = this.muted;
this._updateMuteVisual();
}
destroy() {
if (this._audio) {
this._audio.pause();
this._audio.onended = null;
this._audio = null;
}
for (const o of this._objs) { try { o.destroy(); } catch (_) {} }
this._objs = [];
}
}