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 */ } } }