180 lines
5.7 KiB
JavaScript
180 lines
5.7 KiB
JavaScript
// Server-side Word Search puzzle generator.
|
||
//
|
||
// A puzzle is a square letter grid with a set of theme words hidden inside it,
|
||
// placed along straight lines in one of 8 directions. Difficulty controls grid
|
||
// size, how many words are hidden, and which directions are allowed (harder
|
||
// puzzles add diagonals and backwards placement).
|
||
//
|
||
// Self-contained: theme word lists live here, no dependency on the ENABLE list.
|
||
|
||
const A = 'A'.charCodeAt(0);
|
||
|
||
// [rowDelta, colDelta] for each direction.
|
||
const DIR = {
|
||
E: [0, 1],
|
||
W: [0, -1],
|
||
S: [1, 0],
|
||
N: [-1, 0],
|
||
SE: [1, 1],
|
||
SW: [1, -1],
|
||
NE: [-1, 1],
|
||
NW: [-1, -1],
|
||
};
|
||
|
||
const DIFFICULTY = {
|
||
easy: { id: 'easy', size: 10, count: 8, dirs: [DIR.E, DIR.S] },
|
||
medium: { id: 'medium', size: 12, count: 10, dirs: [DIR.E, DIR.S, DIR.SE, DIR.NE] },
|
||
hard: { id: 'hard', size: 14, count: 12, dirs: [DIR.E, DIR.S, DIR.SE, DIR.NE, DIR.W, DIR.N, DIR.SW, DIR.NW] },
|
||
};
|
||
|
||
// Curated themes. Each entry: display label + uppercase A–Z words (3–9 letters).
|
||
const THEMES = {
|
||
space: {
|
||
label: 'Space',
|
||
words: ['COMET', 'PLANET', 'ROCKET', 'GALAXY', 'ORBIT', 'NEBULA', 'METEOR',
|
||
'SOLAR', 'COSMOS', 'SATURN', 'VENUS', 'PLUTO', 'GRAVITY', 'ASTEROID',
|
||
'MARS', 'MOON'],
|
||
},
|
||
animals: {
|
||
label: 'Animals',
|
||
words: ['TIGER', 'ELEPHANT', 'GIRAFFE', 'MONKEY', 'ZEBRA', 'KANGAROO',
|
||
'PENGUIN', 'DOLPHIN', 'LEOPARD', 'PANDA', 'RABBIT', 'FALCON',
|
||
'OTTER', 'BEAVER', 'COBRA', 'WALRUS'],
|
||
},
|
||
food: {
|
||
label: 'Food',
|
||
words: ['PIZZA', 'BURGER', 'PASTA', 'SALAD', 'CHEESE', 'TOMATO', 'BANANA',
|
||
'COOKIE', 'WAFFLE', 'PRETZEL', 'MUFFIN', 'NOODLE', 'PEPPER',
|
||
'CARROT', 'YOGURT', 'PANCAKE'],
|
||
},
|
||
ocean: {
|
||
label: 'Ocean',
|
||
words: ['CORAL', 'SHARK', 'WHALE', 'OCTOPUS', 'DOLPHIN', 'LOBSTER',
|
||
'STARFISH', 'SEAWEED', 'JELLYFISH', 'URCHIN', 'OYSTER', 'MARLIN',
|
||
'SHRIMP', 'ANCHOR', 'LAGOON', 'CURRENT'],
|
||
},
|
||
sports: {
|
||
label: 'Sports',
|
||
words: ['SOCCER', 'TENNIS', 'HOCKEY', 'BOXING', 'RUGBY', 'SKIING',
|
||
'CYCLING', 'ARCHERY', 'BOWLING', 'CRICKET', 'DIVING', 'FENCING',
|
||
'KARATE', 'ROWING', 'SAILING', 'SURFING'],
|
||
},
|
||
music: {
|
||
label: 'Music',
|
||
words: ['GUITAR', 'PIANO', 'VIOLIN', 'DRUMS', 'TRUMPET', 'FLUTE', 'CELLO',
|
||
'HARP', 'BANJO', 'MELODY', 'RHYTHM', 'TEMPO', 'CHORD', 'OCTAVE',
|
||
'SONATA', 'BALLAD'],
|
||
},
|
||
};
|
||
|
||
function randomFrom(arr) {
|
||
return arr[Math.floor(Math.random() * arr.length)];
|
||
}
|
||
|
||
function shuffle(arr) {
|
||
const a = [...arr];
|
||
for (let i = a.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[a[i], a[j]] = [a[j], a[i]];
|
||
}
|
||
return a;
|
||
}
|
||
|
||
function resolveDifficulty(id) {
|
||
return DIFFICULTY[String(id).toLowerCase()] ?? DIFFICULTY.medium;
|
||
}
|
||
|
||
function resolveTheme(id) {
|
||
const key = String(id).toLowerCase();
|
||
if (key === 'random' || !THEMES[key]) {
|
||
const keys = Object.keys(THEMES);
|
||
const pick = randomFrom(keys);
|
||
return { key: pick, ...THEMES[pick] };
|
||
}
|
||
return { key, ...THEMES[key] };
|
||
}
|
||
|
||
// Try to place `word` into `grid` along one of `dirs`. Accepts a placement when
|
||
// every target cell is empty or already holds the matching letter (crossings).
|
||
// Returns { row, col, dir } on success, or null after exhausting attempts.
|
||
function tryPlace(grid, word, dirs, size, attempts = 150) {
|
||
for (let a = 0; a < attempts; a++) {
|
||
const [dr, dc] = randomFrom(dirs);
|
||
const len = word.length;
|
||
|
||
// Valid start range so the whole word stays in bounds.
|
||
const rowMin = dr < 0 ? (len - 1) : 0;
|
||
const rowMax = dr > 0 ? (size - len) : (size - 1);
|
||
const colMin = dc < 0 ? (len - 1) : 0;
|
||
const colMax = dc > 0 ? (size - len) : (size - 1);
|
||
if (rowMin > rowMax || colMin > colMax) continue;
|
||
|
||
const row = rowMin + Math.floor(Math.random() * (rowMax - rowMin + 1));
|
||
const col = colMin + Math.floor(Math.random() * (colMax - colMin + 1));
|
||
|
||
let ok = true;
|
||
for (let i = 0; i < len; i++) {
|
||
const r = row + dr * i;
|
||
const c = col + dc * i;
|
||
const cur = grid[r][c];
|
||
if (cur !== '' && cur !== word[i]) { ok = false; break; }
|
||
}
|
||
if (!ok) continue;
|
||
|
||
for (let i = 0; i < len; i++) {
|
||
grid[row + dr * i][col + dc * i] = word[i];
|
||
}
|
||
return { row, col, dir: [dr, dc] };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Build a single puzzle. Returns the grid as an array of row strings plus the
|
||
// list of words actually placed and their placements.
|
||
export function generatePuzzle({ difficulty, theme } = {}) {
|
||
const cfg = resolveDifficulty(difficulty);
|
||
const themeInfo = resolveTheme(theme ?? 'random');
|
||
const { size, count, dirs } = cfg;
|
||
|
||
// Candidate words that fit the grid, longest first so the hard ones place
|
||
// before the grid fills up.
|
||
const candidates = shuffle(themeInfo.words.filter(w => w.length <= size))
|
||
.sort((x, y) => y.length - x.length);
|
||
|
||
const grid = Array.from({ length: size }, () => Array(size).fill(''));
|
||
const placedWords = [];
|
||
const placements = [];
|
||
|
||
for (const word of candidates) {
|
||
if (placedWords.length >= count) break;
|
||
const placed = tryPlace(grid, word, dirs, size);
|
||
if (placed) {
|
||
placedWords.push(word);
|
||
placements.push({ word, row: placed.row, col: placed.col, dir: placed.dir });
|
||
}
|
||
}
|
||
|
||
// Fill the remaining empties with random letters.
|
||
for (let r = 0; r < size; r++) {
|
||
for (let c = 0; c < size; c++) {
|
||
if (grid[r][c] === '') {
|
||
grid[r][c] = String.fromCharCode(A + Math.floor(Math.random() * 26));
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
difficulty: cfg.id,
|
||
theme: themeInfo.key,
|
||
themeLabel: themeInfo.label,
|
||
size,
|
||
words: placedWords,
|
||
placements,
|
||
grid: grid.map(row => row.join('')),
|
||
};
|
||
}
|
||
|
||
export function listThemes() {
|
||
return Object.entries(THEMES).map(([id, t]) => ({ id, label: t.label }));
|
||
}
|