edu-boardgame-generator/js/world.js

723 lines
33 KiB
JavaScript

/**
* js/world.js — Gemeinsame Welt-Engine für Editor (Vorschau/Generator) und Spieler.
*
* API:
* World.generate(themeId, seed, fieldCount) → world (normalized, deterministisch)
* World.render(ctx, world, state, t) → zeichnet ein Frame
* World.hitTestPad(world, x, y, W, H) → fieldIndex | -1
* World.padCenter(world, i, W, H) → {x,y}
* World.resolveTheme(id) → mapped theme id (Legacy)
* World.randomSeed() → uint32
* World.hashSeed(str) → uint32
*
* state = { W, H, pos, visited, fields, storyItems, figEmoji, figX, figY,
* gameName?, devName?, hover? (fieldIndex), locked? }
*/
window.World = (function () {
// ── Seedable PRNG (mulberry32) ─────────────────────────────
function mulberry32(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (s + 0x6D2B79F5) | 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hashSeed(str) {
let h = 2166136261;
const s = String(str || '');
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0) || 1;
}
function randomSeed() { return (Math.floor(Math.random() * 0x100000000)) >>> 0; }
// ── Theme registry (4 Welten) ───────────────────────────────
const THEMES = {
underwater: {
id: 'underwater', name: 'Unterwasser', emoji: '🌊',
primary: '#22d3ee', accent: '#0ea5e9',
bgGrad: ['#052236', '#083b56', '#0e567a'],
pad: { fill: '#0891b2', edge: '#155e75', glow: 'rgba(34,211,238,0.5)' },
},
space: {
id: 'space', name: 'Weltall', emoji: '🌌',
primary: '#a78bfa', accent: '#7c3aed',
bgGrad: ['#050314', '#0f0a2e', '#1e1b4b'],
pad: { fill: '#7c3aed', edge: '#4c1d95', glow: 'rgba(167,139,250,0.55)' },
},
haunted: {
id: 'haunted', name: 'Geisterhaus', emoji: '👻',
primary: '#86efac', accent: '#22d3ee',
bgGrad: ['#0a0a0e', '#16162a', '#1a1a2e'],
pad: { fill: '#475569', edge: '#1e293b', glow: 'rgba(134,239,172,0.45)' },
},
fantasy: {
id: 'fantasy', name: 'Fantasy', emoji: '🧙',
primary: '#f0abfc', accent: '#c084fc',
bgGrad: ['#1a0a2e', '#3b0764', '#581c87'],
pad: { fill: '#a855f7', edge: '#581c87', glow: 'rgba(240,171,252,0.55)' },
},
};
// Mapping für entfernte Welten (Migration alter Saves/URLs)
const LEGACY_MAP = {
ocean: 'underwater', jungle: 'fantasy', volcano: 'space', snow: 'space',
city: 'space', candy: 'fantasy', desert: 'fantasy', school: 'fantasy', future: 'space',
};
function resolveTheme(id) { return THEMES[id] ? id : (LEGACY_MAP[id] || 'space'); }
// ── Pad-Platzierung (Snake-Grid + Jitter, normalisierte Koords) ─────
function placePads(rng, fieldCount) {
const aspect = 16 / 9;
const cols = Math.max(2, Math.min(fieldCount, Math.round(Math.sqrt(fieldCount * aspect))));
const rows = Math.ceil(fieldCount / cols);
const marginX = 0.07, marginY = 0.13; // Platz oben für Header
const usableW = 1 - 2 * marginX;
const usableH = 1 - marginY - 0.10;
const cellW = usableW / cols;
const cellH = usableH / rows;
const pads = [];
for (let i = 0; i < fieldCount; i++) {
const r = Math.floor(i / cols);
const c = r % 2 === 0 ? i % cols : (cols - 1) - (i % cols);
const cx = marginX + (c + 0.5) * cellW;
const cy = marginY + (r + 0.5) * cellH;
const jx = (rng() - 0.5) * cellW * 0.55;
const jy = (rng() - 0.5) * cellH * 0.45;
const ux = Math.max(marginX, Math.min(1 - marginX, cx + jx));
const uy = Math.max(marginY, Math.min(1 - 0.06, cy + jy));
pads.push({ ux, uy });
}
return pads;
}
// Catmull-Rom-Spline durch Pads (normalized space)
function spline(pads, samplesPerSeg) {
const out = [];
const n = pads.length;
if (n < 2) return pads.slice();
for (let i = 0; i < n - 1; i++) {
const p0 = pads[Math.max(0, i - 1)];
const p1 = pads[i];
const p2 = pads[i + 1];
const p3 = pads[Math.min(n - 1, i + 2)];
for (let s = 0; s < samplesPerSeg; s++) {
const t = s / samplesPerSeg, t2 = t * t, t3 = t2 * t;
const ux = 0.5 * ((2 * p1.ux) + (-p0.ux + p2.ux) * t + (2 * p0.ux - 5 * p1.ux + 4 * p2.ux - p3.ux) * t2 + (-p0.ux + 3 * p1.ux - 3 * p2.ux + p3.ux) * t3);
const uy = 0.5 * ((2 * p1.uy) + (-p0.uy + p2.uy) * t + (2 * p0.uy - 5 * p1.uy + 4 * p2.uy - p3.uy) * t2 + (-p0.uy + 3 * p1.uy - 3 * p2.uy + p3.uy) * t3);
out.push({ ux, uy });
}
}
out.push({ ux: pads[n - 1].ux, uy: pads[n - 1].uy });
return out;
}
// ── Theme-spezifische Dekoration deterministisch generieren ────
function makeDecor(rng, themeId, fieldCount) {
const d = {};
if (themeId === 'underwater') {
d.bubbles = []; for (let i = 0; i < 30; i++) d.bubbles.push({ ux: rng(), uy: rng(), r: 2 + rng() * 5, sp: 0.04 + rng() * 0.10, ph: rng() * 6.28 });
d.grass = []; for (let i = 0; i < 14; i++) d.grass.push({ ux: rng(), h: 0.10 + rng() * 0.22, sw: 1.5 + rng() * 2.5, ph: rng() * 6.28 });
d.rocks = []; for (let i = 0; i < 8; i++) d.rocks.push({ ux: rng(), w: 0.04 + rng() * 0.08, h: 0.05 + rng() * 0.08, col: rng() });
d.rays = []; for (let i = 0; i < 5; i++) d.rays.push({ x: rng(), w: 60 + rng() * 80, sk: rng(), ph: rng() * 6.28 });
}
else if (themeId === 'space') {
d.stars = []; for (let i = 0; i < 90; i++) d.stars.push({ ux: rng(), uy: rng(), r: 0.5 + rng() * 1.8, sp: 0.5 + rng() * 1.8, ph: rng() * 6.28 });
d.planets = []; for (let i = 0; i < 3; i++) d.planets.push({ ux: 0.1 + rng() * 0.8, uy: rng() * 0.5, r: 0.03 + rng() * 0.045, hue: rng() * 360 });
d.nebula = []; for (let i = 0; i < 2; i++) d.nebula.push({ ux: rng(), uy: rng() * 0.6, r: 0.18 + rng() * 0.12, hue: 250 + rng() * 80 });
}
else if (themeId === 'haunted') {
d.trees = []; for (let i = 0; i < 7; i++) d.trees.push({ ux: rng(), h: 0.30 + rng() * 0.30, lean: (rng() - 0.5) * 0.4 });
d.graves = []; for (let i = 0; i < 8; i++) d.graves.push({ ux: rng(), w: 0.03 + rng() * 0.04, h: 0.04 + rng() * 0.05 });
d.wisps = []; for (let i = 0; i < 14; i++) d.wisps.push({ ux: rng(), uy: rng(), r: 4 + rng() * 5, sp: 0.02 + rng() * 0.05, ph: rng() * 6.28 });
d.moonX = 0.78 + rng() * 0.12;
}
else if (themeId === 'fantasy') {
d.trees = []; for (let i = 0; i < 9; i++) d.trees.push({ ux: rng(), h: 0.25 + rng() * 0.30 });
d.flies = []; for (let i = 0; i < 40; i++) d.flies.push({ ux: rng(), uy: rng(), r: 1.5 + rng() * 2, sp: 0.6 + rng() * 1.2, ph: rng() * 6.28 });
d.runes = []; for (let i = 0; i < 5; i++) d.runes.push({ ux: 0.05 + rng() * 0.9, uy: 0.1 + rng() * 0.5, r: 0.018 + rng() * 0.020, ph: rng() * 6.28 });
}
return d;
}
// ── generate ────────────────────────────────────────────────
function generate(themeIdRaw, seed, fieldCount) {
const themeId = resolveTheme(themeIdRaw);
const s = ((seed >>> 0) || 1) >>> 0;
const rng = mulberry32(s);
const pads = placePads(rng, fieldCount);
const path = spline(pads, 22);
const decor = makeDecor(rng, themeId, fieldCount);
return { theme: themeId, seed: s, fieldCount, pads, path, decor };
}
// ── Hilfen ──────────────────────────────────────────────────
function padR(W, H) { return Math.min(W, H) * 0.052; }
function padCenter(world, i, W, H) { const p = world.pads[i]; return { x: p.ux * W, y: p.uy * H }; }
function hitTestPad(world, x, y, W, H) {
const r = padR(W, H) * 1.25; // etwas größer für Touch
for (let i = 0; i < world.pads.length; i++) {
const p = world.pads[i], px = p.ux * W, py = p.uy * H;
if ((x - px) * (x - px) + (y - py) * (y - py) < r * r) return i;
}
return -1;
}
// ── Background-Dekoration zeichnen ──────────────────────────
function drawBgDecor(ctx, world, theme, state, t) {
const W = state.W, H = state.H, tt = t * 0.001;
if (theme.id === 'underwater') {
// Caustic-Lichtstrahlen
ctx.save(); ctx.globalCompositeOperation = 'lighter';
world.decor.rays.forEach(ry => {
const x = ((ry.x + Math.sin(tt * 0.2 + ry.ph) * 0.03) % 1 + 1) % 1 * W;
const g = ctx.createLinearGradient(x, 0, x + ry.w * 0.6, H);
g.addColorStop(0, 'rgba(125,211,252,0.10)');
g.addColorStop(1, 'rgba(125,211,252,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x + ry.w, 0); ctx.lineTo(x + ry.w * 1.2 + 60, H); ctx.lineTo(x + 40, H); ctx.closePath(); ctx.fill();
});
ctx.restore();
// Felsen
world.decor.rocks.forEach(r => {
const x = r.ux * W, w = r.w * W, h = r.h * H;
ctx.fillStyle = r.col > 0.5 ? '#0e7490' : '#155e75';
ctx.beginPath(); ctx.ellipse(x, H - h * 0.3, w, h * 0.7, 0, Math.PI, 2 * Math.PI); ctx.fill();
});
// Seetang
world.decor.grass.forEach(gr => {
const x = gr.ux * W, sway = Math.sin(tt * 1.4 + gr.ph) * 14;
ctx.strokeStyle = 'rgba(20,184,166,0.65)'; ctx.lineWidth = gr.sw; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(x, H);
ctx.quadraticCurveTo(x + sway * 0.3, H - gr.h * H * 0.5, x + sway, H - gr.h * H);
ctx.stroke();
});
// Blasen
world.decor.bubbles.forEach(b => {
const x = b.ux * W + Math.sin(tt + b.ph) * 12;
const y = ((b.uy - tt * b.sp) % 1 + 1) % 1 * H;
ctx.globalAlpha = 0.35 + 0.35 * Math.sin(tt * 2 + b.ph);
ctx.fillStyle = 'rgba(186,230,253,0.6)';
ctx.beginPath(); ctx.arc(x, y, b.r, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 0.5; ctx.stroke();
});
ctx.globalAlpha = 1;
}
else if (theme.id === 'space') {
// Nebel
world.decor.nebula.forEach(n => {
const x = n.ux * W, y = n.uy * H, r = n.r * Math.min(W, H);
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, `hsla(${n.hue},80%,60%,0.20)`);
g.addColorStop(1, `hsla(${n.hue},80%,40%,0)`);
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
});
// Sterne
world.decor.stars.forEach(s => {
const x = s.ux * W, y = s.uy * H;
ctx.globalAlpha = 0.4 + 0.5 * Math.sin(tt * s.sp + s.ph);
ctx.fillStyle = '#cdd6ff';
ctx.fillRect(x, y, s.r, s.r);
});
ctx.globalAlpha = 1;
// Planeten
world.decor.planets.forEach(p => {
const x = p.ux * W, y = p.uy * H, r = p.r * Math.min(W, H);
const g = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, 0, x, y, r);
g.addColorStop(0, `hsla(${p.hue},70%,65%,0.55)`);
g.addColorStop(1, `hsla(${p.hue},70%,30%,0.05)`);
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
});
}
else if (theme.id === 'haunted') {
// Mond
const mx = world.decor.moonX * W, my = H * 0.16, mr = Math.min(W, H) * 0.06;
ctx.fillStyle = 'rgba(220,230,200,0.18)';
ctx.beginPath(); ctx.arc(mx, my, mr * 2.2, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(220,230,200,0.92)';
ctx.beginPath(); ctx.arc(mx, my, mr, 0, Math.PI * 2); ctx.fill();
// Bäume
ctx.fillStyle = 'rgba(5,5,12,0.85)';
world.decor.trees.forEach(tr => {
const x = tr.ux * W, h = tr.h * H;
ctx.beginPath(); ctx.moveTo(x - 8, H);
ctx.quadraticCurveTo(x + tr.lean * 30, H - h * 0.55, x + tr.lean * 50, H - h);
ctx.lineTo(x + tr.lean * 50 + 4, H - h * 0.97);
ctx.lineTo(x + 8, H); ctx.closePath(); ctx.fill();
});
// Grabsteine
world.decor.graves.forEach(g => {
const x = g.ux * W, w = g.w * W, h = g.h * H;
ctx.fillStyle = 'rgba(60,60,75,0.85)'; ctx.fillRect(x - w / 2, H - h, w, h);
ctx.beginPath(); ctx.arc(x, H - h, w / 2, Math.PI, 2 * Math.PI); ctx.fill();
});
// Wisps
world.decor.wisps.forEach(b => {
const x = b.ux * W + Math.sin(tt + b.ph) * 30;
const y = ((b.uy - tt * b.sp) % 1 + 1) % 1 * H;
const a = 0.3 + 0.35 * Math.sin(tt * 2 + b.ph);
const g2 = ctx.createRadialGradient(x, y, 0, x, y, b.r * 3.5);
g2.addColorStop(0, `rgba(134,239,172,${a * 0.7})`);
g2.addColorStop(1, 'rgba(134,239,172,0)');
ctx.fillStyle = g2;
ctx.beginPath(); ctx.arc(x, y, b.r * 3.5, 0, Math.PI * 2); ctx.fill();
});
}
else if (theme.id === 'fantasy') {
// Doppelmond
ctx.fillStyle = 'rgba(240,171,252,0.85)';
ctx.beginPath(); ctx.arc(W * 0.78, H * 0.14, Math.min(W, H) * 0.045, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(240,171,252,0.6)';
ctx.beginPath(); ctx.arc(W * 0.87, H * 0.22, Math.min(W, H) * 0.025, 0, Math.PI * 2); ctx.fill();
// Wald
ctx.fillStyle = 'rgba(15,5,30,0.85)';
world.decor.trees.forEach(tr => {
const x = tr.ux * W, h = tr.h * H, w = h * 0.45;
ctx.beginPath(); ctx.moveTo(x - w / 2, H); ctx.lineTo(x, H - h); ctx.lineTo(x + w / 2, H); ctx.closePath(); ctx.fill();
});
// Glühwürmchen
world.decor.flies.forEach(b => {
const x = b.ux * W + Math.cos(tt * b.sp + b.ph) * 20;
const y = b.uy * H + Math.sin(tt * b.sp * 1.2 + b.ph) * 12;
const a = 0.4 + 0.55 * Math.sin(tt * 3 + b.ph);
const g = ctx.createRadialGradient(x, y, 0, x, y, b.r * 4);
g.addColorStop(0, `rgba(253,224,71,${a})`);
g.addColorStop(1, 'rgba(253,224,71,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(x, y, b.r * 4, 0, Math.PI * 2); ctx.fill();
});
// Runen
world.decor.runes.forEach(r => {
const x = r.ux * W, y = r.uy * H + Math.sin(tt + r.ph) * 8;
const rr = r.r * Math.min(W, H);
ctx.save(); ctx.translate(x, y); ctx.rotate(tt * 0.4 + r.ph);
ctx.strokeStyle = 'rgba(240,171,252,0.65)'; ctx.lineWidth = 1.5;
ctx.strokeRect(-rr, -rr, rr * 2, rr * 2);
ctx.beginPath(); ctx.moveTo(-rr * 0.5, 0); ctx.lineTo(rr * 0.5, 0); ctx.moveTo(0, -rr * 0.5); ctx.lineTo(0, rr * 0.5); ctx.stroke();
ctx.restore();
});
}
}
// ── Pfad zeichnen ───────────────────────────────────────────
function drawPath(ctx, world, theme, state, t) {
if (world.path.length < 2) return;
const W = state.W, H = state.H;
// Outer glow
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.strokeStyle = theme.pad.glow;
ctx.lineWidth = 16;
ctx.beginPath();
ctx.moveTo(world.path[0].ux * W, world.path[0].uy * H);
for (let i = 1; i < world.path.length; i++) ctx.lineTo(world.path[i].ux * W, world.path[i].uy * H);
ctx.stroke();
// Inner line
ctx.strokeStyle = theme.primary; ctx.lineWidth = 5;
ctx.beginPath();
ctx.moveTo(world.path[0].ux * W, world.path[0].uy * H);
for (let i = 1; i < world.path.length; i++) ctx.lineTo(world.path[i].ux * W, world.path[i].uy * H);
ctx.stroke();
// Animierte fließende Lichtpunkte
const tt = t * 0.0007;
for (let d = 0; d < 8; d++) {
const frac = ((tt + d / 8) % 1 + 1) % 1;
const idx = Math.min(world.path.length - 1, Math.floor(frac * (world.path.length - 1)));
const p = world.path[idx];
ctx.fillStyle = '#fff'; ctx.globalAlpha = 0.55;
ctx.beginPath(); ctx.arc(p.ux * W, p.uy * H, 2.6, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
// ── Pads zeichnen ───────────────────────────────────────────
function drawPads(ctx, world, theme, state, t) {
const W = state.W, H = state.H, R = padR(W, H), tt = t * 0.001;
const MG_ICONS = { snake: '🐍', flappy: '🐦', memory: '🃏', quiz: '❓', reaction: '⚡', basketball: '🏀', catch: '🍎', maze: '🌀', simon: '🔴', puzzle: '🧩', spotdiff: '🔍', typing: '⌨️' };
for (let i = 0; i < world.pads.length; i++) {
const p = world.pads[i], x = p.ux * W, y = p.uy * H;
const isStart = i === 0, isEnd = i === world.pads.length - 1;
const isActive = state.pos === i;
const isVisited = state.visited && state.visited.has(i);
const isHover = state.hover === i;
const gameId = state.fields ? state.fields[i] : null;
ctx.save();
// Glow ring
if (isActive) {
const pulse = 0.6 + 0.4 * Math.sin(tt * 4);
const g = ctx.createRadialGradient(x, y, R * 0.9, x, y, R * 1.9);
g.addColorStop(0, theme.pad.glow); g.addColorStop(1, 'transparent');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(x, y, R * 1.9 * pulse, 0, Math.PI * 2); ctx.fill();
} else if (isHover) {
const g = ctx.createRadialGradient(x, y, R * 0.9, x, y, R * 1.6);
g.addColorStop(0, 'rgba(255,255,255,0.25)'); g.addColorStop(1, 'transparent');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(x, y, R * 1.6, 0, Math.PI * 2); ctx.fill();
}
// Pad fill
const grd = ctx.createRadialGradient(x - R * 0.35, y - R * 0.35, 0, x, y, R);
if (isStart) { grd.addColorStop(0, '#6ee7b7'); grd.addColorStop(1, '#065f46'); }
else if (isEnd) { grd.addColorStop(0, '#fde68a'); grd.addColorStop(1, '#78350f'); }
else if (gameId) { grd.addColorStop(0, theme.primary); grd.addColorStop(1, theme.pad.edge); }
else { grd.addColorStop(0, theme.pad.fill); grd.addColorStop(1, theme.pad.edge); }
ctx.fillStyle = grd;
if (theme.id === 'haunted' && !isStart && !isEnd) {
// Grabstein-Form
ctx.beginPath();
ctx.moveTo(x - R * 0.85, y + R * 0.95);
ctx.lineTo(x - R * 0.85, y - R * 0.35);
ctx.quadraticCurveTo(x - R * 0.85, y - R * 1.0, x, y - R * 1.0);
ctx.quadraticCurveTo(x + R * 0.85, y - R * 1.0, x + R * 0.85, y - R * 0.35);
ctx.lineTo(x + R * 0.85, y + R * 0.95);
ctx.closePath(); ctx.fill();
} else {
ctx.beginPath(); ctx.arc(x, y, R, 0, Math.PI * 2); ctx.fill();
}
// Rim
ctx.strokeStyle = isActive ? '#fff' : 'rgba(255,255,255,0.35)';
ctx.lineWidth = isActive ? 2.5 : 1.2;
ctx.beginPath(); ctx.arc(x, y, R, 0, Math.PI * 2); ctx.stroke();
// Glas-Highlight
ctx.beginPath();
ctx.arc(x - R * 0.32, y - R * 0.32, R * 0.45, Math.PI * 1.05, Math.PI * 1.9);
ctx.lineWidth = 2.5; ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.stroke();
ctx.restore();
// Icon + Nummer
const icon = isStart ? '▶' : isEnd ? '🏁' : gameId ? (MG_ICONS[gameId] || '🎮') : '';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
if (icon) {
ctx.font = `bold ${Math.round(R * 0.9)}px ${gameId ? 'serif' : 'Nunito,sans-serif'}`;
ctx.fillStyle = '#fff';
ctx.fillText(icon, x, y);
} else if (!isStart && !isEnd) {
ctx.font = `bold ${Math.round(R * 0.7)}px Nunito,sans-serif`;
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.fillText(String(i), x, y);
}
// Visited check
if (isVisited && !isActive && i > 0) {
ctx.font = `bold ${Math.round(R * 0.5)}px Nunito,sans-serif`;
ctx.fillStyle = '#86efac';
ctx.fillText('✓', x + R * 0.78, y - R * 0.78);
}
// Story-Badge
const hasStory = state.storyItems && state.storyItems.some(s => s.fieldIndex === i);
if (hasStory) {
ctx.font = `${Math.round(R * 0.5)}px serif`;
ctx.fillText('📖', x - R * 0.8, y - R * 0.8);
}
}
}
function drawFigure(ctx, world, theme, state, t) {
const W = state.W, H = state.H, R = padR(W, H);
// Neue Multi-Figuren-API: state.figures [{emoji,x,y,active,dimmed}]
// Backwards-Compat: alte Felder state.figEmoji/figX/figY → in 1-Element-Array umwandeln
let figs = state.figures;
if (!figs || !figs.length) {
if (state.figEmoji == null || state.figX == null) return;
figs = [{ emoji: state.figEmoji, x: state.figX, y: state.figY, active: true, dimmed: false }];
}
// Same-Pad-Offset: wenn zwei Figuren ~auf gleicher Pixel-Position sind, leicht versetzen
const fsBase = R * 1.5;
const pts = figs.map(f => ({ ...f, dx: 0, dy: 0 }));
if (pts.length >= 2) {
for (let i = 0; i < pts.length; i++) {
for (let j = i+1; j < pts.length; j++) {
const dx = pts[j].x - pts[i].x, dy = pts[j].y - pts[i].y;
if (dx*dx + dy*dy < (R*0.6)*(R*0.6)) {
pts[i].dx -= R * 0.55; pts[j].dx += R * 0.55;
}
}
}
}
// Inaktive zuerst zeichnen (damit aktiver oben liegt)
pts.sort((a, b) => (a.active === b.active ? 0 : (a.active ? 1 : -1)));
pts.forEach(p => {
const fx = p.x + p.dx, fy = p.y + p.dy;
const active = !!p.active && !p.dimmed;
const fs = active ? fsBase : fsBase * 0.88;
const bounce = active ? Math.sin(t * 0.005) * 3 : 0;
// Aura
const g = ctx.createRadialGradient(fx, fy, 0, fx, fy, fs * 1.1);
g.addColorStop(0, p.dimmed ? 'rgba(100,100,100,0.25)' : theme.pad.glow); g.addColorStop(1, 'transparent');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(fx, fy, fs * 1.1, 0, Math.PI * 2); ctx.fill();
// Emoji
ctx.font = `${Math.round(fs)}px serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
if (active) { ctx.shadowColor = theme.primary; ctx.shadowBlur = 15; }
ctx.globalAlpha = p.dimmed ? 0.45 : 1;
ctx.fillText(p.emoji || '🎮', fx, fy - R * 0.05 + bounce);
ctx.shadowBlur = 0; ctx.globalAlpha = 1;
});
}
function drawHeader(ctx, state) {
if (!state.gameName) return;
const W = state.W, H = state.H;
ctx.textAlign = 'center';
ctx.font = `bold ${Math.round(Math.min(W, H) * 0.038)}px 'Fredoka One',cursive`;
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 14;
ctx.fillText(String(state.gameName).toUpperCase(), W / 2, H * 0.055);
if (state.devName) {
ctx.font = `${Math.round(Math.min(W, H) * 0.018)}px Nunito,sans-serif`;
ctx.fillStyle = 'rgba(255,255,255,0.65)';
ctx.fillText('von ' + state.devName, W / 2, H * 0.085);
}
ctx.shadowBlur = 0;
}
// ── Vordergrund: passierende Bewohner (Schwärme, Wal, Komet, Drache, Fledermaus, Blitz) ──
function drawFish(ctx, x, y, s, color) {
ctx.save(); ctx.translate(x, y); ctx.fillStyle = color;
ctx.beginPath(); ctx.ellipse(0, 0, s, s * 0.55, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.moveTo(-s * 0.7, 0); ctx.lineTo(-s * 1.5, -s * 0.6); ctx.lineTo(-s * 1.5, s * 0.6); ctx.closePath(); ctx.fill();
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(s * 0.4, -s * 0.1, s * 0.16, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(s * 0.42, -s * 0.1, s * 0.08, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawFishLayer(ctx, tt, W, H, color, count, yFrac, speed, size, bob) {
const cycle = W + 240;
for (let i = 0; i < count; i++) {
const x = ((tt * speed * 60 + i * (cycle / count)) % cycle) - 120;
const y = yFrac * H + Math.sin(tt * 1.4 + i * 0.7) * bob;
drawFish(ctx, x, y, size, color);
}
}
function drawWhale(ctx, W, H, c) {
const x = -260 + c * (W + 520);
const y = H * 0.42 + Math.sin(c * Math.PI * 2) * 22;
ctx.save(); ctx.translate(x, y); ctx.fillStyle = 'rgba(20,40,68,0.55)';
ctx.beginPath(); ctx.ellipse(0, 0, 110, 36, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.moveTo(-100, 0); ctx.lineTo(-140, -30); ctx.lineTo(-140, 30); ctx.closePath(); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.beginPath(); ctx.arc(55, -6, 4, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawShootingStar(ctx, W, H, c, idx) {
// Position bewegt sich von oben-rechts nach unten-links → Kopf führt, Schweif trailt
const sx = W * (1.15 - c * 1.4);
const sy = H * (0.08 + idx * 0.08 + c * 0.55);
const len = 70 + 50 * Math.sin(c * Math.PI);
ctx.save(); ctx.translate(sx, sy); ctx.rotate(-0.45 - idx * 0.12);
// Kopf (hell) am Ursprung = aktuelle Position, Schweif erstreckt sich in +x = entgegen Flugrichtung
const g = ctx.createLinearGradient(0, 0, len, 0);
g.addColorStop(0, 'rgba(255,255,255,0.95)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.strokeStyle = g; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(len, 0); ctx.stroke();
ctx.fillStyle = '#fff'; ctx.shadowColor = '#fff'; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0;
ctx.restore();
}
function drawUFO(ctx, W, H, c, theme) {
// Sanft auf-und-ab fliegend, von links nach rechts
const x = -60 + c * (W + 120);
const y = H * 0.28 + Math.sin(c * Math.PI * 5) * 18;
ctx.save(); ctx.translate(x, y);
// Beam unter dem UFO
const beamA = 0.10 + 0.10 * Math.sin(c * Math.PI * 12);
ctx.fillStyle = `rgba(160,255,255,${beamA})`;
ctx.beginPath(); ctx.moveTo(-6, 5); ctx.lineTo(-20, 38); ctx.lineTo(20, 38); ctx.lineTo(6, 5); ctx.closePath(); ctx.fill();
// Untertasse
const dish = ctx.createLinearGradient(0, -2, 0, 8);
dish.addColorStop(0, '#666c80'); dish.addColorStop(1, '#1f2433');
ctx.fillStyle = dish;
ctx.beginPath(); ctx.ellipse(0, 2, 26, 8, 0, 0, Math.PI * 2); ctx.fill();
// Glas-Dome
const dome = ctx.createRadialGradient(-3, -7, 1, 0, -3, 14);
dome.addColorStop(0, 'rgba(180,235,255,0.95)'); dome.addColorStop(1, 'rgba(80,150,200,0.7)');
ctx.fillStyle = dome;
ctx.beginPath(); ctx.ellipse(0, -3, 13, 9, 0, Math.PI, 2 * Math.PI); ctx.fill();
// Highlight im Dome
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.beginPath(); ctx.ellipse(-4, -6, 3, 2, 0, 0, Math.PI * 2); ctx.fill();
// Blinkende Lichter unten
for (let i = -2; i <= 2; i++) {
const on = Math.sin(c * Math.PI * 22 + i * 1.2) > 0;
ctx.fillStyle = on ? '#ffd166' : 'rgba(255,209,102,0.35)';
ctx.shadowColor = '#ffd166'; ctx.shadowBlur = on ? 6 : 0;
ctx.beginPath(); ctx.arc(i * 7, 8, 1.8, 0, Math.PI * 2); ctx.fill();
}
ctx.shadowBlur = 0;
ctx.restore();
}
function drawGhost(ctx, W, H, c, tt) {
// Schwebt in Wellenform durchs Bild
const x = -50 + c * (W + 100);
const y = H * 0.32 + Math.sin(c * Math.PI * 3) * 30;
ctx.save(); ctx.translate(x, y);
ctx.shadowColor = 'rgba(220,230,245,0.7)'; ctx.shadowBlur = 22;
ctx.fillStyle = 'rgba(230,235,245,0.88)';
// Körper: runde Oberseite, geschwungene Unterkante
ctx.beginPath();
ctx.arc(0, -8, 22, Math.PI, 2 * Math.PI);
ctx.lineTo(22, 16);
for (let i = 4; i >= -4; i--) {
const wx = (i / 4) * 22;
const wy = 16 + Math.sin(tt * 4 + i * 0.7 + c * 6) * 5;
ctx.lineTo(wx, wy);
}
ctx.lineTo(-22, 16);
ctx.closePath(); ctx.fill();
ctx.shadowBlur = 0;
// Augen
ctx.fillStyle = '#0c0c14';
ctx.beginPath(); ctx.ellipse(-7, -8, 3, 4, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.ellipse(7, -8, 3, 4, 0, 0, Math.PI * 2); ctx.fill();
// Mund (Oval)
ctx.beginPath(); ctx.ellipse(0, 2, 3.5, 5, 0, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawComet(ctx, W, H, c, theme) {
const x = W * (1.1 - c * 1.3), y = H * (0.12 + c * 0.5);
ctx.save(); ctx.translate(x, y);
const g = ctx.createLinearGradient(0, 0, 70, 0);
g.addColorStop(0, theme.primary); g.addColorStop(1, 'transparent');
ctx.fillStyle = g;
ctx.beginPath(); ctx.moveTo(0, -7); ctx.lineTo(70, 0); ctx.lineTo(0, 7); ctx.closePath(); ctx.fill();
ctx.shadowColor = theme.primary; ctx.shadowBlur = 14;
ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(0, 0, 5, 0, Math.PI * 2); ctx.fill();
ctx.shadowBlur = 0; ctx.restore();
}
function drawBat(ctx, W, H, c, idx, tt) {
const x = c * (W + 120) - 60;
const y = H * (0.18 + idx * 0.08) + Math.sin(tt * 5 + idx) * 14;
const flap = Math.sin(tt * 14) * 0.4 + 0.7;
ctx.save(); ctx.translate(x, y); ctx.fillStyle = 'rgba(10,10,18,0.92)';
ctx.beginPath(); ctx.ellipse(0, 0, 5, 4, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.moveTo(0, 0); ctx.quadraticCurveTo(-12, -9 * flap, -20, -2); ctx.quadraticCurveTo(-10, 4, 0, 2); ctx.fill();
ctx.beginPath(); ctx.moveTo(0, 0); ctx.quadraticCurveTo(12, -9 * flap, 20, -2); ctx.quadraticCurveTo(10, 4, 0, 2); ctx.fill();
ctx.restore();
}
function drawMist(ctx, W, H, tt) {
for (let i = 0; i < 3; i++) {
const x = ((tt * (10 + i * 6) + i * 240) % (W + 480)) - 240;
const y = H - 50 - i * 10;
const g = ctx.createRadialGradient(x, y, 0, x, y, 220);
g.addColorStop(0, 'rgba(200,200,225,0.15)'); g.addColorStop(1, 'transparent');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, 220, 0, Math.PI * 2); ctx.fill();
}
}
function drawLeaves(ctx, W, H, tt) {
for (let i = 0; i < 7; i++) {
const x = ((tt * 22 + i * 240) % (W + 200)) - 100;
const y = ((tt * 14 + i * 130) % (H + 80)) - 40;
const rot = tt * 1.4 + i;
ctx.save(); ctx.translate(x, y); ctx.rotate(rot);
ctx.fillStyle = `hsla(${280 + i * 8},65%,55%,0.55)`;
ctx.beginPath(); ctx.ellipse(0, 0, 7, 3.5, 0, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
}
function drawDragon(ctx, W, H, c, theme) {
const x = -140 + c * (W + 320);
const y = H * 0.16 + Math.sin(c * Math.PI * 3) * 30;
ctx.save(); ctx.translate(x, y);
ctx.shadowColor = theme.primary; ctx.shadowBlur = 18;
ctx.fillStyle = 'rgba(30,10,40,0.92)';
// S-Body
ctx.beginPath();
ctx.moveTo(0, 0); ctx.quadraticCurveTo(-30, -16, -60, 0);
ctx.quadraticCurveTo(-90, 16, -120, 4); ctx.lineTo(-120, 12);
ctx.quadraticCurveTo(-90, 24, -60, 14); ctx.quadraticCurveTo(-30, 10, 0, 14);
ctx.closePath(); ctx.fill();
// Kopf
ctx.beginPath(); ctx.ellipse(10, 8, 13, 9, 0, 0, Math.PI * 2); ctx.fill();
// Flügel
const wing = Math.sin(c * Math.PI * 16) * 14;
ctx.beginPath(); ctx.moveTo(-22, 6); ctx.quadraticCurveTo(-42, -22 - wing, -60, -10); ctx.lineTo(-30, 4); ctx.fill();
ctx.beginPath(); ctx.moveTo(-50, 6); ctx.quadraticCurveTo(-72, -28 - wing, -86, -8); ctx.lineTo(-58, 4); ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = theme.primary;
ctx.beginPath(); ctx.arc(17, 6, 2.4, 0, Math.PI * 2); ctx.fill();
ctx.restore();
}
function drawForeground(ctx, world, theme, state, t) {
const W = state.W, H = state.H, tt = t * 0.001;
if (theme.id === 'underwater') {
drawFishLayer(ctx, tt, W, H, 'rgba(252,211,77,0.85)', 6, 0.22, 0.5, 11, 7);
drawFishLayer(ctx, tt, W, H, 'rgba(125,211,252,0.7)', 5, 0.42, 0.7, 9, 5);
drawFishLayer(ctx, tt, W, H, 'rgba(74,222,128,0.7)', 7, 0.62, 0.4, 8, 6);
const wc = (tt % 22) / 14; if (wc <= 1) drawWhale(ctx, W, H, wc);
}
else if (theme.id === 'space') {
for (let i = 0; i < 3; i++) {
const c = ((tt + i * 4.3) % 11) / 3.5;
if (c <= 1) drawShootingStar(ctx, W, H, c, i);
}
const cc = (tt % 28) / 18; if (cc <= 1) drawComet(ctx, W, H, cc, theme);
// UFO sporadisch (alle ~36 s, fliegt ~14 s)
const uc = (tt % 36) / 14; if (uc <= 1) drawUFO(ctx, W, H, uc, theme);
}
else if (theme.id === 'haunted') {
// Blitz-Flicker: kurz, alle ~9 s
const flickerPhase = (tt * 0.5) % 9;
if (flickerPhase < 0.15) {
ctx.fillStyle = `rgba(200,210,230,${(0.15 - flickerPhase) * 1.8})`;
ctx.fillRect(0, 0, W, H);
}
drawMist(ctx, W, H, tt);
for (let i = 0; i < 2; i++) {
const c = ((tt + i * 6.5) % 12) / 7.5;
if (c <= 1) drawBat(ctx, W, H, c, i, tt);
}
// Gespenst sporadisch (alle ~28 s, schwebt ~16 s durchs Bild)
const gc = (tt % 28) / 16; if (gc <= 1) drawGhost(ctx, W, H, gc, tt);
}
else if (theme.id === 'fantasy') {
drawLeaves(ctx, W, H, tt);
// Drache sporadisch (alle ~38 s, fliegt ~17 s) — wirkt seltener/zufälliger
const dc = (tt % 38) / 17; if (dc <= 1) drawDragon(ctx, W, H, dc, theme);
}
}
// ── render() ────────────────────────────────────────────────
function render(ctx, world, state, t) {
const W = state.W, H = state.H;
const theme = THEMES[world.theme];
// Background gradient
const bg = ctx.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, theme.bgGrad[0]); bg.addColorStop(0.55, theme.bgGrad[1]); bg.addColorStop(1, theme.bgGrad[2]);
ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
// Atmosphäre
drawBgDecor(ctx, world, theme, state, t);
// Pfad + Pads + Vordergrund-Bewohner + Figur + Header
drawPath(ctx, world, theme, state, t);
drawPads(ctx, world, theme, state, t);
drawForeground(ctx, world, theme, state, t);
drawFigure(ctx, world, theme, state, t);
drawHeader(ctx, state);
}
return { THEMES, generate, render, hitTestPad, padCenter, resolveTheme, randomSeed, hashSeed };
})();