0a2ebf46b4
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>
2436 lines
93 KiB
JavaScript
2436 lines
93 KiB
JavaScript
// 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 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;
|
||
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})`;
|
||
}
|
||
}
|