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:
parent
d9a68de8e4
commit
2dbcb83754
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
Binary file not shown.
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue