701 lines
26 KiB
JavaScript
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));
|
|
}
|
|
}
|