feat: add Boggle word game with AI opponents and board solver
- Implement full Boggle frontend using Phaser, featuring a wooden tray UI, player word sheet, opponent cards, and an animated hourglass timer. - Add AI scheduling system that simulates opponent word finds based on configurable skill levels and word length/rarity weighting. - Introduce pure game logic for adjacency validation, path checking, and classic scoring with duplicate-word resolution. - Add backend board generation and exhaustive solver using a dictionary trie, exposing `/api/words/boggle/start` for board setup. - Register Boggle in the game registry, scene router, and opponent selection UI. - Include tutorial assets for Parchisi.
This commit is contained in:
parent
f5e74d7970
commit
63df39442c
Binary file not shown.
|
After Width: | Height: | Size: 692 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 724 KiB |
|
|
@ -0,0 +1,60 @@
|
||||||
|
// Boggle AI — no Phaser. Given the board solution set, produce a time-ordered
|
||||||
|
// schedule of words an opponent "finds" during the round. Higher skill finds more
|
||||||
|
// words and weights longer/rarer words more heavily.
|
||||||
|
|
||||||
|
// Per-skill profile:
|
||||||
|
// fraction — share of the solution set the AI ultimately finds
|
||||||
|
// lengthBias — exponent applied to word length when weighting picks
|
||||||
|
// (higher => strongly prefers longer words)
|
||||||
|
const SKILL = {
|
||||||
|
1: { fraction: 0.02, lengthBias: 0.4 },
|
||||||
|
2: { fraction: 0.03, lengthBias: 0.8 },
|
||||||
|
3: { fraction: 0.06, lengthBias: 1.3 },
|
||||||
|
4: { fraction: 0.10, lengthBias: 1.9 },
|
||||||
|
5: { fraction: 0.15, lengthBias: 2.6 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function profile(skill) {
|
||||||
|
return SKILL[Math.max(1, Math.min(5, skill | 0))] ?? SKILL[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weighted sampling without replacement.
|
||||||
|
function pickWeighted(pool, weights, count) {
|
||||||
|
const chosen = [];
|
||||||
|
const idx = pool.map((_, i) => i);
|
||||||
|
const w = [...weights];
|
||||||
|
for (let k = 0; k < count && idx.length; k++) {
|
||||||
|
let total = 0;
|
||||||
|
for (const i of idx) total += w[i];
|
||||||
|
let roll = Math.random() * total;
|
||||||
|
let pickPos = 0;
|
||||||
|
for (let p = 0; p < idx.length; p++) {
|
||||||
|
roll -= w[idx[p]];
|
||||||
|
if (roll <= 0) { pickPos = p; break; }
|
||||||
|
}
|
||||||
|
chosen.push(pool[idx[pickPos]]);
|
||||||
|
idx.splice(pickPos, 1);
|
||||||
|
}
|
||||||
|
return chosen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// solutions: [{ word, path }] (path unused by the AI but kept for parity)
|
||||||
|
// Returns: [{ atMs, word }] sorted ascending by atMs.
|
||||||
|
export function scheduleFinds(solutions, skill, durationMs) {
|
||||||
|
if (!solutions?.length) return [];
|
||||||
|
const { fraction, lengthBias } = profile(skill);
|
||||||
|
|
||||||
|
const words = solutions.map((s) => s.word);
|
||||||
|
const weights = words.map((w) => Math.pow(w.length, lengthBias));
|
||||||
|
const target = Math.max(1, Math.round(words.length * fraction));
|
||||||
|
|
||||||
|
const picks = pickWeighted(words, weights, target);
|
||||||
|
|
||||||
|
// Spread finds across the middle ~90% of the round with jitter so the count
|
||||||
|
// ticks up naturally rather than all at once.
|
||||||
|
const lead = durationMs * 0.05;
|
||||||
|
const span = durationMs * 0.9;
|
||||||
|
return picks
|
||||||
|
.map((word) => ({ word, atMs: lead + Math.random() * span }))
|
||||||
|
.sort((a, b) => a.atMs - b.atMs);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,839 @@
|
||||||
|
import * as Phaser from 'phaser';
|
||||||
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||||
|
import { api } from '../../services/api.js';
|
||||||
|
import { Button } from '../../ui/Button.js';
|
||||||
|
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||||
|
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||||
|
import { scoreWord, areAdjacent, resolveRound } from './BoggleLogic.js';
|
||||||
|
import { scheduleFinds } from './BoggleAI.js';
|
||||||
|
|
||||||
|
// ── Palette ───────────────────────────────────────────────────────────────────
|
||||||
|
const FELT = 0x14241c; // dark green table
|
||||||
|
const TRAY_WOOD = 0x4a2f1a;
|
||||||
|
const TRAY_EDGE = 0x2c1a0d;
|
||||||
|
const CUBE_FACE = 0xf2e4c2; // ivory cube
|
||||||
|
const CUBE_TOP = 0xfff6e0;
|
||||||
|
const CUBE_LETTER = '#2a1c0c';
|
||||||
|
const SELECT_GOLD = 0xf2c14e;
|
||||||
|
const PAPER = 0xf6efdd;
|
||||||
|
const PAPER_EDGE = 0xe6dcc0;
|
||||||
|
const INK = '#3a2a18';
|
||||||
|
const TITLE_RED = '#b03a2e';
|
||||||
|
const SAND = 0xe0a23a;
|
||||||
|
const GLASS = 0xcfe6ee;
|
||||||
|
|
||||||
|
const D = { bg: 0, tray: 4, cube: 8, line: 11, hud: 14, sheet: 16, sheetTxt: 17, ui: 22, overlay: 40, overlayUI: 42 };
|
||||||
|
|
||||||
|
// ── Tray geometry ───────────────────────────────────────────────────────────────
|
||||||
|
const TRAY_CX = 720, TRAY_CY = 600;
|
||||||
|
const CUBE = 140, GAP = 16;
|
||||||
|
const SPAN = 4 * CUBE + 3 * GAP; // 608
|
||||||
|
const ORIGIN_X = TRAY_CX - SPAN / 2;
|
||||||
|
const ORIGIN_Y = TRAY_CY - SPAN / 2;
|
||||||
|
|
||||||
|
// ── Player sheet geometry ─────────────────────────────────────────────────────
|
||||||
|
const PS_X = 1120, PS_Y = 110, PS_W = 760, PS_H = 870;
|
||||||
|
const PER_COL = 24;
|
||||||
|
|
||||||
|
// ── Opponent cards (left column) ──────────────────────────────────────────────
|
||||||
|
const OPP_X = 40, OPP_Y = 300, OPP_W = 340, OPP_H = 150, OPP_GAP = 24;
|
||||||
|
|
||||||
|
// ── Hourglass ─────────────────────────────────────────────────────────────────
|
||||||
|
const HX = 720, HY = 150, HW = 64, HH = 86;
|
||||||
|
|
||||||
|
const TIMER_OPTIONS = [
|
||||||
|
{ label: '90 sec', ms: 90000 },
|
||||||
|
{ label: '2 min', ms: 120000 },
|
||||||
|
{ label: '3 min', ms: 180000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default class BoggleGame extends Phaser.Scene {
|
||||||
|
constructor() { super('BoggleGame'); }
|
||||||
|
|
||||||
|
init(data) {
|
||||||
|
this._initData = { ...data };
|
||||||
|
this.gameDef = data.game;
|
||||||
|
const opps = (data.opponents ?? []).slice(0, 3);
|
||||||
|
|
||||||
|
this.players = [
|
||||||
|
{ id: 'player', name: 'You', isHuman: true },
|
||||||
|
...opps.map((o) => ({
|
||||||
|
id: o.id, name: o.name ?? 'Rival', skill: o.skill ?? 3,
|
||||||
|
spriteIndex: o.spriteIndex ?? 0, isHuman: false,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
this.round = 1;
|
||||||
|
this.totalRounds = 3;
|
||||||
|
this.timerMs = 90000; // default; chosen on the first pre-round panel
|
||||||
|
this.matchTotal = {};
|
||||||
|
this.players.forEach((p) => { this.matchTotal[p.id] = 0; });
|
||||||
|
|
||||||
|
// per-round state (reset in beginRound)
|
||||||
|
this.playing = false;
|
||||||
|
this.path = [];
|
||||||
|
this.found = {};
|
||||||
|
this.roundObjs = [];
|
||||||
|
this.preObjs = [];
|
||||||
|
this.scoreObjs = [];
|
||||||
|
this.timerTween = null;
|
||||||
|
this.aiSchedules = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.bg);
|
||||||
|
const music = this.cache.json.get('music');
|
||||||
|
if (music?.tracks) new MusicPlayer(this, music.tracks);
|
||||||
|
|
||||||
|
this.add.text(40, 36, 'BOGGLE', {
|
||||||
|
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.goldHex,
|
||||||
|
}).setDepth(D.ui);
|
||||||
|
|
||||||
|
this.events.once('shutdown', () => this.cleanup());
|
||||||
|
|
||||||
|
this.showPreRound();
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
this.timerTween?.remove();
|
||||||
|
this.timerTween = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pre-round panel ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
showPreRound() {
|
||||||
|
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||||
|
const first = this.round === 1;
|
||||||
|
const panelH = first ? 560 : Math.max(520, 440 + this.players.length * 46);
|
||||||
|
|
||||||
|
const panel = this.add.graphics().setDepth(D.overlay);
|
||||||
|
panel.fillStyle(PAPER, 1);
|
||||||
|
panel.fillRoundedRect(cx - 460, cy - panelH / 2, 920, panelH, 22);
|
||||||
|
panel.lineStyle(3, PAPER_EDGE, 1);
|
||||||
|
panel.strokeRoundedRect(cx - 460, cy - panelH / 2, 920, panelH, 22);
|
||||||
|
panel.postFX.addShadow(0, 6, 0.02, 1.2, 0x000000, 12, 0.6);
|
||||||
|
this.preObjs.push(panel);
|
||||||
|
|
||||||
|
const top = cy - panelH / 2;
|
||||||
|
this.preObjs.push(this.add.text(cx, top + 70, 'Boggle', {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '88px', color: TITLE_RED,
|
||||||
|
}).setOrigin(0.5).setDepth(D.overlayUI));
|
||||||
|
|
||||||
|
this.preObjs.push(this.add.text(cx, top + 150, `Round ${this.round} of ${this.totalRounds}`, {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '42px', color: INK,
|
||||||
|
}).setOrigin(0.5).setDepth(D.overlayUI));
|
||||||
|
|
||||||
|
if (first) {
|
||||||
|
this.preObjs.push(this.add.text(cx, top + 230, 'Choose round length', {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '36px', color: INK,
|
||||||
|
}).setOrigin(0.5).setDepth(D.overlayUI));
|
||||||
|
|
||||||
|
this.timerChips = [];
|
||||||
|
TIMER_OPTIONS.forEach((opt, i) => {
|
||||||
|
const x = cx - 270 + i * 270;
|
||||||
|
const y = top + 310;
|
||||||
|
const bg = this.add.graphics().setDepth(D.overlayUI);
|
||||||
|
const txt = this.add.text(x, y, opt.label, {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '34px', color: INK,
|
||||||
|
}).setOrigin(0.5).setDepth(D.overlayUI + 1);
|
||||||
|
const hit = this.add.rectangle(x, y, 230, 64, 0xffffff, 0.001)
|
||||||
|
.setDepth(D.overlayUI + 1).setInteractive({ useHandCursor: true });
|
||||||
|
hit.on('pointerdown', () => { this.timerMs = opt.ms; this.refreshTimerChips(); playSound(this, SFX.PIECE_CLICK); });
|
||||||
|
this.timerChips.push({ ms: opt.ms, x, y, bg, txt });
|
||||||
|
this.preObjs.push(bg, txt, hit);
|
||||||
|
});
|
||||||
|
this.refreshTimerChips();
|
||||||
|
} else {
|
||||||
|
// Standings so far
|
||||||
|
const standings = [...this.players].sort((a, b) => this.matchTotal[b.id] - this.matchTotal[a.id]);
|
||||||
|
standings.forEach((p, i) => {
|
||||||
|
this.preObjs.push(this.add.text(cx, top + 220 + i * 46,
|
||||||
|
`${p.name}: ${this.matchTotal[p.id]}`, {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '34px',
|
||||||
|
color: p.isHuman ? TITLE_RED : INK,
|
||||||
|
}).setOrigin(0.5).setDepth(D.overlayUI));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const begin = new Button(this, cx, top + panelH - 130, 'Begin Round',
|
||||||
|
() => this.beginRound(), { width: 320, height: 70, fontSize: 30 });
|
||||||
|
begin.setDepth(D.overlayUI);
|
||||||
|
this.preObjs.push(begin);
|
||||||
|
|
||||||
|
const leave = new Button(this, cx, top + panelH - 50, 'Leave',
|
||||||
|
() => this.scene.start('GameMenu'),
|
||||||
|
{ variant: 'ghost', width: 200, height: 50, fontSize: 22 });
|
||||||
|
leave.setDepth(D.overlayUI);
|
||||||
|
this.preObjs.push(leave);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshTimerChips() {
|
||||||
|
this.timerChips.forEach((c) => {
|
||||||
|
const sel = c.ms === this.timerMs;
|
||||||
|
c.bg.clear();
|
||||||
|
if (sel) { c.bg.fillStyle(0xb03a2e, 0.9); c.bg.fillRoundedRect(c.x - 115, c.y - 32, 230, 64, 12); }
|
||||||
|
c.bg.lineStyle(2, 0x9a7a4a, 0.9);
|
||||||
|
c.bg.strokeRoundedRect(c.x - 115, c.y - 32, 230, 64, 12);
|
||||||
|
c.txt.setColor(sel ? '#fff6e6' : INK);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyPre() {
|
||||||
|
this.preObjs.forEach((o) => o.destroy());
|
||||||
|
this.preObjs = [];
|
||||||
|
this.timerChips = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Begin a round ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async beginRound() {
|
||||||
|
this.destroyPre();
|
||||||
|
playSound(this, SFX.PIECE_CLICK);
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await api.get('/words/boggle/start');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[boggle] failed to fetch board:', err);
|
||||||
|
this.showPreRound();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.board = data.board; // 4×4 face tokens
|
||||||
|
this.solutions = data.solutions ?? [];
|
||||||
|
this.validWords = new Set(this.solutions.map((s) => s.word));
|
||||||
|
|
||||||
|
// reset per-round state
|
||||||
|
this.playing = false;
|
||||||
|
this.path = [];
|
||||||
|
this.found = {};
|
||||||
|
this.players.forEach((p) => { this.found[p.id] = new Set(); });
|
||||||
|
this.playerSheetWords = [];
|
||||||
|
|
||||||
|
this.buildTray();
|
||||||
|
this.buildPlayerSheet();
|
||||||
|
this.buildOpponentCards();
|
||||||
|
this.buildWordBanner();
|
||||||
|
this.buildControls();
|
||||||
|
this.buildHourglass();
|
||||||
|
|
||||||
|
this.shuffleReveal();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyRound() {
|
||||||
|
this.roundObjs.forEach((o) => o.destroy());
|
||||||
|
this.roundObjs = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tray + cubes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
buildTray() {
|
||||||
|
const g = this.add.graphics().setDepth(D.tray);
|
||||||
|
g.fillStyle(TRAY_EDGE, 1);
|
||||||
|
g.fillRoundedRect(ORIGIN_X - 34, ORIGIN_Y - 34, SPAN + 68, SPAN + 68, 28);
|
||||||
|
g.fillStyle(TRAY_WOOD, 1);
|
||||||
|
g.fillRoundedRect(ORIGIN_X - 22, ORIGIN_Y - 22, SPAN + 44, SPAN + 44, 22);
|
||||||
|
g.lineStyle(3, TRAY_EDGE, 1);
|
||||||
|
g.strokeRoundedRect(ORIGIN_X - 22, ORIGIN_Y - 22, SPAN + 44, SPAN + 44, 22);
|
||||||
|
g.postFX.addShadow(0, 8, 0.02, 1.2, 0x000000, 14, 0.7);
|
||||||
|
this.roundObjs.push(g);
|
||||||
|
|
||||||
|
this.cubes = [];
|
||||||
|
for (let r = 0; r < 4; r++) {
|
||||||
|
this.cubes[r] = [];
|
||||||
|
for (let c = 0; c < 4; c++) {
|
||||||
|
const { x, y } = this.cubeCenter(r, c);
|
||||||
|
const cont = this.add.container(x, y).setDepth(D.cube);
|
||||||
|
const face = this.add.graphics();
|
||||||
|
this.drawCubeFace(face, false);
|
||||||
|
const letter = this.add.text(0, 4, '', {
|
||||||
|
fontFamily: 'Righteous', fontSize: '64px', color: CUBE_LETTER,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
cont.add([face, letter]);
|
||||||
|
cont.setSize(CUBE, CUBE);
|
||||||
|
cont.setInteractive({ useHandCursor: true });
|
||||||
|
cont.on('pointerdown', () => this.onCubeClick(r, c));
|
||||||
|
cont._face = face;
|
||||||
|
cont._letter = letter;
|
||||||
|
this.cubes[r][c] = cont;
|
||||||
|
this.roundObjs.push(cont);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selGfx = this.add.graphics().setDepth(D.cube - 1);
|
||||||
|
this.lineGfx = this.add.graphics().setDepth(D.line);
|
||||||
|
this.roundObjs.push(this.selGfx, this.lineGfx);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCubeFace(g, selected) {
|
||||||
|
g.clear();
|
||||||
|
const s = CUBE / 2;
|
||||||
|
g.fillStyle(TRAY_EDGE, 0.5);
|
||||||
|
g.fillRoundedRect(-s + 3, -s + 5, CUBE, CUBE, 16); // drop
|
||||||
|
g.fillStyle(selected ? SELECT_GOLD : CUBE_FACE, 1);
|
||||||
|
g.fillRoundedRect(-s, -s, CUBE, CUBE, 16);
|
||||||
|
g.fillStyle(CUBE_TOP, selected ? 0.5 : 0.7);
|
||||||
|
g.fillRoundedRect(-s + 8, -s + 8, CUBE - 16, CUBE * 0.4, 12); // top sheen
|
||||||
|
g.lineStyle(3, selected ? 0xb8860b : TRAY_EDGE, selected ? 1 : 0.5);
|
||||||
|
g.strokeRoundedRect(-s, -s, CUBE, CUBE, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
cubeCenter(r, c) {
|
||||||
|
return { x: ORIGIN_X + c * (CUBE + GAP) + CUBE / 2, y: ORIGIN_Y + r * (CUBE + GAP) + CUBE / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shuffle + reveal animation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
shuffleReveal() {
|
||||||
|
playSound(this, SFX.CARD_SHUFFLE);
|
||||||
|
const rand = () => String.fromCharCode(65 + Math.floor(Math.random() * 26));
|
||||||
|
|
||||||
|
// initial pop-in
|
||||||
|
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) {
|
||||||
|
const cube = this.cubes[r][c];
|
||||||
|
cube.setScale(0.2); cube.setAlpha(0.4);
|
||||||
|
this.tweens.add({ targets: cube, scale: 1, alpha: 1, duration: 260, delay: (r * 4 + c) * 12, ease: 'Back.easeOut' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// spin through random letters
|
||||||
|
const cycle = this.time.addEvent({
|
||||||
|
delay: 60, repeat: 13,
|
||||||
|
callback: () => {
|
||||||
|
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) {
|
||||||
|
this.cubes[r][c]._letter.setText(rand());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.time.delayedCall(60 * 14 + 200, () => this.revealLetters());
|
||||||
|
}
|
||||||
|
|
||||||
|
revealLetters() {
|
||||||
|
let last = 0;
|
||||||
|
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) {
|
||||||
|
const cube = this.cubes[r][c];
|
||||||
|
const delay = (r * 4 + c) * 45;
|
||||||
|
last = Math.max(last, delay);
|
||||||
|
this.time.delayedCall(delay, () => {
|
||||||
|
this.tweens.add({
|
||||||
|
targets: cube, scaleX: 0, duration: 90, yoyo: true, ease: 'Quad.easeIn',
|
||||||
|
onYoyo: () => { cube._letter.setText(this.board[r][c]); },
|
||||||
|
});
|
||||||
|
playSound(this, SFX.PIECE_CLICK);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.time.delayedCall(last + 260, () => this.startTimer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hourglass + timer ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
buildHourglass() {
|
||||||
|
this.hg = this.add.container(HX, HY).setDepth(D.hud);
|
||||||
|
this.hgFrame = this.add.graphics();
|
||||||
|
this.hgSand = this.add.graphics();
|
||||||
|
this.hg.add([this.hgSand, this.hgFrame]);
|
||||||
|
this.drawHourglassFrame();
|
||||||
|
this.drawSand(1);
|
||||||
|
this.roundObjs.push(this.hg);
|
||||||
|
|
||||||
|
this.timerText = this.add.text(HX, HY + HH + 30, this.fmtTime(this.timerMs), {
|
||||||
|
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
|
||||||
|
}).setOrigin(0.5).setDepth(D.hud);
|
||||||
|
this.roundObjs.push(this.timerText);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawHourglassFrame() {
|
||||||
|
const g = this.hgFrame;
|
||||||
|
g.clear();
|
||||||
|
g.fillStyle(0x6b4a2a, 1);
|
||||||
|
g.fillRoundedRect(-HW - 10, -HH - 16, (HW + 10) * 2, 14, 5); // top cap
|
||||||
|
g.fillRoundedRect(-HW - 10, HH + 2, (HW + 10) * 2, 14, 5); // bottom cap
|
||||||
|
g.lineStyle(5, 0x6b4a2a, 1);
|
||||||
|
g.beginPath(); g.moveTo(-HW, -HH); g.lineTo(HW, -HH); g.lineTo(0, 0); g.lineTo(-HW, -HH); g.strokePath();
|
||||||
|
g.beginPath(); g.moveTo(-HW, HH); g.lineTo(HW, HH); g.lineTo(0, 0); g.lineTo(-HW, HH); g.strokePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// p = fraction of time remaining (1 → full top, 0 → empty)
|
||||||
|
drawSand(p) {
|
||||||
|
const g = this.hgSand;
|
||||||
|
g.clear();
|
||||||
|
g.fillStyle(GLASS, 0.18);
|
||||||
|
g.fillTriangle(-HW, -HH, HW, -HH, 0, 0);
|
||||||
|
g.fillTriangle(-HW, HH, HW, HH, 0, 0);
|
||||||
|
|
||||||
|
g.fillStyle(SAND, 1);
|
||||||
|
// top sand: shrinking downward triangle anchored at the neck
|
||||||
|
if (p > 0.001) {
|
||||||
|
g.fillTriangle(0, 0, -HW * p, -HH * p, HW * p, -HH * p);
|
||||||
|
}
|
||||||
|
// bottom sand: growing pile rising from the base
|
||||||
|
const fill = 1 - p;
|
||||||
|
if (fill > 0.001) {
|
||||||
|
const apexY = HH - HH * fill;
|
||||||
|
g.fillTriangle(-HW, HH, HW, HH, 0, apexY);
|
||||||
|
}
|
||||||
|
// falling stream
|
||||||
|
if (p > 0.02 && p < 0.99) {
|
||||||
|
g.fillStyle(SAND, 0.9);
|
||||||
|
g.fillRect(-2, 0, 4, HH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startTimer() {
|
||||||
|
// flip the hourglass over, then start draining
|
||||||
|
this.tweens.add({
|
||||||
|
targets: this.hg, angle: 180, duration: 650, ease: 'Back.easeOut',
|
||||||
|
onComplete: () => { this.hg.angle = 0; this.runCountdown(); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
runCountdown() {
|
||||||
|
this.playing = true;
|
||||||
|
|
||||||
|
// build AI find schedules
|
||||||
|
this.aiSchedules = this.players.filter((p) => !p.isHuman).map((p) => ({
|
||||||
|
id: p.id, queue: scheduleFinds(this.solutions, p.skill, this.timerMs), idx: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const state = { t: 0 };
|
||||||
|
this.timerTween = this.tweens.add({
|
||||||
|
targets: state, t: 1, duration: this.timerMs, ease: 'Linear',
|
||||||
|
onUpdate: () => {
|
||||||
|
const remaining = 1 - state.t;
|
||||||
|
this.drawSand(remaining);
|
||||||
|
this.timerText.setText(this.fmtTime(this.timerMs * remaining));
|
||||||
|
this.advanceAI(state.t * this.timerMs);
|
||||||
|
},
|
||||||
|
onComplete: () => this.endRound(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
advanceAI(elapsedMs) {
|
||||||
|
for (const sched of this.aiSchedules) {
|
||||||
|
while (sched.idx < sched.queue.length && sched.queue[sched.idx].atMs <= elapsedMs) {
|
||||||
|
const w = sched.queue[sched.idx].word;
|
||||||
|
sched.idx++;
|
||||||
|
if (!this.found[sched.id].has(w)) {
|
||||||
|
this.found[sched.id].add(w);
|
||||||
|
this.bumpOppCount(sched.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmtTime(ms) {
|
||||||
|
const s = Math.max(0, Math.ceil(ms / 1000));
|
||||||
|
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Player sheet ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
buildPlayerSheet() {
|
||||||
|
const g = this.add.graphics().setDepth(D.sheet);
|
||||||
|
g.fillStyle(PAPER, 1);
|
||||||
|
g.fillRoundedRect(PS_X, PS_Y, PS_W, PS_H, 18);
|
||||||
|
g.lineStyle(3, PAPER_EDGE, 1);
|
||||||
|
g.strokeRoundedRect(PS_X, PS_Y, PS_W, PS_H, 18);
|
||||||
|
g.postFX.addShadow(0, 6, 0.02, 1.2, 0x000000, 12, 0.6);
|
||||||
|
this.roundObjs.push(g);
|
||||||
|
|
||||||
|
this.roundObjs.push(this.add.text(PS_X + PS_W / 2, PS_Y + 56, 'Your Words', {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '56px', color: TITLE_RED,
|
||||||
|
}).setOrigin(0.5).setDepth(D.sheetTxt));
|
||||||
|
|
||||||
|
this.playerScoreText = this.add.text(PS_X + PS_W / 2, PS_Y + 104, 'Score: 0', {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '34px', color: INK,
|
||||||
|
}).setOrigin(0.5).setDepth(D.sheetTxt);
|
||||||
|
this.roundObjs.push(this.playerScoreText);
|
||||||
|
}
|
||||||
|
|
||||||
|
addPlayerWord(word) {
|
||||||
|
const idx = this.playerSheetWords.length;
|
||||||
|
const col = Math.floor(idx / PER_COL);
|
||||||
|
const row = idx % PER_COL;
|
||||||
|
const x = PS_X + 40 + col * ((PS_W - 80) / 2);
|
||||||
|
const y = PS_Y + 150 + row * 30;
|
||||||
|
const t = this.add.text(x, y, word, {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '28px', color: INK,
|
||||||
|
}).setOrigin(0, 0.5).setDepth(D.sheetTxt);
|
||||||
|
t.setScale(1.4); t.setAlpha(0);
|
||||||
|
this.tweens.add({ targets: t, scale: 1, alpha: 1, duration: 220, ease: 'Back.easeOut' });
|
||||||
|
this.playerSheetWords.push(t);
|
||||||
|
this.roundObjs.push(t);
|
||||||
|
|
||||||
|
const total = [...this.found.player].reduce((a, w) => a + scoreWord(w), 0);
|
||||||
|
this.playerScoreText.setText(`Score: ${total}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Opponent cards ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
buildOpponentCards() {
|
||||||
|
this.oppCards = {};
|
||||||
|
const opps = this.players.filter((p) => !p.isHuman);
|
||||||
|
opps.forEach((p, i) => {
|
||||||
|
const y = OPP_Y + i * (OPP_H + OPP_GAP);
|
||||||
|
const g = this.add.graphics().setDepth(D.hud);
|
||||||
|
g.fillStyle(COLORS.panel, 0.95);
|
||||||
|
g.fillRoundedRect(OPP_X, y, OPP_W, OPP_H, 16);
|
||||||
|
g.lineStyle(2, COLORS.accent, 0.8);
|
||||||
|
g.strokeRoundedRect(OPP_X, y, OPP_W, OPP_H, 16);
|
||||||
|
this.roundObjs.push(g);
|
||||||
|
|
||||||
|
const spr = this.add.sprite(OPP_X + 70, y + OPP_H / 2, 'opponents', p.spriteIndex)
|
||||||
|
.setDepth(D.hud + 1);
|
||||||
|
const scale = 116 / spr.height;
|
||||||
|
spr.setScale(scale);
|
||||||
|
const maskG = this.make.graphics();
|
||||||
|
maskG.fillCircle(OPP_X + 70, y + OPP_H / 2, 56);
|
||||||
|
spr.setMask(maskG.createGeometryMask());
|
||||||
|
this.roundObjs.push(spr);
|
||||||
|
|
||||||
|
const ring = this.add.graphics().setDepth(D.hud + 1);
|
||||||
|
ring.lineStyle(3, COLORS.accent, 1); ring.strokeCircle(OPP_X + 70, y + OPP_H / 2, 56);
|
||||||
|
this.roundObjs.push(ring);
|
||||||
|
|
||||||
|
this.roundObjs.push(this.add.text(OPP_X + 142, y + 34, p.name, {
|
||||||
|
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
|
||||||
|
}).setOrigin(0, 0.5).setDepth(D.hud + 1));
|
||||||
|
|
||||||
|
// skill pips
|
||||||
|
for (let s = 0; s < 5; s++) {
|
||||||
|
const pip = this.add.graphics().setDepth(D.hud + 1);
|
||||||
|
const filled = s < p.skill;
|
||||||
|
pip.fillStyle(filled ? COLORS.gold : 0x000000, filled ? 1 : 0.25);
|
||||||
|
pip.fillCircle(OPP_X + 150 + s * 18, y + 64, 6);
|
||||||
|
pip.lineStyle(1, COLORS.gold, 0.8); pip.strokeCircle(OPP_X + 150 + s * 18, y + 64, 6);
|
||||||
|
this.roundObjs.push(pip);
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = this.add.text(OPP_X + OPP_W - 30, y + OPP_H / 2 + 6, '0', {
|
||||||
|
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.goldHex,
|
||||||
|
}).setOrigin(1, 0.5).setDepth(D.hud + 1);
|
||||||
|
const label = this.add.text(OPP_X + OPP_W - 30, y + OPP_H / 2 + 42, 'words', {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '20px', color: COLORS.mutedHex,
|
||||||
|
}).setOrigin(1, 0.5).setDepth(D.hud + 1);
|
||||||
|
this.roundObjs.push(count, label);
|
||||||
|
|
||||||
|
this.oppCards[p.id] = { count };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bumpOppCount(id) {
|
||||||
|
const card = this.oppCards[id];
|
||||||
|
if (!card) return;
|
||||||
|
card.count.setText(String(this.found[id].size));
|
||||||
|
this.tweens.add({ targets: card.count, scale: { from: 1.5, to: 1 }, duration: 220, ease: 'Back.easeOut' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Current-word banner + controls ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
buildWordBanner() {
|
||||||
|
this.wordBanner = this.add.text(TRAY_CX, ORIGIN_Y - 70, '', {
|
||||||
|
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.goldHex,
|
||||||
|
}).setOrigin(0.5).setDepth(D.ui);
|
||||||
|
this.feedback = this.add.text(TRAY_CX, ORIGIN_Y + SPAN + 70, '', {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '36px', color: '#fff',
|
||||||
|
}).setOrigin(0.5).setDepth(D.ui).setAlpha(0);
|
||||||
|
this.roundObjs.push(this.wordBanner, this.feedback);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildControls() {
|
||||||
|
const y = ORIGIN_Y + SPAN + 150;
|
||||||
|
const submit = new Button(this, TRAY_CX + 120, y, 'Submit Word',
|
||||||
|
() => this.submitWord(), { width: 220, height: 64, fontSize: 26 });
|
||||||
|
submit.setDepth(D.ui);
|
||||||
|
const clear = new Button(this, TRAY_CX - 120, y, 'Clear',
|
||||||
|
() => this.clearPath(), { variant: 'ghost', width: 200, height: 64, fontSize: 26 });
|
||||||
|
clear.setDepth(D.ui);
|
||||||
|
const leave = new Button(this, GAME_WIDTH - 100, GAME_HEIGHT - 44, 'Leave',
|
||||||
|
() => this.scene.start('GameMenu'), { variant: 'ghost', width: 160, height: 48, fontSize: 20 });
|
||||||
|
leave.setDepth(D.ui);
|
||||||
|
this.roundObjs.push(submit, clear, leave);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selection input ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
onCubeClick(r, c) {
|
||||||
|
if (!this.playing) return;
|
||||||
|
const cell = [r, c];
|
||||||
|
const path = this.path;
|
||||||
|
|
||||||
|
if (path.length === 0) { path.push(cell); }
|
||||||
|
else {
|
||||||
|
const last = path[path.length - 1];
|
||||||
|
if (last[0] === r && last[1] === c) { path.pop(); } // undo
|
||||||
|
else if (path.some(([pr, pc]) => pr === r && pc === c)) { return; } // used (not last)
|
||||||
|
else if (areAdjacent(last, cell)) { path.push(cell); }
|
||||||
|
else { this.flash('Not adjacent', COLORS.dangerHex); return; }
|
||||||
|
}
|
||||||
|
playSound(this, SFX.PIECE_CLICK);
|
||||||
|
this.redrawSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
redrawSelection() {
|
||||||
|
// cube highlights
|
||||||
|
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) {
|
||||||
|
const sel = this.path.some(([pr, pc]) => pr === r && pc === c);
|
||||||
|
this.drawCubeFace(this.cubes[r][c]._face, sel);
|
||||||
|
}
|
||||||
|
// connecting line + nodes
|
||||||
|
this.lineGfx.clear();
|
||||||
|
if (this.path.length) {
|
||||||
|
const pts = this.path.map(([r, c]) => this.cubeCenter(r, c));
|
||||||
|
this.lineGfx.lineStyle(12, SELECT_GOLD, 0.85);
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
this.lineGfx.lineBetween(pts[i - 1].x, pts[i - 1].y, pts[i].x, pts[i].y);
|
||||||
|
}
|
||||||
|
pts.forEach((p, i) => {
|
||||||
|
this.lineGfx.fillStyle(i === pts.length - 1 ? 0xffffff : SELECT_GOLD, 1);
|
||||||
|
this.lineGfx.fillCircle(p.x, p.y, i === pts.length - 1 ? 16 : 11);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.wordBanner.setText(this.currentWord());
|
||||||
|
}
|
||||||
|
|
||||||
|
currentWord() {
|
||||||
|
return this.path.map(([r, c]) => this.board[r][c].toUpperCase()).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPath() {
|
||||||
|
this.path = [];
|
||||||
|
this.redrawSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
submitWord() {
|
||||||
|
if (!this.playing) return;
|
||||||
|
const w = this.currentWord();
|
||||||
|
if (w.length < 3) { this.flash('Too short', COLORS.dangerHex); return; }
|
||||||
|
if (this.found.player.has(w)) { this.flash('Already found', COLORS.mutedHex); return; }
|
||||||
|
if (!this.validWords.has(w)) { this.flash('Not a word', COLORS.dangerHex); return; }
|
||||||
|
|
||||||
|
this.found.player.add(w);
|
||||||
|
this.addPlayerWord(w);
|
||||||
|
this.flash(`+${scoreWord(w)} ${w}`, COLORS.successHex);
|
||||||
|
playSound(this, SFX.PENCIL_WRITE);
|
||||||
|
this.clearPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
flash(msg, colorHex) {
|
||||||
|
this.feedback.setText(msg).setColor(colorHex).setAlpha(1).setScale(1.2);
|
||||||
|
this.tweens.killTweensOf(this.feedback);
|
||||||
|
this.tweens.add({ targets: this.feedback, scale: 1, duration: 160, ease: 'Back.easeOut' });
|
||||||
|
this.tweens.add({ targets: this.feedback, alpha: 0, delay: 900, duration: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── End of round → scoring ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
endRound() {
|
||||||
|
this.playing = false;
|
||||||
|
this.timerTween = null;
|
||||||
|
this.clearPath();
|
||||||
|
// ensure all scheduled AI words counted
|
||||||
|
for (const sched of this.aiSchedules) {
|
||||||
|
while (sched.idx < sched.queue.length) {
|
||||||
|
this.found[sched.id].add(sched.queue[sched.idx].word);
|
||||||
|
sched.idx++;
|
||||||
|
}
|
||||||
|
this.bumpOppCount(sched.id);
|
||||||
|
}
|
||||||
|
this.time.delayedCall(500, () => this.showScoring());
|
||||||
|
}
|
||||||
|
|
||||||
|
showScoring() {
|
||||||
|
const sheets = this.players.map((p) => ({ found: this.found[p.id] }));
|
||||||
|
const results = resolveRound(sheets); // aligned with this.players order
|
||||||
|
|
||||||
|
const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6)
|
||||||
|
.setDepth(D.overlay);
|
||||||
|
this.scoreObjs.push(dim);
|
||||||
|
this.scoreObjs.push(this.add.text(GAME_WIDTH / 2, 70, `Round ${this.round} — Scoring`, {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '66px', color: '#fff6e6',
|
||||||
|
}).setOrigin(0.5).setDepth(D.overlayUI));
|
||||||
|
|
||||||
|
const n = this.players.length;
|
||||||
|
const colW = Math.min(420, (GAME_WIDTH - 120) / n);
|
||||||
|
const totalW = colW * n;
|
||||||
|
const startX = (GAME_WIDTH - totalW) / 2;
|
||||||
|
const topY = 170;
|
||||||
|
|
||||||
|
// build columns and a flat list of animation steps
|
||||||
|
const steps = [];
|
||||||
|
this.scoreCols = this.players.map((p, pi) => {
|
||||||
|
const cx = startX + pi * colW + colW / 2;
|
||||||
|
const header = this.add.graphics().setDepth(D.overlayUI);
|
||||||
|
header.fillStyle(p.isHuman ? 0xb03a2e : COLORS.panel, 0.95);
|
||||||
|
header.fillRoundedRect(cx - colW / 2 + 10, topY, colW - 20, 70, 12);
|
||||||
|
header.lineStyle(2, COLORS.accent, 0.8);
|
||||||
|
header.strokeRoundedRect(cx - colW / 2 + 10, topY, colW - 20, 70, 12);
|
||||||
|
this.scoreObjs.push(header);
|
||||||
|
|
||||||
|
this.scoreObjs.push(this.add.text(cx, topY + 24, p.name, {
|
||||||
|
fontFamily: 'Righteous', fontSize: '28px', color: COLORS.textHex,
|
||||||
|
}).setOrigin(0.5).setDepth(D.overlayUI + 1));
|
||||||
|
const scoreText = this.add.text(cx, topY + 52, '0 pts', {
|
||||||
|
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.goldHex,
|
||||||
|
}).setOrigin(0.5).setDepth(D.overlayUI + 1);
|
||||||
|
this.scoreObjs.push(scoreText);
|
||||||
|
|
||||||
|
// Notebook paper background behind the word list
|
||||||
|
const nbX = cx - colW / 2 + 10;
|
||||||
|
const nbY = topY + 80;
|
||||||
|
const nbW = colW - 20;
|
||||||
|
const nbH = GAME_HEIGHT - 170 - nbY;
|
||||||
|
const nb = this.add.graphics().setDepth(D.overlayUI);
|
||||||
|
nb.fillStyle(0x000000, 0.22);
|
||||||
|
nb.fillRoundedRect(nbX + 4, nbY + 4, nbW, nbH, 8);
|
||||||
|
nb.fillStyle(0xfdf0d0, 1);
|
||||||
|
nb.fillRoundedRect(nbX, nbY, nbW, nbH, 8);
|
||||||
|
nb.lineStyle(1, 0x9bbfd4, 0.45);
|
||||||
|
for (let ly = topY + 100; ly < nbY + nbH - 10; ly += 30) {
|
||||||
|
nb.lineBetween(nbX + 36, ly, nbX + nbW - 12, ly);
|
||||||
|
}
|
||||||
|
nb.lineStyle(1.5, 0xe8a8a8, 0.65);
|
||||||
|
nb.lineBetween(nbX + 32, nbY + 10, nbX + 32, nbY + nbH - 10);
|
||||||
|
nb.lineStyle(1, 0xc8b080, 0.5);
|
||||||
|
nb.strokeRoundedRect(nbX, nbY, nbW, nbH, 8);
|
||||||
|
this.scoreObjs.push(nb);
|
||||||
|
|
||||||
|
const res = results[pi];
|
||||||
|
const colState = { running: 0, scoreText };
|
||||||
|
res.words.forEach((entry, wi) => {
|
||||||
|
const wy = topY + 100 + wi * 30;
|
||||||
|
if (wy > GAME_HEIGHT - 160) return; // overflow guard
|
||||||
|
const wt = this.add.text(cx, wy, entry.word, {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '26px',
|
||||||
|
color: '#8a7a60',
|
||||||
|
}).setOrigin(0.5).setDepth(D.overlayUI + 1).setAlpha(0.25);
|
||||||
|
this.scoreObjs.push(wt);
|
||||||
|
steps.push({ entry, wt, colState });
|
||||||
|
});
|
||||||
|
return colState;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scoreSteps = steps;
|
||||||
|
this.scoreStepIdx = 0;
|
||||||
|
|
||||||
|
const skip = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT - 70, 'Skip',
|
||||||
|
() => this.finishScoring(), { variant: 'ghost', width: 200, height: 56, fontSize: 24 });
|
||||||
|
skip.setDepth(D.overlayUI + 2);
|
||||||
|
this.scoreObjs.push(skip);
|
||||||
|
this.skipBtn = skip;
|
||||||
|
|
||||||
|
this.results = results;
|
||||||
|
this.stepScore();
|
||||||
|
}
|
||||||
|
|
||||||
|
stepScore() {
|
||||||
|
if (this.scoreStepIdx >= this.scoreSteps.length) { this.finishScoring(); return; }
|
||||||
|
const { entry, wt, colState } = this.scoreSteps[this.scoreStepIdx++];
|
||||||
|
wt.setAlpha(1);
|
||||||
|
this.tweens.add({ targets: wt, scale: { from: 1.3, to: 1 }, duration: 120, ease: 'Back.easeOut' });
|
||||||
|
|
||||||
|
if (entry.dup) {
|
||||||
|
wt.setColor('#7a7064');
|
||||||
|
const b = wt.getBounds();
|
||||||
|
const strike = this.add.graphics().setDepth(D.overlayUI + 2);
|
||||||
|
strike.lineStyle(3, COLORS.danger, 0.9);
|
||||||
|
strike.lineBetween(b.left - 4, b.centerY, b.right + 4, b.centerY);
|
||||||
|
this.scoreObjs.push(strike);
|
||||||
|
playSound(this, SFX.PIECE_CLICK);
|
||||||
|
} else {
|
||||||
|
wt.setColor('#3a2a18');
|
||||||
|
colState.running += entry.score;
|
||||||
|
colState.scoreText.setText(`${colState.running} pts`);
|
||||||
|
this.tweens.add({ targets: colState.scoreText, scale: { from: 1.4, to: 1 }, duration: 160, ease: 'Back.easeOut' });
|
||||||
|
playSound(this, SFX.PENCIL_WRITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scoreTimer = this.time.delayedCall(110, () => this.stepScore());
|
||||||
|
}
|
||||||
|
|
||||||
|
finishScoring() {
|
||||||
|
this.scoreTimer?.remove();
|
||||||
|
// reveal any remaining (skipped) words instantly + finalize totals
|
||||||
|
while (this.scoreStepIdx < this.scoreSteps.length) {
|
||||||
|
const { entry, wt, colState } = this.scoreSteps[this.scoreStepIdx++];
|
||||||
|
wt.setAlpha(1);
|
||||||
|
if (entry.dup) { wt.setColor('#7a7064'); }
|
||||||
|
else { wt.setColor('#3a2a18'); colState.running += entry.score; }
|
||||||
|
}
|
||||||
|
this.scoreCols.forEach((cs) => cs.scoreText.setText(`${cs.running} pts`));
|
||||||
|
this.skipBtn?.destroy(); this.skipBtn = null;
|
||||||
|
|
||||||
|
// commit round scores to match totals
|
||||||
|
this.results.forEach((res, pi) => { this.matchTotal[this.players[pi].id] += res.roundScore; });
|
||||||
|
|
||||||
|
const label = this.round < this.totalRounds ? 'Continue' : 'Final Results';
|
||||||
|
const cont = new Button(this, GAME_WIDTH / 2, GAME_HEIGHT - 70, label,
|
||||||
|
() => this.afterScoring(), { width: 280, height: 60, fontSize: 26 });
|
||||||
|
cont.setDepth(D.overlayUI + 2);
|
||||||
|
this.scoreObjs.push(cont);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterScoring() {
|
||||||
|
this.scoreObjs.forEach((o) => o.destroy());
|
||||||
|
this.scoreObjs = [];
|
||||||
|
this.destroyRound();
|
||||||
|
|
||||||
|
if (this.round < this.totalRounds) {
|
||||||
|
this.round++;
|
||||||
|
this.showPreRound();
|
||||||
|
} else {
|
||||||
|
this.showFinal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Final results ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
showFinal() {
|
||||||
|
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||||
|
const standings = [...this.players].sort((a, b) => this.matchTotal[b.id] - this.matchTotal[a.id]);
|
||||||
|
const topScore = this.matchTotal[standings[0].id];
|
||||||
|
const winners = standings.filter((p) => this.matchTotal[p.id] === topScore);
|
||||||
|
const human = this.matchTotal['player'];
|
||||||
|
const playerWon = human === topScore;
|
||||||
|
const result = playerWon ? (winners.length > 1 ? 'draw' : 'win') : 'loss';
|
||||||
|
|
||||||
|
this.recordResult(result);
|
||||||
|
|
||||||
|
this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.7).setDepth(D.overlay);
|
||||||
|
const panel = this.add.graphics().setDepth(D.overlayUI);
|
||||||
|
panel.fillStyle(PAPER, 1); panel.fillRoundedRect(cx - 420, cy - 320, 840, 640, 22);
|
||||||
|
panel.lineStyle(3, PAPER_EDGE, 1); panel.strokeRoundedRect(cx - 420, cy - 320, 840, 640, 22);
|
||||||
|
|
||||||
|
const title = playerWon
|
||||||
|
? (winners.length > 1 ? 'Tie Game!' : 'You Win!')
|
||||||
|
: `${standings[0].name} Wins!`;
|
||||||
|
this.add.text(cx, cy - 240, title, {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '88px', color: TITLE_RED,
|
||||||
|
}).setOrigin(0.5).setDepth(D.overlayUI + 1);
|
||||||
|
|
||||||
|
standings.forEach((p, i) => {
|
||||||
|
this.add.text(cx, cy - 130 + i * 56, `${i + 1}. ${p.name}`, {
|
||||||
|
fontFamily: 'YummyCupcakes', fontSize: '40px',
|
||||||
|
color: p.isHuman ? TITLE_RED : INK,
|
||||||
|
}).setOrigin(0, 0.5).setDepth(D.overlayUI + 1).setX(cx - 300);
|
||||||
|
this.add.text(cx + 300, cy - 130 + i * 56, `${this.matchTotal[p.id]}`, {
|
||||||
|
fontFamily: 'Righteous', fontSize: '40px', color: INK,
|
||||||
|
}).setOrigin(1, 0.5).setDepth(D.overlayUI + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
new Button(this, cx - 160, cy + 240, 'Play Again',
|
||||||
|
() => this.scene.restart(this._initData), { width: 280, height: 60, fontSize: 26 })
|
||||||
|
.setDepth(D.overlayUI + 1);
|
||||||
|
new Button(this, cx + 160, cy + 240, 'Leave',
|
||||||
|
() => this.scene.start('GameMenu'), { variant: 'ghost', width: 280, height: 60, fontSize: 26 })
|
||||||
|
.setDepth(D.overlayUI + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordResult(result) {
|
||||||
|
try {
|
||||||
|
await api.post('/history/single-player', {
|
||||||
|
slug: 'boggle',
|
||||||
|
score: this.matchTotal['player'],
|
||||||
|
opponentScores: this.players.filter((p) => !p.isHuman).map((p) => this.matchTotal[p.id]),
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
// Pure Boggle logic — no Phaser, no network. Unit-testable.
|
||||||
|
|
||||||
|
// Classic Boggle scoring by letter count. Because solution words store "Qu" as the
|
||||||
|
// two letters Q + U, word.length already counts Qu as two letters automatically.
|
||||||
|
export function scoreWord(word) {
|
||||||
|
const n = word.length;
|
||||||
|
if (n < 3) return 0;
|
||||||
|
if (n <= 4) return 1;
|
||||||
|
if (n === 5) return 2;
|
||||||
|
if (n === 6) return 3;
|
||||||
|
if (n === 7) return 5;
|
||||||
|
return 11; // 8+
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two board cells are adjacent if they differ by at most one row and one column
|
||||||
|
// (8-neighbour), and are not the same cell.
|
||||||
|
export function areAdjacent(a, b) {
|
||||||
|
const dr = Math.abs(a[0] - b[0]);
|
||||||
|
const dc = Math.abs(a[1] - b[1]);
|
||||||
|
return (dr <= 1 && dc <= 1) && !(dr === 0 && dc === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A path is valid if every step is to an adjacent cell and no cell repeats.
|
||||||
|
export function isValidPath(path) {
|
||||||
|
const seen = new Set();
|
||||||
|
for (let i = 0; i < path.length; i++) {
|
||||||
|
const key = path[i][0] + ',' + path[i][1];
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
if (i > 0 && !areAdjacent(path[i - 1], path[i])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Given each player's found words, apply the classic duplicate rule: any word
|
||||||
|
// found by 2+ players scores 0 for everyone. Returns per-player scored results in
|
||||||
|
// the same order as `sheets`.
|
||||||
|
// sheets: [{ found: Iterable<string> }]
|
||||||
|
// => [{ words: [{ word, score, dup }], roundScore }]
|
||||||
|
export function resolveRound(sheets) {
|
||||||
|
const counts = new Map();
|
||||||
|
const lists = sheets.map((s) => {
|
||||||
|
const arr = [...new Set([...s.found].map((w) => w.toUpperCase()))];
|
||||||
|
for (const w of arr) counts.set(w, (counts.get(w) ?? 0) + 1);
|
||||||
|
return arr;
|
||||||
|
});
|
||||||
|
|
||||||
|
return lists.map((arr) => {
|
||||||
|
let roundScore = 0;
|
||||||
|
const words = arr
|
||||||
|
.sort((a, b) => b.length - a.length || a.localeCompare(b))
|
||||||
|
.map((word) => {
|
||||||
|
const dup = (counts.get(word) ?? 0) >= 2;
|
||||||
|
const score = dup ? 0 : scoreWord(word);
|
||||||
|
roundScore += score;
|
||||||
|
return { word, score, dup };
|
||||||
|
});
|
||||||
|
return { words, roundScore };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,7 @@ import GoGame from './games/go/GoGame.js';
|
||||||
import BattleshipGame from './games/battleship/BattleshipGame.js';
|
import BattleshipGame from './games/battleship/BattleshipGame.js';
|
||||||
import MastermindGame from './games/mastermind/MastermindGame.js';
|
import MastermindGame from './games/mastermind/MastermindGame.js';
|
||||||
import Connect4Game from './games/connect4/Connect4Game.js';
|
import Connect4Game from './games/connect4/Connect4Game.js';
|
||||||
|
import BoggleGame from './games/boggle/BoggleGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -101,6 +102,7 @@ const config = {
|
||||||
BattleshipGame,
|
BattleshipGame,
|
||||||
MastermindGame,
|
MastermindGame,
|
||||||
Connect4Game,
|
Connect4Game,
|
||||||
|
BoggleGame,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,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', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game' };
|
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', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame' };
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -376,7 +376,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', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4'].includes(this.gameDef.slug)) {
|
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle'].includes(this.gameDef.slug)) {
|
||||||
bio.style.webkitLineClamp = '1';
|
bio.style.webkitLineClamp = '1';
|
||||||
|
|
||||||
const skillRow = document.createElement('div');
|
const skillRow = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -58,3 +58,4 @@ registerGame({ slug: 'go', name: 'Go', category: 'tabletop', minPlayers: 2, maxP
|
||||||
registerGame({ slug: 'battleship', name: 'Battleship', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true });
|
registerGame({ slug: 'battleship', name: 'Battleship', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true });
|
||||||
registerGame({ slug: 'mastermind', name: 'Mastermind', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true });
|
registerGame({ slug: 'mastermind', name: 'Mastermind', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true });
|
||||||
registerGame({ slug: 'connect4', name: 'Connect 4', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
registerGame({ slug: 'connect4', name: 'Connect 4', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||||
|
registerGame({ slug: 'boggle', name: 'Boggle', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
// Boggle engine: dictionary trie, dice rolling, and board solving.
|
||||||
|
// Pure logic — no Express. Initialized once at server start from the ENABLE list.
|
||||||
|
|
||||||
|
// Standard 16-die Boggle set. The Q die face is treated as the token "Qu".
|
||||||
|
const DICE = [
|
||||||
|
'AAEEGN', 'ABBJOO', 'ACHOPS', 'AFFKPS',
|
||||||
|
'AOOTTW', 'CIMOTU', 'DEILRX', 'DELRVY',
|
||||||
|
'DISTTY', 'EEGHNW', 'EEINSU', 'EHRTVW',
|
||||||
|
'EIOSST', 'ELRTTY', 'HIMNQU', 'HLNNRZ',
|
||||||
|
];
|
||||||
|
|
||||||
|
const MIN_LEN = 3; // minimum scoring word length
|
||||||
|
|
||||||
|
// ── Trie ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Node: { word: boolean, kids: Map<char, node> }. Used for fast prefix pruning
|
||||||
|
// during the board DFS while keeping memory far below a full prefix Set.
|
||||||
|
|
||||||
|
let root = null;
|
||||||
|
|
||||||
|
function makeNode() {
|
||||||
|
return { word: false, kids: new Map() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initBoggleDictionary(words) {
|
||||||
|
root = makeNode();
|
||||||
|
for (const raw of words) {
|
||||||
|
const w = String(raw).toUpperCase();
|
||||||
|
if (w.length < MIN_LEN || w.length > 16 || !/^[A-Z]+$/.test(w)) continue;
|
||||||
|
let node = root;
|
||||||
|
for (const ch of w) {
|
||||||
|
let next = node.kids.get(ch);
|
||||||
|
if (!next) { next = makeNode(); node.kids.set(ch, next); }
|
||||||
|
node = next;
|
||||||
|
}
|
||||||
|
node.word = true;
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Board rolling ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function shuffle(arr) {
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a 4×4 grid (array of 4 rows, each 4 face tokens). Each cell is a single
|
||||||
|
// uppercase letter, except the Q die which yields the token "Qu".
|
||||||
|
export function rollBoard() {
|
||||||
|
const dice = shuffle([...DICE]);
|
||||||
|
const faces = dice.map((die) => {
|
||||||
|
const ch = die[Math.floor(Math.random() * die.length)];
|
||||||
|
return ch === 'Q' ? 'Qu' : ch;
|
||||||
|
});
|
||||||
|
const grid = [];
|
||||||
|
for (let r = 0; r < 4; r++) grid.push(faces.slice(r * 4, r * 4 + 4));
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Solving ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const NEIGHBORS = [
|
||||||
|
[-1, -1], [-1, 0], [-1, 1],
|
||||||
|
[0, -1], [0, 1],
|
||||||
|
[1, -1], [1, 0], [1, 1],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Walk a trie down every character of a face token (handles the two-letter "Qu").
|
||||||
|
// Returns the resulting node, or null if the prefix leaves the trie.
|
||||||
|
function stepNode(node, token) {
|
||||||
|
let n = node;
|
||||||
|
for (const ch of token.toUpperCase()) {
|
||||||
|
n = n.kids.get(ch);
|
||||||
|
if (!n) return null;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns every valid word (length >= 3) found on the board, each with one
|
||||||
|
// representative path: [{ word, path: [[r,c], ...] }]. De-duped by word.
|
||||||
|
export function solveBoard(grid) {
|
||||||
|
if (!root) return [];
|
||||||
|
const size = grid.length;
|
||||||
|
const found = new Map(); // word -> path
|
||||||
|
const visited = Array.from({ length: size }, () => new Array(size).fill(false));
|
||||||
|
|
||||||
|
const dfs = (r, c, node, word, path) => {
|
||||||
|
const next = stepNode(node, grid[r][c]);
|
||||||
|
if (!next) return;
|
||||||
|
const nextWord = word + grid[r][c].toUpperCase();
|
||||||
|
const nextPath = [...path, [r, c]];
|
||||||
|
|
||||||
|
if (next.word && nextWord.length >= MIN_LEN && !found.has(nextWord)) {
|
||||||
|
found.set(nextWord, nextPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
visited[r][c] = true;
|
||||||
|
for (const [dr, dc] of NEIGHBORS) {
|
||||||
|
const nr = r + dr, nc = c + dc;
|
||||||
|
if (nr < 0 || nc < 0 || nr >= size || nc >= size) continue;
|
||||||
|
if (visited[nr][nc]) continue;
|
||||||
|
dfs(nr, nc, next, nextWord, nextPath);
|
||||||
|
}
|
||||||
|
visited[r][c] = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let r = 0; r < size; r++) {
|
||||||
|
for (let c = 0; c < size; c++) {
|
||||||
|
dfs(r, c, root, '', []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...found.entries()].map(([word, path]) => ({ word, path }));
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
listThemes as wordSearchThemes,
|
listThemes as wordSearchThemes,
|
||||||
} from './wordSearchEngine.js';
|
} from './wordSearchEngine.js';
|
||||||
import { generatePuzzle as sudokuGenerate } from './sudokuEngine.js';
|
import { generatePuzzle as sudokuGenerate } from './sudokuEngine.js';
|
||||||
|
import { initBoggleDictionary, rollBoard, solveBoard } from './boggleEngine.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');
|
||||||
|
|
@ -144,6 +145,11 @@ function loadWordLists() {
|
||||||
initWordLadderDictionary(ladderThree, ladderFour);
|
initWordLadderDictionary(ladderThree, ladderFour);
|
||||||
console.log(`[words] loaded Word Ladder dictionaries (${ladderThree.length} 3-letter, ${ladderFour.length} 4-letter)`);
|
console.log(`[words] loaded Word Ladder dictionaries (${ladderThree.length} 3-letter, ${ladderFour.length} 4-letter)`);
|
||||||
|
|
||||||
|
// Boggle dictionary: every ENABLE word of length 3–16 (Boggle scores words 3+).
|
||||||
|
const boggleWords = allWords.filter(w => w.length >= 3 && w.length <= 16 && /^[A-Z]+$/.test(w));
|
||||||
|
initBoggleDictionary(boggleWords);
|
||||||
|
console.log(`[words] loaded ${boggleWords.length} Boggle words (3–16 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));
|
||||||
|
|
@ -175,6 +181,17 @@ router.post('/wordle/validate', (req, res) => {
|
||||||
res.json({ valid: allFiveLetterWords.has(word) });
|
res.json({ valid: allFiveLetterWords.has(word) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Boggle ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/words/boggle/start
|
||||||
|
// Rolls a fresh 4×4 board and returns it with the full solution set (every valid
|
||||||
|
// word and one representative path). The client validates player words and drives
|
||||||
|
// the AI from this set — no per-word round trips needed.
|
||||||
|
router.get('/boggle/start', (_req, res) => {
|
||||||
|
const board = rollBoard();
|
||||||
|
res.json({ board, solutions: solveBoard(board) });
|
||||||
|
});
|
||||||
|
|
||||||
// ── Scrabble ──────────────────────────────────────────────────────────────────
|
// ── Scrabble ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// POST /api/words/scrabble/validate { words: string[] }
|
// POST /api/words/scrabble/validate { words: string[] }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue