Files
geograsim/App/sims/tourismustal/js/iso.js
T
Adminator 0a2ebf46b4 Tourismustal: Komplett-Integration (Sim + Wrapper + SQL + Lehrer-Backend)
Sim nach App/sims/tourismustal umgezogen (Source of Truth), game.html mit
Injection-Marker. Neu in der Sim: 5 Sommergebaeude (Bikepark mit Flowtrails,
Hochseilgarten, Fahrradverleih als Verstaerker, Aussichtsplattform,
Erlebnisspielplatz), Rettungsstation mit Notarzthubschrauber, Ausbau-
Bauanimation, Event-Pacing, Schulden-Notbremse (Bank-Zwangsverkauf),
Berater-Tipps (Ruecklagen), Sommer-Trend-Mechanik (Serfaus-Effekt) und
Wissens-Bausteine mit echten DACH-Tourismusdaten (QUELLEN.md).

Plattform: PHP-Wrapper mit base-Tag + Session-Mode, Modul-Detailseite,
module_info (beta) + Glossar (14 Begriffe inkl. Leichter Sprache + Quellen)
+ Lehrplan-Anker AT/DE/CH an neuer Kompetenz tourismus-raumentwicklung.
Lehrer-Backend: Benchmark-Formel, Live-Cockpit-Spalten, needsHelp-Heuristik,
Modul-Highlights. Strategie-Testharness (tests/strategien.mjs) belegt:
Panzer-Rush wird liquidiert, Ruecklagen zahlen sich aus.

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

2436 lines
93 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
const SPRITE_DEFS = {
restaurant: { w: 78, bottom: 10 },
hotel: { w: 88, bottom: 10 },
hotel2: { w: 104, bottom: 11 },
staffhouse: { w: 64, bottom: 9 },
house: { w: 54, bottom: 8 },
church: { w: 66, bottom: 9 },
access: { w: 52, bottom: 8 },
rescue: { w: 60, bottom: 9 },
snow: { w: 44, bottom: 7 },
bikepark: { w: 58, bottom: 8 },
ropes: { w: 58, bottom: 8 },
bikerental: { w: 50, bottom: 8 },
platform: { w: 62, bottom: 9 },
playground: { w: 58, bottom: 9 },
// Hinweis: Bus bleibt bewusst Vektor (dynamische Fahrtrichtung).
trail: { w: 48, bottom: 8 },
badesee: { w: 74, bottom: 12 },
bus: { w: 46, bottom: 3 },
fir: { w: 42, bottom: 6 },
fir_snow: { w: 42, bottom: 6 },
broadleaf: { w: 44, bottom: 6 },
};
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;
}
}
if (g.mode === 'crashed') { g.moving = false; continue; } // liegt, wartet auf Bergung
if (g.mode === 'dwell') { // verweilen (essen, schauen)
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) {
if (g.mode === 'ski' && Math.random() < 0.02 * 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 };
this.heli = { x: home.x, y: home.y, phase: 'out', face: 1,
bob: 0, hoverT: 0, ang: 0, victim, home };
}
}
// Heli langsam & natürlich: hinfliegen → über dem Gast kreisen → zurück
if (this.heli) {
const h = this.heli;
h.bob += dt;
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 === '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 this.heli = null; // an der Station gelandet
} 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 fly = 9 + Math.sin(h.bob * 3) * 0.35; // sanftes Wippen
const { x, y } = this.tileToWorld(h.x, h.y, fly);
const g = this.tileToWorld(h.x, h.y, this.elevAt(h.x, h.y));
this._shadow(g.x, g.y, 9, 3.6);
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 = this.time / 25;
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();
ctx.strokeStyle = 'rgba(47,62,70,0.12)'; ctx.lineWidth = 3;
ctx.beginPath(); ctx.ellipse(x, y - 10, 20, 5, 0, 0, 7); ctx.stroke();
const rot = this.time / 55;
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;
}
// 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();
if (this.state.stageRow > 0) this._drawFog();
this._drawPreview();
this._drawBus();
this._drawFestivals(winter);
this._drawStaffAlerts();
this._drawConstruction();
this._drawRiders();
this._drawStaff();
this._drawGuests(winter);
this._drawHeli();
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
if (!snowy && !tile.road) 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;
// Bike-Trail (Bikepark): brauner Singletrail, der sich talwärts schlängelt
if (this.state.trails?.some(t => t.c === tile.c && t.r === tile.r) && !tile.building) {
ctx.strokeStyle = 'rgba(122,90,58,0.75)';
ctx.lineWidth = 2.4;
ctx.beginPath();
ctx.moveTo(x - 11, y - 5);
ctx.quadraticCurveTo(x + 7, y - 3, x - 4, y + 5);
ctx.stroke();
ctx.strokeStyle = 'rgba(122,90,58,0.35)';
ctx.lineWidth = 4.5;
ctx.beginPath();
ctx.moveTo(x - 11, y - 5);
ctx.quadraticCurveTo(x + 7, y - 3, x - 4, y + 5);
ctx.stroke();
ctx.lineWidth = 1;
}
if (tile.terrain === 'forest' && !tile.building && !isPiste) {
this._drawTree(x, y, snowy, tile.tint);
}
if (tile.deco) this._drawDeco(tile.deco, x, y);
if (tile.building) this._drawBuilding(tile.building, x, y, tile);
// 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);
}
}
// 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 c1 ↔ 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;
let f = (this.simTime / 26000) % 1;
if (f > 0.5) f = 1 - f;
f *= 2;
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
ctx.strokeStyle = this._shade(body, -34); ctx.lineWidth = 0.8;
ctx.beginPath(); ctx.moveTo(...P(L * 0.06, W)); ctx.lineTo(...P(L * 0.06, W, winB)); ctx.stroke();
// Front (Fahrtrichtung, +L)
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();
// 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();
// 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
ctx.strokeStyle = this._shade(body, -42); ctx.lineWidth = 1;
const gh = P(L * 0.16, W, winB * 0.7);
ctx.beginPath(); ctx.moveTo(gh[0] - 2, gh[1]); ctx.lineTo(gh[0] + 2, gh[1]); ctx.stroke();
// Außenspiegel vorne
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();
// Rücklicht hinten
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) {
const s = this.sprites[name];
if (!s) return false;
const w = s.w * scale;
this._shadow(x, y + 2, w * 0.38, w * 0.16);
// Motiv endet im 1024er-Bild bei ~88 % der Höhe
const top = y + s.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;
// KI-Sprites für Gebäude (Bahnen & Rodelbahn bleiben Vektor: dynamisch)
const spriteName = type === 'hotel' && level >= 2 ? 'hotel2' : type;
const spriteScale = type === 'hotel'
? (level >= 3 ? 1.15 : 1)
: [1, 1.15, 1.3][Math.min(2, level - 1)];
if (type !== 'lift' && type !== 'gondola' && type !== 'sled'
&& this._sprite(spriteName, x, y, spriteScale)) {
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();
}
}
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;
}
}
}
// 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();
}
}
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})`;
}
}