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