Umbau zu "Wanted!"-Minigame

- Neues Spielprinzip: Fahndungsplakat + Feld voller Fuesse/Deko, gesuchten Fuss
  antippen bevor Timer ablaeuft; sofort neue Runde mit anderer Farbe.
- Progression: Raster -> wild verteilt -> Bewegung (2-3 Gruppen -> einzeln),
  Anzahl + Tempo steigen mit Score.
- Farben: 8 klar unterscheidbare Fuss-Farben (kein Braun/Hautton-Doppel) ->
  die gesuchte Farbe kommt garantiert nur EINMAL vor.
- Serverseitige Rangliste (data/leaderboard.json), Top 10 auf der Startseite,
  Name einmalig abgefragt + in localStorage gecacht.
- Reset per Befehl: npm run reset-stats
- Server stark vereinfacht (/api/feet, /api/leaderboard, /api/score); Pan/Zoom-,
  Wimmelbild- und Szenen-Generator-Code entfernt.
- WANTED-Plakat groesser, Timer schlanker, Startseite sauber zentriert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-04 21:57:13 +02:00
parent adc0fae93f
commit 5a6a1f363b
28 changed files with 795 additions and 1347 deletions

View File

@@ -1,107 +1,102 @@
// 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.
// fuesse.sexy — "Wanted!" (Fuss-Fahndung)
// Schlanker Express-Server: liefert das Frontend + die Bildchen aus, stellt die Liste
// der verfuegbaren Fuesse/Deko bereit und verwaltet eine persistente Rangliste.
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';
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
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 LIBRARY_DIR = join(ROOT, 'library');
const DATA_DIR = join(ROOT, 'data');
const LEADERBOARD_FILE = join(DATA_DIR, 'leaderboard.json');
const PORT = process.env.PORT || 3000;
const IMAGE_EXT = ['.svg', '.png', '.webp', '.jpg', '.jpeg', '.gif'];
// 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);
// ---- Bildchen aus der Library einlesen ----
function isImage(f) {
return IMAGE_EXT.some((ext) => f.toLowerCase().endsWith(ext));
}
function prettyName(file) {
return file
.replace(/\.[^.]+$/, '')
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function listImages(dir, urlPrefix) {
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter(isImage)
.sort()
.map((f) => ({ id: f.replace(/\.[^.]+$/, ''), name: prettyName(f), url: `${urlPrefix}/${f}` }));
}
for (const s of specials) scenePool.push(s);
if (scenePool.length === 0) {
console.warn('[server] Keine spielbaren Szenen — fuelle library/ oder scenes/.');
const feet = listImages(join(LIBRARY_DIR, 'feet'), '/library/feet');
const props = listImages(join(LIBRARY_DIR, 'props'), '/library/props');
if (feet.length < 2) {
console.warn(`[server] Achtung: nur ${feet.length} Fuss/Fuesse in library/feet — brauche mind. 2.`);
} else {
console.log(
`[server] bereit: Library=${library ? 'ja' : 'nein'}, Spezial-Level: ${specials.map((c) => c.id).join(', ') || '(keine)'}`
);
console.log(`[server] ${feet.length} Fuesse, ${props.length} Deko-Bildchen geladen.`);
}
// ---- Rangliste (persistente JSON-Datei) ----
function readLeaderboard() {
try {
if (!existsSync(LEADERBOARD_FILE)) return [];
const data = JSON.parse(readFileSync(LEADERBOARD_FILE, 'utf8'));
return Array.isArray(data) ? data : [];
} catch (err) {
console.warn('[server] Rangliste unlesbar:', err.message);
return [];
}
}
function writeLeaderboard(list) {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
writeFileSync(LEADERBOARD_FILE, JSON.stringify(list, null, 2));
}
function topN(list, n) {
return [...list].sort((a, b) => b.score - a.score).slice(0, n);
}
const app = express();
app.use(express.json());
// Statisches Frontend + Assets.
// no-cache fuers Frontend (HTML/JS/CSS): Browser/Proxy muss revalidieren -> nie altes
// Skript nach einem Update (haeufige Ursache fuer "geht gar nix" nach git pull).
app.use(
express.static(PUBLIC_DIR, {
setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache'),
})
);
app.use('/scenes', express.static(SCENES_DIR));
// Statisches Frontend + Bildchen (no-cache -> nie veraltetes JS nach Update)
app.use(express.static(PUBLIC_DIR, { setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache') }));
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);
// Verfuegbare Bildchen
app.get('/api/feet', (req, res) => {
res.json({ feet, props });
});
// 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.' });
}
// Rangliste: Top 10
app.get('/api/leaderboard', (req, res) => {
res.json(topN(readLeaderboard(), 10));
});
let hit = null;
for (const t of entry.targets) {
if (Math.hypot(t.cx - x, t.cy - y) <= t.radius) {
hit = t.id;
break;
}
// Score eintragen -> aktualisierte Top 10 zurueck
app.post('/api/score', (req, res) => {
let { name, score } = req.body || {};
score = Number(score);
if (!Number.isFinite(score) || score < 0) {
return res.status(400).json({ error: 'Ungueltiger Score.' });
}
res.json({ hit });
name = String(name || 'Anonym').trim().slice(0, 20) || 'Anonym';
score = Math.min(Math.floor(score), 100000); // grobe Plausibilitaet
const list = readLeaderboard();
list.push({ name, score, at: new Date().toISOString() });
const trimmed = topN(list, 100); // Datei klein halten
writeLeaderboard(trimmed);
res.json(topN(trimmed, 10));
});
app.listen(PORT, () => {
console.log(`[server] fuesse.sexy laeuft auf http://localhost:${PORT}`);
console.log(`[server] fuesse.sexy (Wanted!) laeuft auf http://localhost:${PORT}`);
});