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); } }