Crossword Changes and added Tectonic
This commit is contained in:
parent
e07f48a85c
commit
9cb05f5f44
Binary file not shown.
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 158 KiB |
Binary file not shown.
|
|
@ -24,9 +24,13 @@ const TITLE_GOLD = '#d4a017';
|
|||
const DEPTH = { bg: 0, panel: 2, cell: 8, cellTxt: 10, ui: 20, overlay: 40, overlayUI: 42 };
|
||||
|
||||
// ── Grid geometry ────────────────────────────────────────────────────────────────
|
||||
const CELL_SIZE = 116;
|
||||
const GRID_LEFT = 300;
|
||||
const GRID_TOP = 300;
|
||||
// The grid is sized to fit the left region (clue panel lives on the right at
|
||||
// x>=1010), so 5x5 / 6x6 / 7x7 all fit without overlap. Cell size, position and
|
||||
// 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 {
|
||||
constructor() { super('MiniCrosswordGame'); }
|
||||
|
|
@ -91,21 +95,25 @@ export default class MiniCrosswordGame extends Phaser.Scene {
|
|||
}).setOrigin(0.5).setDepth(DEPTH.ui));
|
||||
|
||||
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,
|
||||
align: 'center', lineSpacing: 8,
|
||||
}).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,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui));
|
||||
|
||||
[['Easy', 'easy'], ['Medium', 'medium'], ['Hard', 'hard']].forEach(([label, id], i) => {
|
||||
const b = new Button(this, cx - 270 + i * 270, cy + 110, label,
|
||||
[['Easy', '5×5', 'easy'], ['Medium', '6×6', 'medium'], ['Hard', '7×7', 'hard']].forEach(([label, size, id], i) => {
|
||||
const x = cx - 270 + i * 270;
|
||||
const b = new Button(this, x, cy + 110, label,
|
||||
() => this.startPuzzle(id),
|
||||
{ width: 230, height: 68, fontSize: 28, bgHover: GOLD });
|
||||
b.setDepth(DEPTH.ui);
|
||||
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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 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() {
|
||||
this.computeGeometry();
|
||||
this.buildClueBanner();
|
||||
this.buildGrid();
|
||||
this.buildCluePanel();
|
||||
|
|
@ -154,8 +176,8 @@ export default class MiniCrosswordGame extends Phaser.Scene {
|
|||
|
||||
cellCenter(r, c) {
|
||||
return {
|
||||
x: GRID_LEFT + c * CELL_SIZE + CELL_SIZE / 2,
|
||||
y: GRID_TOP + r * CELL_SIZE + CELL_SIZE / 2,
|
||||
x: this.gridLeft + c * this.cellSize + this.cellSize / 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 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);
|
||||
if (block) continue;
|
||||
|
||||
|
|
@ -173,13 +195,13 @@ export default class MiniCrosswordGame extends Phaser.Scene {
|
|||
rect.on('pointerdown', () => this.onCellClick(r, c));
|
||||
|
||||
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);
|
||||
|
||||
const num = this.numbers[`${r},${c}`];
|
||||
if (num) {
|
||||
this.add.text(x - CELL_SIZE / 2 + 12, y - CELL_SIZE / 2 + 8, String(num), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: INK_DARK,
|
||||
this.add.text(x - this.cellSize / 2 + 10, y - this.cellSize / 2 + 6, String(num), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: `${this.numberFont}px`, color: INK_DARK,
|
||||
}).setOrigin(0, 0).setDepth(DEPTH.cellTxt);
|
||||
}
|
||||
|
||||
|
|
@ -189,8 +211,8 @@ export default class MiniCrosswordGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
buildClueBanner() {
|
||||
const gridW = this.grid[0].length * CELL_SIZE;
|
||||
this.banner = this.add.text(GRID_LEFT + gridW / 2, GRID_TOP - 70, '', {
|
||||
const gridW = this.grid[0].length * this.cellSize;
|
||||
this.banner = this.add.text(this.gridLeft + gridW / 2, this.gridTop - 70, '', {
|
||||
fontFamily: 'Righteous', fontSize: '34px', color: TITLE_GOLD,
|
||||
align: 'center', wordWrap: { width: gridW + 40 },
|
||||
}).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 },
|
||||
];
|
||||
|
||||
// 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) {
|
||||
this.add.text(col.x, PY + 36, col.title, {
|
||||
fontFamily: 'Righteous', fontSize: '34px', color: TITLE_GOLD,
|
||||
}).setDepth(DEPTH.cellTxt);
|
||||
|
||||
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}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
|
||||
wordWrap: { width: 360 },
|
||||
|
|
@ -230,10 +256,10 @@ export default class MiniCrosswordGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
buildControls() {
|
||||
const y = GRID_TOP + this.grid.length * CELL_SIZE + 70;
|
||||
this.checkBtn = new Button(this, GRID_LEFT + 120, y, 'Check',
|
||||
const y = this.gridTop + this.grid.length * this.cellSize + 70;
|
||||
this.checkBtn = new Button(this, this.gridLeft + 120, y, 'Check',
|
||||
() => 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.checkBtn, this.revealBtn].forEach((b) => b.setDepth(DEPTH.ui));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (1–5 + 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 1–N · 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 */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)];
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ import MiniCrosswordGame from './games/minicrossword/MiniCrosswordGame.js';
|
|||
import ForbiddenIslandGame from './games/forbiddenisland/ForbiddenIslandGame.js';
|
||||
import SolitaireTourGame from './games/solitairetour/SolitaireTourGame.js';
|
||||
import SplendorGame from './games/splendor/SplendorGame.js';
|
||||
import TectonicGame from './games/tectonic/TectonicGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -117,6 +118,7 @@ const config = {
|
|||
ForbiddenIslandGame,
|
||||
SolitaireTourGame,
|
||||
SplendorGame,
|
||||
TectonicGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', 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]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,286 +2,209 @@
|
|||
{
|
||||
"id": "easy-001",
|
||||
"difficulty": "easy",
|
||||
"grid": ["APART", "POWER", "AWARE", "RERUN", "TREND"],
|
||||
"grid": ["#BLAH", "SLAVE", "CANAL", "AMEND", "REST#"],
|
||||
"across": [
|
||||
"Separated, as two people",
|
||||
"Electrical energy",
|
||||
"Conscious of; in the know",
|
||||
"TV episode shown again",
|
||||
"Current fashion or direction"
|
||||
"Dull and uninspiring",
|
||||
"One forced to work for no pay",
|
||||
"Panama waterway",
|
||||
"Change, as a law",
|
||||
"Take a break"
|
||||
],
|
||||
"down": [
|
||||
"Not together",
|
||||
"Strength or might",
|
||||
"Mindful",
|
||||
"Summer TV staple",
|
||||
"What goes viral, perhaps"
|
||||
"Point the finger at",
|
||||
"Bowling alley divisions",
|
||||
"___-garde (cutting-edge)",
|
||||
"Gripped tightly",
|
||||
"Mark left by a healed wound"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "easy-002",
|
||||
"id": "easy-008",
|
||||
"difficulty": "easy",
|
||||
"grid": ["SMART", "POLAR", "OLIVE", "RAVEN", "TREND"],
|
||||
"grid": ["WATCH", "AWARE", "SABER", "PILED", "STEPS"],
|
||||
"across": [
|
||||
"Clever",
|
||||
"Like a bear at the North Pole",
|
||||
"Green martini garnish",
|
||||
"Edgar Allan Poe's black bird",
|
||||
"Hot new fashion"
|
||||
"Wrist timepiece",
|
||||
"In the know",
|
||||
"Fencing sword",
|
||||
"Stacked up",
|
||||
"Stairs, individually"
|
||||
],
|
||||
"down": [
|
||||
"Baseball or soccer, e.g.",
|
||||
"Back tooth",
|
||||
"Living and breathing",
|
||||
"Glossy black bird",
|
||||
"Topic that's taking off online"
|
||||
"Stinging insects",
|
||||
"Wait for",
|
||||
"Dining room furniture",
|
||||
"Move slowly and stealthily",
|
||||
"Cattle groups"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "easy-003",
|
||||
"id": "easy-009",
|
||||
"difficulty": "easy",
|
||||
"grid": ["START", "THROW", "ARGUE", "ROUTE", "TWEED"],
|
||||
"grid": ["#TONE", "PERIL", "URGES", "BRACE", "SANE#"],
|
||||
"across": [
|
||||
"Begin",
|
||||
"Toss a ball",
|
||||
"Quarrel",
|
||||
"Path a bus takes",
|
||||
"Rough wool fabric for jackets"
|
||||
"Muscle firmness, or vocal quality",
|
||||
"Grave danger",
|
||||
"Strongly encourages",
|
||||
"Support for a sprained joint",
|
||||
"Of sound mind"
|
||||
],
|
||||
"down": [
|
||||
"A race's beginning",
|
||||
"Pitch, as a baseball",
|
||||
"Bicker",
|
||||
"Mail carrier's territory",
|
||||
"Tweedy jacket cloth"
|
||||
"___ firma (solid ground)",
|
||||
"Church keyboard instrument",
|
||||
"Your sibling's daughter",
|
||||
"Or ___ (otherwise)",
|
||||
"Spots for a pint"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "easy-004",
|
||||
"id": "easy-018",
|
||||
"difficulty": "easy",
|
||||
"grid": ["CLOSE", "LLAMA", "OASIS", "SMILE", "EASEL"],
|
||||
"grid": ["#FIRE", "COCOA", "ALIAS", "LINDY", "LOGS#"],
|
||||
"across": [
|
||||
"Shut, as a door",
|
||||
"Andean pack animal",
|
||||
"Green spot in the desert",
|
||||
"Happy expression",
|
||||
"Painter's stand"
|
||||
"Campsite blaze",
|
||||
"Hot ___ (cozy winter drink)",
|
||||
"Assumed name",
|
||||
"___ Hop (jazz-era swing dance)",
|
||||
"Firewood pieces"
|
||||
],
|
||||
"down": [
|
||||
"Nearby",
|
||||
"Spitting Andean beast",
|
||||
"Desert refuge",
|
||||
"What a camera asks you to do",
|
||||
"Stand for a canvas"
|
||||
"Large book-page size",
|
||||
"Sugary cake topping",
|
||||
"Routes for cars",
|
||||
"Not difficult",
|
||||
"Ring up on the phone"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "easy-005",
|
||||
"id": "easy-020",
|
||||
"difficulty": "easy",
|
||||
"grid": ["MAPLE", "AGAIN", "PAINT", "LINER", "ENTRY"],
|
||||
"grid": ["#PILE", "GENOA", "RADAR", "ACIDS", "BEES#"],
|
||||
"across": [
|
||||
"Syrup-producing tree",
|
||||
"Once more",
|
||||
"What an artist applies",
|
||||
"Big cruise ship",
|
||||
"Doorway, or a diary post"
|
||||
"Big heap",
|
||||
"Italian port city",
|
||||
"Speed-gun technology",
|
||||
"Bases' chemical opposites",
|
||||
"Hive dwellers"
|
||||
],
|
||||
"down": [
|
||||
"Tree on Canada's flag",
|
||||
"Time and time ___",
|
||||
"Wall color in a can",
|
||||
"Ocean-crossing vessel",
|
||||
"Way in"
|
||||
"Opposite of war",
|
||||
"Like non-mainstream music",
|
||||
"Tons; a whole lot",
|
||||
"Corn units, or hearing organs",
|
||||
"Snatch quickly"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "medium-001",
|
||||
"id": "medium-020",
|
||||
"difficulty": "medium",
|
||||
"grid": ["DROVE", "RAVEN", "OVERT", "VERVE", "ENTER"],
|
||||
"grid": ["#BLEW#", "PRISON", "RATTLE", "OCTAVE", "MELTED", "#SEES#"],
|
||||
"across": [
|
||||
"Operated the car; also, a herd",
|
||||
"Bird that quoth 'Nevermore'",
|
||||
"Out in the open, not hidden",
|
||||
"Energy and enthusiasm",
|
||||
"Key pressed to confirm"
|
||||
"Past tense of \"blow\"",
|
||||
"Place for inmates",
|
||||
"Baby's shaking toy",
|
||||
"Span of eight musical notes",
|
||||
"Turned to liquid, as ice",
|
||||
"Lays eyes on"
|
||||
],
|
||||
"down": [
|
||||
"Chauffeured",
|
||||
"Corvid in a Poe poem",
|
||||
"Undisguised",
|
||||
"Pep and flair",
|
||||
"Go in"
|
||||
"Teeth straighteners",
|
||||
"Not much; small",
|
||||
"Large property with a mansion",
|
||||
"Pack animals that howl",
|
||||
"High school spring dance",
|
||||
"Require"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "medium-002",
|
||||
"id": "medium-008",
|
||||
"difficulty": "medium",
|
||||
"grid": ["GRASP", "RUMOR", "AMPLE", "SOLVE", "PREEN"],
|
||||
"grid": ["#SEAL#", "MIRROR", "ARRIVE", "LEASED", "ENTERS", "#SANS#"],
|
||||
"across": [
|
||||
"Grip firmly; comprehend",
|
||||
"Unverified bit of gossip",
|
||||
"More than enough",
|
||||
"Crack, as a puzzle",
|
||||
"Groom feathers, as a bird does"
|
||||
"Whiskered arctic swimmer",
|
||||
"Bathroom reflector",
|
||||
"Reach a destination",
|
||||
"Rented out",
|
||||
"Goes inside",
|
||||
"Without, in French"
|
||||
],
|
||||
"down": [
|
||||
"Get a handle on",
|
||||
"Word on the street",
|
||||
"Plentiful",
|
||||
"Figure out",
|
||||
"Primp"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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"
|
||||
"Ambulance wailers",
|
||||
"List of printed errors",
|
||||
"Gotten up out of bed",
|
||||
"Romantic partners",
|
||||
"Not female",
|
||||
"Crimson shades"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "medium-004",
|
||||
"difficulty": "medium",
|
||||
"grid": ["CRANE", "RESIN", "ASSET", "NIECE", "ENTER"],
|
||||
"grid": ["#WEST#", "MISHAP", "ATTIRE", "CHARGE", "SETTER", "#REST#"],
|
||||
"across": [
|
||||
"Construction lifting machine; or a tall bird",
|
||||
"Sticky secretion that becomes amber",
|
||||
"Valuable item on a balance sheet",
|
||||
"Your sibling's daughter",
|
||||
"Type in, as data"
|
||||
"Sunset direction",
|
||||
"Minor accident",
|
||||
"Clothing; an outfit",
|
||||
"Put on a credit card",
|
||||
"Irish ___ (long-haired dog)",
|
||||
"The remainder"
|
||||
],
|
||||
"down": [
|
||||
"Stretch the neck to see",
|
||||
"Pine-tree ooze",
|
||||
"A plus on the books",
|
||||
"Nephew's sister",
|
||||
"Walk into a room"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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"
|
||||
"Shrivel and dry up",
|
||||
"Inherited property",
|
||||
"Closet hang-ups",
|
||||
"Bullseye holder",
|
||||
"Apple computers",
|
||||
"Look closely"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hard-002",
|
||||
"difficulty": "hard",
|
||||
"grid": ["GRUFF", "RIVER", "UVULA", "FELON", "FRANK"],
|
||||
"grid": ["##TWO##", "#POEMS#", "TIDBITS", "AND#TEA", "BELATED", "#SEVER#", "##RED##"],
|
||||
"across": [
|
||||
"Brusque and surly",
|
||||
"The Mississippi, for one",
|
||||
"Dangly bit at the back of the throat",
|
||||
"Convicted criminal",
|
||||
"Candidly blunt; or a hot dog"
|
||||
"Number after one",
|
||||
"Verses by Emily Dickinson",
|
||||
"Small, tasty morsels",
|
||||
"Plus",
|
||||
"Afternoon British drink",
|
||||
"Late, as a birthday card",
|
||||
"Cut off completely",
|
||||
"Stop sign's color"
|
||||
],
|
||||
"down": [
|
||||
"Hoarse and curt",
|
||||
"Flowing waterway",
|
||||
"Throat's little hanging punching bag",
|
||||
"One with a rap sheet",
|
||||
"Straight-talking; ballpark sausage"
|
||||
"Child just learning to walk",
|
||||
"Spider's silken trap",
|
||||
"Left out",
|
||||
"Evergreen trees",
|
||||
"Guide a car",
|
||||
"Bar bill",
|
||||
"Feeling blue",
|
||||
"Street: Abbr."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hard-003",
|
||||
"id": "hard-014",
|
||||
"difficulty": "hard",
|
||||
"grid": ["PLUMB", "LUNAR", "UNTIE", "MAIZE", "BREED"],
|
||||
"grid": ["##PIN##", "#PACED#", "FORESEE", "ART#TAG", "TEASING", "#SKINS#", "##ENG##"],
|
||||
"across": [
|
||||
"Measure the depth of; dead vertical",
|
||||
"Of the moon",
|
||||
"Loosen, as shoelaces",
|
||||
"Corn, by another name",
|
||||
"Raise animals; a dog variety"
|
||||
"Bowling target",
|
||||
"Walked back and forth nervously",
|
||||
"Predict in advance",
|
||||
"Museum display",
|
||||
"Playground chasing game",
|
||||
"Good-natured ribbing",
|
||||
"Potato jackets",
|
||||
"English: Abbr."
|
||||
],
|
||||
"down": [
|
||||
"Perfectly upright",
|
||||
"___ eclipse",
|
||||
"Undo a knot",
|
||||
"Native American staple grain",
|
||||
"Labrador or poodle, e.g."
|
||||
]
|
||||
},
|
||||
{
|
||||
"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"
|
||||
"Join in; participate",
|
||||
"Frozen water",
|
||||
"What birds do in spring",
|
||||
"Tiny skin openings",
|
||||
"College administrators",
|
||||
"Not lean",
|
||||
"Breakfast protein source",
|
||||
"Commandment breaker"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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: '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: '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: '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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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.
|
||||
//
|
||||
// A puzzle is authored as { id, difficulty, grid:[5 row strings], across:[5],
|
||||
// down:[5] }. Grids are fixed 5x5; a '#' marks a black square. Across/Down clue
|
||||
// arrays are ordered by row index / column index respectively. This engine
|
||||
// derives the standard crossword numbering and pairs each clue with its slot.
|
||||
// A puzzle is authored as { id, difficulty, grid:[row strings], across:[...],
|
||||
// down:[...] }. Difficulty sets the grid size: easy 5x5, medium 6x6, hard 7x7.
|
||||
// A '#' marks a black square. Across/Down clue arrays are ordered by the derived
|
||||
// crossword numbering (reading order). This engine derives that numbering and
|
||||
// pairs each clue with its slot.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
|
@ -13,18 +14,19 @@ import { fileURLToPath } from 'node:url';
|
|||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PUZZLE_PATH = path.join(__dirname, '../data/crosswords/minicrossword.json');
|
||||
|
||||
const SIZE = 5;
|
||||
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 allPuzzles = [];
|
||||
|
||||
// ── 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) {
|
||||
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.
|
||||
|
|
@ -36,8 +38,8 @@ function deriveSlots(grid) {
|
|||
const down = [];
|
||||
let number = 0;
|
||||
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
for (let r = 0; r < grid.length; r++) {
|
||||
for (let c = 0; c < grid[r].length; c++) {
|
||||
if (!isCell(grid, r, c)) continue;
|
||||
|
||||
const startsAcross = !isCell(grid, r, c - 1) && isCell(grid, r, c + 1);
|
||||
|
|
@ -66,12 +68,17 @@ function deriveSlots(grid) {
|
|||
// ── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
function validatePuzzle(p) {
|
||||
if (!Array.isArray(p.grid) || p.grid.length !== SIZE) {
|
||||
throw new Error(`puzzle ${p.id}: grid must have ${SIZE} rows`);
|
||||
const size = TIER_SIZE[p.difficulty];
|
||||
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) {
|
||||
if (typeof row !== 'string' || row.length !== SIZE || !/^[A-Z#]{5}$/.test(row)) {
|
||||
throw new Error(`puzzle ${p.id}: each row must be ${SIZE} chars of A-Z or '#'`);
|
||||
if (typeof row !== 'string' || !rowRe.test(row)) {
|
||||
throw new Error(`puzzle ${p.id}: each row must be ${size} chars of A-Z or '#'`);
|
||||
}
|
||||
}
|
||||
const { across, down } = deriveSlots(p.grid);
|
||||
|
|
@ -101,8 +108,7 @@ export function initMiniCrosswordPuzzles() {
|
|||
const bank = JSON.parse(raw);
|
||||
for (const p of bank) {
|
||||
validatePuzzle(p);
|
||||
const diff = DIFFICULTIES.includes(p.difficulty) ? p.difficulty : 'medium';
|
||||
byDifficulty[diff].push(p);
|
||||
byDifficulty[p.difficulty].push(p);
|
||||
allPuzzles.push(p);
|
||||
}
|
||||
return { puzzles: allPuzzles.length };
|
||||
|
|
@ -112,10 +118,10 @@ export function initMiniCrosswordPuzzles() {
|
|||
|
||||
// Returns a random puzzle for the requested difficulty, packaged with derived
|
||||
// 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;
|
||||
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)];
|
||||
|
|
@ -124,8 +130,8 @@ export function getPuzzle(difficulty = 'medium') {
|
|||
return {
|
||||
id: p.id,
|
||||
difficulty: p.difficulty,
|
||||
rows: SIZE,
|
||||
cols: SIZE,
|
||||
rows: p.grid.length,
|
||||
cols: p.grid[0].length,
|
||||
grid: p.grid,
|
||||
across: across.map((slot, i) => ({ ...slot, clue: p.across[i] })),
|
||||
down: down.map((slot, i) => ({ ...slot, clue: p.down[i] })),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,317 @@
|
|||
// Tectonic (a.k.a. Suguru) generator.
|
||||
//
|
||||
// Rules:
|
||||
// • The grid is partitioned into irregular regions ("cages") of 1–5 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 1–5-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 };
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
listThemes as wordSearchThemes,
|
||||
} from './wordSearchEngine.js';
|
||||
import { generatePuzzle as sudokuGenerate } from './sudokuEngine.js';
|
||||
import { generatePuzzle as tectonicGenerate } from './tectonicEngine.js';
|
||||
import { initBoggleDictionary, rollBoard, solveBoard } from './boggleEngine.js';
|
||||
import { initSpellingBeeDictionary, generatePuzzle as spellingBeeGenerate } from './spellingBeeEngine.js';
|
||||
import { initMiniCrosswordPuzzles, getPuzzle as miniCrosswordGet } from './miniCrosswordEngine.js';
|
||||
|
|
@ -156,7 +157,7 @@ function loadWordLists() {
|
|||
const beeStats = initSpellingBeeDictionary(allWords);
|
||||
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();
|
||||
console.log(`[words] loaded ${crosswordStats.puzzles} Mini Crossword puzzles`);
|
||||
|
||||
|
|
@ -216,10 +217,11 @@ router.get('/spellingbee/start', (req, res) => {
|
|||
// ── Mini Crossword ────────────────────────────────────────────────────────────
|
||||
|
||||
// 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) => {
|
||||
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));
|
||||
});
|
||||
|
||||
|
|
@ -425,4 +427,11 @@ router.get('/sudoku/start', (req, res) => {
|
|||
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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue