fertig-classic-games/public/src/games/phase10/Phase10Game.js

1124 lines
41 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 { PHASES, getPhase } from './PhaseSpec.js';
import {
applyDrawFromDeck,
applyDrawFromDiscard,
applyLaydown,
applyHit,
applyDiscard,
cardPoints,
createInitialState,
discardTop,
getHitTargets,
runAssignedValues,
startNextRound,
} from './Phase10Logic.js';
import { chooseAction, findLaydown } from './Phase10AI.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 = 96;
const GROUP_GAP = 14;
const LAIDOWN_CARD_W = 60;
const LAIDOWN_CARD_H = 84;
const D = {
felt: -1, board: 0, pile: 5, card: 10, highlight: 20,
ui: 30, portrait: 35, chip: 40, banner: 60,
playbookPanel: 70, playbookTab: 71, modal: 80,
};
// Suit color tinting.
const SUIT = {
red: { fill: 0xfbe7e2, stroke: 0xc92a2a, num: '#c92a2a' },
blue: { fill: 0xe2ecfb, stroke: 0x2b7fbf, num: '#1c63a3' },
yellow: { fill: 0xfbf3d4, stroke: 0xc69a1f, num: '#a17a10' },
green: { fill: 0xdff3e3, stroke: 0x2f9e44, num: '#1c6b30' },
};
const WILD_FILL = 0xf2ead8;
const WILD_STRIPE = 0xc8a84b;
const SKIP_FILL = 0x1e1a12;
const SKIP_GLYPH = 0xe06c75;
// Seat slot mapping. Slot index in SLOTS_USED matches seat index.
const SLOTS_USED = {
2: ['bottom', 'top'],
3: ['bottom', 'left', 'right'],
4: ['bottom', 'left', 'top', 'right'],
};
function slotLayout(slot) {
switch (slot) {
case 'bottom':
return {
rotation: 0,
handStart: { x: 460, y: 990 },
handAxis: 'x',
handFaceUp: true,
laidStart: { x: 200, y: 820 },
laidAxis: 'x',
portrait: { x: 120, y: 990, r: 56 },
nameLabel: { x: 120, y: 1056 },
chip: { x: 240, y: 990 },
rotateCards: 0,
};
case 'top':
return {
rotation: 180,
handStart: { x: 460, y: 60 },
handAxis: 'x',
handFaceUp: false,
laidStart: { x: 200, y: 200 },
laidAxis: 'x',
portrait: { x: 120, y: 70, r: 50 },
nameLabel: { x: 120, y: 134 },
chip: { x: 240, y: 70 },
rotateCards: 180,
};
case 'left':
return {
rotation: 90,
handStart: { x: 60, y: 900 },
handAxis: 'y-up',
handFaceUp: false,
laidStart: { x: 200, y: 480 },
laidAxis: 'y',
portrait: { x: 100, y: 130, r: 50 },
nameLabel: { x: 100, y: 196 },
chip: { x: 220, y: 130 },
rotateCards: 90,
};
case 'right':
return {
rotation: 270,
handStart: { x: 1860, y: 900 },
handAxis: 'y-up',
handFaceUp: false,
laidStart: { x: 1720, y: 480 },
laidAxis: 'y',
portrait: { x: 1820, y: 130, r: 50 },
nameLabel: { x: 1820, y: 196 },
chip: { x: 1700, y: 130 },
rotateCards: 270,
};
default:
throw new Error(`Unknown slot: ${slot}`);
}
}
const DRAW_POS = { x: CX - 80, y: CY };
const DISCARD_POS = { x: CX + 80, y: CY };
// ── Scene ───────────────────────────────────────────────────────────────────
export default class Phase10Game extends Phaser.Scene {
constructor() { super('Phase10Game'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
this.gs = null;
this.animating = false;
this.matchOver = false;
this.cardObjs = new Map();
this.transientObjs = [];
this.highlightObjs = [];
this.opponentPortraits = [];
this.selectedHandIdx = null;
this.slotForSeat = [];
this.seatChips = []; // seat → { container, phaseText, scoreText, doneRibbon }
this.playbookOpen = false;
this.playbookPanel = null;
this.layDownBtn = null;
}
create() {
this.buildPlayfield();
this.assignSeats();
this.buildSeatAreas();
this.buildCenter();
this.buildPlaybook();
this.buildHUD();
this.startNewMatch();
}
// ── 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(`Phase 10 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);
// Portrait & name
if (seat === 0) {
createPlayerPortrait(this, layout.portrait.x, layout.portrait.y, layout.portrait.r, D.portrait, 'Phase10Game');
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);
}
}
// Phase chip + score badge anchored next to portrait.
this.seatChips[seat] = this.makeSeatChip(seat, layout.chip.x, layout.chip.y);
}
}
makeSeatChip(seat, x, y) {
const container = this.add.container(x, y).setDepth(D.chip);
const bg = this.add.graphics();
bg.fillStyle(COLORS.panel, 0.92);
bg.fillRoundedRect(0, -32, 280, 64, 10);
bg.lineStyle(2, COLORS.accent, 1);
bg.strokeRoundedRect(0, -32, 280, 64, 10);
container.add(bg);
const phaseText = this.add.text(14, -22, '', {
fontFamily: 'Righteous', fontSize: '16px', color: COLORS.goldHex,
});
const goalText = this.add.text(14, 0, '', {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.textHex, wordWrap: { width: 200 },
});
const scoreText = this.add.text(264, -22, '', {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.accentHex,
}).setOrigin(1, 0);
const scoreLabel = this.add.text(264, 4, 'pts', {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: COLORS.mutedHex,
}).setOrigin(1, 0);
const ribbon = this.add.text(140, 18, '', {
fontFamily: '"Julius Sans One"', fontSize: '12px',
color: COLORS.textDarkHex, backgroundColor: COLORS.goldHex,
padding: { x: 6, y: 2 },
}).setOrigin(0.5);
ribbon.setVisible(false);
container.add([phaseText, goalText, scoreText, scoreLabel, ribbon]);
return { container, phaseText, goalText, scoreText, ribbon };
}
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)
.setInteractive({ useHandCursor: true });
drawRect.on('pointerdown', () => this.onDrawDeckClick());
this.add.text(DRAW_POS.x, DRAW_POS.y - CARD_H/2 - 18, 'DRAW', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.drawPileText = this.add.text(DRAW_POS.x, DRAW_POS.y + CARD_H/2 + 16, '', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(D.ui);
this.drawPileRect = drawRect;
// Discard pile placeholder
const discRect = this.add.rectangle(DISCARD_POS.x, DISCARD_POS.y, CARD_W + 8, CARD_H + 8, 0x000000, 0.4)
.setStrokeStyle(2, COLORS.muted).setDepth(D.pile)
.setInteractive({ useHandCursor: true });
discRect.on('pointerdown', () => this.onDiscardPileClick());
this.add.text(DISCARD_POS.x, DISCARD_POS.y - CARD_H/2 - 18, 'DISCARD', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.discardPileRect = discRect;
}
buildPlaybook() {
// Vertical "ribbon" tab on the right edge.
const tabX = GAME_WIDTH - 22;
const tabY = CY;
const tab = this.add.container(tabX, tabY).setDepth(D.playbookTab);
const tabBg = this.add.graphics();
const drawTab = (color) => {
tabBg.clear();
tabBg.fillStyle(color, 1);
tabBg.fillRoundedRect(-18, -110, 36, 220, 8);
tabBg.lineStyle(2, COLORS.accent, 1);
tabBg.strokeRoundedRect(-18, -110, 36, 220, 8);
};
drawTab(COLORS.panel);
const tabText = this.add.text(0, 0, 'PHASES', {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5);
tabText.setRotation(-Math.PI / 2);
tab.add([tabBg, tabText]);
tab.setSize(36, 220);
tab.setInteractive(new Phaser.Geom.Rectangle(-18, -110, 36, 220), Phaser.Geom.Rectangle.Contains);
tab.input.cursor = 'pointer';
tab.on('pointerover', () => drawTab(COLORS.accent));
tab.on('pointerout', () => drawTab(this.playbookOpen ? COLORS.accent : COLORS.panel));
tab.on('pointerdown', () => this.togglePlaybook());
this.playbookTab = tab;
// Off-screen panel container — slides in from the right when opened.
const panelW = 480;
const panelH = 880;
const panelX = GAME_WIDTH + panelW / 2; // start fully off-screen
const panel = this.add.container(panelX, CY).setDepth(D.playbookPanel);
const pbg = this.add.graphics();
pbg.fillStyle(COLORS.panel, 0.97);
pbg.fillRoundedRect(-panelW / 2, -panelH / 2, panelW, panelH, 16);
pbg.lineStyle(3, COLORS.accent, 1);
pbg.strokeRoundedRect(-panelW / 2, -panelH / 2, panelW, panelH, 16);
panel.add(pbg);
const header = this.add.text(0, -panelH / 2 + 30, 'PHASE PLAYBOOK', {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex,
}).setOrigin(0.5);
panel.add(header);
const subhead = this.add.text(0, -panelH / 2 + 64, 'race to clear all ten', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0.5);
panel.add(subhead);
// 10 phase rows
this.playbookRows = [];
const rowH = 70;
const rowY0 = -panelH / 2 + 100;
for (let i = 0; i < PHASES.length; i++) {
const phase = PHASES[i];
const y = rowY0 + i * rowH;
const rowBg = this.add.graphics();
const drawRowBg = (highlight) => {
rowBg.clear();
rowBg.fillStyle(highlight ? COLORS.gold : COLORS.bg, highlight ? 0.2 : 0.6);
rowBg.fillRoundedRect(-panelW / 2 + 16, y - rowH / 2 + 4, panelW - 32, rowH - 8, 8);
if (highlight) {
rowBg.lineStyle(2, COLORS.gold, 0.8);
rowBg.strokeRoundedRect(-panelW / 2 + 16, y - rowH / 2 + 4, panelW - 32, rowH - 8, 8);
}
};
drawRowBg(false);
panel.add(rowBg);
const num = this.add.text(-panelW / 2 + 36, y - 14, `${phase.num}`, {
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex,
}).setOrigin(0, 0.5);
const goal = this.add.text(-panelW / 2 + 84, y - 14, phase.short, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex,
}).setOrigin(0, 0.5);
const dots = this.add.container(-panelW / 2 + 84, y + 16);
panel.add([num, goal, dots]);
this.playbookRows.push({ phase: phase.num, drawRowBg, dots });
}
panel.setSize(panelW, panelH);
this.playbookPanel = panel;
this.playbookPanelW = panelW;
this.playbookClosedX = panelX;
this.playbookOpenX = GAME_WIDTH - panelW / 2 - 40;
}
togglePlaybook() {
if (!this.playbookPanel) return;
const open = !this.playbookOpen;
this.playbookOpen = open;
this.tweens.add({
targets: this.playbookPanel,
x: open ? this.playbookOpenX : this.playbookClosedX,
duration: 260,
ease: 'Cubic.easeOut',
});
}
refreshPlaybook() {
if (!this.gs || !this.playbookRows) return;
for (const row of this.playbookRows) {
const isLocalCurrent = this.gs.players[0]?.phase === row.phase;
row.drawRowBg(isLocalCurrent);
row.dots.removeAll(true);
// Per-player chip showing where each seat is relative to this phase.
const dotR = 9;
const seatColors = [COLORS.gold, 0x4dabf7, 0xa78bfa, 0xff8a5b];
for (let s = 0; s < this.gs.players.length; s++) {
const p = this.gs.players[s];
const x = s * 26;
const onThisPhase = p.phase === row.phase && p.clearedAt == null;
const passed = p.phase > row.phase || (p.clearedAt != null && row.phase <= 10);
const color = seatColors[s % seatColors.length];
const dot = this.add.graphics();
if (passed) {
// dim filled dot
dot.fillStyle(color, 0.35);
dot.fillCircle(x, 0, dotR);
} else if (onThisPhase) {
dot.fillStyle(color, 1);
dot.fillCircle(x, 0, dotR);
dot.lineStyle(2, COLORS.text, 0.95);
dot.strokeCircle(x, 0, dotR);
} else {
dot.lineStyle(2, color, 0.6);
dot.strokeCircle(x, 0, dotR);
}
row.dots.add(dot);
// Seat initial label
const label = this.add.text(x, 0, s === 0 ? 'Y' : `${s + 1}`, {
fontFamily: '"Julius Sans One"', fontSize: '10px',
color: passed ? COLORS.mutedHex : COLORS.textDarkHex, fontStyle: 'bold',
}).setOrigin(0.5);
if (!onThisPhase) label.setColor(COLORS.mutedHex);
row.dots.add(label);
}
}
}
buildHUD() {
this.statusText = this.add.text(CX, 36, '', {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
new Button(this, 110, GAME_HEIGHT - 50, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 110, height: 40, fontSize: 18,
}).setDepth(D.ui);
new Button(this, 110, GAME_HEIGHT - 100, 'New', () => this.startNewMatch(), {
variant: 'ghost', width: 110, height: 40, fontSize: 18,
}).setDepth(D.ui);
// "Lay Down" button — hidden until the local player can lay down.
this.layDownBtn = new Button(this, CX, CY + 200, 'Lay Down Phase', () => this.onLayDownClick(), {
width: 280, height: 56, fontSize: 22,
}).setDepth(D.ui);
this.layDownBtn.setVisible(false);
}
// ── Match lifecycle ──────────────────────────────────────────────────────
startNewMatch() {
if (this.animating) return;
this.matchOver = false;
this.clearAllCardObjs();
this.clearHighlights();
this.selectedHandIdx = null;
const playerCount = this.slotForSeat.length;
this.gs = createInitialState({ playerCount });
this.renderAll();
this.setStatus('Your turn — draw from deck or discard.');
this.maybeStartAITurn();
}
// ── Card sprite factory ─────────────────────────────────────────────────
makeCardSprite(card, x, y, { faceUp = true, rotation = 0, scale = 1 } = {}) {
const c = this.add.container(x, y).setDepth(D.card);
c.setRotation((rotation * Math.PI) / 180);
c.setScale(scale);
this.renderCardFace(c, card, faceUp);
c.card = card;
return c;
}
renderCardFace(container, card, faceUp) {
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') {
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);
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);
container.add(this.add.text(0, 0, 'W', {
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.textDarkHex,
}).setOrigin(0.5));
return;
}
if (card.value === 'skip') {
g.fillStyle(SKIP_FILL, 1);
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
g.lineStyle(3, SKIP_GLYPH, 1);
g.strokeRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
// ⊘ glyph
g.lineStyle(5, SKIP_GLYPH, 1);
g.strokeCircle(0, 0, 26);
g.beginPath();
g.moveTo(-18, -18); g.lineTo(18, 18);
g.strokePath();
container.add(g);
container.add(this.add.text(0, 36, 'SKIP', {
fontFamily: 'Righteous', fontSize: '14px', color: COLORS.dangerHex,
}).setOrigin(0.5));
return;
}
// Numbered colored card
const suit = SUIT[card.color] ?? SUIT.red;
g.fillStyle(suit.fill, 1);
g.fillRoundedRect(x, y, CARD_W, CARD_H, CARD_R);
g.lineStyle(4, suit.stroke, 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: suit.num });
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.clearAllCardObjs();
this.renderCenter();
for (let seat = 0; seat < this.gs.players.length; seat++) {
this.renderSeat(seat);
}
this.renderSeatChips();
this.renderTurnIndicator();
this.refreshPlaybook();
this.refreshLayDownBtn();
}
renderCenter() {
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}`);
const top = discardTop(this.gs);
if (top) {
const c = this.makeCardSprite(top, DISCARD_POS.x, DISCARD_POS.y, { faceUp: true });
this.cardObjs.set(`discard-top-${top.id}`, c);
}
}
renderSeat(seat) {
const player = this.gs.players[seat];
const slot = this.slotForSeat[seat];
const layout = slotLayout(slot);
// Hand
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 {
x = layout.handStart.x;
y = layout.handStart.y - i * HAND_SPREAD;
}
const c = this.makeCardSprite(card, x, y, {
faceUp: layout.handFaceUp, rotation: layout.rotateCards,
});
this.cardObjs.set(`hand-${seat}-${card.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', () => this.onHandClick(i));
}
}
// Laid-down phase
if (player.laidDown) this.renderLaidDown(seat, player.laidDown);
}
renderLaidDown(seat, laid) {
const slot = this.slotForSeat[seat];
const layout = slotLayout(slot);
let cursorX = layout.laidStart.x;
let cursorY = layout.laidStart.y;
for (let gi = 0; gi < laid.length; gi++) {
const group = laid[gi];
const N = group.cards.length;
const groupW = N * (LAIDOWN_CARD_W * 0.75) + (LAIDOWN_CARD_W * 0.25);
// Determine run values for labelling
const runVals = group.kind === 'run' ? runAssignedValues(group) : null;
for (let ci = 0; ci < N; ci++) {
const card = group.cards[ci];
let x, y;
if (layout.laidAxis === 'x') {
x = cursorX + ci * (LAIDOWN_CARD_W * 0.75) + LAIDOWN_CARD_W / 2;
y = cursorY;
} else {
x = cursorX;
y = cursorY + ci * (LAIDOWN_CARD_H * 0.5) + LAIDOWN_CARD_H / 2;
}
const c = this.makeCardSprite(card, x, y, {
faceUp: true,
rotation: layout.rotateCards,
scale: LAIDOWN_CARD_W / CARD_W,
});
// If wild in a run, overlay its assigned value as a small chip.
if (card.value === 'wild' && runVals) {
const chip = this.add.text(x, y, `${runVals[ci]}`, {
fontFamily: 'Righteous', fontSize: '18px',
color: COLORS.textDarkHex, backgroundColor: COLORS.goldHex,
padding: { x: 4, y: 1 },
}).setOrigin(0.5).setDepth(D.card + 1);
this.transientObjs.push(chip);
}
this.cardObjs.set(`laid-${seat}-${gi}-${ci}-${card.id}`, c);
}
// Advance cursor for next group
if (layout.laidAxis === 'x') cursorX += groupW + GROUP_GAP;
else cursorY += N * (LAIDOWN_CARD_H * 0.5) + GROUP_GAP;
}
}
renderSeatChips() {
for (let s = 0; s < this.gs.players.length; s++) {
const p = this.gs.players[s];
const chip = this.seatChips[s];
if (!chip) continue;
if (p.clearedAt != null) {
chip.phaseText.setText('Phase 10 ✓');
chip.goalText.setText('cleared');
} else {
const ph = getPhase(p.phase);
chip.phaseText.setText(`Phase ${p.phase}`);
chip.goalText.setText(ph ? ph.short : '');
}
chip.scoreText.setText(`${p.score}`);
chip.ribbon.setVisible(!!p.laidDown);
chip.ribbon.setText('LAID DOWN');
}
}
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, 70, COLORS.accent, 0.18).setDepth(D.portrait - 1);
}
this.turnGlow.setPosition(lay.portrait.x, lay.portrait.y);
}
refreshLayDownBtn() {
if (!this.layDownBtn) return;
const isLocal = this.isLocalTurn();
const localPlayer = this.gs.players[0];
if (!isLocal || !this.gs.drawnThisTurn || localPlayer.laidDown) {
this.layDownBtn.setVisible(false);
return;
}
const layout = findLaydown(localPlayer.hand, localPlayer.phase);
this.layDownBtn.setVisible(!!layout);
}
// ── Local input ─────────────────────────────────────────────────────────
isLocalTurn() {
return this.gs && !this.matchOver && this.gs.roundPhase === 'play' && this.gs.currentPlayer === 0;
}
onDrawDeckClick() {
if (!this.isLocalTurn() || this.animating) return;
if (this.gs.drawnThisTurn) return;
const next = applyDrawFromDeck(this.gs);
if (next === this.gs) return;
this.gs = next;
this.renderAll();
this.setStatus('Drew from deck. Lay down, hit, or discard.');
}
onDiscardPileClick() {
if (!this.isLocalTurn() || this.animating) return;
// If we have a card selected and we've already drawn, discarding ends turn.
if (this.gs.drawnThisTurn && this.selectedHandIdx != null) {
const handIdx = this.selectedHandIdx;
this.selectedHandIdx = null;
this.clearHighlights();
this.commitDiscard(handIdx);
return;
}
// Otherwise, attempt draw from discard.
if (!this.gs.drawnThisTurn) {
const top = discardTop(this.gs);
if (!top) { this.setStatus('Discard pile is empty.'); return; }
if (top.value === 'skip') { this.setStatus("Can't draw a Skip from the discard pile."); return; }
const next = applyDrawFromDiscard(this.gs);
if (next === this.gs) return;
this.gs = next;
this.renderAll();
this.setStatus('Took from discard. Lay down, hit, or discard.');
}
}
onHandClick(handIdx) {
if (!this.isLocalTurn() || this.animating) return;
if (!this.gs.drawnThisTurn) {
this.setStatus('Draw a card first.');
return;
}
this.selectedHandIdx = handIdx;
this.highlightTargetsForCard();
const card = this.gs.players[0].hand[handIdx];
if (card.value === 'skip') {
this.setStatus('Click the discard pile to discard. Choose a target to skip.');
} else if (this.gs.players[0].laidDown) {
this.setStatus('Click a laid-down group to hit, or the discard pile to end turn.');
} else {
this.setStatus('Click the discard pile to end your turn.');
}
}
onLayDownClick() {
if (!this.isLocalTurn() || this.animating) return;
const player = this.gs.players[0];
if (player.laidDown) return;
const layout = findLaydown(player.hand, player.phase);
if (!layout) {
this.setStatus("You don't have the cards for this phase yet.");
return;
}
const groups = layout.map((g) => ({ kind: g.kind, cardIds: g.cards.map((c) => c.id) }));
const next = applyLaydown(this.gs, groups);
if (next === this.gs) {
this.setStatus("Couldn't lay down — invalid grouping.");
return;
}
this.gs = next;
this.selectedHandIdx = null;
this.clearHighlights();
this.renderAll();
this.setStatus('Laid down! Now hit or discard.');
}
highlightTargetsForCard() {
this.clearHighlights();
if (this.selectedHandIdx == null) return;
const card = this.gs.players[0].hand[this.selectedHandIdx];
// Discard pile target — always available (with a Skip-target picker if skip).
{
const h = this.add.rectangle(DISCARD_POS.x, DISCARD_POS.y, CARD_W + 18, CARD_H + 18, 0x4dabf7, 0.18)
.setStrokeStyle(3, 0x4dabf7, 0.9).setDepth(D.highlight);
this.highlightObjs.push(h);
}
// Hit targets — only if we've laid down.
if (this.gs.players[0].laidDown) {
const targets = getHitTargets(this.gs, card);
for (const t of targets) {
const rect = this.hitTargetHighlight(t);
if (rect) this.highlightObjs.push(rect);
}
}
}
hitTargetHighlight(target) {
const slot = this.slotForSeat[target.targetSeat];
const layout = slotLayout(slot);
const player = this.gs.players[target.targetSeat];
const laid = player.laidDown;
if (!laid) return null;
// Replicate cursor walk from renderLaidDown to find this group's centroid.
let cursorX = layout.laidStart.x;
let cursorY = layout.laidStart.y;
let groupCenter = null;
let lowX = 0, highX = 0;
for (let gi = 0; gi < laid.length; gi++) {
const group = laid[gi];
const N = group.cards.length;
const groupW = N * (LAIDOWN_CARD_W * 0.75) + (LAIDOWN_CARD_W * 0.25);
if (gi === target.groupIdx) {
if (layout.laidAxis === 'x') {
const cx = cursorX + groupW / 2;
groupCenter = { x: cx, y: cursorY, w: groupW + 6, h: LAIDOWN_CARD_H + 8 };
lowX = cursorX - 8;
highX = cursorX + groupW + 8;
} else {
const groupH = N * (LAIDOWN_CARD_H * 0.5) + (LAIDOWN_CARD_H * 0.5);
groupCenter = { x: cursorX, y: cursorY + groupH / 2, w: LAIDOWN_CARD_W + 8, h: groupH + 6 };
}
break;
}
if (layout.laidAxis === 'x') cursorX += groupW + GROUP_GAP;
else cursorY += N * (LAIDOWN_CARD_H * 0.5) + GROUP_GAP;
}
if (!groupCenter) return null;
// For runs with position, draw a small marker on the relevant end too.
const seatColor = 0xffd700;
const rect = this.add.rectangle(groupCenter.x, groupCenter.y, groupCenter.w, groupCenter.h, seatColor, 0.12)
.setStrokeStyle(2, seatColor, 0.9).setDepth(D.highlight)
.setInteractive({ useHandCursor: true });
rect.on('pointerdown', () => this.commitHit(target));
if (target.position && layout.laidAxis === 'x') {
const tip = this.add.text(target.position === 'low' ? lowX : highX, groupCenter.y, target.position === 'low' ? '◀' : '▶', {
fontFamily: 'Righteous', fontSize: '20px', color: '#ffd700',
}).setOrigin(0.5).setDepth(D.highlight);
this.transientObjs.push(tip);
}
return rect;
}
clearHighlights() {
for (const h of this.highlightObjs) h.destroy();
this.highlightObjs = [];
}
// ── Action commit (local) ───────────────────────────────────────────────
commitDiscard(handIdx) {
const card = this.gs.players[0].hand[handIdx];
if (card.value === 'skip') {
this.openSkipTargetModal(handIdx);
return;
}
const next = applyDiscard(this.gs, handIdx);
if (next === this.gs) { this.setStatus("Can't discard right now."); return; }
this.gs = next;
this.renderAll();
this.afterTurnTransition();
}
openSkipTargetModal(handIdx) {
const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
.setInteractive().setDepth(D.modal);
const w = 560, h = 280;
const panel = this.add.rectangle(CX, CY, w, h, COLORS.panel, 1)
.setStrokeStyle(2, COLORS.accent).setDepth(D.modal);
const title = this.add.text(CX, CY - h/2 + 40, 'Skip which player?', {
fontFamily: 'Righteous', fontSize: '28px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.modal);
const buttons = [];
const valid = [];
for (let s = 1; s < this.gs.players.length; s++) {
if (this.gs.players[s].skipped) continue;
valid.push(s);
}
if (valid.length === 0) {
// No valid target; just close and reset.
overlay.destroy(); panel.destroy(); title.destroy();
this.setStatus('No one to skip. Pick a different card to discard.');
this.selectedHandIdx = null;
this.clearHighlights();
return;
}
const startX = CX - ((valid.length - 1) * 180) / 2;
valid.forEach((s, i) => {
const opp = this.opponents[s - 1];
const name = opp?.name ?? `Player ${s + 1}`;
const btn = new Button(this, startX + i * 180, CY + 10, name, () => {
overlay.destroy(); panel.destroy(); title.destroy();
for (const b of buttons) b.destroy();
cancelBtn.destroy();
const next = applyDiscard(this.gs, handIdx, s);
if (next === this.gs) { this.setStatus("Can't skip that player."); return; }
this.gs = next;
this.renderAll();
this.afterTurnTransition();
}, { width: 160, height: 56, fontSize: 18 }).setDepth(D.modal);
buttons.push(btn);
});
const cancelBtn = new Button(this, CX, CY + h/2 - 40, 'Cancel', () => {
overlay.destroy(); panel.destroy(); title.destroy();
for (const b of buttons) b.destroy();
cancelBtn.destroy();
}, { variant: 'ghost', width: 160, height: 40, fontSize: 16 }).setDepth(D.modal);
}
commitHit(target) {
if (this.selectedHandIdx == null) return;
const handIdx = this.selectedHandIdx;
this.selectedHandIdx = null;
this.clearHighlights();
const next = applyHit(this.gs, handIdx, target.targetSeat, target.groupIdx, target.position);
if (next === this.gs) { this.setStatus("Can't hit there."); return; }
this.gs = next;
this.renderAll();
if (this.gs.roundPhase === 'roundOver' || this.gs.roundPhase === 'gameOver') {
this.handleRoundEnd();
return;
}
if (this.gs.players[0].hand.length === 0) {
this.handleRoundEnd();
return;
}
this.setStatus('Hit! Hit again or discard.');
}
afterTurnTransition() {
if (this.gs.roundPhase === 'roundOver' || this.gs.roundPhase === 'gameOver') {
this.handleRoundEnd();
return;
}
this.maybeStartAITurn();
}
maybeStartAITurn() {
if (this.gs.currentPlayer === 0) {
this.setStatus('Your turn — draw from deck or discard.');
return;
}
this.runAIStep();
}
// ── AI loop ─────────────────────────────────────────────────────────────
runAIStep() {
if (this.matchOver) return;
if (this.gs.roundPhase !== 'play') { this.handleRoundEnd(); return; }
if (this.gs.currentPlayer === 0) {
this.setStatus('Your turn — draw from deck or discard.');
return;
}
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());
}
applyAIAction() {
if (this.matchOver) return;
if (this.gs.roundPhase !== 'play') { this.handleRoundEnd(); return; }
const action = chooseAction(this.gs);
if (!action) { this.runAIStep(); return; }
const apply = () => {
let next;
if (action.type === 'drawDeck') next = applyDrawFromDeck(this.gs);
else if (action.type === 'drawDiscard') next = applyDrawFromDiscard(this.gs);
else if (action.type === 'laydown') next = applyLaydown(this.gs, action.groups);
else if (action.type === 'hit') next = applyHit(this.gs, action.handIdx, action.targetSeat, action.groupIdx, action.position);
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;
this.renderAll();
if (this.gs.roundPhase === 'roundOver' || this.gs.roundPhase === 'gameOver') {
this.handleRoundEnd();
return;
}
if (this.gs.currentPlayer === 0) {
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());
};
apply();
}
// ── Round / match end ───────────────────────────────────────────────────
handleRoundEnd() {
if (this.gs.roundPhase === 'gameOver') {
this.matchOver = true;
this.recordHistory();
this.showMatchEndPanel();
return;
}
// roundOver
this.showRoundSummaryPanel();
}
showRoundSummaryPanel() {
const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
.setInteractive().setDepth(D.modal);
const w = 720, h = 460;
const bg = this.add.rectangle(CX, CY, w, h, COLORS.panel, 1)
.setStrokeStyle(2, COLORS.accent).setDepth(D.modal);
const items = [overlay, bg];
const summary = this.gs.lastRoundSummary;
const winnerName = this.nameForSeat(summary.winnerSeat);
const title = this.add.text(CX, CY - h/2 + 40, `Round ${summary.roundNum}${winnerName} went out`, {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.modal);
items.push(title);
summary.rows.forEach((row, i) => {
const y = CY - h/2 + 100 + i * 50;
const name = this.nameForSeat(row.seat);
const status = row.cleared10 ? 'cleared phase 10!' : row.advanced ? `→ phase ${row.phaseNext}` : `still phase ${row.phaseNext}`;
const t = this.add.text(CX - w/2 + 36, y, `${name}`, {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.textHex,
}).setDepth(D.modal);
const sc = this.add.text(CX - 100, y, `+${row.pointsThisRound} (total ${row.totalScore})`, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setDepth(D.modal);
const ph = this.add.text(CX + 160, y, status, {
fontFamily: '"Julius Sans One"', fontSize: '18px',
color: row.advanced ? COLORS.goldHex : COLORS.dangerHex,
}).setDepth(D.modal);
items.push(t, sc, ph);
});
const tieNote = this.gs.matchTied ? 'Tie among phase 10 finishers — extra round!' : null;
if (tieNote) {
items.push(this.add.text(CX, CY + h/2 - 90, tieNote, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.modal));
}
const btn = new Button(this, CX, CY + h/2 - 50, 'Next round', () => {
for (const o of items) o.destroy();
btn.destroy();
this.gs = startNextRound(this.gs);
this.renderAll();
this.maybeStartAITurn();
}, { width: 240, height: 48, fontSize: 20 }).setDepth(D.modal);
}
showMatchEndPanel() {
const winner = this.gs.matchWinner;
const youWon = winner === 0;
const name = this.nameForSeat(winner);
const msg = youWon ? `You win!` : `${name} wins!`;
this.setStatus(msg);
for (let s = 1; s < this.gs.players.length; s++) {
const p = this.opponentPortraits[s];
if (!p) continue;
if (winner === s) p.playEmotion?.('win');
else p.playEmotion?.('loss');
}
const overlay = this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65)
.setInteractive().setDepth(D.modal);
const w = 720, h = 360;
this.add.rectangle(CX, CY, w, h, COLORS.panel, 1)
.setStrokeStyle(2, COLORS.accent).setDepth(D.modal);
this.add.text(CX, CY - h/2 + 56, msg, {
fontFamily: 'Righteous', fontSize: '42px',
color: youWon ? COLORS.goldHex : COLORS.textHex,
}).setOrigin(0.5).setDepth(D.modal);
const me = this.gs.players[0];
this.add.text(CX, CY - 10, `Final score: ${me.score} (lower is better)`, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.modal);
new Button(this, CX - 130, CY + h / 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 + h / 2 - 60, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 220, fontSize: 22 }).setDepth(D.modal);
}
async recordHistory() {
try {
const me = this.gs.players[0];
const youWon = this.gs.matchWinner === 0;
const opponentScores = [];
for (let s = 1; s < this.gs.players.length; s++) opponentScores.push(this.gs.players[s].score);
await api.post('/history/single-player', {
slug: 'phase10',
score: me.score, // lower is better, but the schema is generic — record raw score
opponentScores,
result: youWon ? 'win' : 'loss',
});
} catch (err) {
console.warn('[phase10] failed to record history', err);
}
}
// ── HUD helpers ─────────────────────────────────────────────────────────
setStatus(s) {
if (this.statusText) this.statusText.setText(s);
}
nameForSeat(seat) {
if (seat === 0) return auth.user?.username ?? 'You';
return this.opponents[seat - 1]?.name ?? `Player ${seat + 1}`;
}
}