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:
parent
731937bb82
commit
366a6b095b
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue