/** * 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 }; })();