fertig-classic-games/server/words/wordSearchEngine.js

180 lines
5.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 AZ words (39 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 }));
}