// Isometrischer Canvas-Renderer inkl. Touch-/Maus-Eingabe (Pan, Pinch-Zoom, // Tap). Liebevolle Vektor-Grafik im Nordic-Flat-Stil: echte Iso-Häuser mit // Giebel und Dachschrägen, Kaminrauch, Schatten, Grasbüschel, Figuren mit // Beinchen. Himmel mit Bergkulisse und Wolken, Schneefall im Winter. import { MAP_COLS, MAP_ROWS, ROAD_ROW, PISTE_LEN, CORE, canPlace, UPGRADABLE, liftSkiable, seasonFactor } from './model.js'; import { BUILDINGS } from './data.js'; const TILE_W = 80; const TILE_H = 40; const TW2 = TILE_W / 2; const TH2 = TILE_H / 2; const ELEV_Y = 15; const TERRAIN_COLORS = { meadow: '#a9c98b', field: '#dccf9e', alm: '#c2d39a', forest: '#4f7f5e', rock: '#b8b2a6', water: '#7fb2c9', village: '#cfc4a5', }; // KI-generierte Sprites (Nordic-Flat, transparent). Fehlt eine Datei, // greift automatisch die Vektor-Zeichnung als Fallback. // w = Zeichenbreite in px, bottom = Abstand Unterkante Motiv → Kachelmitte // Werte aus Thomas' Editor-Export 2026-07-12 (erste Justier-Runde) — // weitere Feinjustagen kommen über editor.html → Export → hier einbacken. export const SPRITE_DEFS = { restaurant: { w: 69, bottom: 17 }, hotel: { w: 88, bottom: 10 }, hotel2: { w: 104, bottom: 11 }, staffhouse: { w: 47, bottom: 17 }, house: { w: 54, bottom: 8 }, church: { w: 66, bottom: 9 }, access: { w: 38, bottom: 15 }, rescue: { w: 51, bottom: 19 }, snow: { w: 44, bottom: 7 }, bikepark: { w: 50, bottom: 19 }, // Basisstation; Strecke via _drawBikeParks // Megabauwerke (2×2): eigene Motive, zentriert über den 4 Kacheln mega_restaurant: { w: 122, bottom: 41 }, mega_hotel: { w: 102, bottom: 30 }, mega_staffhouse: { w: 101, bottom: 32 }, mega_snow: { w: 92, bottom: 30 }, mega_badesee: { w: 84, bottom: 31 }, mega_ropes: { w: 116, bottom: 22 }, mega_playground: { w: 90, bottom: 20 }, mega_bikerental: { w: 75, bottom: 28 }, ropes: { w: 53, bottom: 13 }, bikerental: { w: 44, bottom: 19 }, platform: { w: 49, bottom: 14 }, playground: { w: 41, bottom: 17 }, // Hinweis: Bus bleibt bewusst Vektor (dynamische Fahrtrichtung). trail: { w: 41, bottom: 16 }, badesee: { w: 47, bottom: 22 }, bus: { w: 46, bottom: 3 }, fir: { w: 42, bottom: 6 }, fir_snow: { w: 42, bottom: 6 }, broadleaf: { w: 44, bottom: 6 }, }; // Solarpanel-Position (Öko-Umbau) je Sprite-Key, optional pro Ausbaustufe // ("@L"). Fallback default/defaultBig. dx/dy relativ zum // Gebäude-Ankerpunkt, s = Skalierung. Werte: Editor-Export 2026-07-12. export const SOLAR_POS = { default: { dx: -3, dy: -27, s: 1 }, defaultBig: { dx: -5, dy: -36, s: 1.6 }, playground: { dx: -3, dy: 7, s: 1.5 }, 'mega_playground': { dx: 34, dy: -44, s: 1.15 }, bikepark: { dx: -6, dy: -45, s: 1 }, 'lift@L1': { dx: 3, dy: -15, s: 1.2 }, 'lift@L2': { dx: 1, dy: -14, s: 1 }, 'lift@L3': { dx: 4, dy: -14, s: 1.3 }, 'lift@L4': { dx: 3, dy: -13, s: 1.55 }, 'gondola@L1': { dx: 4, dy: -14, s: 1 }, 'gondola@L2': { dx: 2, dy: -13, s: 1 }, 'gondola@L3': { dx: 2, dy: -13, s: 1 }, 'gondola@L4': { dx: 0, dy: -14, s: 1.2 }, 'mega_hotel@L1': { dx: -13, dy: -42, s: 2.15 }, 'mega_hotel@L2': { dx: -14, dy: -41, s: 1.95 }, 'mega_hotel@L3': { dx: -14, dy: -41, s: 2 }, 'mega_hotel@L4': { dx: -15, dy: -42, s: 2 }, 'hotel@L1': { dx: -4, dy: -48, s: 1.3 }, 'hotel2@L2': { dx: -8, dy: -53, s: 1.55 }, 'hotel2@L3': { dx: -10, dy: -62, s: 1.55 }, 'hotel2@L4': { dx: -11, dy: -62, s: 1.55 }, 'mega_restaurant@L1': { dx: 22, dy: -22, s: 1.6 }, 'mega_restaurant@L2': { dx: 21, dy: -22, s: 1.6 }, 'mega_restaurant@L3': { dx: 20, dy: -22, s: 1.6 }, 'mega_restaurant@L4': { dx: 21, dy: -25, s: 1.6 }, 'restaurant@L1': { dx: -11, dy: -19, s: 1.35 }, 'restaurant@L2': { dx: -12, dy: -24, s: 1.45 }, 'restaurant@L3': { dx: -14, dy: -28, s: 1.65 }, 'restaurant@L4': { dx: -13, dy: -28, s: 1.7 }, 'snow@L1': { dx: 8, dy: 4, s: 1 }, 'snow@L2': { dx: 8, dy: 5, s: 1 }, 'snow@L3': { dx: 9, dy: 4, s: 1 }, 'snow@L4': { dx: 8, dy: 4, s: 1 }, 'mega_snow@L1': { dx: -1, dy: -32, s: 1.6 }, 'mega_snow@L2': { dx: -1, dy: -33, s: 1.6 }, 'mega_snow@L3': { dx: 0, dy: -33, s: 1.6 }, 'mega_snow@L4': { dx: 0, dy: -32, s: 1.6 }, 'rescue@L1': { dx: -3, dy: -14, s: 1 }, 'rescue@L2': { dx: -3, dy: -16, s: 1 }, 'rescue@L3': { dx: -5, dy: -21, s: 1 }, 'rescue@L4': { dx: -4, dy: -21, s: 1 }, 'access@L1': { dx: 0, dy: -8, s: 1 }, 'access@L2': { dx: -1, dy: -11, s: 1 }, 'access@L3': { dx: -2, dy: -13, s: 1 }, 'access@L4': { dx: -2, dy: -14, s: 1 }, 'staffhouse@L1': { dx: 2, dy: -16, s: 1 }, 'staffhouse@L2': { dx: 0, dy: -21, s: 1 }, 'staffhouse@L3': { dx: 0, dy: -23, s: 1 }, 'staffhouse@L4': { dx: 0, dy: -23, s: 1 }, 'mega_staffhouse@L1': { dx: -3, dy: -37, s: 1.6 }, 'mega_staffhouse@L2': { dx: -3, dy: -43, s: 1.6 }, 'mega_staffhouse@L3': { dx: -6, dy: -53, s: 1.6 }, 'mega_staffhouse@L4': { dx: -7, dy: -60, s: 1.6 }, 'mega_badesee@L1': { dx: -3, dy: -34, s: 1.6 }, 'mega_badesee@L2': { dx: 52, dy: -6, s: 1.6 }, 'mega_badesee@L3': { dx: 57, dy: -5, s: 1.6 }, 'mega_badesee@L4': { dx: 57, dy: -4, s: 1.6 }, 'badesee@L1': { dx: -28, dy: -2, s: 1 }, 'badesee@L2': { dx: -28, dy: -3, s: 1 }, 'badesee@L3': { dx: -27, dy: -3, s: 1 }, 'badesee@L4': { dx: -27, dy: -3, s: 1 }, 'sled@L1': { dx: -3, dy: -16, s: 1 }, 'sled@L2': { dx: -4, dy: -16, s: 1 }, 'sled@L3': { dx: -3, dy: -16, s: 1 }, 'sled@L4': { dx: -4, dy: -16, s: 1 }, 'trail@L1': { dx: 24, dy: -2, s: 1 }, 'trail@L2': { dx: 25, dy: -2, s: 1 }, 'trail@L3': { dx: 25, dy: -3, s: 1 }, 'trail@L4': { dx: 26, dy: -2, s: 1 }, 'bikepark@L1': { dx: -5, dy: -15, s: 1 }, 'bikepark@L2': { dx: -3, dy: -19, s: 1 }, 'bikepark@L3': { dx: -5, dy: -23, s: 1 }, 'bikepark@L4': { dx: -5, dy: -23, s: 1 }, 'ropes@L1': { dx: 27, dy: -2, s: 1 }, 'ropes@L2': { dx: 26, dy: -2, s: 1 }, 'ropes@L3': { dx: 25, dy: -4, s: 1 }, 'ropes@L4': { dx: 26, dy: -3, s: 1 }, 'bikerental@L1': { dx: -2, dy: -17, s: 1 }, 'bikerental@L2': { dx: -2, dy: -17, s: 1 }, 'bikerental@L3': { dx: -2, dy: -19, s: 1 }, 'bikerental@L4': { dx: -2, dy: -20, s: 1 }, 'mega_bikerental@L1': { dx: 1, dy: -21, s: 1.6 }, 'mega_bikerental@L2': { dx: 0, dy: -24, s: 1.6 }, 'mega_bikerental@L3': { dx: -1, dy: -25, s: 1.6 }, 'mega_bikerental@L4': { dx: -2, dy: -28, s: 1.6 }, 'platform@L1': { dx: 8, dy: -4, s: 1 }, 'platform@L2': { dx: 9, dy: -7, s: 1 }, 'platform@L3': { dx: 8, dy: -8, s: 1 }, 'platform@L4': { dx: 9, dy: -8, s: 1 }, 'playground@L1': { dx: -25, dy: -5, s: 1.5 }, 'playground@L2': { dx: -24, dy: -5, s: 1.15 }, 'playground@L3': { dx: -26, dy: -5, s: 1.1 }, 'playground@L4': { dx: -28, dy: -3, s: 0.85 }, 'mega_playground@L1': { dx: 28, dy: -29, s: 1.15 }, 'mega_playground@L2': { dx: 33, dy: -31, s: 1.15 }, 'mega_playground@L3': { dx: 36, dy: -32, s: 1.15 }, 'mega_playground@L4': { dx: 39, dy: -34, s: 1.15 }, trail: { dx: 18, dy: -4, s: 1 }, // Öko-Marker Panoramaweg = Mülleimer, rechte Ecke ropes: { dx: 18, dy: -4, s: 1 }, // Öko-Marker Hochseilgarten = Mülleimer }; // Feinjustierung aus dem Editor (editor.html): überschreibt SPRITE_DEFS- // und SOLAR_POS-Werte lokal. Finale Werte werden in den Code übernommen. try { const tune = JSON.parse(localStorage.getItem('tt_sprite_tuning') || 'null'); if (tune?.sprites) { for (const [k, v] of Object.entries(tune.sprites)) { SPRITE_DEFS[k] = Object.assign(SPRITE_DEFS[k] || {}, v); } } if (tune?.solar) Object.assign(SOLAR_POS, tune.solar); } catch { /* kaputte Overrides ignorieren */ } export class IsoRenderer { constructor(canvas, state, callbacks) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.state = state; this.cb = callbacks; // {onTileTap(c,r)} this.cam = { x: 0, y: 0, scale: 1 }; this.pointers = new Map(); this.dragMoved = 0; this.pinchDist = 0; this.hoverTile = null; this.buildType = null; // aktiver Platzierungs-/Abrissmodus this.guests = []; // sichtbare Gäste-Figuren this.staff = []; // sichtbare Personal-Figuren (pendeln) this.time = 0; // echte Zeit (Wolken, Schneefall) this.simTime = 0; // Spielzeit – skaliert mit der Zeitskala this.simSpeed = 0; this.flakes = []; // Schneeflocken (Screen-Space) this.tracks = []; // Skispuren im Schnee {x1,y1,x2,y2,t} this.heli = null; // aktiver Rettungshubschrauber {x,y,phase,victim,home} this.floats = []; // aufsteigende Texte (z.B. Tageseinnahmen) this.vel = { x: 0, y: 0 }; // Kamera-Schwung nach dem Ziehen this.zoomTarget = null; // sanfter Mausrad-Zoom this.clouds = [ { x: 0.15, y: 70, w: 130, s: 6, a: 0.75 }, { x: 0.45, y: 40, w: 190, s: 4, a: 0.85 }, { x: 0.78, y: 95, w: 110, s: 8, a: 0.6 }, { x: 0.3, y: 130, w: 80, s: 11, a: 0.4 }, { x: 0.9, y: 55, w: 150, s: 7, a: 0.7 }, ]; this.birds = [ { y: 90, s: 26, ph: 0 }, { y: 130, s: 21, ph: 2.1 }, { y: 60, s: 31, ph: 4.4 }, ]; // Sprites laden (Fallback: Vektor-Zeichnung) this.sprites = {}; for (const [name, def] of Object.entries(SPRITE_DEFS)) { const img = new Image(); img.onload = () => { this.sprites[name] = { img, ...def }; }; img.src = `assets/sprites/${name}.png`; } this._bindInput(); this.resize(); this._centerCamera(); } // Aufsteigender Kurztext in Weltkoordinaten (z.B. "+1.240 €") addFloat(text, color, c, r, small = false) { this.floats.push({ text, color, c, r, small, t0: this.simTime }); if (this.floats.length > 10) this.floats.shift(); } resize() { const dpr = window.devicePixelRatio || 1; this.dpr = dpr; this.canvas.width = innerWidth * dpr; this.canvas.height = innerHeight * dpr; // Zoom-Grenzen: am weitesten rein = ~8 Kacheln Breite (schärfegrenze), // am weitesten raus = ganze Kernzone im Blick, Rand bleibt gefüllt. this.maxScale = innerWidth / (8 * TILE_W); this.minScale = Math.max(0.55, innerWidth / ((CORE.c1 - CORE.c0 + 8) * TILE_W)); if (this.cam) this.cam.scale = Math.max(this.minScale, Math.min(this.maxScale, this.cam.scale)); } _centerCamera() { // Start nah beim Dorf – klein anfangen, Überblick durch Zoomen const village = this.tileToWorld(CORE.c0 + 7.5, CORE.r0 + 22.5, 1); this.cam.scale = Math.min(this.maxScale, Math.max(this.minScale, innerWidth / 1100)); this.cam.x = innerWidth / 2 - village.x * this.cam.scale; this.cam.y = innerHeight * 0.62 - village.y * this.cam.scale; } // Kamera so begrenzen, dass der Blick über der gefüllten Kernzone bleibt // (das rechteckige Fenster ist dadurch immer voll Landschaft, nie leer). _clampCamera() { const mid = this.tileToWorld((CORE.c0 + CORE.c1) / 2, (CORE.r0 + CORE.r1) / 2, 2); // erlaubter Bereich für den Weltpunkt in Bildschirmmitte const halfSpanX = (CORE.c1 - CORE.c0) / 2 * TW2; const halfSpanY = (CORE.r1 - CORE.r0) / 2 * TH2; const wx = (innerWidth / 2 - this.cam.x) / this.cam.scale; const wy = (innerHeight / 2 - this.cam.y) / this.cam.scale; const cx = Math.max(mid.x - halfSpanX, Math.min(mid.x + halfSpanX, wx)); const cy = Math.max(mid.y - halfSpanY, Math.min(mid.y + halfSpanY, wy)); this.cam.x = innerWidth / 2 - cx * this.cam.scale; this.cam.y = innerHeight / 2 - cy * this.cam.scale; } tileToWorld(c, r, elev = 0) { return { x: (c - r) * TW2, y: (c + r) * TH2 - elev * ELEV_Y, }; } // Glatt interpolierte Geländehöhe an einer Bruch-Position (x = Spalte, // y = Reihe). Figuren nutzen sie, damit sie nicht von Kachel zu Kachel auf // und ab hüpfen, sondern über eine gedachte Ebene gleiten (bilinear). elevAt(x, y) { const map = this.state.map; const x0 = Math.floor(x), y0 = Math.floor(y); const fx = x - x0, fy = y - y0; const at = (cc, rr) => map[Math.max(0, Math.min(MAP_ROWS - 1, rr))] ?.[Math.max(0, Math.min(MAP_COLS - 1, cc))]?.elev ?? 0; const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * fx; const bot = at(x0, y0 + 1) + (at(x0 + 1, y0 + 1) - at(x0, y0 + 1)) * fx; return top + (bot - top) * fy; } screenToTile(sx, sy) { const wx = (sx - this.cam.x) / this.cam.scale; const wy = (sy - this.cam.y) / this.cam.scale; // Höhenbewusst: von der höchsten Stufe abwärts prüfen, welche Kachel // ihre OBERSEITE genau unter dem Zeiger hat – sonst zeigt der Cursor // am Hang mehrere Felder daneben. for (let e = 9; e >= 0; e--) { const wyE = wy + e * ELEV_Y; const c = Math.floor((wx / TW2 + wyE / TH2) / 2 + 0.5); const r = Math.floor((wyE / TH2 - wx / TW2) / 2 + 0.5); if (c < 0 || r < 0 || c >= MAP_COLS || r >= MAP_ROWS) continue; // >= statt ===: Punkte auf der Seitenwand gehören zur höheren Kachel, // sonst gibt es „tote" Streifen zwischen den Höhenstufen if (this.state.map[r][c].elev >= e) return { c, r }; } return null; } setBuildType(type) { this.buildType = type; } // ---------- Eingabe ---------- _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; this.vel.y = 0; // Momentum stoppen 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); if (!p) { // Maus-Hover am PC: Platzierungsvorschau this.hoverTile = this.screenToTile(e.clientX, e.clientY); return; } this.hoverTile = this.screenToTile(e.clientX, e.clientY); const dx = e.clientX - p.x; const 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; // für Momentum beim Loslassen this.vel.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) { const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2; this._zoomAround(mx, my, d / this.pinchDist); } this.pinchDist = d; } }); const up = e => { const wasTap = this.dragMoved < 10 && this.pointers.size === 1; this.pointers.delete(e.pointerId); this.pinchDist = 0; if (wasTap) { this.vel.x = 0; this.vel.y = 0; const t = this.screenToTile(e.clientX, e.clientY); if (t) this.cb.onTileTap(t.c, t.r); } // sonst: Schwung aus der letzten Bewegung mitnehmen (Momentum) }; cv.addEventListener('pointerup', up); cv.addEventListener('pointercancel', e => this.pointers.delete(e.pointerId)); cv.addEventListener('wheel', e => { e.preventDefault(); // sanft: Zielwert setzen, die Animation macht draw() const cur = this.zoomTarget?.scale ?? this.cam.scale; this.zoomTarget = { scale: Math.min(this.maxScale, 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(this.maxScale, 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; } // ---------- Gäste-Figuren: individueller Tagesablauf ---------- // Gäste reisen mit dem Bus an (Gruppen steigen an der Haltestelle aus), // kommen aus dem Hotel oder zu Fuß über die Talstraße. Dann folgen sie // einer Routine: spazieren/wandern → einkehren (verweilen) → Bergfahrt/ // Skifahren (eigene Abfahrtsroute, Liftwechsel) → … → Abreise zu Fuß. RIDE_MS = 12000; // muss zur Sesselgeschwindigkeit passen _busInfo() { if (!this.state.buildings.some(b => b.type === 'access')) return null; let f = (this.simTime / 26000) % 1; if (f > 0.5) f = 1 - f; f *= 2; return { c: CORE.c0 + 0.5 + f * (CORE.c1 - CORE.c0 - 1), r: ROAD_ROW }; } _spawnPoint() { const s = this.state; const stop = s.buildings.find(b => b.type === 'access'); const hotel = s.buildings.find(b => b.type === 'hotel'); const r = Math.random(); if (hotel && r < 0.35) return { x: hotel.c + 0.5, y: hotel.r + 1 }; if (stop && r < 0.85) return { x: stop.c + 0.3, y: stop.r + 0.8 }; return { x: Math.random() < 0.5 ? CORE.c0 + 0.2 : CORE.c1 - 0.2, y: ROAD_ROW }; } _exitPoint(g) { const stop = this.state.buildings.find(b => b.type === 'access'); if (stop) return { c: stop.c + 0.3, r: stop.r + 0.8 }; return { c: g.x < (CORE.c0 + CORE.c1) / 2 ? CORE.c0 + 0.2 : CORE.c1 - 0.2, r: ROAD_ROW }; } _spawnGuest(at) { const p = at || this._spawnPoint(); this.guests.push({ x: p.x, y: p.y, tx: p.x, ty: p.y, mode: 'dwell', wait: 0.4 + Math.random() * 0.8, route: [], ride: 0, lift: null, done: false, speed: 0.55 + Math.random() * 0.35, phase: Math.random() * 7, stridePhase: Math.random() * 6, kid: Math.random() < 0.18, pack: Math.random() < 0.5, prop: ['pole', 'umbrella', 'bag', null, null, null][Math.floor(Math.random() * 6)], hue: ['#e8892b', '#2f6ea0', '#a34a8e', '#3f8f6b', '#c9531f'][Math.floor(Math.random() * 5)], }); } _pisteLenOf(b) { return BUILDINGS[b.type]?.pisteLen || PISTE_LEN; } _rideMsOf(g) { return this.RIDE_MS * (this._pisteLenOf(g.lift) / PISTE_LEN); } _goLift(g, lifts) { g.lift = lifts[Math.floor(Math.random() * lifts.length)]; // Liftwechsel! g.mode = 'toLift'; g.tx = g.lift.c + 0.3; g.ty = g.lift.r + 0.5; this._say(g, liftSkiable(this.state, g.lift.alt) ? '\u{1F3BF}' : '\u{1F6A0}'); } // Gedankenblase (Sims-Stil): zeigt Absicht/Stimmung der Figur _say(g, icon, ms = 2800) { g.bubble = { icon, until: this.simTime + ms }; } // Nächster Punkt im Tagesablauf _nextActivity(g) { const s = this.state; const lifts = s.buildings.filter(b => b.type === 'lift' || b.type === 'gondola'); const skiLifts = lifts.filter(b => liftSkiable(s, b.alt)); // je Höhenlage const anySki = skiLifts.length > 0; const restaurants = s.buildings.filter(b => b.type === 'restaurant'); const attractions = s.buildings.filter(b => b.type === 'trail' || (s.season === 'Sommer' && b.type === 'badesee') || (s.season !== 'Winter' && ['sled', 'bikepark', 'ropes', 'platform', 'playground'].includes(b.type))); const r = Math.random(); if (anySki && r < 0.6) return this._goLift(g, skiLifts); if (lifts.length && r < 0.22) return this._goLift(g, lifts); if (restaurants.length && r < 0.45) { // einkehren const t = restaurants[Math.floor(Math.random() * restaurants.length)]; g.mode = 'walk'; g.longDwell = true; g.visit = t; g.tx = t.c + 0.4; g.ty = t.r + 0.9; this._say(g, '\u{1F37D}\uFE0F'); return; } if (attractions.length && r < 0.68) { // Attraktion besuchen const t = attractions[Math.floor(Math.random() * attractions.length)]; g.mode = 'walk'; g.longDwell = true; g.visit = t; g.tx = t.c + 0.4; g.ty = t.r + 0.8; this._say(g, { badesee: '\u{1F3D6}\uFE0F', sled: '\u{1F6F7}', trail: '\u{1F97E}', bikepark: '\u{1F6B5}', ropes: '\u{1F9D7}', platform: '\u{1F4F8}', playground: '\u{1F3A0}', }[t.type] || '\u{1F97E}'); return; } // Winter ohne Skimöglichkeit: Enttäuschung zeigen (kein Schnee/Lift) if (s.season === 'Winter' && !anySki && Math.random() < 0.25) { this._say(g, lifts.length ? '\u2744\uFE0F' : '\u{1F6A0}'); } const d = this._randomDestination(); // spazieren g.mode = 'walk'; g.longDwell = false; g.tx = d.c; g.ty = d.r; } // Individuelle Abfahrtsroute: Wegpunkte mit seitlichem Versatz _startSki(g) { const s = this.state; const len = this._pisteLenOf(g.lift); const top = g.lift.r - len; g.route = []; const steps = len > 5 ? 5 : 3; let side = Math.random() < 0.5 ? 1 : -1; for (let i = 1; i <= steps; i++) { // weite Schwünge: abwechselnd stark seitlich ausholen g.route.push({ c: Math.max(1, Math.min(MAP_COLS - 2, g.lift.c + side * (1.5 + Math.random() * 2.8))), r: top + (len * i) / steps, }); side = -side; } // manchmal unten quer zum NÄCHSTEN Lift fahren (Liftwechsel auf Skiern) const others = s.buildings.filter(b => (b.type === 'lift' || b.type === 'gondola') && b !== g.lift); if (others.length && Math.random() < 0.4) { const nxt = others[Math.floor(Math.random() * others.length)]; g.route.push({ c: nxt.c + 0.3, r: nxt.r + 0.4 }); g.nextLift = nxt; } else { g.route.push({ c: g.lift.c + (Math.random() - 0.5) * 0.8, r: g.lift.r + 0.4 }); g.nextLift = null; } g.mode = 'ski'; const n = g.route.shift(); g.tx = n.c; g.ty = n.r; } syncGuests(dt) { const s = this.state; const target = Math.min(45, Math.round(s.guestsToday / 10)); // 1 Figur = 10 Gäste const active = this.guests.filter(g => g.mode !== 'leave').length; // Anreise: Gruppen steigen aus dem Bus, sonst einzeln (Hotel/Straße) this._spawnCd = (this._spawnCd ?? 0) - dt; this._busCd = (this._busCd ?? 0) - dt; const bus = this._busInfo(); const stop = s.buildings.find(b => b.type === 'access'); if (bus && stop && this._busCd <= 0 && Math.abs(bus.c - stop.c) < 0.5 && active < target) { const n = Math.min(3, target - active); for (let i = 0; i < n; i++) { this._spawnGuest({ x: stop.c + 0.2 + i * 0.25, y: stop.r + 0.8 + i * 0.15 }); } this._busCd = 8; } else if (active < target && this._spawnCd <= 0) { this._spawnGuest(); this._spawnCd = 0.7; } // Abreise: überzählige Gäste gehen zur Haltestelle/Straße und verschwinden dort if (active > target + 1) { const g = this.guests.find(x => (x.mode === 'walk' || x.mode === 'dwell')); if (g) { const e = this._exitPoint(g); g.mode = 'leave'; g.tx = e.c; g.ty = e.r; this._say(g, this.state.satisfaction < 55 ? '\u{1F61E}' : '\u{1F44B}', 4000); } } this.guests = this.guests.filter(g => !g.done); const lifts = s.buildings.filter(b => b.type === 'lift' || b.type === 'gondola'); for (const g of this.guests) { if (g.lift && !lifts.includes(g.lift)) { // Lift wurde abgerissen g.lift = null; if (g.mode === 'ride' || g.mode === 'ski' || g.mode === 'toLift') g.mode = 'walk'; } // Skifahrer nur auf Schnee: sobald sie unter die Schneegrenze geraten // (ihre AKTUELLE Kachel ist zu tief/grün), werden sie wieder Fußgänger // und laufen talwärts weiter – sonst „Ski auf Wiese", sieht komisch aus. if (g.mode === 'ski') { const ct = s.map[Math.round(g.y)]?.[Math.round(g.x)]; if (!ct || ct.terrain === 'water' || !liftSkiable(s, ct.elev)) { g.mode = 'walk'; g.route = []; g.nextLift = null; g.longDwell = false; g.tx = g.x + (Math.random() - 0.5) * 2; g.ty = g.y + 1.5; this._say(g, '#*!', 4000); // schimpft: Schnee zu Ende, Ski getragen! } } if (g.mode === 'crashed') { g.moving = false; continue; } // liegt, wartet auf Bergung if (g.mode === 'dwell') { // verweilen (essen, schauen) // Niemand steht im Wasser herum: sofort ans Ufer weitergehen const wt = s.map[Math.round(g.y)]?.[Math.round(g.x)]; if (wt?.terrain === 'water') { const d = this._randomDestination(); g.mode = 'walk'; g.longDwell = false; g.tx = d.c; g.ty = d.r; } else { g.wait -= dt; g.moving = false; if (g.wait <= 0) this._nextActivity(g); continue; } } if (g.mode === 'ride') { // sitzt sichtbar im Sessel g.ride += dt * 1000; const len = this._pisteLenOf(g.lift); const f = Math.min(1, g.ride / this._rideMsOf(g)); g.x = g.lift.c; g.y = g.lift.r - len * f; g.moving = false; if (f >= 1) { if (liftSkiable(s, g.lift.alt)) this._startSki(g); else { // Wanderer: oben aussteigen g.mode = 'walk'; g.longDwell = true; g.tx = g.lift.c + (Math.random() - 0.5) * 3; g.ty = g.lift.r - len + 1 + Math.random() * 2; } } continue; } // Bewegung (walk / toLift / ski / leave) const dx = g.tx - g.x; const dy = g.ty - g.y; const dist = Math.hypot(dx, dy); const speed = g.mode === 'ski' ? g.speed * 1.6 : g.speed; g.moving = dist >= 0.15; if (dist < 0.15) { if (g.mode === 'leave') { g.done = true; continue; } if (g.mode === 'toLift') { g.mode = 'ride'; g.ride = 0; if (Math.random() < 0.5) { const price = s.season === 'Winter' ? s.price : 8; this.addFloat(`🎫 +${price} €`, '#3f8f5e', g.lift.c + 0.3, g.lift.r - 0.4, true); } continue; } if (g.mode === 'ski') { if (g.route.length) { // nächster Wegpunkt const n = g.route.shift(); g.tx = n.c; g.ty = n.r; } else if (g.nextLift) { // quergefahren → gleich einsteigen g.lift = g.nextLift; g.nextLift = null; g.mode = 'ride'; g.ride = 0; } else this._nextActivity(g); // unten angekommen continue; } // walk angekommen → verweilen; Besuch macht Einnahmen sichtbar if (g.visit) { const V = { restaurant: ['🥨', 12], badesee: ['🏖️', 5], sled: ['🛷', 7], trail: ['🥾', 3], bikepark: ['🚵', 9], ropes: ['🧗', 8], platform: ['📸', 6], playground: ['🎠', 4], }; const v = V[g.visit.type]; if (v) this.addFloat(`${v[0]} +${v[1]} €`, '#3f8f5e', g.visit.c + 0.4, g.visit.r - 0.3, true); this._say(g, '\u{1F60B}', 3200); g.visit = null; } g.mode = 'dwell'; g.wait = g.longDwell ? 3 + Math.random() * 4 : 1 + Math.random() * 2; continue; } const px = g.x, py = g.y; g.x += (dx / dist) * speed * dt; g.y += (dy / dist) * speed * dt; if (g.mode === 'ski') g.x += Math.sin(this.simTime / 250 + g.phase) * 0.012; g.vx = g.x - px; g.vy = g.y - py; // für Blickrichtung g.stridePhase += Math.hypot(g.vx, g.vy) * 18; // Schritte = Strecke // Spuren hinterlassen: Skispuren (Winter) bzw. Fußspuren (Sommer) const leaves = g.mode === 'ski' || (g.mode === 'walk' && this.state.season !== 'Winter'); if (leaves) { g.trackDist = (g.trackDist || 0) + Math.hypot(g.x - px, g.y - py); const step = g.mode === 'ski' ? 0.3 : 0.5; if (g.trackDist > step) { g.trackDist = 0; this.tracks.push({ x1: g.lastTx ?? px, y1: g.lastTy ?? py, x2: g.x, y2: g.y, t: this.simTime, foot: g.mode !== 'ski', }); g.lastTx = g.x; g.lastTy = g.y; if (this.tracks.length > 500) this.tracks.shift(); } } else { g.lastTx = g.lastTy = undefined; } } } _randomDestination() { const s = this.state; if (s.buildings.length && Math.random() < 0.6) { const t = s.buildings[Math.floor(Math.random() * s.buildings.length)]; return { c: t.c + 0.5, r: t.r + 1.2 }; } return { c: CORE.c0 + 3 + Math.random() * (CORE.c1 - CORE.c0 - 6), r: CORE.r1 - 2 - Math.random() * 5 }; } // ---------- Personal-Figuren (pendeln Wohnung ↔ Betrieb) ---------- syncStaff(dt) { const s = this.state; const workplaces = s.buildings.filter(b => (BUILDINGS[b.type].staff || 0) > 0); const homes = s.buildings.filter(b => b.type === 'staffhouse'); // 1 Figur je 2 besetzte Stellen, gedeckelt const working = Math.min(s.staffNeeded, s.staffAvailable); const target = Math.min(24, Math.round(working / 2)); while (this.staff.length < target && workplaces.length) { const home = homes.length ? homes[Math.floor(Math.random() * homes.length)] : { c: CORE.c0 + 7, r: CORE.r1 - 1 }; // Dorf, wenn kein Personalhaus const wp = workplaces[Math.floor(Math.random() * workplaces.length)]; this.staff.push({ x: home.c + 0.5, y: home.r + 1, home, wp, mode: 'toWork', tx: wp.c + 0.5, ty: wp.r + 1, wait: 0, stridePhase: Math.random() * 6, speed: 0.6 + Math.random() * 0.3, vx: 0, vy: 0, hue: ['#4f7f5e', '#c9531f', '#5a6b7a'][Math.floor(Math.random() * 3)], }); } if (this.staff.length > target) this.staff.length = target; for (const w of this.staff) { // Ziel-Gebäude könnte abgerissen sein if (!s.buildings.includes(w.wp)) { w.wp = workplaces[0]; if (!w.wp) { w.done = true; continue; } } if (w.mode === 'work' || w.mode === 'rest') { w.wait -= dt; w.moving = false; if (w.wait <= 0) { if (w.mode === 'work') { w.mode = 'toHome'; w.tx = w.home.c + 0.5; w.ty = w.home.r + 1; } else { w.mode = 'toWork'; w.tx = w.wp.c + 0.5; w.ty = w.wp.r + 1; } } continue; } const dx = w.tx - w.x, dy = w.ty - w.y; const d = Math.hypot(dx, dy); w.moving = d >= 0.12; if (d < 0.12) { w.mode = w.mode === 'toWork' ? 'work' : 'rest'; w.wait = 2 + Math.random() * 3; } else { const px = w.x, py = w.y; w.x += (dx / d) * w.speed * dt; w.y += (dy / d) * w.speed * dt; w.vx = w.x - px; w.vy = w.y - py; w.stridePhase += d ? w.speed * dt * 18 : 0; } } this.staff = this.staff.filter(w => !w.done); } _drawStaff() { for (const w of this.staff) { if (w.mode === 'work' || w.mode === 'rest') continue; // drinnen, unsichtbar const { x, y } = this.tileToWorld(w.x, w.y, this.elevAt(w.x, w.y)); const svx = (w.vx ?? 0) - (w.vy ?? 0); this._shadow(x, y + 1, 4, 1.8); this._walker(x, y, { h: 15, color: w.hue, phase: w.stridePhase || 0, still: w.moving === false, flip: svx < 0, prop: 'hat', pack: false, worker: true, }); } } // Rettungseinsatz: selten stürzt ein Skifahrer weit abseits. Gibt es eine // Rettungsstation, startet automatisch der Notarzthubschrauber, birgt ihn // und fliegt zurück. Ohne Station steht der Gast nach einer Weile selbst auf. _updateRescue(dt) { const s = this.state; const hasStation = s.buildings.some(b => b.type === 'rescue'); const incident = this.heli || this.guests.some(g => g.mode === 'crashed'); if (!incident) { for (const g of this.guests) { // (0.01: Tage sind seit der Zeit-Halbierung doppelt so lang) if (g.mode === 'ski' && Math.random() < 0.01 * dt) { g.mode = 'crashed'; g.crashAt = this.simTime; g.route = []; g.nextLift = null; g.moving = false; this._say(g, '\u{1F198}', 9e8); // 🆘 bleibt sichtbar break; } } } // Ohne Station: Selbst-Genesung nach ~20 s (Spielzeit) if (!hasStation) { for (const g of this.guests) { if (g.mode === 'crashed' && this.simTime - g.crashAt > 20000) { g.mode = 'walk'; g.bubble = null; const d = this._randomDestination(); g.tx = d.c; g.ty = d.r; } } } // Heli-Einsatz starten if (!this.heli && hasStation) { const victim = this.guests.find(g => g.mode === 'crashed'); if (victim) { const st = s.buildings.find(b => b.type === 'rescue'); const home = { x: st.c + 0.5, y: st.r - 0.5 }; // Start am Boden: erst Rotor hochdrehen und senkrecht abheben this.heli = { x: home.x, y: home.y, phase: 'takeoff', face: 1, bob: 0, hoverT: 0, ang: 0, alt: 0, spin: 0, victim, home }; } } // Heli natürlich: abheben → hinfliegen → kreisen → zurück → landen if (this.heli) { const h = this.heli; h.bob += dt; // Rotor: dreht am Boden langsam, in der Luft schnell (Anlauf/Auslauf) h.spin += dt * (2 + 14 * (h.alt ?? 1)); const SPD = 2.6 * dt; // gemächliches Tempo // Nase nach der BILDSCHIRM-Bewegung ausrichten: screen-x ∝ (Spalte − Reihe) const faceBy = (px, py) => { const sdx = (h.x - px) - (h.y - py); if (Math.abs(sdx) > 0.0015) h.face = sdx > 0 ? 1 : -1; }; if (h.phase === 'takeoff') { h.alt = Math.min(1, h.alt + dt / 1.4); // senkrecht abheben if (h.alt >= 1) h.phase = 'out'; } else if (h.phase === 'landing') { h.alt = Math.max(0, h.alt - dt / 1.6); // sanft aufsetzen if (h.alt <= 0) this.heli = null; // steht wieder am Pad } else if (h.phase === 'hover') { h.hoverT += dt; h.ang += dt * 1.6; const px = h.x, py = h.y; h.x = h.victim.x + Math.cos(h.ang) * 1.1; // kreist über dem Gast h.y = h.victim.y + Math.sin(h.ang) * 0.7; faceBy(px, py); if (h.hoverT > 3) { h.victim.done = true; h.phase = 'back'; } } else { const t = h.phase === 'out' ? h.victim : h.home; const dx = t.x - h.x, dy = t.y - h.y, dist = Math.hypot(dx, dy); if (dist < (h.phase === 'out' ? 1.1 : 0.3)) { if (h.phase === 'out') { h.phase = 'hover'; h.hoverT = 0; } else { h.phase = 'landing'; h.x = t.x; h.y = t.y; } // über dem Pad einschweben } else { const px = h.x, py = h.y; h.x += (dx / dist) * SPD; h.y += (dy / dist) * SPD; faceBy(px, py); } } } } _drawHeli() { if (!this.heli) return; const ctx = this.ctx; const h = this.heli; const lift = h.alt ?? 1; // 0 = Boden, 1 = Flughöhe // Wippen erst in der Luft; eased Höhenkurve für sanftes Abheben/Aufsetzen const ease = lift * lift * (3 - 2 * lift); const fly = 0.6 + ease * (8.4 + Math.sin(h.bob * 3) * 0.35 * lift); const { x, y } = this.tileToWorld(h.x, h.y, fly); const g = this.tileToWorld(h.x, h.y, this.elevAt(h.x, h.y)); // Schatten: am Boden groß und satt, in der Luft kleiner und blasser this._shadow(g.x, g.y, 9 - 2.5 * ease, 3.6 - ease); // Rotor-Abwind beim Starten/Landen: Wirbel-Ringe am Boden if (lift > 0.02 && lift < 0.85 && (h.phase === 'takeoff' || h.phase === 'landing')) { const a = (1 - lift) * 0.4; ctx.strokeStyle = `rgba(228,224,212,${a.toFixed(2)})`; ctx.lineWidth = 1.6; for (let i = 0; i < 3; i++) { const p = (h.bob * 1.8 + i / 3) % 1; ctx.beginPath(); ctx.ellipse(g.x, g.y + 1, 6 + p * 16, 2.4 + p * 6, 0, 0.3 + i, 2.4 + i); ctx.stroke(); } ctx.lineWidth = 1; } const f = h.face || 1; // 1 = Nase nach rechts // Heckausleger (hinten) + Finne + drehender Heckrotor ctx.strokeStyle = '#2f3e46'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x - 3 * f, y - 1); ctx.lineTo(x - 22 * f, y - 4); ctx.stroke(); ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x - 22 * f, y - 4); ctx.lineTo(x - 24 * f, y - 9); ctx.stroke(); const trot = h.spin * 2.2; ctx.strokeStyle = 'rgba(47,62,70,0.6)'; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.moveTo(x - 24 * f - Math.cos(trot) * 4, y - 7 - Math.sin(trot) * 4); ctx.lineTo(x - 24 * f + Math.cos(trot) * 4, y - 7 + Math.sin(trot) * 4); ctx.stroke(); // Kufen mit Streben ctx.strokeStyle = '#2f3e46'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x - 9, y + 8); ctx.lineTo(x + 11, y + 8); ctx.moveTo(x - 5, y + 5); ctx.lineTo(x - 6, y + 8); ctx.moveTo(x + 6, y + 5); ctx.lineTo(x + 7, y + 8); ctx.stroke(); // Rumpf (tropfenförmig) + Bauchschattierung ctx.fillStyle = '#e8892b'; ctx.beginPath(); ctx.ellipse(x, y, 12, 7, 0, 0, 7); ctx.fill(); ctx.fillStyle = this._shade('#e8892b', -16); ctx.beginPath(); ctx.ellipse(x, y + 2.5, 11, 4, 0, 0, Math.PI); ctx.fill(); // Cockpit-Verglasung vorne (Nasenrichtung) mit Rahmen + Türlinie ctx.fillStyle = 'rgba(200,224,232,0.95)'; ctx.beginPath(); ctx.ellipse(x + 6.5 * f, y - 0.5, 4.4, 4, 0, 0, 7); ctx.fill(); ctx.strokeStyle = 'rgba(47,62,70,0.5)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.ellipse(x + 6.5 * f, y - 0.5, 4.4, 4, 0, 0, 7); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x - 1 * f, y - 5); ctx.lineTo(x - 1 * f, y + 4); ctx.stroke(); // rotes Kreuz auf der Seite ctx.strokeStyle = '#c0392b'; ctx.lineWidth = 2.2; ctx.beginPath(); ctx.moveTo(x - 5.5 * f, y - 1); ctx.lineTo(x - 1.5 * f, y - 1); ctx.moveTo(x - 3.5 * f, y - 3); ctx.lineTo(x - 3.5 * f, y + 1); ctx.stroke(); // Rotor-Mast + Nabe + drehender Hauptrotor (lang) + Rotor-Unschärfe ctx.strokeStyle = '#2f3e46'; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.moveTo(x, y - 6.5); ctx.lineTo(x, y - 10); ctx.stroke(); ctx.fillStyle = '#2f3e46'; ctx.beginPath(); ctx.arc(x, y - 10, 1.6, 0, 7); ctx.fill(); // Rotor-Unschärfe-Scheibe nur bei schneller Drehung (in der Luft) if (lift > 0.4) { ctx.strokeStyle = `rgba(47,62,70,${(0.12 * lift).toFixed(2)})`; ctx.lineWidth = 3; ctx.beginPath(); ctx.ellipse(x, y - 10, 20, 5, 0, 0, 7); ctx.stroke(); } const rot = h.spin; ctx.strokeStyle = 'rgba(47,62,70,0.55)'; ctx.lineWidth = 2; for (let i = 0; i < 2; i++) { const a = rot + i * Math.PI; ctx.beginPath(); ctx.moveTo(x - Math.cos(a) * 20, y - 10 - Math.sin(a) * 5); ctx.lineTo(x + Math.cos(a) * 20, y - 10 + Math.sin(a) * 5); ctx.stroke(); } ctx.lineWidth = 1; } // Problem-Sprechblasen: Gebäude mit akutem Problem melden sich alle ~3 // Minuten (Echtzeit) mit einer aufsteigenden Blase – z. B. „Personal // gesucht!" – damit man Ursachen auch ohne Klick ins Gebäude sieht. _problemBubbles() { const s = this.state; const now = this.time; if (!this._probNext) this._probNext = {}; const staffGap = s.staffNeeded > s.staffAvailable; for (const b of s.buildings) { const def = BUILDINGS[b.type]; let msg = null; if (staffGap && (def.staff || 0) > 0) msg = '👷 Personal gesucht!'; else if ((b.type === 'lift' || b.type === 'gondola') && s.season === 'Winter' && !liftSkiable(s, b.alt ?? 3)) msg = '❄️ Kein Schnee!'; const k = `${b.c},${b.r}`; if (!msg) { delete this._probNext[k]; continue; } if (!this._probNext[k]) { // erster Hinweis nach 15–35 s, danach im 3-Minuten-Takt this._probNext[k] = now + 15000 + Math.random() * 20000; } else if (now >= this._probNext[k]) { this._probNext[k] = now + 180000; this.addFloat(msg, '#c0392b', b.c + 0.5, b.r - 0.4, true); } } } // Sichtbarer Umbau: ~10 s Bauzeit (Spielzeit), danach wird die neue Stufe // scharf geschaltet. Läuft für ALLE Baustellen (auch außerhalb des Bildes). CONSTRUCT_MS = 10000; _updateConstruction() { for (const b of this.state.buildings) { if (!b.underConstruction) continue; if (b._cStart === undefined) b._cStart = this.simTime; if (this.simTime - b._cStart >= this.CONSTRUCT_MS) { b.level = b.constructTo; const tile = this.state.map[b.r]?.[b.c]; if (tile) tile.level = b.constructTo; delete b.underConstruction; delete b.constructTo; delete b._cStart; } } } _drawConstruction() { const ctx = this.ctx; for (const b of this.state.buildings) { if (!b.underConstruction) continue; const tile = this.state.map[b.r]?.[b.c]; if (!tile) continue; const { x, y } = this.tileToWorld(b.c, b.r, tile.elev); const p = Math.max(0, Math.min(1, (this.simTime - (b._cStart ?? this.simTime)) / this.CONSTRUCT_MS)); // Gerüst: vier Eckstangen + zwei Querlatten ctx.strokeStyle = 'rgba(122,112,92,0.85)'; ctx.lineWidth = 1.3; for (const dx of [-11, 11]) { ctx.beginPath(); ctx.moveTo(x + dx, y + 6); ctx.lineTo(x + dx, y - 30); ctx.stroke(); } ctx.beginPath(); ctx.moveTo(x - 11, y - 8); ctx.lineTo(x + 11, y - 8); ctx.moveTo(x - 11, y - 21); ctx.lineTo(x + 11, y - 21); ctx.stroke(); // Staub-Püffchen ctx.fillStyle = 'rgba(200,190,170,0.5)'; for (let i = 0; i < 3; i++) { const t = (this.simTime / 500 + i / 3) % 1; ctx.beginPath(); ctx.arc(x - 8 + i * 8, y + 3 - t * 9, 1.4 + t * 2, 0, 7); ctx.fill(); } // Kranausleger + wippender Haken const hook = Math.sin(this.simTime / 420) * 3; ctx.strokeStyle = '#2f3e46'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x + 11, y - 30); ctx.lineTo(x + 2, y - 30); ctx.lineTo(x + 2, y - 22 + hook); ctx.stroke(); // Fortschrittsbalken über der Baustelle const bw = 26, bx = x - bw / 2, by = y - 40; ctx.fillStyle = 'rgba(47,62,70,0.22)'; ctx.fillRect(bx, by, bw, 4); ctx.fillStyle = '#e8892b'; ctx.fillRect(bx, by, bw * p, 4); ctx.strokeStyle = 'rgba(47,62,70,0.4)'; ctx.lineWidth = 0.8; ctx.strokeRect(bx, by, bw, 4); ctx.lineWidth = 1; } } // ---------- Zeichnen ---------- draw(now, dt, speed = 0) { this.time = now; this.simSpeed = speed; const simDt = dt * speed; // Spielzeit: Figuren, Lift, Bus … this.simTime += simDt; // … laufen mit der Zeitskala mit if (simDt > 0) { this.syncGuests(simDt / 1000); this.syncStaff(simDt / 1000); this._updateRescue(simDt / 1000); this._updateConstruction(); } // Kamera: Schwung nach dem Ziehen + sanfter Mausrad-Zoom 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; } this._clampCamera(); // Blick über der gefüllten Kernzone halten const { ctx, cam, dpr } = this; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, innerWidth, innerHeight); this._drawSky(); ctx.save(); ctx.translate(cam.x, cam.y); ctx.scale(cam.scale, cam.scale); const winter = this.state.season === 'Winter'; const snowline = winter ? (this.state.snow > 10 ? 1 : 3) : this.state.season === 'Frühling' ? 6 : 99; // Culling: nur Kacheln zeichnen, die im Fenster liegen (großer Rahmen!) const vw = innerWidth / cam.scale, vh = innerHeight / cam.scale; const ox = -cam.x / cam.scale, oy = -cam.y / cam.scale; for (let r = 0; r < MAP_ROWS; r++) { for (let c = 0; c < MAP_COLS; c++) { const wx = (c - r) * TW2, wy = (c + r) * TH2; if (wx < ox - TILE_W || wx > ox + vw + TILE_W) continue; if (wy < oy - 160 || wy > oy + vh + TILE_H) continue; this._drawTile(this.state.map[r][c], snowline, winter); } } this._drawTracks(winter); this._drawSledRuns(); this._drawBikeParks(); if (this.state.stageRow > 0) this._drawFog(); this._drawPreview(); this._drawBus(); this._drawFestivals(winter); this._drawStaffAlerts(); this._problemBubbles(); this._drawConstruction(); this._drawRiders(); this._drawStaff(); this._drawAnimals(winter); this._drawGuests(winter); this._drawHeli(); this._drawCloudShadows(); this._drawOccupancy(); this._drawFloats(); ctx.restore(); if (winter && dt > 0) this._drawSnowfall(dt); } _drawSky() { const ctx = this.ctx; const w = innerWidth, h = innerHeight; const winter = this.state.season === 'Winter'; const grad = ctx.createLinearGradient(0, 0, 0, h); if (winter) { grad.addColorStop(0, '#c9dbe4'); grad.addColorStop(1, '#e9e9e2'); } else { grad.addColorStop(0, '#bcd8e8'); grad.addColorStop(1, '#ece7d6'); } ctx.fillStyle = grad; ctx.fillRect(0, 0, w, h); // Ferne Bergsilhouetten mit leichter Parallaxe zur Kamera const par = this.cam.x * 0.06; ctx.fillStyle = winter ? 'rgba(160,180,190,0.5)' : 'rgba(120,150,140,0.35)'; this._ridge(ctx, par, h * 0.34, 220, 90); ctx.fillStyle = winter ? 'rgba(190,205,212,0.6)' : 'rgba(150,175,160,0.4)'; this._ridge(ctx, par * 1.6 + 300, h * 0.4, 170, 60); // Wolken: weiche Häufchen aus mehreren Puffs, die leicht atmen for (const cl of this.clouds) { const x = ((cl.x * innerWidth + this.time / (60 * cl.s)) % (innerWidth + 260)) - 130; const yb = cl.y + Math.sin(this.time / 5000 + cl.s) * 3; ctx.fillStyle = `rgba(255,255,255,${cl.a})`; ctx.beginPath(); ctx.ellipse(x, yb, cl.w * 0.5, cl.w * 0.13, 0, 0, 7); ctx.ellipse(x + cl.w * 0.24, yb - cl.w * 0.09, cl.w * 0.3, cl.w * 0.11, 0, 0, 7); ctx.ellipse(x - cl.w * 0.26, yb - cl.w * 0.05, cl.w * 0.26, cl.w * 0.09, 0, 0, 7); ctx.ellipse(x + cl.w * 0.05, yb - cl.w * 0.13, cl.w * 0.2, cl.w * 0.08, 0, 0, 7); ctx.fill(); } // Sommer-Vögel ziehen mit flatternden Flügeln über den Himmel if (!winter) { ctx.strokeStyle = 'rgba(60,75,85,0.7)'; ctx.lineWidth = 1.4; ctx.lineCap = 'round'; for (const b of this.birds) { const bx = ((this.time / (14 * b.s)) + b.ph * 300) % (innerWidth + 200) - 100; const by = b.y + Math.sin(this.time / 900 + b.ph) * 8; const flap = Math.sin(this.time / 120 + b.ph) * 3.5; ctx.beginPath(); ctx.moveTo(bx - 5, by - flap); ctx.quadraticCurveTo(bx, by, bx, by); ctx.quadraticCurveTo(bx, by, bx + 5, by - flap); ctx.stroke(); } ctx.lineWidth = 1; } } _ridge(ctx, offset, baseY, spread, height) { const w = innerWidth; ctx.beginPath(); ctx.moveTo(-50, baseY + 60); for (let x = -50; x <= w + 50; x += spread) { const peak = baseY - height * (0.5 + 0.5 * Math.sin((x + offset) / 130)); ctx.lineTo(x, peak); ctx.lineTo(x + spread / 2, baseY - height * 0.15); } ctx.lineTo(w + 50, baseY + 60); ctx.closePath(); ctx.fill(); } _drawSnowfall(dt) { const ctx = this.ctx; while (this.flakes.length < 70) { this.flakes.push({ x: Math.random() * innerWidth, y: Math.random() * innerHeight, r: 1 + Math.random() * 2, v: 20 + Math.random() * 40, drift: Math.random() * 2 - 1, }); } ctx.fillStyle = 'rgba(255,255,255,0.8)'; for (const f of this.flakes) { f.y += f.v * dt / 1000; f.x += f.drift * 12 * dt / 1000; if (f.y > innerHeight) { f.y = -4; f.x = Math.random() * innerWidth; } ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, 7); ctx.fill(); } } _diamond(x, y) { const ctx = this.ctx; ctx.beginPath(); ctx.moveTo(x, y - TH2); ctx.lineTo(x + TW2, y); ctx.lineTo(x, y + TH2); ctx.lineTo(x - TW2, y); ctx.closePath(); } _drawTile(tile, snowline, winter) { const ctx = this.ctx; const { x, y } = this.tileToWorld(tile.c, tile.r, tile.elev); const snowy = tile.elev >= snowline && tile.terrain !== 'water'; let color = TERRAIN_COLORS[tile.terrain]; if (snowy) color = tile.terrain === 'forest' ? '#8fae94' : '#eef4f1'; color = this._shade(color, tile.tint); // Höhenkanten für Tiefenwirkung; Kartenrand bekommt tiefe Klippen const edge = tile.r === MAP_ROWS - 1 || tile.c === MAP_COLS - 1; if (tile.elev > 0 || edge) { const h = tile.elev * ELEV_Y + (edge ? 30 : 0); ctx.fillStyle = this._shade(color, -34); ctx.beginPath(); ctx.moveTo(x - TW2, y); ctx.lineTo(x, y + TH2); ctx.lineTo(x, y + TH2 + h); ctx.lineTo(x - TW2, y + h); ctx.closePath(); ctx.fill(); ctx.fillStyle = this._shade(color, -18); ctx.beginPath(); ctx.moveTo(x + TW2, y); ctx.lineTo(x, y + TH2); ctx.lineTo(x, y + TH2 + h); ctx.lineTo(x + TW2, y + h); ctx.closePath(); ctx.fill(); } this._diamond(x, y); if (tile.terrain === 'water') { const shimmer = Math.sin(this.simTime / 500 + tile.r) * 8; ctx.fillStyle = this._shade('#7fb2c9', shimmer); } else { ctx.fillStyle = color; } ctx.fill(); ctx.strokeStyle = 'rgba(47,62,70,0.06)'; ctx.stroke(); // kleine Details je Untergrund (nicht auf bebauten Kacheln – sonst // liegen Grasbüschel ÜBER dem Fuß großer 2×2-Gebäude) if (!snowy && !tile.road && !tile.building) this._tileDetail(tile, x, y); if (tile.terrain === 'water' && !tile.bridge) this._waterStreak(tile, x, y); // Talstraße (bzw. Brücke über den Fluss) if (tile.road) this._drawRoad(tile, x, y, snowy); // Piste: weiß, solange am zugehörigen Lift Schnee liegt (Höhenlage!), // sonst als grüne Schneise – so weicht das Weiß von unten nach oben. const piste = this.state.pistes.find(p => p.c === tile.c && p.r === tile.r); if (piste) { const ownerTile = this.state.map[piste.owner.r]?.[piste.owner.c]; const skiable = ownerTile && liftSkiable(this.state, ownerTile.elev); this._diamond(x, y); ctx.fillStyle = skiable ? 'rgba(255,255,255,0.65)' : 'rgba(216,228,178,0.7)'; ctx.fill(); } const isPiste = !!piste; // (Bikepark-Strecke wird als durchgehende Bahn in _drawBikeParks gezeichnet) if (tile.terrain === 'forest' && !tile.building && !isPiste) { this._drawTree(x, y, snowy, tile.tint); } if (tile.deco) this._drawDeco(tile.deco, x, y); // Gebäude: 1×1 zeichnet seine Kachel. Große (2×2) zeichnet die VORDERSTE // Teil-Kachel (Painter-Reihenfolge!) – sonst übermalen die davorliegenden // Kacheln den Gebäudefuß. Gezeichnet wird zentriert über dem Areal. if (tile.building) { const a = tile.buildingRef || { c: tile.c, r: tile.r }; const at = this.state.map[a.r]?.[a.c]; const big = at && ((BUILDINGS[at.building]?.size || 1) > 1 || at.mega); if (!big) { if (!tile.buildingRef) this._drawBuilding(tile.building, x, y, tile); } else if (tile.c === a.c + 1 && tile.r === a.r + 1) { this._drawBuilding(at.building, x, y, at); } } // Noch nicht erschlossene Hochlagen liegen unter Nebel if (tile.r < this.state.stageRow) { this._diamond(x, y); ctx.fillStyle = 'rgba(238,243,245,0.55)'; ctx.fill(); } } // Ausbaustufe markant zeigen: Wimpelgirlande + Stern-Abzeichen; höhere // Stufen bekommen einen warmen Glanz. (x,y) = Ankerpunkt oben am Gebäude. _badge(x, y, level) { if ((level || 1) < 2) return; const ctx = this.ctx; const roman = ['', 'I', 'II', 'III', 'IV'][level] || String(level); // Warmer Glanz hinter höher ausgebauten Gebäuden (Stufe III/IV) if (level >= 3) { const g = ctx.createRadialGradient(x - 6, y + 14, 2, x - 6, y + 14, 34); g.addColorStop(0, `rgba(255,210,120,${level >= 4 ? 0.28 : 0.18})`); g.addColorStop(1, 'rgba(255,210,120,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x - 6, y + 14, 34, 0, 7); ctx.fill(); } // Wimpelgirlande über dem Dach – ein Bogen mit (level-1) Wimpeln const span = 18 + level * 3; const ax = x - 12 - span / 2, bx = x - 12 + span / 2, ty = y - 6; ctx.strokeStyle = '#6b4f2f'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(ax, ty); ctx.quadraticCurveTo((ax + bx) / 2, ty + 7, bx, ty); ctx.stroke(); const flags = level + 1; for (let i = 1; i < flags; i++) { const f = i / flags; const fx = (1 - f) * (1 - f) * ax + 2 * (1 - f) * f * ((ax + bx) / 2) + f * f * bx; const fy = (1 - f) * (1 - f) * ty + 2 * (1 - f) * f * (ty + 7) + f * f * ty; ctx.fillStyle = ['#e8892b', '#7ba05b', '#c0392b', '#2f6ea0'][i % 4]; ctx.beginPath(); ctx.moveTo(fx - 2.5, fy); ctx.lineTo(fx + 2.5, fy); ctx.lineTo(fx, fy + 5); ctx.closePath(); ctx.fill(); } // Stern-Abzeichen mit Stufe ctx.fillStyle = level >= 4 ? '#e8892b' : level >= 3 ? '#e8b93b' : '#e8b93b'; ctx.strokeStyle = '#a8801f'; ctx.lineWidth = 1; this._star(x, y, 7, 3.2, 5); ctx.fill(); ctx.stroke(); ctx.fillStyle = level >= 4 ? '#fff' : '#5a4310'; ctx.font = 'bold 6px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(roman, x, y + 2.2); } _star(cx, cy, rO, rI, points) { const ctx = this.ctx; ctx.beginPath(); for (let i = 0; i < points * 2; i++) { const r = i % 2 ? rI : rO; const a = (Math.PI / points) * i - Math.PI / 2; const px = cx + Math.cos(a) * r, py = cy + Math.sin(a) * r; i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); } ctx.closePath(); } // Rodelbahnen: Schiene, Stützen und Schlitten ÜBER dem Gelände _drawSledRuns() { const ctx = this.ctx; for (const b of this.state.buildings) { if (b.type !== 'sled') continue; const tile = this.state.map[b.r][b.c]; const { x, y } = this.tileToWorld(b.c, b.r, tile.elev); const level = tile.level || 1; const segs = [5, 8, 10][Math.min(2, level - 1)]; const pts = []; for (let i = 0; i <= segs; i++) { const rr = Math.min(MAP_ROWS - 1, b.r + i); const cc = b.c + Math.sin(i * 1.6 + tile.tint) * 0.5; const tt = this.state.map[rr]?.[Math.round(Math.max(0, Math.min(MAP_COLS - 1, cc)))]; pts.push(this.tileToWorld(cc, rr, tt ? tt.elev : 0)); } // Stützen ctx.strokeStyle = '#8a8378'; ctx.lineWidth = 1.5; for (let i = 1; i < segs; i++) { ctx.beginPath(); ctx.moveTo(pts[i].x, pts[i].y + 3); ctx.lineTo(pts[i].x, pts[i].y - 4); ctx.stroke(); } // Doppelschiene ctx.strokeStyle = '#9aa4ab'; ctx.lineWidth = 2.2; for (const off of [-1.6, 1.6]) { ctx.beginPath(); ctx.moveTo(pts[0].x + off, pts[0].y - 4); for (let i = 1; i <= segs; i++) ctx.lineTo(pts[i].x + off, pts[i].y - 4); ctx.stroke(); } ctx.lineWidth = 1; // Zielplattform am unteren Ende ctx.fillStyle = '#d9cfb4'; ctx.fillRect(pts[segs].x - 7, pts[segs].y - 6, 14, 5); // Schlitten mit Kurvenneigung const drawSled = (phase, riderHue) => { const pr = ((this.simTime / (2200 * segs)) + phase) % 1; const fi = pr * segs; const i0 = Math.min(segs - 1, Math.floor(fi)); const f = fi - i0; const sx = pts[i0].x + (pts[i0 + 1].x - pts[i0].x) * f; const sy = pts[i0].y + (pts[i0 + 1].y - pts[i0].y) * f - 5; const ang = Math.atan2(pts[i0 + 1].y - pts[i0].y, pts[i0 + 1].x - pts[i0].x); ctx.save(); ctx.translate(sx, sy); ctx.rotate(ang * 0.5); ctx.fillStyle = '#c9531f'; ctx.fillRect(-4.5, -2, 9, 4); ctx.fillStyle = riderHue; ctx.beginPath(); ctx.ellipse(0, -4, 2, 2.8, -0.2, 0, 7); ctx.fill(); ctx.fillStyle = '#f0d4b8'; ctx.beginPath(); ctx.arc(0.5, -7, 1.7, 0, 7); ctx.fill(); ctx.restore(); }; drawSled(0, '#2f6ea0'); if (level >= 2) drawSled(0.5, '#a34a8e'); if (level >= 3) drawSled(0.25, '#3f8f6b'); this._badge(x + 14, y - 22, level); } } // Wolkenschatten: weiche Flecken, die gemächlich über das Tal ziehen _drawCloudShadows() { const ctx = this.ctx; for (let i = 0; i < 3; i++) { const t = (this.simTime / (110000 + i * 30000) + i * 0.37) % 1; const c = -8 + t * (MAP_COLS + 16); const r = 8 + i * 11 + Math.sin(t * 6.3 + i * 2) * 3; const p = this.tileToWorld(c, r, 0); ctx.fillStyle = 'rgba(47,62,70,0.05)'; ctx.beginPath(); ctx.ellipse(p.x, p.y, 140 + i * 45, 55 + i * 15, 0, 0, 7); ctx.ellipse(p.x + 95, p.y + 22, 95, 38, 0, 0, 7); ctx.fill(); } } // Weidetiere: Schafe auf den Almen, Kühe auf den Talwiesen. Feste Herden- // Plätze (deterministisch aus der Karte), leichtes Grasen/Wandern. _drawAnimals(winter) { if (winter) return; // im Winter sind die Tiere im Stall if (!this.herds) { this.herds = []; const s = this.state; for (let r = 2; r < MAP_ROWS - 2; r++) { for (let c = 2; c < MAP_COLS - 2; c++) { if ((c * 7 + r * 13) % 41 !== 0) continue; // deterministisch dünn streuen const t = s.map[r][c]; if (t.road || t.building) continue; if (t.terrain === 'alm' && this.herds.filter(h => h.kind === 'sheep').length < 5) { this.herds.push({ c, r, kind: 'sheep', n: 3 + (c + r) % 3 }); } else if (t.terrain === 'meadow' && t.elev <= 2 && this.herds.filter(h => h.kind === 'cow').length < 3) { this.herds.push({ c, r, kind: 'cow', n: 2 + (c + r) % 2 }); } } } } const ctx = this.ctx; for (const h of this.herds) { const tile = this.state.map[h.r][h.c]; if (tile.building || tile.bikeTrail) continue; // inzwischen bebaut for (let i = 0; i < h.n; i++) { const drift = Math.sin(this.simTime / 5200 + i * 2.4 + h.c) * 0.9; const gx = h.c + 0.25 + (i % 3) * 0.28 + drift * 0.12; const gy = h.r + 0.3 + Math.floor(i / 3) * 0.35 + Math.cos(this.simTime / 6100 + i) * 0.1; const p = this.tileToWorld(gx, gy, this.elevAt(gx, gy)); const graze = Math.sin(this.simTime / 900 + i * 1.7) > 0.55; // Kopf unten? if (h.kind === 'sheep') { this._shadow(p.x, p.y + 1, 2.6, 1.1); ctx.fillStyle = '#f2efe6'; ctx.beginPath(); ctx.ellipse(p.x, p.y - 2.2, 2.6, 1.8, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#4a4540'; ctx.beginPath(); ctx.arc(p.x + 2.4, p.y - (graze ? 1.2 : 2.6), 1, 0, 7); ctx.fill(); } else { this._shadow(p.x, p.y + 1, 3.6, 1.4); ctx.fillStyle = '#8a6a4f'; ctx.beginPath(); ctx.ellipse(p.x, p.y - 2.8, 3.7, 2.3, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#f2efe6'; // Flecken ctx.beginPath(); ctx.ellipse(p.x - 1.2, p.y - 3.2, 1.3, 0.9, 0.4, 0, 7); ctx.ellipse(p.x + 1.4, p.y - 2.2, 1, 0.7, -0.3, 0, 7); ctx.fill(); ctx.fillStyle = '#6e523c'; ctx.beginPath(); ctx.arc(p.x + 3.6, p.y - (graze ? 1.4 : 3.4), 1.3, 0, 7); ctx.fill(); // Beine ctx.strokeStyle = '#6e523c'; ctx.lineWidth = 0.9; ctx.beginPath(); ctx.moveTo(p.x - 2, p.y - 0.8); ctx.lineTo(p.x - 2, p.y + 1); ctx.moveTo(p.x + 2, p.y - 0.8); ctx.lineTo(p.x + 2, p.y + 1); ctx.stroke(); } } } } // Bikepark: Flowtrail-Strecke talwärts (Rodelbahn-Prinzip) mit fahrenden // Rädern. Die Strecken-Kacheln sind im Modell reserviert (tile.bikeTrail). _drawBikeParks() { const ctx = this.ctx; for (const b of this.state.buildings) { if (b.type !== 'bikepark') continue; const tile = this.state.map[b.r][b.c]; const segs = BUILDINGS.bikepark.trailLen || 5; const level = tile.level || 1; const pts = []; for (let i = 0; i <= segs; i++) { const rr = Math.min(MAP_ROWS - 1, b.r + i); const cc = b.c + Math.sin(i * 1.7 + tile.tint) * 0.55; // Flow-Schwünge const tt = this.state.map[rr]?.[Math.round(Math.max(0, Math.min(MAP_COLS - 1, cc)))]; pts.push(this.tileToWorld(cc, rr, tt ? tt.elev : 0)); } // Erdige Trail-Bahn mit hellen Berm-Kanten ctx.lineCap = 'round'; ctx.strokeStyle = 'rgba(122,90,58,0.85)'; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y - 2); for (let i = 1; i <= segs; i++) ctx.lineTo(pts[i].x, pts[i].y - 2); ctx.stroke(); ctx.strokeStyle = 'rgba(214,196,158,0.8)'; ctx.lineWidth = 1.4; for (const off of [-3, 3]) { ctx.beginPath(); ctx.moveTo(pts[0].x + off, pts[0].y - 2); for (let i = 1; i <= segs; i++) ctx.lineTo(pts[i].x + off, pts[i].y - 2); ctx.stroke(); } ctx.lineCap = 'butt'; ctx.lineWidth = 1; // Ziel-Bogen am unteren Ende ctx.strokeStyle = '#e8892b'; ctx.lineWidth = 1.6; ctx.beginPath(); ctx.arc(pts[segs].x, pts[segs].y - 6, 6, Math.PI, 0); ctx.stroke(); ctx.lineWidth = 1; // Fahrende Räder (mit Kurvenneigung); höhere Stufe = mehr Biker const drawBiker = (phase, hue) => { const pr = ((this.simTime / (2000 * segs)) + phase) % 1; const fi = pr * segs; const i0 = Math.min(segs - 1, Math.floor(fi)); const f = fi - i0; const sdx = pts[i0 + 1].x - pts[i0].x; const sdy = pts[i0 + 1].y - pts[i0].y; const bx = pts[i0].x + sdx * f; const by = pts[i0].y + sdy * f - 4; ctx.save(); ctx.translate(bx, by); // Blickrichtung: Rad zeigt in Fahrtrichtung (spiegeln statt rotieren), // dazu nur eine LEICHTE Bergab-Neigung – sonst stehen sie kopf. ctx.scale(sdx < 0 ? -1 : 1, 1); ctx.rotate(Math.atan2(sdy, Math.abs(sdx) || 0.01) * 0.3); // Räder ctx.strokeStyle = '#2f3e46'; ctx.lineWidth = 1.1; ctx.beginPath(); ctx.arc(-3.2, 1.5, 2.1, 0, 7); ctx.stroke(); ctx.beginPath(); ctx.arc(3.2, 1.5, 2.1, 0, 7); ctx.stroke(); // Rahmen ctx.beginPath(); ctx.moveTo(-3.2, 1.5); ctx.lineTo(0, -1.5); ctx.lineTo(3.2, 1.5); ctx.moveTo(0, -1.5); ctx.lineTo(-1, 1.2); ctx.stroke(); // Fahrer:in (gebückt) + Helm ctx.fillStyle = hue; ctx.beginPath(); ctx.ellipse(0.4, -3.4, 2.4, 1.9, -0.5, 0, 7); ctx.fill(); ctx.fillStyle = '#f0d4b8'; ctx.beginPath(); ctx.arc(2.2, -4.6, 1.4, 0, 7); ctx.fill(); ctx.fillStyle = '#c9531f'; ctx.beginPath(); ctx.arc(2.2, -5.3, 1.2, Math.PI, 0); ctx.fill(); ctx.restore(); }; drawBiker(0, '#2f6ea0'); if (level >= 2) drawBiker(0.4, '#3f8f6b'); if (level >= 3) drawBiker(0.7, '#a34a8e'); } } // Skispuren im Schnee – verblassen nach ~90 Spielsekunden _drawTracks(winter) { const ctx = this.ctx; const DUR = 90000; this.tracks = this.tracks.filter(t => { if (this.simTime - t.t > DUR) return false; if (!t.foot && (!winter || this.state.snow < 8)) return false; // Skispur schmilzt return true; }); ctx.lineWidth = 1.1; for (const t of this.tracks) { const age = (this.simTime - t.t) / DUR; const a = this.tileToWorld(t.x1, t.y1, this.elevAt(t.x1, t.y1)); const b = this.tileToWorld(t.x2, t.y2, this.elevAt(t.x2, t.y2)); if (t.foot) { // Wanderer-Fußspur: gedämpfte Trittpunkte auf dem Pfad ctx.fillStyle = `rgba(120,100,70,${0.28 * (1 - age)})`; ctx.beginPath(); ctx.arc(b.x - 1, b.y, 1.1, 0, 7); ctx.fill(); ctx.beginPath(); ctx.arc(b.x + 1, b.y + 0.6, 1.1, 0, 7); ctx.fill(); } else { ctx.strokeStyle = `rgba(96,118,134,${0.4 * (1 - age)})`; ctx.beginPath(); ctx.moveTo(a.x - 1.2, a.y); ctx.lineTo(b.x - 1.2, b.y); ctx.moveTo(a.x + 1.2, a.y); ctx.lineTo(b.x + 1.2, b.y); ctx.stroke(); } } ctx.lineWidth = 1; } // Treibende Nebelschwaden über dem gesperrten Hochgebirge _drawFog() { const ctx = this.ctx; const rFog = Math.max(1, this.state.stageRow - 3); ctx.fillStyle = 'rgba(255,255,255,0.45)'; for (let i = 0; i < 5; i++) { const c = ((this.time / (4000 + i * 900)) + i * 4.7) % (MAP_COLS + 8) - 4; const rr = rFog + (i % 3); const t = this.state.map[rr]?.[Math.max(0, Math.min(MAP_COLS - 1, Math.round(c)))]; const { x, y } = this.tileToWorld(c, rr, t ? t.elev : 6); ctx.beginPath(); ctx.ellipse(x, y - 12, 65, 16, 0, 0, 7); ctx.ellipse(x + 45, y - 5, 48, 12, 0, 0, 7); ctx.fill(); } } _tileDetail(tile, x, y) { const ctx = this.ctx; const t = tile.tint; if (tile.terrain === 'meadow' || tile.terrain === 'alm') { // Grasbüschel ctx.strokeStyle = 'rgba(74,110,60,0.5)'; ctx.lineWidth = 1; for (let i = 0; i < 3; i++) { const gx = x + ((t * 7 + i * 23) % 36) - 18; const gy = y + ((t * 5 + i * 13) % 14) - 7; ctx.beginPath(); ctx.moveTo(gx, gy); ctx.lineTo(gx - 2, gy - 4); ctx.moveTo(gx, gy); ctx.lineTo(gx + 2, gy - 4); ctx.stroke(); } // Blümchen im Sommer if (this.state.season === 'Sommer' && (t % 3) === 0) { ctx.fillStyle = (t % 2) ? '#f4f1e4' : '#e8a45b'; ctx.beginPath(); ctx.arc(x + t, y + 4, 1.6, 0, 7); ctx.fill(); } } else if (tile.terrain === 'field') { // Ackerfurchen ctx.strokeStyle = 'rgba(140,120,80,0.4)'; for (let i = -1; i <= 1; i++) { ctx.beginPath(); ctx.moveTo(x - TW2 * 0.5 + i * 8, y - TH2 * 0.25 + i * 8 * 0.5); ctx.lineTo(x + TW2 * 0.35 + i * 8, y + TH2 * 0.55 - (TW2 * 0.35 - i * 8) / TW2 * 0 + i * 8 * 0.5 - TH2 * 0.35); ctx.stroke(); } } else if (tile.terrain === 'rock') { ctx.strokeStyle = 'rgba(80,75,65,0.35)'; ctx.beginPath(); ctx.moveTo(x - 8 + t, y - 2); ctx.lineTo(x + t, y + 3); ctx.lineTo(x + 7 + t, y + 1); ctx.stroke(); } } _drawRoad(tile, x, y, snowy) { const ctx = this.ctx; // Fahrbahn folgt der Ost-West-Richtung (Nachbar c−1 ↔ c+1) const a = [x - TW2 * 0.5, y - TH2 * 0.5]; const b = [x + TW2 * 0.5, y + TH2 * 0.5]; ctx.lineCap = 'round'; if (tile.bridge) { // Holzbrücke: etwas breiter, mit Geländer ctx.strokeStyle = '#8a6a42'; ctx.lineWidth = 15; ctx.beginPath(); ctx.moveTo(...a); ctx.lineTo(...b); ctx.stroke(); ctx.strokeStyle = '#6b4f2f'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(a[0], a[1] - 8); ctx.lineTo(b[0], b[1] - 8); ctx.moveTo(a[0], a[1] + 8); ctx.lineTo(b[0], b[1] + 8); ctx.stroke(); } else { ctx.strokeStyle = snowy ? '#d3d8d5' : '#b5a98e'; ctx.lineWidth = 13; ctx.beginPath(); ctx.moveTo(...a); ctx.lineTo(...b); ctx.stroke(); // Mittellinie ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = 1.2; ctx.setLineDash([5, 6]); ctx.beginPath(); ctx.moveTo(...a); ctx.lineTo(...b); ctx.stroke(); ctx.setLineDash([]); } ctx.lineWidth = 1; ctx.lineCap = 'butt'; } // Kleiner Bus pendelt auf der Talstraße, sobald es eine Haltestelle gibt. // Als echter Iso-Körper entlang der Straßenachse gezeichnet. _drawBus() { const s = this.state; if (!s.buildings.some(b => b.type === 'access')) return; const ctx = this.ctx; const phase = (this.simTime / 26000) % 1; let f = phase; if (f > 0.5) f = 1 - f; f *= 2; const dir = phase <= 0.5 ? 1 : -1; // 1 = fährt nach rechts unten, −1 = zurück const c = CORE.c0 + 0.5 + f * (CORE.c1 - CORE.c0 - 1); const r = ROAD_ROW; const tile = s.map[r][Math.min(MAP_COLS - 1, Math.round(c))]; const { x, y } = this.tileToWorld(c, r, tile.elev); // Iso-Quader entlang der Fahrbahn (dc-Achse). Bewusst Vektor gehalten. const P = (dc, dr, up = 0) => [x + (dc - dr) * TW2, y + (dc + dr) * TH2 - up]; const L = 0.34, W = 0.11, H = 13, body = '#e8892b'; const winB = H * 0.46, winT = H * 0.82; this._shadow(x, y + 2, 15, 5); // Räder zuerst (Körper deckt die Oberseiten ab) – mit Radkappe for (const dc of [-L * 0.55, L * 0.55]) { const wp = P(dc, W); ctx.fillStyle = '#2f3e46'; ctx.beginPath(); ctx.arc(wp[0], wp[1] + 1, 2.9, 0, 7); ctx.fill(); ctx.fillStyle = '#8a939a'; ctx.beginPath(); ctx.arc(wp[0], wp[1] + 1, 1.2, 0, 7); ctx.fill(); } // lange Seitenwand (zum Betrachter) ctx.fillStyle = this._shade(body, -18); ctx.beginPath(); ctx.moveTo(...P(-L, W)); ctx.lineTo(...P(L, W)); ctx.lineTo(...P(L, W, H)); ctx.lineTo(...P(-L, W, H)); ctx.closePath(); ctx.fill(); // drei Seitenfenster mit Stegen ctx.fillStyle = 'rgba(223,232,236,0.92)'; for (let i = 0; i < 3; i++) { const a = -L * 0.78 + i * (L * 1.52 / 3), b = a + L * 1.52 / 3 - 0.04; ctx.beginPath(); ctx.moveTo(...P(a, W, winB)); ctx.lineTo(...P(b, W, winB)); ctx.lineTo(...P(b, W, winT)); ctx.lineTo(...P(a, W, winT)); ctx.closePath(); ctx.fill(); } // Türlinie (Tür sitzt Richtung Front) ctx.strokeStyle = this._shade(body, -34); ctx.lineWidth = 0.8; ctx.beginPath(); ctx.moveTo(...P(L * 0.06 * dir, W)); ctx.lineTo(...P(L * 0.06 * dir, W, winB)); ctx.stroke(); // Sichtbares Ende (+L): je nach Fahrtrichtung FRONT oder HECK ctx.fillStyle = body; ctx.beginPath(); ctx.moveTo(...P(L, W)); ctx.lineTo(...P(L, -W)); ctx.lineTo(...P(L, -W, H)); ctx.lineTo(...P(L, W, H)); ctx.closePath(); ctx.fill(); if (dir === 1) { // Windschutzscheibe ctx.fillStyle = 'rgba(223,232,236,0.92)'; ctx.beginPath(); ctx.moveTo(...P(L, W * 0.7, winB)); ctx.lineTo(...P(L, -W * 0.7, winB)); ctx.lineTo(...P(L, -W * 0.7, winT)); ctx.lineTo(...P(L, W * 0.7, winT)); ctx.closePath(); ctx.fill(); // Scheinwerfer ctx.fillStyle = '#ffe6a0'; const hl1 = P(L, W * 0.6, H * 0.3), hl2 = P(L, -W * 0.6, H * 0.3); ctx.beginPath(); ctx.arc(hl1[0], hl1[1], 1.1, 0, 7); ctx.arc(hl2[0], hl2[1], 1.1, 0, 7); ctx.fill(); } else { // Heckscheibe (kleiner, höher gesetzt) ctx.fillStyle = 'rgba(223,232,236,0.85)'; ctx.beginPath(); ctx.moveTo(...P(L, W * 0.55, winB + 1.5)); ctx.lineTo(...P(L, -W * 0.55, winB + 1.5)); ctx.lineTo(...P(L, -W * 0.55, winT)); ctx.lineTo(...P(L, W * 0.55, winT)); ctx.closePath(); ctx.fill(); // rote Rücklichter ctx.fillStyle = '#c0392b'; const tl1 = P(L, W * 0.68, H * 0.3), tl2 = P(L, -W * 0.68, H * 0.3); ctx.beginPath(); ctx.arc(tl1[0], tl1[1], 1.2, 0, 7); ctx.arc(tl2[0], tl2[1], 1.2, 0, 7); ctx.fill(); } // Stoßstange ctx.strokeStyle = '#2f3e46'; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.moveTo(...P(L, W, H * 0.16)); ctx.lineTo(...P(L, -W, H * 0.16)); ctx.stroke(); // Dach + Highlight-Kante ctx.fillStyle = this._shade(body, 24); ctx.beginPath(); ctx.moveTo(...P(-L, W, H)); ctx.lineTo(...P(L, W, H)); ctx.lineTo(...P(L, -W, H)); ctx.lineTo(...P(-L, -W, H)); ctx.closePath(); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(...P(-L, W, H)); ctx.lineTo(...P(L, W, H)); ctx.stroke(); // --- Feinheiten --- // Zierstreifen unter den Fenstern ctx.strokeStyle = this._shade(body, 32); ctx.lineWidth = 1.3; ctx.beginPath(); ctx.moveTo(...P(-L, W, winB - 1)); ctx.lineTo(...P(L, W, winB - 1)); ctx.stroke(); // Radkästen (Bogen über den Rädern) ctx.strokeStyle = this._shade(body, -36); ctx.lineWidth = 1.5; for (const dc of [-L * 0.55, L * 0.55]) { const wp = P(dc, W); ctx.beginPath(); ctx.moveTo(wp[0] - 3.7, wp[1] + 1); ctx.quadraticCurveTo(wp[0], wp[1] - 4.2, wp[0] + 3.7, wp[1] + 1); ctx.stroke(); } // Türgriff (Richtung Front) ctx.strokeStyle = this._shade(body, -42); ctx.lineWidth = 1; const gh = P(L * 0.16 * dir, W, winB * 0.7); ctx.beginPath(); ctx.moveTo(gh[0] - 2, gh[1]); ctx.lineTo(gh[0] + 2, gh[1]); ctx.stroke(); if (dir === 1) { // Außenspiegel vorne + Rücklicht am hinteren (verdeckten) Ende ctx.fillStyle = '#2f3e46'; const mir = P(L, W * 1.4, H * 0.62); ctx.beginPath(); ctx.arc(mir[0], mir[1], 1.3, 0, 7); ctx.fill(); ctx.fillStyle = '#c0392b'; const rl = P(-L, W, H * 0.3); ctx.beginPath(); ctx.arc(rl[0], rl[1], 1.2, 0, 7); ctx.fill(); } ctx.lineWidth = 1; } _waterStreak(tile, x, y) { const ctx = this.ctx; const p = (this.simTime / 900 + tile.r * 0.7) % 1; ctx.strokeStyle = 'rgba(255,255,255,0.45)'; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.moveTo(x - 12 + p * 10, y - 3 + p * 4); ctx.lineTo(x + 2 + p * 10, y + 2 + p * 4); ctx.stroke(); ctx.lineWidth = 1; } _shadow(x, y, rx, ry = null) { const ctx = this.ctx; ctx.fillStyle = 'rgba(47,62,70,0.14)'; ctx.beginPath(); ctx.ellipse(x, y + 2, rx, ry ?? rx * 0.45, 0, 0, 7); ctx.fill(); } // KI-Sprite zeichnen, falls geladen; sonst false (→ Vektor-Fallback) _sprite(name, x, y, scale = 1, flip = false, centerScale = false) { const s = this.sprites[name]; if (!s) return false; // w/bottom IMMER live aus SPRITE_DEFS lesen – so wirken Editor- // Justierungen sofort (die geladene Kopie wäre eingefroren). const def = SPRITE_DEFS[name] || s; const w = def.w * scale; this._shadow(x, y + 2, w * 0.38, w * 0.16); // Motiv endet im 1024er-Bild bei ~88 % der Höhe. // centerScale (Megabauwerke): Stufen-Wachstum vom MOTIV-ZENTRUM aus – // die Mitte bleibt fix, statt dass der Fuß klebt und alles nach oben wächst. const top = centerScale ? (y + def.bottom - 0.38 * def.w) - w / 2 : y + def.bottom - w * 0.88; if (flip) { const ctx = this.ctx; ctx.save(); ctx.translate(x, 0); ctx.scale(-1, 1); ctx.drawImage(s.img, -w / 2, top, w, w); ctx.restore(); } else { this.ctx.drawImage(s.img, x - w / 2, top, w, w); } return true; } _drawTree(x, y, snowy, tint) { const ctx = this.ctx; const deciduous = (tint % 4) === 1 && y > 0; // etwas Abwechslung const autumn = this.state.season === 'Herbst'; // KI-Sprites, wenn vorhanden (Herbst-Laubbaum bleibt Vektor: orange!) if (snowy && this._sprite('fir_snow', x, y)) return; if (!snowy && !deciduous && this._sprite('fir', x, y)) return; if (!snowy && deciduous && !autumn && this._sprite('broadleaf', x, y)) return; this._shadow(x, y, 9); if (deciduous) { ctx.fillStyle = '#6b4f2f'; ctx.fillRect(x - 1.5, y - 10, 3, 10); ctx.fillStyle = snowy ? '#e7efe9' : autumn ? '#d08a3e' : '#5f9455'; ctx.beginPath(); ctx.arc(x, y - 16, 8, 0, 7); ctx.arc(x - 5, y - 12, 5, 0, 7); ctx.arc(x + 5, y - 12, 5, 0, 7); ctx.fill(); } else { // Tanne mit zwei Etagen ctx.fillStyle = '#6b4f2f'; ctx.fillRect(x - 1.5, y - 8, 3, 8); const green = snowy ? '#e7efe9' : '#3c6b4f'; ctx.fillStyle = green; ctx.beginPath(); ctx.moveTo(x, y - 30); ctx.lineTo(x + 8, y - 16); ctx.lineTo(x - 8, y - 16); ctx.closePath(); ctx.fill(); ctx.fillStyle = this._shade(green === '#e7efe9' ? '#e7efe9' : '#3c6b4f', snowy ? -8 : 6); ctx.beginPath(); ctx.moveTo(x, y - 22); ctx.lineTo(x + 10, y - 7); ctx.lineTo(x - 10, y - 7); ctx.closePath(); ctx.fill(); } } // Punkt auf der Kachel: dc/dr in Kachel-Einheiten (−0.5 … 0.5) _pt(x, y, dc, dr) { return [x + (dc - dr) * TW2, y + (dc + dr) * TH2]; } // Liebevolles Alpenhaus: zwei sichtbare Wände, Giebel, zwei Dachschrägen, // Fenster, Tür, optional Kamin mit Rauch. _alpineHouse(x, y, o) { const ctx = this.ctx; const w2 = o.w / 2, d2 = o.d / 2, h = o.h, rh = o.rh; const N = this._pt(x, y, -w2, -d2); const E = this._pt(x, y, w2, -d2); const S = this._pt(x, y, w2, d2); const W = this._pt(x, y, -w2, d2); const up = p => [p[0], p[1] - h]; const Nt = up(N), Et = up(E), St = up(S), Wt = up(W); const G1 = [(Wt[0] + St[0]) / 2, (Wt[1] + St[1]) / 2 - rh]; // Giebel vorne links const G2 = [(Nt[0] + Et[0]) / 2, (Nt[1] + Et[1]) / 2 - rh]; // Giebel hinten rechts this._shadow(x, y + 4, o.w * TW2 * 0.75, o.d * TH2 * 0.75); // linke Wand (Giebelseite, dunkler) ctx.fillStyle = this._shade(o.wall, -22); ctx.beginPath(); ctx.moveTo(...W); ctx.lineTo(...S); ctx.lineTo(...St); ctx.lineTo(...G1); ctx.lineTo(...Wt); ctx.closePath(); ctx.fill(); // rechte Wand (hell) ctx.fillStyle = o.wall; ctx.beginPath(); ctx.moveTo(...S); ctx.lineTo(...E); ctx.lineTo(...Et); ctx.lineTo(...St); ctx.closePath(); ctx.fill(); // Tür auf der Giebelseite if (o.door !== false) { const dx = W[0] + (S[0] - W[0]) * 0.45; const dy = W[1] + (S[1] - W[1]) * 0.45; ctx.fillStyle = 'rgba(60,45,30,0.85)'; ctx.fillRect(dx - 3, dy - 9, 6, 9); } // Fenster auf der rechten Wand (warm erleuchtet) const wins = o.windows ?? 1; for (let i = 0; i < wins; i++) { for (let row = 0; row < (o.floors ?? 1); row++) { const f = (i + 1) / (wins + 1); const wx = S[0] + (E[0] - S[0]) * f; const wy = S[1] + (E[1] - S[1]) * f - h * (0.35 + row * 0.38); ctx.fillStyle = '#f4c86a'; ctx.fillRect(wx - 2.5, wy - 3, 5, 6); ctx.strokeStyle = 'rgba(47,62,70,0.4)'; ctx.strokeRect(wx - 2.5, wy - 3, 5, 6); } } // Dach: rechte Schräge sichtbar, linke als schmale Silhouette ctx.fillStyle = this._shade(o.roof, -18); ctx.beginPath(); ctx.moveTo(...Wt); ctx.lineTo(...Nt); ctx.lineTo(...G2); ctx.lineTo(...G1); ctx.closePath(); ctx.fill(); ctx.fillStyle = o.roof; ctx.beginPath(); ctx.moveTo(...St); ctx.lineTo(...Et); ctx.lineTo(...G2); ctx.lineTo(...G1); ctx.closePath(); ctx.fill(); // Firstlinie ctx.strokeStyle = this._shade(o.roof, -30); ctx.beginPath(); ctx.moveTo(...G1); ctx.lineTo(...G2); ctx.stroke(); // Kamin mit Rauch if (o.chimney) { const cx = G1[0] + (G2[0] - G1[0]) * 0.7; const cy = G1[1] + (G2[1] - G1[1]) * 0.7; ctx.fillStyle = '#8a8378'; ctx.fillRect(cx - 2.5, cy - 8, 5, 8); for (let i = 0; i < 3; i++) { const t = (this.simTime / 1400 + i / 3) % 1; ctx.fillStyle = `rgba(230,230,225,${(1 - t) * 0.4})`; ctx.beginPath(); ctx.arc(cx + Math.sin(t * 6) * 2 + t * 4, cy - 10 - t * 16, 2 + t * 3, 0, 7); ctx.fill(); } } } _drawDeco(kind, x, y) { if (this._sprite(kind, x, y)) return; if (kind === 'house') { this._alpineHouse(x, y, { w: 0.5, d: 0.4, h: 11, rh: 7, wall: '#e5dcc6', roof: '#7a5a3a', windows: 1, chimney: true, }); } else if (kind === 'church') { this._alpineHouse(x, y, { w: 0.5, d: 0.42, h: 13, rh: 8, wall: '#efe9da', roof: '#8c8073', windows: 1, door: false, }); const ctx = this.ctx; // Kirchturm mit Zwiebelhaube ctx.fillStyle = '#efe9da'; ctx.fillRect(x + 6, y - 36, 8, 24); ctx.fillStyle = 'rgba(47,62,70,0.5)'; ctx.fillRect(x + 8.5, y - 32, 3, 4); ctx.fillStyle = '#8c8073'; ctx.beginPath(); ctx.moveTo(x + 4, y - 36); ctx.quadraticCurveTo(x + 10, y - 48, x + 16, y - 36); ctx.closePath(); ctx.fill(); ctx.strokeStyle = '#8c8073'; ctx.beginPath(); ctx.moveTo(x + 10, y - 46); ctx.lineTo(x + 10, y - 50); ctx.stroke(); } } _drawBuilding(type, x, y, tile) { const ctx = this.ctx; const level = tile.level || 1; // 2×2-Gebäude (Bikepark oder Megabauwerk): zentriert über dem Areal const isBig = (BUILDINGS[type]?.size || 1) > 1 || tile.mega; if (isBig) { const ctr = this.tileToWorld(tile.c + 0.5, tile.r + 0.5, tile.elev); x = ctr.x; y = ctr.y; } // KI-Sprites für Gebäude (Bahnen & Rodelbahn bleiben Vektor: dynamisch) let spriteName = type === 'hotel' && level >= 2 ? 'hotel2' : type; let spriteScale = type === 'hotel' ? (level >= 3 ? 1.15 : 1) : [1, 1.15, 1.3][Math.min(2, level - 1)]; if (tile.mega) { // Eigenes Mega-Motiv (glaubwürdige Proportionen); wächst mit der // Ausbaustufe: I=90 %, II=100 %, III=112 %, IV=124 %. Fallback ×1,4. if (this.sprites[`mega_${type}`]) { spriteName = `mega_${type}`; spriteScale = [0.9, 1, 1.12, 1.24][Math.min(3, level - 1)]; } else { spriteScale *= 1.4; } } if (type !== 'lift' && type !== 'gondola' && type !== 'sled' && this._sprite(spriteName, x, y, spriteScale, false, !!tile.mega)) { this._badge(x + 18, y - 34, level); if (type === 'badesee' && this.state.season === 'Sommer') { // Schwimmer: wippende Köpfe + kleine Planscher im See for (let i = 0; i < 3; i++) { const p = this.simTime / 1400 + i * 2.2; const sx = x + Math.sin(p * 0.7 + i) * 12; const sy = y - 8 + Math.cos(p * 0.5 + i * 2) * 4; ctx.fillStyle = '#f0d4b8'; ctx.beginPath(); ctx.arc(sx, sy + Math.sin(p * 3) * 0.8, 1.8, 0, 7); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.7)'; ctx.beginPath(); ctx.arc(sx, sy + 2, 3.5, 0.2, Math.PI - 0.2); ctx.stroke(); } } if (type === 'snow' && this.state.season === 'Winter' && this.state.snow < 45) { ctx.fillStyle = 'rgba(255,255,255,0.55)'; for (let i = 0; i < 3; i++) { const p = (this.simTime / 700 + i / 3) % 1; ctx.beginPath(); ctx.arc(x + 10 + p * 16, y - 24 - p * 10 + i * 2, 1.5 + p, 0, 7); ctx.fill(); } } // Leben in den Funktions-Gebäuden (dezente Dauer-Animationen) if (type === 'playground') { // Schaukel pendelt + alle paar Sekunden rutscht jemand const sw = Math.sin(this.simTime / 420) * 0.55; ctx.strokeStyle = '#5a5f63'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x + 12, y - 16); ctx.lineTo(x + 12 + Math.sin(sw) * 9, y - 16 + Math.cos(sw) * 9); ctx.stroke(); ctx.fillStyle = '#2f6ea0'; ctx.beginPath(); ctx.arc(x + 12 + Math.sin(sw) * 9, y - 15 + Math.cos(sw) * 9, 1.8, 0, 7); ctx.fill(); const sp = (this.simTime % 4200) / 4200; if (sp < 0.35) { const q = sp / 0.35; ctx.fillStyle = '#a34a8e'; ctx.beginPath(); ctx.arc(x - 10 + q * 9, y - 15 + q * 8, 1.8, 0, 7); ctx.fill(); } } if (type === 'ropes') { // Zipline-Gleiter pendelt zwischen den Bäumen (mit Seildurchhang) const zp = (this.simTime % 5200) / 5200; const q = zp < 0.5 ? zp * 2 : (1 - zp) * 2; const zx = x - 16 + q * 30; const zy = y - 26 + Math.sin(q * Math.PI) * 5; ctx.strokeStyle = '#5a5f63'; ctx.lineWidth = 0.8; ctx.beginPath(); ctx.moveTo(zx, zy); ctx.lineTo(zx, zy + 4.5); ctx.stroke(); ctx.fillStyle = '#c9531f'; ctx.beginPath(); ctx.arc(zx, zy + 6, 1.9, 0, 7); ctx.fill(); } if (type === 'platform') { // Ausflugsgäste fotografieren: kurzer Blitz alle ~4 s const fp = this.simTime % 4300; if (fp < 260) { const a = 1 - fp / 260; ctx.strokeStyle = `rgba(255,255,240,${a.toFixed(2)})`; ctx.lineWidth = 1.4; for (let i = 0; i < 4; i++) { const ang = i * Math.PI / 2 + 0.4; ctx.beginPath(); ctx.moveTo(x + 8 + Math.cos(ang) * 2, y - 22 + Math.sin(ang) * 2); ctx.lineTo(x + 8 + Math.cos(ang) * (4 + a * 4), y - 22 + Math.sin(ang) * (4 + a * 4)); ctx.stroke(); } } } if (type === 'bikerental') { // Werkstatt: rotierendes Vorderrad am Ständer const wa = this.simTime / 260; ctx.strokeStyle = '#2f3e46'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(x + 15, y - 6, 3, 0, 7); ctx.stroke(); for (let i = 0; i < 3; i++) { const a2 = wa + i * (Math.PI * 2 / 3); ctx.beginPath(); ctx.moveTo(x + 15, y - 6); ctx.lineTo(x + 15 + Math.cos(a2) * 3, y - 6 + Math.sin(a2) * 3); ctx.stroke(); } } if (type === 'restaurant') { // Kamin dampft – es wird gekocht ctx.fillStyle = 'rgba(240,238,230,0.5)'; for (let i = 0; i < 3; i++) { const p = (this.simTime / 1600 + i / 3) % 1; ctx.beginPath(); ctx.arc(x + 9 + Math.sin(p * 5 + i) * 2, y - 30 - p * 12, 1.2 + p * 2.2, 0, 7); ctx.fill(); } } if (tile.eco) this._drawSolar(spriteName, x, y, !!tile.mega || (BUILDINGS[type]?.size || 1) > 1, level); return; } switch (type) { case 'restaurant': this._alpineHouse(x, y, { w: 0.62, d: 0.5, h: 16, rh: 10, wall: '#f2e0c0', roof: '#b0563a', windows: 2, chimney: true, }); // Sonnenschirm davor (außer im Winter) if (this.state.season !== 'Winter') { ctx.fillStyle = '#e8892b'; ctx.beginPath(); ctx.moveTo(x - 18, y + 12); ctx.lineTo(x - 10, y + 6); ctx.lineTo(x - 4, y + 12); ctx.closePath(); ctx.fill(); ctx.strokeStyle = '#6b4f2f'; ctx.beginPath(); ctx.moveTo(x - 11, y + 10); ctx.lineTo(x - 11, y + 17); ctx.stroke(); } break; default: // Fallback für Gebäude ohne Sprite und ohne Spezialzeichnung: // kleines Alpenhaus + Emoji-Schild, bis der Sprite geladen ist. this._alpineHouse(x, y, { w: 0.55, d: 0.45, h: 13, rh: 8, wall: '#ece4d2', roof: '#8a6f4d', windows: 1, }); ctx.font = '12px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(BUILDINGS[type]?.emoji || '❓', x, y - 24); break; case 'rescue': this._alpineHouse(x, y, { w: 0.6, d: 0.5, h: 15, rh: 9, wall: '#f2ede2', roof: '#b0563a', windows: 2, chimney: false, }); // rotes Kreuz an der Wand (Bergrettung) ctx.strokeStyle = '#c0392b'; ctx.lineWidth = 2.4; ctx.beginPath(); ctx.moveTo(x - 3, y - 4); ctx.lineTo(x + 3, y - 4); ctx.moveTo(x, y - 7); ctx.lineTo(x, y - 1); ctx.stroke(); ctx.lineWidth = 1; break; case 'hotel': this._alpineHouse(x, y, { w: 0.72, d: 0.55, h: 27, rh: 10, wall: '#ede6d6', roof: '#8c2f2f', windows: 3, floors: 2, chimney: true, }); break; case 'staffhouse': this._alpineHouse(x, y, { w: 0.55, d: 0.45, h: 14, rh: 8, wall: '#b9c9a0', roof: '#44513f', windows: 2, chimney: true, }); break; case 'lift': case 'gondola': { // Talstation + Seil über die Piste bis zur Bergstation, mit // leeren Sesseln (bzw. Gondelkabinen); Fahrgäste: _drawRiders const ends = this._liftEnds(tile); this._alpineHouse(x, y, { w: 0.5, d: 0.4, h: 10, rh: 6, wall: '#d9cfb4', roof: '#5a5f63', windows: 1, door: false, }); ctx.strokeStyle = '#5a5f63'; ctx.lineWidth = 2.5; for (let f = 0.22; f < 1; f += 0.2) { const px = ends.a.x + (ends.b.x - ends.a.x) * f; const py = ends.aGround + (ends.bGround - ends.aGround) * f; const cy = ends.a.y + (ends.b.y - ends.a.y) * f; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, cy); ctx.stroke(); ctx.beginPath(); ctx.moveTo(px - 5, cy); ctx.lineTo(px + 5, cy); ctx.stroke(); } ctx.fillStyle = '#5a5f63'; const stW = 18 + (level - 1) * 6; ctx.fillRect(ends.b.x - stW / 2, ends.b.y - 4, stW, 11); ctx.beginPath(); ctx.moveTo(ends.a.x, ends.a.y); ctx.lineTo(ends.b.x, ends.b.y); ctx.stroke(); if (level >= 2) { // Stufe II: zweites Seil (Doppelbahn) – deutlich erkennbar ctx.beginPath(); ctx.moveTo(ends.a.x + 5, ends.a.y + 3); ctx.lineTo(ends.b.x + 5, ends.b.y + 3); ctx.stroke(); } ctx.lineWidth = 1; // leere Sessel/Kabinen fahren gemächlich mit (Stufe II: mehr davon) const len = BUILDINGS[type].pisteLen || PISTE_LEN; const cycle = 24000 * (len / PISTE_LEN); const n = (type === 'gondola' ? 3 : 4) + (level - 1) * 2; for (let i = 0; i < n; i++) { let f = ((this.simTime / cycle) + i / n) % 1; if (f > 0.5) f = 1 - f; f *= 2; const gx = ends.a.x + (ends.b.x - ends.a.x) * f; const gy = ends.a.y + (ends.b.y - ends.a.y) * f; // Stufe II: jede zweite Einheit fährt am zweiten Seil const off = level >= 2 && i % 2 ? 5 : 0; if (type === 'gondola') this._cabin(gx + off, gy + off * 0.6); else this._chair(gx + off, gy + off * 0.6); } this._badge(x + 14, y - 24, level); break; } case 'snow': { this._shadow(x, y, 8); ctx.strokeStyle = '#5a5f63'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + 6, y - 16); ctx.stroke(); ctx.lineWidth = 1; ctx.fillStyle = '#dfe8ec'; ctx.beginPath(); ctx.ellipse(x + 8, y - 18, 6, 4, -0.5, 0, 7); ctx.fill(); ctx.fillStyle = '#5a5f63'; ctx.fillRect(x - 4, y - 3, 8, 4); if (this.state.season === 'Winter' && this.state.snow < 45) { ctx.fillStyle = 'rgba(255,255,255,0.55)'; for (let i = 0; i < 3; i++) { const p = (this.simTime / 700 + i / 3) % 1; ctx.beginPath(); ctx.arc(x + 10 + p * 16, y - 20 - p * 10 + i * 2, 1.5 + p, 0, 7); ctx.fill(); } } break; } case 'badesee': { this._shadow(x, y + 2, 20, 8); ctx.fillStyle = '#7fc4d9'; ctx.beginPath(); ctx.ellipse(x, y - 2, 20, 10, 0, 0, 7); ctx.fill(); ctx.strokeStyle = '#e8dfc0'; ctx.lineWidth = 3; ctx.beginPath(); ctx.ellipse(x, y - 2, 21, 11, 0, 0, 7); ctx.stroke(); ctx.lineWidth = 1; // Steg + Sonnenschirm ctx.fillStyle = '#8a6a42'; ctx.fillRect(x - 3, y - 8, 14, 3); ctx.fillStyle = '#e8892b'; ctx.beginPath(); ctx.moveTo(x - 14, y - 16); ctx.lineTo(x - 6, y - 12); ctx.lineTo(x - 14, y - 8); ctx.closePath(); ctx.fill(); break; } case 'sled': { // Nur die Starthütte hier – Schiene & Schlitten zeichnet // _drawSledRuns() NACH allen Kacheln (sonst übermalt der Boden // der tieferliegenden Felder die hangabwärts führende Bahn!) this._shadow(x, y + 2, 12, 5); this._alpineHouse(x - 6, y - 2, { w: 0.34, d: 0.3, h: 9, rh: 6, wall: '#d9cfb4', roof: '#8a6a42', windows: 0, door: false, }); break; } case 'trail': { // Wegweiser + Pfadsteine (Fallback, falls Sprite fehlt) this._shadow(x, y, 10, 4); ctx.strokeStyle = '#6b4f2f'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y - 22); ctx.stroke(); ctx.lineWidth = 1; ctx.fillStyle = '#e8892b'; ctx.beginPath(); ctx.moveTo(x - 1, y - 20); ctx.lineTo(x + 13, y - 20); ctx.lineTo(x + 17, y - 17); ctx.lineTo(x + 13, y - 14); ctx.lineTo(x - 1, y - 14); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#d9cfb4'; ctx.beginPath(); ctx.moveTo(x + 1, y - 12); ctx.lineTo(x - 13, y - 12); ctx.lineTo(x - 17, y - 9); ctx.lineTo(x - 13, y - 6); ctx.lineTo(x + 1, y - 6); ctx.closePath(); ctx.fill(); ctx.fillStyle = 'rgba(180,168,140,0.8)'; for (let i = 0; i < 4; i++) { ctx.beginPath(); ctx.ellipse(x - 10 + i * 7, y + 4 + (i % 2) * 3, 3, 1.6, 0, 0, 7); ctx.fill(); } break; } case 'access': { this._shadow(x, y + 2, 16, 7); // Wartehäuschen ctx.fillStyle = '#d9cfb4'; ctx.fillRect(x - 14, y - 14, 18, 14); ctx.fillStyle = '#5a5f63'; ctx.beginPath(); ctx.moveTo(x - 16, y - 14); ctx.lineTo(x - 5, y - 20); ctx.lineTo(x + 6, y - 14); ctx.closePath(); ctx.fill(); ctx.fillStyle = 'rgba(47,62,70,0.35)'; ctx.fillRect(x - 11, y - 10, 12, 7); // Haltestellen-Schild ctx.strokeStyle = '#5a5f63'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x + 12, y + 2); ctx.lineTo(x + 12, y - 18); ctx.stroke(); ctx.lineWidth = 1; ctx.fillStyle = '#e8892b'; ctx.beginPath(); ctx.arc(x + 12, y - 21, 5, 0, 7); ctx.fill(); ctx.fillStyle = '#fff'; ctx.font = 'bold 6px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('BUS', x + 12, y - 19); break; } } if (tile.eco) this._drawSolar(type, x, y, !!tile.mega, level); } // Solardach: Zeichen des Öko-Umbaus – dunkles Panel mit weißen Stegen // und kleinem Glanzpunkt. Position pro Sprite-Key UND Ausbaustufe // justierbar: erst "@L", dann "", dann Default. _drawSolar(key, x, y, big = false, level = 1) { const ctx = this.ctx; const pos = SOLAR_POS[`${key}@L${level}`] || SOLAR_POS[key] || (big ? SOLAR_POS.defaultBig : SOLAR_POS.default); const s = pos.s ?? 1; ctx.save(); ctx.translate(x + (pos.dx ?? 0), y + (pos.dy ?? 0)); ctx.scale(s, s); // Panoramaweg & Hochseilgarten: statt Solarzelle ein gepflegter // Mülleimer am Wegrand (Natur-Attraktionen haben kein Dach) if (['trail', 'ropes'].includes(key.replace(/^mega_/, ''))) { ctx.fillStyle = '#3f6b4f'; // Korpus ctx.fillRect(-2.4, -5.2, 4.8, 5.6); ctx.fillStyle = this._shade('#3f6b4f', -18); // Schattenseite ctx.fillRect(0.6, -5.2, 1.8, 5.6); ctx.fillStyle = '#2f3e46'; // Deckel ctx.fillRect(-3, -6.6, 6, 1.7); ctx.fillStyle = 'rgba(255,255,255,0.75)'; // Einwurf ctx.fillRect(-1.4, -6.2, 2.8, 0.8); ctx.restore(); return; } ctx.fillStyle = '#2d4a66'; ctx.beginPath(); ctx.moveTo(-9, 3); ctx.lineTo(1, -2); ctx.lineTo(10, 2.5); ctx.lineTo(0, 7.5); ctx.closePath(); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = 0.7; ctx.beginPath(); ctx.moveTo(-5.5, 4.6); ctx.lineTo(4.5, -0.4); ctx.moveTo(-2, 6); ctx.lineTo(8, 1); ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.beginPath(); ctx.arc(6.5, 0.6, 1, 0, 7); ctx.fill(); ctx.restore(); } // Seil-Endpunkte eines Lifts: a = Talstation, b = Bergstation (Screen-Koordinaten) _liftEnds(tile) { const base = this.tileToWorld(tile.c, tile.r, tile.elev); const len = BUILDINGS[tile.building]?.pisteLen || PISTE_LEN; const topR = Math.max(0, tile.r - len); const topTile = this.state.map[topR]?.[tile.c]; const top = this.tileToWorld(tile.c, topR, topTile ? topTile.elev : tile.elev); return { a: { x: base.x, y: base.y - 16 }, b: { x: top.x, y: top.y - 26 }, aGround: base.y, bGround: top.y, }; } // Sessel eines Sessellifts (hängt am Seilpunkt gx/gy) _chair(gx, gy, riderHue = null) { const ctx = this.ctx; ctx.strokeStyle = '#5a5f63'; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.moveTo(gx, gy); ctx.lineTo(gx, gy + 6); ctx.stroke(); // Sitzfläche + Lehne ctx.beginPath(); ctx.moveTo(gx - 4, gy + 6); ctx.lineTo(gx + 4, gy + 6); ctx.moveTo(gx + 4, gy + 6); ctx.lineTo(gx + 4, gy + 1); ctx.stroke(); ctx.lineWidth = 1; if (riderHue) { // sitzender Fahrgast: Körper + Kopf + angewinkelte Beine ctx.fillStyle = riderHue; ctx.beginPath(); ctx.ellipse(gx, gy + 3.5, 2.6, 3.6, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#f0d4b8'; ctx.beginPath(); ctx.arc(gx, gy - 1.5, 2.2, 0, 7); ctx.fill(); ctx.strokeStyle = '#3a4750'; ctx.beginPath(); ctx.moveTo(gx - 1, gy + 6); ctx.lineTo(gx - 1, gy + 9); ctx.moveTo(gx + 1.5, gy + 6); ctx.lineTo(gx + 1.5, gy + 9); ctx.stroke(); } } // Geschlossene Gondelkabine (hängt am Seilpunkt gx/gy) _cabin(gx, gy, riderHue = null) { const ctx = this.ctx; ctx.strokeStyle = '#5a5f63'; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.moveTo(gx, gy); ctx.lineTo(gx, gy + 3); ctx.stroke(); ctx.lineWidth = 1; ctx.fillStyle = '#c9531f'; ctx.beginPath(); ctx.roundRect ? ctx.roundRect(gx - 5, gy + 3, 10, 10, 2) : ctx.rect(gx - 5, gy + 3, 10, 10); ctx.fill(); ctx.fillStyle = riderHue || 'rgba(223,232,236,0.9)'; ctx.fillRect(gx - 3, gy + 5, 6, 4); } // Fahrgäste in Sessellift/Gondel (Modus 'ride') sichtbar unterwegs _drawRiders() { for (const g of this.guests) { if (g.mode !== 'ride' || !g.lift) continue; const tile = this.state.map[g.lift.r]?.[g.lift.c]; if (!tile) continue; const ends = this._liftEnds(tile); const f = Math.min(1, g.ride / this._rideMsOf(g)); const gx = ends.a.x + (ends.b.x - ends.a.x) * f; const gy = ends.a.y + (ends.b.y - ends.a.y) * f; if (g.lift.type === 'gondola') this._cabin(gx, gy, g.hue); else this._chair(gx, gy, g.hue); } } _drawPreview() { const t = this.hoverTile; if (!t || !this.buildType) return; const tile = this.state.map[t.r]?.[t.c]; if (!tile) return; const demolishMode = this.buildType === 'demolish'; const upgradeMode = this.buildType === 'upgrade'; const ok = demolishMode ? !!tile.building : upgradeMode ? !!tile.building && UPGRADABLE.includes(tile.building) && (tile.level || 1) < 3 : canPlace(this.state, this.buildType, t.c, t.r).ok; const { x, y } = this.tileToWorld(t.c, t.r, tile.elev); // Lange Elemente zeigen ihren ganzen Verlauf schon in der Vorschau const fill = ok ? 'rgba(123,160,91,0.28)' : 'rgba(200,60,50,0.25)'; const def = BUILDINGS[this.buildType]; // Einflusskreis: Gasthaus 5 Felder (Synergie), Personalhaus 6 (Personal) const RADIUS = { restaurant: 5, staffhouse: 6 }[this.buildType]; if (RADIUS) { this.ctx.strokeStyle = 'rgba(79,127,94,0.7)'; this.ctx.fillStyle = 'rgba(123,160,91,0.10)'; this.ctx.setLineDash([7, 5]); this.ctx.lineWidth = 1.8; this.ctx.beginPath(); this.ctx.ellipse(x, y, (RADIUS + 0.5) * TW2, (RADIUS + 0.5) * TH2, 0, 0, 7); this.ctx.fill(); this.ctx.stroke(); this.ctx.setLineDash([]); this.ctx.lineWidth = 1; // Versorgungslinien: welche Gebäude im Kreis bedient/genutzt werden? const serves = this.buildType === 'restaurant' ? ['lift', 'gondola', 'badesee', 'sled', 'trail', 'hotel'] : null; // staffhouse: alle Betriebe mit Personal this.ctx.strokeStyle = 'rgba(232,137,43,0.9)'; this.ctx.lineWidth = 2; for (const b of this.state.buildings) { const inRange = Math.max(Math.abs(b.c - t.c), Math.abs(b.r - t.r)) <= RADIUS; if (!inRange) continue; const hit = serves ? serves.includes(b.type) : (BUILDINGS[b.type].staff || 0) > 0; if (!hit) continue; const bt = this.state.map[b.r][b.c]; const p = this.tileToWorld(b.c, b.r, bt.elev); this.ctx.beginPath(); this.ctx.moveTo(x, y - 4); this.ctx.lineTo(p.x, p.y - 8); this.ctx.stroke(); this.ctx.fillStyle = 'rgba(232,137,43,0.9)'; this.ctx.beginPath(); this.ctx.arc(p.x, p.y - 8, 3, 0, 7); this.ctx.fill(); } this.ctx.lineWidth = 1; } if (def?.pisteLen) { // Pistenkorridor hangaufwärts (Lift/Gondel) for (let i = 1; i <= def.pisteLen; i++) { const tt = this.state.map[t.r - i]?.[t.c]; if (!tt) break; const p = this.tileToWorld(t.c, t.r - i, tt.elev); this._diamond(p.x, p.y); this.ctx.fillStyle = fill; this.ctx.fill(); } } else if (this.buildType === 'sled') { // Bahnverlauf hangabwärts (Stufe I = 5 Felder) for (let i = 1; i <= 5; i++) { const rr = Math.min(MAP_ROWS - 1, t.r + i); const cc = t.c + Math.sin(i * 1.6 + tile.tint) * 0.5; const tt = this.state.map[rr]?.[Math.round(Math.max(0, Math.min(MAP_COLS - 1, cc)))]; if (!tt) break; const p = this.tileToWorld(cc, rr, tt.elev); this._diamond(p.x, p.y); this.ctx.fillStyle = fill; this.ctx.fill(); } } else if (def?.trailLen) { // Bikepark: die ganze reservierte Strecke talwärts zeigen – // pro Kachel grün/rot, je nachdem ob sie frei ist for (let i = 1; i <= def.trailLen; i++) { const tt = this.state.map[t.r + i]?.[t.c]; if (!tt) break; const free = !tt.building && !tt.bikeTrail && !tt.road && !['water', 'rock', 'village'].includes(tt.terrain); const p = this.tileToWorld(t.c, t.r + i, tt.elev); this._diamond(p.x, p.y); this.ctx.fillStyle = free ? fill : 'rgba(200,60,50,0.4)'; this.ctx.fill(); } } this._diamond(x, y); this.ctx.fillStyle = ok ? 'rgba(123,160,91,0.5)' : 'rgba(200,60,50,0.45)'; this.ctx.fill(); this.ctx.strokeStyle = ok ? '#4f7f5e' : '#a33227'; this.ctx.lineWidth = 2; this.ctx.stroke(); this.ctx.lineWidth = 1; } // ---------- Skelett-Walker (portiert aus der Tower-Defense-App) ---------- // „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. _rrect(x, y, w, h, r) { const ctx = this.ctx; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(x, y, w, h, r); else ctx.rect(x, y, w, h); ctx.fill(); } _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 = o.still ? 0 : Math.abs(Math.cos(t)) * H * 0.04; const lean = o.still ? 0.02 : 0.12; const skin = '#f0d4b8'; const 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) => { 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 = o.still ? 0 : 0.58 * Math.sin(ph); const knee = o.still ? 0 : Math.max(0, Math.sin(ph + 0.8)) * 0.95; const foot = limb(hip.x, hip.y, thighA, thigh, thighA - knee, shin, H * 0.10, color); 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 = o.still ? 0.08 : -0.5 * Math.sin(ph); const elbow = o.still ? 0.15 : 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 if (o.pack) { ctx.fillStyle = this._shade(o.color, -40); this._rrect(hip.x - H * 0.24, sh.y + H * 0.02, H * 0.15, H * 0.24, H * 0.05); } // Oberkörper + 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(); // Personal trägt eine Warnweste (deutlich als Arbeiter erkennbar) if (o.worker) { ctx.strokeStyle = '#f4c20d'; ctx.lineWidth = H * 0.09; ctx.beginPath(); ctx.moveTo(hip.x + Math.sin(lean) * torsoL * 0.35, hip.y - Math.cos(lean) * torsoL * 0.35); ctx.lineTo(sh.x, sh.y); ctx.stroke(); } const nod = o.still ? 0 : 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(); // Kopfbedeckung: Personal = weißer Bauhelm, sonst Mütze/Sonnenhut/Haar ctx.fillStyle = o.worker ? '#f4f1e4' : (o.prop === 'hat' ? '#e8d9b0' : this._shade(o.color, 26)); ctx.beginPath(); ctx.arc(head.x, head.y - headR * 0.25, headR * (o.worker || 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 in der vorderen Hand ctx.lineCap = 'round'; if (o.prop === 'pole') { ctx.strokeStyle = '#6b4f2f'; ctx.lineWidth = H * 0.04; ctx.beginPath(); ctx.moveTo(hand.x, hand.y - H * 0.3); ctx.lineTo(hand.x + H * 0.08, hand.y + H * 0.28); 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'; this._rrect(hand.x - H * 0.07, hand.y + H * 0.09, H * 0.14, H * 0.11, H * 0.03); } ctx.lineWidth = 1; ctx.restore(); } _drawGuests(winter) { const ctx = this.ctx; // Painter-Sortierung, damit sich Figuren korrekt überlappen const sorted = [...this.guests].sort((a, b) => (a.x + a.y) - (b.x + b.y)); for (const g of sorted) { if (g.mode === 'ride') continue; // sitzt in der Bahn const { x, y } = this.tileToWorld(g.x, g.y, this.elevAt(g.x, g.y)); const skiing = g.mode === 'ski'; const svx = (g.vx ?? 0) - (g.vy ?? 0); const svy = ((g.vx ?? 0) + (g.vy ?? 0)) * 0.5; const facing = svx >= 0 ? 1 : -1; this._shadow(x, y + 1, 4.5, 2); if (skiing) { // Ski zeigen in Fahrtrichtung, Körper lehnt in die Kurve const ang = Math.atan2(svy, svx || 0.01); ctx.save(); ctx.translate(x, y); ctx.rotate(ang); ctx.strokeStyle = '#c9531f'; ctx.lineWidth = 1.7; ctx.beginPath(); ctx.moveTo(-7, -1.6); ctx.lineTo(8, -1.6); ctx.moveTo(-7, 1.6); ctx.lineTo(8, 1.6); ctx.stroke(); ctx.restore(); ctx.strokeStyle = '#5a5f63'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x - 3 * facing, y - 8); ctx.lineTo(x - 7 * facing, y - 1); ctx.moveTo(x + 2 * facing, y - 8); ctx.lineTo(x - 2 * facing, y - 1); ctx.stroke(); const lean = Math.sin(this.simTime / 250 + g.phase) * 2.5; ctx.fillStyle = g.hue; ctx.beginPath(); ctx.ellipse(x + lean * 0.5, y - 7, 3.2, 4.6, lean * 0.09, 0, 7); ctx.fill(); ctx.fillStyle = '#f0d4b8'; ctx.beginPath(); ctx.arc(x + lean * 0.7, y - 13, 2.7, 0, 7); ctx.fill(); ctx.fillStyle = this._shade(g.hue, 20); ctx.beginPath(); ctx.arc(x + lean * 0.7, y - 14.3, 2.3, Math.PI, 0); ctx.fill(); continue; } // Wanderer als Skelett-Walker (im Sommer mit Hut/Schirm/Tasche) this._walker(x, y, { h: g.kid ? 12.5 : 17, color: g.hue, phase: g.stridePhase || 0, still: g.moving === false, flip: facing < 0, pack: g.pack, prop: winter ? null : g.prop, kid: g.kid, }); this._drawBubble(g, x, y - (g.kid ? 16 : 21)); } } // Sims-artige Gedankenblase mit verständlichem Symbol _drawBubble(g, x, y) { if (!g.bubble || this.simTime > g.bubble.until) return; const ctx = this.ctx; const lift = Math.sin(this.simTime / 300) * 1.2; const by = y - 10 + lift; ctx.fillStyle = 'rgba(255,255,255,0.95)'; ctx.strokeStyle = 'rgba(47,62,70,0.35)'; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(x - 8, by - 8, 16, 15, 6); else ctx.rect(x - 8, by - 8, 16, 15); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x - 2, by + 6.5); ctx.lineTo(x, by + 11); ctx.lineTo(x + 3, by + 6.5); ctx.closePath(); ctx.fill(); ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = '#2f3e46'; ctx.fillText(g.bubble.icon, x, by + 3.5); } // Sichtbare Saisonhöhepunkte: Skirennen auf der Piste, Sommerfest im Dorf _drawFestivals(winter) { const s = this.state; const ctx = this.ctx; // Skirennen: Slalomtore auf der Piste + Zuschauer an der Talstation if (s.race && winter) { const lift = s.buildings.find(b => b.type === 'lift' || b.type === 'gondola'); if (lift) { const gates = s.pistes.filter(p => p.owner.c === lift.c && p.owner.r === lift.r); gates.forEach((p, i) => { const t = s.map[p.r][p.c]; const { x, y } = this.tileToWorld(p.c + (i % 2 ? 0.22 : -0.22), p.r, t.elev); this._flag(x, y, i % 2 ? '#c0392b' : '#2f6ea0'); }); const base = s.map[lift.r][lift.c]; const b = this.tileToWorld(lift.c, lift.r + 0.8, base.elev); ctx.strokeStyle = '#5a5f63'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(b.x - 22, b.y); ctx.lineTo(b.x - 22, b.y - 20); ctx.moveTo(b.x + 22, b.y); ctx.lineTo(b.x + 22, b.y - 20); ctx.stroke(); ctx.lineWidth = 1; ctx.fillStyle = '#e8892b'; ctx.fillRect(b.x - 22, b.y - 20, 44, 8); ctx.fillStyle = '#fff'; ctx.font = 'bold 6px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('ZIEL', b.x, b.y - 14); for (let i = 0; i < 8; i++) { const px = b.x - 26 + i * 7.5; const py = b.y + 8 + (i % 2) * 3; ctx.fillStyle = ['#e8892b', '#2f6ea0', '#a34a8e', '#3f8f6b'][i % 4]; ctx.beginPath(); ctx.ellipse(px, py - 4, 2.4, 4.2, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#f0d4b8'; ctx.beginPath(); ctx.arc(px, py - 9.5, 2, 0, 7); ctx.fill(); } } } // Sommerfest: großes Festzelt, Bühne, Girlanden, Menschenmenge, Musik if (s.fest && !winter) { const cx = CORE.c0 + 7.5, cr = CORE.r0 + 21.5; const ct = this.state.map[Math.floor(cr)]?.[Math.round(cx)]; const { x: fx, y: fy } = this.tileToWorld(cx, cr, ct ? ct.elev : 1); // Wimpelketten von einem Mast zu zwei Seiten const mast = { x: fx, y: fy - 46 }; ctx.strokeStyle = '#6b4f2f'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(fx, fy); ctx.lineTo(mast.x, mast.y); ctx.stroke(); for (const dir of [-1, 1]) { const end = { x: fx + dir * 62, y: fy - 6 }; ctx.strokeStyle = '#6b4f2f'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(mast.x, mast.y); ctx.quadraticCurveTo((mast.x + end.x) / 2, (mast.y + end.y) / 2 + 14, end.x, end.y); ctx.stroke(); for (let i = 1; i < 9; i++) { const f = i / 9; const gx = (1 - f) * (1 - f) * mast.x + 2 * (1 - f) * f * ((mast.x + end.x) / 2) + f * f * end.x; const gy = (1 - f) * (1 - f) * mast.y + 2 * (1 - f) * f * ((mast.y + end.y) / 2 + 14) + f * f * end.y; ctx.fillStyle = ['#e8892b', '#7ba05b', '#2f6ea0', '#c0392b'][i % 4]; ctx.beginPath(); ctx.moveTo(gx - 3, gy); ctx.lineTo(gx + 3, gy); ctx.lineTo(gx, gy + 6); ctx.closePath(); ctx.fill(); } } // Großes Festzelt (Streifen-Dach) mittig this._shadow(fx, fy + 4, 34, 13); const tentW = 30, tentH = 20; ctx.fillStyle = '#f4ede0'; ctx.fillRect(fx - tentW, fy - tentH, tentW * 2, tentH); // gestreiftes Spitzdach for (let i = -3; i < 3; i++) { ctx.fillStyle = i % 2 ? '#c0392b' : '#f4ede0'; ctx.beginPath(); ctx.moveTo(fx + i * (tentW / 3), fy - tentH); ctx.lineTo(fx + (i + 1) * (tentW / 3), fy - tentH); ctx.lineTo(fx, fy - tentH - 16); ctx.closePath(); ctx.fill(); } // Wimpel auf der Spitze ctx.fillStyle = '#e8892b'; ctx.beginPath(); ctx.moveTo(fx, fy - tentH - 16); ctx.lineTo(fx + 10, fy - tentH - 13); ctx.lineTo(fx, fy - tentH - 10); ctx.closePath(); ctx.fill(); // Zelteingang ctx.fillStyle = '#8a5a2f'; ctx.fillRect(fx - 5, fy - 12, 10, 12); // Kleine Bühne mit Musiker-Note daneben ctx.fillStyle = '#8a6a42'; ctx.fillRect(fx + tentW - 2, fy - 8, 18, 8); const bob = Math.sin(this.simTime / 220) * 2; ctx.fillStyle = '#2f3e46'; ctx.font = '11px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('🎵', fx + tentW + 7, fy - 12 + bob); // Menschenmenge davor (wuselnd) for (let i = 0; i < 14; i++) { const ang = i * 1.7; const rx = fx + Math.cos(ang) * (14 + (i % 5) * 5); const ry = fy + 12 + Math.sin(ang) * 7 + (i % 3) * 2; const j = Math.sin(this.simTime / 180 + i) * 0.8; ctx.fillStyle = ['#e8892b', '#2f6ea0', '#a34a8e', '#3f8f6b', '#c0392b'][i % 5]; ctx.beginPath(); ctx.ellipse(rx, ry - 4 + j, 2, 3.4, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#f0d4b8'; ctx.beginPath(); ctx.arc(rx, ry - 8 + j, 1.6, 0, 7); ctx.fill(); } } } // Auslastungs-Punkte ueber Gebaeuden: Personal (besetzt/frei) + Gaeste _drawOccupancy() { if (this.cam.scale < 1.1) return; const s = this.state; const ctx = this.ctx; const levelMul = b => 1 + 0.5 * ((b.level || 1) - 1); let avail = s.staffAvailable; const guestActive = s.guestsToday > 0; for (const b of s.buildings) { const def = BUILDINGS[b.type]; const need = Math.round((def.staff || 0) * levelMul(b) * seasonFactor(b.type, s.season)); const tile = s.map[b.r][b.c]; const { x, y } = this.tileToWorld(b.c, b.r, tile.elev); let yy = y - 34; if (need > 0) { const filled = Math.max(0, Math.min(need, avail)); avail -= filled; const dw = 4.2, total = need * dw; for (let i = 0; i < need; i++) { ctx.fillStyle = i < filled ? '#2f6ea0' : '#c0392b'; ctx.beginPath(); ctx.arc(x - total / 2 + i * dw + dw / 2, yy, 1.7, 0, 7); ctx.fill(); } yy -= 6; } const servesGuests = ['restaurant', 'hotel', 'lift', 'gondola', 'badesee', 'sled', 'trail'].includes(b.type); const seasonOk = (b.type === 'badesee' || b.type === 'sled' || b.type === 'trail') ? s.season !== 'Winter' : true; if (servesGuests && guestActive && seasonOk) { const pulse = 0.5 + 0.5 * Math.sin(this.time / 350 + b.c); ctx.fillStyle = 'rgba(63,143,94,' + (0.5 + 0.5 * pulse) + ')'; ctx.beginPath(); ctx.arc(x, yy, 2, 0, 7); ctx.fill(); } } } // Personalmangel-Marker über unterbesetzten Betrieben (rotes „!") _drawStaffAlerts() { const s = this.state; if (s.staffNeeded <= s.staffAvailable) return; const ctx = this.ctx; const pulse = 0.6 + 0.4 * Math.sin(this.time / 300); for (const b of s.buildings) { if ((BUILDINGS[b.type].staff || 0) === 0) continue; const t = s.map[b.r][b.c]; const { x, y } = this.tileToWorld(b.c, b.r, t.elev); ctx.globalAlpha = pulse; ctx.fillStyle = '#c0392b'; ctx.beginPath(); ctx.arc(x, y - 30, 6, 0, 7); ctx.fill(); ctx.fillStyle = '#fff'; ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('!', x, y - 27); ctx.globalAlpha = 1; } } // kleine Slalom-Fahne _flag(x, y, color) { const ctx = this.ctx; ctx.strokeStyle = '#5a5f63'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y - 14); ctx.stroke(); ctx.lineWidth = 1; ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(x, y - 14); ctx.lineTo(x + 8, y - 11.5); ctx.lineTo(x, y - 9); ctx.closePath(); ctx.fill(); } // Aufsteigende Texte (Tageseinnahmen, Gebäude-Einnahmen) _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; const tile = this.state.map[Math.floor(f.r)]?.[Math.floor(f.c)]; const { x, y } = this.tileToWorld(f.c, f.r, tile ? tile.elev : 0); ctx.globalAlpha = 1 - age; ctx.font = f.small ? 'bold 10px -apple-system, sans-serif' : 'bold 13px -apple-system, sans-serif'; ctx.textAlign = 'center'; ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(253,251,244,0.9)'; ctx.strokeText(f.text, x, y - 34 - age * 26); ctx.fillStyle = f.color; ctx.fillText(f.text, x, y - 34 - age * 26); ctx.globalAlpha = 1; 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})`; } }