feat: add single-player Hangman game with difficulty levels

- Implement Hangman game scene using Phaser with a sketch/paper-style UI
- Add pure logic module for word masking, wrong guess tracking, and win/loss conditions
- Create backend `/api/words/hangman/start` endpoint with curated word pools for easy, medium, and hard difficulties
- Register game in scene manager and route dispatcher for seamless menu integration
- Add score tracking and basic game state management
This commit is contained in:
Brian Fertig 2026-05-30 08:50:44 -06:00
parent d9a68de8e4
commit 2dbcb83754
8 changed files with 706 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

View File

@ -0,0 +1,586 @@
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 { MAX_WRONG, maskWord, isWordSolved, countWrong, wrongLetters } from './HangmanLogic.js';
// ── Palette ──────────────────────────────────────────────────────────────────
const PAPER = 0xFFF8F0;
const PAPER_EDGE = 0xE8D8C0;
const INK = '#3a2a18';
const INK_N = 0x3a2a18;
const TITLE_INK = '#5c3a1e';
const RED_INK = '#b03a2e';
const FADED = '#b09a7a';
const FADED_N = 0xb09a7a;
const SPIRAL_CLR = 0x8a7060;
const CARD_EDGE = 0xc8b898;
const WRONG_RED = '#c0392b';
const SKULL_FULL = 0x4a3020;
const SKULL_FADE = 0xd8c8b0;
const DEPTH = { bg: 0, paper: 1, card: 2, gallows: 5, ui: 20, overlay: 40 };
// ── Layout constants ─────────────────────────────────────────────────────────
const CX = GAME_WIDTH / 2;
const CY = GAME_HEIGHT / 2;
// Paper sheet
const PX = 80, PY = 50, PW = 1760, PH = 980;
// Spiral binding circles
const SPIRAL_R = 18;
const SPIRAL_CNT = 32;
// Gallows card (left)
const GC_X = 260, GC_Y = 220, GC_W = 560, GC_H = 560;
// Info card (right)
const IC_X = 920, IC_Y = 220, IC_W = 820, IC_H = 560;
// Keyboard card (bottom)
const KB_X = 140, KB_Y = 840, KB_W = 1640, KB_H = 190;
// Gallows drawing origin inside gallows card
const GW_OX = GC_X + GC_W / 2 - 20;
const GW_OY = GC_Y + 50;
const GW_H = 400; // total height of gallows
const GW_W = 180; // base width
// ── QWERTY rows ───────────────────────────────────────────────────────────────
const QWERTY = [
['Q','W','E','R','T','Y','U','I','O','P'],
['A','S','D','F','G','H','J','K','L'],
['Z','X','C','V','B','N','M'],
];
export default class HangmanGame extends Phaser.Scene {
constructor() { super('HangmanGame'); }
init(data) {
this._initData = { ...data };
this.gameDef = data.game;
this.word = '';
this.hint = '';
this.category = '';
this.difficulty = '';
this.guessed = new Set();
this.gameEnded = false;
this.gallowsGfx = null;
this.blanksObjs = [];
this.skullObjs = [];
this.wrongText = null;
this.keyButtons = {};
this.startObjs = [];
this.gameObjs = [];
}
async create() {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
this.add.rectangle(CX, CY, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(DEPTH.bg);
await this.showStartPanel();
}
// ── Start panel ──────────────────────────────────────────────────────────────
async showStartPanel() {
const cx = CX, cy = CY;
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 - 520, cy - 290, 1040, 580, 18);
sheet.lineStyle(3, PAPER_EDGE, 1);
sheet.strokeRoundedRect(cx - 520, cy - 290, 1040, 580, 18);
this.startObjs.push(sheet);
this.startObjs.push(
this.add.text(cx, cy - 210, 'Hangman', {
fontFamily: 'YummyCupcakes', fontSize: '100px', color: TITLE_INK,
}).setOrigin(0.5).setDepth(DEPTH.ui),
);
this.startObjs.push(
this.add.text(cx, cy - 110, 'Choose difficulty', {
fontFamily: 'YummyCupcakes', fontSize: '46px', color: INK,
}).setOrigin(0.5).setDepth(DEPTH.ui),
);
const diffs = [['Easy', 'easy'], ['Medium', 'medium'], ['Hard', 'hard']];
diffs.forEach(([label, id], i) => {
const b = new Button(this, cx - 290 + i * 290, cy + 10, label,
() => this.startGame(id), { width: 250, height: 68, fontSize: 30 });
b.setDepth(DEPTH.ui);
this.startObjs.push(b);
});
const leave = new Button(this, cx, cy + 130, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 200, height: 50, fontSize: 22 });
leave.setDepth(DEPTH.ui);
this.startObjs.push(leave);
}
destroyStart() {
this.startObjs.forEach(o => o.destroy());
this.startObjs = [];
}
// ── Start game ───────────────────────────────────────────────────────────────
async startGame(difficulty) {
this.destroyStart();
playSound(this, SFX.PIECE_CLICK);
try {
const data = await api.get(`/words/hangman/start?difficulty=${difficulty}`);
this.word = data.word;
this.hint = data.hint;
this.category = data.category;
this.difficulty = difficulty;
} catch (err) {
console.error('[hangman] failed to fetch word:', err);
this.showStartPanel();
return;
}
this.guessed = new Set();
this.gameEnded = false;
this.buildPaper();
this.buildGallowsCard();
this.buildInfoCard();
this.buildKeyboard();
this.updateGallows();
this.updateBlanks();
this.updateSkulls();
}
// ── Paper + spiral ───────────────────────────────────────────────────────────
buildPaper() {
// Paper sheet
const g = this.add.graphics().setDepth(DEPTH.paper);
g.postFX.addShadow(0, 8, 0.02, 1.2, 0x000000, 14, 0.55);
g.fillStyle(PAPER, 1);
g.fillRoundedRect(PX, PY, PW, PH, 16);
g.lineStyle(2, PAPER_EDGE, 1);
g.strokeRoundedRect(PX, PY, PW, PH, 16);
this.gameObjs.push(g);
// Spiral binding — top and bottom rows
const spiralG = this.add.graphics().setDepth(DEPTH.paper + 1);
const step = PW / (SPIRAL_CNT + 1);
for (let i = 1; i <= SPIRAL_CNT; i++) {
const sx = PX + step * i;
spiralG.fillStyle(SPIRAL_CLR, 0.75);
spiralG.fillCircle(sx, PY + 10, SPIRAL_R);
spiralG.fillStyle(PAPER, 1);
spiralG.fillCircle(sx, PY + 10, SPIRAL_R - 6);
spiralG.fillStyle(SPIRAL_CLR, 0.75);
spiralG.fillCircle(sx, PY + PH - 10, SPIRAL_R);
spiralG.fillStyle(PAPER, 1);
spiralG.fillCircle(sx, PY + PH - 10, SPIRAL_R - 6);
}
this.gameObjs.push(spiralG);
// Title
const title = this.add.text(CX, PY + 110, 'Hangman', {
fontFamily: 'YummyCupcakes', fontSize: '92px', color: TITLE_INK,
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.gameObjs.push(title);
// Leave button
const leave = new Button(this, PX + PW - 120, PY + 48, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 180, height: 46, fontSize: 20 });
leave.setDepth(DEPTH.ui);
this.gameObjs.push(leave);
}
// ── Gallows card ─────────────────────────────────────────────────────────────
buildGallowsCard() {
const g = this.add.graphics().setDepth(DEPTH.card);
this.drawSketchCard(g, GC_X, GC_Y, GC_W, GC_H);
this.gameObjs.push(g);
this.gallowsGfx = this.add.graphics().setDepth(DEPTH.gallows);
this.gameObjs.push(this.gallowsGfx);
}
updateGallows() {
const wrong = countWrong(this.word, this.guessed);
const gfx = this.gallowsGfx;
if (!gfx) return;
gfx.clear();
const lw = 5;
gfx.lineStyle(lw, INK_N, 1);
// Stage 0 — always: base, pole, beam, rope
const bx = GC_X + 80, by = GC_Y + GC_H - 60; // base left
const bx2= bx + GW_W; // base right
const px = bx + 50, py = by; // pole base
const ptop = GC_Y + 90; // pole top
const bm_r = ptop + 36; // beam right end (x)
const bm_rx= px + 130;
const ropeX = bm_rx, ropeY1 = bm_r, ropeY2 = bm_r + 50;
// base
gfx.lineBetween(bx, by, bx2, by);
// pole
gfx.lineBetween(px, by, px, ptop);
// beam
gfx.lineBetween(px, ptop, bm_rx, ptop);
// rope
gfx.lineBetween(ropeX, ptop + 4, ropeX, ropeY2);
// head center
const headCY = ropeY2 + 30;
const headR = 28;
// body
const bodyTop = headCY + headR;
const bodyBot = bodyTop + 90;
// arm y
const armY = bodyTop + 28;
// leg endpoints
const legBotL = { x: ropeX - 44, y: bodyBot + 70 };
const legBotR = { x: ropeX + 44, y: bodyBot + 70 };
if (wrong >= 1) {
// Head
gfx.lineStyle(lw, INK_N, 1);
gfx.strokeCircle(ropeX, headCY, headR);
}
if (wrong >= 2) {
// Body
gfx.lineBetween(ropeX, bodyTop, ropeX, bodyBot);
}
if (wrong >= 3) {
// Left arm
gfx.lineBetween(ropeX, armY, ropeX - 52, armY + 40);
}
if (wrong >= 4) {
// Right arm
gfx.lineBetween(ropeX, armY, ropeX + 52, armY + 40);
}
if (wrong >= 5) {
// Left leg
gfx.lineBetween(ropeX, bodyBot, legBotL.x, legBotL.y);
}
if (wrong >= 6) {
// Right leg
gfx.lineBetween(ropeX, bodyBot, legBotR.x, legBotR.y);
}
if (wrong >= 7) {
// Eyes X
gfx.lineStyle(3, INK_N, 1);
const ex = 10;
gfx.lineBetween(ropeX - ex, headCY - ex, ropeX - ex + 8, headCY - ex + 8);
gfx.lineBetween(ropeX - ex + 8, headCY - ex, ropeX - ex, headCY - ex + 8);
gfx.lineBetween(ropeX + ex - 8, headCY - ex, ropeX + ex, headCY - ex + 8);
gfx.lineBetween(ropeX + ex, headCY - ex, ropeX + ex - 8, headCY - ex + 8);
}
}
// ── Info card ────────────────────────────────────────────────────────────────
buildInfoCard() {
const g = this.add.graphics().setDepth(DEPTH.card);
this.drawSketchCard(g, IC_X, IC_Y, IC_W, IC_H);
this.gameObjs.push(g);
// Category label
const catLabel = this.add.text(IC_X + IC_W / 2, IC_Y + 46,
`Category: ${this.category}`, {
fontFamily: 'YummyCupcakes', fontSize: '36px', color: FADED,
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.gameObjs.push(catLabel);
// Hint label — easy only
if (this.difficulty === 'easy') {
const hintLabel = this.add.text(IC_X + IC_W / 2, IC_Y + 96,
this.hint, {
fontFamily: 'YummyCupcakes', fontSize: '30px', color: INK,
wordWrap: { width: IC_W - 60 }, align: 'center',
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
this.gameObjs.push(hintLabel);
}
// Word blanks row — built into this.blanksObjs
this.buildBlanks();
// Skull tally area — rebuilt on each wrong guess
this.buildSkullArea();
// Wrong letters text
this.wrongText = this.add.text(IC_X + IC_W / 2, IC_Y + IC_H - 48,
'Wrong: —', {
fontFamily: 'YummyCupcakes', fontSize: '32px', color: WRONG_RED,
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.gameObjs.push(this.wrongText);
}
buildBlanks() {
// Destroy old
this.blanksObjs.forEach(o => o.destroy());
this.blanksObjs = [];
const letters = maskWord(this.word, this.guessed);
const letterW = Math.min(62, Math.floor((IC_W - 60) / this.word.length));
const totalW = this.word.length * letterW;
const startX = IC_X + (IC_W - totalW) / 2 + letterW / 2;
const y = IC_Y + IC_H / 2 - 10;
letters.forEach((ch, i) => {
const x = startX + i * letterW;
// Underline
const line = this.add.graphics().setDepth(DEPTH.ui);
line.lineStyle(3, INK_N, 1);
line.lineBetween(x - letterW / 2 + 6, y + 26, x + letterW / 2 - 6, y + 26);
this.blanksObjs.push(line);
// Letter (or blank space)
const t = this.add.text(x, y, ch === '_' ? '' : ch, {
fontFamily: 'YummyCupcakes', fontSize: '48px', color: INK,
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.blanksObjs.push(t);
});
}
buildSkullArea() {
this.skullObjs.forEach(o => o.destroy());
this.skullObjs = [];
const skullG = this.add.graphics().setDepth(DEPTH.ui);
const y = IC_Y + IC_H - 110;
const startX = IC_X + (IC_W - MAX_WRONG * 44) / 2;
for (let i = 0; i < MAX_WRONG; i++) {
const x = startX + i * 44 + 22;
this.drawSkullIcon(skullG, x, y, false); // all faded initially
}
this.skullObjs.push(skullG);
}
updateSkulls() {
this.skullObjs.forEach(o => o.destroy());
this.skullObjs = [];
const wrong = countWrong(this.word, this.guessed);
const skullG = this.add.graphics().setDepth(DEPTH.ui);
const y = IC_Y + IC_H - 110;
const startX = IC_X + (IC_W - MAX_WRONG * 44) / 2;
for (let i = 0; i < MAX_WRONG; i++) {
const x = startX + i * 44 + 22;
const lit = i < wrong;
this.drawSkullIcon(skullG, x, y, lit);
}
this.skullObjs.push(skullG);
}
drawSkullIcon(gfx, x, y, lit) {
const color = lit ? SKULL_FULL : SKULL_FADE;
gfx.fillStyle(color, 1);
// Dome
gfx.fillCircle(x, y - 4, 11);
// Jaw rect
gfx.fillRect(x - 8, y + 4, 16, 8);
// Eye sockets (cutouts — draw with paper color)
gfx.fillStyle(PAPER, 1);
gfx.fillCircle(x - 4, y - 4, 3);
gfx.fillCircle(x + 4, y - 4, 3);
}
updateBlanks() {
this.buildBlanks();
}
updateWrongText() {
if (!this.wrongText) return;
const wl = wrongLetters(this.word, this.guessed);
this.wrongText.setText(wl.length ? `Wrong: ${wl.join(' ')}` : 'Wrong: —');
}
// ── Keyboard ─────────────────────────────────────────────────────────────────
buildKeyboard() {
const g = this.add.graphics().setDepth(DEPTH.card);
this.drawSketchCard(g, KB_X, KB_Y, KB_W, KB_H);
this.gameObjs.push(g);
const btnW = 110, btnH = 54;
const gapX = 118, gapY = 56;
const row0Y = KB_Y + 40;
QWERTY.forEach((row, ri) => {
const rowWidth = row.length * gapX - (gapX - btnW);
const rowStartX = KB_X + (KB_W - rowWidth) / 2 + btnW / 2;
row.forEach((letter, ci) => {
const bx = rowStartX + ci * gapX;
const by = row0Y + ri * gapY;
const bg = this.add.graphics().setDepth(DEPTH.ui);
bg.lineStyle(2, CARD_EDGE, 1);
bg.strokeRoundedRect(bx - btnW / 2, by - btnH / 2, btnW, btnH, 8);
const txt = this.add.text(bx, by, letter, {
fontFamily: 'YummyCupcakes', fontSize: '36px', color: INK,
}).setOrigin(0.5).setDepth(DEPTH.ui + 1);
const hit = this.add.rectangle(bx, by, btnW, btnH, 0xffffff, 0.001)
.setDepth(DEPTH.ui + 2).setInteractive({ useHandCursor: true });
hit.on('pointerdown', () => this.onLetterClick(letter, bg, txt, hit));
hit.on('pointerover', () => {
if (!this.guessed.has(letter)) {
bg.clear();
bg.fillStyle(INK_N, 0.08);
bg.fillRoundedRect(bx - btnW / 2, by - btnH / 2, btnW, btnH, 8);
bg.lineStyle(2, INK_N, 0.6);
bg.strokeRoundedRect(bx - btnW / 2, by - btnH / 2, btnW, btnH, 8);
}
});
hit.on('pointerout', () => {
if (!this.guessed.has(letter)) {
bg.clear();
bg.lineStyle(2, CARD_EDGE, 1);
bg.strokeRoundedRect(bx - btnW / 2, by - btnH / 2, btnW, btnH, 8);
}
});
this.keyButtons[letter] = { bg, txt, hit, bx, by, btnW, btnH };
this.gameObjs.push(bg, txt, hit);
});
});
}
onLetterClick(letter, bg, txt, hit) {
if (this.gameEnded || this.guessed.has(letter)) return;
this.guessed.add(letter);
playSound(this, SFX.PIECE_CLICK);
const correct = this.word.includes(letter);
const { bx, by, btnW, btnH } = this.keyButtons[letter];
// Disable and style the key
hit.disableInteractive();
bg.clear();
if (correct) {
bg.fillStyle(0x3c8a4e, 0.25);
bg.fillRoundedRect(bx - btnW / 2, by - btnH / 2, btnW, btnH, 8);
bg.lineStyle(2, 0x3c8a4e, 0.8);
bg.strokeRoundedRect(bx - btnW / 2, by - btnH / 2, btnW, btnH, 8);
txt.setColor('#2d6e3e');
} else {
bg.fillStyle(0xb03a2e, 0.15);
bg.fillRoundedRect(bx - btnW / 2, by - btnH / 2, btnW, btnH, 8);
bg.lineStyle(2, 0xb03a2e, 0.7);
bg.strokeRoundedRect(bx - btnW / 2, by - btnH / 2, btnW, btnH, 8);
txt.setColor('#9a3228');
}
this.updateBlanks();
this.updateGallows();
this.updateSkulls();
this.updateWrongText();
const wrong = countWrong(this.word, this.guessed);
if (isWordSolved(this.word, this.guessed)) {
this.time.delayedCall(400, () => this.handleWin());
} else if (wrong >= MAX_WRONG) {
this.time.delayedCall(400, () => this.handleLoss());
}
}
// ── Win / Loss ───────────────────────────────────────────────────────────────
handleWin() {
this.gameEnded = true;
this.recordResult('win');
this.showEndPanel(true);
}
handleLoss() {
this.gameEnded = true;
// Reveal the word
this.word.split('').forEach(ch => this.guessed.add(ch));
this.updateBlanks();
this.recordResult('loss');
this.showEndPanel(false);
}
showEndPanel(won) {
const cx = CX, cy = CY;
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.postFX.addShadow(0, 6, 0.02, 1.2, 0x000000, 10, 0.55);
panel.fillStyle(PAPER, 1);
panel.fillRoundedRect(cx - 420, cy - 220, 840, 440, 18);
panel.lineStyle(3, PAPER_EDGE, 1);
panel.strokeRoundedRect(cx - 420, cy - 220, 840, 440, 18);
this.add.text(cx, cy - 130, won ? 'You got it!' : 'Game over!', {
fontFamily: 'YummyCupcakes', fontSize: '88px',
color: won ? '#3c8a4e' : RED_INK,
}).setOrigin(0.5).setDepth(DEPTH.overlay + 2);
if (!won) {
this.add.text(cx, cy - 28, `The word was: ${this.word}`, {
fontFamily: 'YummyCupcakes', fontSize: '44px', color: INK,
}).setOrigin(0.5).setDepth(DEPTH.overlay + 2);
} else {
this.add.text(cx, cy - 28, `"${this.hint}"`, {
fontFamily: 'YummyCupcakes', fontSize: '36px', color: FADED,
}).setOrigin(0.5).setDepth(DEPTH.overlay + 2);
}
new Button(this, cx - 160, cy + 130, 'Play again',
() => this.scene.restart(this._initData),
{ width: 260, height: 58, fontSize: 26 }).setDepth(DEPTH.overlay + 2);
new Button(this, cx + 160, cy + 130, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 260, height: 58, fontSize: 26 }).setDepth(DEPTH.overlay + 2);
}
async recordResult(result) {
try {
const wrong = countWrong(this.word, this.guessed);
const score = result === 'win' ? Math.max(0, (MAX_WRONG - wrong) * 15) : 0;
await api.post('/history/single-player', {
slug: 'hangman', score, opponentScores: [], result,
});
} catch { /* best effort */ }
}
// ── Helpers ───────────────────────────────────────────────────────────────────
drawSketchCard(gfx, x, y, w, h) {
gfx.fillStyle(PAPER, 1);
gfx.fillRoundedRect(x, y, w, h, 12);
gfx.lineStyle(2, CARD_EDGE, 1);
gfx.strokeRoundedRect(x, y, w, h, 12);
// Subtle inner shadow line for sketch feel
gfx.lineStyle(1, CARD_EDGE, 0.4);
gfx.strokeRoundedRect(x + 3, y + 3, w - 6, h - 6, 10);
}
}

View File

@ -0,0 +1,24 @@
// Pure helpers for Hangman — no Phaser dependency.
export const MAX_WRONG = 7;
// Returns the masked word: revealed letters shown, others as '_'.
export function maskWord(word, guessed) {
return word.split('').map(ch => guessed.has(ch) ? ch : '_');
}
// Returns true if all letters in word have been guessed.
export function isWordSolved(word, guessed) {
return word.split('').every(ch => guessed.has(ch));
}
// Returns wrongGuesses count (letters guessed that are not in the word).
export function countWrong(word, guessed) {
return [...guessed].filter(ch => !word.includes(ch)).length;
}
// List of wrong guessed letters in the order they were guessed (from a Set,
// so we return an array filtered from all guessed).
export function wrongLetters(word, guessed) {
return [...guessed].filter(ch => !word.includes(ch));
}

View File

@ -37,6 +37,7 @@ import ScrabbleGame from './games/scrabble/ScrabbleGame.js';
import GhostGame from './games/ghost/GhostGame.js';
import WordLadderGame from './games/wordladder/WordLadderGame.js';
import WordSearchGame from './games/wordsearch/WordSearchGame.js';
import HangmanGame from './games/hangman/HangmanGame.js';
const config = {
type: Phaser.AUTO,
@ -87,6 +88,7 @@ const config = {
GhostGame,
WordLadderGame,
WordSearchGame,
HangmanGame,
],
};

View File

@ -20,7 +20,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
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' };
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' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -50,3 +50,4 @@ registerGame({ slug: 'scrabble', name: 'Scrabble', category: 'word', minPlayers:
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: 'wordsearch', name: 'Word Search', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0 });
registerGame({ slug: 'hangman', name: 'Hangman', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0 });

View File

@ -264,6 +264,98 @@ router.post('/wordladder/hint', (req, res) => {
// ── Word Search ──────────────────────────────────────────────────────────────
// ── Hangman ──────────────────────────────────────────────────────────────────
const HANGMAN_WORDS = {
easy: [
{ word: 'CAT', hint: 'A common household pet', category: 'Animals' },
{ word: 'DOG', hint: 'Man\'s best friend', category: 'Animals' },
{ word: 'SUN', hint: 'It lights up the day', category: 'Nature' },
{ word: 'FISH', hint: 'Lives in water', category: 'Animals' },
{ word: 'BIRD', hint: 'Has wings and feathers', category: 'Animals' },
{ word: 'TREE', hint: 'Tall plant with a trunk', category: 'Nature' },
{ word: 'CAKE', hint: 'Sweet birthday treat', category: 'Food' },
{ word: 'STAR', hint: 'Twinkles in the night sky', category: 'Nature' },
{ word: 'FROG', hint: 'Hops and croaks', category: 'Animals' },
{ word: 'RAIN', hint: 'Falls from clouds', category: 'Nature' },
{ word: 'DUCK', hint: 'Says quack', category: 'Animals' },
{ word: 'SNOW', hint: 'White and cold', category: 'Nature' },
{ word: 'BEAR', hint: 'Loves honey', category: 'Animals' },
{ word: 'MILK', hint: 'Comes from a cow', category: 'Food' },
{ word: 'MOON', hint: 'Orbits the Earth', category: 'Nature' },
{ word: 'WOLF', hint: 'Howls at the moon', category: 'Animals' },
{ word: 'CORN', hint: 'Yellow vegetable on a cob', category: 'Food' },
{ word: 'ROSE', hint: 'A thorny flower', category: 'Nature' },
{ word: 'LION', hint: 'King of the jungle', category: 'Animals' },
{ word: 'LEAF', hint: 'Falls from trees in autumn', category: 'Nature' },
],
medium: [
{ word: 'CASTLE', hint: 'A medieval fortress', category: 'Places' },
{ word: 'JUNGLE', hint: 'Dense tropical forest', category: 'Nature' },
{ word: 'PLANET', hint: 'Orbits a star', category: 'Space' },
{ word: 'BRIDGE', hint: 'Spans a river or gap', category: 'Structures'},
{ word: 'CAMERA', hint: 'Used to take photos', category: 'Objects' },
{ word: 'DRAGON', hint: 'Mythical fire-breathing creature', category: 'Fantasy' },
{ word: 'FOREST', hint: 'Dense woodland', category: 'Nature' },
{ word: 'GUITAR', hint: 'Six-stringed instrument', category: 'Music' },
{ word: 'ISLAND', hint: 'Land surrounded by water', category: 'Places' },
{ word: 'KNIGHT', hint: 'Armored medieval warrior', category: 'History' },
{ word: 'LEMON', hint: 'Sour yellow citrus fruit', category: 'Food' },
{ word: 'MARBLE', hint: 'Smooth polished stone', category: 'Objects' },
{ word: 'ORANGE', hint: 'A citrus fruit or color', category: 'Food' },
{ word: 'PENCIL', hint: 'Used for writing and drawing', category: 'Objects' },
{ word: 'PUZZLE', hint: 'A brain-teasing challenge', category: 'Games' },
{ word: 'RABBIT', hint: 'Hops and has long ears', category: 'Animals' },
{ word: 'ROCKET', hint: 'Launches into space', category: 'Space' },
{ word: 'SCHOOL', hint: 'Where students learn', category: 'Places' },
{ word: 'SPIDER', hint: 'Eight-legged arachnid', category: 'Animals' },
{ word: 'TURTLE', hint: 'Has a shell on its back', category: 'Animals' },
{ word: 'VIOLIN', hint: 'Bowed string instrument', category: 'Music' },
{ word: 'WINDOW', hint: 'Lets in light and air', category: 'Structures'},
{ word: 'WINTER', hint: 'The coldest season', category: 'Nature' },
{ word: 'WIZARD', hint: 'Casts magical spells', category: 'Fantasy' },
],
hard: [
{ word: 'ARCHITECT', hint: 'Designs buildings', category: 'Professions' },
{ word: 'BACKPACK', hint: 'Worn on your back for carrying', category: 'Objects' },
{ word: 'BREAKFAST', hint: 'The first meal of the day', category: 'Food' },
{ word: 'BUTTERFLY', hint: 'Colorful winged insect', category: 'Animals' },
{ word: 'CALENDAR', hint: 'Tracks days and months', category: 'Objects' },
{ word: 'CHOCOLATE', hint: 'Sweet treat from cacao beans', category: 'Food' },
{ word: 'CLOCKWORK', hint: 'Gears and springs mechanism', category: 'Objects' },
{ word: 'CROCODILE', hint: 'Large reptile in rivers', category: 'Animals' },
{ word: 'DAUGHTER', hint: 'Female offspring', category: 'People' },
{ word: 'DISCOVERY', hint: 'Finding something new', category: 'Concepts' },
{ word: 'EARTHQUAKE', hint: 'Shaking of the ground', category: 'Nature' },
{ word: 'FINGERTIP', hint: 'End of a finger', category: 'Body' },
{ word: 'FIREWORKS', hint: 'Colorful sky explosions', category: 'Events' },
{ word: 'FLAMINGO', hint: 'Pink bird that stands on one leg', category: 'Animals' },
{ word: 'GEOGRAPHY', hint: 'Study of Earth and its features', category: 'Science' },
{ word: 'HURRICANE', hint: 'Powerful tropical storm', category: 'Nature' },
{ word: 'JELLYFISH', hint: 'Translucent ocean creature', category: 'Animals' },
{ word: 'KANGAROO', hint: 'Marsupial that hops', category: 'Animals' },
{ word: 'LIGHTHOUSE', hint: 'Tower with a guiding beacon', category: 'Structures' },
{ word: 'MUSHROOM', hint: 'A fungus with a cap', category: 'Nature' },
{ word: 'PORCUPINE', hint: 'Covered in sharp quills', category: 'Animals' },
{ word: 'QUICKSAND', hint: 'Wet sand that sucks you in', category: 'Nature' },
{ word: 'SUBMARINE', hint: 'Vessel that travels underwater', category: 'Vehicles' },
{ word: 'TELESCOPE', hint: 'Used to observe distant objects', category: 'Science' },
{ word: 'THUNDERSTORM',hint: 'Rain with lightning and thunder', category: 'Nature' },
{ word: 'WATERFALL', hint: 'Water cascading over a cliff', category: 'Nature' },
],
};
// GET /api/words/hangman/start?difficulty=easy|medium|hard
router.get('/hangman/start', (req, res) => {
const difficulty = ['easy', 'medium', 'hard'].includes(req.query.difficulty)
? req.query.difficulty : 'medium';
const pool = HANGMAN_WORDS[difficulty];
const entry = pool[Math.floor(Math.random() * pool.length)];
res.json({ word: entry.word, hint: entry.hint, category: entry.category, maxWrong: 7 });
});
// ── 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) => {