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:
parent
fcc061e43d
commit
8ccb100678
|
|
@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)];
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue