Initial commit: fuesse.sexy — Where's the Feet
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>
This commit is contained in:
137
server/sceneScanner.js
Normal file
137
server/sceneScanner.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// 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());
|
||||
}
|
||||
Reference in New Issue
Block a user