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:
Guido
2026-08-04 17:29:25 +02:00
commit 1a9710ec88
42 changed files with 2555 additions and 0 deletions

137
server/sceneScanner.js Normal file
View 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());
}