630 lines
21 KiB
JavaScript
630 lines
21 KiB
JavaScript
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 */ }
|
||
}
|
||
}
|