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