fertig-classic-games/src/games/hangman/HangmanGame.js

587 lines
20 KiB
JavaScript

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