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:
492
public/js/app.js
492
public/js/app.js
@@ -1,106 +1,428 @@
|
||||
// App-Flow: beim Laden sofort eine zufaellige Szene holen (kein Start-Screen, keine
|
||||
// Auswahl). Alle 3 Fuesse finden -> Gewinn-Overlay -> "Weiter" laedt die naechste.
|
||||
// fuesse.sexy — "Wanted!"-Minigame
|
||||
// Oben ein Fahndungs-Fuss, im Feld wuseln viele Fuesse/Deko. Schnapp den gesuchten,
|
||||
// bevor die Zeit ablaeuft. Schwierigkeit (Anzahl, Layout, Bewegung, Tempo) steigt mit Score.
|
||||
|
||||
import { Scene } from './scene.js';
|
||||
import { Legend } from './legend.js';
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const stage = document.getElementById('stage');
|
||||
const world = document.getElementById('world');
|
||||
const sceneImg = document.getElementById('scene-img');
|
||||
const loading = document.getElementById('loading');
|
||||
const winOverlay = document.getElementById('win-overlay');
|
||||
const toast = document.getElementById('toast');
|
||||
// ---- Konstanten (leicht justierbar) ----
|
||||
const START_TIME = 22; // Sekunden zu Beginn
|
||||
const MAX_TIME = 30; // Deckel (Timerleiste = timeLeft / MAX_TIME)
|
||||
const BONUS = 2.0; // Zeit pro Treffer
|
||||
const PENALTY = 2.0; // Zeitabzug pro Fehlklick
|
||||
const NAME_KEY = 'fuesse_name';
|
||||
|
||||
const scene = new Scene(stage, world, sceneImg);
|
||||
const legend = new Legend(
|
||||
document.getElementById('legend-items'),
|
||||
document.getElementById('legend-counter')
|
||||
);
|
||||
// ---- Zustand ----
|
||||
let FEET = []; // [{id,name,url}]
|
||||
let PROPS = []; // [{id,name,url}]
|
||||
let state = null;
|
||||
let rafId = null;
|
||||
let lastT = 0;
|
||||
|
||||
let current = null; // aktuelles Szenen-Payload
|
||||
let busy = false;
|
||||
|
||||
function showLoading(on) {
|
||||
loading.classList.toggle('hidden', !on);
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
toast.textContent = message;
|
||||
toast.classList.add('show');
|
||||
clearTimeout(showToast._t);
|
||||
showToast._t = setTimeout(() => toast.classList.remove('show'), 900);
|
||||
}
|
||||
|
||||
function showError(err) {
|
||||
let box = document.getElementById('error-box');
|
||||
if (!box) {
|
||||
box = document.createElement('div');
|
||||
box.id = 'error-box';
|
||||
stage.appendChild(box);
|
||||
// ---- Utils ----
|
||||
const rand = (a, b) => a + Math.random() * (b - a);
|
||||
const randInt = (a, b) => Math.floor(rand(a, b + 1));
|
||||
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||
function shuffle(a) {
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = randInt(0, i);
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
box.textContent = 'Fehler beim Laden der Szene: ' + msg;
|
||||
box.classList.remove('hidden');
|
||||
return a;
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
const box = document.getElementById('error-box');
|
||||
if (box) box.classList.add('hidden');
|
||||
// Schwierigkeit aus dem Score ableiten
|
||||
function difficulty(score) {
|
||||
const count = Math.min(10 + Math.floor(score * 1.5), 46);
|
||||
if (score < 3) return { count, stage: 'grid', groups: 0, speed: 0 };
|
||||
if (score < 6) return { count, stage: 'scatter', groups: 0, speed: 0 };
|
||||
if (score < 11) return { count, stage: 'groups', groups: score < 9 ? 2 : 3, speed: Math.min(45 + (score - 6) * 10, 150) };
|
||||
return { count, stage: 'individual', groups: 0, speed: Math.min(70 + (score - 11) * 12, 320) };
|
||||
}
|
||||
|
||||
async function loadScene() {
|
||||
showLoading(true);
|
||||
clearError();
|
||||
winOverlay.classList.add('hidden');
|
||||
// ---- Rangliste ----
|
||||
async function fetchLeaderboard() {
|
||||
try {
|
||||
const res = await fetch('/api/scene');
|
||||
if (!res.ok) throw new Error('Server-Antwort ' + res.status);
|
||||
current = await res.json();
|
||||
await scene.render(current);
|
||||
scene.resetView();
|
||||
legend.render(current.targets);
|
||||
document.getElementById('category-name').textContent = current.category || '';
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showError(err);
|
||||
} finally {
|
||||
showLoading(false);
|
||||
const res = await fetch('/api/leaderboard');
|
||||
return res.ok ? await res.json() : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function renderLeaderboard(el, list) {
|
||||
el.innerHTML = '';
|
||||
if (!list.length) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'lb-empty';
|
||||
li.textContent = 'Noch keine Einträge — sei die/der Erste!';
|
||||
el.appendChild(li);
|
||||
return;
|
||||
}
|
||||
list.forEach((entry, i) => {
|
||||
const li = document.createElement('li');
|
||||
const rank = document.createElement('span');
|
||||
rank.className = 'lb-rank';
|
||||
rank.textContent = `${i + 1}.`;
|
||||
const name = document.createElement('span');
|
||||
name.className = 'lb-name';
|
||||
name.textContent = entry.name; // textContent -> kein XSS
|
||||
const score = document.createElement('span');
|
||||
score.className = 'lb-score';
|
||||
score.textContent = entry.score;
|
||||
li.append(rank, name, score);
|
||||
if (i < 3) li.classList.add('top3');
|
||||
el.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
// ================= Spiel =================
|
||||
function startGame() {
|
||||
$('home').classList.add('hidden');
|
||||
$('gameover').classList.add('hidden');
|
||||
$('game').classList.remove('hidden');
|
||||
|
||||
state = { score: 0, timeLeft: START_TIME, items: [], groups: [], stage: 'grid', running: true };
|
||||
$('score').textContent = '0';
|
||||
buildRound();
|
||||
|
||||
lastT = performance.now();
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function fieldSize() {
|
||||
const r = $('field').getBoundingClientRect();
|
||||
return { w: r.width, h: r.height };
|
||||
}
|
||||
|
||||
// Baut eine neue Runde: neuer (zufaelliger) Ziel-Fuss + Ablenker, Layout je nach Stufe.
|
||||
function buildRound() {
|
||||
const field = $('field');
|
||||
field.innerHTML = '';
|
||||
state.items = [];
|
||||
state.groups = [];
|
||||
|
||||
const { w, h } = fieldSize();
|
||||
const diff = difficulty(state.score);
|
||||
state.stage = diff.stage;
|
||||
|
||||
// Ziel-Fusstyp zufaellig (Farbe wechselt jede Runde)
|
||||
const target = pick(FEET);
|
||||
$('wanted-img').src = target.url;
|
||||
|
||||
// Item-Groessenwahl: schrumpft mit steigender Anzahl, bleibt tippfreundlich
|
||||
const size = clamp(Math.sqrt((w * h) / (diff.count * 3.4)), 40, 66);
|
||||
|
||||
// Typen fuer die Items zusammenstellen: 1 Ziel + Ablenker
|
||||
const otherFeet = FEET.filter((f) => f.id !== target.id);
|
||||
const decoyPool = [];
|
||||
// Ablenker: v.a. andersfarbige Fuesse (inkl. aehnliche), plus ein paar Deko-Props
|
||||
for (let i = 0; i < diff.count - 1; i++) {
|
||||
const useProp = PROPS.length && Math.random() < 0.22;
|
||||
decoyPool.push({ type: useProp ? pick(PROPS) : pick(otherFeet), isTarget: false });
|
||||
}
|
||||
const specs = shuffle([{ type: target, isTarget: true }, ...decoyPool]);
|
||||
|
||||
// Positionen je Stufe
|
||||
const positions = layoutPositions(diff, specs.length, w, h, size);
|
||||
|
||||
specs.forEach((spec, i) => {
|
||||
const el = document.createElement('img');
|
||||
el.className = 'item';
|
||||
el.src = spec.type.url;
|
||||
el.draggable = false;
|
||||
el.style.width = size + 'px';
|
||||
el.style.height = size + 'px';
|
||||
field.appendChild(el);
|
||||
|
||||
const p = positions[i];
|
||||
const item = {
|
||||
el,
|
||||
isTarget: spec.isTarget,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
rot: rand(-12, 12),
|
||||
vr: rand(-25, 25),
|
||||
size,
|
||||
gi: p.gi ?? 0,
|
||||
ox: p.ox ?? 0,
|
||||
oy: p.oy ?? 0,
|
||||
};
|
||||
el.__item = item;
|
||||
state.items.push(item);
|
||||
applyTransform(item);
|
||||
});
|
||||
|
||||
// Bewegung vorbereiten
|
||||
if (diff.stage === 'groups') {
|
||||
for (let g = 0; g < diff.groups; g++) {
|
||||
const ang = rand(0, Math.PI * 2);
|
||||
state.groups.push({
|
||||
cx: rand(w * 0.25, w * 0.75),
|
||||
cy: rand(h * 0.25, h * 0.75),
|
||||
vx: Math.cos(ang) * diff.speed,
|
||||
vy: Math.sin(ang) * diff.speed,
|
||||
});
|
||||
}
|
||||
// Item-Positionen relativ zum Gruppenzentrum (Offset) neu setzen
|
||||
state.items.forEach((it) => {
|
||||
const grp = state.groups[it.gi];
|
||||
it.ox = it.x - grp.cx;
|
||||
it.oy = it.y - grp.cy;
|
||||
});
|
||||
} else if (diff.stage === 'individual') {
|
||||
state.items.forEach((it) => {
|
||||
const ang = rand(0, Math.PI * 2);
|
||||
it.vx = Math.cos(ang) * diff.speed;
|
||||
it.vy = Math.sin(ang) * diff.speed;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTap({ x, y }) {
|
||||
if (busy || !current) return;
|
||||
busy = true;
|
||||
// Startpositionen je Stufe. Gibt {x,y,gi,ox,oy}-Liste (Top-Left-Koordinaten).
|
||||
function layoutPositions(diff, n, w, h, size) {
|
||||
const pad = 6;
|
||||
const out = [];
|
||||
if (diff.stage === 'grid') {
|
||||
const cols = Math.max(1, Math.ceil(Math.sqrt(n * (w / h))));
|
||||
const rows = Math.ceil(n / cols);
|
||||
const cellW = w / cols;
|
||||
const cellH = h / rows;
|
||||
const cells = [];
|
||||
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) cells.push({ r, c });
|
||||
shuffle(cells);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const { r, c } = cells[i];
|
||||
out.push({
|
||||
x: clamp(c * cellW + (cellW - size) / 2, pad, w - size - pad),
|
||||
y: clamp(r * cellH + (cellH - size) / 2, pad, h - size - pad),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (diff.stage === 'groups') {
|
||||
// Cluster-Zentren, Items rundherum verteilt
|
||||
const centers = [];
|
||||
for (let g = 0; g < diff.groups; g++) centers.push({ cx: rand(w * 0.3, w * 0.7), cy: rand(h * 0.3, h * 0.7) });
|
||||
const radius = clamp(Math.min(w, h) * 0.22, 60, 160);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const gi = i % diff.groups;
|
||||
const c = centers[gi];
|
||||
const a = rand(0, Math.PI * 2);
|
||||
const rr = rand(0, radius);
|
||||
out.push({
|
||||
x: clamp(c.cx + Math.cos(a) * rr - size / 2, pad, w - size - pad),
|
||||
y: clamp(c.cy + Math.sin(a) * rr - size / 2, pad, h - size - pad),
|
||||
gi,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// scatter / individual: frei verteilt
|
||||
for (let i = 0; i < n; i++) {
|
||||
out.push({ x: rand(pad, w - size - pad), y: rand(pad, h - size - pad) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function applyTransform(it) {
|
||||
it.el.style.transform = `translate(${it.x}px, ${it.y}px) rotate(${it.rot}deg)`;
|
||||
}
|
||||
|
||||
// ---- Animationsschleife ----
|
||||
function loop(now) {
|
||||
if (!state || !state.running) return;
|
||||
let dt = (now - lastT) / 1000;
|
||||
lastT = now;
|
||||
dt = Math.min(dt, 0.05);
|
||||
|
||||
const { w, h } = fieldSize();
|
||||
|
||||
if (state.stage === 'groups') {
|
||||
for (const grp of state.groups) {
|
||||
grp.cx += grp.vx * dt;
|
||||
grp.cy += grp.vy * dt;
|
||||
const m = 90;
|
||||
if (grp.cx < m) { grp.cx = m; grp.vx = Math.abs(grp.vx); }
|
||||
if (grp.cx > w - m) { grp.cx = w - m; grp.vx = -Math.abs(grp.vx); }
|
||||
if (grp.cy < m) { grp.cy = m; grp.vy = Math.abs(grp.vy); }
|
||||
if (grp.cy > h - m) { grp.cy = h - m; grp.vy = -Math.abs(grp.vy); }
|
||||
}
|
||||
for (const it of state.items) {
|
||||
const grp = state.groups[it.gi];
|
||||
it.x = clamp(grp.cx + it.ox, 0, w - it.size);
|
||||
it.y = clamp(grp.cy + it.oy, 0, h - it.size);
|
||||
it.rot += it.vr * dt;
|
||||
applyTransform(it);
|
||||
}
|
||||
} else if (state.stage === 'individual') {
|
||||
for (const it of state.items) {
|
||||
it.x += it.vx * dt;
|
||||
it.y += it.vy * dt;
|
||||
if (it.x < 0) { it.x = 0; it.vx = Math.abs(it.vx); }
|
||||
if (it.x > w - it.size) { it.x = w - it.size; it.vx = -Math.abs(it.vx); }
|
||||
if (it.y < 0) { it.y = 0; it.vy = Math.abs(it.vy); }
|
||||
if (it.y > h - it.size) { it.y = h - it.size; it.vy = -Math.abs(it.vy); }
|
||||
it.rot += it.vr * dt;
|
||||
applyTransform(it);
|
||||
}
|
||||
}
|
||||
|
||||
// Timer
|
||||
state.timeLeft -= dt;
|
||||
if (state.timeLeft <= 0) {
|
||||
state.timeLeft = 0;
|
||||
updateTimerBar();
|
||||
return gameOver();
|
||||
}
|
||||
updateTimerBar();
|
||||
rafId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function updateTimerBar() {
|
||||
const pct = clamp(state.timeLeft / MAX_TIME, 0, 1) * 100;
|
||||
const bar = $('timer-bar');
|
||||
bar.style.width = pct + '%';
|
||||
bar.classList.toggle('low', state.timeLeft <= 6);
|
||||
}
|
||||
|
||||
// ---- Treffer ----
|
||||
function onFieldPointerDown(e) {
|
||||
if (!state || !state.running) return;
|
||||
const el = e.target.closest('.item');
|
||||
if (!el || !el.__item) return;
|
||||
const item = el.__item;
|
||||
if (item.isTarget) {
|
||||
state.score += 1;
|
||||
$('score').textContent = state.score;
|
||||
$('score').parentElement.classList.remove('bump');
|
||||
void $('score').parentElement.offsetWidth;
|
||||
$('score').parentElement.classList.add('bump');
|
||||
state.timeLeft = Math.min(state.timeLeft + BONUS, MAX_TIME);
|
||||
flashField('ok');
|
||||
buildRound(); // neues Plakat, andere Farbe
|
||||
} else {
|
||||
state.timeLeft = Math.max(state.timeLeft - PENALTY, 0);
|
||||
el.classList.remove('shake');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('shake');
|
||||
flashField('miss');
|
||||
showFeedback('Daneben! −2s');
|
||||
if (state.timeLeft <= 0) gameOver();
|
||||
}
|
||||
}
|
||||
|
||||
let feedbackT = null;
|
||||
function showFeedback(msg) {
|
||||
const f = $('feedback');
|
||||
f.textContent = msg;
|
||||
f.classList.add('show');
|
||||
clearTimeout(feedbackT);
|
||||
feedbackT = setTimeout(() => f.classList.remove('show'), 700);
|
||||
}
|
||||
let flashT = null;
|
||||
function flashField(kind) {
|
||||
const field = $('field');
|
||||
field.classList.remove('flash-ok', 'flash-miss');
|
||||
void field.offsetWidth;
|
||||
field.classList.add(kind === 'ok' ? 'flash-ok' : 'flash-miss');
|
||||
clearTimeout(flashT);
|
||||
flashT = setTimeout(() => field.classList.remove('flash-ok', 'flash-miss'), 250);
|
||||
}
|
||||
|
||||
// ---- Game Over ----
|
||||
async function gameOver() {
|
||||
if (!state) return;
|
||||
state.running = false;
|
||||
cancelAnimationFrame(rafId);
|
||||
|
||||
$('final-score').textContent = state.score;
|
||||
$('gameover').classList.remove('hidden');
|
||||
|
||||
const cachedName = localStorage.getItem(NAME_KEY);
|
||||
if (cachedName) {
|
||||
$('name-ask').classList.add('hidden');
|
||||
const list = await submitScore(cachedName, state.score);
|
||||
renderLeaderboard($('go-leaderboard'), list);
|
||||
} else {
|
||||
// Namensabfrage (nur beim ersten Mal)
|
||||
$('name-ask').classList.remove('hidden');
|
||||
renderLeaderboard($('go-leaderboard'), await fetchLeaderboard());
|
||||
$('name-input').focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function submitScore(name, score) {
|
||||
try {
|
||||
const res = await fetch(`/api/scene/${current.sceneId}/guess`, {
|
||||
const res = await fetch('/api/score', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ x, y }),
|
||||
body: JSON.stringify({ name, score }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.hit) {
|
||||
const isNew = legend.markFound(data.hit);
|
||||
scene.pingAt(x, y, true);
|
||||
if (isNew && legend.allFound()) {
|
||||
setTimeout(() => winOverlay.classList.remove('hidden'), 400);
|
||||
}
|
||||
} else {
|
||||
scene.pingAt(x, y, false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
if (res.ok) return await res.json();
|
||||
} catch {}
|
||||
return await fetchLeaderboard();
|
||||
}
|
||||
|
||||
scene.onTap(handleTap);
|
||||
// ---- Setup / Events ----
|
||||
function goHome() {
|
||||
$('gameover').classList.add('hidden');
|
||||
$('game').classList.add('hidden');
|
||||
$('home').classList.remove('hidden');
|
||||
refreshHomeBoard();
|
||||
}
|
||||
async function refreshHomeBoard() {
|
||||
renderLeaderboard($('leaderboard'), await fetchLeaderboard());
|
||||
}
|
||||
|
||||
// Steuerung
|
||||
document.getElementById('zoom-in').addEventListener('click', () => scene.zoomIn());
|
||||
document.getElementById('zoom-out').addEventListener('click', () => scene.zoomOut());
|
||||
document.getElementById('reset-view').addEventListener('click', () => scene.resetView());
|
||||
document.getElementById('next-scene').addEventListener('click', loadScene);
|
||||
async function init() {
|
||||
// Bildchen laden
|
||||
try {
|
||||
const res = await fetch('/api/feet');
|
||||
const data = await res.json();
|
||||
FEET = data.feet || [];
|
||||
PROPS = data.props || [];
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
if (FEET.length < 2) {
|
||||
$('home').querySelector('.tagline').textContent = 'Fehler: zu wenige Füße in library/feet.';
|
||||
}
|
||||
|
||||
// Los geht's — sofort.
|
||||
loadScene();
|
||||
await refreshHomeBoard();
|
||||
|
||||
$('play-btn').addEventListener('click', startGame);
|
||||
$('play-again').addEventListener('click', startGame);
|
||||
$('to-home').addEventListener('click', goHome);
|
||||
$('field').addEventListener('pointerdown', onFieldPointerDown);
|
||||
|
||||
$('save-score').addEventListener('click', async () => {
|
||||
const name = ($('name-input').value || '').trim().slice(0, 20) || 'Anonym';
|
||||
localStorage.setItem(NAME_KEY, name);
|
||||
$('name-ask').classList.add('hidden');
|
||||
renderLeaderboard($('go-leaderboard'), await submitScore(name, state.score));
|
||||
});
|
||||
$('name-input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') $('save-score').click();
|
||||
});
|
||||
|
||||
$('change-name').addEventListener('click', () => {
|
||||
const current = localStorage.getItem(NAME_KEY) || '';
|
||||
const next = prompt('Dein Name für die Rangliste:', current);
|
||||
if (next !== null) localStorage.setItem(NAME_KEY, next.trim().slice(0, 20) || 'Anonym');
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
// Positionen bei Groessenaenderung grob im Feld halten
|
||||
if (!state || !state.running) return;
|
||||
const { w, h } = fieldSize();
|
||||
for (const it of state.items) {
|
||||
it.x = clamp(it.x, 0, w - it.size);
|
||||
it.y = clamp(it.y, 0, h - it.size);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// Die "Gesucht"-Leiste oben: zeigt die 3 zu findenden Fuesse und hakt gefundene ab.
|
||||
|
||||
const EMOJI_BASE = '/emoji/';
|
||||
|
||||
export class Legend {
|
||||
constructor(container, counterEl) {
|
||||
this.container = container;
|
||||
this.counterEl = counterEl;
|
||||
this.found = new Set();
|
||||
this.total = 0;
|
||||
}
|
||||
|
||||
render(targets) {
|
||||
this.found.clear();
|
||||
this.total = targets.length;
|
||||
this.container.innerHTML = '';
|
||||
|
||||
for (const t of targets) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'legend-item';
|
||||
card.dataset.id = t.id;
|
||||
|
||||
const thumb = document.createElement('div');
|
||||
thumb.className = 'legend-thumb';
|
||||
|
||||
if (t.assetUrl) {
|
||||
const img = document.createElement('img');
|
||||
img.src = t.assetUrl;
|
||||
img.alt = t.name || 'Fuss';
|
||||
thumb.appendChild(img);
|
||||
} else if (t.glyph) {
|
||||
// Emoji: Bild aus Set, sonst Textzeichen
|
||||
const img = document.createElement('img');
|
||||
img.alt = t.name || 'Fuss';
|
||||
img.onerror = () => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'legend-emoji';
|
||||
span.textContent = t.glyph;
|
||||
img.replaceWith(span);
|
||||
};
|
||||
img.src = EMOJI_BASE + t.codepoint + '.png';
|
||||
thumb.appendChild(img);
|
||||
}
|
||||
|
||||
card.appendChild(thumb);
|
||||
this.container.appendChild(card);
|
||||
}
|
||||
|
||||
this._updateCounter();
|
||||
}
|
||||
|
||||
markFound(id) {
|
||||
if (this.found.has(id)) return false;
|
||||
this.found.add(id);
|
||||
const card = this.container.querySelector(`.legend-item[data-id="${id}"]`);
|
||||
if (card) card.classList.add('found');
|
||||
this._updateCounter();
|
||||
return true;
|
||||
}
|
||||
|
||||
allFound() {
|
||||
return this.found.size >= this.total && this.total > 0;
|
||||
}
|
||||
|
||||
_updateCounter() {
|
||||
if (this.counterEl) {
|
||||
this.counterEl.textContent = `${this.found.size} / ${this.total}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
// Rendert eine Szene und liefert das "Google-Maps-Gefuehl" via panzoom.
|
||||
//
|
||||
// Die komplette Szene wird EINMAL in ein Bild (Canvas -> <img>) gerendert; panzoom
|
||||
// bewegt/zoomt dann nur dieses eine Bild -> butterweich, egal wie viele Items.
|
||||
// Aufbau bewusst als reines DOM (div#world > img) statt SVG-viewBox: so stimmt der
|
||||
// Pinch-Fokuspunkt auf Touch, und die Szene fuellt den Schirm (Cover), statt in
|
||||
// schwarzen Balken zu ertrinken.
|
||||
|
||||
const EMOJI_BASE = '/emoji/';
|
||||
const TAP_THRESHOLD_PX = 12; // Bewegung darueber = Ziehen, kein Tippen
|
||||
const TAP_MAX_MS = 500; // laenger gedrueckt = kein Tippen
|
||||
const RENDER_SCALE = 2; // Aufloesung des Szenen-Bitmaps (Schaerfe beim Zoomen)
|
||||
const EMOJI_FONT = '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif';
|
||||
|
||||
let emojiAssetsAvailable = null; // pro Session: liegt ein Emoji-Bildset vor?
|
||||
|
||||
function loadImage(url) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
// Kein crossOrigin: Assets sind same-origin. crossOrigin kann je nach Browser/Proxy
|
||||
// dazu fuehren, dass das Canvas als "tainted" gilt und der Export fehlschlaegt.
|
||||
img.onload = () => resolve({ url, img });
|
||||
img.onerror = () => resolve({ url, img: null });
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export class Scene {
|
||||
constructor(stage, world, img) {
|
||||
this.stage = stage;
|
||||
this.world = world;
|
||||
this.img = img;
|
||||
this.pz = null;
|
||||
this.tapHandler = null;
|
||||
this._objectUrl = null;
|
||||
this._imgCache = new Map();
|
||||
this._w = 2000;
|
||||
this._h = 1400;
|
||||
this._cover = 1;
|
||||
|
||||
// Tippen ueber Pointer-Events (funktioniert auch auf Touch, wo panzoom das
|
||||
// synthetische click-Event unterdrueckt). Pinch/Mehrfinger zaehlt nie als Tipp.
|
||||
this._pointers = new Map();
|
||||
this._multiTouch = false;
|
||||
|
||||
this.stage.addEventListener('pointerdown', (e) => {
|
||||
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY, t: performance.now() });
|
||||
if (this._pointers.size > 1) this._multiTouch = true;
|
||||
});
|
||||
this.stage.addEventListener('pointerup', (e) => {
|
||||
const down = this._pointers.get(e.pointerId);
|
||||
this._pointers.delete(e.pointerId);
|
||||
const wasMulti = this._multiTouch;
|
||||
if (this._pointers.size === 0) this._multiTouch = false;
|
||||
if (!down || wasMulti) return;
|
||||
const moved = Math.hypot(e.clientX - down.x, e.clientY - down.y);
|
||||
const dt = performance.now() - down.t;
|
||||
if (moved > TAP_THRESHOLD_PX || dt > TAP_MAX_MS) return; // war Ziehen/Halten
|
||||
this._emitTap(e.clientX, e.clientY);
|
||||
});
|
||||
const forget = (e) => {
|
||||
this._pointers.delete(e.pointerId);
|
||||
if (this._pointers.size === 0) this._multiTouch = false;
|
||||
};
|
||||
this.stage.addEventListener('pointercancel', forget);
|
||||
|
||||
// Bei Groessen-/Orientierungswechsel die Cover-Ansicht neu einpassen.
|
||||
window.addEventListener('resize', () => {
|
||||
if (this.pz) this._applyCover();
|
||||
});
|
||||
}
|
||||
|
||||
onTap(handler) {
|
||||
this.tapHandler = handler;
|
||||
}
|
||||
|
||||
_emitTap(clientX, clientY) {
|
||||
if (!this.tapHandler || !this.pz) return;
|
||||
const r = this.stage.getBoundingClientRect();
|
||||
const t = this.pz.getTransform();
|
||||
const x = (clientX - r.left - t.x) / t.scale;
|
||||
const y = (clientY - r.top - t.y) / t.scale;
|
||||
this.tapHandler({ x, y });
|
||||
}
|
||||
|
||||
async render(payload) {
|
||||
if (this.pz) {
|
||||
this.pz.dispose();
|
||||
this.pz = null;
|
||||
}
|
||||
this._w = payload.width;
|
||||
this._h = payload.height;
|
||||
|
||||
if (payload.type === 'emoji' && emojiAssetsAvailable === null) {
|
||||
const sample = payload.items.find((i) => i.kind === 'emoji');
|
||||
emojiAssetsAvailable = sample
|
||||
? (await loadImage(EMOJI_BASE + sample.codepoint + '.png')).img !== null
|
||||
: false;
|
||||
}
|
||||
|
||||
await this._preloadAssets(payload);
|
||||
const url = await this._rasterize(payload);
|
||||
|
||||
if (this._objectUrl) URL.revokeObjectURL(this._objectUrl);
|
||||
this._objectUrl = url;
|
||||
|
||||
// Bild sicher laden, bevor wir einpassen (getBoundingClientRect etc.)
|
||||
await new Promise((res) => {
|
||||
this.img.onload = res;
|
||||
this.img.onerror = res;
|
||||
this.img.src = url;
|
||||
});
|
||||
|
||||
this.world.style.width = this._w + 'px';
|
||||
this.world.style.height = this._h + 'px';
|
||||
|
||||
this._initPanzoom();
|
||||
}
|
||||
|
||||
async _preloadAssets(payload) {
|
||||
const urls = new Set();
|
||||
for (const it of payload.items) {
|
||||
if (it.kind === 'image') urls.add(it.assetUrl);
|
||||
else if (it.kind === 'emoji' && emojiAssetsAvailable) urls.add(EMOJI_BASE + it.codepoint + '.png');
|
||||
}
|
||||
const missing = [...urls].filter((u) => !this._imgCache.has(u));
|
||||
const loaded = await Promise.all(missing.map(loadImage));
|
||||
for (const { url, img } of loaded) this._imgCache.set(url, img);
|
||||
}
|
||||
|
||||
async _rasterize(payload) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = payload.width * RENDER_SCALE;
|
||||
canvas.height = payload.height * RENDER_SCALE;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(RENDER_SCALE, RENDER_SCALE);
|
||||
|
||||
// Basisfarbe + dezenter Tiefen-Verlauf (oben heller, unten leichter Schatten)
|
||||
ctx.fillStyle = payload.background || '#ffffff';
|
||||
ctx.fillRect(0, 0, payload.width, payload.height);
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, payload.height);
|
||||
grad.addColorStop(0, 'rgba(255,255,255,0.22)');
|
||||
grad.addColorStop(0.5, 'rgba(255,255,255,0)');
|
||||
grad.addColorStop(1, 'rgba(0,0,0,0.14)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, payload.width, payload.height);
|
||||
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
for (const item of payload.items) {
|
||||
ctx.save();
|
||||
ctx.translate(item.cx, item.cy);
|
||||
ctx.rotate((item.rot * Math.PI) / 180);
|
||||
this._drawItem(ctx, item);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Export als Blob-URL; mit Fallback auf dataURL, falls toBlob nicht greift.
|
||||
try {
|
||||
const blob = await new Promise((resolve, reject) => {
|
||||
try {
|
||||
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob lieferte null'))), 'image/png');
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (err) {
|
||||
console.warn('[scene] toBlob fehlgeschlagen, versuche toDataURL:', err);
|
||||
return canvas.toDataURL('image/png'); // wirft nur, wenn Canvas wirklich "tainted" ist
|
||||
}
|
||||
}
|
||||
|
||||
_drawItem(ctx, item) {
|
||||
const size = item.size;
|
||||
|
||||
if (item.kind === 'emoji' && !emojiAssetsAvailable) {
|
||||
ctx.font = `${size}px ${EMOJI_FONT}`;
|
||||
ctx.fillText(item.glyph, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = item.kind === 'image' ? item.assetUrl : EMOJI_BASE + item.codepoint + '.png';
|
||||
const img = this._imgCache.get(url);
|
||||
if (!img) {
|
||||
if (item.kind === 'emoji') {
|
||||
ctx.font = `${size}px ${EMOJI_FONT}`;
|
||||
ctx.fillText(item.glyph, 0, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isSvg = url.toLowerCase().endsWith('.svg');
|
||||
let dw = size;
|
||||
let dh = size;
|
||||
if (!isSvg && img.naturalWidth && img.naturalHeight) {
|
||||
const s = Math.min(size / img.naturalWidth, size / img.naturalHeight);
|
||||
dw = img.naturalWidth * s;
|
||||
dh = img.naturalHeight * s;
|
||||
}
|
||||
ctx.drawImage(img, -dw / 2, -dh / 2, dw, dh);
|
||||
}
|
||||
|
||||
_coverScale() {
|
||||
const r = this.stage.getBoundingClientRect();
|
||||
return Math.max(r.width / this._w, r.height / this._h);
|
||||
}
|
||||
|
||||
_initPanzoom() {
|
||||
this._cover = this._coverScale();
|
||||
if (typeof panzoom === 'function') {
|
||||
this.pz = panzoom(this.world, {
|
||||
maxZoom: this._cover * 9,
|
||||
minZoom: this._cover, // nicht weiter raus als "Cover"
|
||||
smoothScroll: true,
|
||||
zoomDoubleClickSpeed: 1,
|
||||
});
|
||||
// Eigene Cover-Klemmung: das Bild deckt den Schirm immer voll (kein Leerraum).
|
||||
this._clamping = false;
|
||||
this.pz.on('transform', () => this._clampToCover());
|
||||
}
|
||||
this._applyCover();
|
||||
}
|
||||
|
||||
// Szene bildschirmfuellend zentrieren (Cover).
|
||||
_applyCover() {
|
||||
if (!this.pz) return;
|
||||
const r = this.stage.getBoundingClientRect();
|
||||
this._cover = this._coverScale();
|
||||
this.pz.zoomAbs(0, 0, this._cover);
|
||||
const tx = (r.width - this._w * this._cover) / 2;
|
||||
const ty = (r.height - this._h * this._cover) / 2;
|
||||
this.pz.moveTo(tx, ty);
|
||||
}
|
||||
|
||||
// Haelt die Translation so, dass das Bild den Viewport immer vollstaendig deckt.
|
||||
_clampToCover() {
|
||||
if (!this.pz || this._clamping) return;
|
||||
const r = this.stage.getBoundingClientRect();
|
||||
const t = this.pz.getTransform();
|
||||
const w = this._w * t.scale;
|
||||
const h = this._h * t.scale;
|
||||
|
||||
let x = t.x;
|
||||
let y = t.y;
|
||||
x = w >= r.width ? Math.min(0, Math.max(r.width - w, x)) : (r.width - w) / 2;
|
||||
y = h >= r.height ? Math.min(0, Math.max(r.height - h, y)) : (r.height - h) / 2;
|
||||
|
||||
if (Math.abs(x - t.x) > 0.5 || Math.abs(y - t.y) > 0.5) {
|
||||
this._clamping = true; // Rekursion durch das erneute transform-Event vermeiden
|
||||
this.pz.moveTo(x, y);
|
||||
this._clamping = false;
|
||||
}
|
||||
}
|
||||
|
||||
_stageCenterClient() {
|
||||
const r = this.stage.getBoundingClientRect();
|
||||
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
||||
}
|
||||
|
||||
zoomIn() {
|
||||
if (!this.pz) return;
|
||||
const c = this._stageCenterClient();
|
||||
this.pz.smoothZoom(c.x, c.y, 1.6);
|
||||
}
|
||||
|
||||
zoomOut() {
|
||||
if (!this.pz) return;
|
||||
const c = this._stageCenterClient();
|
||||
this.pz.smoothZoom(c.x, c.y, 0.625);
|
||||
}
|
||||
|
||||
resetView() {
|
||||
this._applyCover();
|
||||
}
|
||||
|
||||
// kurze Treffer-/Daneben-Animation an einer Szenen-Position (Welt-Pixel)
|
||||
pingAt(x, y, ok) {
|
||||
const ping = document.createElement('div');
|
||||
ping.className = 'ping ' + (ok ? 'ping-ok' : 'ping-miss');
|
||||
ping.style.left = x + 'px';
|
||||
ping.style.top = y + 'px';
|
||||
this.world.appendChild(ping);
|
||||
setTimeout(() => ping.remove(), 650);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user