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:
parent
17133787c1
commit
95ff6f8de2
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 1–5 AI skill.
|
// when this opponent is selected. Enabled for games with a 1–5 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');
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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 1–5 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' };
|
||||||
|
}
|
||||||
|
|
@ -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 (2–15 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 (2–15 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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue