feat: add Scrabble game with client-server AI and Phaser UI

- Implement `ScrabbleGame` scene with drag-and-drop, rack management, and animated tile placement.
- Add pure JS modules for Scrabble rules, scoring, and tile data (`ScrabbleLogic`, `ScrabbleTiles`).
- Create server-side dictionary trie and DFS-based move generator for AI (`scrabbleEngine`).
- Register game routes for move validation and AI move requests (`wordRoutes`).
- Update game registry, opponent selection, and preload scenes to support Scrabble.
- Include custom font (`YummyCupcakes`) for the scorepad UI.
This commit is contained in:
Brian Fertig 2026-05-27 23:54:02 -06:00
parent 17133787c1
commit 95ff6f8de2
12 changed files with 1387 additions and 5 deletions

View File

@ -0,0 +1,30 @@
// Thin client wrapper around the server move generator. The heavy lifting (the
// dictionary trie + anchor/cross-check search) lives in server/words/scrabbleEngine.js;
// here we just ship the board + rack and pace the AI so turns feel deliberate.
import { api } from '../../services/api.js';
// Ask the server for this AI's move. Resolves to:
// { type:'play', placements, score, word } | { type:'exchange', tiles } | { type:'pass' }
export async function requestAIMove({ board, rack, skill, firstMove, bagCount }) {
try {
return await api.post('/words/scrabble/ai-move', { board, rack, skill, firstMove, bagCount });
} catch (err) {
console.error('[scrabble] ai-move request failed:', err);
return { type: 'pass' };
}
}
// "Thinking" pause before the AI commits, in ms. Stronger players act faster.
const THINK_DELAY = {
1: [2200, 3600],
2: [1800, 3000],
3: [1400, 2400],
4: [1000, 1800],
5: [700, 1300],
};
export function nextThinkDelay(skill) {
const [min, max] = THINK_DELAY[skill] ?? THINK_DELAY[3];
return min + Math.random() * (max - min);
}

View File

@ -0,0 +1,853 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { api } from '../../services/api.js';
import { auth } from '../../services/auth.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import {
createEmptyBoard, createBag, shuffle, drawTiles, isBoardEmpty,
applyPlacements, rackValue, validateMove, scoreMove,
} from './ScrabbleLogic.js';
import {
BOARD_SIZE, RACK_SIZE, BLANK, LETTER_VALUES, premiumAt, isCenter,
} from './ScrabbleTiles.js';
import { requestAIMove, nextThinkDelay } from './ScrabbleAI.js';
// ── Layout ─────────────────────────────────────────────────────────────────────
const SQ = 50; // board square size
const BOARD_PX = SQ * BOARD_SIZE; // 750
const BOARD_X0 = Math.round(GAME_WIDTH / 2 - BOARD_PX / 2); // 585
const BOARD_Y0 = 90;
const RACK_TILE = 56;
const RACK_GAP = 6;
const RACK_Y = 915;
const STATUS_Y = 968;
const CONTROLS_Y = 1014;
const NOTE_X = 1360, NOTE_W = 540, NOTE_Y = 90, NOTE_H = 750;
const OPP_X0 = 60, OPP_X1 = 565; // left column bounds
const DEPTH = { bg: 0, board: 1, grid: 2, panel: 4, tile: 6, tentative: 7, ui: 12, drag: 60, modal: 90, victory: 100 };
// Premium-square palette + label color
const PREM_STYLE = {
TW: { fill: 0xcf4b3e, text: '#ffffff' },
DW: { fill: 0xe39aa0, text: '#3a2417' },
TL: { fill: 0x2f6fb0, text: '#ffffff' },
DL: { fill: 0x8fc1e3, text: '#23323f' },
'': { fill: 0xbfa97e, text: '#5a4a28' },
};
const GRID_LINE = 0x6b5d3e;
const TILE_FILL = 0xf2e3b3, TILE_FILL_TENT = 0xfff0c0, TILE_STROKE = 0xb89b5e, TILE_INK = '#2a1f0c';
export default class ScrabbleGame extends Phaser.Scene {
constructor() { super('ScrabbleGame'); }
init(data) {
this._initData = { ...data };
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.board = createEmptyBoard();
this.bag = shuffle(createBag());
this.players = [
{ type: 'human', name: auth.user?.username ?? 'You', rack: [], score: 0, portrait: null },
...this.opponents.map(o => ({
type: 'ai', name: o.name ?? 'CPU', skill: o.skill ?? 3, opp: o, rack: [], score: 0, portrait: null,
})),
];
this.turnIndex = 0;
this.tentative = []; // [{ row, col, token, letter, blank, obj }]
this.boardObjs = {}; // 'r,c' -> committed tile container
this.rackObjs = []; // rack tile containers (human)
this.oppFaceObjs = []; // per-opponent array of face-down tile containers
this.scorelessTurns = 0;
this.animating = false;
this.gameOver = false;
this.exchangeMode = false;
this.exchangeSel = new Set();
this.blankPicking = false;
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildParticleTexture();
this.buildBackground();
this.buildBoard();
this.buildOpponents();
this.buildNotepad();
this.buildRackTray();
this.buildStatus();
this.buildControls();
this.setupDrag();
// Deal racks
for (const p of this.players) p.rack = drawTiles(this.bag, RACK_SIZE);
this.renderRack();
this.renderOpponentRacks();
this.updateNotepad();
this.highlightActive();
this.beginTurn();
}
// ── Build: scenery ───────────────────────────────────────────────────────────
buildParticleTexture() {
if (this.textures.exists('scrabbleParticle')) return;
const g = this.make.graphics({ add: false });
g.fillStyle(0xffffff, 1);
g.fillCircle(5, 5, 5);
g.generateTexture('scrabbleParticle', 10, 10);
g.destroy();
}
buildBackground() {
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(DEPTH.bg);
this.add.text(GAME_WIDTH / 2, 44, 'SCRABBLE', {
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
}
buildBoard() {
// backing frame
const frame = this.add.graphics().setDepth(DEPTH.board);
frame.fillStyle(0x3a3327, 1);
frame.fillRoundedRect(BOARD_X0 - 10, BOARD_Y0 - 10, BOARD_PX + 20, BOARD_PX + 20, 10);
const g = this.add.graphics().setDepth(DEPTH.grid);
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) {
const prem = premiumAt(r, c);
const style = PREM_STYLE[prem.label] ?? PREM_STYLE[''];
const x = BOARD_X0 + c * SQ;
const y = BOARD_Y0 + r * SQ;
g.fillStyle(style.fill, 1);
g.fillRect(x + 1, y + 1, SQ - 2, SQ - 2);
g.lineStyle(1, GRID_LINE, 0.8);
g.strokeRect(x + 1, y + 1, SQ - 2, SQ - 2);
if (isCenter(r, c)) {
this.add.text(x + SQ / 2, y + SQ / 2, '★', {
fontFamily: 'Righteous', fontSize: '26px', color: '#3a2417',
}).setOrigin(0.5).setDepth(DEPTH.grid);
} else if (prem.label) {
this.add.text(x + SQ / 2, y + SQ / 2, prem.label, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: style.text,
}).setOrigin(0.5).setDepth(DEPTH.grid);
}
}
}
}
buildOpponents() {
const ai = this.players.filter(p => p.type === 'ai');
const top = BOARD_Y0, bottom = BOARD_Y0 + BOARD_PX;
const slotH = (bottom - top) / ai.length;
const blockCx = (OPP_X0 + OPP_X1) / 2;
ai.forEach((p, i) => {
const cy = Math.round(top + slotH * i + slotH / 2);
const R = ai.length <= 2 ? 50 : 44;
const bg = this.add.rectangle(blockCx, cy, OPP_X1 - OPP_X0, slotH - 16, COLORS.panel, 0.55)
.setStrokeStyle(2, 0x2a2a2c).setDepth(DEPTH.panel);
p._blockBg = bg;
const px = OPP_X0 + R + 16;
p.portrait = createOpponentPortrait(this, p.opp, px, cy, R, DEPTH.ui);
const infoX = px + R + 22;
this.add.text(infoX, cy - R + 6, p.name, {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0, 0).setDepth(DEPTH.ui);
this.add.text(infoX, cy - R + 36, `Skill ${p.skill}`, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setOrigin(0, 0).setDepth(DEPTH.ui);
p._faceY = cy + 6;
p._faceX = infoX;
p._thinkText = this.add.text(infoX, cy + R - 16, '', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.accentHex,
}).setOrigin(0, 0).setDepth(DEPTH.ui);
});
}
buildNotepad() {
const g = this.add.graphics().setDepth(DEPTH.panel);
g.fillStyle(0xf5efdc, 1);
g.fillRoundedRect(NOTE_X, NOTE_Y, NOTE_W, NOTE_H, 8);
// ruled lines
g.lineStyle(1, 0xbcd0e0, 0.9);
for (let y = NOTE_Y + 96; y < NOTE_Y + NOTE_H - 10; y += 40) {
g.lineBetween(NOTE_X + 24, y, NOTE_X + NOTE_W - 18, y);
}
// red margin
g.lineStyle(2, 0xd98a8a, 0.9);
g.lineBetween(NOTE_X + 56, NOTE_Y + 12, NOTE_X + 56, NOTE_Y + NOTE_H - 12);
this.add.text(NOTE_X + NOTE_W / 2, NOTE_Y + 40, 'Score Pad', {
fontFamily: 'YummyCupcakes', fontSize: '56px', color: '#b03a2e',
}).setOrigin(0.5).setDepth(DEPTH.ui);
// Per-player score lines
this.noteScoreObjs = [];
const startY = NOTE_Y + 120;
this.players.forEach((p, i) => {
const y = startY + i * 64;
const nameText = this.add.text(NOTE_X + 72, y, p.name, {
fontFamily: 'YummyCupcakes', fontSize: '40px', color: '#27408b',
}).setOrigin(0, 0.5).setDepth(DEPTH.ui);
const scoreText = this.add.text(NOTE_X + NOTE_W - 40, y, '0', {
fontFamily: 'YummyCupcakes', fontSize: '40px', color: '#27408b',
}).setOrigin(1, 0.5).setDepth(DEPTH.ui);
this.noteScoreObjs.push({ nameText, scoreText });
});
this.noteBagText = this.add.text(NOTE_X + 72, NOTE_Y + NOTE_H - 80, '', {
fontFamily: 'YummyCupcakes', fontSize: '34px', color: '#5a6b3a',
}).setOrigin(0, 0.5).setDepth(DEPTH.ui);
this.noteLastText = this.add.text(NOTE_X + 72, NOTE_Y + NOTE_H - 38, '', {
fontFamily: 'YummyCupcakes', fontSize: '30px', color: '#7a5a2a',
}).setOrigin(0, 0.5).setDepth(DEPTH.ui);
}
buildRackTray() {
const w = RACK_SIZE * RACK_TILE + (RACK_SIZE - 1) * RACK_GAP + 28;
this.rackTrayBg = this.add.rectangle(GAME_WIDTH / 2, RACK_Y, w, RACK_TILE + 24, 0x3a2b16, 1)
.setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.panel);
}
buildStatus() {
this.statusText = this.add.text(GAME_WIDTH / 2, STATUS_Y, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
}
buildControls() {
const labels = [
['Recall', () => this.recallAll()],
['Shuffle', () => this.shuffleRack()],
['Exchange', () => this.onExchangeButton()],
['Pass', () => this.onPassButton()],
['Play', () => this.onPlayButton()],
];
const bw = 150, gap = 12;
const total = labels.length * bw + (labels.length - 1) * gap;
let x = GAME_WIDTH / 2 - total / 2 + bw / 2;
this.btn = {};
for (const [label, fn] of labels) {
const b = new Button(this, x, CONTROLS_Y, label, fn, { width: bw, height: 44, fontSize: 18 });
b.setDepth(DEPTH.ui);
this.btn[label.toLowerCase()] = b;
x += bw + gap;
}
new Button(this, GAME_WIDTH - 96, GAME_HEIGHT - 28, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 150, height: 40, fontSize: 18,
}).setDepth(DEPTH.ui);
}
// ── Tile rendering ───────────────────────────────────────────────────────────
makeTile(size, letter, value, { tentative = false, faceDown = false } = {}) {
const c = this.add.container(0, 0);
const g = this.add.graphics();
const half = size / 2;
if (faceDown) {
g.fillStyle(COLORS.panel, 1);
g.fillRoundedRect(-half, -half, size, size, 6);
g.lineStyle(2, COLORS.accent, 0.8);
g.strokeRoundedRect(-half, -half, size, size, 6);
c.add(g);
c.add(this.add.text(0, 0, '✦', {
fontFamily: 'Righteous', fontSize: `${Math.round(size * 0.4)}px`, color: COLORS.accentHex,
}).setOrigin(0.5));
return c;
}
g.fillStyle(tentative ? TILE_FILL_TENT : TILE_FILL, 1);
g.fillRoundedRect(-half, -half, size, size, 6);
g.lineStyle(2, tentative ? COLORS.gold : TILE_STROKE, 1);
g.strokeRoundedRect(-half, -half, size, size, 6);
c.add(g);
if (letter) {
c.add(this.add.text(-2, -2, letter, {
fontFamily: 'Righteous', fontSize: `${Math.round(size * 0.52)}px`, color: TILE_INK,
}).setOrigin(0.5));
}
if (value > 0) {
c.add(this.add.text(half - 6, half - 5, String(value), {
fontFamily: 'Righteous', fontSize: `${Math.round(size * 0.24)}px`, color: TILE_INK,
}).setOrigin(1, 1));
}
return c;
}
squareCenter(row, col) {
return { x: BOARD_X0 + col * SQ + SQ / 2, y: BOARD_Y0 + row * SQ + SQ / 2 };
}
// ── Rack (human) ─────────────────────────────────────────────────────────────
renderRack() {
for (const o of this.rackObjs) o.destroy();
this.rackObjs = [];
const rack = this.players[0].rack;
const w = RACK_SIZE * RACK_TILE + (RACK_SIZE - 1) * RACK_GAP;
const startX = GAME_WIDTH / 2 - w / 2 + RACK_TILE / 2;
rack.forEach((token, i) => {
const x = startX + i * (RACK_TILE + RACK_GAP);
const letter = token === BLANK ? '' : token;
const value = token === BLANK ? 0 : (LETTER_VALUES[token] ?? 0);
const tile = this.makeTile(RACK_TILE, letter, value);
tile.setPosition(x, RACK_Y).setDepth(DEPTH.tile);
tile._token = token;
tile._rackIndex = i;
tile._homeX = x;
tile._homeY = RACK_Y;
tile.setSize(RACK_TILE, RACK_TILE);
// A Container's displayOrigin is width*0.5, which Phaser adds to the hit
// test, so the auto-generated Rectangle(0,0,w,h) lands centered. Passing a
// manual (-w/2,-h/2,w,h) rect double-shifts it up-left — don't.
tile.setInteractive({ useHandCursor: true });
this.input.setDraggable(tile);
tile.on('pointerup', () => { if (this.exchangeMode) this.toggleExchangeSel(tile); });
this.rackObjs.push(tile);
});
this.refreshExchangeVisuals();
}
setupDrag() {
this.input.on('dragstart', (_p, obj) => {
if (!this.canHumanAct() || this.exchangeMode || obj._rackIndex === undefined) return;
obj.setDepth(DEPTH.drag);
obj._dragging = true;
});
this.input.on('drag', (_p, obj, dx, dy) => {
if (!obj._dragging) return;
obj.setPosition(dx, dy);
});
this.input.on('dragend', (_p, obj) => {
if (!obj._dragging) return;
obj._dragging = false;
const sq = this.squareAt(obj.x, obj.y);
if (sq && this.squareFree(sq.row, sq.col)) {
// Defer: placeTentative rebuilds the rack and destroys this very tile,
// so let Phaser finish dispatching dragend first.
obj.setVisible(false);
const token = obj._token, row = sq.row, col = sq.col;
this.time.delayedCall(0, () => this.placeTentative(token, row, col));
} else {
obj.setDepth(DEPTH.tile);
this.tweens.add({ targets: obj, x: obj._homeX, y: obj._homeY, duration: 140, ease: 'Back.easeOut' });
}
});
}
squareAt(x, y) {
const col = Math.floor((x - BOARD_X0) / SQ);
const row = Math.floor((y - BOARD_Y0) / SQ);
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return null;
return { row, col };
}
squareFree(row, col) {
if (this.board[row][col]) return false;
return !this.tentative.some(t => t.row === row && t.col === col);
}
placeTentative(token, row, col) {
const finalize = (letter) => {
// remove one matching token from rack
const rack = this.players[0].rack;
const idx = rack.indexOf(token);
if (idx !== -1) rack.splice(idx, 1);
const blank = token === BLANK;
const { x, y } = this.squareCenter(row, col);
const obj = this.makeTile(SQ - 4, letter, blank ? 0 : (LETTER_VALUES[letter] ?? 0), { tentative: true });
obj.setPosition(x, y).setDepth(DEPTH.tentative);
obj.setSize(SQ, SQ);
obj.setInteractive({ useHandCursor: true });
const t = { row, col, token, letter, blank, obj };
obj.on('pointerup', () => { if (this.canHumanAct() && !this.exchangeMode) this.recallTentative(t); });
this.tentative.push(t);
playSound(this, SFX.PIECE_CLICK);
this.renderRack();
this.clearStatus();
};
if (token === BLANK) {
this.openBlankPicker((letter) => {
if (letter) finalize(letter);
else this.renderRack(); // cancelled — snap back
});
} else {
finalize(token);
}
}
recallTentative(t) {
this.players[0].rack.push(t.token);
t.obj.destroy();
this.tentative = this.tentative.filter(x => x !== t);
this.renderRack();
}
recallAll() {
if (!this.canHumanAct()) return;
if (this.exchangeMode) { this.cancelExchange(); return; }
for (const t of this.tentative) {
this.players[0].rack.push(t.token);
t.obj.destroy();
}
this.tentative = [];
this.renderRack();
}
shuffleRack() {
if (!this.canHumanAct() || this.exchangeMode) return;
shuffle(this.players[0].rack);
this.renderRack();
playSound(this, SFX.PIECE_CLICK);
}
// ── Blank picker ─────────────────────────────────────────────────────────────
openBlankPicker(onPick) {
this.blankPicking = true;
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const objs = [];
objs.push(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setDepth(DEPTH.modal).setInteractive());
objs.push(this.add.rectangle(cx, cy, 520, 360, COLORS.panel, 1).setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.modal + 1));
objs.push(this.add.text(cx, cy - 150, 'Choose a letter for the blank', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.modal + 2));
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const cols = 7, cell = 60, gx = cx - (cols * cell) / 2 + cell / 2, gy = cy - 88;
const cleanup = (pick) => { for (const o of objs) o.destroy(); this.blankPicking = false; onPick(pick); };
letters.forEach((L, i) => {
const r = Math.floor(i / cols), c = i % cols;
const x = gx + c * cell, y = gy + r * cell;
const box = this.add.rectangle(x, y, cell - 8, cell - 8, 0x2a2a2c, 1).setStrokeStyle(1, COLORS.accent)
.setDepth(DEPTH.modal + 2).setInteractive({ useHandCursor: true });
const txt = this.add.text(x, y, L, { fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex })
.setOrigin(0.5).setDepth(DEPTH.modal + 3);
box.on('pointerover', () => box.setFillStyle(COLORS.gold));
box.on('pointerout', () => box.setFillStyle(0x2a2a2c));
box.on('pointerup', () => cleanup(L));
objs.push(box, txt);
});
const cancel = new Button(this, cx, cy + 142, 'Cancel', () => cleanup(null), { variant: 'ghost', width: 160, height: 40, fontSize: 18 });
cancel.setDepth(DEPTH.modal + 3);
objs.push(cancel);
}
// ── Play a move (human) ──────────────────────────────────────────────────────
async onPlayButton() {
if (!this.canHumanAct()) return;
if (this.exchangeMode) { this.confirmExchange(); return; }
if (!this.tentative.length) { this.flashStatus('Drag tiles onto the board first.'); return; }
const placements = this.tentative.map(t => ({ row: t.row, col: t.col, letter: t.letter, blank: t.blank }));
const firstMove = isBoardEmpty(this.board);
const res = validateMove(this.board, placements, { firstMove });
if (!res.ok) { this.flashStatus(res.error); this.shakeTentative(); return; }
this.animating = true;
let check;
try {
check = await api.post('/words/scrabble/validate', { words: res.words.map(w => w.word) });
} catch {
check = { valid: true, invalid: [] }; // network failure → allow (best effort)
}
this.animating = false;
if (!check.valid) {
this.flashStatus(`Not a word: ${check.invalid.join(', ')}`);
this.shakeTentative();
return;
}
const score = scoreMove(this.board, placements);
this.commitMove(this.players[0], placements, res.words, score);
}
commitMove(player, placements, words, score, alreadyApplied = false) {
if (!alreadyApplied) {
// commit to board model and turn tentative tiles into committed ones
this.board = applyPlacements(this.board, placements);
for (const t of this.tentative) t.obj.destroy();
this.tentative = [];
for (const p of placements) this.renderCommittedTile(p.row, p.col);
}
player.score += score;
this.scorelessTurns = score > 0 ? 0 : this.scorelessTurns + 1;
const best = words.slice().sort((a, b) => b.word.length - a.word.length)[0];
this.noteLastText.setText(`${player.name}: ${best?.word ?? ''} +${score}`);
playSound(this, SFX.PENCIL_WRITE);
// refill from bag
const need = RACK_SIZE - player.rack.length;
if (need > 0) player.rack.push(...drawTiles(this.bag, need));
if (player.type === 'human') this.renderRack();
this.renderOpponentRacks();
this.updateNotepad();
this.bumpScore(this.players.indexOf(player));
if (player.rack.length === 0) { this.endGame(player); return; }
this.advanceTurn();
}
renderCommittedTile(row, col) {
const cell = this.board[row][col];
const { x, y } = this.squareCenter(row, col);
const value = cell.blank ? 0 : (LETTER_VALUES[cell.letter] ?? 0);
const obj = this.makeTile(SQ - 4, cell.letter, value);
obj.setPosition(x, y).setDepth(DEPTH.tile);
this.boardObjs[`${row},${col}`] = obj;
this.tweens.add({ targets: obj, scale: { from: 1.25, to: 1 }, duration: 160, ease: 'Back.easeOut' });
}
shakeTentative() {
const objs = this.tentative.map(t => t.obj);
if (!objs.length) return;
const homeX = objs.map(o => o.x);
this.tweens.add({
targets: objs, x: '+=6', duration: 50, yoyo: true, repeat: 3,
onComplete: () => objs.forEach((o, i) => { o.x = homeX[i]; }),
});
}
// ── Exchange ─────────────────────────────────────────────────────────────────
onExchangeButton() {
if (!this.canHumanAct()) return;
if (this.exchangeMode) { this.confirmExchange(); return; }
if (this.tentative.length) { this.flashStatus('Recall your tiles before exchanging.'); return; }
if (this.bag.length < 1) { this.flashStatus('No tiles left to exchange.'); return; }
this.exchangeMode = true;
this.exchangeSel.clear();
this.flashStatus('Select tiles to swap, then press Swap.');
this.updateControls();
this.refreshExchangeVisuals();
}
toggleExchangeSel(tile) {
const i = tile._rackIndex;
if (this.exchangeSel.has(i)) this.exchangeSel.delete(i);
else this.exchangeSel.add(i);
this.refreshExchangeVisuals();
}
refreshExchangeVisuals() {
this.rackObjs.forEach(o => {
const sel = this.exchangeMode && this.exchangeSel.has(o._rackIndex);
o.setY(sel ? RACK_Y - 14 : RACK_Y);
o.setAlpha(this.exchangeMode && !sel ? 0.7 : 1);
});
}
cancelExchange() {
this.exchangeMode = false;
this.exchangeSel.clear();
this.updateControls();
this.renderRack();
this.clearStatus();
}
confirmExchange() {
const rack = this.players[0].rack;
const idxs = [...this.exchangeSel].sort((a, b) => b - a);
if (!idxs.length) { this.flashStatus('Select at least one tile.'); return; }
const returned = [];
for (const i of idxs) returned.push(rack.splice(i, 1)[0]);
const drawn = drawTiles(this.bag, returned.length);
rack.push(...drawn);
this.bag.push(...returned);
shuffle(this.bag);
this.exchangeMode = false;
this.exchangeSel.clear();
this.scorelessTurns += 1;
this.updateControls();
this.renderRack();
this.updateNotepad();
playSound(this, SFX.CARD_SHUFFLE);
this.flashStatus('Tiles exchanged.');
this.advanceTurn();
}
onPassButton() {
if (!this.canHumanAct()) return;
if (this.exchangeMode) { this.cancelExchange(); return; }
this.recallAll();
this.scorelessTurns += 1;
this.flashStatus('You passed.');
this.advanceTurn();
}
updateControls() {
if (this.exchangeMode) {
this.btn.play.setLabel('Swap');
this.btn.recall.setLabel('Cancel');
this.btn.shuffle.setEnabled(false);
this.btn.exchange.setEnabled(false);
this.btn.pass.setEnabled(false);
} else {
this.btn.play.setLabel('Play');
this.btn.recall.setLabel('Recall');
this.btn.shuffle.setEnabled(true);
this.btn.exchange.setEnabled(true);
this.btn.pass.setEnabled(true);
}
}
setControlsEnabled(on) {
for (const k of Object.keys(this.btn)) this.btn[k].setEnabled(on);
if (on) this.updateControls();
}
// ── Turn loop ────────────────────────────────────────────────────────────────
beginTurn() {
if (this.gameOver) return;
this.highlightActive();
const player = this.players[this.turnIndex];
if (player.type === 'human') {
this.setControlsEnabled(true);
this.flashStatus('Your turn — build a word.');
} else {
this.setControlsEnabled(false);
this.runAITurn(player);
}
}
advanceTurn() {
if (this.gameOver) return;
if (this.scorelessTurns >= 6) { this.endGame(null); return; }
this.turnIndex = (this.turnIndex + 1) % this.players.length;
this.beginTurn();
}
async runAITurn(player) {
this.animating = true;
player._thinkText?.setText('thinking…');
const delay = nextThinkDelay(player.skill);
const move = await requestAIMove({
board: this.serializeBoard(),
rack: player.rack.slice(),
skill: player.skill,
firstMove: isBoardEmpty(this.board),
bagCount: this.bag.length,
});
await this.delay(delay);
player._thinkText?.setText('');
this.animating = false;
if (this.gameOver) return;
if (move.type === 'play' && Array.isArray(move.placements) && move.placements.length) {
const firstMove = isBoardEmpty(this.board);
const res = validateMove(this.board, move.placements, { firstMove });
if (res.ok) {
const score = scoreMove(this.board, move.placements); // board still pre-move
await this.animateAIPlacements(move.placements); // applies board + renders tiles
player.portrait?.playEmotion('happy');
this.commitMove(player, move.placements, res.words, score, true);
return;
}
}
if (move.type === 'exchange' && this.bag.length >= 1) {
this.aiExchange(player, move.tiles ?? []);
return;
}
// pass
this.scorelessTurns += 1;
this.noteLastText.setText(`${player.name}: passed`);
this.advanceTurn();
}
aiExchange(player, tiles) {
const returned = [];
for (const tok of tiles) {
const idx = player.rack.indexOf(tok);
if (idx !== -1) returned.push(player.rack.splice(idx, 1)[0]);
}
if (returned.length) {
player.rack.push(...drawTiles(this.bag, returned.length));
this.bag.push(...returned);
shuffle(this.bag);
}
this.scorelessTurns += 1;
this.noteLastText.setText(`${player.name}: exchanged ${returned.length}`);
this.renderOpponentRacks();
this.updateNotepad();
playSound(this, SFX.CARD_SHUFFLE);
this.advanceTurn();
}
animateAIPlacements(placements) {
return new Promise(resolve => {
// commit to board model up front so renderCommittedTile reads letters
this.board = applyPlacements(this.board, placements);
placements.forEach((p, i) => {
this.time.delayedCall(i * 160, () => {
this.renderCommittedTile(p.row, p.col);
playSound(this, SFX.PIECE_CLICK);
if (i === placements.length - 1) this.time.delayedCall(220, resolve);
});
});
});
}
// ── Opponent face-down racks + active highlight ────────────────────────────────
renderOpponentRacks() {
for (const arr of this.oppFaceObjs) for (const o of arr) o.destroy();
this.oppFaceObjs = [];
const ai = this.players.filter(p => p.type === 'ai');
ai.forEach((p) => {
const arr = [];
const size = 26, gap = 4;
p.rack.forEach((_, i) => {
const tile = this.makeTile(size, '', 0, { faceDown: true });
tile.setPosition(p._faceX + size / 2 + i * (size + gap), p._faceY).setDepth(DEPTH.ui);
arr.push(tile);
});
this.oppFaceObjs.push(arr);
});
}
highlightActive() {
const active = this.players[this.turnIndex];
for (const p of this.players) {
if (p._blockBg) {
const on = p === active;
p._blockBg.setStrokeStyle(on ? 3 : 2, on ? COLORS.accent : 0x2a2a2c);
p._blockBg.setFillStyle(COLORS.panel, on ? 0.8 : 0.55);
}
}
const humanActive = active.type === 'human';
this.rackTrayBg?.setStrokeStyle(3, humanActive ? COLORS.accent : 0x6b5d3e);
}
// ── Notepad / status ──────────────────────────────────────────────────────────
updateNotepad() {
this.players.forEach((p, i) => this.noteScoreObjs[i]?.scoreText.setText(String(p.score)));
this.noteBagText.setText(`Tiles in bag: ${this.bag.length}`);
}
bumpScore(i) {
const obj = this.noteScoreObjs[i]?.scoreText;
if (obj) this.tweens.add({ targets: obj, scale: { from: 1.4, to: 1 }, duration: 220, ease: 'Back.easeOut' });
}
flashStatus(msg) {
this.statusText.setText(msg);
if (this._statusTimer) this._statusTimer.remove();
this._statusTimer = this.time.delayedCall(2600, () => this.statusText.setText(''));
}
clearStatus() { this.statusText.setText(''); }
// ── Game over ──────────────────────────────────────────────────────────────────
endGame(outPlayer) {
if (this.gameOver) return;
this.gameOver = true;
this.setControlsEnabled(false);
// Final rack-penalty scoring
let bonus = 0;
for (const p of this.players) {
const v = rackValue(p.rack);
if (p !== outPlayer) { p.score -= v; bonus += v; }
}
if (outPlayer) outPlayer.score += bonus;
this.updateNotepad();
const winner = this.players.slice().sort((a, b) => b.score - a.score)[0];
const humanWon = winner.type === 'human';
this.recordResult(humanWon);
this.time.delayedCall(700, () => this.showVictoryScreen(winner, humanWon));
}
showVictoryScreen(winner, humanWon) {
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const PW = 760, PH = 520;
const fw = this.add.particles(cx, cy, 'scrabbleParticle', {
speed: { min: 80, max: 460 }, lifespan: 1400, scale: { start: 1.2, end: 0 }, alpha: { start: 1, end: 0 },
quantity: 3, frequency: 35, angle: { min: 0, max: 360 },
tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0xff44aa, 0x88ff44],
emitZone: { type: 'random', source: new Phaser.Geom.Rectangle(-GAME_WIDTH / 2, -GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT) },
}).setDepth(DEPTH.victory - 1);
this.time.delayedCall(3200, () => { fw.stop(); this.time.delayedCall(1400, () => fw.destroy()); });
this.add.rectangle(cx, cy, PW, PH, 0x0a0e14, 0.95).setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.victory);
this.add.text(cx, cy - PH / 2 + 56, humanWon ? 'You Win!' : `${winner.name} Wins!`, {
fontFamily: 'Righteous', fontSize: '46px', color: humanWon ? '#ffd700' : COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.victory + 1);
// final standings, sorted
const sorted = this.players.slice().sort((a, b) => b.score - a.score);
sorted.forEach((p, i) => {
const y = cy - 120 + i * 56;
this.add.text(cx - PW / 2 + 80, y, `${i + 1}. ${p.name}`, {
fontFamily: 'YummyCupcakes', fontSize: '38px', color: p === winner ? '#ffd700' : COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.victory + 1);
this.add.text(cx + PW / 2 - 80, y, String(p.score), {
fontFamily: 'YummyCupcakes', fontSize: '38px', color: p === winner ? '#ffd700' : COLORS.textHex,
}).setOrigin(1, 0.5).setDepth(DEPTH.victory + 1);
});
const btnsY = cy + PH / 2 - 56;
new Button(this, cx - 130, btnsY, 'Play Again', () => this.scene.restart({ ...this._initData }), {
width: 230, height: 52, fontSize: 22,
}).setDepth(DEPTH.victory + 1);
new Button(this, cx + 130, btnsY, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 230, height: 52, fontSize: 22,
}).setDepth(DEPTH.victory + 1);
}
async recordResult(humanWon) {
try {
const opponentScores = this.players.filter(p => p.type === 'ai').map(p => Math.max(0, p.score));
await api.post('/history/single-player', {
slug: 'scrabble',
score: Math.max(0, this.players[0].score),
opponentScores,
result: humanWon ? 'win' : 'loss',
});
} catch { /* best effort */ }
}
// ── Utility ────────────────────────────────────────────────────────────────────
serializeBoard() {
return this.board.map(row => row.map(cell => (cell ? { letter: cell.letter, blank: !!cell.blank } : null)));
}
canHumanAct() {
return !this.gameOver && !this.animating && !this.blankPicking && this.players[this.turnIndex]?.type === 'human';
}
delay(ms) { return new Promise(resolve => this.time.delayedCall(ms, resolve)); }
shutdown() {
for (const p of this.players) p.portrait?.destroy?.();
}
}

View File

@ -0,0 +1,179 @@
// Pure Scrabble rules: board model, tile bag, move legality, word extraction,
// and scoring. No Phaser, no DOM, no network. The scene owns the mutable game
// state (board, bag, racks, scores) and calls these helpers.
//
// Board model: board[row][col] is null (empty) or { letter:'A', blank:false }.
// Placement: { row, col, letter, blank } — a tentative tile the player dragged.
import {
BOARD_SIZE, RACK_SIZE, BLANK, LETTER_VALUES, LETTER_DISTRIBUTION,
tileValue, premiumAt, isCenter,
} from './ScrabbleTiles.js';
// ── Board / bag setup ─────────────────────────────────────────────────────────
export function createEmptyBoard() {
return Array.from({ length: BOARD_SIZE }, () => Array(BOARD_SIZE).fill(null));
}
export function createBag() {
const bag = [];
for (const [letter, count] of Object.entries(LETTER_DISTRIBUTION)) {
for (let i = 0; i < count; i++) bag.push(letter);
}
return bag;
}
export function shuffle(arr, rng = Math.random) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// Pop up to n tiles off the (already shuffled) bag. Mutates the bag.
export function drawTiles(bag, n) {
return bag.splice(0, n);
}
export function isBoardEmpty(board) {
return board.every(row => row.every(cell => !cell));
}
export function applyPlacements(board, placements) {
const next = board.map(row => row.slice());
for (const p of placements) next[p.row][p.col] = { letter: p.letter, blank: !!p.blank };
return next;
}
// Sum of tile values left on a rack (blanks count 0) — used for end-game penalty.
export function rackValue(rack) {
return rack.reduce((s, t) => s + (t === BLANK ? 0 : (LETTER_VALUES[t] ?? 0)), 0);
}
// ── Word extraction ─────────────────────────────────────────────────────────
function applyTemp(board, placements) {
const temp = board.map(row => row.slice());
for (const p of placements) temp[p.row][p.col] = { letter: p.letter, blank: !!p.blank };
return temp;
}
// Read the full contiguous word that passes through (row,col) along an axis.
function wordThrough(temp, row, col, orient) {
const dr = orient === 'down' ? 1 : 0;
const dc = orient === 'across' ? 1 : 0;
let r = row, c = col;
while (temp[r - dr]?.[c - dc]) { r -= dr; c -= dc; } // back up to the start
const cells = [];
while (temp[r]?.[c]) {
cells.push({ row: r, col: c, letter: temp[r][c].letter, blank: temp[r][c].blank });
r += dr; c += dc;
}
return cells;
}
// Every word (length ≥ 2) formed by the placements that contains a new tile.
// Returns [{ word, cells, hasNew }]. The primary-axis word is taken once; the
// perpendicular word at each placed tile catches all cross words (and, for a
// single tile, the other direction).
export function extractWords(board, placements) {
if (!placements.length) return [];
const temp = applyTemp(board, placements);
const sameRow = new Set(placements.map(p => p.row)).size === 1;
const sameCol = new Set(placements.map(p => p.col)).size === 1;
const primary = sameRow && !sameCol ? 'across' : sameCol && !sameRow ? 'down' : 'across';
const cross = primary === 'across' ? 'down' : 'across';
const placedKeys = new Set(placements.map(p => `${p.row},${p.col}`));
const words = [];
const seen = new Set();
const add = (cells) => {
if (cells.length < 2) return;
const key = cells.map(c => `${c.row},${c.col}`).join('|');
if (seen.has(key)) return;
seen.add(key);
const hasNew = cells.some(c => placedKeys.has(`${c.row},${c.col}`));
if (hasNew) words.push({ word: cells.map(c => c.letter).join(''), cells, hasNew });
};
add(wordThrough(temp, placements[0].row, placements[0].col, primary));
for (const p of placements) add(wordThrough(temp, p.row, p.col, cross));
return words;
}
// ── Move legality ─────────────────────────────────────────────────────────────
function hasNeighbor(board, row, col) {
return [[-1, 0], [1, 0], [0, -1], [0, 1]].some(([dr, dc]) => board[row + dr]?.[col + dc]);
}
// Validate the geometry + connectivity of a move and return the words it forms.
// firstMove: true when the board is empty (the move must cover the center).
export function validateMove(board, placements, { firstMove }) {
if (!placements.length) return { ok: false, error: 'Place at least one tile.' };
for (const p of placements) {
if (p.row < 0 || p.row >= BOARD_SIZE || p.col < 0 || p.col >= BOARD_SIZE)
return { ok: false, error: 'Tile is off the board.' };
if (board[p.row][p.col]) return { ok: false, error: 'Square already taken.' };
}
const sameRow = new Set(placements.map(p => p.row)).size === 1;
const sameCol = new Set(placements.map(p => p.col)).size === 1;
if (!sameRow && !sameCol) return { ok: false, error: 'Tiles must be in a single row or column.' };
// Contiguity along the line (existing board tiles may bridge gaps).
if (placements.length > 1) {
const across = sameRow;
const line = across ? placements[0].row : placements[0].col;
const idxs = placements.map(p => (across ? p.col : p.row)).sort((a, b) => a - b);
const filled = (r, c) => board[r][c] || placements.some(p => p.row === r && p.col === c);
for (let i = idxs[0]; i <= idxs[idxs.length - 1]; i++) {
const r = across ? line : i;
const c = across ? i : line;
if (!filled(r, c)) return { ok: false, error: 'Tiles must be connected with no gaps.' };
}
}
if (firstMove) {
if (!placements.some(p => isCenter(p.row, p.col)))
return { ok: false, error: 'The first word must cover the center star.' };
} else if (!placements.some(p => hasNeighbor(board, p.row, p.col))) {
return { ok: false, error: 'Your word must connect to tiles already on the board.' };
}
const words = extractWords(board, placements);
if (!words.length) return { ok: false, error: 'That does not form a word.' };
return { ok: true, words };
}
// ── Scoring ─────────────────────────────────────────────────────────────────
// Premium squares apply only under newly placed tiles. A 7-tile play earns +50.
export function scoreMove(board, placements) {
const words = extractWords(board, placements);
const placedKeys = new Set(placements.map(p => `${p.row},${p.col}`));
let total = 0;
for (const w of words) {
let wordScore = 0;
let wordMult = 1;
for (const cell of w.cells) {
const base = tileValue(cell.letter, cell.blank);
if (placedKeys.has(`${cell.row},${cell.col}`)) {
const prem = premiumAt(cell.row, cell.col);
wordScore += base * prem.letterMult;
wordMult *= prem.wordMult;
} else {
wordScore += base;
}
}
total += wordScore * wordMult;
}
if (placements.length === RACK_SIZE) total += 50;
return total;
}

View File

@ -0,0 +1,70 @@
// Standard English Scrabble tile data and the 15×15 premium-square layout.
// Pure data + tiny helpers — no Phaser, no DOM. The server mirrors the values
// and layout it needs in server/words/scrabbleEngine.js.
export const BOARD_SIZE = 15;
export const CENTER = 7; // center square (0-indexed) — first move must cover it
export const RACK_SIZE = 7;
export const BLANK = '_'; // blank/wild tile token in racks and the bag
// Point value per letter. Blank scores 0.
export const LETTER_VALUES = {
A: 1, B: 3, C: 3, D: 2, E: 1, F: 4, G: 2, H: 4, I: 1, J: 8,
K: 5, L: 1, M: 3, N: 1, O: 1, P: 3, Q: 10, R: 1, S: 1, T: 1,
U: 1, V: 4, W: 4, X: 8, Y: 4, Z: 10, [BLANK]: 0,
};
// How many of each tile are in a full 100-tile bag.
export const LETTER_DISTRIBUTION = {
A: 9, B: 2, C: 2, D: 4, E: 12, F: 2, G: 3, H: 2, I: 9, J: 1,
K: 1, L: 4, M: 2, N: 6, O: 8, P: 2, Q: 1, R: 6, S: 4, T: 6,
U: 4, V: 2, W: 2, X: 1, Y: 2, Z: 1, [BLANK]: 2,
};
// Point value of a single placed tile. Tiles played from a blank score 0,
// which the board model records by storing { letter, blank: true }.
export function tileValue(letter, isBlank = false) {
if (isBlank) return 0;
return LETTER_VALUES[letter] ?? 0;
}
// ── Premium-square layout ────────────────────────────────────────────────────
// Compact, human-verifiable map (symmetric standard Scrabble board):
// 3 = triple word 2 = double word t = triple letter d = double letter
// The center (7,7) is a double word and is rendered with a star.
const LAYOUT = [
'3..d...3...d..3',
'.2...t...t...2.',
'..2...d.d...2..',
'd..2...d...2..d',
'....2.....2....',
'.t...t...t...t.',
'..d...d.d...d..',
'3..d...2...d..3',
'..d...d.d...d..',
'.t...t...t...t.',
'....2.....2....',
'd..2...d...2..d',
'..2...d.d...2..',
'.2...t...t...2.',
'3..d...3...d..3',
];
const CODE = {
'3': { wordMult: 3, letterMult: 1, label: 'TW' },
'2': { wordMult: 2, letterMult: 1, label: 'DW' },
t: { wordMult: 1, letterMult: 3, label: 'TL' },
d: { wordMult: 1, letterMult: 2, label: 'DL' },
'.': { wordMult: 1, letterMult: 1, label: '' },
};
// PREMIUM[row][col] → { wordMult, letterMult, label }. Built once at load.
export const PREMIUM = LAYOUT.map(row => [...row].map(ch => CODE[ch]));
export function premiumAt(row, col) {
return PREMIUM[row]?.[col] ?? CODE['.'];
}
export function isCenter(row, col) {
return row === CENTER && col === CENTER;
}

View File

@ -32,6 +32,7 @@ import DominionGame from './games/dominion/DominionGame.js';
import CheckersGame from './games/checkers/CheckersGame.js'; import CheckersGame from './games/checkers/CheckersGame.js';
import ChessGame from './games/chess/ChessGame.js'; import ChessGame from './games/chess/ChessGame.js';
import WordleGame from './games/wordle/WordleGame.js'; import WordleGame from './games/wordle/WordleGame.js';
import ScrabbleGame from './games/scrabble/ScrabbleGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -77,6 +78,7 @@ const config = {
CheckersGame, CheckersGame,
ChessGame, ChessGame,
WordleGame, WordleGame,
ScrabbleGame,
], ],
}; };

View File

@ -17,7 +17,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame' }; const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame' };
if (slugDispatch[this.game.slug]) { if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], { this.scene.start(slugDispatch[this.game.slug], {
game: this.game, game: this.game,

View File

@ -366,7 +366,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
// Skill control: pips always show the level; the +/- buttons appear only // Skill control: pips always show the level; the +/- buttons appear only
// when this opponent is selected. Enabled for games with a 15 AI skill. // when this opponent is selected. Enabled for games with a 15 AI skill.
if (['nerts', 'checkers', 'chess', 'wordle'].includes(this.gameDef.slug)) { if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble'].includes(this.gameDef.slug)) {
bio.style.webkitLineClamp = '1'; bio.style.webkitLineClamp = '1';
const skillRow = document.createElement('div'); const skillRow = document.createElement('div');

View File

@ -89,6 +89,10 @@ export default class PreloadScene extends Phaser.Scene {
}); });
} }
// Warm the handwritten Scrabble notepad font so its first paint isn't a
// fallback face. Best effort — never block startup on it.
try { await Promise.race([document.fonts.load('48px YummyCupcakes'), new Promise(r => setTimeout(r, 1500))]); } catch { /* ignore */ }
await auth.refresh(); await auth.refresh();
this.scene.start('Landing'); this.scene.start('Landing');
} }

View File

@ -12,6 +12,13 @@
font-style: normal; font-style: normal;
} }
@font-face {
font-family: 'YummyCupcakes';
src: url('/assets/fonts/YummyCupcakes.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
html, body { html, body {
margin: 0; margin: 0;
padding: 0; padding: 0;

View File

@ -45,3 +45,4 @@ registerGame({ slug: 'dominion', name: 'Dominion', category: 'cards', cardGame:
registerGame({ slug: 'checkers', name: 'Checkers', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 }); registerGame({ slug: 'checkers', name: 'Checkers', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
registerGame({ slug: 'chess', name: 'Chess', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 }); registerGame({ slug: 'chess', name: 'Chess', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
registerGame({ slug: 'wordle', name: 'Wordle', category: 'word', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 }); registerGame({ slug: 'wordle', name: 'Wordle', category: 'word', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
registerGame({ slug: 'scrabble', name: 'Scrabble', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });

View File

@ -0,0 +1,199 @@
// Server-side Scrabble dictionary + AI move generator.
//
// Scoring/extraction is imported from the SAME pure modules the browser uses
// (no Phaser/DOM deps there) so the AI ranks moves exactly as the client scores
// them. Move generation is a line-scan DFS over a trie: for every line and every
// legal word-start we walk the trie, consuming board tiles or placing rack tiles
// (with cross-checks), recording each terminal word that connects to the board.
import { scoreMove } from '../../public/src/games/scrabble/ScrabbleLogic.js';
import { BOARD_SIZE, RACK_SIZE, BLANK, CENTER } from '../../public/src/games/scrabble/ScrabbleTiles.js';
let TRIE = null;
let WORD_SET = null;
export function initScrabbleDictionary(words) {
WORD_SET = words instanceof Set ? words : new Set(words);
TRIE = { children: Object.create(null), terminal: false };
for (const w of WORD_SET) {
let node = TRIE;
for (const ch of w) {
node = node.children[ch] || (node.children[ch] = { children: Object.create(null), terminal: false });
}
node.terminal = true;
}
}
export function isValidWord(word) {
return WORD_SET?.has(word) ?? false;
}
export function dictionaryReady() {
return TRIE !== null;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
function rackCounts(rack) {
const counts = Object.create(null);
for (const t of rack) counts[t] = (counts[t] ?? 0) + 1;
return counts;
}
// Take a tile for letter L, preferring a real tile over a blank. Mutates counts.
function takeTile(counts, L) {
if (counts[L] > 0) { counts[L]--; return { token: L, blank: false }; }
if (counts[BLANK] > 0) { counts[BLANK]--; return { token: BLANK, blank: true }; }
return null;
}
function giveTile(counts, t) { counts[t.token]++; }
// Perpendicular word constraint at an empty square. null = no constraint
// (no tiles above/below); otherwise a Set of letters that complete a valid
// cross word. An empty Set means nothing legal can be placed here.
function crossCheckSet(board, r, c, cdr, cdc, cache) {
const key = `${r},${c},${cdr},${cdc}`;
if (cache.has(key)) return cache.get(key);
let pre = '';
let rr = r - cdr, cc = c - cdc;
while (board[rr]?.[cc]) { pre = board[rr][cc].letter + pre; rr -= cdr; cc -= cdc; }
let suf = '';
rr = r + cdr; cc = c + cdc;
while (board[rr]?.[cc]) { suf += board[rr][cc].letter; rr += cdr; cc += cdc; }
let result;
if (!pre && !suf) {
result = null;
} else {
result = new Set();
for (const L of LETTERS) if (WORD_SET.has(pre + L + suf)) result.add(L);
}
cache.set(key, result);
return result;
}
// DFS from (R,C) along (mdr,mdc); cross axis (cdr,cdc). Records every terminal,
// board-connected word that places ≥1 new tile.
function dfs(ctx, R, C, node, placed, usedExisting, touchedCross) {
const { board, counts, mdr, mdc, cdr, cdc, firstMove, moves, cache } = ctx;
const inBounds = R >= 0 && C >= 0 && R < BOARD_SIZE && C < BOARD_SIZE;
const cell = inBounds ? board[R][C] : undefined;
if (inBounds && cell) { // read an existing tile
const child = node.children[cell.letter];
if (child) dfs(ctx, R + mdr, C + mdc, child, placed, true, touchedCross);
return;
}
// square is empty or off-board → the word may end here
if (node.terminal && placed.length > 0) {
const connected = firstMove
? placed.some(p => p.row === CENTER && p.col === CENTER)
: (usedExisting || touchedCross);
if (connected) moves.push(placed.slice());
}
if (!inBounds) return; // cannot place past the edge
const cc = crossCheckSet(board, R, C, cdr, cdc, cache);
for (const L in node.children) {
if (cc && !cc.has(L)) continue;
const tile = takeTile(counts, L);
if (!tile) continue;
placed.push({ row: R, col: C, letter: L, blank: tile.blank });
dfs(ctx, R + mdr, C + mdc, node.children[L], placed, usedExisting, touchedCross || cc !== null);
placed.pop();
giveTile(counts, tile);
}
}
function scanLine(ctx, startList) {
for (const [sr, sc] of startList) {
const lr = sr - ctx.mdr, lc = sc - ctx.mdc;
if (ctx.board[lr]?.[lc]) continue; // mid-word start → skip (covered by an earlier start)
dfs(ctx, sr, sc, TRIE, [], false, false);
}
}
function generateMoves(board, rack, firstMove) {
const counts = rackCounts(rack);
const cache = new Map();
const moves = [];
const directions = firstMove
? [
{ mdr: 0, mdc: 1, cdr: 1, cdc: 0, line: Array.from({ length: BOARD_SIZE }, (_, c) => [CENTER, c]) },
{ mdr: 1, mdc: 0, cdr: 0, cdc: 1, line: Array.from({ length: BOARD_SIZE }, (_, r) => [r, CENTER]) },
]
: buildFullScanDirections();
for (const d of directions) {
const ctx = { board, counts, mdr: d.mdr, mdc: d.mdc, cdr: d.cdr, cdc: d.cdc, firstMove, moves, cache };
scanLine(ctx, d.line);
}
return moves;
}
function buildFullScanDirections() {
const across = { mdr: 0, mdc: 1, cdr: 1, cdc: 0, line: [] };
const down = { mdr: 1, mdc: 0, cdr: 0, cdc: 1, line: [] };
for (let r = 0; r < BOARD_SIZE; r++) for (let c = 0; c < BOARD_SIZE; c++) {
across.line.push([r, c]);
down.line.push([c, r]);
}
return [across, down];
}
// ── Public: choose a move for an AI player ─────────────────────────────────────
// board: 15×15 of null | { letter, blank }. rack: array of tokens ('A'…'Z','_').
// Returns { type:'play', placements, score, word } | { type:'exchange', tiles }
// | { type:'pass' }.
export function chooseMove({ board, rack, skill = 3, firstMove, bagCount = 0 }) {
if (!TRIE) return { type: 'pass' };
const rawMoves = generateMoves(board, rack, firstMove);
if (!rawMoves.length) return fallbackMove(rack, bagCount);
const scored = rawMoves
.map(placements => ({ placements, score: scoreMove(board, placements) }))
.filter(m => m.score > 0)
.sort((a, b) => b.score - a.score);
if (!scored.length) return fallbackMove(rack, bagCount);
const pick = scored[pickIndex(scored.length, skill)];
return { type: 'play', placements: pick.placements, score: pick.score, word: longestWord(pick.placements) };
}
// Map skill 15 onto a slice of the score-ranked move list (0 = best).
function pickIndex(n, skill) {
const ranges = {
5: [0, Math.min(1, n - 1)],
4: [0, Math.max(0, Math.ceil(n * 0.10) - 1)],
3: [Math.floor(n * 0.15), Math.ceil(n * 0.45)],
2: [Math.floor(n * 0.45), Math.ceil(n * 0.75)],
1: [Math.floor(n * 0.70), n - 1],
};
const [lo, hi] = ranges[skill] ?? ranges[3];
const a = Math.max(0, Math.min(lo, n - 1));
const b = Math.max(a, Math.min(hi, n - 1));
return a + Math.floor(Math.random() * (b - a + 1));
}
function longestWord(placements) {
// The display word: rebuild the main line from the placements alone is enough
// for a label; the client recomputes the authoritative words when applying.
return placements.map(p => p.letter).join('');
}
// No legal play: exchange when the bag can support it, otherwise pass.
function fallbackMove(rack, bagCount) {
if (bagCount >= RACK_SIZE) {
return { type: 'exchange', tiles: rack.slice(0, Math.min(rack.length, bagCount)) };
}
return { type: 'pass' };
}

View File

@ -2,6 +2,7 @@ import { Router } from 'express';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { initScrabbleDictionary, isValidWord, chooseMove } from './scrabbleEngine.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt'); const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
@ -103,14 +104,21 @@ function loadWordLists() {
raw = [...COMMON_WORDS].join('\n'); raw = [...COMMON_WORDS].join('\n');
} }
const allWords = raw.split('\n').map(w => w.trim().toUpperCase());
const enableFive = new Set( const enableFive = new Set(
raw.split('\n') allWords.filter(w => w.length === 5 && /^[A-Z]{5}$/.test(w)),
.map(w => w.trim().toUpperCase())
.filter(w => w.length === 5 && /^[A-Z]{5}$/.test(w)),
); );
allFiveLetterWords = enableFive; allFiveLetterWords = enableFive;
// Scrabble dictionary: every ENABLE word of legal play length (215 letters).
const scrabbleWords = new Set(
allWords.filter(w => w.length >= 2 && w.length <= 15 && /^[A-Z]+$/.test(w)),
);
initScrabbleDictionary(scrabbleWords);
console.log(`[words] loaded ${scrabbleWords.size} Scrabble words (215 letters)`);
// Answer pool: prefer curated common words that are also in ENABLE; // Answer pool: prefer curated common words that are also in ENABLE;
// supplement with additional ENABLE words up to a healthy pool size. // supplement with additional ENABLE words up to a healthy pool size.
const curated = [...COMMON_WORDS].filter(w => enableFive.has(w)); const curated = [...COMMON_WORDS].filter(w => enableFive.has(w));
@ -142,4 +150,33 @@ router.post('/wordle/validate', (req, res) => {
res.json({ valid: allFiveLetterWords.has(word) }); res.json({ valid: allFiveLetterWords.has(word) });
}); });
// ── Scrabble ──────────────────────────────────────────────────────────────────
// POST /api/words/scrabble/validate { words: string[] }
// Validates every word a human move forms. Returns the list of invalid ones.
router.post('/scrabble/validate', (req, res) => {
const words = Array.isArray(req.body?.words) ? req.body.words : [];
const invalid = words
.map(w => String(w).trim().toUpperCase())
.filter(w => !isValidWord(w));
res.json({ valid: invalid.length === 0, invalid });
});
// POST /api/words/scrabble/ai-move { board, rack, skill, firstMove, bagCount }
// Returns the AI's chosen move: a play, an exchange, or a pass.
router.post('/scrabble/ai-move', (req, res) => {
const { board, rack, skill, firstMove, bagCount } = req.body ?? {};
if (!Array.isArray(board) || !Array.isArray(rack)) {
return res.status(400).json({ error: 'board and rack are required' });
}
const move = chooseMove({
board,
rack,
skill: Number(skill) || 3,
firstMove: !!firstMove,
bagCount: Number(bagCount) || 0,
});
res.json(move);
});
export default router; export default router;