fertig-classic-games/src/games/skipbo/SkipBoGame.js

1109 lines
40 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}