// Diorama-Renderer: vorgerendertes 3D-Kartenbild (assets/maps/*) als Welt, // darüber die dynamische Ebene – Bauplatz-Slots, KI-Gebäudesprites (mit // Deluxe-Variante ab Stufe 4), KI-Besuchergruppen, Seilbahn, Bedien-Bögen, // Auslastung, schwebende Objekte, Saisontönung, Schneefall/Herbstlaub. // Ersetzt den früheren Iso-Kachel-Renderer komplett. import { BUILDINGS, GROUPS } from './data.js'; import { canPlace, TILE2PX } from './world.js'; import { audio } from './audio.js'; // Zeichenbreite der Gebäude-Sprites in Welt-Pixeln (Bildkoordinaten) const BUILDING_W = { wirtshaus: 62, cafe: 52, marktstand: 48, aussicht: 50, spielplatz: 58, wc: 40, badesteg: 60, eisdiele: 52, radstation: 46, tourismusinfo: 44, naturschutz: 52, museum: 64, parkplatz: 66, bushaltestelle: 48, souvenir: 50, skilift: 56, almhuette: 56, wellness: 66, }; const GROUP_W = { spaziergaenger: 46, wanderer: 48, familie: 56, senioren: 52, schulklasse: 62, busgruppe: 64, junge_gruppe: 56, luxuspaar: 46, }; // Kleidung der Walker: Jeans-/Khaki-Hosen, Oberteile bunt gemischt // (deterministisch je Gast + Figur, damit nichts flackert) const PANTS_COLORS = ['#3e5a74', '#54627a', '#8a7d5a', '#4a4f57', '#6b5a45', '#46586b']; const TOP_COLORS = ['#c9531f', '#2f6ea0', '#3f8f6b', '#a34a8e', '#e8892b', '#8c6239', '#5b6ee1', '#b0356b', '#4a7f56', '#c94f4f', '#6d8a3a', '#996fb8']; // Gruppen als Walker-Kompositionen: dx = entlang der Gehrichtung, dy = Tiefe const WALK_GROUPS = { spaziergaenger: [{ dx: -8, dy: 0 }, { dx: 7, dy: 3, shade: 18 }], wanderer: [{ dx: -9, dy: 0, prop: 'pole', pack: true }, { dx: 8, dy: 3, shade: 16, prop: 'pole', pack: true }], familie: [{ dx: -13, dy: 0 }, { dx: 1, dy: 3, shade: 16 }, { dx: 11, dy: 1, kid: true }, { dx: -3, dy: -3, kid: true, shade: 30 }], senioren: [{ dx: -10, dy: 0, prop: 'cane', lean: 0.06 }, { dx: 3, dy: 3, shade: 14, prop: 'hat' }, { dx: 13, dy: -1, shade: 26 }], schulklasse: [{ dx: -17, dy: 2 }, { dx: -5, dy: -2, kid: true }, { dx: 3, dy: 3, kid: true, shade: 20 }, { dx: 11, dy: -1, kid: true, shade: 35 }, { dx: 19, dy: 2, kid: true, shade: 50 }], busgruppe: [{ dx: -15, dy: 0, prop: 'umbrella' }, { dx: -3, dy: 3, shade: 14, prop: 'hat' }, { dx: 5, dy: -2, shade: 26 }, { dx: 14, dy: 2, shade: 38 }, { dx: 22, dy: -1, shade: 50 }], junge_gruppe: [{ dx: -12, dy: 0 }, { dx: -2, dy: 3, shade: 18 }, { dx: 8, dy: -2, shade: 34 }, { dx: 17, dy: 2, shade: 50 }], luxuspaar: [{ dx: -7, dy: 0, prop: 'hat', lean: -0.04 }, { dx: 7, dy: 3, shade: 16, prop: 'bag' }], }; const SEASON_TINT = { spring: null, summer: 'rgba(255,214,110,0.06)', autumn: 'rgba(205,145,55,0.12)', winter: 'rgba(228,238,248,0.42)', }; export class SceneRenderer { constructor(canvas, state, callbacks) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.state = state; this.cb = callbacks; // {onTap(hit)} this.cam = { x: 0, y: 0, scale: 1 }; this.pointers = new Map(); this.dragMoved = 0; this.pinchDist = 0; this.hover = null; this.buildType = null; this.selected = null; this.reduceAnim = false; this.debug = new URLSearchParams(location.search).has('debug'); this.time = 0; this.simTime = 0; this.flakes = []; this.leaves = []; this.floats = []; this.fxAnims = []; this.vel = { x: 0, y: 0 }; this.zoomTarget = null; // Kartenbild + Sprites laden (fehlt eines → einfacher Fallback) this.mapImg = new Image(); this.mapImg.src = state.map.image; this.sprites = {}; const load = (key, src) => { const img = new Image(); img.onload = () => { this.sprites[key] = img; }; img.src = src; }; for (const id of Object.keys(BUILDINGS)) { load(id, `assets/sprites/${id}.png`); load(`${id}_2`, `assets/sprites/${id}_2.png`); } for (const id of Object.keys(GROUP_W)) { load(`g_${id}`, `assets/sprites/groups/${id}.png`); for (let f = 0; f < 4; f++) load(`g_${id}_w${f}`, `assets/sprites/groups/${id}_w${f}.png`); } load('g_radler', 'assets/sprites/radler.png'); load('slot', 'assets/sprites/slot.png'); load('bus', 'assets/sprites/bus.png'); load('baustelle', 'assets/sprites/baustelle.png'); this._bindInput(); this.resize(); this.resetCamera(); } addFloat(text, color, x, y) { this.floats.push({ text, color, x, y, t0: this.simTime }); if (this.floats.length > 14) this.floats.shift(); } resize() { const dpr = window.devicePixelRatio || 1; this.dpr = dpr; this.canvas.width = innerWidth * dpr; this.canvas.height = innerHeight * dpr; } resetCamera() { const m = this.state.map; const fit = Math.min(innerWidth / m.w, innerHeight / m.h); this.cam.scale = fit * 1.06; this.cam.x = (innerWidth - m.w * this.cam.scale) / 2; this.cam.y = (innerHeight - m.h * this.cam.scale) / 2; this.zoomTarget = null; this.vel = { x: 0, y: 0 }; } // kleinster Zoom: Karte füllt den Bildschirm (nicht weiter herauszoomen) _minScale() { const m = this.state.map; return Math.max(innerWidth / m.w, innerHeight / m.h) * 0.98; } worldToScreen(x, y) { return { x: x * this.cam.scale + this.cam.x, y: y * this.cam.scale + this.cam.y }; } screenToWorld(sx, sy) { return { x: (sx - this.cam.x) / this.cam.scale, y: (sy - this.cam.y) / this.cam.scale }; } setBuildType(type) { this.buildType = type; } // ---------- Eingabe (Pan / Pinch / Tap) ---------- _bindInput() { const cv = this.canvas; cv.addEventListener('pointerdown', e => { cv.setPointerCapture(e.pointerId); this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); this.dragMoved = 0; this.vel = { x: 0, y: 0 }; this.zoomTarget = null; if (this.pointers.size === 2) { const [a, b] = [...this.pointers.values()]; this.pinchDist = Math.hypot(a.x - b.x, a.y - b.y); } }); cv.addEventListener('pointermove', e => { const p = this.pointers.get(e.pointerId); this.hover = this.screenToWorld(e.clientX, e.clientY); if (!p) return; const dx = e.clientX - p.x, dy = e.clientY - p.y; this.dragMoved += Math.abs(dx) + Math.abs(dy); if (this.pointers.size === 1) { this.cam.x += dx; this.cam.y += dy; this.vel = { x: dx, y: dy }; } p.x = e.clientX; p.y = e.clientY; if (this.pointers.size === 2) { const [a, b] = [...this.pointers.values()]; const d = Math.hypot(a.x - b.x, a.y - b.y); if (this.pinchDist > 0) this._zoomAround((a.x + b.x) / 2, (a.y + b.y) / 2, d / this.pinchDist); this.pinchDist = d; } }); cv.addEventListener('pointerup', e => { const wasTap = this.dragMoved < 10 && this.pointers.size === 1; this.pointers.delete(e.pointerId); this.pinchDist = 0; if (wasTap) { this.vel = { x: 0, y: 0 }; const w = this.screenToWorld(e.clientX, e.clientY); this.cb.onTap(this._hitTest(w.x, w.y)); if (this.debug) { // Mini-Pfad-Editor: jeder Tap sammelt einen Punkt; Konsole zeigt // das fertige points-Array für js/maps.js. Reset: __ttTrace = [] window.__ttTrace = window.__ttTrace || []; window.__ttTrace.push([Math.round(w.x), Math.round(w.y)]); console.log('points:', JSON.stringify(window.__ttTrace)); } } }); cv.addEventListener('pointercancel', e => this.pointers.delete(e.pointerId)); cv.addEventListener('wheel', e => { e.preventDefault(); const cur = this.zoomTarget?.scale ?? this.cam.scale; this.zoomTarget = { scale: Math.min(3, Math.max(this._minScale(), cur * (e.deltaY < 0 ? 1.15 : 0.87))), x: e.clientX, y: e.clientY, }; }, { passive: false }); window.addEventListener('resize', () => this.resize()); } _zoomAround(mx, my, factor) { const next = Math.min(3, Math.max(this._minScale(), this.cam.scale * factor)); const real = next / this.cam.scale; this.cam.x = mx - (mx - this.cam.x) * real; this.cam.y = my - (my - this.cam.y) * real; this.cam.scale = next; } _hitTest(x, y) { // Gebäude/Slots zuerst, dann Besucher let bestSlot = null, bestD = 48; for (const slot of this.state.slots) { const d = Math.hypot(slot.x - x, slot.y - y); if (d < bestD) { bestD = d; bestSlot = slot; } } if (bestSlot) { return bestSlot.building ? { kind: 'building', building: bestSlot.building, slot: bestSlot } : { kind: 'slot', slot: bestSlot }; } let bestV = null, bestVD = 46; for (const v of this.state.visitors) { const d = Math.hypot(v.x - x, v.y - y); if (d < bestVD) { bestVD = d; bestV = v; } } if (bestV) return { kind: 'visitor', visitor: bestV }; return { kind: 'none', x, y }; } // ---------- Zeichnen ---------- draw(now, dt, speed = 0) { this.time = now; const simDt = dt * speed; this.simTime += simDt; if (!this.pointers.size && (Math.abs(this.vel.x) > 0.3 || Math.abs(this.vel.y) > 0.3)) { this.cam.x += this.vel.x; this.cam.y += this.vel.y; this.vel.x *= 0.92; this.vel.y *= 0.92; } if (this.zoomTarget) { const zt = this.zoomTarget; const next = this.cam.scale + (zt.scale - this.cam.scale) * 0.22; this._zoomAround(zt.x, zt.y, next / this.cam.scale); if (Math.abs(this.cam.scale - zt.scale) < 0.003) this.zoomTarget = null; } const { ctx, cam, dpr } = this; const m = this.state.map; const season = this.state.seasonKey; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // weicher Studiohintergrund passend zum Diorama const grad = ctx.createLinearGradient(0, 0, 0, innerHeight); if (season === 'winter') { grad.addColorStop(0, '#dbe4ec'); grad.addColorStop(1, '#efeeea'); } else { grad.addColorStop(0, '#eee3cd'); grad.addColorStop(1, '#e7dcc4'); } ctx.fillStyle = grad; ctx.fillRect(0, 0, innerWidth, innerHeight); ctx.save(); ctx.translate(cam.x, cam.y); ctx.scale(cam.scale, cam.scale); if (this.mapImg.complete && this.mapImg.naturalWidth) { ctx.drawImage(this.mapImg, 0, 0, m.w, m.h); } else { ctx.fillStyle = '#a9cb8d'; ctx.fillRect(0, 0, m.w, m.h); } // Saisontönung über dem Artwork const tint = SEASON_TINT[season]; if (tint) { ctx.fillStyle = tint; ctx.fillRect(0, 0, m.w, m.h); } this._drawSlots(); this._drawLift(); if (!this.reduceAnim) this._drawServiceArcs(); // Tiefensortierung: Gebäude UND Besucher gemeinsam nach Fußpunkt – // wer weiter hinten (kleineres y) steht, wird zuerst gezeichnet const depth = [ ...this.state.buildings.map(b => ({ y: b.y + 10, draw: () => this._drawBuilding(b) })), ...this.state.visitors.map(v => ({ y: v.y + (v.mode === 'lift' ? 400 : 4), draw: () => this._drawVisitor(v, season === 'winter') })), ].sort((a, b) => a.y - b.y); for (const d of depth) d.draw(); this._drawFx(simDt); this._drawFloats(); this._drawRangePreview(); if (this.debug) this._drawDebug(); ctx.restore(); if (!this.reduceAnim && dt > 0) { if (season === 'winter') this._particles(dt, 'snow'); if (season === 'autumn') this._particles(dt, 'leaf'); } } // ---------- Slots ---------- _drawSlots() { const ctx = this.ctx; const zoneHint = { road: '🅿️', lake: '🏖️', hang: '🚡', berg: '⛰️' }; // Bauplatz-Plattform (PNG mit Alpha) unter JEDEM Slot – Gebäude stehen // sichtbar auf ihrem Platz const plate = this.sprites.slot; if (plate) { for (const slot of this.state.slots) { if (slot.blocked && !slot.building) continue; // Schutzgebiet: keine Plattform const w = 74; const h = w * (plate.naturalHeight / plate.naturalWidth); ctx.drawImage(plate, slot.x - w / 2, slot.y - h / 2, w, h); } } for (const slot of this.state.slots) { if (slot.building) continue; // gesperrter Platz (Naturschutz): kleine Wildpflanzen statt Bauplatz if (slot.blocked) { ctx.font = '15px sans-serif'; ctx.textAlign = 'center'; ctx.globalAlpha = 0.9; ctx.fillText('🌿', slot.x - 8, slot.y + 4); ctx.fillText('🌸', slot.x + 9, slot.y + 8); ctx.globalAlpha = 1; continue; } const building = this.buildType && this.buildType !== 'demolish'; let ring = 'rgba(253,251,244,0.65)', fill = 'rgba(47,62,70,0.10)'; if (building) { const ok = canPlace(this.state, this.buildType, slot.idx).ok; ring = ok ? 'rgba(79,160,110,0.95)' : 'rgba(150,150,150,0.5)'; fill = ok ? 'rgba(123,180,110,0.28)' : 'rgba(120,120,120,0.12)'; } const pulse = building ? 1 + Math.sin(this.simTime / 240) * 0.06 : 1; ctx.beginPath(); ctx.ellipse(slot.x, slot.y + 5, 30 * pulse, 18 * pulse, 0, 0, 7); ctx.fillStyle = fill; ctx.fill(); ctx.setLineDash([7, 6]); ctx.lineDashOffset = -((this.simTime / 120) % 13); ctx.strokeStyle = ring; ctx.lineWidth = 2.2; ctx.stroke(); ctx.setLineDash([]); ctx.lineWidth = 1; if (zoneHint[slot.zone]) { ctx.font = '17px sans-serif'; ctx.textAlign = 'center'; ctx.globalAlpha = 0.85; ctx.fillText(zoneHint[slot.zone], slot.x, slot.y + 12); ctx.globalAlpha = 1; } } } // ---------- Seilbahn ---------- _drawLift() { const lift = this.state.buildings.find(b => b.type === 'skilift'); const m = this.state.map; if (!lift || !m.lift) return; const ctx = this.ctx; const a = { x: lift.x, y: lift.y - 28 }; const b = { x: m.lift.topX, y: m.lift.topY - 16 }; // Stützen ctx.strokeStyle = '#5a5f63'; ctx.lineWidth = 4; for (let f = 0.25; f < 1; f += 0.25) { const px = a.x + (b.x - a.x) * f; const py = a.y + (b.y - a.y) * f; ctx.beginPath(); ctx.moveTo(px, py + 26); ctx.lineTo(px, py); ctx.stroke(); ctx.beginPath(); ctx.moveTo(px - 8, py); ctx.lineTo(px + 8, py); ctx.stroke(); } // Seil + Bergstation ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); ctx.fillStyle = '#5a5f63'; ctx.fillRect(b.x - 16, b.y - 6, 32, 18); ctx.lineWidth = 1; // Talstation (unten) — sonst wirkt der Lift-Anfang „transparent". // Solider Baukörper + Satteldach + angedeutetes Umlenkrad am Seil. ctx.fillStyle = '#7a5c3e'; // Wand (Holz) ctx.strokeStyle = '#3a2c1e'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.roundRect(a.x - 19, a.y - 2, 38, 24, 2); ctx.fill(); ctx.stroke(); ctx.fillStyle = '#8a6a48'; // Tor ctx.fillRect(a.x - 9, a.y + 8, 18, 14); ctx.fillStyle = '#5a4632'; // Satteldach ctx.beginPath(); ctx.moveTo(a.x - 24, a.y - 2); ctx.lineTo(a.x, a.y - 15); ctx.lineTo(a.x + 24, a.y - 2); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.strokeStyle = '#3a3f43'; ctx.lineWidth = 2; // Umlenkrad ctx.beginPath(); ctx.arc(a.x, a.y + 2, 5, 0, 7); ctx.stroke(); ctx.lineWidth = 1; // leere Gondeln pendeln for (let i = 0; i < 4; i++) { let f = ((this.simTime / 26000) + i / 4) % 1; if (f > 0.5) f = 1 - f; f *= 2; this._gondel(a.x + (b.x - a.x) * f, a.y + (b.y - a.y) * f); } } _gondel(gx, gy, riderColor = null) { const ctx = this.ctx; ctx.strokeStyle = '#5a5f63'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(gx, gy); ctx.lineTo(gx, gy + 9); ctx.stroke(); ctx.lineWidth = 1; ctx.fillStyle = riderColor ? '#e8892b' : '#d9cfb4'; ctx.strokeStyle = '#5a5f63'; ctx.beginPath(); ctx.roundRect(gx - 8, gy + 9, 16, 13, 3); ctx.fill(); ctx.stroke(); if (riderColor) { ctx.fillStyle = '#f0d4b8'; ctx.beginPath(); ctx.arc(gx, gy + 14, 3, 0, 7); ctx.fill(); } } // ---------- Bedien-Bögen (nur solange die Bedienung wirklich wirkt) ---------- _drawServiceArcs() { const ctx = this.ctx; const byId = new Map(this.state.visitors.map(v => [v.id, v])); for (const b of this.state.buildings) { if (!b.arcs?.length) continue; for (const vid of b.arcs) { const v = byId.get(vid); if (!v || v.mode === 'lift') continue; const mx = (b.x + v.x) / 2; const my = Math.min(b.y, v.y) - 40 - Math.hypot(v.x - b.x, v.y - b.y) * 0.08; ctx.setLineDash([]); ctx.strokeStyle = 'rgba(47,62,70,0.28)'; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(b.x, b.y - 40); ctx.quadraticCurveTo(mx, my, v.x, v.y - 24); ctx.stroke(); ctx.setLineDash([7, 9]); ctx.lineDashOffset = -((this.simTime / 70) % 16); ctx.strokeStyle = 'rgba(232,137,43,0.8)'; ctx.lineWidth = 2.2; ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = 'rgba(232,137,43,0.85)'; ctx.beginPath(); ctx.arc(v.x, v.y - 24, 2.6, 0, 7); ctx.fill(); } } ctx.lineWidth = 1; } // ---------- Gebäude ---------- _drawBuilding(b) { const ctx = this.ctx; { const def = BUILDINGS[b.type]; const isNew = b.construction > 0; const busy = isNew || b.upgrading; const deluxe = b.level >= 4 && this.sprites[`${b.type}_2`]; const img = deluxe || this.sprites[b.type]; const w = (BUILDING_W[b.type] || 52) * (1 + (b.level - 1) * 0.05); this._shadow(b.x, b.y + 6, w * 0.46, w * 0.17); const site = this.sprites.baustelle; const drawSprite = (sp, ww) => { const h = ww * (sp.naturalHeight / sp.naturalWidth); ctx.drawImage(sp, b.x - ww / 2, b.y + 14 - h, ww, h); }; if (isNew && site) { // Neubau: nur Baustelle (Gerüst), noch kein fertiges Gebäude drawSprite(site, w * 1.05); } else if (img) { // fertiges Gebäude (beim Ausbau zusätzlich Gerüst darüber) drawSprite(img, w); if (b.upgrading && site) { ctx.globalAlpha = 0.92; drawSprite(site, w * 1.08); ctx.globalAlpha = 1; } } else { ctx.font = `${Math.round(w * 0.5)}px sans-serif`; ctx.textAlign = 'center'; ctx.fillText(isNew ? '🏗️' : def.emoji, b.x, b.y); } // Fortschrittsbalken über der Baustelle if (busy) { const total = isNew ? (5 + def.cost[0] / 50) : (4 + def.cost[b.level] / 60); const left = isNew ? b.construction : b.upgrading.left; const p = Math.max(0, Math.min(1, 1 - left / total)); const by = b.y + 14 - w * (site ? 1.02 : 0.9) - 8; ctx.fillStyle = 'rgba(47,62,70,0.45)'; ctx.fillRect(b.x - 18, by, 36, 6); ctx.fillStyle = '#e8892b'; ctx.fillRect(b.x - 18, by, 36 * p, 6); ctx.strokeStyle = 'rgba(253,251,244,0.8)'; ctx.lineWidth = 1; ctx.strokeRect(b.x - 18, by, 36, 6); ctx.font = '9px sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = '#2f3e46'; ctx.fillText(isNew ? 'Bau' : 'Ausbau', b.x, by - 3); } // Ausbaustufen-Pins if (b.level > 1 && !busy) { for (let i = 0; i < b.level; i++) { ctx.fillStyle = '#e8892b'; ctx.beginPath(); ctx.arc(b.x - 14 + i * 7.5, b.y + 20, 2.8, 0, 7); ctx.fill(); ctx.strokeStyle = 'rgba(253,251,244,0.9)'; ctx.stroke(); } } this._utilArc(b); } } _utilArc(b) { if (!b.util || b.util <= 0) return; const ctx = this.ctx; const w = BUILDING_W[b.type] || 100; const cy = b.y - w * 0.9; const start = Math.PI * 0.75, span = Math.PI * 1.5; const full = b.util >= 1; ctx.strokeStyle = 'rgba(47,62,70,0.25)'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(b.x, cy, 13, start, start + span); ctx.stroke(); ctx.strokeStyle = full ? `rgba(192,57,43,${0.75 + 0.25 * Math.sin(this.simTime / 180)})` : b.util >= 0.7 ? '#e8892b' : '#3f8f5e'; ctx.beginPath(); ctx.arc(b.x, cy, 13, start, start + span * Math.min(1, b.util)); ctx.stroke(); ctx.lineWidth = 1; if (full) { ctx.font = 'bold 10px sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = '#c0392b'; ctx.fillText('voll', b.x, cy + 4); } } _shadow(x, y, rx, ry) { this.ctx.fillStyle = 'rgba(47,62,70,0.16)'; this.ctx.beginPath(); this.ctx.ellipse(x, y, rx, ry, 0, 0, 7); this.ctx.fill(); } // ---------- Skelett-Walker (prozedural animierte Figuren) ---------- // Eine „richtig" animierte Figur: Hüfte/Knie/Fuß pro Bein, gegenläufig // schwingende Arme mit Ellbogen, geneigter Oberkörper, nickender Kopf. // phase ist an die zurückgelegte Strecke gekoppelt → Schritte = Tempo. _walker(x, y, o) { const ctx = this.ctx; const H = o.h; const legL = H * 0.46, thigh = legL * 0.52, shin = legL * 0.55; const torsoL = H * 0.36; const headR = H * (o.kid ? 0.15 : 0.12); const t = o.phase; const bob = Math.abs(Math.cos(t)) * H * 0.04; const lean = 0.10 + (o.lean || 0); const skin = '#f0d4b8'; const pants = o.pants || this._shade(o.color, -55); ctx.save(); ctx.translate(x, y); if (o.flip) ctx.scale(-1, 1); const hip = { x: 0, y: -legL - bob }; const sh = { x: hip.x + Math.sin(lean) * torsoL, y: hip.y - Math.cos(lean) * torsoL }; const limb = (ox, oy, a1, l1, a2, l2, width, color) => { // zweigliedrige Extremität: Winkel absolut, 0 = senkrecht nach unten const mx = ox + Math.sin(a1) * l1, my = oy + Math.cos(a1) * l1; const ex = mx + Math.sin(a2) * l2, ey = my + Math.cos(a2) * l2; ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(mx, my); ctx.lineTo(ex, ey); ctx.stroke(); return { x: ex, y: ey }; }; const leg = (ph, color) => { const thighA = 0.58 * Math.sin(ph); const knee = Math.max(0, Math.sin(ph + 0.8)) * 0.95; // beugt beim Vorschwingen const foot = limb(hip.x, hip.y, thighA, thigh, thighA - knee, shin, H * 0.10, color); // Schuh ctx.fillStyle = this._shade(color, -25); ctx.beginPath(); ctx.ellipse(foot.x + H * 0.035, foot.y - H * 0.015, H * 0.055, H * 0.03, 0, 0, 7); ctx.fill(); }; const arm = (ph, color) => { const armA = -0.5 * Math.sin(ph); const elbow = 0.35 + 0.3 * Math.max(0, Math.sin(ph)); return limb(sh.x, sh.y + H * 0.03, armA, H * 0.16, armA + elbow, H * 0.15, H * 0.075, color); }; // hinteres Bein + hinterer Arm (dunkler) leg(t + Math.PI, this._shade(pants, -22)); arm(t + Math.PI, this._shade(o.color, -28)); // Rucksack (hinter dem Rücken) if (o.pack) { ctx.fillStyle = this._shade(o.color, -40); ctx.beginPath(); ctx.roundRect(hip.x - H * 0.24, sh.y + H * 0.02, H * 0.15, H * 0.24, H * 0.05); ctx.fill(); } // Oberkörper (Kapsel) + Kopf ctx.strokeStyle = o.color; ctx.lineWidth = H * 0.17; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(hip.x, hip.y); ctx.lineTo(sh.x, sh.y); ctx.stroke(); const nod = Math.sin(t * 2) * H * 0.012; const head = { x: sh.x + Math.sin(lean) * headR * 1.6, y: sh.y - headR * 1.15 + nod }; ctx.fillStyle = skin; ctx.beginPath(); ctx.arc(head.x, head.y, headR, 0, 7); ctx.fill(); // Haar/Mütze ctx.fillStyle = o.prop === 'hat' ? '#e8d9b0' : this._shade(o.color, 26); ctx.beginPath(); ctx.arc(head.x, head.y - headR * 0.25, headR * (o.prop === 'hat' ? 1.25 : 0.95), Math.PI, 0); ctx.fill(); // vorderes Bein + vorderer Arm leg(t, pants); const hand = arm(t, o.color); // Requisiten an der vorderen Hand ctx.lineCap = 'round'; if (o.prop === 'cane' || o.prop === 'pole') { ctx.strokeStyle = '#6b4f2f'; ctx.lineWidth = H * 0.04; ctx.beginPath(); ctx.moveTo(hand.x, hand.y); ctx.lineTo(hand.x + H * 0.10, o.prop === 'pole' ? H * 0.0 : 0); ctx.stroke(); } else if (o.prop === 'umbrella') { ctx.strokeStyle = '#5a5f63'; ctx.lineWidth = H * 0.04; ctx.beginPath(); ctx.moveTo(hand.x, hand.y); ctx.lineTo(hand.x + H * 0.05, sh.y - H * 0.42); ctx.stroke(); ctx.fillStyle = '#e8892b'; ctx.beginPath(); ctx.moveTo(hand.x - H * 0.16, sh.y - H * 0.40); ctx.quadraticCurveTo(hand.x + H * 0.05, sh.y - H * 0.62, hand.x + H * 0.26, sh.y - H * 0.40); ctx.closePath(); ctx.fill(); } else if (o.prop === 'bag') { ctx.strokeStyle = this._shade(o.color, -35); ctx.lineWidth = H * 0.03; ctx.beginPath(); ctx.moveTo(hand.x, hand.y); ctx.lineTo(hand.x, hand.y + H * 0.09); ctx.stroke(); ctx.fillStyle = '#8c2f4f'; ctx.beginPath(); ctx.roundRect(hand.x - H * 0.07, hand.y + H * 0.09, H * 0.14, H * 0.11, H * 0.03); ctx.fill(); } ctx.lineWidth = 1; ctx.restore(); } // ---------- Besucher ---------- _drawVisitor(v, winter) { const ctx = this.ctx; { const g = GROUPS[v.type]; if (v.mode === 'lift') { this._gondel(v.x, v.y - 34, g.color); this._satBar(v.x, v.y - 54, v, g); return; } // Busfahrt: hübscher Reisebus statt Figuren; beim Halt steigen sie aus if (v.travel === 'bus' || v.travel === 'busstop') { const bus = this.sprites.bus; const flip2 = v.headRight === false; this._shadow(v.x, v.y + 4, 34, 9); if (bus) { const bw = 78, bh = bw * (bus.naturalHeight / bus.naturalWidth); const rumble = v.travel === 'bus' ? Math.sin(this.simTime / 60) * 0.8 : 0; ctx.save(); ctx.translate(v.x, v.y + rumble); if (flip2) ctx.scale(-1, 1); ctx.drawImage(bus, -bw / 2, -bh + 4, bw, bh); ctx.restore(); } else { ctx.fillStyle = '#e8892b'; ctx.fillRect(v.x - 30, v.y - 22, 60, 22); } if (v.travel === 'busstop') { // Aussteigende neben der Tür const doorX = v.x + (flip2 ? -30 : 30); const out = Math.min(3, Math.max(1, Math.round((2.6 - v.busWait) / 0.8))); for (let k = 0; k < out; k++) { this._walker(doorX + k * 9, v.y + 4 + (k % 2) * 3, { h: 26, phase: this.simTime / 140 + k, color: TOP_COLORS[(v.id * 7 + k) % TOP_COLORS.length], pants: PANTS_COLORS[(v.id + k) % PANTS_COLORS.length], flip: flip2, }); } } this._satBar(v.x, v.y - 40, v, g); return; } const flip = v.headRight === false; const bob = Math.sin(this.simTime / 150 + v.wobble) * 1.2; if (v.type === 'radfahrer') { const img = this.sprites.g_radler; this._shadow(v.x, v.y + 3, 20, 6); if (img) { for (const off of [-10, 12]) { const roll = Math.sin(v.s / 5 + off) * 0.05; ctx.save(); ctx.translate(v.x + off, v.y + Math.sin(v.s / 4 + off) * 1.2); ctx.rotate(flip ? -roll : roll); if (flip) ctx.scale(-1, 1); ctx.drawImage(img, -16, -30, 32, 32); ctx.restore(); } } else { this._dotGroup(v, g, 2); } } else { // Gruppe aus animierten Skelett-Figuren (Schritte = Strecke) const comp = WALK_GROUPS[v.type] || [{ dx: 0, dy: 0 }]; this._shadow(v.x, v.y + 3, 22, 7); const dir = flip ? -1 : 1; const sorted2 = [...comp].sort((a, b) => (a.dy || 0) - (b.dy || 0)); const paused = v.stopped; for (let i = 0; i < sorted2.length; i++) { const mMember = sorted2[i]; const H = mMember.kid ? 19 : 30; const seed = v.id * 7 + i * 3; const ph = paused ? Math.sin(this.simTime / 500 + i) * 0.25 // verweilen: leicht umsehen : v.s / (mMember.kid ? 2.6 : 3.6) + i * 1.15 + v.wobble; this._walker(v.x + (mMember.dx || 0) * dir, v.y + (mMember.dy || 0), { h: H, phase: ph, color: TOP_COLORS[seed % TOP_COLORS.length], pants: PANTS_COLORS[(seed + 2) % PANTS_COLORS.length], flip, kid: mMember.kid, prop: mMember.prop, pack: mMember.pack, lean: mMember.lean || 0, }); } if (winter) { ctx.fillStyle = 'rgba(255,255,255,0.18)'; ctx.beginPath(); ctx.ellipse(v.x, v.y - 18, 20, 5, 0, 0, 7); ctx.fill(); } if (paused) { ctx.font = '11px sans-serif'; ctx.textAlign = 'center'; ctx.globalAlpha = 0.85; ctx.fillText('⏸️', v.x + 16, v.y - 30); ctx.globalAlpha = 1; } } this._satBar(v.x, v.y - 46, v, g); } } // Fallback ohne Sprite: kleine Figuren-Punkte in Gruppenfarbe _dotGroup(v, g, n) { const ctx = this.ctx; for (let i = 0; i < n; i++) { const ox = v.x + ((i % 3) - 1) * 9; const oy = v.y + Math.floor(i / 3) * 7 - 3; ctx.fillStyle = i === 0 ? g.color : this._shade(g.color, i * 12); ctx.beginPath(); ctx.ellipse(ox, oy - 8, 4, 7, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#f0d4b8'; ctx.beginPath(); ctx.arc(ox, oy - 17, 3.4, 0, 7); ctx.fill(); } } _satBar(x, y, v, g) { const ctx = this.ctx; const ratio = Math.max(0, Math.min(1, v.satisfaction / g.satTarget)); ctx.fillStyle = 'rgba(47,62,70,0.4)'; ctx.fillRect(x - 13, y, 26, 4); ctx.fillStyle = ratio >= 0.6 ? '#3f8f5e' : ratio >= 0.3 ? '#e8892b' : '#c0392b'; ctx.fillRect(x - 13, y, 26 * ratio, 4); ctx.font = '12px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(g.emoji, x, y - 4); } // ---------- schwebende Objekte ---------- _drawFx(simDt) { const ctx = this.ctx; while (this.state.fx.length) { const fx = this.state.fx.shift(); if (fx.pop) { // Abreise: Sterne-Pop am Ausgang (grün = glücklich, rot = vergrault) const good = fx.stars >= 4; this.addFloat('★'.repeat(fx.stars), good ? '#3f8f5e' : fx.stars >= 3 ? '#e8892b' : '#c0392b', fx.fx, fx.fy - 10); if (this.simTime - (this._lastPop || 0) > 350) { this._lastPop = this.simTime; audio.pling(good ? 1.4 : 0.75); } continue; } this.fxAnims.push({ ...fx, t: 0 }); if (this.fxAnims.length > 90) this.fxAnims.shift(); // Dienstleistungs-Geräusch (Apfelbiss, Kaffee, Werkzeug …), gedrosselt if (this.simTime - (this._lastPling || 0) > 300) { this._lastPling = this.simTime; audio.fx(fx.sound || 'pling'); } } const byId = new Map(this.state.visitors.map(v => [v.id, v])); const DUR = 1300; this.fxAnims = this.fxAnims.filter(a => a.t < DUR); for (const a of this.fxAnims) { a.t += simDt; // Ankunft: Geldbetrag + Zufriedenheits-Herz beim Gast aufpoppen if (!a.done && a.t >= DUR) { a.done = true; const tx2 = a.tx ?? a.fx, ty2 = a.ty ?? a.fy - 60; if (a.amount > 0) this.addFloat(`+${a.amount} €`, '#3f8f5e', tx2 + 14, ty2 + 24); this.addFloat('💛', '#e8892b', tx2 - 12, ty2 + 18); } const p = Math.min(1, a.t / DUR); const ease = p * p * (3 - 2 * p); const v = byId.get(a.vid); if (v) { a.tx = v.x; a.ty = v.y - 30; } const tx = a.tx ?? a.fx, ty = a.ty ?? a.fy - 60; const x = a.fx + (tx - a.fx) * ease; const y = (a.fy - 50) + (ty - (a.fy - 50)) * ease - Math.sin(p * Math.PI) * 26; // Objekt bleibt voll sichtbar, nur die letzten ~12 % blenden kurz aus ctx.globalAlpha = p > 0.88 ? (1 - p) / 0.12 : 1; const sz = 20 + Math.sin(p * Math.PI) * 6; // kleiner Zoom-Effekt beim Flug ctx.font = `${sz}px sans-serif`; ctx.textAlign = 'center'; // weiße Kontur für Kontrast auf jedem Untergrund ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(253,251,244,0.85)'; ctx.strokeText(a.emoji, x, y); ctx.fillStyle = '#2f3e46'; ctx.fillText(a.emoji, x, y); ctx.globalAlpha = 1; ctx.lineWidth = 1; } } _drawFloats() { const ctx = this.ctx; const DUR = 1600; this.floats = this.floats.filter(f => this.simTime - f.t0 < DUR); for (const f of this.floats) { const age = (this.simTime - f.t0) / DUR; ctx.globalAlpha = 1 - age; ctx.font = 'bold 18px -apple-system, sans-serif'; ctx.textAlign = 'center'; ctx.lineWidth = 4; ctx.strokeStyle = 'rgba(253,251,244,0.9)'; ctx.strokeText(f.text, f.x, f.y - 60 - age * 34); ctx.fillStyle = f.color; ctx.fillText(f.text, f.x, f.y - 60 - age * 34); ctx.globalAlpha = 1; ctx.lineWidth = 1; } } // ---------- Reichweiten-Vorschau ---------- _drawRangePreview() { const ctx = this.ctx; const drawRange = (x, y, rTiles, stroke, fill) => { ctx.beginPath(); ctx.ellipse(x, y, rTiles * TILE2PX, rTiles * TILE2PX * 0.72, 0, 0, 7); ctx.fillStyle = fill; ctx.fill(); ctx.setLineDash([8, 7]); ctx.strokeStyle = stroke; ctx.lineWidth = 2; ctx.stroke(); ctx.setLineDash([]); ctx.lineWidth = 1; }; if (this.selected && this.state.buildings.includes(this.selected)) { const b = this.selected; drawRange(b.x, b.y, BUILDINGS[b.type].range[b.level - 1], 'rgba(47,110,160,0.6)', 'rgba(47,110,160,0.10)'); } if (this.buildType && this.buildType !== 'demolish' && this.hover) { const def = BUILDINGS[this.buildType]; // Vorschau am nächsten freien Slot unter dem Zeiger let best = null, bestD = 70; for (const slot of this.state.slots) { if (slot.building) continue; const d = Math.hypot(slot.x - this.hover.x, slot.y - this.hover.y); if (d < bestD) { bestD = d; best = slot; } } if (best) { const ok = canPlace(this.state, this.buildType, best.idx).ok; drawRange(best.x, best.y, def.range[0], ok ? 'rgba(79,127,94,0.8)' : 'rgba(200,60,50,0.6)', ok ? 'rgba(123,160,91,0.14)' : 'rgba(200,60,50,0.08)'); } } } // ---------- Partikel (Screen-Space) ---------- _particles(dt, kind) { const ctx = this.ctx; const arr = kind === 'snow' ? this.flakes : this.leaves; const max = kind === 'snow' ? 70 : 24; while (arr.length < max) { arr.push({ x: Math.random() * innerWidth, y: Math.random() * innerHeight, r: 1 + Math.random() * 2.2, v: 18 + Math.random() * 40, ph: Math.random() * 7, }); } for (const f of arr) { f.y += f.v * dt / 1000; f.x += Math.sin(this.time / 900 + f.ph) * 0.4; if (f.y > innerHeight) { f.y = -4; f.x = Math.random() * innerWidth; } if (kind === 'snow') { ctx.fillStyle = 'rgba(255,255,255,0.8)'; ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, 7); ctx.fill(); } else { ctx.fillStyle = f.ph % 2 > 1 ? 'rgba(208,138,62,0.7)' : 'rgba(180,150,60,0.6)'; ctx.beginPath(); ctx.ellipse(f.x, f.y, f.r + 1, f.r * 0.6, f.ph, 0, 7); ctx.fill(); } } } // ---------- Debug: Routen + Slot-Indizes (?debug) ---------- _drawDebug() { const ctx = this.ctx; for (const [id, r] of Object.entries(this.state.map.routes)) { ctx.strokeStyle = 'rgba(200,30,30,0.85)'; ctx.lineWidth = 3; ctx.beginPath(); r.points.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); ctx.stroke(); ctx.fillStyle = '#c01e1e'; for (const [x, y] of r.points) { ctx.beginPath(); ctx.arc(x, y, 5, 0, 7); ctx.fill(); } ctx.font = 'bold 20px sans-serif'; ctx.fillText(id, r.points[0][0], r.points[0][1] - 14); } ctx.font = 'bold 16px sans-serif'; for (const slot of this.state.slots) { ctx.fillStyle = '#1e50c0'; ctx.fillText(`${slot.idx}·${slot.zone[0]}`, slot.x, slot.y - 28); } // Editor-Punkte (getappt im Debug-Modus) const tr = window.__ttTrace || []; if (tr.length) { ctx.strokeStyle = '#0a9648'; ctx.lineWidth = 3; ctx.beginPath(); tr.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); ctx.stroke(); ctx.fillStyle = '#0a9648'; for (const [x, y] of tr) { ctx.beginPath(); ctx.arc(x, y, 6, 0, 7); ctx.fill(); } } ctx.lineWidth = 1; } _shade(hex, amt) { if (hex.startsWith('rgb')) return hex; const n = parseInt(hex.slice(1), 16); const r = Math.max(0, Math.min(255, (n >> 16) + amt)); const g = Math.max(0, Math.min(255, ((n >> 8) & 255) + amt)); const b = Math.max(0, Math.min(255, (n & 255) + amt)); return `rgb(${r},${g},${b})`; } }