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

101
server/index.js Normal file
View File

@@ -0,0 +1,101 @@
// fuesse.sexy — "Where's the Feet"
// Express-Server: liefert das Frontend aus, scannt die Szenen-Ordner beim Start,
// generiert auf Anfrage eine zufaellige Szene und prueft Klick-Treffer serverseitig.
import express from 'express';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync } from 'node:fs';
import { scanLibrary, scanScenes } from './sceneScanner.js';
import { generateScene } from './sceneGenerator.js';
import { getScene } from './sceneStore.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const SCENES_DIR = join(ROOT, 'scenes');
const LIBRARY_DIR = join(ROOT, 'library');
const PUBLIC_DIR = join(ROOT, 'public');
const PANZOOM_DIST = join(ROOT, 'node_modules', 'panzoom', 'dist');
const PORT = process.env.PORT || 3000;
// Wie oft die normale Library-Szene im Vergleich zu einem Spezial-Level drankommt.
const LIBRARY_WEIGHT = 3;
// Beim Start: Library (Normalfall) + Spezial-Level (Ausnahmen) einlesen und zu einem
// gewichteten Zufalls-Pool zusammenbauen.
const library = scanLibrary(LIBRARY_DIR);
const specials = scanScenes(SCENES_DIR);
const scenePool = [];
if (library) {
const libCategory = {
id: 'library',
name: '',
type: 'clipart',
isLibrary: true,
background: null, // pro Szene zufaellig
minSize: 68,
maxSize: 128,
cellFactor: 0.82,
props: library.props,
targets: library.feet,
};
for (let i = 0; i < LIBRARY_WEIGHT; i++) scenePool.push(libCategory);
}
for (const s of specials) scenePool.push(s);
if (scenePool.length === 0) {
console.warn('[server] Keine spielbaren Szenen — fuelle library/ oder scenes/.');
} else {
console.log(
`[server] bereit: Library=${library ? 'ja' : 'nein'}, Spezial-Level: ${specials.map((c) => c.id).join(', ') || '(keine)'}`
);
}
const app = express();
app.use(express.json());
// Statisches Frontend + Assets
app.use(express.static(PUBLIC_DIR));
app.use('/scenes', express.static(SCENES_DIR));
app.use('/library', express.static(LIBRARY_DIR));
if (existsSync(PANZOOM_DIST)) {
app.use('/vendor', express.static(PANZOOM_DIST));
}
// Eine zufaellige, frisch gemischte Szene liefern (meist Library, selten Spezial-Level).
app.get('/api/scene', (req, res) => {
if (scenePool.length === 0) {
return res.status(503).json({ error: 'Noch keine Szenen vorhanden.' });
}
const cat = scenePool[Math.floor(Math.random() * scenePool.length)];
const payload = generateScene(cat);
res.json(payload);
});
// Klick pruefen: liegt (x, y) in einer der Ziel-Trefferzonen dieser Szene?
app.post('/api/scene/:sceneId/guess', (req, res) => {
const entry = getScene(req.params.sceneId);
if (!entry) {
return res.status(404).json({ error: 'Szene unbekannt oder abgelaufen.' });
}
const { x, y } = req.body || {};
if (typeof x !== 'number' || typeof y !== 'number') {
return res.status(400).json({ error: 'x und y muessen Zahlen sein.' });
}
let hit = null;
for (const t of entry.targets) {
if (Math.hypot(t.cx - x, t.cy - y) <= t.radius) {
hit = t.id;
break;
}
}
res.json({ hit });
});
app.listen(PORT, () => {
console.log(`[server] fuesse.sexy laeuft auf http://localhost:${PORT}`);
});