From 8ccb1006783c9cec725b17de0af1f33a25360a4b Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 30 May 2026 18:05:40 -0600 Subject: [PATCH] feat: add single-player Sudoku game with hints and scoring - Implement Phaser UI and client-side logic for gameplay - Add server-side puzzle generator with 5 difficulty levels - Register game in frontend router and backend registry - Include hint system, score calculation, and notebook-themed design --- public/src/games/sudoku/SudokuGame.js | 629 ++++++++++++++++++ public/src/games/sudoku/SudokuLogic.js | 32 + .../games/tickettoride/TicketToRideGame.js | 4 +- public/src/main.js | 2 + public/src/scenes/GameRoomScene.js | 2 +- server/games/registry.js | 1 + server/words/sudokuEngine.js | 97 +++ server/words/wordRoutes.js | 8 + 8 files changed, 772 insertions(+), 3 deletions(-) create mode 100644 public/src/games/sudoku/SudokuGame.js create mode 100644 public/src/games/sudoku/SudokuLogic.js create mode 100644 server/words/sudokuEngine.js diff --git a/public/src/games/sudoku/SudokuGame.js b/public/src/games/sudoku/SudokuGame.js new file mode 100644 index 0000000..3819bdd --- /dev/null +++ b/public/src/games/sudoku/SudokuGame.js @@ -0,0 +1,629 @@ +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 and bottom + 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; + for (const sy of [PY + 10, PY + PH - 10]) { + spiralG.fillStyle(SPIRAL_CLR, 0.75); + spiralG.fillCircle(sx, sy, 18); + spiralG.fillStyle(PAPER, 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() { + this.add.text(TITLE_CX, 118, 'Sudoku', { + fontFamily: 'YummyCupcakes', fontSize: '84px', color: TITLE_BROWN, + }).setOrigin(0.5).setDepth(DEPTH.ui); + + this.add.text(TITLE_CX, 155, DIFF_LABELS[this.difficulty] ?? this.difficulty, { + fontFamily: 'YummyCupcakes', fontSize: '36px', color: FADED, + }).setOrigin(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 */ } + } +} diff --git a/public/src/games/sudoku/SudokuLogic.js b/public/src/games/sudoku/SudokuLogic.js new file mode 100644 index 0000000..4806ce4 --- /dev/null +++ b/public/src/games/sudoku/SudokuLogic.js @@ -0,0 +1,32 @@ +export const HINT_LIMITS = { + 'very-easy': Infinity, + 'easy': Infinity, + 'regular': 5, + 'hard': 0, + 'brutal': 0, +}; + +export const DIFFICULTY_SCORES = { + 'very-easy': 10, + 'easy': 20, + 'regular': 40, + 'hard': 70, + 'brutal': 100, +}; + +export function isBoardComplete(grid, solution) { + for (let r = 0; r < 9; r++) + for (let c = 0; c < 9; c++) + if (grid[r][c] !== solution[r][c]) return false; + return true; +} + +// Returns a random empty cell that should be filled per the solution, or null. +export function getHintCell(grid, solution) { + const empties = []; + for (let r = 0; r < 9; r++) + for (let c = 0; c < 9; c++) + if (grid[r][c] === 0) empties.push({ r, c }); + if (empties.length === 0) return null; + return empties[Math.floor(Math.random() * empties.length)]; +} diff --git a/public/src/games/tickettoride/TicketToRideGame.js b/public/src/games/tickettoride/TicketToRideGame.js index e91faca..389a127 100644 --- a/public/src/games/tickettoride/TicketToRideGame.js +++ b/public/src/games/tickettoride/TicketToRideGame.js @@ -315,10 +315,10 @@ export default class TicketToRideGame extends Phaser.Scene { this.add.text(80, BOT_Y + 60, auth.user?.username ?? 'You', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.hud); - this.add.text(180, BOT_Y - 70, 'Your Trains', { + this.add.text(180, BOT_Y, 'Your Trains', { fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, }).setOrigin(0, 0.5).setDepth(D.hud); - this.trainsText = this.add.text(300, BOT_Y - 70, '', { + this.trainsText = this.add.text(300, BOT_Y, '', { fontFamily: 'Righteous', fontSize: '16px', color: COLORS.textHex, }).setOrigin(0, 0.5).setDepth(D.hud); diff --git a/public/src/main.js b/public/src/main.js index d611979..fb921c2 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -38,6 +38,7 @@ import GhostGame from './games/ghost/GhostGame.js'; import WordLadderGame from './games/wordladder/WordLadderGame.js'; import WordSearchGame from './games/wordsearch/WordSearchGame.js'; import HangmanGame from './games/hangman/HangmanGame.js'; +import SudokuGame from './games/sudoku/SudokuGame.js'; const config = { type: Phaser.AUTO, @@ -89,6 +90,7 @@ const config = { WordLadderGame, WordSearchGame, HangmanGame, + SudokuGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 021d2e8..6d392d7 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -20,7 +20,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/server/games/registry.js b/server/games/registry.js index eb9cf47..8337e1d 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -51,3 +51,4 @@ registerGame({ slug: 'ghost', name: 'Ghost', category: 'word', minPlayers: 2, ma registerGame({ slug: 'wordladder', name: 'Word Ladder', category: 'word', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 1 }); registerGame({ slug: 'wordsearch', name: 'Word Search', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0 }); registerGame({ slug: 'hangman', name: 'Hangman', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0 }); +registerGame({ slug: 'sudoku', name: 'Sudoku', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0 }); diff --git a/server/words/sudokuEngine.js b/server/words/sudokuEngine.js new file mode 100644 index 0000000..7932360 --- /dev/null +++ b/server/words/sudokuEngine.js @@ -0,0 +1,97 @@ +function isValid(grid, row, col, num) { + for (let c = 0; c < 9; c++) if (grid[row][c] === num) return false; + for (let r = 0; r < 9; r++) if (grid[r][col] === num) return false; + const br = Math.floor(row / 3) * 3; + const bc = Math.floor(col / 3) * 3; + for (let r = br; r < br + 3; r++) + for (let c = bc; c < bc + 3; c++) + if (grid[r][c] === num) return false; + return true; +} + +function shuffle(arr) { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +function fillBox(grid, startRow, startCol) { + const nums = shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9]); + let idx = 0; + for (let r = startRow; r < startRow + 3; r++) + for (let c = startCol; c < startCol + 3; c++) + grid[r][c] = nums[idx++]; +} + +function solve(grid) { + for (let r = 0; r < 9; r++) { + for (let c = 0; c < 9; c++) { + if (grid[r][c] === 0) { + for (const num of shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9])) { + if (isValid(grid, r, c, num)) { + grid[r][c] = num; + if (solve(grid)) return true; + grid[r][c] = 0; + } + } + return false; + } + } + } + return true; +} + +function generateSolution() { + const grid = Array.from({ length: 9 }, () => Array(9).fill(0)); + // Seed diagonal boxes first (no inter-box constraints) for faster solve + fillBox(grid, 0, 0); + fillBox(grid, 3, 3); + fillBox(grid, 6, 6); + solve(grid); + return grid; +} + +function cloneGrid(g) { + return g.map(row => [...row]); +} + +function countGivens(grid) { + return grid.flat().filter(v => v !== 0).length; +} + +const GIVENS = { + 'very-easy': 50, + 'easy': 36, + 'regular': 28, + 'hard': 24, + 'brutal': 18, +}; + +function digHoles(solution, targetGivens) { + const grid = cloneGrid(solution); + const positions = []; + for (let r = 0; r < 9; r++) + for (let c = 0; c < 9; c++) + positions.push([r, c]); + + shuffle(positions); + + for (const [r, c] of positions) { + if (countGivens(grid) <= targetGivens) break; + const mr = 8 - r, mc = 8 - c; + if (grid[r][c] === 0 && grid[mr][mc] === 0) continue; + grid[r][c] = 0; + if (r !== mr || c !== mc) grid[mr][mc] = 0; + } + + return grid; +} + +export function generatePuzzle(difficulty) { + const target = GIVENS[difficulty] ?? 28; + const solution = generateSolution(); + const grid = digHoles(solution, target); + return { grid, solution, difficulty }; +} diff --git a/server/words/wordRoutes.js b/server/words/wordRoutes.js index 8518174..bcc8ae3 100644 --- a/server/words/wordRoutes.js +++ b/server/words/wordRoutes.js @@ -16,6 +16,7 @@ import { generatePuzzle as wordSearchGenerate, listThemes as wordSearchThemes, } from './wordSearchEngine.js'; +import { generatePuzzle as sudokuGenerate } from './sudokuEngine.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt'); @@ -369,4 +370,11 @@ router.get('/wordsearch/themes', (_req, res) => { res.json({ themes: wordSearchThemes() }); }); +// GET /api/words/sudoku/start?difficulty=very-easy|easy|regular|hard|brutal +router.get('/sudoku/start', (req, res) => { + const VALID = ['very-easy', 'easy', 'regular', 'hard', 'brutal']; + const difficulty = VALID.includes(req.query.difficulty) ? req.query.difficulty : 'regular'; + res.json(sudokuGenerate(difficulty)); +}); + export default router;