Files
geograsim/App/sims/tourismusregion/js/scene.js
T
Adminator 8047778a5b Tourismusregion: Komplett-Integration (zweite neue Sim, beta)
Wellenbasierte Routen-Sim (DefensegeograSimMac) nach dem Tourismustal-
Playbook integriert: Sim nach App/sims/tourismusregion (game.html,
Sprites 47MB->9.5MB, ohne Eigen-Backend/Editor/Dashboard/Keys),
Plattform-Hooks (GGS_LIVE_STATE mit Welle/Budget/Umwelt/Sterne/Lenkung,
Submit bei Partie-Ende mit fertigem 0-100-Score, GGS_TUTORIAL, Home-
Button, Musik leise). Wrapper + Modul-Detailseite. SQL: module_info
(beta, Karte), Glossar-Kernbegriff Besucherlenkung (+Bild, Leichte
Sprache) + 12 Zuordnungen, Lehrplan-Mapping auf tourismus-
raumentwicklung (primaer) + verkehrserziehung/systemisches-denken.
LEHRPLAN.md mit Schwerpunkt Besucherlenkung & Mobilitaet. Lehrer-
Backend: Benchmark (Sim-Score direkt), Live-Cockpit-Spalten,
needsHelp, Highlights.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:14:12 +02:00

967 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
// 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})`;
}
}