fertig-classic-games/public/src/games/yatzi/YatziGame.js

701 lines
26 KiB
JavaScript

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