Wimmel-Suchspiel: finde 3 versteckte Fuesse in einer randvollen Szene. - Node/Express-Server generiert Szenen prozedural aus einer gemeinsamen Library (library/props = Deko, library/feet = Fuesse); Spezial-Level (emoji) unter scenes/ - Serverseitige Trefferpruefung (nicht per DOM austrickbar) - Frontend rendert die Szene als ein Bild -> fluessiges Zoom/Pan (panzoom) - Dark Mode, kein Start-Screen, Installationsskript + Deploy-Doku (Proxmox LXC) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
138 lines
4.3 KiB
JavaScript
138 lines
4.3 KiB
JavaScript
// Liest den scenes/-Ordner ein und stellt die Kategorie-Metadaten bereit.
|
|
// Eine "Kategorie" = ein Unterordner von scenes/. Der Scan passiert einmal beim
|
|
// Serverstart; ein neuer Ordner wird also erst nach einem Neustart sichtbar.
|
|
|
|
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const IMAGE_EXTENSIONS = ['.svg', '.png', '.webp', '.jpg', '.jpeg', '.gif'];
|
|
|
|
function isImage(filename) {
|
|
const lower = filename.toLowerCase();
|
|
return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
|
}
|
|
|
|
function listImages(dir) {
|
|
if (!existsSync(dir)) return [];
|
|
return readdirSync(dir)
|
|
.filter((name) => isImage(name))
|
|
.sort();
|
|
}
|
|
|
|
function readConfig(categoryDir) {
|
|
const configPath = join(categoryDir, 'config.json');
|
|
if (!existsSync(configPath)) return {};
|
|
try {
|
|
return JSON.parse(readFileSync(configPath, 'utf8'));
|
|
} catch (err) {
|
|
console.warn(`[scanner] config.json in ${categoryDir} ist ungueltig:`, err.message);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
// Liest die gemeinsame Library (Fundgrube): library/props/ (Deko) + library/feet/
|
|
// (alle Fuesse). Daraus generiert der Server normale, nicht-thematische Szenen.
|
|
export function scanLibrary(libraryRoot) {
|
|
const propsDir = join(libraryRoot, 'props');
|
|
const feetDir = join(libraryRoot, 'feet');
|
|
|
|
const props = listImages(propsDir).map((f) => `/library/props/${f}`);
|
|
const feet = listImages(feetDir).map((f) => ({
|
|
id: f.replace(/\.[^.]+$/, ''),
|
|
name: prettyName(f),
|
|
url: `/library/feet/${f}`,
|
|
}));
|
|
|
|
if (feet.length < 3) {
|
|
console.warn(`[scanner] Library hat < 3 Fuesse (${feet.length}) — normale Szenen deaktiviert.`);
|
|
return null;
|
|
}
|
|
if (props.length < 1) {
|
|
console.warn('[scanner] Library hat keine Deko in props/ — normale Szenen deaktiviert.');
|
|
return null;
|
|
}
|
|
return { props, feet };
|
|
}
|
|
|
|
// Baut die Liste aller SPEZIAL-Level (Unterordner von scenes/). Ungueltige Ordner
|
|
// werden mit einer Warnung uebersprungen, statt den Start zu blockieren.
|
|
export function scanScenes(scenesRoot) {
|
|
if (!existsSync(scenesRoot)) {
|
|
console.warn(`[scanner] scenes/-Ordner fehlt: ${scenesRoot}`);
|
|
return [];
|
|
}
|
|
|
|
const categories = [];
|
|
|
|
for (const entry of readdirSync(scenesRoot)) {
|
|
const categoryDir = join(scenesRoot, entry);
|
|
if (!statSync(categoryDir).isDirectory()) continue;
|
|
|
|
const config = readConfig(categoryDir);
|
|
const type = config.type || 'clipart';
|
|
const name = config.name || entry;
|
|
|
|
if (type === 'emoji') {
|
|
const targets = Array.isArray(config.targets) ? config.targets : [];
|
|
const props = Array.isArray(config.props) ? config.props : [];
|
|
if (targets.length < 1 || props.length < 3) {
|
|
console.warn(`[scanner] "${entry}" (emoji) uebersprungen: braucht targets + genug props in config.json`);
|
|
continue;
|
|
}
|
|
categories.push({
|
|
id: entry,
|
|
name,
|
|
type,
|
|
background: config.background || '#f4f4f8',
|
|
propCount: config.propCount || 250,
|
|
minSize: config.minSize || 42,
|
|
maxSize: config.maxSize || 78,
|
|
targetGlyphs: targets,
|
|
propGlyphs: props,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// clipart / photo: Bilddateien aus props/ und targets/
|
|
const propsDir = join(categoryDir, 'props');
|
|
const targetsDir = join(categoryDir, 'targets');
|
|
const props = listImages(propsDir).map((f) => `/scenes/${entry}/props/${f}`);
|
|
const targets = listImages(targetsDir).map((f) => ({
|
|
id: f.replace(/\.[^.]+$/, ''),
|
|
name: prettyName(f),
|
|
url: `/scenes/${entry}/targets/${f}`,
|
|
}));
|
|
|
|
if (targets.length < 3) {
|
|
console.warn(`[scanner] "${entry}" uebersprungen: braucht mind. 3 Bilder in targets/ (gefunden: ${targets.length})`);
|
|
continue;
|
|
}
|
|
if (props.length < 1) {
|
|
console.warn(`[scanner] "${entry}" uebersprungen: keine Bilder in props/`);
|
|
continue;
|
|
}
|
|
|
|
categories.push({
|
|
id: entry,
|
|
name,
|
|
type,
|
|
background: config.background || '#f4f4f8',
|
|
propCount: config.propCount || 60,
|
|
minSize: config.minSize || 70,
|
|
maxSize: config.maxSize || 130,
|
|
props,
|
|
targets,
|
|
});
|
|
}
|
|
|
|
return categories;
|
|
}
|
|
|
|
// "ballerina-fuss.svg" -> "Ballerina Fuss"
|
|
function prettyName(filename) {
|
|
return filename
|
|
.replace(/\.[^.]+$/, '')
|
|
.replace(/[-_]+/g, ' ')
|
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
}
|