fertig-classic-games/src/games/minicrossword/MiniCrosswordGame.js

480 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
numberMap, cellsForSlot, slotAt, isSolved, wrongCells,
} from './MiniCrosswordLogic.js';
// ── Palette / styling ──────────────────────────────────────────────────────────
const FELT = 0x12100a; // deep background
const PAPER = 0x1e1a12; // panels
const GOLD = 0xd4a017;
const GOLD_DARK = 0xb8860b;
const CELL = 0xf2ead8; // empty letter cell
const CELL_WORD = 0xefdca6; // cells of the active word
const CELL_SEL = 0xf3c75a; // the selected cell
const BLOCK_FILL = 0x0c0a07; // black square
const EDGE = 0x4a4030;
const INK_DARK = '#1a1208'; // letters on light cells
const TITLE_GOLD = '#d4a017';
const DEPTH = { bg: 0, panel: 2, cell: 8, cellTxt: 10, ui: 20, overlay: 40, overlayUI: 42 };
// ── Grid geometry ────────────────────────────────────────────────────────────────
// The grid is sized to fit the left region (clue panel lives on the right at
// x>=1010), so 5x5 / 6x6 / 7x7 all fit without overlap. Cell size, position and
// font sizes are derived per-puzzle in computeGeometry().
const GRID_AREA = 680; // target pixel extent of the larger grid dimension
const GRID_LEFT_X = 90; // left edge of the region the grid is centered within
const GRID_REGION = 880; // width of that region
const GRID_TOP = 280;
export default class MiniCrosswordGame extends Phaser.Scene {
constructor() { super('MiniCrosswordGame'); }
init(data) {
this._initData = { ...data };
this.gameDef = data.game;
this.puzzle = null;
this.grid = []; // solution rows (strings)
this.across = [];
this.down = [];
this.entries = {}; // "r,c" -> letter
this.cells = {}; // "r,c" -> { rect, txt }
this.numbers = {}; // "r,c" -> number
this.sel = { row: 0, col: 0 };
this.dir = 'across';
this.ended = false;
this.startObjs = [];
}
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, FELT)
.setDepth(DEPTH.bg);
this.add.text(40, 36, 'MINI CROSSWORD', {
fontFamily: 'Righteous', fontSize: '46px', color: TITLE_GOLD,
}).setDepth(DEPTH.ui);
this.events.once('shutdown', () => this.cleanup());
this.showStartPanel();
}
cleanup() {
if (this.keyHandler) {
this.input.keyboard.off('keydown', this.keyHandler);
this.keyHandler = null;
}
}
// ── Start panel ──────────────────────────────────────────────────────────────
showStartPanel() {
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const sheet = this.add.graphics().setDepth(DEPTH.panel);
sheet.postFX.addShadow(0, 6, 0.02, 1.2, 0x000000, 10, 0.6);
sheet.fillStyle(PAPER, 1);
sheet.fillRoundedRect(cx - 480, cy - 280, 960, 560, 20);
sheet.lineStyle(3, GOLD_DARK, 1);
sheet.strokeRoundedRect(cx - 480, cy - 280, 960, 560, 20);
this.startObjs.push(sheet);
this.startObjs.push(this.add.text(cx, cy - 190, 'Mini Crossword', {
fontFamily: 'Righteous', fontSize: '88px', color: TITLE_GOLD,
}).setOrigin(0.5).setDepth(DEPTH.ui));
this.startObjs.push(this.add.text(cx, cy - 90,
'Fill the grid so every across and down answer matches\nits clue. Bigger grid, bigger challenge. Click a cell or\nclue to start typing.', {
fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.mutedHex,
align: 'center', lineSpacing: 8,
}).setOrigin(0.5).setDepth(DEPTH.ui));
this.startObjs.push(this.add.text(cx, cy + 10, 'Choose a grid size', {
fontFamily: 'Righteous', fontSize: '36px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui));
[['Easy', '5×5', 'easy'], ['Medium', '6×6', 'medium'], ['Hard', '7×7', 'hard']].forEach(([label, size, id], i) => {
const x = cx - 270 + i * 270;
const b = new Button(this, x, cy + 110, label,
() => this.startPuzzle(id),
{ width: 230, height: 68, fontSize: 28, bgHover: GOLD });
b.setDepth(DEPTH.ui);
this.startObjs.push(b);
this.startObjs.push(this.add.text(x, cy + 168, size, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui));
});
}
async startPuzzle(difficulty) {
let data;
try {
data = await api.get(`/words/minicrossword/start?difficulty=${difficulty}`);
} catch (err) {
console.error('[minicrossword] failed to fetch puzzle:', err);
return;
}
if (!data?.grid?.length) {
console.error('[minicrossword] empty puzzle payload');
return;
}
this.startObjs.forEach((o) => o.destroy());
this.startObjs = [];
this.puzzle = data;
this.grid = data.grid;
this.across = data.across ?? [];
this.down = data.down ?? [];
this.numbers = numberMap(this.across, this.down);
this.buildBoard();
}
// ── Board ──────────────────────────────────────────────────────────────────────
// Derive cell size, grid origin and font sizes from the puzzle dimensions so
// every grid size fits the left region and stays clear of the clue panel.
computeGeometry() {
const cols = this.grid[0].length;
const rows = this.grid.length;
this.cellSize = Math.max(88, Math.min(128, Math.floor(GRID_AREA / Math.max(rows, cols))));
const gridW = cols * this.cellSize;
this.gridLeft = GRID_LEFT_X + (GRID_REGION - gridW) / 2;
this.gridTop = GRID_TOP;
this.letterFont = Math.round(this.cellSize * 0.5);
this.numberFont = Math.round(this.cellSize * 0.19);
}
buildBoard() {
this.computeGeometry();
this.buildClueBanner();
this.buildGrid();
this.buildCluePanel();
this.buildControls();
this.bindKeyboard();
// Start on the first across answer.
const first = this.across[0] ?? this.down[0];
if (first) {
this.dir = this.across[0] ? 'across' : 'down';
this.setSelection(first.row, first.col, false);
}
this.refresh();
}
cellCenter(r, c) {
return {
x: this.gridLeft + c * this.cellSize + this.cellSize / 2,
y: this.gridTop + r * this.cellSize + this.cellSize / 2,
};
}
buildGrid() {
for (let r = 0; r < this.grid.length; r++) {
for (let c = 0; c < this.grid[r].length; c++) {
const { x, y } = this.cellCenter(r, c);
const block = this.grid[r][c] === '#';
const rect = this.add.rectangle(x, y, this.cellSize - 4, this.cellSize - 4,
block ? BLOCK_FILL : CELL).setStrokeStyle(3, EDGE).setDepth(DEPTH.cell);
if (block) continue;
rect.setInteractive({ useHandCursor: true });
rect.on('pointerdown', () => this.onCellClick(r, c));
const txt = this.add.text(x, y + 6, '', {
fontFamily: 'Righteous', fontSize: `${this.letterFont}px`, color: INK_DARK,
}).setOrigin(0.5).setDepth(DEPTH.cellTxt);
const num = this.numbers[`${r},${c}`];
if (num) {
this.add.text(x - this.cellSize / 2 + 10, y - this.cellSize / 2 + 6, String(num), {
fontFamily: '"Julius Sans One"', fontSize: `${this.numberFont}px`, color: INK_DARK,
}).setOrigin(0, 0).setDepth(DEPTH.cellTxt);
}
this.cells[`${r},${c}`] = { rect, txt };
}
}
}
buildClueBanner() {
const gridW = this.grid[0].length * this.cellSize;
this.banner = this.add.text(this.gridLeft + gridW / 2, this.gridTop - 70, '', {
fontFamily: 'Righteous', fontSize: '34px', color: TITLE_GOLD,
align: 'center', wordWrap: { width: gridW + 40 },
}).setOrigin(0.5, 1).setDepth(DEPTH.ui);
}
buildCluePanel() {
const PX = 1010, PY = 150, PW = 850, PH = 760;
const g = this.add.graphics().setDepth(DEPTH.panel);
g.fillStyle(PAPER, 1);
g.fillRoundedRect(PX, PY, PW, PH, 18);
g.lineStyle(3, EDGE, 1);
g.strokeRoundedRect(PX, PY, PW, PH, 18);
this.clueTexts = {}; // "across:idx" / "down:idx" -> text object
const columns = [
{ title: 'ACROSS', dir: 'across', slots: this.across, x: PX + 40 },
{ title: 'DOWN', dir: 'down', slots: this.down, x: PX + 440 },
];
// Space clues to fit the tallest column (up to 8 entries for a 7x7).
const maxRows = Math.max(this.across.length, this.down.length, 1);
const step = Math.min(76, Math.floor((PH - 130) / maxRows));
for (const col of columns) {
this.add.text(col.x, PY + 36, col.title, {
fontFamily: 'Righteous', fontSize: '34px', color: TITLE_GOLD,
}).setDepth(DEPTH.cellTxt);
col.slots.forEach((slot, i) => {
const y = PY + 100 + i * step;
const t = this.add.text(col.x, y, `${slot.number}. ${slot.clue}`, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
wordWrap: { width: 360 },
}).setDepth(DEPTH.cellTxt);
t.setInteractive({ useHandCursor: true });
t.on('pointerdown', () => this.selectClue(col.dir, slot));
this.clueTexts[`${col.dir}:${i}`] = { t, slot };
});
}
}
buildControls() {
const y = this.gridTop + this.grid.length * this.cellSize + 70;
this.checkBtn = new Button(this, this.gridLeft + 120, y, 'Check',
() => this.checkGrid(), { width: 200, height: 60, fontSize: 24 });
this.revealBtn = new Button(this, this.gridLeft + 350, y, 'Reveal Word',
() => this.revealWord(), { width: 220, height: 60, fontSize: 24, bgHover: GOLD });
[this.checkBtn, this.revealBtn].forEach((b) => b.setDepth(DEPTH.ui));
const leaveBtn = new Button(this, GAME_WIDTH - 90, GAME_HEIGHT - 44, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 140, height: 52, fontSize: 20 });
leaveBtn.setDepth(DEPTH.ui);
}
// ── Selection & active word ─────────────────────────────────────────────────────
activeSlot() {
const slots = this.dir === 'across' ? this.across : this.down;
let slot = slotAt(slots, this.dir, this.sel.row, this.sel.col);
if (!slot) {
// No word in this direction through the cell — try the other direction.
const other = this.dir === 'across' ? 'down' : 'across';
const otherSlots = other === 'across' ? this.across : this.down;
slot = slotAt(otherSlots, other, this.sel.row, this.sel.col);
if (slot) this.dir = other;
}
return slot;
}
setSelection(r, c, toggleIfSame = true) {
if (this.grid[r][c] === '#') return;
if (toggleIfSame && this.sel.row === r && this.sel.col === c) {
this.dir = this.dir === 'across' ? 'down' : 'across';
}
this.sel = { row: r, col: c };
this.refresh();
}
onCellClick(r, c) {
if (this.ended) return;
this.setSelection(r, c, true);
}
selectClue(dir, slot) {
if (this.ended) return;
this.dir = dir;
this.sel = { row: slot.row, col: slot.col };
this.refresh();
}
// ── Rendering refresh ────────────────────────────────────────────────────────────
refresh() {
const slot = this.activeSlot();
const wordKeys = new Set(
slot ? cellsForSlot(slot, this.dir).map((p) => `${p.row},${p.col}`) : [],
);
const selKey = `${this.sel.row},${this.sel.col}`;
for (const [key, cell] of Object.entries(this.cells)) {
let fill = CELL;
if (key === selKey) fill = CELL_SEL;
else if (wordKeys.has(key)) fill = CELL_WORD;
cell.rect.setFillStyle(fill);
cell.txt.setText(this.entries[key] ?? '');
}
// Highlight the active clue in the side panel.
if (this.clueTexts) {
for (const { t } of Object.values(this.clueTexts)) t.setColor(COLORS.textHex);
if (slot) {
const slots = this.dir === 'across' ? this.across : this.down;
const idx = slots.indexOf(slot);
const entry = this.clueTexts[`${this.dir}:${idx}`];
if (entry) entry.t.setColor(TITLE_GOLD);
this.banner.setText(`${slot.number} ${this.dir === 'across' ? 'Across' : 'Down'}: ${slot.clue}`);
}
}
}
// ── Keyboard input ───────────────────────────────────────────────────────────────
bindKeyboard() {
this.keyHandler = (event) => {
if (this.ended) return;
const key = event.key;
if (/^[a-zA-Z]$/.test(key)) {
this.typeLetter(key.toUpperCase());
} else if (key === 'Backspace') {
event.preventDefault();
this.backspace();
} else if (key === ' ') {
event.preventDefault();
this.dir = this.dir === 'across' ? 'down' : 'across';
this.refresh();
} else if (key.startsWith('Arrow')) {
event.preventDefault();
this.moveArrow(key);
}
};
this.input.keyboard.on('keydown', this.keyHandler);
}
typeLetter(letter) {
const key = `${this.sel.row},${this.sel.col}`;
this.entries[key] = letter;
playSound(this, SFX.PIECE_CLICK);
this.advance();
this.refresh();
if (isSolved(this.grid, this.entries)) this.handleWin();
}
backspace() {
const key = `${this.sel.row},${this.sel.col}`;
if (this.entries[key]) {
delete this.entries[key];
} else {
this.advance(-1);
delete this.entries[`${this.sel.row},${this.sel.col}`];
}
this.refresh();
}
// Step to the next (dir=1) or previous (dir=-1) cell of the active word.
advance(step = 1) {
const slot = this.activeSlot();
if (!slot) return;
const cells = cellsForSlot(slot, this.dir);
const idx = cells.findIndex((p) => p.row === this.sel.row && p.col === this.sel.col);
const next = cells[idx + step];
if (next) this.sel = { row: next.row, col: next.col };
}
moveArrow(arrow) {
let { row, col } = this.sel;
const deltas = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] };
const [dr, dc] = deltas[arrow];
// Prefer the direction the arrow implies.
this.dir = (dr !== 0) ? 'down' : 'across';
let nr = row + dr, nc = col + dc;
while (nr >= 0 && nr < this.grid.length && nc >= 0 && nc < this.grid[0].length) {
if (this.grid[nr][nc] !== '#') { this.sel = { row: nr, col: nc }; break; }
nr += dr; nc += dc;
}
this.refresh();
}
// ── Helpers (Check / Reveal) ─────────────────────────────────────────────────────
checkGrid() {
if (this.ended) return;
const wrong = wrongCells(this.grid, this.entries);
if (!wrong.length) { playSound(this, SFX.PIECE_CLICK); return; }
wrong.forEach((key) => {
const cell = this.cells[key];
if (!cell) return;
cell.txt.setColor('#e06c75');
this.tweens.add({
targets: cell.txt, alpha: { from: 1, to: 0.2 }, yoyo: true, repeat: 2,
duration: 160, onComplete: () => cell.txt.setColor(INK_DARK).setAlpha(1),
});
});
playSound(this, SFX.CARD_SHUFFLE);
}
revealWord() {
if (this.ended) return;
const slot = this.activeSlot();
if (!slot) return;
cellsForSlot(slot, this.dir).forEach((p) => {
this.entries[`${p.row},${p.col}`] = this.grid[p.row][p.col];
});
playSound(this, SFX.PENCIL_WRITE);
this.refresh();
if (isSolved(this.grid, this.entries)) this.handleWin();
}
// ── Win ──────────────────────────────────────────────────────────────────────────
handleWin() {
this.ended = true;
this.cleanup();
playSound(this, SFX.CASINO_WIN);
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.55).setDepth(DEPTH.overlay);
const panel = this.add.graphics().setDepth(DEPTH.overlay + 1);
panel.fillStyle(PAPER, 1);
panel.fillRoundedRect(cx - 400, cy - 200, 800, 400, 20);
panel.lineStyle(3, GOLD_DARK, 1);
panel.strokeRoundedRect(cx - 400, cy - 200, 800, 400, 20);
this.add.text(cx, cy - 90, 'Solved!', {
fontFamily: 'Righteous', fontSize: '96px', color: TITLE_GOLD,
}).setOrigin(0.5).setDepth(DEPTH.overlayUI);
this.add.text(cx, cy + 10, 'You completed the mini crossword.', {
fontFamily: '"Julius Sans One"', fontSize: '32px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.overlayUI);
new Button(this, cx - 160, cy + 120, 'New Puzzle',
() => this.scene.restart(this._initData),
{ width: 280, height: 64, fontSize: 26, bgHover: GOLD }).setDepth(DEPTH.overlayUI);
new Button(this, cx + 160, cy + 120, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 280, height: 64, fontSize: 26 }).setDepth(DEPTH.overlayUI);
}
async recordResult(result) {
try {
await api.post('/history/single-player', {
slug: 'minicrossword', score: this.grid.length * this.grid[0].length, opponentScores: [], result,
});
} catch { /* best effort */ }
}
}