Crossword Changes and added Tectonic

This commit is contained in:
Brian Fertig 2026-06-05 23:24:11 -06:00
parent e07f48a85c
commit 9cb05f5f44
16 changed files with 20492 additions and 256 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

View File

@ -24,9 +24,13 @@ const TITLE_GOLD = '#d4a017';
const DEPTH = { bg: 0, panel: 2, cell: 8, cellTxt: 10, ui: 20, overlay: 40, overlayUI: 42 }; const DEPTH = { bg: 0, panel: 2, cell: 8, cellTxt: 10, ui: 20, overlay: 40, overlayUI: 42 };
// ── Grid geometry ──────────────────────────────────────────────────────────────── // ── Grid geometry ────────────────────────────────────────────────────────────────
const CELL_SIZE = 116; // The grid is sized to fit the left region (clue panel lives on the right at
const GRID_LEFT = 300; // x>=1010), so 5x5 / 6x6 / 7x7 all fit without overlap. Cell size, position and
const GRID_TOP = 300; // font sizes are derived per-puzzle in computeGeometry().
const GRID_AREA = 680; // target pixel extent of the larger grid dimension
const GRID_LEFT_X = 90; // left edge of the region the grid is centered within
const GRID_REGION = 880; // width of that region
const GRID_TOP = 280;
export default class MiniCrosswordGame extends Phaser.Scene { export default class MiniCrosswordGame extends Phaser.Scene {
constructor() { super('MiniCrosswordGame'); } constructor() { super('MiniCrosswordGame'); }
@ -91,21 +95,25 @@ export default class MiniCrosswordGame extends Phaser.Scene {
}).setOrigin(0.5).setDepth(DEPTH.ui)); }).setOrigin(0.5).setDepth(DEPTH.ui));
this.startObjs.push(this.add.text(cx, cy - 90, this.startObjs.push(this.add.text(cx, cy - 90,
'Fill the 5×5 grid so every across and down answer\nmatches its clue. Click a cell or clue to start typing.', { 'Fill the grid so every across and down answer matches\nits clue. Bigger grid, bigger challenge. Click a cell or\nclue to start typing.', {
fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.mutedHex,
align: 'center', lineSpacing: 8, align: 'center', lineSpacing: 8,
}).setOrigin(0.5).setDepth(DEPTH.ui)); }).setOrigin(0.5).setDepth(DEPTH.ui));
this.startObjs.push(this.add.text(cx, cy + 10, 'Choose difficulty', { this.startObjs.push(this.add.text(cx, cy + 10, 'Choose a grid size', {
fontFamily: 'Righteous', fontSize: '36px', color: COLORS.textHex, fontFamily: 'Righteous', fontSize: '36px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui)); }).setOrigin(0.5).setDepth(DEPTH.ui));
[['Easy', 'easy'], ['Medium', 'medium'], ['Hard', 'hard']].forEach(([label, id], i) => { [['Easy', '5×5', 'easy'], ['Medium', '6×6', 'medium'], ['Hard', '7×7', 'hard']].forEach(([label, size, id], i) => {
const b = new Button(this, cx - 270 + i * 270, cy + 110, label, const x = cx - 270 + i * 270;
const b = new Button(this, x, cy + 110, label,
() => this.startPuzzle(id), () => this.startPuzzle(id),
{ width: 230, height: 68, fontSize: 28, bgHover: GOLD }); { width: 230, height: 68, fontSize: 28, bgHover: GOLD });
b.setDepth(DEPTH.ui); b.setDepth(DEPTH.ui);
this.startObjs.push(b); this.startObjs.push(b);
this.startObjs.push(this.add.text(x, cy + 168, size, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui));
}); });
} }
@ -136,7 +144,21 @@ export default class MiniCrosswordGame extends Phaser.Scene {
// ── Board ────────────────────────────────────────────────────────────────────── // ── Board ──────────────────────────────────────────────────────────────────────
// Derive cell size, grid origin and font sizes from the puzzle dimensions so
// every grid size fits the left region and stays clear of the clue panel.
computeGeometry() {
const cols = this.grid[0].length;
const rows = this.grid.length;
this.cellSize = Math.max(88, Math.min(128, Math.floor(GRID_AREA / Math.max(rows, cols))));
const gridW = cols * this.cellSize;
this.gridLeft = GRID_LEFT_X + (GRID_REGION - gridW) / 2;
this.gridTop = GRID_TOP;
this.letterFont = Math.round(this.cellSize * 0.5);
this.numberFont = Math.round(this.cellSize * 0.19);
}
buildBoard() { buildBoard() {
this.computeGeometry();
this.buildClueBanner(); this.buildClueBanner();
this.buildGrid(); this.buildGrid();
this.buildCluePanel(); this.buildCluePanel();
@ -154,8 +176,8 @@ export default class MiniCrosswordGame extends Phaser.Scene {
cellCenter(r, c) { cellCenter(r, c) {
return { return {
x: GRID_LEFT + c * CELL_SIZE + CELL_SIZE / 2, x: this.gridLeft + c * this.cellSize + this.cellSize / 2,
y: GRID_TOP + r * CELL_SIZE + CELL_SIZE / 2, y: this.gridTop + r * this.cellSize + this.cellSize / 2,
}; };
} }
@ -165,7 +187,7 @@ export default class MiniCrosswordGame extends Phaser.Scene {
const { x, y } = this.cellCenter(r, c); const { x, y } = this.cellCenter(r, c);
const block = this.grid[r][c] === '#'; const block = this.grid[r][c] === '#';
const rect = this.add.rectangle(x, y, CELL_SIZE - 4, CELL_SIZE - 4, const rect = this.add.rectangle(x, y, this.cellSize - 4, this.cellSize - 4,
block ? BLOCK_FILL : CELL).setStrokeStyle(3, EDGE).setDepth(DEPTH.cell); block ? BLOCK_FILL : CELL).setStrokeStyle(3, EDGE).setDepth(DEPTH.cell);
if (block) continue; if (block) continue;
@ -173,13 +195,13 @@ export default class MiniCrosswordGame extends Phaser.Scene {
rect.on('pointerdown', () => this.onCellClick(r, c)); rect.on('pointerdown', () => this.onCellClick(r, c));
const txt = this.add.text(x, y + 6, '', { const txt = this.add.text(x, y + 6, '', {
fontFamily: 'Righteous', fontSize: '58px', color: INK_DARK, fontFamily: 'Righteous', fontSize: `${this.letterFont}px`, color: INK_DARK,
}).setOrigin(0.5).setDepth(DEPTH.cellTxt); }).setOrigin(0.5).setDepth(DEPTH.cellTxt);
const num = this.numbers[`${r},${c}`]; const num = this.numbers[`${r},${c}`];
if (num) { if (num) {
this.add.text(x - CELL_SIZE / 2 + 12, y - CELL_SIZE / 2 + 8, String(num), { this.add.text(x - this.cellSize / 2 + 10, y - this.cellSize / 2 + 6, String(num), {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: INK_DARK, fontFamily: '"Julius Sans One"', fontSize: `${this.numberFont}px`, color: INK_DARK,
}).setOrigin(0, 0).setDepth(DEPTH.cellTxt); }).setOrigin(0, 0).setDepth(DEPTH.cellTxt);
} }
@ -189,8 +211,8 @@ export default class MiniCrosswordGame extends Phaser.Scene {
} }
buildClueBanner() { buildClueBanner() {
const gridW = this.grid[0].length * CELL_SIZE; const gridW = this.grid[0].length * this.cellSize;
this.banner = this.add.text(GRID_LEFT + gridW / 2, GRID_TOP - 70, '', { this.banner = this.add.text(this.gridLeft + gridW / 2, this.gridTop - 70, '', {
fontFamily: 'Righteous', fontSize: '34px', color: TITLE_GOLD, fontFamily: 'Righteous', fontSize: '34px', color: TITLE_GOLD,
align: 'center', wordWrap: { width: gridW + 40 }, align: 'center', wordWrap: { width: gridW + 40 },
}).setOrigin(0.5, 1).setDepth(DEPTH.ui); }).setOrigin(0.5, 1).setDepth(DEPTH.ui);
@ -211,13 +233,17 @@ export default class MiniCrosswordGame extends Phaser.Scene {
{ title: 'DOWN', dir: 'down', slots: this.down, x: PX + 440 }, { title: 'DOWN', dir: 'down', slots: this.down, x: PX + 440 },
]; ];
// Space clues to fit the tallest column (up to 8 entries for a 7x7).
const maxRows = Math.max(this.across.length, this.down.length, 1);
const step = Math.min(76, Math.floor((PH - 130) / maxRows));
for (const col of columns) { for (const col of columns) {
this.add.text(col.x, PY + 36, col.title, { this.add.text(col.x, PY + 36, col.title, {
fontFamily: 'Righteous', fontSize: '34px', color: TITLE_GOLD, fontFamily: 'Righteous', fontSize: '34px', color: TITLE_GOLD,
}).setDepth(DEPTH.cellTxt); }).setDepth(DEPTH.cellTxt);
col.slots.forEach((slot, i) => { col.slots.forEach((slot, i) => {
const y = PY + 100 + i * 76; const y = PY + 100 + i * step;
const t = this.add.text(col.x, y, `${slot.number}. ${slot.clue}`, { const t = this.add.text(col.x, y, `${slot.number}. ${slot.clue}`, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
wordWrap: { width: 360 }, wordWrap: { width: 360 },
@ -230,10 +256,10 @@ export default class MiniCrosswordGame extends Phaser.Scene {
} }
buildControls() { buildControls() {
const y = GRID_TOP + this.grid.length * CELL_SIZE + 70; const y = this.gridTop + this.grid.length * this.cellSize + 70;
this.checkBtn = new Button(this, GRID_LEFT + 120, y, 'Check', this.checkBtn = new Button(this, this.gridLeft + 120, y, 'Check',
() => this.checkGrid(), { width: 200, height: 60, fontSize: 24 }); () => this.checkGrid(), { width: 200, height: 60, fontSize: 24 });
this.revealBtn = new Button(this, GRID_LEFT + 350, y, 'Reveal Word', this.revealBtn = new Button(this, this.gridLeft + 350, y, 'Reveal Word',
() => this.revealWord(), { width: 220, height: 60, fontSize: 24, bgHover: GOLD }); () => this.revealWord(), { width: 220, height: 60, fontSize: 24, bgHover: GOLD });
[this.checkBtn, this.revealBtn].forEach((b) => b.setDepth(DEPTH.ui)); [this.checkBtn, this.revealBtn].forEach((b) => b.setDepth(DEPTH.ui));

View File

@ -0,0 +1,645 @@
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 './TectonicLogic.js';
// ── Palette (graph-paper notebook) ─────────────────────────────────────────────
const PAPER = 0xFCFEFF; // bright graph-paper white
const PAPER_EDGE = 0xD9E4EC;
const GRAPH_BLUE = 0x9DC4E6; // fine printed grid lines
const CELL_BLUE = 0x6FA3CF; // puzzle cell separators
const REGION_INK = 0x223547; // bold cage outlines
const INK = '#21303f'; // given numbers (ink)
const INK_N = 0x21303f;
const FADED = '#8aa6bd';
const TITLE_BLUE = '#27506e';
const RED_MARK = 0xc0392b;
const PLAYER_INK = '#1c6fb0'; // player-entered numbers (blue pen)
const DEPTH = {
bg: 0, paper: 1, graph: 2, cell: 3, cellLines: 4, region: 5,
number: 6, selector: 7, 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;
const GRAPH_PITCH = 45;
// Grid (8×8, centered in the left panel)
const N = 8;
const CELL = 90;
const GRID_W = N * CELL; // 720
const GRID_H = N * CELL; // 720
const TITLE_CX = 540; // centre of the left panel
const GRID_X = TITLE_CX - GRID_W / 2; // 180
const GRID_Y = 200;
// Right-panel number selector (15 + eraser)
const NUM_X = 1440;
const NUM_START_Y = 282;
const NUM_STEP = 108;
const ERASER_CY = NUM_START_Y + 5 * NUM_STEP + 6; // below the "5"
const HINT_BTN_Y = ERASER_CY + 86;
const BTN_Y = PY + PH - 48;
const DIFF_LABELS = { easy: 'Easy', medium: 'Medium', hard: 'Hard' };
export default class TectonicGame extends Phaser.Scene {
constructor() { super('TectonicGame'); }
init(data) {
this._initData = { ...data };
this.gameDef = data.game;
this.grid = null;
this.solution = null;
this.regions = null;
this.regionSize = null; // 2D: size of the region each cell belongs to
this.neededFor = null; // neededFor[v] = how many regions hold value v
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 - 320, 1200, 640, 18);
sheet.lineStyle(3, PAPER_EDGE, 1);
sheet.strokeRoundedRect(cx - 600, cy - 320, 1200, 640, 18);
this.startObjs.push(sheet);
// graph lines inside the start sheet
const g = this.add.graphics().setDepth(DEPTH.graph);
g.lineStyle(1, GRAPH_BLUE, 0.4);
for (let x = cx - 600 + GRAPH_PITCH; x < cx + 600; x += GRAPH_PITCH)
g.lineBetween(x, cy - 320, x, cy + 320);
for (let y = cy - 320 + GRAPH_PITCH; y < cy + 320; y += GRAPH_PITCH)
g.lineBetween(cx - 600, y, cx + 600, y);
this.startObjs.push(g);
this.startObjs.push(
this.add.text(cx, cy - 226, 'Tectonic', {
fontFamily: 'YummyCupcakes', fontSize: '104px', color: TITLE_BLUE,
}).setOrigin(0.5).setDepth(DEPTH.ui),
);
this.startObjs.push(
this.add.text(cx, cy - 118, 'Fill each cage 1N · no touching twins', {
fontFamily: 'YummyCupcakes', fontSize: '34px', color: '#5a7a92',
}).setOrigin(0.5).setDepth(DEPTH.ui),
);
this.startObjs.push(
this.add.text(cx, cy - 44, 'Choose difficulty', {
fontFamily: 'YummyCupcakes', fontSize: '44px', color: INK,
}).setOrigin(0.5).setDepth(DEPTH.ui),
);
const diffs = [['Easy', 'easy'], ['Medium', 'medium'], ['Hard', 'hard']];
diffs.forEach(([label, id], i) => {
const b = new Button(this, cx + (i - 1) * 260, cy + 70, label,
() => this.startGame(id), { width: 230, height: 70, fontSize: 26 });
b.setDepth(DEPTH.ui);
this.startObjs.push(b);
});
const leave = new Button(this, cx, cy + 210, '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/tectonic/start?difficulty=${difficulty}`);
} catch (err) {
console.error('[tectonic] failed to fetch puzzle:', err);
await this.showStartPanel();
return;
}
this.grid = data.grid.map(row => [...row]);
this.solution = data.solution;
this.regions = data.regions;
this.difficulty = difficulty;
this.selectedNum = 1;
this.hintsLeft = HINT_LIMITS[difficulty] ?? 0;
this.hintsUsed = 0;
this.gameEnded = false;
this.hoveredCell = null;
this.revealed = false;
this.computeRegionMeta();
this.givenCells = this.grid.map(row => row.map(v => v !== 0));
this.cellState = Array.from({ length: N }, () => Array(N).fill(0));
this.buildPaper();
this.buildTitle();
this.buildCellLines();
this.buildRegionBorders();
this.buildCells();
this.buildSelector();
if (this.hintsLeft > 0) this.buildHintBtn();
this.buildControls();
this.refreshSelector();
this.updateCompletedNums();
}
// Region size per cell + how many regions contain each value 1..5.
computeRegionMeta() {
const counts = {};
for (let r = 0; r < N; r++)
for (let c = 0; c < N; c++) {
const id = this.regions[r][c];
counts[id] = (counts[id] || 0) + 1;
}
this.regionSize = this.regions.map(row => row.map(id => counts[id]));
this.neededFor = [0, 0, 0, 0, 0, 0];
for (const id in counts) {
const size = counts[id];
for (let v = 1; v <= size; v++) this.neededFor[v]++;
}
}
// ── Paper & graph lines ─────────────────────────────────────────────────────
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);
// Printed graph-paper grid across the whole sheet.
const gl = this.add.graphics().setDepth(DEPTH.graph);
gl.lineStyle(1, GRAPH_BLUE, 0.45);
for (let x = PX + GRAPH_PITCH; x < PX + PW; x += GRAPH_PITCH)
gl.lineBetween(x, PY + 6, x, PY + PH - 6);
for (let y = PY + GRAPH_PITCH; y < PY + PH; y += GRAPH_PITCH)
gl.lineBetween(PX + 6, y, PX + PW - 6, y);
}
buildTitle() {
const titleTxt = this.add.text(TITLE_CX, 116, 'Tectonic', {
fontFamily: 'YummyCupcakes', fontSize: '84px', color: TITLE_BLUE,
}).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 lines & cage borders ────────────────────────────────────────────────
buildCellLines() {
const gfx = this.add.graphics().setDepth(DEPTH.cellLines);
gfx.lineStyle(1.5, CELL_BLUE, 0.5);
for (let i = 1; i < N; i++) {
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);
}
}
buildRegionBorders() {
const gfx = this.add.graphics().setDepth(DEPTH.region);
gfx.lineStyle(5, REGION_INK, 0.92);
const idAt = (r, c) => (r < 0 || r >= N || c < 0 || c >= N) ? -1 : this.regions[r][c];
for (let r = 0; r < N; r++) {
for (let c = 0; c < N; c++) {
const id = this.regions[r][c];
const x = GRID_X + c * CELL;
const y = GRID_Y + r * CELL;
if (idAt(r - 1, c) !== id) gfx.lineBetween(x, y, x + CELL, y);
if (idAt(r, c - 1) !== id) gfx.lineBetween(x, y, x, y + CELL);
if (idAt(r + 1, c) !== id) gfx.lineBetween(x, y + CELL, x + CELL, y + CELL);
if (idAt(r, c + 1) !== id) gfx.lineBetween(x + CELL, y, x + CELL, y + CELL);
}
}
}
// ── Cells ─────────────────────────────────────────────────────────────────────
buildCells() {
this.cellBgObjs = [];
this.cellTextObjs = [];
this.cellHitObjs = [];
for (let r = 0; r < N; r++) {
this.cellBgObjs.push([]);
this.cellTextObjs.push([]);
this.cellHitObjs.push([]);
for (let c = 0; c < N; 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 ? '46px' : '50px',
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 <= 5; 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: '74px', color: INK,
}).setOrigin(0.5).setDepth(DEPTH.number + 1);
this.numTextObjs[n] = numTxt;
const hit = this.add.rectangle(NUM_X, ny, 220, 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: '60px', color: FADED,
}).setOrigin(0.5).setDepth(DEPTH.number + 1);
const eraserHit = this.add.rectangle(NUM_X, ERASER_CY, 220, 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 <= 5) {
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) {
gfx.lineStyle(3, RED_MARK, 0.88);
gfx.strokeEllipse(cx, cy, 92, 80);
gfx.lineStyle(2, RED_MARK, 0.28);
gfx.strokeEllipse(cx + 3, cy - 2, 96, 84);
}
updateCompletedNums() {
const gfx = this.numStrikeGfx;
if (!gfx) return;
gfx.clear();
for (let n = 1; n <= 5; n++) {
let count = 0;
for (let r = 0; r < N; r++)
for (let c = 0; c < N; c++)
if (this.grid[r][c] === n) count++;
const txt = this.numTextObjs[n];
if (!txt) continue;
if (count >= this.neededFor[n]) {
txt.setColor('#9bb0c2');
const ny = NUM_START_Y + (n - 1) * NUM_STEP;
gfx.lineStyle(3, 0x7a92a6, 0.8);
gfx.lineBetween(NUM_X - 38, ny - 6, NUM_X + 38, 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: 240, height: 56, 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 {
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 === 'easy';
}
isAllFilled() {
for (let r = 0; r < N; r++)
for (let c = 0; c < N; 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 < N; r++) {
for (let c = 0; c < N; 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.30 : 0.20);
bg.fillRect(x, y, sz, sz);
} else if (state === 2) {
bg.fillStyle(0xb03a2e, isHovered ? 0.27 : 0.16);
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);
if (!cell) return;
const { r, c } = cell;
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_BLUE,
}).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 === 'medium' ? this.hintsUsed * 4 : 0;
return Math.max(0, base - hintPenalty);
}
async recordResult() {
try {
await api.post('/history/single-player', {
slug: 'tectonic', score: this.calcScore(), opponentScores: [], result: 'win',
});
} catch { /* best effort */ }
}
}

View File

@ -0,0 +1,28 @@
export const HINT_LIMITS = {
easy: Infinity,
medium: 5,
hard: 0,
};
export const DIFFICULTY_SCORES = {
easy: 20,
medium: 50,
hard: 90,
};
export function isBoardComplete(grid, solution) {
for (let r = 0; r < grid.length; r++)
for (let c = 0; c < grid[r].length; c++)
if (grid[r][c] !== solution[r][c]) return false;
return true;
}
// Returns a random empty cell that still needs filling, or null.
export function getHintCell(grid) {
const empties = [];
for (let r = 0; r < grid.length; r++)
for (let c = 0; c < grid[r].length; 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

@ -52,6 +52,7 @@ import MiniCrosswordGame from './games/minicrossword/MiniCrosswordGame.js';
import ForbiddenIslandGame from './games/forbiddenisland/ForbiddenIslandGame.js'; import ForbiddenIslandGame from './games/forbiddenisland/ForbiddenIslandGame.js';
import SolitaireTourGame from './games/solitairetour/SolitaireTourGame.js'; import SolitaireTourGame from './games/solitairetour/SolitaireTourGame.js';
import SplendorGame from './games/splendor/SplendorGame.js'; import SplendorGame from './games/splendor/SplendorGame.js';
import TectonicGame from './games/tectonic/TectonicGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -117,6 +118,7 @@ const config = {
ForbiddenIslandGame, ForbiddenIslandGame,
SolitaireTourGame, SolitaireTourGame,
SplendorGame, SplendorGame,
TectonicGame,
], ],
}; };

View File

@ -22,7 +22,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', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame' }; 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', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame' };
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,

File diff suppressed because it is too large Load Diff

View File

@ -2,286 +2,209 @@
{ {
"id": "easy-001", "id": "easy-001",
"difficulty": "easy", "difficulty": "easy",
"grid": ["APART", "POWER", "AWARE", "RERUN", "TREND"], "grid": ["#BLAH", "SLAVE", "CANAL", "AMEND", "REST#"],
"across": [ "across": [
"Separated, as two people", "Dull and uninspiring",
"Electrical energy", "One forced to work for no pay",
"Conscious of; in the know", "Panama waterway",
"TV episode shown again", "Change, as a law",
"Current fashion or direction" "Take a break"
], ],
"down": [ "down": [
"Not together", "Point the finger at",
"Strength or might", "Bowling alley divisions",
"Mindful", "___-garde (cutting-edge)",
"Summer TV staple", "Gripped tightly",
"What goes viral, perhaps" "Mark left by a healed wound"
] ]
}, },
{ {
"id": "easy-002", "id": "easy-008",
"difficulty": "easy", "difficulty": "easy",
"grid": ["SMART", "POLAR", "OLIVE", "RAVEN", "TREND"], "grid": ["WATCH", "AWARE", "SABER", "PILED", "STEPS"],
"across": [ "across": [
"Clever", "Wrist timepiece",
"Like a bear at the North Pole", "In the know",
"Green martini garnish", "Fencing sword",
"Edgar Allan Poe's black bird", "Stacked up",
"Hot new fashion" "Stairs, individually"
], ],
"down": [ "down": [
"Baseball or soccer, e.g.", "Stinging insects",
"Back tooth", "Wait for",
"Living and breathing", "Dining room furniture",
"Glossy black bird", "Move slowly and stealthily",
"Topic that's taking off online" "Cattle groups"
] ]
}, },
{ {
"id": "easy-003", "id": "easy-009",
"difficulty": "easy", "difficulty": "easy",
"grid": ["START", "THROW", "ARGUE", "ROUTE", "TWEED"], "grid": ["#TONE", "PERIL", "URGES", "BRACE", "SANE#"],
"across": [ "across": [
"Begin", "Muscle firmness, or vocal quality",
"Toss a ball", "Grave danger",
"Quarrel", "Strongly encourages",
"Path a bus takes", "Support for a sprained joint",
"Rough wool fabric for jackets" "Of sound mind"
], ],
"down": [ "down": [
"A race's beginning", "___ firma (solid ground)",
"Pitch, as a baseball", "Church keyboard instrument",
"Bicker", "Your sibling's daughter",
"Mail carrier's territory", "Or ___ (otherwise)",
"Tweedy jacket cloth" "Spots for a pint"
] ]
}, },
{ {
"id": "easy-004", "id": "easy-018",
"difficulty": "easy", "difficulty": "easy",
"grid": ["CLOSE", "LLAMA", "OASIS", "SMILE", "EASEL"], "grid": ["#FIRE", "COCOA", "ALIAS", "LINDY", "LOGS#"],
"across": [ "across": [
"Shut, as a door", "Campsite blaze",
"Andean pack animal", "Hot ___ (cozy winter drink)",
"Green spot in the desert", "Assumed name",
"Happy expression", "___ Hop (jazz-era swing dance)",
"Painter's stand" "Firewood pieces"
], ],
"down": [ "down": [
"Nearby", "Large book-page size",
"Spitting Andean beast", "Sugary cake topping",
"Desert refuge", "Routes for cars",
"What a camera asks you to do", "Not difficult",
"Stand for a canvas" "Ring up on the phone"
] ]
}, },
{ {
"id": "easy-005", "id": "easy-020",
"difficulty": "easy", "difficulty": "easy",
"grid": ["MAPLE", "AGAIN", "PAINT", "LINER", "ENTRY"], "grid": ["#PILE", "GENOA", "RADAR", "ACIDS", "BEES#"],
"across": [ "across": [
"Syrup-producing tree", "Big heap",
"Once more", "Italian port city",
"What an artist applies", "Speed-gun technology",
"Big cruise ship", "Bases' chemical opposites",
"Doorway, or a diary post" "Hive dwellers"
], ],
"down": [ "down": [
"Tree on Canada's flag", "Opposite of war",
"Time and time ___", "Like non-mainstream music",
"Wall color in a can", "Tons; a whole lot",
"Ocean-crossing vessel", "Corn units, or hearing organs",
"Way in" "Snatch quickly"
] ]
}, },
{ {
"id": "medium-001", "id": "medium-020",
"difficulty": "medium", "difficulty": "medium",
"grid": ["DROVE", "RAVEN", "OVERT", "VERVE", "ENTER"], "grid": ["#BLEW#", "PRISON", "RATTLE", "OCTAVE", "MELTED", "#SEES#"],
"across": [ "across": [
"Operated the car; also, a herd", "Past tense of \"blow\"",
"Bird that quoth 'Nevermore'", "Place for inmates",
"Out in the open, not hidden", "Baby's shaking toy",
"Energy and enthusiasm", "Span of eight musical notes",
"Key pressed to confirm" "Turned to liquid, as ice",
"Lays eyes on"
], ],
"down": [ "down": [
"Chauffeured", "Teeth straighteners",
"Corvid in a Poe poem", "Not much; small",
"Undisguised", "Large property with a mansion",
"Pep and flair", "Pack animals that howl",
"Go in" "High school spring dance",
"Require"
] ]
}, },
{ {
"id": "medium-002", "id": "medium-008",
"difficulty": "medium", "difficulty": "medium",
"grid": ["GRASP", "RUMOR", "AMPLE", "SOLVE", "PREEN"], "grid": ["#SEAL#", "MIRROR", "ARRIVE", "LEASED", "ENTERS", "#SANS#"],
"across": [ "across": [
"Grip firmly; comprehend", "Whiskered arctic swimmer",
"Unverified bit of gossip", "Bathroom reflector",
"More than enough", "Reach a destination",
"Crack, as a puzzle", "Rented out",
"Groom feathers, as a bird does" "Goes inside",
"Without, in French"
], ],
"down": [ "down": [
"Get a handle on", "Ambulance wailers",
"Word on the street", "List of printed errors",
"Plentiful", "Gotten up out of bed",
"Figure out", "Romantic partners",
"Primp" "Not female",
] "Crimson shades"
},
{
"id": "medium-003",
"difficulty": "medium",
"grid": ["SHADE", "HELIX", "ALIVE", "RIVER", "EXERT"],
"across": [
"Shadow cast by a tree",
"DNA's spiral shape",
"Not dead",
"The Nile or the Amazon",
"Put forth, as effort"
],
"down": [
"Portion; or post online",
"Double ___ (DNA structure)",
"Kicking, so to speak",
"One leaping off a board",
"Apply, as force"
] ]
}, },
{ {
"id": "medium-004", "id": "medium-004",
"difficulty": "medium", "difficulty": "medium",
"grid": ["CRANE", "RESIN", "ASSET", "NIECE", "ENTER"], "grid": ["#WEST#", "MISHAP", "ATTIRE", "CHARGE", "SETTER", "#REST#"],
"across": [ "across": [
"Construction lifting machine; or a tall bird", "Sunset direction",
"Sticky secretion that becomes amber", "Minor accident",
"Valuable item on a balance sheet", "Clothing; an outfit",
"Your sibling's daughter", "Put on a credit card",
"Type in, as data" "Irish ___ (long-haired dog)",
"The remainder"
], ],
"down": [ "down": [
"Stretch the neck to see", "Shrivel and dry up",
"Pine-tree ooze", "Inherited property",
"A plus on the books", "Closet hang-ups",
"Nephew's sister", "Bullseye holder",
"Walk into a room" "Apple computers",
] "Look closely"
},
{
"id": "medium-005",
"difficulty": "medium",
"grid": ["EVADE", "VEGAN", "AGENT", "DANCE", "ENTER"],
"across": [
"Dodge, as a question",
"One who eats no animal products",
"Spy, or an actor's rep",
"Waltz or tango",
"Sign up for, as a contest"
],
"down": [
"Slip away from",
"Plant-based eater",
"007, for one",
"Boogie",
"Join, as a race"
]
},
{
"id": "hard-001",
"difficulty": "hard",
"grid": ["CRONE", "RIVAL", "OVOID", "NAIVE", "ELDER"],
"across": [
"Witchy old woman of folklore",
"Competitor to beat",
"Egg-shaped",
"Innocently unworldly",
"Respected senior; also a berry bush"
],
"down": [
"Hag of fairy tales",
"Archnemesis",
"Like an egg's outline",
"Wet behind the ears",
"Tribal sage"
] ]
}, },
{ {
"id": "hard-002", "id": "hard-002",
"difficulty": "hard", "difficulty": "hard",
"grid": ["GRUFF", "RIVER", "UVULA", "FELON", "FRANK"], "grid": ["##TWO##", "#POEMS#", "TIDBITS", "AND#TEA", "BELATED", "#SEVER#", "##RED##"],
"across": [ "across": [
"Brusque and surly", "Number after one",
"The Mississippi, for one", "Verses by Emily Dickinson",
"Dangly bit at the back of the throat", "Small, tasty morsels",
"Convicted criminal", "Plus",
"Candidly blunt; or a hot dog" "Afternoon British drink",
"Late, as a birthday card",
"Cut off completely",
"Stop sign's color"
], ],
"down": [ "down": [
"Hoarse and curt", "Child just learning to walk",
"Flowing waterway", "Spider's silken trap",
"Throat's little hanging punching bag", "Left out",
"One with a rap sheet", "Evergreen trees",
"Straight-talking; ballpark sausage" "Guide a car",
"Bar bill",
"Feeling blue",
"Street: Abbr."
] ]
}, },
{ {
"id": "hard-003", "id": "hard-014",
"difficulty": "hard", "difficulty": "hard",
"grid": ["PLUMB", "LUNAR", "UNTIE", "MAIZE", "BREED"], "grid": ["##PIN##", "#PACED#", "FORESEE", "ART#TAG", "TEASING", "#SKINS#", "##ENG##"],
"across": [ "across": [
"Measure the depth of; dead vertical", "Bowling target",
"Of the moon", "Walked back and forth nervously",
"Loosen, as shoelaces", "Predict in advance",
"Corn, by another name", "Museum display",
"Raise animals; a dog variety" "Playground chasing game",
"Good-natured ribbing",
"Potato jackets",
"English: Abbr."
], ],
"down": [ "down": [
"Perfectly upright", "Join in; participate",
"___ eclipse", "Frozen water",
"Undo a knot", "What birds do in spring",
"Native American staple grain", "Tiny skin openings",
"Labrador or poodle, e.g." "College administrators",
] "Not lean",
}, "Breakfast protein source",
{ "Commandment breaker"
"id": "hard-004",
"difficulty": "hard",
"grid": ["SARGE", "ACORN", "ROBOT", "GROVE", "ENTER"],
"across": [
"Nickname for a drill instructor",
"Oak tree's nut",
"Automaton like R2-D2",
"Small cluster of trees",
"Make an entrance"
],
"down": [
"Boot-camp boss, informally",
"Squirrel's buried snack",
"Mechanical worker",
"Orange ___ in Florida",
"Step inside"
]
},
{
"id": "hard-005",
"difficulty": "hard",
"grid": ["SHALE", "HELIX", "ALIVE", "LIVER", "EXERT"],
"across": [
"Rock that yields oil and gas",
"Spiral, as of DNA",
"Full of life",
"Organ that filters toxins",
"Bring to bear, as influence"
],
"down": [
"Sedimentary fracking rock",
"Corkscrew shape",
"Among the living",
"Onions' classic skillet partner",
"Wield, as pressure"
] ]
} }
] ]

File diff suppressed because it is too large Load Diff

View File

@ -64,6 +64,7 @@ registerGame({ slug: 'oldmaid', name: 'Old Maid', category: 'ca
registerGame({ slug: 'blokus', name: 'Blokus', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 36 }); registerGame({ slug: 'blokus', name: 'Blokus', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 36 });
registerGame({ slug: 'spellingbee', name: 'Spelling Bee', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 37 }); registerGame({ slug: 'spellingbee', name: 'Spelling Bee', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 37 });
registerGame({ slug: 'minicrossword', name: 'Mini Crossword', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 38 }); registerGame({ slug: 'minicrossword', name: 'Mini Crossword', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 38 });
registerGame({ slug: 'tectonic', name: 'Tectonic', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 42 });
registerGame({ slug: 'forbiddenisland', name: 'Forbidden Island', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: false, iconFrame: 39 }); registerGame({ slug: 'forbiddenisland', name: 'Forbidden Island', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: false, iconFrame: 39 });
registerGame({ slug: 'solitairetour', name: 'Solitaire Tour', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 40 }); registerGame({ slug: 'solitairetour', name: 'Solitaire Tour', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 40 });
registerGame({ slug: 'splendor', name: 'Splendor', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 41 }); registerGame({ slug: 'splendor', name: 'Splendor', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 41 });

View File

@ -0,0 +1,54 @@
// Builds server/data/wordlists/common.txt — the clue-able word subset the Mini
// Crossword generator fills from. It intersects the ENABLE dictionary with the
// Norvig word-frequency list, applying a *tighter rank cutoff for short words*:
// short obscure words (ANI, CESS, SEG) are the worst crossword fill, and the
// truly common short words rank very high, so a low cutoff filters the junk
// while keeping enough vocabulary to fill.
//
// Usage: node server/scripts/buildCommonWords.js
// Caches the 5 MB frequency list at /tmp/count_1w.txt to avoid re-downloading.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ENABLE_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
const OUT_PATH = path.join(__dirname, '../data/wordlists/common.txt');
const FREQ_URL = 'https://norvig.com/ngrams/count_1w.txt';
const FREQ_CACHE = '/tmp/count_1w.txt';
// Frequency-rank cutoff per word length. Short words get a much stricter bar.
const CUTOFF = { 3: 6000, 4: 18000, 5: 45000, 6: 70000, 7: 70000 };
async function loadFreq() {
if (fs.existsSync(FREQ_CACHE)) return fs.readFileSync(FREQ_CACHE, 'utf8');
console.log('Downloading frequency list...');
const text = await (await fetch(FREQ_URL)).text();
fs.writeFileSync(FREQ_CACHE, text);
return text;
}
async function main() {
const enable = new Set(
fs.readFileSync(ENABLE_PATH, 'utf8').split('\n').map((w) => w.trim().toUpperCase()),
);
const ranked = (await loadFreq())
.split('\n').map((l) => l.split('\t')[0]).filter(Boolean).map((w) => w.toUpperCase());
const out = [];
const byLen = {};
ranked.forEach((w, rank) => {
const len = w.length;
if (!CUTOFF[len] || rank >= CUTOFF[len]) return;
if (!/^[A-Z]+$/.test(w) || !enable.has(w)) return;
out.push(w);
byLen[len] = (byLen[len] || 0) + 1;
});
fs.writeFileSync(OUT_PATH, out.join('\n') + '\n');
console.log(`Wrote ${out.length} common words to ${OUT_PATH}`);
console.log('Per length:', JSON.stringify(byLen));
}
main();

View File

@ -0,0 +1,339 @@
// Offline generator for Mini Crossword grids.
//
// Produces *non-symmetric* crossword grids (across words differ from down words)
// at three sizes — easy 5x5, medium 6x6, hard 7x7 — by filling block templates
// against the ENABLE word list with a backtracking solver (MRV slot ordering +
// forward checking). It emits a candidate JSON whose `across`/`down` clue arrays
// are blank placeholders, plus the answers, so clues can be hand-authored after.
//
// Usage:
// node server/scripts/genMiniCrossword.js [perTier] [outFile]
// node server/scripts/genMiniCrossword.js 20 server/data/crosswords/_generated.json
//
// All runs are length >= 3. Templates are rotationally symmetric. Each puzzle
// uses every word at most once.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Common-word subset (top frequency ∩ ENABLE) so fills stay clue-able rather
// than picking obscure Scrabble words. Built once via buildCommonWords.js.
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/common.txt');
const BLOCK = '#';
const EMPTY = '.';
// ── Block templates ────────────────────────────────────────────────────────────
// '.' = fillable cell, '#' = black square. Each is rotationally symmetric with no
// run shorter than 3. Blocks make the fill *easier* (shorter, more-flexible runs)
// while giving a real-crossword shape. More than one template per tier yields
// more grid variety.
const TEMPLATES = {
easy: [
// Open 5x5: a double word square — 5 across + 5 down, all distinct words.
[
'.....',
'.....',
'.....',
'.....',
'.....',
],
// 5x5 with symmetric corner nicks (len-4 corners + len-5 spines).
[
'#....',
'.....',
'.....',
'.....',
'....#',
],
],
medium: [
// 6x6 corner-cut: len-4 corners, len-6 spines.
[
'#....#',
'......',
'......',
'......',
'......',
'#....#',
],
],
hard: [
// 7x7 with symmetric corner blocks + center split. Runs of length 3, 5, 7.
[
'##...##',
'#.....#',
'.......',
'...#...',
'.......',
'#.....#',
'##...##',
],
],
};
const TIER_SIZE = { easy: 5, medium: 6, hard: 7 };
// ── Word list ───────────────────────────────────────────────────────────────────
// Returns { byLen: Map<len, string[]>, index: Map<len, Array<Map<letter, Set<idx>>>> }.
// The index lets us find words matching a partial pattern by intersecting the
// candidate sets of the already-fixed letter positions.
function loadWords() {
const raw = fs.readFileSync(WORDLIST_PATH, 'utf8');
const byLen = new Map();
for (const line of raw.split('\n')) {
const w = line.trim().toUpperCase();
if (w.length < 3 || w.length > 7 || !/^[A-Z]+$/.test(w)) continue;
if (!byLen.has(w.length)) byLen.set(w.length, []);
byLen.get(w.length).push(w);
}
const index = new Map();
const sets = new Map();
for (const [len, words] of byLen) {
sets.set(len, new Set(words));
const positions = Array.from({ length: len }, () => new Map());
words.forEach((w, i) => {
for (let p = 0; p < len; p++) {
const m = positions[p];
const ch = w[p];
if (!m.has(ch)) m.set(ch, new Set());
m.get(ch).add(i);
}
});
index.set(len, positions);
}
return { byLen, index, sets };
}
// Words of `len` whose letters match `pattern` (array of letter|null), excluding
// any already in `used`. Intersects the smallest fixed-position sets first.
function candidates({ byLen, index }, len, pattern, used) {
const fixed = [];
for (let p = 0; p < len; p++) {
if (pattern[p]) fixed.push([p, pattern[p]]);
}
const words = byLen.get(len) || [];
if (!fixed.length) {
return words.filter((w) => !used.has(w));
}
const positions = index.get(len);
const sets = fixed.map(([p, ch]) => positions[p].get(ch) || new Set());
if (sets.some((s) => s.size === 0)) return [];
sets.sort((a, b) => a.size - b.size);
const out = [];
for (const i of sets[0]) {
let ok = true;
for (let k = 1; k < sets.length; k++) {
if (!sets[k].has(i)) { ok = false; break; }
}
if (!ok) continue;
const w = words[i];
if (!used.has(w)) out.push(w);
}
return out;
}
// ── Slot extraction (matches the engine's numbering convention) ─────────────────
function buildGrid(template) {
return template.map((row) => row.split(''));
}
function isCell(grid, r, c) {
return r >= 0 && r < grid.length && c >= 0 && c < grid[0].length && grid[r][c] !== BLOCK;
}
// Returns numbered across/down slots; each slot lists its cells in order.
function deriveSlots(grid) {
const across = [];
const down = [];
let number = 0;
for (let r = 0; r < grid.length; r++) {
for (let c = 0; c < grid[0].length; c++) {
if (!isCell(grid, r, c)) continue;
const startsAcross = !isCell(grid, r, c - 1) && isCell(grid, r, c + 1);
const startsDown = !isCell(grid, r - 1, c) && isCell(grid, r + 1, c);
if (!startsAcross && !startsDown) continue;
number += 1;
if (startsAcross) {
const cells = [];
let cc = c;
while (isCell(grid, r, cc)) { cells.push([r, cc]); cc += 1; }
across.push({ number, row: r, col: c, len: cells.length, cells });
}
if (startsDown) {
const cells = [];
let rr = r;
while (isCell(grid, rr, c)) { cells.push([rr, c]); rr += 1; }
down.push({ number, row: r, col: c, len: cells.length, cells });
}
}
}
return [...across, ...down]; // combined slot list for the solver
}
// ── Backtracking solver ─────────────────────────────────────────────────────────
function patternFor(grid, slot) {
return slot.cells.map(([r, c]) => (grid[r][c] === EMPTY ? null : grid[r][c]));
}
function isFilled(grid, slot) {
return slot.cells.every(([r, c]) => grid[r][c] !== EMPTY);
}
function wordAt(grid, slot) {
return slot.cells.map(([r, c]) => grid[r][c]).join('');
}
function placeWord(grid, slot, word) {
const prev = slot.cells.map(([r, c]) => grid[r][c]);
slot.cells.forEach(([r, c], i) => { grid[r][c] = word[i]; });
return prev;
}
function unplace(grid, slot, prev) {
slot.cells.forEach(([r, c], i) => { grid[r][c] = prev[i]; });
}
// Fills `grid` so every slot is a distinct valid word. Every slot is explicitly
// assigned a candidate word — a slot already fully determined by crossing words
// simply has one candidate (if that spelling is a real word) or zero (if not), so
// invalid crossings are pruned automatically. Mutates grid in place; returns true
// on success. `assigned` tracks resolved slots; `deadline` is an epoch-ms budget.
function solve(grid, slots, words, used, assigned, deadline) {
if (Date.now() > deadline) return false;
// MRV: assign the unresolved slot with the fewest candidates first. Slots fixed
// by crossings collapse to <=1 candidate and resolve immediately.
let target = null;
let targetCands = null;
for (const slot of slots) {
if (assigned.has(slot)) continue;
const cands = candidates(words, slot.len, patternFor(grid, slot), used);
if (cands.length === 0) return false;
if (!targetCands || cands.length < targetCands.length) {
target = slot;
targetCands = cands;
if (cands.length === 1) break;
}
}
if (!target) return true; // every slot assigned
shuffle(targetCands);
for (const word of targetCands) {
const prev = placeWord(grid, target, word);
used.add(word);
assigned.add(target);
if (solve(grid, slots, words, used, assigned, deadline)) return true;
assigned.delete(target);
used.delete(word);
unplace(grid, target, prev);
}
return false;
}
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;
}
// ── Puzzle assembly ─────────────────────────────────────────────────────────────
// Re-derive ordered across/down answer lists from a solved grid (numbering order).
function answersFromGrid(grid) {
const across = [];
const down = [];
let number = 0;
for (let r = 0; r < grid.length; r++) {
for (let c = 0; c < grid[0].length; c++) {
if (!isCell(grid, r, c)) continue;
const sa = !isCell(grid, r, c - 1) && isCell(grid, r, c + 1);
const sd = !isCell(grid, r - 1, c) && isCell(grid, r + 1, c);
if (!sa && !sd) continue;
number += 1;
if (sa) { let s = '', cc = c; while (isCell(grid, r, cc)) { s += grid[r][cc]; cc++; } across.push({ number, answer: s }); }
if (sd) { let s = '', rr = r; while (isCell(grid, rr, c)) { s += grid[rr][c]; rr++; } down.push({ number, answer: s }); }
}
}
return { across, down };
}
function generateOne(template, words, timeoutMs = 800) {
const grid = buildGrid(template);
const slots = deriveSlots(grid);
const used = new Set();
const ok = solve(grid, slots, words, used, new Set(), Date.now() + timeoutMs);
if (!ok) return null;
// Safety net: every across/down answer must be a real common word and unique.
const seen = new Set();
for (const slot of slots) {
const w = wordAt(grid, slot);
if (!words.sets.get(slot.len)?.has(w) || seen.has(w)) return null;
seen.add(w);
}
return grid.map((row) => row.join(''));
}
function main() {
const perTier = parseInt(process.argv[2], 10) || 20;
const outFile = process.argv[3] || path.join(__dirname, '../data/crosswords/_generated.json');
console.log('Loading ENABLE word list...');
const words = loadWords();
console.log(` ${[...words.byLen].map(([l, a]) => `${l}:${a.length}`).join(' ')}`);
const bank = [];
for (const tier of ['easy', 'medium', 'hard']) {
const templates = TEMPLATES[tier];
const seen = new Set();
let made = 0;
let attempts = 0;
const maxAttempts = perTier * 80;
const t0 = Date.now();
while (made < perTier && attempts < maxAttempts) {
attempts += 1;
const template = templates[attempts % templates.length];
const rows = generateOne(template, words);
if (process.env.CW_DEBUG && attempts % 5 === 0) {
console.log(` [${tier}] attempt ${attempts}, made ${made}, ${((Date.now() - t0) / 1000).toFixed(1)}s`);
}
if (!rows) continue;
const key = rows.join('|');
if (seen.has(key)) continue;
seen.add(key);
const { across, down } = answersFromGrid(buildGrid(rows));
bank.push({
id: `${tier}-${String(made + 1).padStart(3, '0')}`,
difficulty: tier,
grid: rows,
// Reference answers (NOT consumed by the engine) to author clues against:
_answersAcross: across.map((a) => `${a.number}. ${a.answer}`),
_answersDown: down.map((a) => `${a.number}. ${a.answer}`),
across: across.map(() => ''),
down: down.map(() => ''),
});
made += 1;
}
console.log(`${tier} (${TIER_SIZE[tier]}x${TIER_SIZE[tier]}): ${made}/${perTier} grids in ${attempts} attempts, ${((Date.now() - t0) / 1000).toFixed(1)}s`);
}
fs.writeFileSync(outFile, JSON.stringify(bank, null, 2));
console.log(`\nWrote ${bank.length} candidate puzzles to ${outFile}`);
console.log('Fill the empty across/down clue arrays; _answers* fields are references.');
}
main();

View File

@ -1,10 +1,11 @@
// Mini Crossword engine: serves curated 5x5 puzzles from a hand-authored bank. // Mini Crossword engine: serves curated puzzles from a hand-authored bank.
// Pure logic — no Express. Loaded once at server start. // Pure logic — no Express. Loaded once at server start.
// //
// A puzzle is authored as { id, difficulty, grid:[5 row strings], across:[5], // A puzzle is authored as { id, difficulty, grid:[row strings], across:[...],
// down:[5] }. Grids are fixed 5x5; a '#' marks a black square. Across/Down clue // down:[...] }. Difficulty sets the grid size: easy 5x5, medium 6x6, hard 7x7.
// arrays are ordered by row index / column index respectively. This engine // A '#' marks a black square. Across/Down clue arrays are ordered by the derived
// derives the standard crossword numbering and pairs each clue with its slot. // crossword numbering (reading order). This engine derives that numbering and
// pairs each clue with its slot.
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@ -13,18 +14,19 @@ import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PUZZLE_PATH = path.join(__dirname, '../data/crosswords/minicrossword.json'); const PUZZLE_PATH = path.join(__dirname, '../data/crosswords/minicrossword.json');
const SIZE = 5;
const BLOCK = '#'; const BLOCK = '#';
const DIFFICULTIES = ['easy', 'medium', 'hard']; // Difficulty no longer means clue obscurity — it sets the grid size.
const TIER_SIZE = { easy: 5, medium: 6, hard: 7 };
const DIFFICULTIES = Object.keys(TIER_SIZE);
let byDifficulty = { easy: [], medium: [], hard: [] }; let byDifficulty = { easy: [], medium: [], hard: [] };
let allPuzzles = []; let allPuzzles = [];
// ── Slot extraction & numbering ─────────────────────────────────────────────── // ── Slot extraction & numbering ───────────────────────────────────────────────
// Returns true when (r,c) is a letter cell (not a black square). // Returns true when (r,c) is a letter cell (not a black square) within the grid.
function isCell(grid, r, c) { function isCell(grid, r, c) {
return r >= 0 && r < SIZE && c >= 0 && c < SIZE && grid[r][c] !== BLOCK; return r >= 0 && r < grid.length && c >= 0 && c < grid[r].length && grid[r][c] !== BLOCK;
} }
// Walks the grid in reading order and builds the numbered across/down slots. // Walks the grid in reading order and builds the numbered across/down slots.
@ -36,8 +38,8 @@ function deriveSlots(grid) {
const down = []; const down = [];
let number = 0; let number = 0;
for (let r = 0; r < SIZE; r++) { for (let r = 0; r < grid.length; r++) {
for (let c = 0; c < SIZE; c++) { for (let c = 0; c < grid[r].length; c++) {
if (!isCell(grid, r, c)) continue; if (!isCell(grid, r, c)) continue;
const startsAcross = !isCell(grid, r, c - 1) && isCell(grid, r, c + 1); const startsAcross = !isCell(grid, r, c - 1) && isCell(grid, r, c + 1);
@ -66,12 +68,17 @@ function deriveSlots(grid) {
// ── Validation ──────────────────────────────────────────────────────────────── // ── Validation ────────────────────────────────────────────────────────────────
function validatePuzzle(p) { function validatePuzzle(p) {
if (!Array.isArray(p.grid) || p.grid.length !== SIZE) { const size = TIER_SIZE[p.difficulty];
throw new Error(`puzzle ${p.id}: grid must have ${SIZE} rows`); if (!size) {
throw new Error(`puzzle ${p.id}: unknown difficulty '${p.difficulty}'`);
} }
if (!Array.isArray(p.grid) || p.grid.length !== size) {
throw new Error(`puzzle ${p.id}: ${p.difficulty} grid must have ${size} rows`);
}
const rowRe = new RegExp(`^[A-Z#]{${size}}$`);
for (const row of p.grid) { for (const row of p.grid) {
if (typeof row !== 'string' || row.length !== SIZE || !/^[A-Z#]{5}$/.test(row)) { if (typeof row !== 'string' || !rowRe.test(row)) {
throw new Error(`puzzle ${p.id}: each row must be ${SIZE} chars of A-Z or '#'`); throw new Error(`puzzle ${p.id}: each row must be ${size} chars of A-Z or '#'`);
} }
} }
const { across, down } = deriveSlots(p.grid); const { across, down } = deriveSlots(p.grid);
@ -101,8 +108,7 @@ export function initMiniCrosswordPuzzles() {
const bank = JSON.parse(raw); const bank = JSON.parse(raw);
for (const p of bank) { for (const p of bank) {
validatePuzzle(p); validatePuzzle(p);
const diff = DIFFICULTIES.includes(p.difficulty) ? p.difficulty : 'medium'; byDifficulty[p.difficulty].push(p);
byDifficulty[diff].push(p);
allPuzzles.push(p); allPuzzles.push(p);
} }
return { puzzles: allPuzzles.length }; return { puzzles: allPuzzles.length };
@ -112,10 +118,10 @@ export function initMiniCrosswordPuzzles() {
// Returns a random puzzle for the requested difficulty, packaged with derived // Returns a random puzzle for the requested difficulty, packaged with derived
// numbering. Each clue entry carries its number, start cell, length and answer. // numbering. Each clue entry carries its number, start cell, length and answer.
export function getPuzzle(difficulty = 'medium') { export function getPuzzle(difficulty = 'easy') {
const bucket = byDifficulty[difficulty]?.length ? byDifficulty[difficulty] : allPuzzles; const bucket = byDifficulty[difficulty]?.length ? byDifficulty[difficulty] : allPuzzles;
if (!bucket.length) { if (!bucket.length) {
return { id: null, difficulty, rows: SIZE, cols: SIZE, grid: [], across: [], down: [] }; return { id: null, difficulty, rows: 0, cols: 0, grid: [], across: [], down: [] };
} }
const p = bucket[Math.floor(Math.random() * bucket.length)]; const p = bucket[Math.floor(Math.random() * bucket.length)];
@ -124,8 +130,8 @@ export function getPuzzle(difficulty = 'medium') {
return { return {
id: p.id, id: p.id,
difficulty: p.difficulty, difficulty: p.difficulty,
rows: SIZE, rows: p.grid.length,
cols: SIZE, cols: p.grid[0].length,
grid: p.grid, grid: p.grid,
across: across.map((slot, i) => ({ ...slot, clue: p.across[i] })), across: across.map((slot, i) => ({ ...slot, clue: p.across[i] })),
down: down.map((slot, i) => ({ ...slot, clue: p.down[i] })), down: down.map((slot, i) => ({ ...slot, clue: p.down[i] })),

View File

@ -0,0 +1,317 @@
// Tectonic (a.k.a. Suguru) generator.
//
// Rules:
// • The grid is partitioned into irregular regions ("cages") of 15 cells.
// • A region of N cells contains the numbers 1..N (so digits never exceed 5).
// • No two cells that touch orthogonally OR diagonally (king-move / 8-way)
// may hold the same number.
//
// generatePuzzle(difficulty) → { grid, solution, regions, difficulty }
// grid : 8×8, 0 = empty cell the player must fill
// solution : 8×8 fully-solved board
// regions : 8×8 of integer region IDs (which cage each cell belongs to)
//
// Generation:
// 1. Partition the grid into 15-cell regions (randomized greedy growth).
// 2. Fill a random valid solution (backtracking on flat Int8Array boards with
// precomputed peer lists and most-constrained-variable ordering).
// 3. Dig holes: remove a cell only while the remaining givens are still
// uniquely solvable *by logic alone* (naked singles + hidden singles in a
// region). Logical solvability proves a unique solution AND guarantees the
// puzzle is human-solvable without guessing. The propagation solver is
// polynomial, so digging is fast and low-variance (unlike brute-force
// solution-counting, which blows up on sparse Tectonic boards).
const N = 8;
const CELLS = N * N;
const ORTHO = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const KING = [
[-1, -1], [-1, 0], [-1, 1],
[0, -1], [0, 1],
[1, -1], [1, 0], [1, 1],
];
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 inBounds(r, c) {
return r >= 0 && r < N && c >= 0 && c < N;
}
function orthoNeighbors(r, c) {
const out = [];
for (const [dr, dc] of ORTHO) {
const nr = r + dr, nc = c + dc;
if (inBounds(nr, nc)) out.push([nr, nc]);
}
return out;
}
function bitToValue(bit) {
return bit === 1 ? 1 : bit === 2 ? 2 : bit === 4 ? 3 : bit === 8 ? 4 : 5;
}
// ── Region partition ────────────────────────────────────────────────────────
// Greedy growth that seeds the most-constrained free cell (fewest free
// neighbours) and grows toward size 5, always extending into the tightest
// pocket. This keeps stranded size-1 regions rare without forbidding them.
function partition() {
const regions = Array.from({ length: N }, () => Array(N).fill(-1));
let unassigned = CELLS;
let nextId = 0;
const freeNeighborCount = (r, c) => {
let cnt = 0;
for (const [nr, nc] of orthoNeighbors(r, c))
if (regions[nr][nc] === -1) cnt++;
return cnt;
};
while (unassigned > 0) {
const free = [];
for (let r = 0; r < N; r++)
for (let c = 0; c < N; c++)
if (regions[r][c] === -1) free.push([r, c]);
shuffle(free);
let seed = free[0], bestSeed = Infinity;
for (const [r, c] of free) {
const u = freeNeighborCount(r, c);
if (u < bestSeed) { bestSeed = u; seed = [r, c]; }
}
const id = nextId++;
const members = [seed];
regions[seed[0]][seed[1]] = id;
while (members.length < 5) {
const frontier = [];
for (const [mr, mc] of members)
for (const [nr, nc] of orthoNeighbors(mr, mc))
if (regions[nr][nc] === -1) frontier.push([nr, nc]);
if (frontier.length === 0) break;
shuffle(frontier);
let pick = frontier[0], bestPick = Infinity;
for (const [r, c] of frontier) {
const u = freeNeighborCount(r, c);
if (u < bestPick) { bestPick = u; pick = [r, c]; }
}
regions[pick[0]][pick[1]] = id;
members.push(pick);
}
unassigned -= members.length;
}
return regions;
}
// Precompute flat lookup tables from a 2D region grid.
// sizeOf[i] : size of i's region (max value allowed in i)
// peers[i] : Int16Array of region-mates + king-neighbours (excl. self)
// byRegion : Map regionId -> array of cell indices
function buildTables(regions) {
const regionOf = new Int16Array(CELLS);
const byRegion = new Map();
for (let r = 0; r < N; r++)
for (let c = 0; c < N; c++) {
const i = r * N + c;
const id = regions[r][c];
regionOf[i] = id;
if (!byRegion.has(id)) byRegion.set(id, []);
byRegion.get(id).push(i);
}
const sizeOf = new Int8Array(CELLS);
const peers = new Array(CELLS);
for (let r = 0; r < N; r++)
for (let c = 0; c < N; c++) {
const i = r * N + c;
const fellows = byRegion.get(regionOf[i]);
sizeOf[i] = fellows.length;
const set = new Set();
for (const j of fellows) if (j !== i) set.add(j);
for (const [dr, dc] of KING) {
const nr = r + dr, nc = c + dc;
if (inBounds(nr, nc)) set.add(nr * N + nc);
}
peers[i] = Int16Array.from(set);
}
return { sizeOf, peers, byRegion };
}
// ── Solution fill (backtracking) ──────────────────────────────────────────────
function valueOk(g, peers, i, v) {
const p = peers[i];
for (let k = 0; k < p.length; k++) if (g[p[k]] === v) return false;
return true;
}
// Most-constrained empty cell. Returns { idx, cands } or null when full;
// short-circuits on the first forced (0- or 1-candidate) cell.
function findBestCell(g, sizeOf, peers) {
let bestIdx = -1, bestCands = null, bestLen = 99;
for (let i = 0; i < CELLS; i++) {
if (g[i] !== 0) continue;
const max = sizeOf[i];
const cands = [];
for (let v = 1; v <= max; v++) if (valueOk(g, peers, i, v)) cands.push(v);
if (cands.length < bestLen) {
bestLen = cands.length; bestIdx = i; bestCands = cands;
if (bestLen <= 1) return { idx: bestIdx, cands: bestCands };
}
}
return bestIdx === -1 ? null : { idx: bestIdx, cands: bestCands };
}
// Fills g in place with one random valid solution. Returns true on success,
// false if unsatisfiable, or null if the node budget was exhausted.
function solveOne(g, sizeOf, peers, budget) {
let nodes = 0;
function rec() {
if (++nodes > budget) return null;
const next = findBestCell(g, sizeOf, peers);
if (!next) return true;
if (next.cands.length === 0) return false;
const { idx } = next;
for (const v of shuffle([...next.cands])) {
g[idx] = v;
const r = rec();
if (r === true || r === null) return r;
g[idx] = 0;
}
return false;
}
return rec();
}
// ── Logic solver (naked + hidden singles) ─────────────────────────────────────
// Returns true iff `given` is fully solvable by propagation alone — which proves
// the solution is unique and the puzzle needs no guessing.
function logicSolves(given, tables) {
const { sizeOf, peers, byRegion } = tables;
const val = new Int8Array(CELLS);
const cand = new Int8Array(CELLS);
for (let i = 0; i < CELLS; i++) cand[i] = (1 << sizeOf[i]) - 1;
const assign = (i, v) => {
val[i] = v;
cand[i] = 1 << (v - 1);
const p = peers[i];
const mask = ~(1 << (v - 1));
for (let k = 0; k < p.length; k++)
if (val[p[k]] === 0) cand[p[k]] &= mask;
};
for (let i = 0; i < CELLS; i++) if (given[i] !== 0) assign(i, given[i]);
let progress = true;
while (progress) {
progress = false;
// Naked singles
for (let i = 0; i < CELLS; i++) {
if (val[i] !== 0) continue;
const c = cand[i];
if (c === 0) return false; // contradiction
if ((c & (c - 1)) === 0) { // exactly one bit
assign(i, bitToValue(c));
progress = true;
}
}
if (progress) continue;
// Hidden singles within each region
for (const cells of byRegion.values()) {
const size = cells.length;
for (let v = 1; v <= size; v++) {
const bit = 1 << (v - 1);
let count = 0, where = -1, present = false;
for (const i of cells) {
if (val[i] === v) { present = true; break; }
if (val[i] === 0 && (cand[i] & bit)) { count++; where = i; }
}
if (present) continue;
if (count === 0) return false; // value has nowhere to go
if (count === 1) { assign(where, v); progress = true; }
}
}
}
for (let i = 0; i < CELLS; i++) if (val[i] === 0) return false;
return true;
}
// ── Hole digging (logic-guarded) ──────────────────────────────────────────────
const GIVENS = { easy: 30, medium: 24, hard: 18 };
function dig(solution, tables, target) {
const given = Int8Array.from(solution);
const order = shuffle([...Array(CELLS).keys()]);
let givens = CELLS;
for (const i of order) {
if (givens <= target) break;
const saved = given[i];
given[i] = 0;
if (logicSolves(given, tables)) givens--;
else given[i] = saved;
}
return { given, givens };
}
// ── Public API ───────────────────────────────────────────────────────────────
function buildSolved(deadline) {
for (let attempt = 0; attempt < 200; attempt++) {
if (Date.now() > deadline) return null;
const regions = partition();
const tables = buildTables(regions);
const sol = new Int8Array(CELLS);
if (solveOne(sol, tables.sizeOf, tables.peers, 30000) === true)
return { regions, tables, sol };
}
return null;
}
function toGrid(flat) {
const out = [];
for (let r = 0; r < N; r++) {
const row = [];
for (let c = 0; c < N; c++) row.push(flat[r * N + c]);
out.push(row);
}
return out;
}
export function generatePuzzle(difficulty) {
const target = GIVENS[difficulty] ?? GIVENS.medium;
const deadline = Date.now() + 3000;
let best = null;
while (Date.now() < deadline) {
const built = buildSolved(deadline);
if (!built) break;
const { regions, tables, sol } = built;
const { given, givens } = dig(sol, tables, target);
if (givens <= target) {
return { grid: toGrid(given), solution: toGrid(sol), regions, difficulty };
}
if (!best || givens < best.givens) {
best = { grid: toGrid(given), solution: toGrid(sol), regions, givens };
}
}
if (!best) throw new Error('tectonic: failed to generate a board');
return { grid: best.grid, solution: best.solution, regions: best.regions, difficulty };
}

View File

@ -17,6 +17,7 @@ import {
listThemes as wordSearchThemes, listThemes as wordSearchThemes,
} from './wordSearchEngine.js'; } from './wordSearchEngine.js';
import { generatePuzzle as sudokuGenerate } from './sudokuEngine.js'; import { generatePuzzle as sudokuGenerate } from './sudokuEngine.js';
import { generatePuzzle as tectonicGenerate } from './tectonicEngine.js';
import { initBoggleDictionary, rollBoard, solveBoard } from './boggleEngine.js'; import { initBoggleDictionary, rollBoard, solveBoard } from './boggleEngine.js';
import { initSpellingBeeDictionary, generatePuzzle as spellingBeeGenerate } from './spellingBeeEngine.js'; import { initSpellingBeeDictionary, generatePuzzle as spellingBeeGenerate } from './spellingBeeEngine.js';
import { initMiniCrosswordPuzzles, getPuzzle as miniCrosswordGet } from './miniCrosswordEngine.js'; import { initMiniCrosswordPuzzles, getPuzzle as miniCrosswordGet } from './miniCrosswordEngine.js';
@ -156,7 +157,7 @@ function loadWordLists() {
const beeStats = initSpellingBeeDictionary(allWords); const beeStats = initSpellingBeeDictionary(allWords);
console.log(`[words] loaded ${beeStats.words} Spelling Bee words (${beeStats.pangrams} pangram sets)`); console.log(`[words] loaded ${beeStats.words} Spelling Bee words (${beeStats.pangrams} pangram sets)`);
// Mini Crossword: curated 5x5 puzzle bank (independent of the ENABLE list). // Mini Crossword: curated puzzle bank, 5x5/6x6/7x7 by difficulty (independent of the ENABLE list).
const crosswordStats = initMiniCrosswordPuzzles(); const crosswordStats = initMiniCrosswordPuzzles();
console.log(`[words] loaded ${crosswordStats.puzzles} Mini Crossword puzzles`); console.log(`[words] loaded ${crosswordStats.puzzles} Mini Crossword puzzles`);
@ -216,10 +217,11 @@ router.get('/spellingbee/start', (req, res) => {
// ── Mini Crossword ──────────────────────────────────────────────────────────── // ── Mini Crossword ────────────────────────────────────────────────────────────
// GET /api/words/minicrossword/start?difficulty=easy|medium|hard // GET /api/words/minicrossword/start?difficulty=easy|medium|hard
// Returns a curated 5x5 puzzle (grid + numbered across/down clues with answers). // Difficulty sets the grid size: easy 5x5, medium 6x6, hard 7x7. Returns the
// grid + numbered across/down clues (with answers).
router.get('/minicrossword/start', (req, res) => { router.get('/minicrossword/start', (req, res) => {
const VALID = ['easy', 'medium', 'hard']; const VALID = ['easy', 'medium', 'hard'];
const difficulty = VALID.includes(req.query.difficulty) ? req.query.difficulty : 'medium'; const difficulty = VALID.includes(req.query.difficulty) ? req.query.difficulty : 'easy';
res.json(miniCrosswordGet(difficulty)); res.json(miniCrosswordGet(difficulty));
}); });
@ -425,4 +427,11 @@ router.get('/sudoku/start', (req, res) => {
res.json(sudokuGenerate(difficulty)); res.json(sudokuGenerate(difficulty));
}); });
// GET /api/words/tectonic/start?difficulty=easy|medium|hard
router.get('/tectonic/start', (req, res) => {
const VALID = ['easy', 'medium', 'hard'];
const difficulty = VALID.includes(req.query.difficulty) ? req.query.difficulty : 'medium';
res.json(tectonicGenerate(difficulty));
});
export default router; export default router;