import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { Button } from '../../ui/Button.js'; import { auth } from '../../services/auth.js'; import { api } from '../../services/api.js'; import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js'; import { CATEGORIES, UPPER, LOWER, CATEGORY_LABELS, createInitialState, rollDice, toggleHold, commitScore, computeTotals, baseScore, scoreForCommit, legalCategories, getWinners, } from './YatziLogic.js'; import { chooseDiceToHold, chooseCategory, shouldKeepRolling } from './YatziAI.js'; // ─── Layout ─────────────────────────────────────────────────────────────── const LEFT_CX = 480; const DICE_SIZE = 104; const DICE_GAP = 22; const DICE_TOTAL_W = 5 * DICE_SIZE + 4 * DICE_GAP; // 608 const DICE_LEFT = LEFT_CX - DICE_TOTAL_W / 2; // 176 const DICE_Y = 480; // Scorecard panel on the right half const SC_X = 980; // left edge of panel const SC_RIGHT_MARGIN = 40; const SC_W = GAME_WIDTH - SC_X - SC_RIGHT_MARGIN; // 900 const SC_CAT_W = 220; const SC_PLAYERS_W = SC_W - SC_CAT_W; // shared among N player columns const SC_TOP = 60; const SC_HEADER_H = 50; const SC_ROW_H = 42; const SC_SUMMARY_H = 40; const SC_GRAND_H = 54; // Portrait column const PORTRAIT_X = 90; const PORTRAIT_R = 48; const PORTRAIT_TOP = 200; const PORTRAIT_GAP = 124; const DEPTH = { bg: -1, panel: 0, grid: 1, cellBg: 2, cellText: 3, preview: 4, hover: 5, dieBox: 10, diePip: 11, dieFrame: 12, dieHit: 13, ui: 20, toast: 50, modal: 60, }; // 3x3 pip layout (col, row offsets in {-1, 0, 1}) per face const PIP_POS = { 1: [[0, 0]], 2: [[-1, -1], [1, 1]], 3: [[-1, -1], [0, 0], [1, 1]], 4: [[-1, -1], [1, -1], [-1, 1], [1, 1]], 5: [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]], 6: [[-1, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [1, 1]], }; const ROW_INDEX = (() => { // y-offset of each category row from top of its section const m = {}; UPPER.forEach((c, i) => { m[c] = i; }); LOWER.forEach((c, i) => { m[c] = i; }); return m; })(); export default class YatziGame extends Phaser.Scene { constructor() { super('YatziGame'); } init(data) { this.gameDef = data.game; this.opponents = data.opponents ?? []; this.playfield = data.playfield ?? null; this.gs = null; this.animating = false; this.aiRunning = false; this.gameOverShown = false; this.dieEls = []; // [{ frame: Graphics, face: Graphics, hit: Zone, currentFace }] this.cellText = {}; // `${pi}-${cat}` → Text this.cellHit = {}; // `${pi}-${cat}` → Zone this.cellHoverGfx = null; // single hover highlight rect this.columnHeaderText = []; // [Text per player] this.activeColumnGfx = null; this.summaryRefs = []; // [{ upperSub, upperBonus, lowerSub, yahtzBonus, grand }] this.portraitCtrls = []; // [{ ring, controller }] this.rollBtn = null; this.rollsText = null; this.statusText = null; this.toastText = null; } async create() { this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(DEPTH.bg); // Compose players: human first, then AI opponents const playerNames = [ { name: auth.user?.username ?? 'You', isAI: false }, ...this.opponents.map((o) => ({ name: o.name ?? o.id ?? 'Bot', isAI: true, avatar: o })), ]; this.gs = createInitialState({ playerNames }); this.buildLeftPanel(); this.buildScorecard(); this.buildDice(); this.buildButtons(); this.buildPortraits(); this.buildHover(); new Button(this, 80, GAME_HEIGHT - 50, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 140, fontSize: 20, }); this.renderAllDice(); this.updateScorecard(); this.updateActiveColumn(); this.updateControls(); this.time.delayedCall(450, () => this.nextTurn()); } // ─── Left panel (title + status) ────────────────────────────────────── buildLeftPanel() { this.add.text(LEFT_CX, 56, 'Yatzi', { fontFamily: 'Righteous', fontSize: '60px', color: COLORS.textHex, }).setOrigin(0.5); this.statusText = this.add.text(LEFT_CX, 130, '', { fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.accentHex, }).setOrigin(0.5); this.rollsText = this.add.text(LEFT_CX, 740, '', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, }).setOrigin(0.5); this.add.text(LEFT_CX, 800, 'Click a die to hold it between rolls.', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, }).setOrigin(0.5); } // ─── Scorecard ──────────────────────────────────────────────────────── buildScorecard() { const N = this.gs.players.length; const colW = SC_PLAYERS_W / N; // Background panel const totalH = SC_HEADER_H + UPPER.length * SC_ROW_H + SC_SUMMARY_H * 2 + LOWER.length * SC_ROW_H + SC_SUMMARY_H * 2 + SC_GRAND_H; this.add.rectangle(SC_X + SC_W / 2, SC_TOP + totalH / 2, SC_W + 4, totalH + 4, COLORS.panel) .setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.panel); const grid = this.add.graphics().setDepth(DEPTH.grid); grid.lineStyle(1, COLORS.muted, 0.4); // Header row let y = SC_TOP; this.drawRowDivider(grid, y); this.drawRowDivider(grid, y + SC_HEADER_H); this.add.text(SC_X + 14, y + SC_HEADER_H / 2, 'Category', { fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex, }).setOrigin(0, 0.5).setDepth(DEPTH.cellText); for (let p = 0; p < N; p++) { const cx = SC_X + SC_CAT_W + colW * p + colW / 2; const name = this.gs.players[p].name; const trimmed = name.length > 10 ? name.slice(0, 9) + '…' : name; const t = this.add.text(cx, y + SC_HEADER_H / 2, trimmed, { fontFamily: 'Righteous', fontSize: '20px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(DEPTH.cellText); this.columnHeaderText.push(t); } // Vertical lines grid.beginPath(); grid.moveTo(SC_X + SC_CAT_W, y); grid.lineTo(SC_X + SC_CAT_W, y + totalH); for (let p = 1; p < N; p++) { const x = SC_X + SC_CAT_W + colW * p; grid.moveTo(x, y); grid.lineTo(x, y + totalH); } grid.strokePath(); y += SC_HEADER_H; // ─── Upper section ────────────────────────────────────────────── for (const cat of UPPER) { this.buildCategoryRow(grid, y, cat, colW, false); y += SC_ROW_H; } // Upper subtotal this.buildSummaryRow(grid, y, 'Upper subtotal', 'upperSubtotal', colW); y += SC_SUMMARY_H; // Upper bonus this.buildSummaryRow(grid, y, 'Bonus (≥63 → +35)', 'upperBonus', colW); y += SC_SUMMARY_H; // ─── Lower section ────────────────────────────────────────────── for (const cat of LOWER) { this.buildCategoryRow(grid, y, cat, colW, true); y += SC_ROW_H; } // Lower subtotal this.buildSummaryRow(grid, y, 'Lower subtotal', 'lowerSubtotal', colW); y += SC_SUMMARY_H; // Yahtzee bonus this.buildSummaryRow(grid, y, 'Yahtzee bonus', 'yahtzeeBonus', colW); y += SC_SUMMARY_H; // Grand total this.drawRowDivider(grid, y); this.drawRowDivider(grid, y + SC_GRAND_H); this.add.text(SC_X + 14, y + SC_GRAND_H / 2, 'GRAND TOTAL', { fontFamily: 'Righteous', fontSize: '24px', color: COLORS.goldHex, }).setOrigin(0, 0.5).setDepth(DEPTH.cellText); for (let p = 0; p < N; p++) { const cx = SC_X + SC_CAT_W + colW * p + colW / 2; const t = this.add.text(cx, y + SC_GRAND_H / 2, '0', { fontFamily: 'Righteous', fontSize: '28px', color: COLORS.goldHex, }).setOrigin(0.5).setDepth(DEPTH.cellText); this.summaryRefs[p] = this.summaryRefs[p] ?? {}; this.summaryRefs[p].grand = t; } // Active column outline (drawn later, updated each turn) this.activeColumnGfx = this.add.graphics().setDepth(DEPTH.hover); this._scLastY = y + SC_GRAND_H; this._scColW = colW; this._scN = N; } drawRowDivider(grid, y) { grid.beginPath(); grid.moveTo(SC_X, y); grid.lineTo(SC_X + SC_W, y); grid.strokePath(); } buildCategoryRow(grid, y, cat, colW, isLower) { this.drawRowDivider(grid, y); this.add.text(SC_X + 14, y + SC_ROW_H / 2, CATEGORY_LABELS[cat], { fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex, }).setOrigin(0, 0.5).setDepth(DEPTH.cellText); for (let p = 0; p < this.gs.players.length; p++) { const cx = SC_X + SC_CAT_W + colW * p + colW / 2; const cy = y + SC_ROW_H / 2; const t = this.add.text(cx, cy, '', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(DEPTH.cellText); this.cellText[`${p}-${cat}`] = t; // Hit zone for clicking const cellX = SC_X + SC_CAT_W + colW * p; const zone = this.add.zone(cellX, y, colW, SC_ROW_H).setOrigin(0, 0).setDepth(DEPTH.cellBg); zone.setInteractive({ useHandCursor: true }); zone.on('pointerover', () => this.onCellHover(p, cat, true)); zone.on('pointerout', () => this.onCellHover(p, cat, false)); zone.on('pointerdown', () => this.onCellClick(p, cat)); this.cellHit[`${p}-${cat}`] = zone; } } buildSummaryRow(grid, y, label, key, colW) { this.drawRowDivider(grid, y); this.add.text(SC_X + 14, y + SC_SUMMARY_H / 2, label, { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, fontStyle: 'italic', }).setOrigin(0, 0.5).setDepth(DEPTH.cellText); for (let p = 0; p < this.gs.players.length; p++) { const cx = SC_X + SC_CAT_W + colW * p + colW / 2; const cy = y + SC_SUMMARY_H / 2; const t = this.add.text(cx, cy, '0', { fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(DEPTH.cellText); this.summaryRefs[p] = this.summaryRefs[p] ?? {}; this.summaryRefs[p][key] = t; } } buildHover() { this.cellHoverGfx = this.add.graphics().setDepth(DEPTH.hover); } // ─── Dice ────────────────────────────────────────────────────────────── buildDice() { for (let i = 0; i < 5; i++) { const x = DICE_LEFT + i * (DICE_SIZE + DICE_GAP) + DICE_SIZE / 2; const y = DICE_Y; const frame = this.add.graphics().setDepth(DEPTH.dieBox); const face = this.add.graphics().setDepth(DEPTH.diePip); const hit = this.add.zone(x, y, DICE_SIZE, DICE_SIZE).setOrigin(0.5).setDepth(DEPTH.dieHit); hit.setInteractive({ useHandCursor: true }); hit.on('pointerdown', () => this.onDieClick(i)); this.dieEls.push({ frame, face, hit, cx: x, cy: y, currentFace: 1 }); } } renderAllDice() { for (let i = 0; i < 5; i++) { this.renderDieFace(i, this.gs.dice[i], this.gs.held[i]); } } renderDieFace(idx, face, held) { const el = this.dieEls[idx]; el.currentFace = face; const x = el.cx, y = el.cy; const half = DICE_SIZE / 2; const r = 14; // Frame el.frame.clear(); const borderColor = held ? COLORS.accent : COLORS.muted; const borderWidth = held ? 5 : 2; el.frame.fillStyle(0xf2ead8, 1); // cream face el.frame.fillRoundedRect(x - half, y - half, DICE_SIZE, DICE_SIZE, r); el.frame.lineStyle(borderWidth, borderColor, 1); el.frame.strokeRoundedRect(x - half, y - half, DICE_SIZE, DICE_SIZE, r); // Pips el.face.clear(); el.face.fillStyle(0x1a1208, 1); const pipR = 9; const off = DICE_SIZE * 0.28; for (const [cx, cy] of PIP_POS[face]) { el.face.fillCircle(x + cx * off, y + cy * off, pipR); } } onDieClick(idx) { if (this.animating || this.aiRunning) return; if (this.gs.players[this.gs.current].isAI) return; if (this.gs.rollsRemaining === 3 || this.gs.rollsRemaining === 0) return; this.gs = toggleHold(this.gs, idx); this.renderDieFace(idx, this.gs.dice[idx], this.gs.held[idx]); } // ─── Buttons ─────────────────────────────────────────────────────────── buildButtons() { this.rollBtn = new Button(this, LEFT_CX, 660, 'Roll', () => this.onRollClick(), { width: 240, fontSize: 28 }); } onRollClick() { if (this.animating || this.aiRunning) return; if (this.gs.players[this.gs.current].isAI) return; if (this.gs.rollsRemaining <= 0) return; this.doRoll(); } async doRoll() { const next = rollDice(this.gs); await this.animateRoll(next.dice); this.gs = next; this.renderAllDice(); this.updateScorecard(); this.updateControls(); } async animateRoll(targetDice) { this.animating = true; return new Promise((resolve) => { this.tweens.addCounter({ from: 0, to: 1, duration: 550, ease: 'Quad.Out', onUpdate: () => { for (let i = 0; i < 5; i++) { if (this.gs.held[i]) continue; const fake = Math.ceil(Math.random() * 6); this.renderDieFace(i, fake, false); } }, onComplete: () => { for (let i = 0; i < 5; i++) { this.renderDieFace(i, targetDice[i], this.gs.held[i]); } this.animating = false; resolve(); }, }); }); } // ─── Portraits ───────────────────────────────────────────────────────── buildPortraits() { const y0 = PORTRAIT_TOP; // Player (human) const ring0 = this.add.graphics().setDepth(DEPTH.ui); const ctrl0 = createPlayerPortrait(this, PORTRAIT_X, y0, PORTRAIT_R, DEPTH.ui, 'YatziGame'); this.portraitCtrls.push({ ring: ring0, controller: ctrl0, x: PORTRAIT_X, y: y0 }); this.add.text(PORTRAIT_X, y0 + PORTRAIT_R + 18, this.gs.players[0].name, { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex, }).setOrigin(0.5); for (let i = 1; i < this.gs.players.length; i++) { const py = y0 + i * PORTRAIT_GAP; const ring = this.add.graphics().setDepth(DEPTH.ui); const opp = this.opponents[i - 1] ?? { id: 'bot', spriteIndex: 0 }; const ctrl = createOpponentPortrait(this, opp, PORTRAIT_X, py, PORTRAIT_R, DEPTH.ui); this.portraitCtrls.push({ ring, controller: ctrl, x: PORTRAIT_X, y: py }); this.add.text(PORTRAIT_X, py + PORTRAIT_R + 18, this.gs.players[i].name, { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex, }).setOrigin(0.5); } } updateActivePortraitRing() { for (let i = 0; i < this.portraitCtrls.length; i++) { const { ring, x, y } = this.portraitCtrls[i]; ring.clear(); if (i === this.gs.current) { ring.lineStyle(4, COLORS.gold, 1); ring.strokeCircle(x, y, PORTRAIT_R + 6); } } } // ─── Active column outline ───────────────────────────────────────────── updateActiveColumn() { const g = this.activeColumnGfx; g.clear(); const colW = this._scColW; const N = this._scN; if (this.gs.current >= N) return; const x = SC_X + SC_CAT_W + colW * this.gs.current; const y = SC_TOP; const h = this._scLastY - SC_TOP; g.lineStyle(4, COLORS.accent, 1); g.strokeRect(x + 1, y + 1, colW - 2, h - 2); // Header color emphasis for (let p = 0; p < N; p++) { this.columnHeaderText[p].setColor(p === this.gs.current ? COLORS.accentHex : COLORS.textHex); } } // ─── Cell hover preview / committed score render ─────────────────────── updateScorecard() { const N = this.gs.players.length; for (let p = 0; p < N; p++) { const player = this.gs.players[p]; for (const cat of CATEGORIES) { const t = this.cellText[`${p}-${cat}`]; const v = player.scorecard[cat]; if (v !== null) { t.setText(String(v)); t.setColor(COLORS.textHex); t.setAlpha(1); } else if (p === this.gs.current && !player.isAI && this.gs.rollsRemaining < 3) { // Preview score for the human, after the first roll const legal = legalCategories(this.gs.dice, player.scorecard); if (legal.includes(cat)) { const preview = scoreForCommit(this.gs.dice, cat, player.scorecard) ?? 0; t.setText(String(preview)); t.setColor(preview > 0 ? COLORS.accentHex : COLORS.mutedHex); t.setAlpha(0.65); } else { t.setText(''); } } else { t.setText(''); } } // Summary rows const totals = computeTotals(player); this.summaryRefs[p].upperSubtotal.setText(String(totals.upperSubtotal)); this.summaryRefs[p].upperBonus.setText(String(totals.upperBonus)); this.summaryRefs[p].lowerSubtotal.setText(String(totals.lowerSubtotal)); this.summaryRefs[p].yahtzeeBonus.setText(String(totals.yahtzeeBonus)); this.summaryRefs[p].grand.setText(String(totals.grand)); } } onCellHover(p, cat, entering) { const g = this.cellHoverGfx; g.clear(); if (!entering) return; if (this.animating || this.aiRunning) return; const cur = this.gs.players[this.gs.current]; if (p !== this.gs.current || cur.isAI) return; if (this.gs.rollsRemaining === 3) return; if (cur.scorecard[cat] !== null) return; const legal = legalCategories(this.gs.dice, cur.scorecard); if (!legal.includes(cat)) return; const zone = this.cellHit[`${p}-${cat}`]; g.lineStyle(2, COLORS.gold, 1); g.strokeRect(zone.x + 2, zone.y + 2, zone.width - 4, zone.height - 4); } onCellClick(p, cat) { if (this.animating || this.aiRunning) return; if (this.gameOverShown) return; const cur = this.gs.players[this.gs.current]; if (p !== this.gs.current || cur.isAI) return; if (this.gs.rollsRemaining === 3) return; if (cur.scorecard[cat] !== null) return; const legal = legalCategories(this.gs.dice, cur.scorecard); if (!legal.includes(cat)) return; this.commitCategory(cat); } commitCategory(cat) { const before = this.gs; const after = commitScore(before, cat); if (after === before) return; this.gs = after; if (after.lastBonusGained) this.showBonusToast(); this.cellHoverGfx?.clear(); this.renderAllDice(); this.updateScorecard(); this.updateActiveColumn(); this.updateControls(); if (this.gs.phase === 'gameover') { this.showGameOverModal(); } else { this.time.delayedCall(350, () => this.nextTurn()); } } showBonusToast() { if (this.toastText) this.toastText.destroy(); this.toastText = this.add.text(LEFT_CX, 380, '+100 Yahtzee Bonus!', { fontFamily: 'Righteous', fontSize: '32px', color: COLORS.goldHex, }).setOrigin(0.5).setDepth(DEPTH.toast); this.tweens.add({ targets: this.toastText, alpha: { from: 1, to: 0 }, y: 340, duration: 1600, onComplete: () => { this.toastText?.destroy(); this.toastText = null; }, }); } // ─── Turn flow ───────────────────────────────────────────────────────── nextTurn() { if (this.gs.phase === 'gameover') { this.showGameOverModal(); return; } this.updateActiveColumn(); this.updateActivePortraitRing(); this.renderAllDice(); this.updateScorecard(); this.updateControls(); const cur = this.gs.players[this.gs.current]; if (cur.isAI) { this.runAITurn(); } } async runAITurn() { this.aiRunning = true; this.updateControls(); await this.delay(500); let keepRolling = true; while (keepRolling && this.gs.rollsRemaining > 0) { await this.doRoll(); if (this.gs.rollsRemaining === 0) break; await this.delay(450); const mask = chooseDiceToHold( this.gs.dice, this.gs.players[this.gs.current].scorecard, this.gs.rollsRemaining, ); // Apply hold mask visibly for (let i = 0; i < 5; i++) { if (mask[i] !== this.gs.held[i]) { this.gs.held[i] = mask[i]; this.renderDieFace(i, this.gs.dice[i], this.gs.held[i]); } } await this.delay(500); keepRolling = shouldKeepRolling(mask, this.gs.rollsRemaining); } await this.delay(500); const cat = chooseCategory( this.gs.dice, this.gs.players[this.gs.current].scorecard, ); if (cat) { this.flashCell(this.gs.current, cat); await this.delay(500); this.aiRunning = false; this.commitCategory(cat); } else { this.aiRunning = false; } } flashCell(p, cat) { const zone = this.cellHit[`${p}-${cat}`]; if (!zone) return; const flash = this.add.graphics().setDepth(DEPTH.hover); flash.fillStyle(COLORS.accent, 0.4); flash.fillRect(zone.x + 2, zone.y + 2, zone.width - 4, zone.height - 4); this.tweens.add({ targets: flash, alpha: { from: 0.6, to: 0 }, duration: 450, onComplete: () => flash.destroy(), }); } // ─── Controls update ─────────────────────────────────────────────────── updateControls() { const cur = this.gs.players[this.gs.current]; const isHuman = !cur.isAI; const canRoll = isHuman && this.gs.rollsRemaining > 0 && !this.animating && !this.aiRunning && this.gs.phase !== 'gameover'; this.rollBtn?.setEnabled(canRoll); this.rollBtn?.setLabel(this.gs.rollsRemaining === 3 ? 'Roll' : 'Re-roll'); this.rollsText?.setText(this.gs.phase === 'gameover' ? '' : `Rolls remaining: ${this.gs.rollsRemaining}`); if (this.gs.phase === 'gameover') { this.statusText.setText('Game over'); } else if (cur.isAI) { this.statusText.setText(`${cur.name}'s turn`); } else if (this.gs.rollsRemaining === 3) { this.statusText.setText('Your turn — roll the dice'); } else if (this.gs.rollsRemaining === 0) { this.statusText.setText('Pick a category to score'); } else { this.statusText.setText('Hold dice or re-roll'); } } // ─── Game over ───────────────────────────────────────────────────────── showGameOverModal() { if (this.gameOverShown) return; this.gameOverShown = true; // Submit to server (silent on failure) this.postHistory().catch(() => {}); const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65) .setInteractive().setDepth(DEPTH.modal); const panelW = 760; const N = this.gs.players.length; const panelH = 220 + N * 60; this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, panelW, panelH, COLORS.panel, 1) .setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.modal); const cy = GAME_HEIGHT / 2; this.add.text(GAME_WIDTH / 2, cy - panelH / 2 + 50, 'Final Score', { fontFamily: 'Righteous', fontSize: '42px', color: COLORS.goldHex, }).setOrigin(0.5).setDepth(DEPTH.modal); const winners = new Set(getWinners(this.gs)); let rowY = cy - panelH / 2 + 110; for (let p = 0; p < N; p++) { const totals = computeTotals(this.gs.players[p]); const isWinner = winners.has(p); const color = isWinner ? COLORS.goldHex : COLORS.textHex; const prefix = isWinner ? '★ ' : ' '; this.add.text(GAME_WIDTH / 2 - panelW / 2 + 40, rowY, `${prefix}${this.gs.players[p].name}`, { fontFamily: 'Righteous', fontSize: '24px', color, }).setOrigin(0, 0.5).setDepth(DEPTH.modal); this.add.text(GAME_WIDTH / 2 + panelW / 2 - 40, rowY, String(totals.grand), { fontFamily: 'Righteous', fontSize: '28px', color, }).setOrigin(1, 0.5).setDepth(DEPTH.modal); rowY += 50; } new Button(this, GAME_WIDTH / 2, cy + panelH / 2 - 50, 'Back to Menu', () => this.scene.start('GameMenu'), { width: 280, fontSize: 24 }, ).setDepth(DEPTH.modal); } async postHistory() { const N = this.gs.players.length; const totals = this.gs.players.map((p) => computeTotals(p).grand); const human = totals[0]; const winners = new Set(getWinners(this.gs)); let result; if (N === 1) { result = 'win'; } else if (winners.has(0) && winners.size === 1) { result = 'win'; } else if (winners.has(0)) { result = 'draw'; } else { result = 'loss'; } await api.post('/history/single-player', { slug: 'yatzi', score: human, opponentScores: totals.slice(1), result, }); } // ─── Utility ─────────────────────────────────────────────────────────── delay(ms) { return new Promise((resolve) => this.time.delayedCall(ms, resolve)); } }