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:
151
server/index.js
151
server/index.js
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
// Baut aus einer Kategorie ein frisch gemischtes Szenen-Layout: Deko wird zufaellig
|
||||
// gestreut, dann werden 3 Ziele darueber versteckt. Weil wir hier platzieren, kennen
|
||||
// wir die Ziel-Trefferzonen exakt -> die Klickpruefung kann serverseitig laufen.
|
||||
|
||||
import { saveScene } from './sceneStore.js';
|
||||
|
||||
const CANVAS_WIDTH = 2000;
|
||||
const CANVAS_HEIGHT = 1400;
|
||||
const TARGET_COUNT = 3;
|
||||
const EDGE_MARGIN = 60;
|
||||
const MIN_TARGET_DISTANCE = 220; // Ziele nicht zu dicht beieinander
|
||||
|
||||
// Zufaellige, angenehme Hintergrundfarben fuer normale (nicht-thematische) Szenen.
|
||||
const PALETTE = [
|
||||
'#fbe7b6', '#d7ecff', '#ffe0ec', '#e5f7e0',
|
||||
'#efe6ff', '#fff2cc', '#dfeff2', '#f4e2d0',
|
||||
];
|
||||
|
||||
function rand(min, max) {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
function randInt(min, max) {
|
||||
return Math.floor(rand(min, max + 1));
|
||||
}
|
||||
|
||||
function pick(array) {
|
||||
return array[randInt(0, array.length - 1)];
|
||||
}
|
||||
|
||||
function makeSceneId() {
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// 🦶 -> "1f9b6" ; entfernt die Variation-Selector-Bytes (FE0F), joined mit "-".
|
||||
// Damit passt es zu gaengigen Emoji-Bildsets (Datei je Codepoint-Sequenz).
|
||||
function toCodepoint(glyph) {
|
||||
return [...glyph]
|
||||
.map((ch) => ch.codePointAt(0))
|
||||
.filter((cp) => cp !== 0xfe0f)
|
||||
.map((cp) => cp.toString(16))
|
||||
.join('-');
|
||||
}
|
||||
|
||||
function randomPosition() {
|
||||
return {
|
||||
cx: rand(EDGE_MARGIN, CANVAS_WIDTH - EDGE_MARGIN),
|
||||
cy: rand(EDGE_MARGIN, CANVAS_HEIGHT - EDGE_MARGIN),
|
||||
};
|
||||
}
|
||||
|
||||
// Jittered Grid: fuellt die ganze Flaeche lueckenlos, wirkt durch Zufalls-Versatz,
|
||||
// -Groesse und -Drehung aber organisch/chaotisch (echtes "Wimmel"-Gefuehl).
|
||||
// cellFactor < 1 => Zellen kleiner als die Items => sie ueberlappen => randvoll.
|
||||
function gridPositions(cellFactor, avgSize) {
|
||||
const cell = Math.max(16, avgSize * cellFactor);
|
||||
const cols = Math.ceil(CANVAS_WIDTH / cell);
|
||||
const rows = Math.ceil(CANVAS_HEIGHT / cell);
|
||||
const jitter = cell * 0.45;
|
||||
const out = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
out.push({
|
||||
cx: c * cell + cell / 2 + rand(-jitter, jitter),
|
||||
cy: r * cell + cell / 2 + rand(-jitter, jitter),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Reihenfolge mischen, damit gleiche Emojis/Props nicht sichtbar "in Reihe" liegen.
|
||||
function shuffle(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = randInt(0, i);
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
// Platz fuer ein Ziel finden, das genug Abstand zu bereits gesetzten Zielen haelt.
|
||||
function findTargetPosition(placedTargets) {
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
const pos = randomPosition();
|
||||
const clear = placedTargets.every((t) => {
|
||||
const dx = t.cx - pos.cx;
|
||||
const dy = t.cy - pos.cy;
|
||||
return Math.hypot(dx, dy) >= MIN_TARGET_DISTANCE;
|
||||
});
|
||||
if (clear) return pos;
|
||||
}
|
||||
return randomPosition(); // Notfall: nimm irgendeine Position
|
||||
}
|
||||
|
||||
function buildImageItem(cat, pos) {
|
||||
return {
|
||||
kind: 'image',
|
||||
assetUrl: pick(cat.props),
|
||||
cx: pos.cx,
|
||||
cy: pos.cy,
|
||||
size: rand(cat.minSize, cat.maxSize),
|
||||
rot: rand(-14, 14), // aufrecht mit leichter Neigung -> wirkt platziert, nicht hingekippt
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmojiItem(cat, pos) {
|
||||
const glyph = pick(cat.propGlyphs);
|
||||
return {
|
||||
kind: 'emoji',
|
||||
glyph,
|
||||
codepoint: toCodepoint(glyph),
|
||||
cx: pos.cx,
|
||||
cy: pos.cy,
|
||||
size: rand(cat.minSize, cat.maxSize),
|
||||
rot: rand(-18, 18),
|
||||
};
|
||||
}
|
||||
|
||||
// Erzeugt eine Szene fuer die gegebene Kategorie und liefert:
|
||||
// { payload } -> geht ans Frontend (ohne Kennzeichnung, welche Items Ziele sind)
|
||||
// und speichert die Trefferzonen serverseitig unter der sceneId.
|
||||
export function generateScene(cat) {
|
||||
const sceneId = makeSceneId();
|
||||
const isEmoji = cat.type === 'emoji';
|
||||
|
||||
// 1) Deko lueckenlos ueber ein jittered Grid streuen -> randvolles Wimmelbild
|
||||
const items = [];
|
||||
const avgSize = (cat.minSize + cat.maxSize) / 2;
|
||||
const cellFactor = cat.cellFactor || (isEmoji ? 0.8 : 0.82);
|
||||
const positions = shuffle(gridPositions(cellFactor, avgSize));
|
||||
for (const pos of positions) {
|
||||
items.push(isEmoji ? buildEmojiItem(cat, pos) : buildImageItem(cat, pos));
|
||||
}
|
||||
|
||||
// 2) Ziele auswaehlen
|
||||
const chosenTargets = [];
|
||||
if (isEmoji) {
|
||||
const glyphPool = shuffle([...cat.targetGlyphs]);
|
||||
for (let i = 0; i < TARGET_COUNT; i++) {
|
||||
const glyph = glyphPool.length > 0 ? glyphPool.pop() : pick(cat.targetGlyphs);
|
||||
chosenTargets.push({ glyph, codepoint: toCodepoint(glyph), name: 'Fuss-Emoji' });
|
||||
}
|
||||
} else {
|
||||
// 3 moeglichst unterschiedliche Ziel-Bilder (Duplikate nur, wenn zu wenige da sind)
|
||||
const pool = shuffle([...cat.targets]);
|
||||
for (let i = 0; i < TARGET_COUNT; i++) {
|
||||
const picked = pool.length > 0 ? pool.pop() : pick(cat.targets);
|
||||
chosenTargets.push({ url: picked.url, name: picked.name });
|
||||
}
|
||||
}
|
||||
|
||||
// 2b) Ablenker-Fuesse streuen: die NICHT gewaehlten Fuesse (nur normale/Clipart-Level).
|
||||
// So muss man die 3 richtigen aus der Legende finden, nicht irgendeinen Fuss.
|
||||
if (!isEmoji && Array.isArray(cat.targets) && cat.targets.length > TARGET_COUNT) {
|
||||
const chosenUrls = new Set(chosenTargets.map((t) => t.url));
|
||||
const decoyPool = cat.targets.filter((t) => !chosenUrls.has(t.url));
|
||||
if (decoyPool.length > 0) {
|
||||
const decoyCount = Math.min(12, decoyPool.length * 2 + 2);
|
||||
for (let i = 0; i < decoyCount; i++) {
|
||||
const d = pick(decoyPool);
|
||||
const pos = randomPosition();
|
||||
items.push({
|
||||
kind: 'image',
|
||||
assetUrl: d.url,
|
||||
cx: pos.cx,
|
||||
cy: pos.cy,
|
||||
size: rand((cat.minSize + cat.maxSize) / 2, cat.maxSize),
|
||||
rot: rand(-16, 16),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Ziele platzieren (kommen ans Ende von items -> werden oben gerendert, also sichtbar)
|
||||
const placedTargets = [];
|
||||
const legend = [];
|
||||
chosenTargets.forEach((t, i) => {
|
||||
const pos = findTargetPosition(placedTargets);
|
||||
const size = isEmoji
|
||||
? rand(cat.minSize, cat.maxSize)
|
||||
: rand((cat.minSize + cat.maxSize) / 2, cat.maxSize);
|
||||
const id = `t${i}`;
|
||||
|
||||
placedTargets.push({ id, cx: pos.cx, cy: pos.cy, radius: size * 0.55 });
|
||||
|
||||
const item = isEmoji
|
||||
? { kind: 'emoji', glyph: t.glyph, codepoint: t.codepoint, cx: pos.cx, cy: pos.cy, size, rot: rand(-12, 12) }
|
||||
: { kind: 'image', assetUrl: t.url, cx: pos.cx, cy: pos.cy, size, rot: rand(-14, 14) };
|
||||
items.push(item);
|
||||
|
||||
legend.push(
|
||||
isEmoji
|
||||
? { id, name: t.name, glyph: t.glyph, codepoint: t.codepoint }
|
||||
: { id, name: t.name, assetUrl: t.url }
|
||||
);
|
||||
});
|
||||
|
||||
// Trefferzonen nur serverseitig ablegen
|
||||
saveScene(sceneId, placedTargets);
|
||||
|
||||
const payload = {
|
||||
sceneId,
|
||||
type: cat.type,
|
||||
category: cat.name,
|
||||
width: CANVAS_WIDTH,
|
||||
height: CANVAS_HEIGHT,
|
||||
background: cat.background || pick(PALETTE),
|
||||
targets: legend,
|
||||
items,
|
||||
};
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
// 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());
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Haelt die Trefferzonen jeder generierten Szene serverseitig, damit Klicks
|
||||
// geprueft werden koennen, ohne die Zielpositionen je ans Frontend zu schicken.
|
||||
// Einfache Groessen- und Zeitbegrenzung verhindert ein Speicher-Leck bei vielen
|
||||
// Aufrufen.
|
||||
|
||||
const MAX_ENTRIES = 500;
|
||||
const TTL_MS = 1000 * 60 * 30; // 30 Minuten
|
||||
|
||||
const scenes = new Map(); // sceneId -> { targets, createdAt }
|
||||
|
||||
function evictIfNeeded() {
|
||||
const now = Date.now();
|
||||
for (const [id, entry] of scenes) {
|
||||
if (now - entry.createdAt > TTL_MS) scenes.delete(id);
|
||||
}
|
||||
while (scenes.size > MAX_ENTRIES) {
|
||||
const oldest = scenes.keys().next().value; // Map behaelt Einfuege-Reihenfolge
|
||||
scenes.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
export function saveScene(sceneId, targets) {
|
||||
scenes.set(sceneId, { targets, createdAt: Date.now() });
|
||||
evictIfNeeded();
|
||||
}
|
||||
|
||||
export function getScene(sceneId) {
|
||||
return scenes.get(sceneId) || null;
|
||||
}
|
||||
Reference in New Issue
Block a user