1109 lines
40 KiB
JavaScript
1109 lines
40 KiB
JavaScript
import * as Phaser from 'phaser';
|
||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||
import { Button } from '../../ui/Button.js';
|
||
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
||
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,
|
||
HAND_SIZE,
|
||
STOCK_SIZE,
|
||
applyAutoPass,
|
||
applyDiscard,
|
||
applyPlay,
|
||
buildPileTopValue,
|
||
canPlayOnBuild,
|
||
createInitialState,
|
||
discardTop,
|
||
getValidPlays,
|
||
isPlayerStuck,
|
||
nextRequired,
|
||
stockTop,
|
||
} from './SkipBoLogic.js';
|
||
import { chooseAction } from './SkipBoAI.js';
|
||
|
||
// ── Layout constants ────────────────────────────────────────────────────────
|
||
const CX = GAME_WIDTH / 2;
|
||
const CY = GAME_HEIGHT / 2;
|
||
|
||
const CARD_W = 90;
|
||
const CARD_H = 126;
|
||
const CARD_R = 8;
|
||
const HAND_SPREAD = 110;
|
||
|
||
const D = {
|
||
felt: -1, board: 0, pile: 5, card: 10, highlight: 20,
|
||
ui: 30, portrait: 35, banner: 60, modal: 80,
|
||
};
|
||
|
||
// Rim color per number band so a glance reveals the top-of-pile value.
|
||
const NUMBER_RIM = {
|
||
1: 0x2b7fbf, 2: 0x2b7fbf, 3: 0x2b7fbf,
|
||
4: 0x2f9e44, 5: 0x2f9e44, 6: 0x2f9e44,
|
||
7: 0xe6b800, 8: 0xe6b800, 9: 0xe6b800,
|
||
10: 0xc92a2a, 11: 0xc92a2a, 12: 0xc92a2a,
|
||
};
|
||
const WILD_FILL = 0xf2ead8;
|
||
const WILD_STRIPE = 0xc8a84b;
|
||
|
||
// ── Seat anchors ────────────────────────────────────────────────────────────
|
||
// Indexed by SEAT slot (0=bottom local, 1=left, 2=top, 3=right). At runtime
|
||
// we map game-state seat indices to slots based on player count.
|
||
const SEAT_SLOTS = ['bottom', 'left', 'top', 'right'];
|
||
|
||
// Number of slot positions used per player count: always uses the bottom slot
|
||
// for the local player, then top/left/right in this order for opponents.
|
||
const SLOTS_USED = {
|
||
2: ['bottom', 'top'],
|
||
3: ['bottom', 'left', 'right'],
|
||
4: ['bottom', 'left', 'top', 'right'],
|
||
};
|
||
|
||
// ── Slot layout ─────────────────────────────────────────────────────────────
|
||
// Each slot returns positions for: stock, 4 discards, the hand row's start
|
||
// position (cards laid out along an axis), the portrait position, and the
|
||
// hand layout direction.
|
||
function slotLayout(slot) {
|
||
switch (slot) {
|
||
case 'bottom':
|
||
return {
|
||
rotation: 0,
|
||
stock: { x: 460, y: 950 },
|
||
discards: [{ x: 630, y: 950 }, { x: 750, y: 950 }, { x: 870, y: 950 }, { x: 990, y: 950 }],
|
||
handStart: { x: 1110, y: 975 },
|
||
handAxis: 'x',
|
||
portrait: { x: 320, y: 950, r: 60 },
|
||
nameLabel: { x: 320, y: 1030 },
|
||
stockCntPos: { x: 460, y: 855 },
|
||
rotateCards: 0,
|
||
outlineRotation: 0,
|
||
};
|
||
case 'top':
|
||
return {
|
||
rotation: 180,
|
||
stock: { x: 560, y: 130 },
|
||
discards: [{ x: 730, y: 130 }, { x: 850, y: 130 }, { x: 970, y: 130 }, { x: 1090, y: 130 }],
|
||
handStart: { x: 1000, y: 80 },
|
||
handAxis: 'x',
|
||
portrait: { x: 420, y: 130, r: 60 },
|
||
nameLabel: { x: 420, y: 210 },
|
||
stockCntPos: { x: 560, y: 225 },
|
||
rotateCards: 180,
|
||
outlineRotation: 0,
|
||
};
|
||
case 'left':
|
||
return {
|
||
rotation: 90,
|
||
stock: { x: 100, y: 260 },
|
||
discards: [{ x: 100, y: 430 }, { x: 100, y: 550 }, { x: 100, y: 670 }, { x: 100, y: 790 }],
|
||
handStart: { x: 40, y: 920 },
|
||
handAxis: 'y-up', // hand fans upward toward 920..520
|
||
portrait: { x: 100, y: 130, r: 56 },
|
||
nameLabel: { x: 100, y: 50 },
|
||
stockCntPos: { x: 205, y: 260 },
|
||
rotateCards: 90,
|
||
outlineRotation: 90,
|
||
};
|
||
case 'right':
|
||
return {
|
||
rotation: 270,
|
||
stock: { x: 1820, y: 260 },
|
||
discards: [{ x: 1820, y: 430 }, { x: 1820, y: 550 }, { x: 1820, y: 670 }, { x: 1820, y: 790 }],
|
||
handStart: { x: 1880, y: 920 },
|
||
handAxis: 'y-up',
|
||
portrait: { x: 1820, y: 130, r: 56 },
|
||
nameLabel: { x: 1820, y: 50 },
|
||
stockCntPos: { x: 1715, y: 260 },
|
||
rotateCards: 270,
|
||
outlineRotation: 90,
|
||
};
|
||
default:
|
||
throw new Error(`Unknown slot: ${slot}`);
|
||
}
|
||
}
|
||
|
||
// Center build piles
|
||
const BUILD_X0 = CX - (CARD_W + 28) * 1.5;
|
||
const BUILD_Y = CY + 30;
|
||
function buildPilePos(idx) {
|
||
return { x: BUILD_X0 + idx * (CARD_W + 28), y: BUILD_Y };
|
||
}
|
||
const DRAW_POS = { x: CX - 320, y: CY + 30 };
|
||
|
||
// ── Scene ───────────────────────────────────────────────────────────────────
|
||
export default class SkipBoGame extends Phaser.Scene {
|
||
constructor() { super('SkipBoGame'); }
|
||
|
||
init(data) {
|
||
this.gameDef = data.game;
|
||
this.opponents = data.opponents ?? [];
|
||
this.playfield = data.playfield ?? null;
|
||
this.cardBack = data.cardBack ?? null;
|
||
|
||
// Runtime state
|
||
this.gs = null;
|
||
this.animating = false;
|
||
this.gameOver = false;
|
||
|
||
// Card visuals: map card.id → Phaser.Container
|
||
this.cardObjs = new Map();
|
||
// Decorations cleared every renderAll (count chips, etc.)
|
||
this.transientObjs = [];
|
||
|
||
// Pile drop targets (rects + zones)
|
||
this.buildPileObjs = []; // [{ rect, label }]
|
||
this.discardPileObjs = []; // [seat][discIdx] → { rect }
|
||
this.stockPileObjs = []; // seat → { rect, count }
|
||
|
||
// 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)
|
||
|
||
// Seat slot mapping. Slot index in SLOTS_USED matches seat index.
|
||
this.slotForSeat = [];
|
||
}
|
||
|
||
create() {
|
||
new MusicPlayer(this, this.cache.json.get('music').tracks);
|
||
this.buildPlayfield();
|
||
this.assignSeats();
|
||
this.buildSeatAreas();
|
||
this.buildCenter();
|
||
this.buildHUD();
|
||
this.setupDragHandlers();
|
||
this.startNewGame();
|
||
}
|
||
|
||
// ── Setup ────────────────────────────────────────────────────────────────
|
||
|
||
buildPlayfield() {
|
||
const pf = this.playfield;
|
||
if (pf?.key && this.textures.exists(pf.key)) {
|
||
this.add.image(CX, CY, pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.felt);
|
||
} else {
|
||
const color = pf?.fallbackColor
|
||
? parseInt(pf.fallbackColor.replace('#', ''), 16) : 0x14532d;
|
||
this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, color).setDepth(D.felt);
|
||
}
|
||
}
|
||
|
||
assignSeats() {
|
||
const playerCount = 1 + this.opponents.length;
|
||
const slots = SLOTS_USED[playerCount];
|
||
if (!slots) throw new Error(`Skip-Bo needs 2..4 players, got ${playerCount}`);
|
||
this.slotForSeat = slots.slice();
|
||
}
|
||
|
||
buildSeatAreas() {
|
||
const playerCount = this.slotForSeat.length;
|
||
for (let seat = 0; seat < playerCount; seat++) {
|
||
const slot = this.slotForSeat[seat];
|
||
const layout = slotLayout(slot);
|
||
|
||
// Stock placeholder
|
||
const sRect = this.add.rectangle(layout.stock.x, layout.stock.y, CARD_W + 6, CARD_H + 6, 0x000000, 0.35)
|
||
.setStrokeStyle(2, COLORS.muted).setAngle(layout.outlineRotation).setDepth(D.pile);
|
||
// Stock count badge
|
||
this.add.rectangle(layout.stockCntPos.x, layout.stockCntPos.y, 52, 34, 0x000000, 0.55)
|
||
.setDepth(D.ui);
|
||
const sCnt = this.add.text(layout.stockCntPos.x, layout.stockCntPos.y, '0', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.accentHex, fontStyle: 'bold',
|
||
}).setOrigin(0.5).setDepth(D.ui + 1);
|
||
this.stockPileObjs[seat] = { rect: sRect, count: sCnt, x: layout.stock.x, y: layout.stock.y };
|
||
|
||
if (seat === 0) {
|
||
// Clickable for local player
|
||
sRect.setInteractive({ useHandCursor: true });
|
||
sRect.on('pointerdown', () => this.onStockClick());
|
||
}
|
||
|
||
// Discard placeholders
|
||
this.discardPileObjs[seat] = [];
|
||
for (let d = 0; d < DISCARD_PILE_COUNT; d++) {
|
||
const pos = layout.discards[d];
|
||
const r = this.add.rectangle(pos.x, pos.y, CARD_W + 6, CARD_H + 6, 0x000000, 0.25)
|
||
.setStrokeStyle(1, COLORS.muted).setAngle(layout.outlineRotation).setDepth(D.pile);
|
||
if (seat === 0) {
|
||
r.setInteractive({ useHandCursor: true });
|
||
r.on('pointerdown', () => this.onDiscardClick(d));
|
||
}
|
||
this.discardPileObjs[seat][d] = { rect: r, x: pos.x, y: pos.y };
|
||
}
|
||
|
||
// Portrait + name
|
||
if (seat === 0) {
|
||
createPlayerPortrait(this, layout.portrait.x, layout.portrait.y, layout.portrait.r, D.portrait, 'SkipBoGame');
|
||
this.add.text(layout.nameLabel.x, layout.nameLabel.y, auth.user?.username ?? 'You', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(D.ui);
|
||
} else {
|
||
const opp = this.opponents[seat - 1];
|
||
if (opp) {
|
||
this.opponentPortraits[seat] = createOpponentPortrait(this, opp, layout.portrait.x, layout.portrait.y, layout.portrait.r, D.portrait);
|
||
this.add.text(layout.nameLabel.x, layout.nameLabel.y, opp.name ?? `P${seat + 1}`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(D.ui);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
buildCenter() {
|
||
// Draw pile placeholder
|
||
const drawRect = this.add.rectangle(DRAW_POS.x, DRAW_POS.y, CARD_W + 8, CARD_H + 8, 0x000000, 0.4)
|
||
.setStrokeStyle(2, COLORS.accent).setDepth(D.pile);
|
||
this.add.text(DRAW_POS.x, DRAW_POS.y - CARD_H / 2 - 18, 'DRAW', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||
}).setOrigin(0.5).setDepth(D.ui);
|
||
this.drawPileText = this.add.text(DRAW_POS.x, DRAW_POS.y, '', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.accentHex, fontStyle: 'bold',
|
||
}).setOrigin(0.5).setDepth(D.ui);
|
||
this.drawPileRect = drawRect;
|
||
|
||
// Build piles
|
||
for (let i = 0; i < BUILD_PILE_COUNT; i++) {
|
||
const p = buildPilePos(i);
|
||
const r = this.add.rectangle(p.x, p.y, CARD_W + 8, CARD_H + 8, 0x000000, 0.3)
|
||
.setStrokeStyle(2, COLORS.muted).setDepth(D.pile)
|
||
.setInteractive({ useHandCursor: true });
|
||
r.on('pointerdown', () => this.onBuildPileClick(i));
|
||
const label = this.add.text(p.x, p.y - CARD_H / 2 - 18, `BUILD ${i + 1}`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
||
}).setOrigin(0.5).setDepth(D.ui);
|
||
const needText = this.add.text(p.x, p.y + CARD_H / 2 + 18, 'next: 1', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.accentHex,
|
||
}).setOrigin(0.5).setDepth(D.ui);
|
||
this.buildPileObjs[i] = { rect: r, label, needText, x: p.x, y: p.y };
|
||
}
|
||
}
|
||
|
||
buildHUD() {
|
||
this.statusText = this.add.text(CX, 36, '', {
|
||
fontFamily: 'Righteous', fontSize: '28px', color: COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(D.ui);
|
||
|
||
new Button(this, 110, GAME_HEIGHT - 50, 'New', () => this.startNewGame(), {
|
||
variant: 'ghost', width: 110, height: 40, fontSize: 18,
|
||
}).setDepth(D.ui);
|
||
new Button(this, 110, GAME_HEIGHT - 100, 'Leave', () => this.scene.start('GameMenu'), {
|
||
variant: 'ghost', width: 110, height: 40, fontSize: 18,
|
||
}).setDepth(D.ui);
|
||
|
||
// 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();
|
||
this.setStatus('Your turn');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Game start ───────────────────────────────────────────────────────────
|
||
|
||
startNewGame() {
|
||
if (this.animating) return;
|
||
this._clearDragState();
|
||
this.gameOver = false;
|
||
this.clearAllCardObjs();
|
||
this.clearHighlights();
|
||
this.selectedSource = null;
|
||
|
||
const playerCount = this.slotForSeat.length;
|
||
this.gs = createInitialState({ playerCount });
|
||
playSound(this, SFX.CARD_SHUFFLE);
|
||
|
||
this.renderAll();
|
||
this.setStatus('Your turn');
|
||
|
||
if (this.gs.currentPlayer !== 0) {
|
||
this.runAITurn();
|
||
}
|
||
}
|
||
|
||
// ── Card sprite factory ─────────────────────────────────────────────────
|
||
|
||
makeCardSprite(card, x, y, { faceUp = true, rotation = 0 } = {}) {
|
||
const c = this.add.container(x, y).setDepth(D.card);
|
||
c.setRotation((rotation * Math.PI) / 180);
|
||
this.renderCardFace(c, card, faceUp);
|
||
c.card = card;
|
||
return c;
|
||
}
|
||
|
||
renderCardFace(container, card, faceUp) {
|
||
// Remove existing children safely
|
||
container.removeAll(true);
|
||
const x = -CARD_W / 2, y = -CARD_H / 2;
|
||
const g = this.add.graphics();
|
||
|
||
if (!faceUp) {
|
||
if (this.cardBack?.spriteIndex !== undefined && this.textures.exists('cardbacks')) {
|
||
g.destroy();
|
||
container.add(
|
||
this.add.image(0, 0, 'cardbacks', this.cardBack.spriteIndex)
|
||
.setDisplaySize(CARD_W, CARD_H)
|
||
.setOrigin(0.5)
|
||
);
|
||
} else {
|
||
this.drawCardBackGfx(g, x, y);
|
||
container.add(g);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (card.value === 'wild') {
|
||
// Wild face — gold stripes + "SKIP-BO" text
|
||
g.fillStyle(WILD_FILL, 1);
|
||
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
|
||
g.lineStyle(3, WILD_STRIPE, 1);
|
||
g.strokeRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
|
||
// Diagonal stripes — clipped to card bounds
|
||
g.lineStyle(2, WILD_STRIPE, 0.5);
|
||
for (let s = -CARD_H; s < CARD_W; s += 14) {
|
||
const t0 = Math.max(0, -s / CARD_H);
|
||
const t1 = Math.min(1, (CARD_W - s) / CARD_H);
|
||
if (t0 >= t1) continue;
|
||
g.beginPath();
|
||
g.moveTo(x + s + t0 * CARD_H, y + CARD_H * (1 - t0));
|
||
g.lineTo(x + s + t1 * CARD_H, y + CARD_H * (1 - t1));
|
||
g.strokePath();
|
||
}
|
||
container.add(g);
|
||
|
||
if (card.playedAs) {
|
||
// Big played-as number overlaid on a chip
|
||
const chip = this.add.graphics();
|
||
chip.fillStyle(0x1a1208, 0.9);
|
||
chip.fillRoundedRect(-26, -22, 52, 44, 8);
|
||
chip.lineStyle(2, WILD_STRIPE, 1);
|
||
chip.strokeRoundedRect(-26, -22, 52, 44, 8);
|
||
container.add(chip);
|
||
container.add(this.add.text(0, 0, `${card.playedAs}`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '32px',
|
||
color: COLORS.accentHex, fontStyle: 'bold',
|
||
}).setOrigin(0.5));
|
||
} else {
|
||
container.add(this.add.text(0, -8, 'SKIP', {
|
||
fontFamily: 'Righteous', fontSize: '18px', color: '#1a1208',
|
||
}).setOrigin(0.5));
|
||
container.add(this.add.text(0, 14, 'BO', {
|
||
fontFamily: 'Righteous', fontSize: '18px', color: '#1a1208',
|
||
}).setOrigin(0.5));
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Numbered face
|
||
const rim = NUMBER_RIM[card.value] ?? 0x888888;
|
||
g.fillStyle(0xfbf6e7, 1);
|
||
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
|
||
g.lineStyle(4, rim, 1);
|
||
g.strokeRoundedRect(x + 2, y + 2, CARD_W - 4, CARD_H - 4, CARD_R - 1);
|
||
container.add(g);
|
||
|
||
const numStyle = (sz) => ({
|
||
fontFamily: 'Righteous', fontSize: `${sz}px`, color: '#1a1208',
|
||
});
|
||
container.add(this.add.text(x + 8, y + 6, `${card.value}`, numStyle(20)));
|
||
container.add(this.add.text(0, 0, `${card.value}`, numStyle(46)).setOrigin(0.5));
|
||
container.add(this.add.text(x + CARD_W - 8, y + CARD_H - 8, `${card.value}`,
|
||
numStyle(20)).setOrigin(1, 1));
|
||
}
|
||
|
||
drawCardBackGfx(g, x, y) {
|
||
const color = this.cardBack?.fallbackColor
|
||
? parseInt(this.cardBack.fallbackColor.replace('#', ''), 16) : 0x1a3a6b;
|
||
g.fillStyle(color, 1);
|
||
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
|
||
g.lineStyle(2, COLORS.accent, 0.6);
|
||
g.strokeRoundedRect(x + 6, y + 6, CARD_W - 12, CARD_H - 12, CARD_R - 2);
|
||
g.lineStyle(1, 0xffffff, 0.15);
|
||
g.strokeRoundedRect(x + 10, y + 10, CARD_W - 20, CARD_H - 20, CARD_R - 4);
|
||
}
|
||
|
||
clearAllCardObjs() {
|
||
for (const c of this.cardObjs.values()) c.destroy();
|
||
this.cardObjs.clear();
|
||
for (const o of this.transientObjs) o.destroy();
|
||
this.transientObjs = [];
|
||
}
|
||
|
||
// ── Rendering ────────────────────────────────────────────────────────────
|
||
|
||
renderAll() {
|
||
this._clearDragState();
|
||
this.clearAllCardObjs();
|
||
this.renderCenter();
|
||
for (let seat = 0; seat < this.gs.players.length; seat++) {
|
||
this.renderSeat(seat);
|
||
}
|
||
this.renderTurnIndicator();
|
||
}
|
||
|
||
renderCenter() {
|
||
// Draw pile — show a card back representing the deck size
|
||
const remaining = this.gs.drawPile.length;
|
||
if (remaining > 0) {
|
||
const c = this.makeCardSprite({ value: 'back', id: -1 }, DRAW_POS.x, DRAW_POS.y, { faceUp: false });
|
||
c.card = null;
|
||
this.cardObjs.set('draw', c);
|
||
}
|
||
this.drawPileText.setText(`${remaining}`);
|
||
|
||
// Build piles
|
||
for (let i = 0; i < BUILD_PILE_COUNT; i++) {
|
||
const pile = this.gs.buildPiles[i];
|
||
const obj = this.buildPileObjs[i];
|
||
const top = pile.length > 0 ? pile[pile.length - 1] : null;
|
||
if (top) {
|
||
const c = this.makeCardSprite(top, obj.x, obj.y, { faceUp: true });
|
||
this.cardObjs.set(`build-${i}-${top.id}`, c);
|
||
}
|
||
const need = nextRequired(this.gs, i);
|
||
obj.needText.setText(need > 12 ? '—' : `next: ${need}`);
|
||
}
|
||
}
|
||
|
||
renderSeat(seat) {
|
||
const slot = this.slotForSeat[seat];
|
||
const layout = slotLayout(slot);
|
||
const player = this.gs.players[seat];
|
||
|
||
// Stock — show the top card if any
|
||
const stockCnt = this.stockPileObjs[seat].count;
|
||
stockCnt.setText(`${player.stock.length}`);
|
||
const top = stockTop(this.gs, seat);
|
||
if (top) {
|
||
const c = this.makeCardSprite(top, layout.stock.x, layout.stock.y, {
|
||
faceUp: true, rotation: layout.rotateCards,
|
||
});
|
||
this.cardObjs.set(`stock-${seat}-${top.id}`, c);
|
||
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', (pointer) => this._onCardPointerDown('stock', 0, c, pointer));
|
||
}
|
||
}
|
||
|
||
// Discards — show top of each
|
||
for (let d = 0; d < DISCARD_PILE_COUNT; d++) {
|
||
const pile = player.discards[d];
|
||
if (pile.length === 0) continue;
|
||
const t = pile[pile.length - 1];
|
||
const pos = layout.discards[d];
|
||
const c = this.makeCardSprite(t, pos.x, pos.y, {
|
||
faceUp: true, rotation: layout.rotateCards,
|
||
});
|
||
this.cardObjs.set(`discard-${seat}-${d}-${t.id}`, c);
|
||
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', (pointer) => this._onCardPointerDown('discard', d, c, pointer));
|
||
}
|
||
// Pile-depth chip if more than one card
|
||
if (pile.length > 1) {
|
||
const chip = this.add.text(pos.x + CARD_W / 2 - 4, pos.y + CARD_H / 2 - 4, `×${pile.length}`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '14px',
|
||
color: COLORS.textDarkHex, backgroundColor: COLORS.accentHex,
|
||
padding: { x: 4, y: 2 },
|
||
}).setOrigin(1, 1).setDepth(D.ui);
|
||
this.transientObjs.push(chip);
|
||
}
|
||
}
|
||
|
||
// Hand
|
||
this.renderHand(seat);
|
||
}
|
||
|
||
renderHand(seat) {
|
||
const slot = this.slotForSeat[seat];
|
||
const layout = slotLayout(slot);
|
||
const player = this.gs.players[seat];
|
||
const isLocal = seat === 0;
|
||
|
||
for (let i = 0; i < player.hand.length; i++) {
|
||
const card = player.hand[i];
|
||
let x, y;
|
||
if (layout.handAxis === 'x') {
|
||
x = layout.handStart.x + i * HAND_SPREAD;
|
||
y = layout.handStart.y;
|
||
} else { // 'y-up'
|
||
x = layout.handStart.x;
|
||
y = layout.handStart.y - i * HAND_SPREAD;
|
||
}
|
||
const c = this.makeCardSprite(card, x, y, {
|
||
faceUp: isLocal, rotation: layout.rotateCards,
|
||
});
|
||
this.cardObjs.set(`hand-${seat}-${card.id}`, c);
|
||
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', (pointer) => this._onCardPointerDown('hand', i, c, pointer));
|
||
}
|
||
}
|
||
}
|
||
|
||
renderTurnIndicator() {
|
||
const seat = this.gs.currentPlayer;
|
||
const slot = this.slotForSeat[seat];
|
||
const lay = slotLayout(slot);
|
||
if (!this.turnGlow) {
|
||
this.turnGlow = this.add.circle(0, 0, 80, COLORS.accent, 0.18).setDepth(D.portrait - 1);
|
||
}
|
||
this.turnGlow.setPosition(lay.portrait.x, lay.portrait.y);
|
||
}
|
||
|
||
// ── Local input ─────────────────────────────────────────────────────────
|
||
|
||
isLocalTurn() {
|
||
return this.gs && !this.gameOver && this.gs.phase === 'play' && this.gs.currentPlayer === 0;
|
||
}
|
||
|
||
onHandClick(handIdx) {
|
||
if (!this.isLocalTurn() || this.animating) return;
|
||
this.selectedSource = { kind: 'hand', idx: handIdx };
|
||
this.highlightValidTargets();
|
||
this.setStatus('Choose a build pile, or click a discard pile to end your turn.');
|
||
}
|
||
|
||
onStockClick() {
|
||
if (!this.isLocalTurn() || this.animating) return;
|
||
this.selectedSource = { kind: 'stock' };
|
||
this.highlightValidTargets();
|
||
this.setStatus('Choose a build pile for your stock top.');
|
||
}
|
||
|
||
onDiscardClick(discardIdx) {
|
||
if (!this.isLocalTurn() || this.animating) return;
|
||
// If we have a selected hand card, treat this as a discard-to-end-turn.
|
||
if (this.selectedSource?.kind === 'hand') {
|
||
const handIdx = this.selectedSource.idx;
|
||
this.selectedSource = null;
|
||
this.clearHighlights();
|
||
this.commitDiscard(handIdx, discardIdx);
|
||
return;
|
||
}
|
||
// Otherwise treat as selecting that discard top as a source.
|
||
const top = discardTop(this.gs, 0, discardIdx);
|
||
if (!top) return;
|
||
this.selectedSource = { kind: 'discard', idx: discardIdx };
|
||
this.highlightValidTargets();
|
||
this.setStatus('Choose a build pile for the discard top.');
|
||
}
|
||
|
||
onBuildPileClick(buildIdx) {
|
||
if (!this.isLocalTurn() || this.animating) return;
|
||
const sel = this.selectedSource;
|
||
if (!sel) {
|
||
this.setStatus('Select a card first (hand, stock top, or discard top).');
|
||
return;
|
||
}
|
||
const card = this.cardForSelection(sel);
|
||
if (!card) { this.selectedSource = null; return; }
|
||
if (!canPlayOnBuild(this.gs, card, buildIdx)) {
|
||
this.setStatus("Can't play there.");
|
||
return;
|
||
}
|
||
const play = this.buildPlayObject(sel, buildIdx, card);
|
||
if (card.value === 'wild') {
|
||
play.asNumber = nextRequired(this.gs, buildIdx);
|
||
}
|
||
this.selectedSource = null;
|
||
this.clearHighlights();
|
||
this.commitPlay(play);
|
||
}
|
||
|
||
cardForSelection(sel) {
|
||
if (sel.kind === 'hand') return this.gs.players[0].hand[sel.idx];
|
||
if (sel.kind === 'stock') return stockTop(this.gs, 0);
|
||
if (sel.kind === 'discard') return discardTop(this.gs, 0, sel.idx);
|
||
return null;
|
||
}
|
||
|
||
buildPlayObject(sel, buildIdx, card) {
|
||
return {
|
||
type: 'play',
|
||
source: sel.kind,
|
||
sourceIdx: sel.kind === 'hand' ? sel.idx : (sel.kind === 'discard' ? sel.idx : 0),
|
||
buildIdx,
|
||
};
|
||
}
|
||
|
||
highlightValidTargets() {
|
||
this.clearHighlights();
|
||
const sel = this.selectedSource;
|
||
const card = this.cardForSelection(sel);
|
||
if (!card) return;
|
||
for (let i = 0; i < BUILD_PILE_COUNT; i++) {
|
||
if (canPlayOnBuild(this.gs, card, i)) {
|
||
const obj = this.buildPileObjs[i];
|
||
const h = this.add.rectangle(obj.x, obj.y, CARD_W + 18, CARD_H + 18, 0xffd700, 0.18)
|
||
.setStrokeStyle(3, 0xffd700, 0.9).setDepth(D.highlight);
|
||
this.highlightObjs.push(h);
|
||
}
|
||
}
|
||
// Highlight discard piles only when a hand card is selected (turn-ender)
|
||
if (sel.kind === 'hand') {
|
||
for (let d = 0; d < DISCARD_PILE_COUNT; d++) {
|
||
const pos = slotLayout(this.slotForSeat[0]).discards[d];
|
||
const h = this.add.rectangle(pos.x, pos.y, CARD_W + 18, CARD_H + 18, 0x4dabf7, 0.12)
|
||
.setStrokeStyle(2, 0x4dabf7, 0.7).setDepth(D.highlight);
|
||
this.highlightObjs.push(h);
|
||
}
|
||
}
|
||
}
|
||
|
||
clearHighlights() {
|
||
for (const h of this.highlightObjs) h.destroy();
|
||
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) {
|
||
this.animating = true;
|
||
const beforeSeat = this.gs.currentPlayer;
|
||
const next = applyPlay(this.gs, action);
|
||
this.animatePlay(this.gs, next, action, () => {
|
||
this.gs = next;
|
||
this.renderAll();
|
||
this.animating = false;
|
||
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
||
if (this.checkForStuck()) return;
|
||
// After a successful play it's still the same player's turn until they
|
||
// discard. If AI's turn, continue.
|
||
if (this.gs.currentPlayer !== 0) {
|
||
this.runAITurn();
|
||
} else {
|
||
this.setStatus("Play more, or end your turn by discarding.");
|
||
}
|
||
});
|
||
}
|
||
|
||
commitDiscard(handIdx, discardIdx) {
|
||
this.animating = true;
|
||
const next = applyDiscard(this.gs, handIdx, discardIdx);
|
||
this.animateDiscard(this.gs, next, handIdx, discardIdx, () => {
|
||
this.gs = next;
|
||
this.renderAll();
|
||
this.animating = false;
|
||
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
||
if (this.checkForStuck()) return;
|
||
if (this.gs.currentPlayer !== 0) {
|
||
this.runAITurn();
|
||
} else {
|
||
this.setStatus('Your turn');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Animation ───────────────────────────────────────────────────────────
|
||
// Cheap approach: identify the source card object before mutating, tween it
|
||
// to the destination, then on complete fully re-render from the next state.
|
||
|
||
animatePlay(before, after, action, done) {
|
||
const fromKey = this.sourceKey(before, action);
|
||
const target = this.cardObjs.get(fromKey);
|
||
const dest = buildPilePos(action.buildIdx);
|
||
if (!target) { done(); return; }
|
||
playSound(this, SFX.CARD_PLACE);
|
||
this.tweens.add({
|
||
targets: target,
|
||
x: dest.x, y: dest.y, scale: 1,
|
||
duration: 280, ease: 'Cubic.easeOut',
|
||
onComplete: done,
|
||
});
|
||
}
|
||
|
||
animateDiscard(before, after, handIdx, discardIdx, done) {
|
||
const card = before.players[before.currentPlayer].hand[handIdx];
|
||
const fromKey = `hand-${before.currentPlayer}-${card.id}`;
|
||
const target = this.cardObjs.get(fromKey);
|
||
const layout = slotLayout(this.slotForSeat[before.currentPlayer]);
|
||
const dest = layout.discards[discardIdx];
|
||
if (!target) { done(); return; }
|
||
playSound(this, SFX.CARD_PLACE);
|
||
this.tweens.add({
|
||
targets: target, x: dest.x, y: dest.y,
|
||
duration: 240, ease: 'Cubic.easeOut',
|
||
onComplete: done,
|
||
});
|
||
}
|
||
|
||
sourceKey(state, action) {
|
||
const seat = state.currentPlayer;
|
||
if (action.source === 'stock') {
|
||
const top = stockTop(state, seat);
|
||
return top ? `stock-${seat}-${top.id}` : null;
|
||
}
|
||
if (action.source === 'hand') {
|
||
const c = state.players[seat].hand[action.sourceIdx];
|
||
return c ? `hand-${seat}-${c.id}` : null;
|
||
}
|
||
if (action.source === 'discard') {
|
||
const c = discardTop(state, seat, action.sourceIdx);
|
||
return c ? `discard-${seat}-${action.sourceIdx}-${c.id}` : null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── AI loop ─────────────────────────────────────────────────────────────
|
||
|
||
runAITurn() {
|
||
if (this.gameOver) return;
|
||
const seat = this.gs.currentPlayer;
|
||
const slot = this.slotForSeat[seat];
|
||
const name = (this.opponents[seat - 1]?.name) ?? `Player ${seat + 1}`;
|
||
this.setStatus(`${name}'s turn…`);
|
||
this.time.delayedCall(450, () => this.stepAI());
|
||
}
|
||
|
||
stepAI() {
|
||
if (this.gameOver) return;
|
||
if (this.gs.currentPlayer === 0) {
|
||
this.setStatus('Your turn');
|
||
return;
|
||
}
|
||
const action = chooseAction(this.gs);
|
||
if (!action) {
|
||
// Defensive — shouldn't happen
|
||
this.setStatus('AI passed.');
|
||
return;
|
||
}
|
||
if (action.type === 'play') {
|
||
this.animating = true;
|
||
const seat = this.gs.currentPlayer;
|
||
if (action.source === 'stock') {
|
||
this.opponentPortraits[seat]?.playEmotion('happy');
|
||
}
|
||
const next = applyPlay(this.gs, action);
|
||
this.animatePlay(this.gs, next, action, () => {
|
||
this.gs = next;
|
||
this.renderAll();
|
||
this.animating = false;
|
||
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
||
if (this.checkForStuck()) return;
|
||
this.time.delayedCall(420, () => this.stepAI());
|
||
});
|
||
} else {
|
||
this.animating = true;
|
||
const next = applyDiscard(this.gs, action.handIdx, action.discardIdx);
|
||
this.animateDiscard(this.gs, next, action.handIdx, action.discardIdx, () => {
|
||
this.gs = next;
|
||
this.renderAll();
|
||
this.animating = false;
|
||
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
||
if (this.checkForStuck()) return;
|
||
if (this.gs.currentPlayer === 0) {
|
||
this.setStatus('Your turn');
|
||
} else {
|
||
this.runAITurn();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── Stuck / auto-pass ───────────────────────────────────────────────────
|
||
|
||
checkForStuck() {
|
||
if (!isPlayerStuck(this.gs)) return false;
|
||
const seat = this.gs.currentPlayer;
|
||
const name = seat === 0
|
||
? (auth.user?.username ?? 'You')
|
||
: (this.opponents[seat - 1]?.name ?? `Player ${seat + 1}`);
|
||
const subject = seat === 0 ? 'You have' : `${name} has`;
|
||
this.setStatus(`${subject} no moves — passing turn.`);
|
||
this.animating = true;
|
||
this.time.delayedCall(1400, () => {
|
||
this.gs = applyAutoPass(this.gs);
|
||
this.renderAll();
|
||
this.animating = false;
|
||
if (this.gs.phase === 'gameover') { this.endGame(); return; }
|
||
if (this.checkForStuck()) return;
|
||
if (this.gs.currentPlayer !== 0) {
|
||
this.runAITurn();
|
||
} else {
|
||
this.setStatus('Your turn');
|
||
}
|
||
});
|
||
return true;
|
||
}
|
||
|
||
// ── Endgame ─────────────────────────────────────────────────────────────
|
||
|
||
endGame() {
|
||
this.gameOver = true;
|
||
const winnerSeat = this.gs.winner;
|
||
|
||
if (winnerSeat === -1) {
|
||
this.setStatus("It's a draw!");
|
||
for (let s = 1; s < this.gs.players.length; s++) {
|
||
this.opponentPortraits[s]?.playEmotion?.('loss');
|
||
}
|
||
this.recordHistory(false);
|
||
this.showGameOverPanel("It's a draw!", false, null, 'No player could make a move.');
|
||
return;
|
||
}
|
||
|
||
const youWon = winnerSeat === 0;
|
||
const name = winnerSeat === 0
|
||
? (auth.user?.username ?? 'You')
|
||
: (this.opponents[winnerSeat - 1]?.name ?? `Player ${winnerSeat + 1}`);
|
||
const msg = youWon ? `You win!` : `${name} wins!`;
|
||
this.setStatus(msg);
|
||
|
||
// Emotion reactions for AI portraits
|
||
for (let s = 1; s < this.gs.players.length; s++) {
|
||
const p = this.opponentPortraits[s];
|
||
if (!p) continue;
|
||
if (winnerSeat === s) p.playEmotion?.('win');
|
||
else p.playEmotion?.('loss');
|
||
}
|
||
|
||
this.recordHistory(youWon);
|
||
this.showGameOverPanel(msg, youWon, name, youWon
|
||
? 'You emptied your stock first.'
|
||
: `${name} emptied their stock first.`);
|
||
}
|
||
|
||
showGameOverPanel(msg, youWon, name, subtitle) {
|
||
const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65)
|
||
.setInteractive().setDepth(D.modal);
|
||
const panelW = 720;
|
||
const panelH = 320;
|
||
this.add.rectangle(CX, CY, panelW, panelH, COLORS.panel, 1)
|
||
.setStrokeStyle(2, COLORS.accent).setDepth(D.modal);
|
||
this.add.text(CX, CY - panelH / 2 + 56, msg, {
|
||
fontFamily: 'Righteous', fontSize: '42px',
|
||
color: youWon ? COLORS.goldHex : COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(D.modal);
|
||
this.add.text(CX, CY - 10, subtitle, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
||
}).setOrigin(0.5).setDepth(D.modal);
|
||
|
||
new Button(this, CX - 130, CY + panelH / 2 - 60, 'Play again', () => {
|
||
overlay.destroy();
|
||
this.scene.restart({
|
||
game: this.gameDef, opponents: this.opponents,
|
||
playfield: this.playfield, cardBack: this.cardBack,
|
||
});
|
||
}, { width: 220, fontSize: 22 }).setDepth(D.modal);
|
||
new Button(this, CX + 130, CY + panelH / 2 - 60, 'Leave',
|
||
() => this.scene.start('GameMenu'),
|
||
{ variant: 'ghost', width: 220, fontSize: 22 }).setDepth(D.modal);
|
||
}
|
||
|
||
async recordHistory(youWon) {
|
||
try {
|
||
const localStockRemaining = this.gs.players[0].stock.length;
|
||
const opponentScores = [];
|
||
for (let s = 1; s < this.gs.players.length; s++) {
|
||
opponentScores.push(this.gs.players[s].stock.length);
|
||
}
|
||
// Score = (STOCK_SIZE - cards remaining in your stock); higher = better
|
||
const score = STOCK_SIZE - localStockRemaining;
|
||
await api.post('/history/single-player', {
|
||
slug: 'skipbo',
|
||
score,
|
||
opponentScores,
|
||
result: youWon ? 'win' : 'loss',
|
||
});
|
||
} catch (err) {
|
||
console.warn('[skipbo] failed to record history', err);
|
||
}
|
||
}
|
||
|
||
// ── HUD helpers ─────────────────────────────────────────────────────────
|
||
|
||
setStatus(s) {
|
||
if (this.statusText) this.statusText.setText(s);
|
||
}
|
||
}
|