fertig-classic-games/public/src/games/sudoku/SudokuGame.js

630 lines
21 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 { HINT_LIMITS, DIFFICULTY_SCORES, isBoardComplete, getHintCell } from './SudokuLogic.js';
// ── Palette ───────────────────────────────────────────────────────────────────
const PAPER = 0xFFF8F0;
const PAPER_EDGE = 0xE8D8C0;
const INK = '#3a2a18';
const INK_N = 0x3a2a18;
const FADED = '#b09a7a';
const TITLE_BROWN = '#5c3a1e';
const RED_CIRCLE = 0xc0392b;
const SPIRAL_CLR = 0x8a7060;
const PLAYER_INK = '#1e4d7a'; // slightly blue-tinted ink for player entries
const DEPTH = {
bg: 0, paper: 1, lines: 2, cell: 3, gridLines: 4,
number: 5, selector: 6, hit: 10, ui: 20, overlay: 40,
};
// ── Layout ────────────────────────────────────────────────────────────────────
const CX = GAME_WIDTH / 2;
const CY = GAME_HEIGHT / 2;
// Paper sheet
const PX = 60, PY = 40, PW = 1800, PH = 1000;
// Grid (9×9, centered in left panel)
const CELL = 82;
const GRID_X = 171; // 540 9×82/2 = 540 369
const GRID_Y = 170;
const GRID_W = 9 * CELL; // 738
const GRID_H = 9 * CELL; // 738
// Red margin line
const MARGIN_X = 1020;
const TITLE_CX = 540; // center of left panel: (60+1020)/2
// Right-panel number selector
const NUM_X = 1440; // center of right panel: (1020+1860)/2
const NUM_START_Y = 163; // y-center of number "1"
const NUM_STEP = 82; // vertical spacing (= CELL, aligns with grid rows)
const ERASER_CY = NUM_START_Y + 9 * NUM_STEP + 6; // 163 + 738 + 6 = 907
const HINT_BTN_Y = ERASER_CY + 56; // 963
const BTN_Y = PY + PH - 48; // 992
const DIFF_LABELS = {
'very-easy': 'Very Easy', 'easy': 'Easy', 'regular': 'Regular',
'hard': 'Hard', 'brutal': 'Brutal',
};
export default class SudokuGame extends Phaser.Scene {
constructor() { super('SudokuGame'); }
init(data) {
this._initData = { ...data };
this.gameDef = data.game;
this.grid = null;
this.solution = null;
this.difficulty = '';
this.selectedNum = 1;
this.hintsLeft = null;
this.hintsUsed = 0;
this.gameEnded = false;
this.hoveredCell = null;
this.givenCells = [];
this.cellState = []; // 0=empty, 1=correct, 2=wrong
this.cellBgObjs = [];
this.cellTextObjs = [];
this.cellHitObjs = [];
this.numCircleGfx = null;
this.numStrikeGfx = null;
this.numTextObjs = {};
this.numHitObjs = {};
this.hintBtn = null;
this.startObjs = [];
this.revealed = false;
}
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 - 600, cy - 310, 1200, 620, 18);
sheet.lineStyle(3, PAPER_EDGE, 1);
sheet.strokeRoundedRect(cx - 600, cy - 310, 1200, 620, 18);
this.startObjs.push(sheet);
this.startObjs.push(
this.add.text(cx, cy - 220, 'Sudoku', {
fontFamily: 'YummyCupcakes', fontSize: '100px', color: TITLE_BROWN,
}).setOrigin(0.5).setDepth(DEPTH.ui),
);
this.startObjs.push(
this.add.text(cx, cy - 114, 'Choose difficulty', {
fontFamily: 'YummyCupcakes', fontSize: '46px', color: INK,
}).setOrigin(0.5).setDepth(DEPTH.ui),
);
const diffs = [
['Very Easy', 'very-easy'],
['Easy', 'easy'],
['Regular', 'regular'],
['Hard', 'hard'],
['Brutal', 'brutal'],
];
diffs.forEach(([label, id], i) => {
const b = new Button(this, cx + (i - 2) * 230, cy + 10, label,
() => this.startGame(id), { width: 200, height: 62, fontSize: 22 });
b.setDepth(DEPTH.ui);
this.startObjs.push(b);
});
const leave = new Button(this, cx, cy + 140, '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 = [];
}
// ── Game start ────────────────────────────────────────────────────────────────
async startGame(difficulty) {
this.destroyStart();
playSound(this, SFX.PIECE_CLICK);
let data;
try {
data = await api.get(`/words/sudoku/start?difficulty=${difficulty}`);
} catch (err) {
console.error('[sudoku] failed to fetch puzzle:', err);
await this.showStartPanel();
return;
}
this.grid = data.grid.map(row => [...row]);
this.solution = data.solution;
this.difficulty = difficulty;
this.selectedNum = 1;
this.hintsLeft = HINT_LIMITS[difficulty] ?? 0;
this.hintsUsed = 0;
this.gameEnded = false;
this.hoveredCell = null;
this.givenCells = this.grid.map(row => row.map(v => v !== 0));
this.cellState = Array.from({ length: 9 }, () => Array(9).fill(0));
this.revealed = false;
this.buildPaper();
this.buildRuledLines();
this.buildMarginLine();
this.buildTitle();
this.buildGrid();
this.buildSelector();
if (this.hintsLeft > 0) this.buildHintBtn();
this.buildControls();
this.refreshSelector();
this.updateCompletedNums();
}
// ── Paper & notebook decoration ───────────────────────────────────────────────
buildPaper() {
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);
// Spiral binding — top only
const spiralG = this.add.graphics().setDepth(DEPTH.paper + 1);
const spiralCnt = 32;
const step = PW / (spiralCnt + 1);
for (let i = 1; i <= spiralCnt; i++) {
const sx = PX + step * i;
const sy = PY + 40;
spiralG.fillStyle(SPIRAL_CLR, 0.75);
spiralG.fillCircle(sx, sy, 18);
spiralG.fillStyle(0x000000, 1);
spiralG.fillCircle(sx, sy, 12);
}
}
buildRuledLines() {
const g = this.add.graphics().setDepth(DEPTH.lines);
g.lineStyle(1, 0xbcd0e0, 0.35);
for (let y = PY + 150; y < PY + PH - 30; y += 44) {
g.lineBetween(PX + 30, y, PX + PW - 30, y);
}
}
buildMarginLine() {
const g = this.add.graphics().setDepth(DEPTH.lines);
g.lineStyle(2, RED_CIRCLE, 0.52);
g.lineBetween(MARGIN_X, PY + 10, MARGIN_X, PY + PH - 10);
}
buildTitle() {
const titleTxt = this.add.text(TITLE_CX, 132, 'Sudoku', {
fontFamily: 'YummyCupcakes', fontSize: '84px', color: TITLE_BROWN,
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.add.text(titleTxt.x + titleTxt.width / 2 + 24, titleTxt.y + 14,
DIFF_LABELS[this.difficulty] ?? this.difficulty, {
fontFamily: 'YummyCupcakes', fontSize: '36px', color: FADED,
}).setOrigin(0, 0.5).setDepth(DEPTH.ui);
}
// ── Grid ──────────────────────────────────────────────────────────────────────
buildGrid() {
const gfx = this.add.graphics().setDepth(DEPTH.gridLines);
// Faint cell dividers
gfx.lineStyle(1, INK_N, 0.18);
for (let i = 1; i <= 8; i++) {
if (i % 3 === 0) continue;
gfx.lineBetween(GRID_X + i * CELL, GRID_Y, GRID_X + i * CELL, GRID_Y + GRID_H);
gfx.lineBetween(GRID_X, GRID_Y + i * CELL, GRID_X + GRID_W, GRID_Y + i * CELL);
}
// Box dividers
gfx.lineStyle(3, INK_N, 0.58);
for (let i = 1; i <= 2; i++) {
gfx.lineBetween(GRID_X + i * 3 * CELL, GRID_Y, GRID_X + i * 3 * CELL, GRID_Y + GRID_H);
gfx.lineBetween(GRID_X, GRID_Y + i * 3 * CELL, GRID_X + GRID_W, GRID_Y + i * 3 * CELL);
}
// Outer border
gfx.lineStyle(4, INK_N, 0.78);
gfx.strokeRect(GRID_X, GRID_Y, GRID_W, GRID_H);
this.cellBgObjs = [];
this.cellTextObjs = [];
this.cellHitObjs = [];
for (let r = 0; r < 9; r++) {
this.cellBgObjs.push([]);
this.cellTextObjs.push([]);
this.cellHitObjs.push([]);
for (let c = 0; c < 9; c++) {
const cx = GRID_X + c * CELL + CELL / 2;
const cy = GRID_Y + r * CELL + CELL / 2;
const given = this.givenCells[r][c];
const val = this.grid[r][c];
const bg = this.add.graphics().setDepth(DEPTH.cell);
this.cellBgObjs[r].push(bg);
const txt = this.add.text(cx, cy, given ? String(val) : '', {
fontFamily: given ? 'Righteous' : 'YummyCupcakes',
fontSize: given ? '40px' : '46px',
color: given ? INK : PLAYER_INK,
}).setOrigin(0.5).setDepth(DEPTH.number);
this.cellTextObjs[r].push(txt);
if (given) {
this.cellHitObjs[r].push(null);
} else {
const hit = this.add.rectangle(cx, cy, CELL - 2, CELL - 2, 0xffffff, 0.001)
.setDepth(DEPTH.hit)
.setInteractive({ useHandCursor: true });
hit.on('pointerover', () => this.onCellHover(r, c, true));
hit.on('pointerout', () => this.onCellHover(r, c, false));
hit.on('pointerdown', () => this.onCellClick(r, c));
this.cellHitObjs[r].push(hit);
}
}
}
}
// ── Number selector ───────────────────────────────────────────────────────────
buildSelector() {
this.numCircleGfx = this.add.graphics().setDepth(DEPTH.selector);
this.numStrikeGfx = this.add.graphics().setDepth(DEPTH.selector + 2);
this.numHitObjs = {};
this.numTextObjs = {};
for (let n = 1; n <= 9; n++) {
const ny = NUM_START_Y + (n - 1) * NUM_STEP;
const numTxt = this.add.text(NUM_X, ny, String(n), {
fontFamily: '"Julius Sans One"', fontSize: '70px', color: INK,
}).setOrigin(0.5).setDepth(DEPTH.number + 1);
this.numTextObjs[n] = numTxt;
const hit = this.add.rectangle(NUM_X, ny, 210, NUM_STEP - 4, 0xffffff, 0.001)
.setDepth(DEPTH.hit + 1)
.setInteractive({ useHandCursor: true });
hit.on('pointerdown', () => this.selectNum(n));
this.numHitObjs[n] = hit;
}
// Eraser ×
this.add.text(NUM_X, ERASER_CY, '×', {
fontFamily: '"Julius Sans One"', fontSize: '56px', color: FADED,
}).setOrigin(0.5).setDepth(DEPTH.number + 1);
const eraserHit = this.add.rectangle(NUM_X, ERASER_CY, 210, NUM_STEP - 4, 0xffffff, 0.001)
.setDepth(DEPTH.hit + 1)
.setInteractive({ useHandCursor: true });
eraserHit.on('pointerdown', () => this.selectNum(0));
this.numHitObjs[0] = eraserHit;
}
refreshSelector() {
const gfx = this.numCircleGfx;
if (!gfx) return;
gfx.clear();
let cy;
if (this.selectedNum >= 1 && this.selectedNum <= 9) {
cy = NUM_START_Y + (this.selectedNum - 1) * NUM_STEP;
} else if (this.selectedNum === 0) {
cy = ERASER_CY;
} else {
return;
}
this.drawHandCircle(gfx, NUM_X, cy);
}
drawHandCircle(gfx, cx, cy) {
// Two slightly-offset ellipses for a hand-drawn wobble
gfx.lineStyle(3, RED_CIRCLE, 0.88);
gfx.strokeEllipse(cx, cy, 88, 78);
gfx.lineStyle(2, RED_CIRCLE, 0.28);
gfx.strokeEllipse(cx + 3, cy - 2, 92, 82);
}
updateCompletedNums() {
const gfx = this.numStrikeGfx;
if (!gfx) return;
gfx.clear();
for (let n = 1; n <= 9; n++) {
let count = 0;
for (let r = 0; r < 9; r++)
for (let c = 0; c < 9; c++)
if (this.grid[r][c] === n) count++;
const txt = this.numTextObjs[n];
if (!txt) continue;
if (count === 9) {
txt.setColor('#b0a090');
const ny = NUM_START_Y + (n - 1) * NUM_STEP;
gfx.lineStyle(3, 0x8a7a6a, 0.8);
gfx.lineBetween(NUM_X - 36, ny - 6, NUM_X + 36, ny - 6);
} else {
txt.setColor(INK);
}
}
}
// ── Hint button ───────────────────────────────────────────────────────────────
buildHintBtn() {
const isLimited = isFinite(this.hintsLeft);
const label = isLimited ? `Hint (${this.hintsLeft} left)` : 'Hint';
this.hintBtn = new Button(this, NUM_X, HINT_BTN_Y, label,
() => this.useHint(), { width: 230, height: 54, fontSize: 22 });
this.hintBtn.setDepth(DEPTH.ui);
}
updateHintBtn() {
if (!this.hintBtn) return;
const isLimited = isFinite(this.hintsLeft);
this.hintBtn.setLabel(isLimited ? `Hint (${this.hintsLeft} left)` : 'Hint');
if (this.hintsLeft === 0) this.hintBtn.setEnabled(false);
}
// ── Controls ──────────────────────────────────────────────────────────────────
buildControls() {
new Button(this, PX + 160, BTN_Y, 'New puzzle',
() => this.scene.restart(this._initData),
{ variant: 'ghost', width: 230, height: 50, fontSize: 22 }).setDepth(DEPTH.ui);
new Button(this, PX + PW - 130, BTN_Y, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 200, height: 50, fontSize: 22 }).setDepth(DEPTH.ui);
}
// ── Input ─────────────────────────────────────────────────────────────────────
selectNum(num) {
this.selectedNum = num;
this.refreshSelector();
playSound(this, SFX.PIECE_CLICK);
}
onCellHover(r, c, entering) {
this.hoveredCell = entering ? { r, c } : null;
this.drawCellBg(r, c, entering);
}
onCellClick(r, c) {
if (this.gameEnded || this.givenCells[r][c]) return;
playSound(this, SFX.PIECE_CLICK);
if (this.selectedNum === 0) {
this.clearCell(r, c);
} else if (this.grid[r][c] === this.selectedNum) {
this.clearCell(r, c);
} else {
this.placeNum(r, c, this.selectedNum);
}
}
placeNum(r, c, num) {
this.grid[r][c] = num;
this.cellTextObjs[r][c].setText(String(num));
const hovered = this.hoveredCell?.r === r && this.hoveredCell?.c === c;
if (this.isEasyMode() || this.revealed) {
const correct = (num === this.solution[r][c]);
this.cellState[r][c] = correct ? 1 : 2;
this.drawCellBg(r, c, hovered);
if (correct && isBoardComplete(this.grid, this.solution)) {
this.time.delayedCall(300, () => this.handleWin());
}
} else {
// No per-cell feedback — wait until all cells are filled
this.cellState[r][c] = 0;
this.drawCellBg(r, c, hovered);
if (this.isAllFilled()) {
this.time.delayedCall(200, () => this.revealAll());
}
}
this.updateCompletedNums();
}
isEasyMode() {
return this.difficulty === 'very-easy' || this.difficulty === 'easy';
}
isAllFilled() {
for (let r = 0; r < 9; r++)
for (let c = 0; c < 9; c++)
if (!this.givenCells[r][c] && this.grid[r][c] === 0) return false;
return true;
}
revealAll() {
this.revealed = true;
let allCorrect = true;
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
if (this.givenCells[r][c]) continue;
const correct = this.grid[r][c] === this.solution[r][c];
if (!correct) allCorrect = false;
this.cellState[r][c] = correct ? 1 : 2;
this.drawCellBg(r, c, false);
}
}
this.updateCompletedNums();
if (allCorrect) {
this.time.delayedCall(300, () => this.handleWin());
}
}
clearCell(r, c) {
if (this.givenCells[r][c]) return;
this.grid[r][c] = 0;
this.cellTextObjs[r][c].setText('');
this.cellState[r][c] = 0;
const hovered = this.hoveredCell?.r === r && this.hoveredCell?.c === c;
this.drawCellBg(r, c, hovered);
this.updateCompletedNums();
}
drawCellBg(r, c, isHovered) {
const bg = this.cellBgObjs[r][c];
if (!bg) return;
bg.clear();
const x = GRID_X + c * CELL + 1;
const y = GRID_Y + r * CELL + 1;
const sz = CELL - 2;
const state = this.cellState[r][c];
if (state === 1) {
bg.fillStyle(0x3c8a4e, isHovered ? 0.28 : 0.18);
bg.fillRect(x, y, sz, sz);
} else if (state === 2) {
bg.fillStyle(0xb03a2e, isHovered ? 0.25 : 0.14);
bg.fillRect(x, y, sz, sz);
} else if (isHovered) {
bg.fillStyle(INK_N, 0.07);
bg.fillRect(x, y, sz, sz);
}
}
// ── Hint ──────────────────────────────────────────────────────────────────────
useHint() {
if (this.gameEnded) return;
if (isFinite(this.hintsLeft) && this.hintsLeft <= 0) return;
const cell = getHintCell(this.grid, this.solution);
if (!cell) return;
const { r, c } = cell;
// Temporarily show gold flash
const bg = this.cellBgObjs[r][c];
bg.clear();
bg.fillStyle(0xd4a017, 0.5);
bg.fillRect(GRID_X + c * CELL + 1, GRID_Y + r * CELL + 1, CELL - 2, CELL - 2);
this.time.delayedCall(550, () => {
this.grid[r][c] = this.solution[r][c];
this.cellTextObjs[r][c].setText(String(this.solution[r][c]));
this.cellState[r][c] = 1;
this.drawCellBg(r, c, false);
this.updateCompletedNums();
if (isBoardComplete(this.grid, this.solution)) {
this.time.delayedCall(200, () => this.handleWin());
}
});
if (isFinite(this.hintsLeft)) {
this.hintsLeft--;
this.hintsUsed++;
this.updateHintBtn();
}
}
// ── Win ───────────────────────────────────────────────────────────────────────
handleWin() {
if (this.gameEnded) return;
this.gameEnded = true;
this.recordResult();
this.showWin();
}
showWin() {
const cx = CX, cy = CY;
this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.42)
.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 - 460, cy - 230, 920, 460, 18);
panel.lineStyle(3, PAPER_EDGE, 1);
panel.strokeRoundedRect(cx - 460, cy - 230, 920, 460, 18);
this.add.text(cx, cy - 130, 'Puzzle Solved!', {
fontFamily: 'YummyCupcakes', fontSize: '88px', color: '#3c8a4e',
}).setOrigin(0.5).setDepth(DEPTH.overlay + 2);
const diffLabel = DIFF_LABELS[this.difficulty] ?? this.difficulty;
const score = this.calcScore();
this.add.text(cx, cy - 26, `${diffLabel} · ${score} pts`, {
fontFamily: 'YummyCupcakes', fontSize: '44px', color: TITLE_BROWN,
}).setOrigin(0.5).setDepth(DEPTH.overlay + 2);
if (this.hintsUsed > 0) {
this.add.text(cx, cy + 28, `${this.hintsUsed} hint${this.hintsUsed > 1 ? 's' : ''} used`, {
fontFamily: 'YummyCupcakes', fontSize: '32px', color: FADED,
}).setOrigin(0.5).setDepth(DEPTH.overlay + 2);
}
new Button(this, cx - 175, cy + 130, 'New puzzle',
() => this.scene.restart(this._initData),
{ width: 280, height: 58, fontSize: 26 }).setDepth(DEPTH.overlay + 3);
new Button(this, cx + 175, cy + 130, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 280, height: 58, fontSize: 26 }).setDepth(DEPTH.overlay + 3);
}
calcScore() {
const base = DIFFICULTY_SCORES[this.difficulty] ?? 10;
const hintPenalty = this.difficulty === 'regular' ? this.hintsUsed * 4 : 0;
return Math.max(0, base - hintPenalty);
}
async recordResult() {
try {
await api.post('/history/single-player', {
slug: 'sudoku', score: this.calcScore(), opponentScores: [], result: 'win',
});
} catch { /* best effort */ }
}
}