feat: add Word Search game and improve solo game flow

- Integrate Word Search game into client and server registries
- Add API endpoints for puzzle generation and theme listing
- Update GameMenuScene to skip opponent selection and stop menu music for solo-only games
This commit is contained in:
Brian Fertig 2026-05-29 08:21:48 -06:00
parent 47885bab01
commit 57eeb3bfee
8 changed files with 723 additions and 2 deletions

View File

@ -0,0 +1,436 @@
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 { snapSelection, lettersAt, matchWord } from './WordSearchLogic.js';
// ── Palette / styling ────────────────────────────────────────────────────────
const PAPER = 0xf6efdd; // cream sheet
const PAPER_EDGE = 0xe6dcc0;
const INK = '#3a2a18'; // pencil-dark letters
const TITLE_RED = '#b03a2e';
const PENCIL = 0x6b6256; // preview loop color
const RULE = 0x8a7a5a; // faint grid guide lines
// Rotating ink colors for found-word loops.
const LOOP_COLORS = [0xb03a2e, 0x2e6da4, 0x3c8a4e, 0x8a5cb8, 0xd08020, 0xc02e7a];
const DEPTH = { bg: 0, paper: 1, lines: 2, loop: 5, letter: 7, ui: 20, overlay: 40 };
// ── Play-area geometry (within the 1920×1080 paper sheet) ─────────────────────
const PX = 110, PY = 64, PW = 1700, PH = 956; // paper sheet
const GRID_AX = 196, GRID_AY = 236, GRID_AW = 1060, GRID_AH = 740; // grid region
const WORD_LEFT = 1360, WORD_HDR_X = 1560, WORD_TOP = 250; // checklist
export default class WordSearchGame extends Phaser.Scene {
constructor() { super('WordSearchGame'); }
init(data) {
this._initData = { ...data };
this.gameDef = data.game;
this.puzzle = null;
this.gridRows = []; // array of row strings
this.size = 0;
this.cell = 0;
this.gridX = 0;
this.gridY = 0;
this.remaining = [];
this.wordEntries = {}; // word -> { text, strike }
this.anchor = null; // {r,c}
this.previewCells = [];
this.previewLoop = null;
this.loopColorIdx = 0;
this.gameEnded = false;
this.selectedTheme = 'random';
this.startObjs = [];
}
async create() {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg)
.setDepth(DEPTH.bg);
await this.showStartPanel();
}
// ── Start panel (opponent-select is skipped for this solo game) ─────────────
async showStartPanel() {
let themes;
try {
const res = await api.get('/words/wordsearch/themes');
themes = res.themes ?? [];
} catch {
themes = [
{ id: 'space', label: 'Space' }, { id: 'animals', label: 'Animals' },
{ id: 'food', label: 'Food' }, { id: 'ocean', label: 'Ocean' },
{ id: 'sports', label: 'Sports' }, { id: 'music', label: 'Music' },
];
}
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const sheet = this.add.graphics().setDepth(DEPTH.paper);
sheet.postFX.addShadow(0, 6, 0.02, 1.2, 0x000000, 10, 0.6);
sheet.fillStyle(PAPER, 1);
sheet.fillRoundedRect(cx - 560, cy - 330, 1120, 660, 18);
sheet.lineStyle(3, PAPER_EDGE, 1);
sheet.strokeRoundedRect(cx - 560, cy - 330, 1120, 660, 18);
this.startObjs.push(sheet);
this.startObjs.push(this.add.text(cx, cy - 250, 'Word Search', {
fontFamily: 'YummyCupcakes', fontSize: '92px', color: TITLE_RED,
}).setOrigin(0.5).setDepth(DEPTH.ui));
this.startObjs.push(this.add.text(cx, cy - 170, 'Pick a theme', {
fontFamily: 'YummyCupcakes', fontSize: '44px', color: INK,
}).setOrigin(0.5).setDepth(DEPTH.ui));
// Theme chips: "Surprise me" (random) + each theme.
const chips = [{ id: 'random', label: 'Surprise me' }, ...themes];
this.chipRefs = [];
const chipW = 235, step = 255;
const layoutRow = (items, y) => {
const total = items.length * chipW + (items.length - 1) * (step - chipW);
const startX = cx - total / 2 + chipW / 2;
items.forEach((t, i) => this.makeChip(startX + i * step, y, t.label, t.id));
};
layoutRow(chips.slice(0, 4), cy - 90);
layoutRow(chips.slice(4), cy - 26);
this.refreshChips();
this.startObjs.push(this.add.text(cx, cy + 50, 'Choose difficulty', {
fontFamily: 'YummyCupcakes', fontSize: '40px', color: INK,
}).setOrigin(0.5).setDepth(DEPTH.ui));
const diffY = cy + 140;
[['Easy', 'easy'], ['Medium', 'medium'], ['Hard', 'hard']].forEach(([label, id], i) => {
const b = new Button(this, cx - 260 + i * 260, diffY, label,
() => this.startPuzzle(id, this.selectedTheme),
{ width: 220, height: 64, fontSize: 26 });
b.setDepth(DEPTH.ui);
this.startObjs.push(b);
});
const leave = new Button(this, cx, cy + 250, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 200, height: 50, fontSize: 22 });
leave.setDepth(DEPTH.ui);
this.startObjs.push(leave);
}
makeChip(x, y, label, value) {
const w = 218, h = 52;
const bg = this.add.graphics().setDepth(DEPTH.ui);
const text = this.add.text(x, y, label, {
fontFamily: 'YummyCupcakes', fontSize: '30px', color: INK,
}).setOrigin(0.5).setDepth(DEPTH.ui + 1);
const hit = this.add.rectangle(x, y, w, h, 0xffffff, 0.001)
.setDepth(DEPTH.ui + 1).setInteractive({ useHandCursor: true });
hit.on('pointerdown', () => { this.selectedTheme = value; this.refreshChips(); playSound(this, SFX.PIECE_CLICK); });
const ref = { value, x, y, w, h, bg, text };
this.chipRefs.push(ref);
this.startObjs.push(bg, text, hit);
}
refreshChips() {
this.chipRefs.forEach((c) => {
const sel = c.value === this.selectedTheme;
c.bg.clear();
if (sel) {
c.bg.fillStyle(0xb03a2e, 0.9);
c.bg.fillRoundedRect(c.x - c.w / 2, c.y - c.h / 2, c.w, c.h, 12);
}
c.bg.lineStyle(2, 0x9a7a4a, 0.9);
c.bg.strokeRoundedRect(c.x - c.w / 2, c.y - c.h / 2, c.w, c.h, 12);
c.text.setColor(sel ? '#fff6e6' : INK);
});
}
destroyStart() {
this.startObjs.forEach((o) => o.destroy());
this.startObjs = [];
this.chipRefs = [];
}
// ── Build the puzzle ────────────────────────────────────────────────────────
async startPuzzle(difficulty, theme) {
this.destroyStart();
playSound(this, SFX.PIECE_CLICK);
try {
this.puzzle = await api.get(`/words/wordsearch/start?difficulty=${difficulty}&theme=${theme}`);
} catch (err) {
console.error('[wordsearch] failed to fetch puzzle:', err);
this.showStartPanel();
return;
}
this.gridRows = this.puzzle.grid;
this.size = this.puzzle.size;
this.remaining = [...this.puzzle.words];
this.cell = Math.min(
Math.floor(GRID_AW / this.size),
Math.floor(GRID_AH / this.size),
66,
);
const span = this.cell * this.size;
this.gridX = GRID_AX + (GRID_AW - span) / 2;
this.gridY = GRID_AY + (GRID_AH - span) / 2;
this.buildPaper();
this.buildGrid();
this.buildWordList();
this.buildControls();
this.setupInput();
}
buildPaper() {
const g = this.add.graphics().setDepth(DEPTH.paper);
g.postFX.addShadow(0, 6, 0.02, 1.2, 0x000000, 12, 0.6);
g.fillStyle(PAPER, 1);
g.fillRoundedRect(PX, PY, PW, PH, 20);
g.lineStyle(3, PAPER_EDGE, 1);
g.strokeRoundedRect(PX, PY, PW, PH, 20);
// faint ruled lines across the sheet for paper texture
const lines = this.add.graphics().setDepth(DEPTH.lines);
lines.lineStyle(1, 0xbcd0e0, 0.35);
for (let y = PY + 150; y < PY + PH - 30; y += 44) {
lines.lineBetween(PX + 30, y, PX + PW - 30, y);
}
this.add.text(GAME_WIDTH / 2, PY + 70, `${this.puzzle.themeLabel} Word Search`, {
fontFamily: 'YummyCupcakes', fontSize: '78px', color: TITLE_RED,
}).setOrigin(0.5).setDepth(DEPTH.ui);
}
buildGrid() {
// faint cell guide lines
const guide = this.add.graphics().setDepth(DEPTH.lines);
guide.lineStyle(1, RULE, 0.22);
for (let i = 0; i <= this.size; i++) {
const x = this.gridX + i * this.cell;
const y = this.gridY + i * this.cell;
guide.lineBetween(x, this.gridY, x, this.gridY + this.cell * this.size);
guide.lineBetween(this.gridX, y, this.gridX + this.cell * this.size, y);
}
const fs = `${Math.floor(this.cell * 0.5)}px`;
for (let r = 0; r < this.size; r++) {
for (let c = 0; c < this.size; c++) {
const { x, y } = this.cellCenter(r, c);
this.add.text(x, y, this.gridRows[r][c], {
fontFamily: 'YummyCupcakes', fontSize: fs, color: INK,
}).setOrigin(0.5).setDepth(DEPTH.letter);
}
}
this.previewLoop = this.add.graphics().setDepth(DEPTH.loop);
}
buildWordList() {
this.add.text(WORD_HDR_X, WORD_TOP, 'Find these words', {
fontFamily: 'YummyCupcakes', fontSize: '46px', color: TITLE_RED,
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
const words = this.puzzle.words;
const lineH = Math.min(56, Math.floor(640 / Math.max(8, words.length)));
const startY = WORD_TOP + 90;
words.forEach((word, i) => {
const y = startY + i * lineH;
const text = this.add.text(WORD_LEFT, y, word, {
fontFamily: 'YummyCupcakes', fontSize: '38px', color: INK,
}).setOrigin(0, 0.5).setDepth(DEPTH.ui);
const strike = this.add.graphics().setDepth(DEPTH.ui + 1);
this.wordEntries[word] = { text, strike };
});
}
buildControls() {
new Button(this, PX + 130, PY + PH - 56, 'New puzzle',
() => this.scene.restart(this._initData),
{ variant: 'ghost', width: 220, height: 50, fontSize: 22 }).setDepth(DEPTH.ui);
new Button(this, PX + PW - 110, PY + PH - 56, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 180, height: 50, fontSize: 22 }).setDepth(DEPTH.ui);
}
// ── Selection input ─────────────────────────────────────────────────────────
setupInput() {
this.input.on('pointerdown', (p) => this.onPointerDown(p));
this.input.on('pointermove', (p) => { if (this.anchor) this.updatePreview(p); });
}
onPointerDown(p) {
if (this.gameEnded || !this.puzzle) return;
if (!this.anchor) {
const cell = this.cellAt(p.x, p.y);
if (!cell) return;
this.anchor = cell;
this.previewCells = [[cell.r, cell.c]];
this.drawLoop(this.previewLoop, this.previewCells, PENCIL, 0.85, 6);
playSound(this, SFX.PIECE_CLICK);
return;
}
// Second click commits the current preview.
this.updatePreview(p);
this.commitSelection();
}
updatePreview(p) {
if (!this.anchor) return;
const t = this.clampedCell(p.x, p.y);
const { cells } = snapSelection(this.size, this.anchor.r, this.anchor.c, t.r, t.c);
this.previewCells = cells;
this.drawLoop(this.previewLoop, cells, PENCIL, 0.85, 6);
}
commitSelection() {
const letters = lettersAt(this.gridRows, this.previewCells);
const matched = matchWord(letters, this.remaining);
if (matched) {
this.acceptWord(matched, this.previewCells);
} else {
playSound(this, SFX.PIECE_CLICK);
}
this.clearAnchor();
}
acceptWord(word, cells) {
const color = LOOP_COLORS[this.loopColorIdx % LOOP_COLORS.length];
this.loopColorIdx++;
const gfx = this.add.graphics().setDepth(DEPTH.loop);
this.drawLoop(gfx, cells, color, 0.95, 6);
this.remaining = this.remaining.filter((w) => w !== word);
this.crossOff(word, color);
playSound(this, SFX.PENCIL_WRITE);
if (this.remaining.length === 0) this.handleWin();
}
crossOff(word, color) {
const entry = this.wordEntries[word];
if (!entry) return;
entry.text.setAlpha(0.5);
const b = entry.text.getBounds();
entry.strike.clear();
entry.strike.lineStyle(4, color, 0.9);
entry.strike.lineBetween(b.left - 6, b.centerY + 3, b.right + 6, b.centerY - 3);
}
clearAnchor() {
this.anchor = null;
this.previewCells = [];
this.previewLoop?.clear();
}
// ── Loop drawing ──────────────────────────────────────────────────────────────
// Draws a rotated capsule outline wrapping the line from the first to the last
// cell. A second offset pass gives it a hand-drawn wobble.
drawLoop(gfx, cells, color, alpha, lineW) {
gfx.clear();
const a = this.cellCenter(cells[0][0], cells[0][1]);
const b = this.cellCenter(cells[cells.length - 1][0], cells[cells.length - 1][1]);
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.hypot(dx, dy);
const len = dist + this.cell * 0.92;
const h = this.cell * 0.82;
gfx.setPosition((a.x + b.x) / 2, (a.y + b.y) / 2);
gfx.setRotation(Math.atan2(dy, dx));
gfx.lineStyle(lineW, color, alpha);
gfx.strokeRoundedRect(-len / 2, -h / 2, len, h, h / 2);
gfx.lineStyle(Math.max(1, lineW - 3), color, alpha * 0.45);
gfx.strokeRoundedRect(-len / 2 + 2, -h / 2 - 2, len, h, h / 2);
}
// ── Win ──────────────────────────────────────────────────────────────────────
handleWin() {
this.gameEnded = true;
this.clearAnchor();
this.recordResult('win');
this.time.delayedCall(400, () => this.showWin());
}
showWin() {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setDepth(DEPTH.overlay);
const panel = this.add.graphics().setDepth(DEPTH.overlay + 1);
panel.fillStyle(PAPER, 1);
panel.fillRoundedRect(cx - 380, cy - 200, 760, 400, 18);
panel.lineStyle(3, PAPER_EDGE, 1);
panel.strokeRoundedRect(cx - 380, cy - 200, 760, 400, 18);
this.add.text(cx, cy - 110, 'Solved!', {
fontFamily: 'YummyCupcakes', fontSize: '90px', color: TITLE_RED,
}).setOrigin(0.5).setDepth(DEPTH.overlay + 2);
this.add.text(cx, cy - 20, `You found all ${this.puzzle.words.length} words!`, {
fontFamily: 'YummyCupcakes', fontSize: '40px', color: INK,
}).setOrigin(0.5).setDepth(DEPTH.overlay + 2);
new Button(this, cx - 150, cy + 110, 'New puzzle',
() => this.scene.restart(this._initData),
{ width: 250, height: 56, fontSize: 24 }).setDepth(DEPTH.overlay + 2);
new Button(this, cx + 150, cy + 110, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 250, height: 56, fontSize: 24 }).setDepth(DEPTH.overlay + 2);
}
async recordResult(result) {
try {
const score = (this.puzzle?.words.length ?? 0) * 10;
await api.post('/history/single-player', {
slug: 'wordsearch', score, opponentScores: [], result,
});
} catch { /* best effort */ }
}
// ── Cell helpers ──────────────────────────────────────────────────────────────
cellCenter(r, c) {
return {
x: this.gridX + c * this.cell + this.cell / 2,
y: this.gridY + r * this.cell + this.cell / 2,
};
}
cellAt(x, y) {
const c = Math.floor((x - this.gridX) / this.cell);
const r = Math.floor((y - this.gridY) / this.cell);
if (r < 0 || c < 0 || r >= this.size || c >= this.size) return null;
return { r, c };
}
clampedCell(x, y) {
const clamp = (v) => Math.max(0, Math.min(this.size - 1, v));
return {
r: clamp(Math.floor((y - this.gridY) / this.cell)),
c: clamp(Math.floor((x - this.gridX) / this.cell)),
};
}
}

View File

@ -0,0 +1,77 @@
// Pure helpers for Word Search selection — no Phaser, easy to reason about/test.
// All 8 directions as [rowDelta, colDelta]. Selection always snaps to one of
// these regardless of difficulty; matchWord checks both orientations, so a word
// can be traced from either end.
export const DIRECTIONS = [
[0, 1], [0, -1], [1, 0], [-1, 0],
[1, 1], [1, -1], [-1, 1], [-1, -1],
];
// How many steps we can take from (br,bc) along (dr,dc) before leaving the grid.
function maxStepsInBounds(size, br, bc, dr, dc) {
let steps = Infinity;
if (dr > 0) steps = Math.min(steps, size - 1 - br);
else if (dr < 0) steps = Math.min(steps, br);
if (dc > 0) steps = Math.min(steps, size - 1 - bc);
else if (dc < 0) steps = Math.min(steps, bc);
return steps === Infinity ? 0 : steps;
}
// Given an anchor cell (br,bc) and a target cell (tr,tc), pick the straight line
// that best matches the drag and return its cells. The chosen direction is the
// one whose unit vector is most aligned with the drag; the length is the drag's
// projection onto that direction, clamped to the grid.
export function snapSelection(size, br, bc, tr, tc, allowedDirs = DIRECTIONS) {
const dR = tr - br;
const dC = tc - bc;
if (dR === 0 && dC === 0) {
return { dir: [0, 0], cells: [[br, bc]] };
}
let best = null;
for (const [dr, dc] of allowedDirs) {
const denom = dr * dr + dc * dc; // 1 (orthogonal) or 2 (diagonal)
const proj = (dR * dr + dC * dc) / denom; // steps along this direction
if (proj <= 0) continue; // wrong way — skip
const maxN = maxStepsInBounds(size, br, bc, dr, dc);
const n = Math.max(1, Math.min(Math.round(proj), maxN));
if (n < 1) continue;
// Landing point if we step n times; error vs. the actual target.
const lr = br + dr * n;
const lc = bc + dc * n;
const err = (lr - tr) * (lr - tr) + (lc - tc) * (lc - tc);
// Prefer the direction with the better unit-vector alignment, breaking ties
// by the smaller landing error.
const align = (dR * dr + dC * dc) / (Math.sqrt(dR * dR + dC * dC) * Math.sqrt(denom));
if (!best || align > best.align + 1e-9 || (Math.abs(align - best.align) <= 1e-9 && err < best.err)) {
best = { dr, dc, n, align, err };
}
}
if (!best) return { dir: [0, 0], cells: [[br, bc]] };
const cells = [];
for (let i = 0; i <= best.n; i++) {
cells.push([br + best.dr * i, bc + best.dc * i]);
}
return { dir: [best.dr, best.dc], cells };
}
// Letters under a list of [r,c] cells, joined into a string. `grid` is an array
// of row strings (grid[r][c] indexing works on strings).
export function lettersAt(grid, cells) {
return cells.map(([r, c]) => grid[r][c]).join('');
}
// If `letters` (or its reverse) is one of `words`, return the matched word;
// otherwise null.
export function matchWord(letters, words) {
const rev = [...letters].reverse().join('');
if (words.includes(letters)) return letters;
if (words.includes(rev)) return rev;
return null;
}

View File

@ -35,6 +35,7 @@ import WordleGame from './games/wordle/WordleGame.js';
import ScrabbleGame from './games/scrabble/ScrabbleGame.js'; import ScrabbleGame from './games/scrabble/ScrabbleGame.js';
import GhostGame from './games/ghost/GhostGame.js'; import GhostGame from './games/ghost/GhostGame.js';
import WordLadderGame from './games/wordladder/WordLadderGame.js'; import WordLadderGame from './games/wordladder/WordLadderGame.js';
import WordSearchGame from './games/wordsearch/WordSearchGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -83,6 +84,7 @@ const config = {
ScrabbleGame, ScrabbleGame,
GhostGame, GhostGame,
WordLadderGame, WordLadderGame,
WordSearchGame,
], ],
}; };

View File

@ -3,7 +3,7 @@ import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { api } from '../services/api.js'; import { api } from '../services/api.js';
import { Button } from '../ui/Button.js'; import { Button } from '../ui/Button.js';
import { addFullscreenButton } from '../ui/FullscreenButton.js'; import { addFullscreenButton } from '../ui/FullscreenButton.js';
import { playMenuMusic } from '../ui/MenuMusic.js'; import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js';
export default class GameMenuScene extends Phaser.Scene { export default class GameMenuScene extends Phaser.Scene {
constructor() { super('GameMenu'); } constructor() { super('GameMenu'); }
@ -74,6 +74,13 @@ export default class GameMenuScene extends Phaser.Scene {
} }
openGame(game) { openGame(game) {
// Solo-only games (maxOpponents === 0) skip the opponent-selection screen,
// so stop the menu music here just as OpponentSelectScene does on start.
if (game.maxOpponents === 0) {
stopMenuMusic();
this.scene.start('GameRoom', { game, opponents: [] });
return;
}
this.scene.start('OpponentSelect', { game }); this.scene.start('OpponentSelect', { game });
} }
} }

View File

@ -18,7 +18,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', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame' }; 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', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame' };
if (slugDispatch[this.game.slug]) { if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], { this.scene.start(slugDispatch[this.game.slug], {
game: this.game, game: this.game,

View File

@ -48,3 +48,4 @@ registerGame({ slug: 'wordle', name: 'Wordle', category: 'word', minPlayers: 2,
registerGame({ slug: 'scrabble', name: 'Scrabble', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 }); registerGame({ slug: 'scrabble', name: 'Scrabble', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'ghost', name: 'Ghost', category: 'word', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 }); registerGame({ slug: 'ghost', name: 'Ghost', category: 'word', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
registerGame({ slug: 'wordladder', name: 'Word Ladder', category: 'word', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 1 }); registerGame({ slug: 'wordladder', name: 'Word Ladder', category: 'word', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 1 });
registerGame({ slug: 'wordsearch', name: 'Word Search', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0 });

View File

@ -12,6 +12,10 @@ import {
hintMove as ladderHintMove, hintMove as ladderHintMove,
validWordsOfLength as ladderValidWords, validWordsOfLength as ladderValidWords,
} from './wordLadderEngine.js'; } from './wordLadderEngine.js';
import {
generatePuzzle as wordSearchGenerate,
listThemes as wordSearchThemes,
} from './wordSearchEngine.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');
@ -258,4 +262,19 @@ router.post('/wordladder/hint', (req, res) => {
res.json({ word: ladderHintMove(current, target) }); res.json({ word: ladderHintMove(current, target) });
}); });
// ── Word Search ──────────────────────────────────────────────────────────────
// GET /api/words/wordsearch/start?difficulty=easy|medium|hard&theme=random|space|...
// Returns a generated puzzle: { difficulty, theme, themeLabel, size, words, placements, grid }.
router.get('/wordsearch/start', (req, res) => {
const difficulty = String(req.query.difficulty ?? 'medium').toLowerCase();
const theme = req.query.theme ? String(req.query.theme).toLowerCase() : 'random';
res.json(wordSearchGenerate({ difficulty, theme }));
});
// GET /api/words/wordsearch/themes — list of { id, label } for the start panel.
router.get('/wordsearch/themes', (_req, res) => {
res.json({ themes: wordSearchThemes() });
});
export default router; export default router;

View File

@ -0,0 +1,179 @@
// Server-side Word Search puzzle generator.
//
// A puzzle is a square letter grid with a set of theme words hidden inside it,
// placed along straight lines in one of 8 directions. Difficulty controls grid
// size, how many words are hidden, and which directions are allowed (harder
// puzzles add diagonals and backwards placement).
//
// Self-contained: theme word lists live here, no dependency on the ENABLE list.
const A = 'A'.charCodeAt(0);
// [rowDelta, colDelta] for each direction.
const DIR = {
E: [0, 1],
W: [0, -1],
S: [1, 0],
N: [-1, 0],
SE: [1, 1],
SW: [1, -1],
NE: [-1, 1],
NW: [-1, -1],
};
const DIFFICULTY = {
easy: { id: 'easy', size: 10, count: 8, dirs: [DIR.E, DIR.S] },
medium: { id: 'medium', size: 12, count: 10, dirs: [DIR.E, DIR.S, DIR.SE, DIR.NE] },
hard: { id: 'hard', size: 14, count: 12, dirs: [DIR.E, DIR.S, DIR.SE, DIR.NE, DIR.W, DIR.N, DIR.SW, DIR.NW] },
};
// Curated themes. Each entry: display label + uppercase AZ words (39 letters).
const THEMES = {
space: {
label: 'Space',
words: ['COMET', 'PLANET', 'ROCKET', 'GALAXY', 'ORBIT', 'NEBULA', 'METEOR',
'SOLAR', 'COSMOS', 'SATURN', 'VENUS', 'PLUTO', 'GRAVITY', 'ASTEROID',
'MARS', 'MOON'],
},
animals: {
label: 'Animals',
words: ['TIGER', 'ELEPHANT', 'GIRAFFE', 'MONKEY', 'ZEBRA', 'KANGAROO',
'PENGUIN', 'DOLPHIN', 'LEOPARD', 'PANDA', 'RABBIT', 'FALCON',
'OTTER', 'BEAVER', 'COBRA', 'WALRUS'],
},
food: {
label: 'Food',
words: ['PIZZA', 'BURGER', 'PASTA', 'SALAD', 'CHEESE', 'TOMATO', 'BANANA',
'COOKIE', 'WAFFLE', 'PRETZEL', 'MUFFIN', 'NOODLE', 'PEPPER',
'CARROT', 'YOGURT', 'PANCAKE'],
},
ocean: {
label: 'Ocean',
words: ['CORAL', 'SHARK', 'WHALE', 'OCTOPUS', 'DOLPHIN', 'LOBSTER',
'STARFISH', 'SEAWEED', 'JELLYFISH', 'URCHIN', 'OYSTER', 'MARLIN',
'SHRIMP', 'ANCHOR', 'LAGOON', 'CURRENT'],
},
sports: {
label: 'Sports',
words: ['SOCCER', 'TENNIS', 'HOCKEY', 'BOXING', 'RUGBY', 'SKIING',
'CYCLING', 'ARCHERY', 'BOWLING', 'CRICKET', 'DIVING', 'FENCING',
'KARATE', 'ROWING', 'SAILING', 'SURFING'],
},
music: {
label: 'Music',
words: ['GUITAR', 'PIANO', 'VIOLIN', 'DRUMS', 'TRUMPET', 'FLUTE', 'CELLO',
'HARP', 'BANJO', 'MELODY', 'RHYTHM', 'TEMPO', 'CHORD', 'OCTAVE',
'SONATA', 'BALLAD'],
},
};
function randomFrom(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function resolveDifficulty(id) {
return DIFFICULTY[String(id).toLowerCase()] ?? DIFFICULTY.medium;
}
function resolveTheme(id) {
const key = String(id).toLowerCase();
if (key === 'random' || !THEMES[key]) {
const keys = Object.keys(THEMES);
const pick = randomFrom(keys);
return { key: pick, ...THEMES[pick] };
}
return { key, ...THEMES[key] };
}
// Try to place `word` into `grid` along one of `dirs`. Accepts a placement when
// every target cell is empty or already holds the matching letter (crossings).
// Returns { row, col, dir } on success, or null after exhausting attempts.
function tryPlace(grid, word, dirs, size, attempts = 150) {
for (let a = 0; a < attempts; a++) {
const [dr, dc] = randomFrom(dirs);
const len = word.length;
// Valid start range so the whole word stays in bounds.
const rowMin = dr < 0 ? (len - 1) : 0;
const rowMax = dr > 0 ? (size - len) : (size - 1);
const colMin = dc < 0 ? (len - 1) : 0;
const colMax = dc > 0 ? (size - len) : (size - 1);
if (rowMin > rowMax || colMin > colMax) continue;
const row = rowMin + Math.floor(Math.random() * (rowMax - rowMin + 1));
const col = colMin + Math.floor(Math.random() * (colMax - colMin + 1));
let ok = true;
for (let i = 0; i < len; i++) {
const r = row + dr * i;
const c = col + dc * i;
const cur = grid[r][c];
if (cur !== '' && cur !== word[i]) { ok = false; break; }
}
if (!ok) continue;
for (let i = 0; i < len; i++) {
grid[row + dr * i][col + dc * i] = word[i];
}
return { row, col, dir: [dr, dc] };
}
return null;
}
// Build a single puzzle. Returns the grid as an array of row strings plus the
// list of words actually placed and their placements.
export function generatePuzzle({ difficulty, theme } = {}) {
const cfg = resolveDifficulty(difficulty);
const themeInfo = resolveTheme(theme ?? 'random');
const { size, count, dirs } = cfg;
// Candidate words that fit the grid, longest first so the hard ones place
// before the grid fills up.
const candidates = shuffle(themeInfo.words.filter(w => w.length <= size))
.sort((x, y) => y.length - x.length);
const grid = Array.from({ length: size }, () => Array(size).fill(''));
const placedWords = [];
const placements = [];
for (const word of candidates) {
if (placedWords.length >= count) break;
const placed = tryPlace(grid, word, dirs, size);
if (placed) {
placedWords.push(word);
placements.push({ word, row: placed.row, col: placed.col, dir: placed.dir });
}
}
// Fill the remaining empties with random letters.
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (grid[r][c] === '') {
grid[r][c] = String.fromCharCode(A + Math.floor(Math.random() * 26));
}
}
}
return {
difficulty: cfg.id,
theme: themeInfo.key,
themeLabel: themeInfo.label,
size,
words: placedWords,
placements,
grid: grid.map(row => row.join('')),
};
}
export function listThemes() {
return Object.entries(THEMES).map(([id, t]) => ({ id, label: t.label }));
}