- Rendering von SVG-viewBox auf sauberes DOM-Modell (div#world > img) umgestellt:
behebt den Pinch-Fokus-Bug ("zoomt in die Ecke") auf Touch
- Szene fuellt jetzt bildschirmfuellend (Cover) mit eigener Cover-Klemmung statt
Letterbox-Balken (v.a. mobil im Hochformat)
- Tippen ueber Pointer-Events statt click (funktioniert auf Touch, wo panzoom das
synthetische click unterdrueckt); Mehrfinger/Pinch zaehlt nie als Tipp
- touch-action:none aufs Bild -> fluessiges Pinch-Zoom
- Objekte aufrecht (leichte Neigung) statt zufaellig gekippt + dezenter Tiefen-Verlauf
-> wirkt wie eine Szene statt "Haufen"
- Favicon: Fuss-Emoji
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
214 lines
6.8 KiB
JavaScript
214 lines
6.8 KiB
JavaScript
// 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;
|
|
}
|