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
This commit is contained in:
Brian Fertig 2026-05-30 18:05:40 -06:00
parent fcc061e43d
commit 8ccb100678
8 changed files with 772 additions and 3 deletions

View File

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

View File

@ -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)];
}

View File

@ -315,10 +315,10 @@ export default class TicketToRideGame extends Phaser.Scene {
this.add.text(80, BOT_Y + 60, auth.user?.username ?? 'You', { this.add.text(80, BOT_Y + 60, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex, fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.hud); }).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, fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(D.hud); }).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, fontFamily: 'Righteous', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.hud); }).setOrigin(0, 0.5).setDepth(D.hud);

View File

@ -38,6 +38,7 @@ import GhostGame from './games/ghost/GhostGame.js';
import WordLadderGame from './games/wordladder/WordLadderGame.js'; import WordLadderGame from './games/wordladder/WordLadderGame.js';
import WordSearchGame from './games/wordsearch/WordSearchGame.js'; import WordSearchGame from './games/wordsearch/WordSearchGame.js';
import HangmanGame from './games/hangman/HangmanGame.js'; import HangmanGame from './games/hangman/HangmanGame.js';
import SudokuGame from './games/sudoku/SudokuGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -89,6 +90,7 @@ const config = {
WordLadderGame, WordLadderGame,
WordSearchGame, WordSearchGame,
HangmanGame, HangmanGame,
SudokuGame,
], ],
}; };

View File

@ -20,7 +20,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { 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]) { if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], { this.scene.start(slugDispatch[this.game.slug], {
game: this.game, game: this.game,

View File

@ -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: '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: '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: '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 });

View File

@ -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 };
}

View File

@ -16,6 +16,7 @@ import {
generatePuzzle as wordSearchGenerate, generatePuzzle as wordSearchGenerate,
listThemes as wordSearchThemes, listThemes as wordSearchThemes,
} from './wordSearchEngine.js'; } from './wordSearchEngine.js';
import { generatePuzzle as sudokuGenerate } from './sudokuEngine.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt'); const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
@ -369,4 +370,11 @@ router.get('/wordsearch/themes', (_req, res) => {
res.json({ themes: wordSearchThemes() }); 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; export default router;