480 lines
18 KiB
JavaScript
480 lines
18 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 {
|
||
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 */ }
|
||
}
|
||
}
|