- 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>
103 lines
3.5 KiB
JavaScript
103 lines
3.5 KiB
JavaScript
// 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, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = join(__dirname, '..');
|
|
const PUBLIC_DIR = join(ROOT, 'public');
|
|
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'];
|
|
|
|
// ---- 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}` }));
|
|
}
|
|
|
|
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] ${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 + 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));
|
|
|
|
// Verfuegbare Bildchen
|
|
app.get('/api/feet', (req, res) => {
|
|
res.json({ feet, props });
|
|
});
|
|
|
|
// Rangliste: Top 10
|
|
app.get('/api/leaderboard', (req, res) => {
|
|
res.json(topN(readLeaderboard(), 10));
|
|
});
|
|
|
|
// 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.' });
|
|
}
|
|
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 (Wanted!) laeuft auf http://localhost:${PORT}`);
|
|
});
|