Tourismusregion: Komplett-Integration (zweite neue Sim, beta)

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 01:14:12 +02:00
parent 778634fab6
commit 8047778a5b
86 changed files with 5237 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
// Klang: KI-generierte Samples (ElevenLabs, assets/sounds/*.mp3) mit
// WebAudio abgespielt; fehlt eine Datei, greift ein kleiner Synth-Fallback.
// Dazu saisonale Atmosphäre (Sommerwiese/Winterwind) als Loop.
// AudioContext entsteht erst nach der ersten Nutzer-Geste (iOS/Safari).
const SAMPLES = ['click', 'build', 'demolish', 'success', 'error',
'card', 'milestone', 'win', 'ambience_summer', 'ambience_winter',
'bite', 'sip', 'splash', 'tool', 'cash'];
export const audio = {
ctx: null,
soundOn: true,
musicOn: true,
_buffers: {}, // Name → AudioBuffer
_raw: {}, // Name → ArrayBuffer (vor dem Decodieren)
_ambience: null, // laufender Ambience-Loop {src, gain, name}
_season: 'Sommer',
// MP3s sofort laden (ohne AudioContext), decodiert wird bei ensure()
preload() {
for (const name of SAMPLES) {
fetch(`assets/sounds/${name}.mp3`)
.then(r => (r.ok ? r.arrayBuffer() : null))
.then(buf => { if (buf) { this._raw[name] = buf; this._decode(name); } })
.catch(() => {});
}
},
_decode(name) {
if (!this.ctx || !this._raw[name] || this._buffers[name]) return;
this.ctx.decodeAudioData(this._raw[name].slice(0))
.then(b => {
this._buffers[name] = b;
// Ambience ggf. nachstarten, sobald sie decodiert ist
if (name === this._ambienceName() && this.musicOn && !this._ambience) {
this._startAmbience();
}
})
.catch(() => {});
},
ensure() {
if (!this.ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return false;
this.ctx = new AC();
for (const name of Object.keys(this._raw)) this._decode(name);
}
if (this.ctx.state === 'suspended') this.ctx.resume();
return true;
},
// ---------- Effekte ----------
play(name, gain = 0.5) {
if (!this.soundOn || !this.ensure()) return;
const buf = this._buffers[name];
if (buf) {
const src = this.ctx.createBufferSource();
const g = this.ctx.createGain();
g.gain.value = gain;
src.buffer = buf;
src.connect(g).connect(this.ctx.destination);
src.start();
} else {
this._synthFallback(name);
}
},
// kurzer, leiser Interaktions-Ton (Tower-Defense-„Treffer"), pitch = Faktor
pling(pitch = 1) {
if (!this.soundOn || !this.ensure()) return;
this._tone(620 * pitch, 0.09, 'triangle', 0.035);
},
// Dienstleistungs-Geräusch (Apfelbiss, Kaffee, Werkzeug …), dezent
fx(name) {
if (!this.soundOn || !this.ensure()) return;
const buf = this._buffers[name];
if (buf) {
const src = this.ctx.createBufferSource();
const g = this.ctx.createGain();
g.gain.value = 0.28;
src.buffer = buf;
src.connect(g).connect(this.ctx.destination);
src.start();
} else {
// Synth-Fallback je Art
switch (name) {
case 'bite': this._tone(140, 0.08, 'square', 0.04); break;
case 'sip': this._tone(320, 0.14, 'sine', 0.03); break;
case 'splash': this._tone(900, 0.10, 'sine', 0.03); this._tone(500, 0.12, 'sine', 0.02, 0.03); break;
case 'tool': this._tone(220, 0.05, 'sawtooth', 0.03); this._tone(260, 0.05, 'sawtooth', 0.03, 0.06); break;
case 'cash': this._tone(880, 0.06, 'triangle', 0.04); this._tone(1240, 0.10, 'triangle', 0.035, 0.06); break;
default: this.pling();
}
}
},
click() { this.play('click', 0.35); },
success() { this.play('success', 0.5); },
error() { this.play('error', 0.45); },
chime() { this.play('card', 0.45); },
card() { this.chime(); },
build() { this.play('build', 0.55); },
demolish() { this.play('demolish', 0.55); },
milestone() { this.play('milestone', 0.55); },
win() { this.play('win', 0.6); },
_tone(freq, dur, type = 'sine', gain = 0.08, delay = 0) {
const t0 = this.ctx.currentTime + delay;
const osc = this.ctx.createOscillator();
const g = this.ctx.createGain();
osc.type = type;
osc.frequency.value = freq;
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(gain, t0 + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
osc.connect(g).connect(this.ctx.destination);
osc.start(t0);
osc.stop(t0 + dur + 0.05);
},
_synthFallback(name) {
switch (name) {
case 'click': this._tone(660, 0.08, 'triangle', 0.05); break;
case 'success': case 'build': case 'milestone': case 'win':
this._tone(523, 0.12, 'triangle', 0.07);
this._tone(784, 0.18, 'triangle', 0.07, 0.09);
break;
case 'error': case 'demolish': this._tone(180, 0.22, 'sawtooth', 0.05); break;
default: this._tone(880, 0.3, 'sine', 0.05);
}
},
// ---------- Saisonale Atmosphäre (läuft, wenn Musik an ist) ----------
_ambienceName() {
return this._season === 'Winter' ? 'ambience_winter' : 'ambience_summer';
},
setSeason(season) {
if (season === this._season) return;
this._season = season;
if (this.musicOn && this.ctx) this._startAmbience(); // Wechsel mit Fade
},
_startAmbience() {
const name = this._ambienceName();
const buf = this._buffers[name];
if (!buf) return;
this._stopAmbience();
const src = this.ctx.createBufferSource();
const g = this.ctx.createGain();
src.buffer = buf;
src.loop = true;
g.gain.setValueAtTime(0, this.ctx.currentTime);
g.gain.linearRampToValueAtTime(0.18, this.ctx.currentTime + 2.5);
src.connect(g).connect(this.ctx.destination);
src.start();
this._ambience = { src, gain: g, name };
},
_stopAmbience() {
if (!this._ambience) return;
const { src, gain } = this._ambience;
gain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + 1.2);
setTimeout(() => { try { src.stop(); } catch (e) { /* schon gestoppt */ } }, 1400);
this._ambience = null;
},
startMusic() {
if (!this.musicOn || !this.ensure()) return;
this._startAmbience();
},
toggleMusic() {
this.musicOn = !this.musicOn;
if (this.musicOn) this.startMusic(); else this._stopAmbience();
return this.musicOn;
},
toggleSound() {
this.soundOn = !this.soundOn;
return this.soundOn;
},
};
audio.preload();
+614
View File
@@ -0,0 +1,614 @@
// ALLE Spielinhalte data-driven: Besuchergruppen, Infrastrukturkatalog,
// Phasen-Wellen, Ereigniskarten, Entscheidungen, Feed-Texte, Regionsprofile.
// Zahlen folgen dem Pflichtenheft (§7, §10, §11, §20) und wurden mit dem
// Headless-Modelltest (tests/model-test.mjs) kalibriert.
export const GAME_LEN = 1200; // 20 Minuten Echtzeit (Sekunden)
export const PHASE_LEN = 150; // 8 Phasen à 2:30
export const SEASON_LEN = 300; // 4 Saisonen à 5:00
export const SEASONS = ['Frühling', 'Sommer', 'Herbst', 'Winter'];
export const SEASON_KEYS = ['spring', 'summer', 'autumn', 'winter'];
export const CONTACT_NORM = 5; // Normkontaktzeit in Sekunden (§11.6)
export const MAX_GAIN_FACTOR = 1.2; // Deckel je Attraktion (× Grundwirkung)
export const MAX_BRAKE = 0.6; // Maximalbremse (§11.5)
export const START_BUDGET = 500;
export const START_ENV = 75;
// ---------- Besuchergruppen (§7.4) ----------
// speed in Kacheln/s · budget = max. Ausgaben · satTarget = Zufriedenheitsziel
// entry: W = Straße West, N = Radweg Nord, S = Fußweg Süd
export const GROUPS = {
spaziergaenger: {
name: 'Spaziergänger', emoji: '🚶', color: '#3f8f6b', size: 2,
speed: 0.55, budget: 30, satTarget: 60, maxTime: 160,
envSens: 0.8, trafficSens: 0.6, transport: 'foot', entry: 'S',
needsService: false, bergAffinity: 0.1, lakeAffinity: 0.7,
likes: 'Natur · Café · Aussicht', wish: 'ein gemütliches Café',
who: 'Einheimische und Gäste aus der Umgebung, oft Paare auf Tagesausflug. Kleines Budget (~30 €), aber wichtig für den Anfang: Sie erzählen weiter, wie schön es bei euch ist.',
fromPhase: 1,
},
wanderer: {
name: 'Wanderer', emoji: '🥾', color: '#2f6ea0', size: 2,
speed: 0.62, budget: 45, satTarget: 75, maxTime: 200,
envSens: 1.0, trafficSens: 0.5, transport: 'foot', entry: 'S',
needsService: false, bergAffinity: 0.9, lakeAffinity: 0.4,
likes: 'Natur · Aussicht · Gasthaus', wish: 'ein Weg auf den Berg',
who: 'Naturfreunde mit Rucksack, kommen zu Fuß über den Südweg. Mittleres Budget (~45 €), sehr umweltbewusst eine kaputte Landschaft vergrault sie sofort. Träumen von einem Weg auf den Berg.',
fromPhase: 1,
},
familie: {
name: 'Familie', emoji: '👨‍👩‍👧‍👦', color: '#e8892b', size: 4,
speed: 0.5, budget: 90, satTarget: 100, maxTime: 180,
envSens: 0.7, trafficSens: 0.8, transport: 'car', entry: 'W',
needsService: true, bergAffinity: 0.25, lakeAffinity: 0.9,
likes: 'Spielplatz · See · Eis · WC', wish: 'ein Spielplatz für die Kinder',
who: 'Eltern mit zwei Kindern aus der Stadt, kommen mit dem Auto über die Weststraße. Solides Budget (~90 €), aber klare Ansprüche: Ohne Spielplatz, WC und Abkühlung am See wird der Ausflug zum Stresstest.',
fromPhase: 2,
},
radfahrer: {
name: 'Radfahrer', emoji: '🚴', color: '#c9531f', size: 2,
speed: 1.35, budget: 40, satTarget: 60, maxTime: 90,
envSens: 0.8, trafficSens: 0.9, transport: 'bike', entry: 'N',
needsService: false, bergAffinity: 0.15, lakeAffinity: 0.4,
likes: 'Radstation · Café · Aussicht', wish: 'eine Radreparaturstation',
who: 'Sportliche Gäste auf dem Radweg von Norden schnell unterwegs! Angebote wirken nur direkt an der Strecke. Kleines Budget (~40 €): Reparaturstation, Café, kurze Stopps.',
fromPhase: 3,
},
senioren: {
name: 'Seniorengruppe', emoji: '👵', color: '#a34a8e', size: 3,
speed: 0.38, budget: 80, satTarget: 90, maxTime: 220,
envSens: 0.6, trafficSens: 0.9, transport: 'car', entry: 'W',
needsService: true, bergAffinity: 0.15, lakeAffinity: 0.5,
likes: 'Café · Kultur · Aussicht · Sitzbänke', wish: 'ein Museum oder Café',
who: 'Reiselustige Pensionistinnen und Pensionisten mit gutem Budget (~80 €) und viel Zeit. Gehen langsam, mögen keinen Verkehrslärm dafür Café, Kultur und schöne Ausblicke.',
fromPhase: 5,
},
schulklasse: {
name: 'Schulklasse', emoji: '🎒', color: '#2e8b8b', size: 6,
speed: 0.6, budget: 25, satTarget: 80, maxTime: 170,
envSens: 0.5, trafficSens: 0.4, transport: 'bus', entry: 'S',
needsService: true, bergAffinity: 0.4, lakeAffinity: 0.8,
likes: 'Erlebnis · Bildung · günstige Jause', wish: 'etwas zum Entdecken (Museum, Spielplatz)',
who: 'Eine Klasse mit Lehrpersonen auf Exkursion knappes Budget (~25 €), aber die Gäste von morgen! Brauchen Erlebnis, etwas zum Lernen (Museum!) und eine günstige Jause.',
fromPhase: 5,
},
busgruppe: {
name: 'Busreisegruppe', emoji: '🚌', color: '#8c6239', size: 6,
speed: 0.45, budget: 70, satTarget: 95, maxTime: 150,
envSens: 0.4, trafficSens: 0.5, transport: 'bus', entry: 'W',
needsService: true, needsParking: true, bergAffinity: 0.3, lakeAffinity: 0.4,
likes: 'Sehenswürdigkeit · Souvenirs · Gasthaus · WC', wish: 'ein Busparkplatz und ein WC',
who: 'Organisierte Tagesreisende mit Reiseleitung, mittleres Budget (~70 €). Achtung: Ohne Busparkplatz fährt der Bus einfach weiter! Programm: Sehenswürdigkeit, Souvenirs, Gasthaus, WC.',
fromPhase: 5,
},
junge_gruppe: {
name: 'Junge Reisegruppe', emoji: '🤸', color: '#5b6ee1', size: 4,
speed: 0.8, budget: 60, satTarget: 105, maxTime: 170,
envSens: 0.5, trafficSens: 0.3, transport: 'car', entry: 'W',
needsService: false, bergAffinity: 0.95, lakeAffinity: 0.6,
likes: 'Action · Berg · Aussicht · günstige Gastro', wish: 'Action am Berg (Skilift!)',
who: 'Freundesgruppen auf der Suche nach Action am Berg. Mittleres Budget (~60 €), unempfindlich gegen Trubel aber ohne Lift und Erlebnis ziehen sie weiter.',
fromPhase: 6,
},
luxuspaar: {
name: 'Wohlhabendes Paar', emoji: '💎', color: '#b0356b', size: 2,
speed: 0.5, budget: 200, satTarget: 140, maxTime: 200,
envSens: 0.9, trafficSens: 0.8, transport: 'car', entry: 'W',
needsService: false, bergAffinity: 0.6, lakeAffinity: 0.5,
likes: 'feines Restaurant · Wellness · Aussicht · Kultur',
wish: 'ein wirklich gutes Restaurant (Wirtshaus Stufe 4+)',
who: 'Wohlhabende Paare mit hohen Erwartungen und großem Budget (~200 €). Sie kommen nur, wenn die Qualität stimmt: feines Restaurant (Wirtshaus Stufe 4+), Wellness, Kultur und eine intakte Landschaft.',
fromPhase: 7,
},
};
// ---------- Infrastrukturkatalog (§9/§10) ----------
// Arrays sind je Ausbaustufe (Index 0 = Stufe 1).
// cost[0] = Baukosten, cost[i] = Kosten des Upgrades auf Stufe i+1.
// sat = Grundwirkung Zufriedenheit je Normkontakt · slow = Bremse (Anteil)
// opCost = Betriebskosten pro Minute · revenue = Basisumsatz je Normkontakt
// env = Umweltwirkung beim Erreichen der Stufe (Delta) · range in Kacheln
// target = Zielgruppenfaktor (fehlend ⇒ 0.4) · season = Saisonfaktor
export const BUILDINGS = {
wirtshaus: {
name: 'Wirtshaus', emoji: '🍽️', cat: 'gastronomy', unlockPhase: 1, item: '🍲', itemsBy: { luxuspaar: '🍷', schulklasse: '🍟' }, sound: 'bite',
desc: 'Stärkt fast alle Gäste. Solides Startgebäude.',
levelNames: ['Wirtshaus', 'Gasthof', 'Landgasthof', 'Restaurant', 'Gourmetrestaurant'],
cost: [100, 180, 320, 520, 900],
sat: [8, 13, 20, 28, 40],
slow: [0.02, 0.04, 0.06, 0.08, 0.1],
range: [4, 4.5, 5, 5.5, 6],
capacity: [5, 8, 12, 18, 25],
opCost: [4, 7, 12, 20, 35],
revenue: [7, 10, 15, 22, 32],
env: [-1, -1, -1, -1, -2],
target: {
wanderer: 1.2, spaziergaenger: 1.1, familie: 0.8, senioren: 1.1,
schulklasse: 0.6, busgruppe: 1.0, radfahrer: 0.9, junge_gruppe: 0.9, luxuspaar: 0.3,
},
targetByLevel: { luxuspaar: [0.3, 0.3, 0.3, 1.2, 1.3] },
season: { spring: 1, summer: 1, autumn: 1.1, winter: 1.2 },
place: { near: 'path' },
},
cafe: {
name: 'Café', emoji: '☕', cat: 'gastronomy', unlockPhase: 1, item: '☕', itemsBy: { familie: '🧃', schulklasse: '🧃', senioren: '🍰' }, sound: 'sip',
desc: 'Bremst und erfreut besonders Senioren, Spaziergänger, Paare.',
cost: [80, 140, 240, 400, 650],
sat: [5, 8, 12, 17, 24],
slow: [0.10, 0.15, 0.20, 0.25, 0.30],
range: [3.5, 4, 4.5, 5, 5.5],
capacity: [5, 7, 10, 14, 20],
opCost: [3, 5, 8, 13, 20],
revenue: [5, 7, 10, 14, 20],
env: [-1, 0, -1, -1, -1],
target: {
senioren: 1.4, spaziergaenger: 1.3, luxuspaar: 1.0, familie: 0.9,
radfahrer: 1.1, wanderer: 0.9, junge_gruppe: 0.8, busgruppe: 0.8, schulklasse: 0.5,
},
season: { spring: 1.1, summer: 1, autumn: 1.1, winter: 1 },
place: { near: 'path' },
},
marktstand: {
name: 'Marktstand', emoji: '🧺', cat: 'shopping', unlockPhase: 1, item: '🍎', itemsBy: { familie: '🧺', busgruppe: '🧺' }, sound: 'bite',
desc: 'Reine Bremse: Gäste bleiben länger, kleiner Umsatz.',
cost: [60, 100, 170, 280, 450],
sat: [1, 2, 3, 4, 5],
slow: [0.08, 0.12, 0.17, 0.22, 0.28],
range: [3, 3.5, 4, 4.5, 5],
capacity: [8, 12, 16, 22, 30],
opCost: [2, 3, 5, 8, 12],
revenue: [3, 5, 7, 10, 14],
env: [0, 0, 0, -1, -1],
target: {
spaziergaenger: 1.1, senioren: 1.2, familie: 1.0, busgruppe: 1.2,
wanderer: 0.8, luxuspaar: 0.8, schulklasse: 0.7, junge_gruppe: 0.8, radfahrer: 0.5,
},
season: { spring: 1, summer: 1.1, autumn: 1.2, winter: 0.9 },
place: { near: 'path' },
},
aussicht: {
name: 'Aussichtspunkt', emoji: '🔭', cat: 'nature', unlockPhase: 1, fx: '📷', sound: 'click',
desc: 'Landschaftserlebnis. Wirkt stärker in erhöhter Lage.',
cost: [90, 160, 280, 470, 780],
sat: [7, 12, 18, 26, 36],
slow: [0.04, 0.05, 0.06, 0.07, 0.08],
range: [4, 4.5, 5, 5.5, 6],
capacity: [8, 12, 16, 22, 30],
opCost: [1, 2, 3, 5, 8],
revenue: [0, 0, 1, 1, 2],
env: [0, 0, 0, -1, -1],
target: {
wanderer: 1.4, senioren: 1.2, luxuspaar: 1.2, spaziergaenger: 1.2,
busgruppe: 1.1, familie: 0.8, radfahrer: 0.9, junge_gruppe: 1.0, schulklasse: 0.8,
},
season: { spring: 1, summer: 1.1, autumn: 1.2, winter: 1.1 },
place: { near: 'path' }, elevBonus: true, natureBoosted: true,
},
spielplatz: {
name: 'Spielplatz', emoji: '🛝', cat: 'play', unlockPhase: 2, fx: '🎈', sound: 'pling',
desc: 'Familien-Magnet, auch Schulklassen. Kaum Umsatz, viel Freude.',
cost: [120, 200, 340, 560, 900],
sat: [12, 18, 26, 35, 45],
slow: [0.08, 0.10, 0.12, 0.14, 0.16],
range: [4, 4.5, 5, 5.5, 6],
capacity: [6, 9, 13, 18, 25],
opCost: [2, 3, 5, 8, 12],
revenue: [0, 0, 0, 1, 1],
env: [0, 0, 0, -1, -1],
target: { familie: 1.0, schulklasse: 0.7, spaziergaenger: 0.3, junge_gruppe: 0.4 },
season: { spring: 1.1, summer: 1.2, autumn: 1, winter: 0.7 },
place: { near: 'path' },
},
wc: {
name: 'WC / Servicepunkt', emoji: '🚻', cat: 'service', unlockPhase: 2, maxLevel: 3,
desc: 'Unauffällig, aber wichtig: verhindert Frust bei langen Aufenthalten.',
cost: [60, 110, 190],
sat: [2, 3, 4],
slow: [0, 0, 0],
range: [5, 6, 7],
capacity: [12, 20, 30],
opCost: [2, 3, 5],
revenue: [0, 0, 0],
env: [0, 0, 0],
target: { familie: 1.2, senioren: 1.2, busgruppe: 1.2, schulklasse: 1.0 },
season: { spring: 1, summer: 1, autumn: 1, winter: 1 },
place: { near: 'path' }, service: true,
},
badesteg: {
name: 'Badesteg', emoji: '🏖️', cat: 'nature', unlockPhase: 2, fx: '🏊', sound: 'splash',
desc: 'Badespaß am See im Sommer der Hit, im Winter tot.',
cost: [110, 190, 320, 530, 860],
sat: [8, 13, 19, 27, 37],
slow: [0.10, 0.12, 0.15, 0.18, 0.22],
range: [4, 4.5, 5, 5.5, 6],
capacity: [6, 9, 13, 18, 25],
opCost: [2, 3, 5, 8, 12],
revenue: [2, 3, 4, 6, 8],
env: [-1, -1, -1, -2, -2],
target: {
familie: 1.3, junge_gruppe: 1.0, schulklasse: 1.0, spaziergaenger: 0.8,
wanderer: 0.5, luxuspaar: 0.6,
},
season: { spring: 0.4, summer: 1.6, autumn: 0.3, winter: 0.1 },
place: { near: 'lake' }, natureBoosted: true,
},
eisdiele: {
name: 'Eisdiele', emoji: '🍦', cat: 'gastronomy', unlockPhase: 3, item: '🍦', sound: 'bite',
desc: 'Süße Bremse für Familien und Kinder, stark im Sommer.',
cost: [90, 150, 260, 430, 700],
sat: [6, 9, 13, 18, 25],
slow: [0.08, 0.11, 0.14, 0.17, 0.20],
range: [3.5, 4, 4.5, 5, 5.5],
capacity: [6, 9, 13, 18, 25],
opCost: [2, 4, 6, 10, 15],
revenue: [4, 6, 9, 13, 18],
env: [0, 0, -1, -1, -1],
target: {
familie: 1.4, schulklasse: 1.2, junge_gruppe: 1.0, spaziergaenger: 1.0,
radfahrer: 1.0, senioren: 0.9,
},
season: { spring: 0.9, summer: 1.5, autumn: 0.7, winter: 0.3 },
place: { near: 'path' },
},
radstation: {
name: 'Radreparaturstation', emoji: '🚲', cat: 'service', unlockPhase: 3, item: '🔧', sound: 'tool',
desc: 'Wirkt fast nur auf Radfahrer dafür richtig stark.',
cost: [70, 120, 210, 350, 570],
sat: [15, 23, 32, 43, 56],
slow: [0.10, 0.12, 0.14, 0.16, 0.18],
range: [3.5, 4, 4.5, 5, 5.5],
capacity: [4, 6, 9, 13, 18],
opCost: [1, 2, 3, 5, 8],
revenue: [2, 3, 5, 7, 10],
env: [0, 0, 0, 0, 0],
target: { radfahrer: 1.0, junge_gruppe: 0.3 },
season: { spring: 1.1, summer: 1.2, autumn: 1, winter: 0.4 },
place: { near: 'path' },
},
tourismusinfo: {
name: 'Tourismusinfo', emoji: '️', cat: 'service', unlockPhase: 3, maxLevel: 5,
desc: 'Multiplikator: verstärkt alle Attraktionen in ihrer Nähe.',
cost: [130, 220, 370, 600, 950],
sat: [2, 3, 4, 5, 6],
slow: [0.03, 0.04, 0.05, 0.06, 0.07],
range: [5, 5.5, 6, 6.5, 7],
capacity: [10, 14, 20, 28, 40],
opCost: [3, 5, 8, 12, 18],
revenue: [0, 0, 0, 0, 0],
env: [0, 0, 0, 0, 0],
boost: [0.05, 0.10, 0.16, 0.23, 0.32], // +% Wirkung benachbarter Gebäude
target: { busgruppe: 1.0, senioren: 1.0, familie: 0.8 },
season: { spring: 1, summer: 1, autumn: 1, winter: 1 },
place: { near: 'path' },
},
naturschutz: {
name: 'Naturschutzgebiet', emoji: '🌿', cat: 'nature', unlockPhase: 4, maxLevel: 1, fx: '🦋', sound: 'pling',
desc: 'Umwelt +20, Naturerlebnis +15 % sperrt aber Bauplätze im Umkreis.',
cost: [300],
sat: [6],
slow: [0.05],
range: [5],
capacity: [999],
opCost: [2],
revenue: [0],
env: [20],
target: { wanderer: 1.3, spaziergaenger: 1.1, luxuspaar: 0.9, familie: 0.7, schulklasse: 0.9 },
season: { spring: 1.2, summer: 1.1, autumn: 1.1, winter: 0.8 },
place: { near: 'any', notVillage: true }, blocksRadius: 2.5,
},
museum: {
name: 'Heimatmuseum', emoji: '🏛️', cat: 'culture', unlockPhase: 5, item: '🎟️', itemsBy: { schulklasse: '📚' }, sound: 'cash',
desc: 'Kultur für Senioren, Schulklassen und Busgruppen.',
cost: [200, 340, 560, 900, 1400],
sat: [10, 15, 22, 30, 40],
slow: [0.08, 0.10, 0.12, 0.14, 0.16],
range: [4, 4.5, 5, 5.5, 6],
capacity: [6, 9, 13, 18, 25],
opCost: [4, 6, 10, 15, 22],
revenue: [3, 5, 7, 10, 14],
env: [0, 0, 0, 0, 0],
target: {
senioren: 1.3, schulklasse: 1.2, busgruppe: 1.2, luxuspaar: 1.0,
wanderer: 0.5, familie: 0.6,
},
season: { spring: 1, summer: 0.9, autumn: 1.2, winter: 1.2 },
place: { near: 'path' },
},
parkplatz: {
name: 'Parkplatz', emoji: '🅿️', cat: 'service', unlockPhase: 5,
desc: 'Bringt mehr Auto- und Busgäste erzeugt aber Verkehr und kostet Umwelt.',
cost: [150, 260, 430, 700, 1100],
sat: [1, 1, 2, 2, 3],
slow: [0, 0, 0, 0, 0],
range: [5, 5.5, 6, 6.5, 7],
capacity: [999, 999, 999, 999, 999],
opCost: [2, 3, 5, 8, 12],
revenue: [2, 3, 5, 7, 10],
env: [-8, -3, -3, -4, -5],
visitorPlus: [0.05, 0.10, 0.17, 0.25, 0.35], // + Anteil Auto-/Busgäste
traffic: [5, 10, 17, 28, 42],
target: { busgruppe: 1.2, familie: 0.8, senioren: 0.8, luxuspaar: 0.6 },
season: { spring: 1, summer: 1, autumn: 1, winter: 1 },
place: { near: 'road' },
},
bushaltestelle: {
name: 'Bushaltestelle', emoji: '🚏', cat: 'service', unlockPhase: 5, maxLevel: 5,
desc: 'Nachhaltige Anreise: mehr Gruppen, kaum Umweltbelastung.',
cost: [120, 210, 350, 570, 900],
sat: [1, 1, 2, 2, 3],
slow: [0, 0, 0, 0, 0],
range: [5, 5.5, 6, 6.5, 7],
capacity: [999, 999, 999, 999, 999],
opCost: [3, 5, 8, 12, 18],
revenue: [1, 2, 3, 4, 6],
env: [1, 1, 1, 1, 1],
busPlus: [0.04, 0.08, 0.14, 0.20, 0.28], // + Anteil Bus-Gäste
target: { busgruppe: 1.0, senioren: 1.0, schulklasse: 1.0 },
season: { spring: 1, summer: 1, autumn: 1, winter: 1 },
place: { near: 'road' },
},
souvenir: {
name: 'Souvenirshop', emoji: '🎁', cat: 'shopping', unlockPhase: 5, item: '🎁', itemsBy: { junge_gruppe: '🧢' }, sound: 'cash',
desc: 'Bremse + Umsatz, geliebt von Busgruppen.',
cost: [100, 170, 290, 480, 780],
sat: [4, 6, 9, 13, 18],
slow: [0.10, 0.13, 0.16, 0.20, 0.24],
range: [3.5, 4, 4.5, 5, 5.5],
capacity: [6, 9, 13, 18, 25],
opCost: [2, 4, 6, 10, 15],
revenue: [5, 7, 10, 14, 20],
env: [0, 0, -1, -1, -1],
target: {
busgruppe: 1.4, senioren: 1.0, familie: 0.8, luxuspaar: 0.7,
schulklasse: 0.8, junge_gruppe: 0.6,
},
season: { spring: 1, summer: 1.1, autumn: 1.1, winter: 1.1 },
place: { near: 'path' },
},
skilift: {
name: 'Skilift', emoji: '🚡', cat: 'sport', unlockPhase: 6, fx: '🎿', sound: 'pling',
desc: 'Erschließt den Berg: neuer Rundweg + Bauplätze oben. Teuer, stark im Winter, Umwelteingriff.',
levelNames: ['Schlepplift', 'Sessellift', '6er-Sessellift', 'Gondelbahn', 'Erlebnisbahn'],
cost: [800, 1300, 2100, 3500, 5500],
sat: [18, 26, 36, 48, 62],
slow: [0, 0, 0, 0, 0],
range: [4, 4.5, 5, 5.5, 6],
capacity: [8, 14, 22, 32, 45],
opCost: [15, 22, 32, 45, 60],
revenue: [10, 14, 20, 28, 40],
env: [-10, -2, -2, -3, -3],
target: {
junge_gruppe: 1.3, wanderer: 1.0, familie: 0.7, schulklasse: 0.9,
luxuspaar: 0.8, senioren: 0.3,
},
targetByLevel: { senioren: [0.3, 0.3, 0.5, 1.0, 1.0] }, // Gondel hilft Senioren
season: { spring: 0.5, summer: 0.7, autumn: 0.6, winter: 1.5 },
place: { zone: 'hang', unique: true }, opensBerg: true,
},
almhuette: {
name: 'Almhütte', emoji: '🏔️', cat: 'gastronomy', unlockPhase: 6, item: '🧀', itemsBy: { wanderer: '🥨', junge_gruppe: '🥤' }, sound: 'bite',
desc: 'Einkehr am Berg nur sinnvoll, wenn der Lift Gäste bringt.',
cost: [180, 300, 500, 820, 1300],
sat: [10, 15, 22, 30, 40],
slow: [0.10, 0.13, 0.16, 0.20, 0.24],
range: [4, 4.5, 5, 5.5, 6],
capacity: [5, 8, 12, 18, 25],
opCost: [4, 6, 10, 15, 22],
revenue: [6, 9, 13, 19, 27],
env: [-1, -1, -1, -1, -2],
target: { wanderer: 1.3, junge_gruppe: 1.2, luxuspaar: 0.9, schulklasse: 0.8, familie: 0.8, senioren: 0.9 },
season: { spring: 0.8, summer: 1.1, autumn: 1.1, winter: 1.4 },
place: { zone: 'berg', needsLift: true },
},
wellness: {
name: 'Wellnesshaus', emoji: '💆', cat: 'culture', unlockPhase: 7, item: '🌺', itemsBy: { luxuspaar: '💆' }, sound: 'sip',
desc: 'Ruhe und Luxus für anspruchsvolle Gäste.',
cost: [600, 1000, 1600, 2600, 4200],
sat: [14, 20, 28, 38, 50],
slow: [0.12, 0.14, 0.17, 0.20, 0.24],
range: [4, 4.5, 5, 5.5, 6],
capacity: [4, 6, 9, 13, 18],
opCost: [8, 12, 18, 26, 38],
revenue: [12, 17, 24, 34, 48],
env: [-3, -1, -2, -2, -2],
target: { luxuspaar: 1.4, senioren: 0.9, familie: 0.3, junge_gruppe: 0.4 },
season: { spring: 1, summer: 0.9, autumn: 1.1, winter: 1.3 },
place: { near: 'path' },
},
};
for (const def of Object.values(BUILDINGS)) {
def.maxLevel = def.maxLevel ?? def.cost.length;
}
// ---------- Wellen je Phase (§20) ----------
// Basisanzahl; skaliert mit Besucherfaktor (0.7 + Ø-Sterne/5) und
// Parkplatz-/Bushaltestellen-Bonus für Auto-/Busgruppen.
export const WAVES = {
1: { spaziergaenger: 5, wanderer: 3 },
2: { spaziergaenger: 5, wanderer: 4, familie: 2 },
3: { spaziergaenger: 3, wanderer: 3, familie: 5, radfahrer: 4 },
4: { spaziergaenger: 2, wanderer: 3, familie: 4, radfahrer: 8 },
5: { spaziergaenger: 2, wanderer: 2, familie: 3, radfahrer: 3, senioren: 4, busgruppe: 3, schulklasse: 3 },
6: { spaziergaenger: 2, wanderer: 3, familie: 4, radfahrer: 3, senioren: 3, busgruppe: 3, schulklasse: 2, junge_gruppe: 3 },
7: { wanderer: 2, familie: 3, senioren: 2, busgruppe: 2, junge_gruppe: 5, luxuspaar: 3 },
8: { spaziergaenger: 2, wanderer: 3, familie: 4, radfahrer: 2, senioren: 3, busgruppe: 3, schulklasse: 2, junge_gruppe: 4, luxuspaar: 3 },
};
// ---------- Ereignisse & Ankündigungen (§13) ----------
// t in Sekunden. type: 'card' (Info/Ankündigung), 'decision' (A/B-Wahl).
export const EVENTS = [
{
id: 'hint_familien', wave: 2, type: 'card', icon: '📣',
title: 'Tourismusverband meldet',
text: 'Immer mehr Familien entdecken die Region als Ausflugsziel. '
+ 'In etwa 5 Minuten werden deutlich mehr Familien erwartet.\n\n'
+ 'Fachlicher Hinweis: Familien achten auf kindgerechte Angebote, '
+ 'kurze Wege und Sanitäranlagen.',
},
{
id: 'hint_radweg', wave: 3, type: 'card', icon: '🚴',
title: 'Ankündigung: Radfernweg',
text: 'Ein Radfernweg wird eröffnet in 2 Phasen kommen deutlich mehr '
+ 'Radfahrer durch den Ort.\n\nFachlicher Hinweis: Radfahrer sind schnell '
+ 'unterwegs. Angebote wirken nur, wenn sie nah am Radweg liegen.',
},
{
id: 'decision_investor', wave: 4, type: 'decision', icon: '🏗️',
title: 'Ein Investor will einsteigen',
text: 'Ein Großinvestor bietet der Gemeinde 800 € Beteiligung dafür will '
+ 'er großflächig am Seeufer bauen. Der Eingriff würde die Natur belasten '
+ '(Umwelt 10).\n\nRaumplanung heißt abwägen: kurzfristiges Geld gegen '
+ 'langfristige Qualität.',
yes: { label: 'Angebot annehmen (+800 €, Umwelt 10)', apply: s => { s.budget += 800; s.env = Math.max(5, s.env - 10); } },
no: { label: 'Ablehnen (Umwelt +3)', apply: s => { s.env = Math.min(100, s.env + 3); } },
},
{
id: 'hint_bus', wave: 5, type: 'card', icon: '🚌',
title: 'Reiseveranstalter plant Tagesfahrten',
text: 'Ein Reiseveranstalter nimmt die Region ins Programm: Ab jetzt '
+ 'kommen Busreisegruppen und Schulklassen.\n\nFachlicher Hinweis: '
+ 'Busgruppen brauchen eine Parkmöglichkeit sonst reisen sie '
+ 'verärgert wieder ab. WC und Gasthaus nicht vergessen.',
},
{
id: 'hint_winter', wave: 6, type: 'card', icon: '❄️',
title: 'Der Winter naht',
text: 'Schneesicherheit ist im Tal unsicher, aber höher gelegene Bereiche '
+ 'bleiben attraktiv. Ein Lift würde den Hang erschließen und oben neue '
+ 'Bauplätze schaffen.\n\nFachlicher Hinweis: Ein Lift ist teuer und '
+ 'belastet die Umwelt ganzjährige Nutzung (Wandern, Aussicht) '
+ 'verbessert die Rechnung.',
},
{
id: 'decision_dorffest', wave: 6, type: 'decision', icon: '🎪',
title: 'Dorffest am Wochenende?',
text: 'Der Verein möchte ein Dorffest veranstalten. Kosten: 150 €. '
+ 'Dafür steigt die Stimmung: Alle Gäste der nächsten Zeit sind '
+ 'spürbar zufriedener.',
yes: {
label: 'Fest finanzieren (150 €)', can: s => s.budget >= 150,
apply: s => { s.budget -= 150; s.festUntil = s.time + 120; },
},
no: { label: 'Diesmal nicht', apply: () => {} },
},
{
id: 'hint_luxus', wave: 7, type: 'card', icon: '💎',
title: 'Anspruchsvolle Gäste im Anmarsch',
text: 'Die Region wird bekannter: Junge Reisegruppen suchen Action am '
+ 'Berg, wohlhabende Paare erwarten Qualität gutes Restaurant '
+ '(Wirtshaus Stufe 4+), Wellness, Kultur und eine intakte Landschaft.',
},
{
id: 'hint_endphase', wave: 8, type: 'card', icon: '🏁',
title: 'Endphase',
text: 'Die letzten 2:30 Minuten! Starke Besucherströme kommen. Jetzt '
+ 'zählt: Läuft deine Region rund für alle Gruppen, wirtschaftlich '
+ 'und ökologisch?',
},
];
// ---------- Social-Media-Feed (§8.3) ----------
export const FEED_TEXTS = {
5: [
'{best} war perfekt für unseren Ausflug gerne wieder!',
'Traumhafter Tag! Besonders {best} hat uns begeistert.',
'Fünf Sterne! {best} und die Landschaft ein Traum.',
],
4: [
'Sehr schöner Ausflug, {best} hat uns gut gefallen.',
'Fast perfekt {best} war toll, ein bisschen mehr Auswahl wäre schön.',
],
3: [
'Schöne Landschaft, aber {missing} haben wir vermisst.',
'Ganz nett. {best} war okay, aber es fehlt noch etwas z. B. {missing}.',
],
2: [
'Für uns gab es leider zu wenig zu tun {missing} würde helfen.',
'Enttäuschend wenig Angebot. {missing} fehlt.',
],
1: [
'Nie wieder. Es gab praktisch nichts für uns {missing} fehlt völlig.',
'Schade um die schöne Gegend ohne Angebote fahren wir woanders hin.',
],
traffic: 'Schöne Gegend, aber der Verkehr im Ort war wirklich mühsam.',
env: 'Die vielen Eingriffe in die Natur haben uns den Ausflug verleidet.',
noParking: 'Unser Bus fand keinen Parkplatz wir mussten weiterfahren!',
};
// ---------- Regionsprofile (§12.3) ----------
export const PROFILES = [
{
id: 'krisenregion', name: 'Krisenregion', icon: '🌧️',
check: s => s.result.avgStars < 2.5 || s.budget < -1000 || s.env < 35,
text: 'Schlechte Bewertungen, Geldsorgen oder Umweltprobleme: Die Region hat ihre Richtung noch nicht gefunden.',
},
{
id: 'nachhaltig', name: 'Nachhaltige Modellregion', icon: '🌍',
check: s => s.result.avgStars >= 3.5 && s.env >= 65,
text: 'Hohe Zufriedenheit UND intakte Umwelt so sieht zukunftsfähiger Tourismus aus.',
},
{
id: 'massentourismus', name: 'Massentourismusregion', icon: '🏗️',
check: s => s.revenueTotal >= 3000 && s.env < 50,
text: 'Wirtschaftlich stark, aber auf Kosten der Natur wie lange geht das gut?',
},
{
id: 'familienregion', name: 'Familienregion', icon: '👨‍👩‍👧‍👦',
check: s => (s.stats.groups.familie?.count ?? 0) >= 8 && (s.stats.groups.familie?.avg ?? 0) >= 3.8,
text: 'Familien fühlen sich hier besonders wohl kindgerechte Angebote zahlen sich aus.',
},
{
id: 'aktivregion', name: 'Aktivregion', icon: '🚵',
check: s => avgOf(s, ['radfahrer', 'wanderer', 'junge_gruppe']) >= 3.8,
text: 'Sport, Berg und Bewegung: Aktive Gäste lieben deine Region.',
},
{
id: 'kulturregion', name: 'Kulturregion', icon: '🏛️',
check: s => s.buildings.some(b => b.type === 'museum') && avgOf(s, ['senioren', 'schulklasse', 'busgruppe']) >= 3.8,
text: 'Kultur und Dorfleben ziehen Senioren, Schulklassen und Gruppen an.',
},
{
id: 'entwicklung', name: 'Ausflugsregion in Entwicklung', icon: '🌱',
check: () => true,
text: 'Eine solide Basis ist gelegt die klare Spezialisierung fehlt noch.',
},
];
function avgOf(s, ids) {
let sum = 0, n = 0;
for (const id of ids) {
const g = s.stats.groups[id];
if (g?.count) { sum += g.stars; n += g.count; }
}
return n ? sum / n : 0;
}
// ---------- Synergien (§9.2 synergyRules) ----------
// Stehen zwei passende Gebäude nah beieinander (Pixel-Radius), verstärken sie
// sich gegenseitig. bonus = zusätzlicher Wirkungsfaktor (+X %). Beidseitig.
export const SYNERGIES = [
{ a: 'skilift', b: 'almhuette', radius: 260, bonus: 0.25, why: 'Almhütte + Lift: Einkehr direkt an der Bergstation' },
{ a: 'aussicht', b: 'naturschutz', radius: 200, bonus: 0.20, why: 'Aussicht + Naturschutz: unverbaute Landschaft' },
{ a: 'cafe', b: 'marktstand', radius: 160, bonus: 0.18, why: 'Café + Marktstand: gemütlicher Bummel' },
{ a: 'wirtshaus', b: 'souvenir', radius: 170, bonus: 0.15, why: 'Wirtshaus + Souvenirshop: Einkehr & Andenken' },
{ a: 'spielplatz', b: 'eisdiele', radius: 160, bonus: 0.22, why: 'Spielplatz + Eisdiele: Familienglück' },
{ a: 'badesteg', b: 'eisdiele', radius: 190, bonus: 0.18, why: 'Badesteg + Eisdiele: Sommer am See' },
{ a: 'museum', b: 'cafe', radius: 170, bonus: 0.15, why: 'Museum + Café: Kultur mit Pause' },
{ a: 'radstation', b: 'cafe', radius: 170, bonus: 0.15, why: 'Radstation + Café: Boxenstopp für Radler' },
{ a: 'skilift', b: 'aussicht', radius: 240, bonus: 0.18, why: 'Lift + Aussicht: Panorama vom Gipfel' },
{ a: 'wellness', b: 'aussicht', radius: 200, bonus: 0.15, why: 'Wellness + Aussicht: Erholung mit Weitblick' },
];
// Ziele fürs Ziele-Panel (§12.1)
export const GOALS = [
{ id: 'rating', label: 'Ø Bewertung ≥ 3,5 Sterne', check: s => currentAvg(s) >= 3.5 },
{ id: 'revenue', label: 'Wertschöpfung ≥ 2500 €', check: s => s.revenueTotal >= 2500 },
{ id: 'env', label: 'Umweltwert ≥ 45', check: s => s.env >= 45 },
{ id: 'debt', label: 'Nicht unter 1000 € rutschen', check: s => s.budget > -1000 },
];
export function currentAvg(s) {
const g = s.stats.all;
return g.count ? g.stars / g.count : 0;
}
+383
View File
@@ -0,0 +1,383 @@
// Einstiegspunkt: Start-Overlay → startGame(). Verdrahtet Modell, Renderer,
// Audio, Telemetrie und UI und treibt die Echtzeit-Partie an (Pause/1×;
// ?speed=4 nur zum Testen). Karten aus dem Modell werden hier angezeigt,
// Bauen/Auswählen läuft über Tile-Taps.
import { createState, tick, place, upgrade, demolish, canPlace, callNextWave } from './model.js';
import { BUILDINGS, GROUPS, GOALS, currentAvg } from './data.js';
import { SceneRenderer } from './scene.js';
import { MAPS, MAP_ORDER, applyMapOverrides } from './maps.js';
import { UI } from './ui.js';
import { telemetry } from './telemetry.js';
import { audio } from './audio.js';
import { music } from './music.js';
window.__ttLoaded = true; // Signal an den file://-Fallback in index.html
const params = new URLSearchParams(location.search);
const TEST_SPEED = Math.max(1, Math.min(16, Number(params.get('speed')) || 1));
let state = null;
let ui = null;
let renderer = null;
let speed = 0; // 0 = Pause, 1 = läuft
let last = performance.now();
let uiAccum = 0;
let endShown = false;
// ---------- Start: Kartenwahl + Partie ----------
let chosenMap = 'talschleife';
const mapList = document.getElementById('mapList');
for (const id of MAP_ORDER) {
const mdef = MAPS[id];
const el = document.createElement('button');
el.className = 'map-btn' + (id === chosenMap ? ' active' : '');
el.dataset.map = id;
el.innerHTML = `<img src="${mdef.image}" alt=""><span><b>${mdef.icon} ${mdef.name}</b><small>${mdef.desc}</small></span>`;
el.addEventListener('click', () => {
chosenMap = id;
document.querySelectorAll('.map-btn').forEach(b => b.classList.toggle('active', b === el));
});
mapList.appendChild(el);
}
// Editor-Anpassungen (js/maps-custom.json) laden, bevor eine Partie startet
const overridesReady = fetch('js/maps-custom.json', { cache: 'no-store' })
.then(r => (r.ok ? r.json() : null))
.then(o => applyMapOverrides(o))
.catch(() => {});
document.getElementById('btnStart').addEventListener('click', async () => {
await overridesReady;
document.getElementById('startOverlay').classList.add('hidden');
startGame();
});
function startGame() {
const name = document.getElementById('playerName').value.trim().slice(0, 40);
const code = document.getElementById('classCode').value.trim().slice(0, 20);
state = createState({ playerName: name, classCode: code, seed: 20260703, mapId: chosenMap });
window.__ttState = state; // für Playwright-Tests lesbar
endShown = false;
telemetry.init(() => Math.round(state?.time ?? 0));
telemetry.log('session_start', { player: name, classCode: code });
ui = new UI(state, {
setSpeed: v => { speed = v; },
getSpeed: () => speed,
setBuildType: t => renderer.setBuildType(t),
selectBuilding: b => { if (renderer) renderer.selected = b; },
upgradeSelected,
demolishSelected,
resetCamera: () => renderer.resetCamera(),
nextWave: () => {
const wasPause = state.waveStatus === 'pause';
const res = callNextWave(state);
if (!res.ok) { ui.toast(`${res.reason}`); audio.error(); return; }
ui.toast(wasPause
? `⏩ Baupause übersprungen Bonus: +${res.bonus}`
: `⏩ Nächste Welle früh gerufen Mut-Bonus: +${res.bonus}`);
audio.success();
ui.updateKPIs();
telemetry.log('next_wave', { bonus: res.bonus, phase: state.phase });
},
toggleReduceAnim: () => { renderer.reduceAnim = !renderer.reduceAnim; return renderer.reduceAnim; },
restart: () => location.reload(),
save: saveResult,
});
renderer = new SceneRenderer(document.getElementById('map'), state, { onTap });
window.__ttRenderer = renderer; // für Playwright-Tests (Welt → Screen)
// Plattform-Standard: Onboarding jederzeit über abrufbar
window.GGS_TUTORIAL = {
simId: 'tourismusregion',
title: 'Tourismusregion entwickeln',
replay: () => ui.showOnboarding(),
};
state._startedAt = Date.now();
audio.ensure();
audio.click();
audio.setSeason(state.season);
music.setVolume(0.09); // Plattform-Standard: Musik leise
music.play(Math.floor(Math.random() * 14)); // Hintergrundmusik ab Start
speed = 1;
ui.markSpeed(1);
ui.updateKPIs();
ui.showCard({
icon: '🏞️', title: 'Willkommen in deiner Region!', sound: 'card',
text: 'Du entwickelst eine kleine Tourismusregion: Dorf, '
+ 'See, Berg und drei Wege, auf denen Gäste ankommen.\n\n'
+ 'Ziel: zufriedene Gäste (Ø 3,5 ★), regionale Wertschöpfung (2500 €), '
+ 'intakte Umwelt (≥ 45) und ein solides Budget.\n\n'
+ 'Gebaut wird auf den markierten Bauplätzen am Weg starte mit einem Wirtshaus!',
});
if (!localStorage.getItem('tourismusregion_seen')) {
localStorage.setItem('tourismusregion_seen', '1');
ui.showOnboarding();
}
requestAnimationFrame(frame);
}
// ---------- Interaktion auf der Karte ----------
function onTap(hit) {
if (!state || state.ended) return;
// Platzierungsmodus: Tap auf einen freien Slot
if (ui.activeBuild && (hit.kind === 'slot' || hit.kind === 'building')) {
const type = ui.activeBuild;
const slotIdx = hit.slot.idx;
const check = canPlace(state, type, slotIdx);
if (!check.ok) {
ui.toast(`${check.reason}`);
audio.error();
telemetry.log('build_rejected', { type, slot: slotIdx, reason: check.reason });
return;
}
const res = place(state, type, slotIdx);
const syn = res.building.synergyList || [];
ui.toast(syn.length
? `${BUILDINGS[type].name} gebaut · 🔗 Synergie: ${syn[0]}!`
: `${BUILDINGS[type].name} gebaut Reichweite im Blick behalten!`);
audio.build();
if (syn.length) { audio.success(); renderer.addFloat('🔗 Synergie!', '#7b3fa0', res.building.x, res.building.y - 20); }
renderer.addFloat(`${BUILDINGS[type].cost[0]}`, '#c0392b', res.building.x, res.building.y);
ui.updateKPIs();
ui.rebuildMenu();
telemetry.log('build_placed', { type, slot: slotIdx, budgetLeft: Math.round(state.budget) });
ui.clearBuildSelection();
ui.showBuildingPanel(res.building);
renderer.selected = res.building;
return;
}
if (ui.activeBuild && hit.kind !== 'none') return;
if (hit.kind === 'building') {
ui.showBuildingPanel(hit.building);
renderer.selected = hit.building;
audio.click();
telemetry.log('select_building', { type: hit.building.type });
} else if (hit.kind === 'visitor') {
ui.showVisitorPanel(hit.visitor);
renderer.selected = null;
audio.click();
telemetry.log('select_visitor', { type: hit.visitor.type });
} else {
ui.hideInfoPanel();
renderer.selected = null;
}
}
function upgradeSelected() {
const b = ui.selectedBuilding;
if (!b) return;
const res = upgrade(state, b);
if (!res.ok) { ui.toast(`${res.reason}`); audio.error(); return; }
const def = BUILDINGS[b.type];
ui.toast(`⬆️ ${def.levelNames ? def.levelNames[b.level - 1] : def.name} Stufe ${b.level}!`);
audio.build();
renderer.addFloat(`${res.cost}`, '#c0392b', b.x, b.y);
ui.updateKPIs();
telemetry.log('upgrade', { type: b.type, level: b.level });
}
function demolishSelected() {
const b = ui.selectedBuilding;
if (!b) return;
const res = demolish(state, b);
if (!res.ok) { ui.toast(`${res.reason}`); audio.error(); return; }
ui.toast(`🧨 ${res.name} abgerissen ${res.refund} € zurück.`);
audio.demolish();
ui.hideInfoPanel();
renderer.selected = null;
ui.updateKPIs();
telemetry.log('demolish', { type: res.name, refund: res.refund });
}
// ---------- Karten aus dem Modell anzeigen ----------
function pumpCards() {
while (state.pendingCards.length) {
const card = state.pendingCards.shift();
switch (card.kind) {
case 'decision':
ui.showCard({
id: card.id, icon: card.icon, title: card.title, text: card.text, sound: 'card',
choices: [
{
label: card.yes.label,
disabled: card.yes.can ? !card.yes.can(state) : false,
onPick: () => {
card.yes.apply(state);
ui.updateKPIs();
telemetry.log('decision', { id: card.id, choice: 'yes' });
},
},
{
label: card.no.label,
onPick: () => {
card.no.apply(state);
ui.updateKPIs();
telemetry.log('decision', { id: card.id, choice: 'no' });
},
},
],
});
break;
case 'banner': {
const g = GROUPS[card.type];
const dir = { W: 'von Westen', N: 'über den Radweg', S: 'über den Fußweg' }[g.entry] || '';
ui.toast(`${g.emoji} Welle: ${card.n}× ${g.name} ${dir} im Anmarsch!`);
audio.chime();
break;
}
case 'feed':
ui.showCard({ ...card, sound: 'card' });
break;
case 'built':
// Baustelle fertig nur dezenter Toast, keine Pause
ui.toast(`${BUILDINGS[card.type].name} ist fertiggebaut und öffnet!`);
audio.success();
break;
case 'phase':
ui.showCard({ ...card, sound: 'milestone' });
ui.rebuildMenu();
break;
case 'season':
ui.showCard({ ...card, sound: 'card' });
audio.setSeason(state.season);
break;
case 'end':
endShown = true;
submitAssessment(); // Plattform: Ergebnis ans Lehrer-Cockpit
speed = 0;
ui.markSpeed(0);
ui.showEnd();
break;
default:
ui.showCard({ ...card, sound: card.sound || 'card' });
}
}
}
// ===== Live-View für Lehrkräfte (Plattform-Hook) =====
// Wird vom live-client.js alle ~4 s an /api/live gesendet.
window.GGS_LIVE_STATE = function () {
if (!state) return null;
return {
scenarioId: state.map?.id, // Karten-Key = Lektions-Gruppierung
mapId: state.map?.id,
wave: state.phase,
waveTotal: 8,
waveStatus: state.waveStatus,
season: state.season,
budget: Math.round(state.budget),
revenue: Math.round(state.revenueTotal),
env: Math.round(state.env),
avgStars: Math.round(currentAvg(state) * 100) / 100,
visitors: state.stats?.spawned ?? 0,
buildings: state.buildings.length,
goalsDone: GOALS.filter(g => g.check(state)).length,
goalsTotal: GOALS.length,
ended: !!state.ended,
};
};
// ===== Submit-Hook (Plattform) =====
// Schreibt am Partie-Ende einen Eintrag in `assessments` (Lehrer-Cockpit).
let _submitted = false;
function submitAssessment() {
if (!window.TT_SESSION_ID || _submitted) return;
const r = state?.result;
if (!r) return;
_submitted = true;
const api = (window.TT_API_BASE || '../../php/api') + '/progress.php';
const goalsDone = r.goals.filter(g => g.done).length;
try {
fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
sim_id: 'tourismusregion',
action: 'submit_assessment',
data: {
level: state.map?.id,
stars: Math.max(0, Math.min(5, Math.round(r.avgStars))),
score: r.score,
duration_ms: Date.now() - (state._startedAt || Date.now()),
completed: goalsDone === r.goals.length,
results: {
mapId: state.map?.id,
score: r.score,
avgStars: Math.round(r.avgStars * 100) / 100,
revenue: r.revenue,
env: r.env,
budget: r.budget,
visitors: r.visitors,
goalsDone,
goalsTotal: r.goals.length,
profile: r.profile?.id,
scores: r.scores,
},
},
}),
}).catch(() => {});
} catch { /* Standalone ohne Plattform: ok */ }
}
// ---------- Ergebnis speichern (PHP/MySQL, Fallback localStorage) ----------
async function saveResult() {
const r = state.result;
if (!r) return;
submitAssessment(); // Plattform-Submit (falls noch nicht geschehen)
const payload = {
player_name: state.playerName || 'anonym',
class_code: state.classCode || '',
final_score: r.score,
avg_rating: Number(r.avgStars.toFixed(2)),
revenue: r.revenue,
environment_score: r.env,
region_profile: r.profile.id,
map_id: state.map.id,
telemetry: telemetry.flush().slice(-200),
};
let saved = false;
try {
const res = await fetch('api/save_score.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
saved = res.ok && (await res.json()).ok;
} catch { /* kein Backend (z. B. python-Server) localStorage reicht */ }
try {
const all = JSON.parse(localStorage.getItem('tourismusregion_results') || '[]');
all.push({ ...payload, telemetry: undefined, ts: Date.now() });
localStorage.setItem('tourismusregion_results', JSON.stringify(all.slice(-30)));
} catch { /* Privatmodus */ }
ui.toast(saved
? '💾 Ergebnis am Server gespeichert.'
: '💾 Ergebnis lokal gespeichert (kein Server erreichbar).');
telemetry.log('save_result', { server: saved });
}
// ---------- Game-Loop ----------
function frame(now) {
const dtMs = Math.min(100, now - last);
last = now;
const simSpeed = speed * TEST_SPEED;
tick(state, (dtMs / 1000) * simSpeed);
pumpCards();
uiAccum += dtMs;
if (uiAccum > 250) {
uiAccum = 0;
ui.updateKPIs();
ui.drawGraph();
}
renderer.draw(now, dtMs, simSpeed);
requestAnimationFrame(frame);
}
@@ -0,0 +1 @@
{}
+325
View File
@@ -0,0 +1,325 @@
// Die 5 Wegkarten: je ein vorgerendertes 3D-Diorama (assets/maps/*.png,
// 1536×1024) mit handvermessenen Routen-Polylinien (Bild-Pixel), festen
// Bauplatz-Slots und optionalem Lift (Seilbahn zum Berg mit Rundweg oben).
// Zonen: standard · road (Parkplatz/Bus) · lake (Badesteg) · hang (Skilift)
// · berg (Almhütte/Aussicht, erst mit Lift erreichbar).
export const TILE2PX = 36; // 1 „Feld" Reichweite = 36 Bild-Pixel
export const MAPS = {
talschleife: {
id: 'talschleife', name: 'Talschleife', icon: '🏞️',
desc: 'Der Klassiker: ein Weg vom Westrand durchs Dorf zum Talausgang See und Berg locken abseits.',
image: 'assets/maps/talschleife.png', w: 1536, h: 1024,
routes: {
westost: {
entry: 'W', exit: 'SE',
points: [[304, 340], [301, 354], [302, 370], [312, 379], [330, 382],
[346, 390], [359, 398], [347, 383], [344, 364], [353, 350],
[368, 338], [384, 333], [400, 335], [419, 347], [430, 357],
[440, 367], [447, 376], [455, 396], [461, 414], [471, 435],
[477, 455], [477, 473], [485, 488], [502, 502], [515, 516],
[532, 532], [549, 541], [564, 544], [572, 531], [579, 544],
[594, 551], [611, 555], [624, 567], [626, 581], [624, 565],
[610, 554], [595, 550], [577, 554], [563, 564], [557, 576],
[549, 591], [552, 611], [562, 627], [568, 640], [576, 658],
[578, 677], [578, 695], [588, 711], [600, 726], [606, 742],
[616, 754], [626, 766], [640, 773], [652, 784], [666, 786],
[686, 784], [705, 788], [716, 800], [715, 816], [728, 816],
[741, 826], [754, 836], [768, 840], [779, 851], [795, 860],
[814, 865], [829, 874], [836, 886], [838, 889]],
},
gegenzug: {
entry: 'S', exit: 'E', reverseOf: 'westost',
},
},
entryRoutes: { W: ['westost'], N: ['westost'], S: ['gegenzug'] },
slots: [
{ x: 300, y: 292, zone: 'road' },
{ x: 245, y: 425, zone: 'road' },
{ x: 430, y: 378, zone: 'standard' },
{ x: 588, y: 298, zone: 'standard' },
{ x: 634, y: 372, zone: 'standard' },
{ x: 697, y: 397, zone: 'standard' },
{ x: 485, y: 595, zone: 'standard' },
{ x: 700, y: 713, zone: 'standard' },
{ x: 776, y: 742, zone: 'standard' },
{ x: 357, y: 619, zone: 'standard' },
{ x: 1235, y: 546, zone: 'standard' },
{ x: 545, y: 655, zone: 'standard' },
{ x: 900, y: 788, zone: 'standard' },
{ x: 261, y: 520, zone: 'standard' },
{ x: 885, y: 615, zone: 'lake' },
{ x: 1005, y: 782, zone: 'lake' },
{ x: 987, y: 381, zone: 'hang' },
{ x: 888, y: 218, zone: 'berg' },
{ x: 1049, y: 296, zone: 'berg' },
],
lift: { topX: 952, topY: 168, loop: [[1000, 212], [935, 242]] },
},
seerunde: {
id: 'seerunde', name: 'Seerunde', icon: '🏖️',
desc: 'Ein Rundweg um den großen Bergsee Nord- oder Südufer? Beide Ströme wollen versorgt sein.',
image: 'assets/maps/seerunde.png', w: 1536, h: 1024,
routes: {
nordufer: {
entry: 'W', exit: 'E',
points: [[274, 784], [290, 768], [300, 756], [311, 746], [323, 733],
[336, 718], [348, 706], [359, 695], [368, 686], [378, 672],
[371, 658], [358, 646], [348, 636], [336, 629], [325, 622],
[308, 608], [302, 590], [310, 570], [322, 555], [336, 542],
[350, 532], [368, 523], [384, 519], [396, 509], [407, 499],
[420, 493], [436, 480], [445, 467], [451, 452], [454, 433],
[456, 411], [459, 389], [466, 370], [477, 353], [490, 338],
[510, 326], [522, 322], [535, 318], [550, 315], [566, 313],
[584, 312], [603, 311], [620, 311], [636, 311], [648, 311],
[668, 312], [683, 315], [698, 318], [714, 315], [734, 322],
[748, 328], [760, 332], [780, 338], [792, 340], [804, 343],
[818, 346], [830, 348], [850, 353], [866, 358], [882, 366],
[902, 372], [921, 374], [942, 377], [960, 380], [976, 384],
[991, 391], [1010, 406], [1020, 414], [1028, 424], [1035, 435],
[1040, 446], [1044, 459], [1045, 471], [1046, 484], [1050, 502],
[1063, 514], [1082, 522], [1100, 531], [1118, 543], [1130, 554],
[1144, 555], [1158, 562], [1167, 574], [1170, 588], [1170, 604],
[1163, 618], [1151, 633], [1137, 642], [1116, 652], [1100, 662],
[1086, 670], [1068, 676], [1052, 678], [1032, 684], [1014, 691],
[1000, 697], [977, 700], [965, 701], [942, 702], [930, 705],
[914, 712], [897, 720], [880, 723], [866, 728], [852, 738],
[837, 746], [822, 751], [814, 762], [808, 780], [796, 798],
[780, 813], [763, 821], [744, 824], [728, 831], [702, 837],
[689, 838], [664, 837], [649, 836], [636, 834], [624, 834],
[605, 831], [588, 826], [576, 816], [588, 812], [606, 814],
[588, 812], [572, 802], [563, 793], [563, 808], [550, 807],
[536, 799], [522, 785], [506, 770], [488, 756], [474, 746],
[457, 736], [449, 728], [440, 715], [429, 701], [415, 686],
[401, 678], [384, 679], [369, 690], [360, 699], [348, 710],
[336, 722], [323, 735], [311, 747], [300, 758], [292, 766],
[279, 779], [274, 784]],
},
suedufer: {
entry: 'S', exit: 'SE', lake: true,
points: [[274, 784], [290, 768], [300, 756], [311, 746], [323, 733],
[336, 718], [349, 706], [360, 696], [370, 688], [387, 680],
[403, 686], [416, 696], [427, 702], [443, 715], [462, 732],
[473, 743], [486, 756], [497, 766], [506, 776], [521, 789],
[534, 796], [552, 805], [568, 808], [588, 812], [604, 815],
[589, 809], [579, 817], [596, 828], [614, 832], [636, 834],
[649, 836], [664, 837], [677, 838], [700, 838], [718, 834],
[735, 826], [750, 822], [770, 818], [788, 807], [797, 798],
[808, 780], [812, 763], [830, 753], [845, 745], [860, 735],
[874, 725], [892, 717], [910, 713], [927, 706], [947, 700],
[959, 699], [973, 699], [991, 698], [1014, 693], [1026, 688],
[1038, 684], [1055, 678], [1070, 676], [1086, 664], [1105, 654],
[1120, 650], [1140, 641], [1157, 626], [1167, 610], [1170, 593],
[1169, 576], [1158, 565], [1144, 561], [1131, 550], [1114, 538],
[1104, 532], [1090, 528], [1079, 522], [1060, 513], [1050, 502],
[1044, 481], [1042, 467], [1039, 450], [1031, 430], [1018, 412],
[1000, 398], [990, 391], [978, 386], [960, 380], [942, 377],
[921, 374], [902, 372], [882, 366], [866, 359], [840, 351],
[827, 348], [806, 345], [785, 340], [766, 334], [748, 327],
[732, 320], [716, 316], [698, 315], [681, 316], [664, 312],
[647, 311], [626, 310], [612, 310], [600, 310], [580, 311],
[562, 312], [545, 315], [534, 318], [518, 325], [503, 334],
[488, 344], [474, 355], [464, 366], [456, 385], [452, 408],
[452, 421], [453, 435], [452, 448], [448, 460], [443, 471],
[436, 481], [420, 494], [402, 502], [388, 512], [372, 522],
[356, 527], [337, 538], [324, 548], [310, 566], [303, 588],
[308, 607], [326, 622], [336, 629], [348, 636], [358, 645],
[368, 655], [381, 669], [368, 687], [357, 697], [347, 708],
[335, 720], [321, 734], [309, 746], [299, 758], [291, 766],
[279, 779], [274, 784]],
},
},
entryRoutes: { W: ['nordufer'], N: ['nordufer'], S: ['suedufer'] },
slots: [
{ x: 255, y: 641, zone: 'road' },
{ x: 330, y: 800, zone: 'road' },
{ x: 395, y: 330, zone: 'standard' },
{ x: 520, y: 285, zone: 'standard' },
{ x: 700, y: 245, zone: 'standard' },
{ x: 966, y: 300, zone: 'standard' },
{ x: 1075, y: 385, zone: 'standard' },
{ x: 1230, y: 651, zone: 'standard' },
{ x: 620, y: 772, zone: 'standard' },
{ x: 900, y: 792, zone: 'standard' },
{ x: 1080, y: 720, zone: 'standard' },
{ x: 598, y: 346, zone: 'lake' },
{ x: 700, y: 655, zone: 'lake' },
{ x: 880, y: 635, zone: 'lake' },
],
},
serpentinen: {
id: 'serpentinen', name: 'Serpentinen', icon: '⛰️',
desc: 'Über den Berg ins Tal: langer Abstieg in Kehren die Seilbahn verbindet Dorf und Gipfelterrasse.',
image: 'assets/maps/serpentinen.png', w: 1536, h: 1024,
routes: {
kehren: {
entry: 'W', exit: 'SE',
points: [[355, 229], [357, 244], [362, 268], [367, 280], [379, 299],
[388, 308], [396, 318], [403, 329], [411, 340], [419, 351],
[432, 364], [446, 372], [463, 379], [480, 385], [498, 389],
[515, 392], [532, 394], [546, 396], [564, 401], [584, 406],
[601, 408], [619, 407], [640, 404], [664, 398], [677, 394],
[690, 390], [704, 384], [720, 377], [734, 372], [748, 367],
[761, 364], [774, 361], [786, 360], [798, 358], [819, 360],
[838, 362], [858, 362], [879, 363], [900, 370], [918, 378],
[936, 380], [945, 370], [952, 356], [950, 373], [939, 383],
[923, 385], [904, 375], [920, 378], [936, 378], [926, 393],
[916, 408], [895, 416], [881, 423], [869, 430], [858, 436],
[840, 446], [822, 455], [806, 462], [791, 467], [777, 472],
[757, 484], [747, 491], [732, 506], [724, 520], [716, 532],
[704, 542], [691, 557], [678, 569], [664, 583], [650, 593],
[635, 601], [614, 607], [602, 608], [584, 607], [566, 605],
[550, 604], [536, 604], [515, 608], [499, 617], [494, 630],
[503, 643], [513, 656], [526, 669], [542, 676], [557, 688],
[572, 704], [586, 712], [580, 731], [570, 740], [560, 752],
[549, 770], [544, 787], [548, 802], [561, 819], [558, 833],
[544, 848], [542, 868], [551, 884], [568, 898], [584, 911],
[598, 922], [601, 925]],
},
talwaerts: { entry: 'S', exit: 'E', reverseOf: 'kehren' },
},
entryRoutes: { W: ['kehren'], N: ['kehren'], S: ['talwaerts'] },
slots: [
{ x: 548, y: 901, zone: 'road' },
{ x: 725, y: 890, zone: 'road' },
{ x: 538, y: 325, zone: 'standard' },
{ x: 665, y: 436, zone: 'standard' },
{ x: 835, y: 428, zone: 'standard' },
{ x: 1006, y: 480, zone: 'standard' },
{ x: 415, y: 646, zone: 'standard' },
{ x: 303, y: 578, zone: 'standard' },
{ x: 695, y: 616, zone: 'standard' },
{ x: 855, y: 636, zone: 'standard' },
{ x: 1090, y: 780, zone: 'standard' },
{ x: 1087, y: 627, zone: 'hang' },
{ x: 845, y: 292, zone: 'berg' },
{ x: 951, y: 312, zone: 'berg' },
],
lift: { topX: 898, topY: 292, loop: [[858, 300], [938, 315]] },
},
kreuzung: {
id: 'kreuzung', name: 'Kreuzung', icon: '🛣️',
desc: 'Zwei Täler kreuzen sich am Dorfplatz: vier Ströme, ein Zentrum wer deckt alle Richtungen ab?',
image: 'assets/maps/kreuzung.png', w: 1536, h: 1024,
routes: {
nordsued: {
entry: 'N', exit: 'SE',
points: [[487, 255], [498, 264], [511, 276], [528, 292], [538, 300],
[546, 308], [556, 316], [565, 325], [575, 333], [585, 342],
[595, 350], [605, 360], [615, 368], [624, 376], [642, 391],
[658, 406], [674, 418], [687, 430], [698, 440], [712, 456],
[716, 477], [712, 494], [705, 510], [695, 528], [682, 546],
[668, 565], [660, 575], [650, 585], [642, 595], [633, 604],
[624, 614], [616, 623], [607, 632], [598, 642], [590, 650],
[580, 660], [572, 668], [563, 677], [547, 694], [532, 710],
[516, 727], [504, 740], [493, 752], [490, 755]],
},
westost: {
entry: 'W', exit: 'E',
points: [[135, 472], [158, 472], [174, 472], [189, 472], [207, 472],
[228, 472], [248, 472], [270, 472], [290, 472], [312, 472],
[332, 472], [354, 472], [374, 472], [396, 472], [416, 472],
[436, 472], [456, 472], [474, 472], [493, 472], [510, 471],
[527, 471], [543, 471], [559, 471], [575, 470], [591, 470],
[607, 470], [623, 470], [638, 471], [654, 471], [670, 471],
[686, 472], [701, 472], [717, 474], [732, 476], [748, 478],
[763, 481], [779, 484], [795, 488], [811, 492], [828, 495],
[844, 499], [862, 502], [879, 506], [897, 509], [916, 513],
[934, 517], [953, 521], [972, 524], [990, 528], [1010, 532],
[1028, 536], [1047, 539], [1066, 543], [1084, 547], [1103, 551],
[1122, 554], [1140, 558], [1160, 561], [1178, 564], [1197, 568],
[1216, 571], [1234, 574], [1253, 577], [1271, 580], [1289, 582],
[1306, 585], [1324, 587], [1340, 590], [1357, 592], [1373, 593],
[1387, 595], [1400, 596], [1418, 598], [1430, 600]],
},
},
entryRoutes: { W: ['westost'], N: ['nordsued'], S: ['nordsued'] },
slots: [
{ x: 425, y: 285, zone: 'road' },
{ x: 300, y: 694, zone: 'road' },
{ x: 645, y: 218, zone: 'standard' },
{ x: 330, y: 380, zone: 'standard' },
{ x: 545, y: 435, zone: 'standard' },
{ x: 933, y: 546, zone: 'standard' },
{ x: 760, y: 665, zone: 'standard' },
{ x: 610, y: 782, zone: 'standard' },
{ x: 890, y: 795, zone: 'standard' },
{ x: 1140, y: 640, zone: 'standard' },
{ x: 1180, y: 478, zone: 'standard' },
{ x: 1405, y: 465, zone: 'standard' },
{ x: 391, y: 512, zone: 'lake' },
{ x: 310, y: 611, zone: 'lake' },
],
},
passstrasse: {
id: 'passstrasse', name: 'Passstraße', icon: '🛤️',
desc: 'Ein langer Anstieg zum Pass, kaum Bauplätze: Hier zählt jede Standortentscheidung doppelt.',
image: 'assets/maps/passstrasse.png', w: 1536, h: 1024,
routes: {
bergauf: {
entry: 'W', exit: 'E',
points: [[700, 859], [687, 855], [684, 840], [692, 824], [705, 814],
[707, 798], [697, 789], [686, 774], [670, 758], [656, 748],
[643, 740], [629, 733], [614, 725], [600, 718], [582, 705],
[570, 690], [564, 675], [564, 660], [572, 643], [592, 626],
[603, 619], [624, 606], [638, 599], [649, 592], [669, 582],
[686, 574], [702, 569], [710, 584], [695, 593], [672, 603],
[656, 611], [642, 620], [658, 612], [669, 606], [688, 598],
[710, 590], [723, 585], [743, 578], [757, 571], [766, 559],
[779, 548], [794, 541], [811, 537], [828, 532], [843, 527],
[863, 517], [880, 510], [897, 514], [913, 511], [925, 498],
[928, 479], [927, 460], [922, 444], [910, 432], [888, 418],
[870, 408], [856, 400], [842, 388], [837, 374], [847, 356],
[859, 345], [870, 336], [890, 324], [909, 315], [926, 311],
[937, 304]],
},
bergab: { entry: 'S', exit: 'SE', reverseOf: 'bergauf' },
},
entryRoutes: { W: ['bergauf'], N: ['bergauf'], S: ['bergab'] },
slots: [
{ x: 562, y: 815, zone: 'road' },
{ x: 760, y: 875, zone: 'road' },
{ x: 300, y: 565, zone: 'standard' },
{ x: 415, y: 660, zone: 'standard' },
{ x: 560, y: 600, zone: 'standard' },
{ x: 850, y: 548, zone: 'standard' },
{ x: 905, y: 400, zone: 'standard' },
{ x: 1016, y: 330, zone: 'standard' },
],
},
};
// Editor-Overrides (js/maps-custom.json) über die eingebauten Daten legen:
// routes ersetzt die Punktlisten (entry/exit/lake-Flags bleiben erhalten,
// unbekannte Routen-IDs werden neu angelegt), slots ersetzt die Slot-Liste.
export function applyMapOverrides(overrides) {
if (!overrides) return;
for (const [mid, o] of Object.entries(overrides)) {
const def = MAPS[mid];
if (!def) continue;
if (o.routes) {
for (const [rk, pts] of Object.entries(o.routes)) {
if (!Array.isArray(pts) || pts.length < 2) continue;
if (def.routes[rk]) {
def.routes[rk] = { ...def.routes[rk], points: pts, reverseOf: undefined };
} else {
def.routes[rk] = { entry: 'W', exit: 'E', points: pts };
}
}
// reverseOf-Varianten zeigen ggf. auf ersetzte Routen bleibt gültig,
// solange die Basisroute existiert (prepMap löst sie auf)
}
if (Array.isArray(o.slots) && o.slots.length) {
def.slots = o.slots.map(s => ({ x: s.x, y: s.y, zone: s.zone || 'standard' }));
}
if (o.lift) def.lift = o.lift;
}
}
// Zubringer-Reihenfolge im Startbildschirm
export const MAP_ORDER = ['talschleife', 'seerunde', 'serpentinen', 'kreuzung', 'passstrasse'];
+802
View File
@@ -0,0 +1,802 @@
// Simulationskern: Spielzustand + tick(state, dt). Enthält Spawning,
// Bewegung entlang der Routen, Infrastrukturwirkung (§11: Zielgruppen-,
// Saison-, Umwelt-, Kapazitätsfaktor, Bremsen, Einwirkzeit), Ökonomie,
// Bewertung beim Verlassen, Phasen/Saisonen, Ereignisse und Endauswertung.
// Komplett DOM-frei läuft auch im Headless-Test (tests/model-test.mjs).
import {
GAME_LEN, PHASE_LEN, SEASON_LEN, SEASONS, SEASON_KEYS,
CONTACT_NORM, MAX_GAIN_FACTOR, MAX_BRAKE, START_BUDGET, START_ENV,
GROUPS, BUILDINGS, WAVES, EVENTS, FEED_TEXTS, PROFILES, GOALS, SYNERGIES, currentAvg,
} from './data.js';
import {
prepMap, mulberry32, makeRoute, cumLengths, TILE2PX, canPlace as worldCanPlace,
} from './world.js';
export { GOALS, currentAvg };
export const canPlace = worldCanPlace;
const LIFT_SPEED = 1.4; // Kacheln/s in der Liftfahrt
const REV_SCALE = 0.42; // globale Einnahmen-Skala (Balancing: Geld darf nicht trivial werden)
export function createState(opts = {}) {
const seed = opts.seed ?? 20260703;
const map = prepMap(opts.mapId || 'talschleife');
const state = {
map,
slots: map.slots,
playerName: opts.playerName || '',
classCode: opts.classCode || '',
time: 0, phase: 0, season: SEASONS[0], seasonKey: SEASON_KEYS[0],
ended: false, result: null,
budget: START_BUDGET, revenueTotal: 0, opCostTotal: 0,
env: START_ENV, traffic: 0,
fame: 1.0, festUntil: 0, natureBoost: 1,
buildings: [], nextBid: 1,
visitors: [], nextVid: 1,
unlocked: new Set(),
spawnQueue: [],
phaseExitStars: [],
feedPool: [],
pendingCards: [],
firedEvents: new Set(),
stats: {
all: { count: 0, stars: 0 },
groups: {},
buildingRevenue: {},
transports: { foot: 0, bike: 0, bus: 0, car: 0 },
exits: { E: 0, SE: 0 },
spawned: 0, skippedNoParking: 0,
},
fx: [],
heat: new Float32Array(48 * 32), heatCols: 48, heatRows: 32,
history: { t: [], budget: [], rating: [], env: [], visitors: [], satPct: [] },
lastSatPct: 0,
actionLog: [],
opAccum: 0, sampleAccum: 0,
waveStatus: 'pause', pauseLeft: 22, waveStartTime: 0,
recentStars: [],
rng: mulberry32(seed + 7),
};
prepareWave(state, 1, { silent: true });
return state;
}
// ---------- Haupt-Tick ----------
export function tick(state, dt) {
if (state.ended || dt <= 0) return;
state.time += dt;
// ---------- Wellen-Maschine (Tower-Defense-Rhythmus) ----------
// Baupause → Welle läuft → alle Gäste der Welle abgereist → nächste
// Baupause. „Nächste Welle rufen" startet früher (mit Bonus).
if (state.waveStatus === 'pause') {
state.pauseLeft -= dt;
if (state.pauseLeft <= 0) launchWave(state);
} else if (state.waveStatus === 'running') {
const active = state.spawnQueue.length > 0
|| state.visitors.some(v => v.wave === state.phase);
if (!active) {
if (state.phase >= 8) { finish(state); return; }
prepareWave(state, state.phase + 1);
state.waveStatus = 'pause';
state.pauseLeft = 16;
}
}
// ---------- Baustellen: Bau- und Ausbauzeit ----------
for (const b of state.buildings) {
if (b.construction > 0) {
b.construction -= dt;
if (b.construction <= 0) {
b.construction = 0;
state.pendingCards.push({ kind: 'built', type: b.type, level: b.level });
}
} else if (b.upgrading) {
b.upgrading.left -= dt;
if (b.upgrading.left <= 0) {
b.level++;
state.env = clampEnv(state.env + (BUILDINGS[b.type].env[b.level - 1] || 0));
b.upgrading = null;
refreshInfoBoosts(state);
state.pendingCards.push({ kind: 'built', type: b.type, level: b.level });
}
}
}
spawnDue(state);
moveAndAffect(state, dt);
// Betriebskosten alle 10 s (§18.7)
state.opAccum += dt;
while (state.opAccum >= 10) {
state.opAccum -= 10;
let cost = 0;
for (const b of state.buildings) {
if (b.construction > 0) continue; // Baustelle: noch keine Betriebskosten
cost += BUILDINGS[b.type].opCost[b.level - 1];
}
cost /= 6; // pro Minute → pro 10 s
state.budget -= cost;
state.opCostTotal += cost;
}
// Verkehr: Parkplätze + aktive Auto-/Busgruppen
let traffic = 0;
for (const b of state.buildings) {
if (b.type === 'parkplatz') traffic += BUILDINGS.parkplatz.traffic[b.level - 1];
}
for (const v of state.visitors) {
const tr = GROUPS[v.type].transport;
if (tr === 'car') traffic += 1.5;
else if (tr === 'bus') traffic += 2.5;
}
state.traffic = Math.round(traffic);
// Verlauf (für den Graphen) alle 5 s
state.sampleAccum += dt;
if (state.sampleAccum >= 5) {
state.sampleAccum = 0;
const h = state.history;
h.t.push(state.time);
h.budget.push(Math.round(state.budget));
h.rating.push(Number(currentAvg(state).toFixed(2)));
h.env.push(Math.round(state.env));
h.visitors.push(state.visitors.length);
// Live-Erfüllungsgrad: wie „voll" sind die Zufriedenheitsbalken gerade?
// (Ziel wie im Tower Defense: 100 % = Gast komplett glücklich)
if (state.visitors.length) {
const mean = state.visitors.reduce((a, v) =>
a + Math.max(0, Math.min(1.1, v.satisfaction / GROUPS[v.type].satTarget)), 0) / state.visitors.length;
state.lastSatPct = Math.round(mean * 100);
}
h.satPct.push(state.lastSatPct || 0);
}
if (state.time > 2400) finish(state); // Sicherheitsdeckel
}
// ---------- Wellen-Vorbereitung: Ruhm, Feed, Freischaltungen, Plan ----------
function prepareWave(state, phase, opts = {}) {
// Besucherfaktor aus den Bewertungen der Vorphase (§8.4)
if (state.phaseExitStars.length) {
const avg = state.phaseExitStars.reduce((a, b) => a + b, 0) / state.phaseExitStars.length;
state.fame = 0.7 + avg / 5;
// Social-Media-Feed der Vorphase
const picks = pickFeed(state);
if (picks.length) {
state.pendingCards.push({
kind: 'feed', icon: '📱', phase: state.phase,
title: `Bewertungen aus Phase ${state.phase}`,
items: picks, avg, fame: state.fame,
});
}
}
state.phaseExitStars = [];
state.feedPool = [];
state.phase = phase;
// Saison hängt an der Welle (2 Wellen je Jahreszeit)
const si = Math.min(3, Math.floor((phase - 1) / 2));
if (SEASON_KEYS[si] !== state.seasonKey) {
state.seasonKey = SEASON_KEYS[si];
state.season = SEASONS[si];
state.pendingCards.push({
kind: 'season', icon: ['🌸', '☀️', '🍂', '❄️'][si],
title: `Saisonwechsel: ${state.season}`,
text: {
summer: 'Der Sommer ist da! Badesee, Eis, Rad und Wandern sind jetzt besonders gefragt.',
autumn: 'Herbst: Kulinarik, Kultur und Wandern haben Hochsaison Badespaß ist vorbei.',
winter: 'Winter! Jetzt zählen Lift, Almhütte, Gasthaus und Aussicht. Der See ist zugefroren.',
}[state.seasonKey] || '',
});
}
// Ankündigungen/Entscheidungen dieser Welle
for (const ev of EVENTS) {
if (ev.wave === phase && !state.firedEvents.has(ev.id)) {
state.firedEvents.add(ev.id);
state.pendingCards.push({ kind: ev.type, ...ev });
}
}
// Freischaltungen
const fresh = Object.entries(BUILDINGS)
.filter(([id, d]) => d.unlockPhase === phase && !state.unlocked.has(id));
for (const [id] of fresh) state.unlocked.add(id);
// Welle planen
const wave = WAVES[phase] || {};
const parkPlus = sumBonus(state, 'parkplatz', 'visitorPlus');
const busPlus = sumBonus(state, 'bushaltestelle', 'busPlus');
const hasParking = state.buildings.some(b => b.type === 'parkplatz');
state.spawnQueue = [];
const waveCounts = [];
let slot = 0;
const t0 = 0; // relativ launchWave addiert die echte Startzeit
for (const [type, base] of Object.entries(wave)) {
const g = GROUPS[type];
let mult = state.fame;
if (g.transport === 'car') mult *= 1 + parkPlus;
if (g.transport === 'bus') mult *= 1 + busPlus + parkPlus * 0.5;
let n = Math.max(1, Math.round(base * mult));
if (g.needsParking && !hasParking) {
const skipped = Math.floor(n / 2);
n -= skipped;
state.stats.skippedNoParking += skipped;
if (skipped > 0) state.feedPool.push({ stars: 1, emoji: g.emoji, text: FEED_TEXTS.noParking, prio: 3 });
}
waveCounts.push(`${n}× ${GROUPS[type].name}`);
// Konvoi statt Streuung: Die Welle einer Gruppe kommt gebündelt kurz
// hintereinander (alle 46 s) und nimmt dieselbe Route so ist sie
// als zusammengehörende Welle erkennbar.
// Konvois innerhalb der Welle: Gruppe für Gruppe, eng beieinander
const clusterStart = t0 + 1 + slot * (n * 1.2 + 9);
const routePick = state.rng();
state.spawnQueue.push({ t: Math.max(t0 + 0.5, clusterStart - 1.2), banner: { type, n } });
for (let i = 0; i < n; i++) {
state.spawnQueue.push({ t: clusterStart + i * (2.4 + state.rng() * 0.8), type, routePick });
}
slot++;
}
state.spawnQueue.sort((a, b) => a.t - b.t);
// Wellen-Ankündigung: Wer kommt? (Porträts neuer Gruppen) + neue Bauoptionen
const newcomers = Object.keys(wave).filter(t => GROUPS[t].fromPhase === phase);
let text = `Erwartete Gäste: ${waveCounts.join(' · ')}.`;
for (const t of newcomers) {
const g = GROUPS[t];
text += `\n\n${g.emoji} ${g.name}: ${g.who}`;
}
if (fresh.length) {
text += `\n\n🔓 Neu baubar: ${fresh.map(([, d]) => `${d.emoji} ${d.name}`).join(' · ')}`;
}
state.pendingCards.push({
kind: 'phase', icon: '👥',
title: phase === 1 ? 'Phase 1: Die ersten Gäste kommen' : `Phase ${phase}: Neue Welle im Anmarsch`,
text,
});
}
// Welle wirklich starten: geplante Spawns auf die aktuelle Zeit schieben
function launchWave(state) {
for (const e of state.spawnQueue) e.t += state.time;
state.waveStatus = 'running';
state.waveStartTime = state.time;
state.pauseLeft = 0;
}
function sumBonus(state, type, key) {
let sum = 0;
for (const b of state.buildings) {
if (b.type === type) sum += BUILDINGS[type][key][b.level - 1];
}
return sum;
}
// ---------- Spawning ----------
function spawnDue(state) {
if (state.waveStatus !== 'running') return;
while (state.spawnQueue.length && state.spawnQueue[0].t <= state.time) {
const { type, routePick, banner } = state.spawnQueue.shift();
if (banner) {
state.pendingCards.push({ kind: 'banner', type: banner.type, n: banner.n });
continue;
}
if (state.visitors.length >= 120) continue; // Performance-Deckel
const g = GROUPS[type];
const route = makeRoute(state, g, state.rng, routePick);
const hasParking = state.buildings.some(b => b.type === 'parkplatz');
// Busgruppen fahren im Bus bis zur Haltestelle und steigen dort aus
let travel = 'walk', busStopS = 0;
if (g.transport === 'bus') {
const stop = state.buildings.find(b => b.type === 'bushaltestelle');
if (stop) {
let bestD = Infinity;
for (let i = 0; i < route.pts.length; i++) {
const d = Math.hypot(route.pts[i].x - stop.x, route.pts[i].y - stop.y);
if (d < bestD && route.cum[i] > 40) { bestD = d; busStopS = route.cum[i]; }
}
if (bestD < 260 && busStopS < route.cum[route.cum.length - 1] - 60) travel = 'bus';
}
}
state.visitors.push({
wave: state.phase,
travel, busStopS, busWait: 0,
id: state.nextVid++,
type,
pts: route.pts,
cum: route.cum,
exitLabel: route.exit,
s: 0, seg: 0,
x: route.pts[0].x, y: route.pts[0].y,
satisfaction: g.needsParking && !hasParking ? -10 : 0,
revFx: {},
spent: 0,
gained: {},
hadService: false,
fxNext: {},
brake: 0,
timeIn: 0,
mode: 'walk',
wobble: state.rng() * 7,
});
state.stats.spawned++;
state.stats.transports[g.transport]++;
}
}
// ---------- Bewegung + Infrastrukturwirkung ----------
// Tower-Defense-Kapazität (§11.7, verschärft): Jedes Gebäude bedient nur so
// viele Gäste, wie es Kapazität hat die nächstgelegenen zuerst. Wer keinen
// Platz bekommt, geht leer aus (sichtbar: Auslastungsbogen + Bedien-Bögen).
function moveAndAffect(state, dt) {
const bs = state.buildings;
const servedBy = new Array(bs.length);
for (let i = 0; i < bs.length; i++) {
const b = bs[i];
b.arcs = [];
if (b.construction > 0 || b.upgrading) {
// Baustelle: bedient niemanden
b.inRange = 0; b.util = 0; b.serving = [];
servedBy[i] = new Set();
continue;
}
const R = range(b);
const near = [];
for (const v of state.visitors) {
if (v.travel === 'bus') continue; // im Bus wird niemand bedient
const d = dist(v, b);
if (d <= R) near.push([d, v]);
}
near.sort((a, b2) => a[0] - b2[0]);
const cap = BUILDINGS[b.type].capacity[b.level - 1];
b.inRange = near.length;
b.util = cap >= 999 ? 0 : near.length / cap;
const taken = near.slice(0, cap);
servedBy[i] = new Set(taken.map(x => x[1].id));
b.serving = taken.slice(0, 10).map(x => x[1].id);
}
const envFBase = 0.6 + state.env / 100; // §11.9
const fest = state.time < state.festUntil ? 1.15 : 1;
for (const v of state.visitors) {
const g = GROUPS[v.type];
v.timeIn += dt;
// Wirkung aller Gebäude, die diesen Gast gerade bedienen
let brakeProduct = 1;
for (let i = 0; i < bs.length; i++) {
if (!servedBy[i].has(v.id)) continue;
const b = bs[i];
const def = BUILDINGS[b.type];
const lvl = b.level - 1;
const tf = targetFactor(def, b.level, v.type);
if (def.service) v.hadService = true;
if (def.slow[lvl] > 0 && tf >= 0.3 && v.mode !== 'lift') {
brakeProduct *= 1 - def.slow[lvl];
}
if (tf <= 0.05) continue;
const sf = def.season[state.seasonKey] ?? 1;
const envF = 1 + g.envSens * (envFBase - 1);
const natF = def.natureBoosted ? state.natureBoost : 1;
const elevB = def.elevBonus && b.slotZone === 'berg' ? 1.3 : 1;
const eff = def.sat[lvl] * tf * sf * envF * natF * (b.infoBoost || 1) * (b.synergy || 1) * elevB * fest;
const maxGain = def.sat[lvl] * tf * sf * MAX_GAIN_FACTOR;
const got = v.gained[b.id] ?? 0;
const add = Math.min((eff / CONTACT_NORM) * dt, Math.max(0, maxGain - got));
if (add > 0) {
v.satisfaction += add;
v.gained[b.id] = got + add;
// Bogen nur, solange die Bedienung wirklich etwas bringt
if (b.arcs.length < 10 && !b.arcs.includes(v.id)) b.arcs.push(v.id);
}
// sichtbare Interaktion: Objekt/Erlebnis schwebt vom Gebäude zum Gast
// beim ersten Kontakt und dann alle paar Sekunden wieder
// Objekt-Flug nur für wirklich passende Zielgruppen (tf ≥ 0.5),
// Produkt je Zielgruppe (itemsBy), Geräusch je Dienstleistung
if (tf >= 0.5 && (v.gained[b.id] ?? 0) >= 2 && state.time >= (v.fxNext[b.id] ?? 0)) {
v.fxNext[b.id] = state.time + 2.2 + state.rng() * 1.2;
const emoji = def.itemsBy?.[v.type] || def.item || def.fx;
if (emoji && state.fx.length < 90) {
const amount = Math.round(v.revFx[b.id] || 0);
if (amount > 0) v.revFx[b.id] = 0;
state.fx.push({ emoji, fx: b.x, fy: b.y, vid: v.id, amount, sound: def.sound || 'pling' });
}
}
// Einnahmen als Folge von Zufriedenheit (§11.8, §25)
const baseRev = def.revenue[lvl];
if (baseRev > 0 && tf > 0.3) {
const satF = Math.min(1.5, 0.5 + Math.max(0, v.satisfaction) / g.satTarget);
const rev = Math.min((baseRev * REV_SCALE * tf * satF / CONTACT_NORM) * dt, g.budget - v.spent);
if (rev > 0) {
state.budget += rev;
state.revenueTotal += rev;
v.spent += rev;
v.revFx[b.id] = (v.revFx[b.id] || 0) + rev;
state.stats.buildingRevenue[b.type] = (state.stats.buildingRevenue[b.type] || 0) + rev;
}
}
}
v.brake = Math.min(MAX_BRAKE, 1 - brakeProduct);
// Bewegung entlang der Route (Bogenlänge in Bild-Pixeln)
const liftSeg = v.pts[v.seg + 1]?.lift;
v.mode = liftSeg ? 'lift' : 'walk';
// starke Bremse ⇒ sichtbares Verweilen: Stop-and-Go statt Zeitlupe
v.stopped = false;
if (!liftSeg && v.travel === 'walk' && v.brake > 0.28) {
const cycle = (state.time + v.id * 1.7) % 7;
if (cycle < 2.8) v.stopped = true;
}
let speed = (liftSeg ? LIFT_SPEED : g.speed * (1 - v.brake * 0.55)) * TILE2PX;
if (v.stopped) speed = 0;
if (v.travel === 'bus') {
speed = 2.6 * TILE2PX; // Busfahrt bis zur Haltestelle
if (v.s >= v.busStopS) { v.travel = 'busstop'; v.busWait = 2.6; }
} else if (v.travel === 'busstop') {
speed = 0; // Halt: Fahrgäste steigen aus
v.busWait -= dt;
if (v.busWait <= 0) v.travel = 'walk';
}
v.s += speed * dt;
const total = v.cum[v.cum.length - 1];
if (v.s >= total) {
exitVisitor(state, v);
} else {
while (v.seg < v.pts.length - 2 && v.cum[v.seg + 1] <= v.s) v.seg++;
const a = v.pts[v.seg], b2 = v.pts[v.seg + 1];
const len = v.cum[v.seg + 1] - v.cum[v.seg] || 1;
const f = (v.s - v.cum[v.seg]) / len;
v.x = a.x + (b2.x - a.x) * f;
v.y = a.y + (b2.y - a.y) * f;
v.headRight = b2.x >= a.x;
}
}
// Besucherstrom-Dichte fürs Heatmap-Raster akkumulieren (§15.7)
const HC = state.heatCols, HR = state.heatRows;
const mw = state.map.w, mh = state.map.h;
for (const v of state.visitors) {
if (v.exited || v.travel === 'bus') continue;
const c = Math.max(0, Math.min(HC - 1, Math.floor(v.x / mw * HC)));
const r = Math.max(0, Math.min(HR - 1, Math.floor(v.y / mh * HR)));
state.heat[r * HC + c] += dt;
}
state.visitors = state.visitors.filter(v => !v.exited);
}
function dist(v, b) {
return Math.hypot(v.x - b.x, v.y - b.y);
}
function range(b) {
return BUILDINGS[b.type].range[b.level - 1] * TILE2PX;
}
function targetFactor(def, level, groupId) {
const byLevel = def.targetByLevel?.[groupId];
if (byLevel) return byLevel[level - 1];
return def.target[groupId] ?? 0.4;
}
// ---------- Abreise + Bewertung (§8, §18.5) ----------
function exitVisitor(state, v) {
v.exited = true;
const g = GROUPS[v.type];
let sat = v.satisfaction;
const malus = { service: false, traffic: false, env: false, time: false };
if (g.needsService && !v.hadService && v.timeIn > 60) { sat -= 10; malus.service = true; }
const tPen = state.traffic * 0.08 * g.trafficSens;
if (tPen > 4) malus.traffic = true;
sat -= tPen;
if (state.env < 45 && g.envSens >= 0.7) malus.env = true;
if (v.timeIn > g.maxTime) { sat *= 0.92; malus.time = true; }
const ratio = Math.max(0, sat) / g.satTarget;
const stars = Math.max(1, Math.min(5, Math.ceil(ratio * 5)));
// sichtbares Abschluss-Feedback am Ausgang (wie „Gegner besiegt")
if (state.fx.length < 90) {
state.fx.push({ pop: true, stars, fx: v.x, fy: v.y });
}
state.recentStars.push(stars);
if (state.recentStars.length > 12) state.recentStars.shift();
if (stars <= 2) state.lastBadExit = state.time;
const st = state.stats;
st.all.count++; st.all.stars += stars;
st.groups[v.type] = st.groups[v.type] || { count: 0, stars: 0 };
st.groups[v.type].count++;
st.groups[v.type].stars += stars;
st.groups[v.type].avg = st.groups[v.type].stars / st.groups[v.type].count;
st.exits[v.exitLabel] = (st.exits[v.exitLabel] || 0) + 1;
state.phaseExitStars.push(stars);
// Feed-Text erzeugen
let text;
if (malus.traffic && stars <= 3 && state.rng() < 0.5) text = FEED_TEXTS.traffic;
else if (malus.env && stars <= 3 && state.rng() < 0.4) text = FEED_TEXTS.env;
else {
const pool = FEED_TEXTS[stars];
text = pool[Math.floor(state.rng() * pool.length)]
.replace('{best}', bestBuildingName(state, v))
.replace('{missing}', g.wish);
}
state.feedPool.push({ stars, emoji: g.emoji, group: g.name, text, prio: stars === 5 || stars <= 2 ? 2 : 1 });
}
function bestBuildingName(state, v) {
let best = null, val = 3;
for (const [bid, gain] of Object.entries(v.gained)) {
if (gain > val) {
const b = state.buildings.find(x => x.id === Number(bid));
if (b) { best = b; val = gain; }
}
}
if (!best) return 'die Landschaft';
const def = BUILDINGS[best.type];
return (def.levelNames ? def.levelNames[best.level - 1] : def.name);
}
function pickFeed(state) {
const sorted = [...state.feedPool].sort((a, b) => (b.prio - a.prio) || (state.rng() - 0.5));
const out = [];
const seen = new Set();
for (const f of sorted) {
if (seen.has(f.text)) continue;
seen.add(f.text);
out.push(f);
if (out.length >= 3) break;
}
return out;
}
// ---------- Bauen / Ausbauen / Abreißen (auf festen Slots) ----------
export function place(state, type, slotIdx) {
const check = canPlace(state, type, slotIdx);
if (!check.ok) return check;
const def = BUILDINGS[type];
const slot = state.slots[slotIdx];
state.budget -= def.cost[0];
const b = {
id: state.nextBid++, type, level: 1,
x: slot.x, y: slot.y, slotIdx, slotZone: slot.zone, infoBoost: 1,
construction: 5 + def.cost[0] / 50, // Baustellenzeit in Sekunden
upgrading: null, synergy: 1, synergyList: [],
};
state.buildings.push(b);
slot.building = b;
state.env = clampEnv(state.env + def.env[0]);
state.actionLog.push({ t: state.time, action: 'build', type, level: 1 });
if (def.opensBerg) {
state.pendingCards.push({
kind: 'card', icon: '⛰️', title: 'Der Berg ist erschlossen!',
text: 'Die Seilbahn bringt Gäste auf den Berg oben warten neue Bauplätze '
+ '(Almhütte, Aussichtspunkt). Wanderer und junge Gruppen lieben das, '
+ 'im Winter läuft der Lift auf Hochtouren.',
});
}
if (type === 'naturschutz') {
state.natureBoost = 1.15;
// Schutzgebiet sperrt umliegende freie Bauplätze (§10: Zielkonflikt
// Wirtschaft ↔ Schutz). Reichweite in Pixeln.
const R = (def.blocksRadius || 3.2) * TILE2PX;
b.blockedSlots = [];
for (const s of state.slots) {
if (s === slot || s.building || s.zone === 'lake' || s.zone === 'road') continue;
if (Math.hypot(s.x - slot.x, s.y - slot.y) <= R) {
s.blocked = true;
b.blockedSlots.push(s.idx);
}
}
}
refreshInfoBoosts(state);
return { ok: true, building: b };
}
export function upgrade(state, b) {
const def = BUILDINGS[b.type];
if (b.construction > 0 || b.upgrading) return { ok: false, reason: 'Hier wird gerade gebaut.' };
if (b.level >= def.maxLevel) return { ok: false, reason: 'Bereits voll ausgebaut.' };
const cost = def.cost[b.level];
if (state.budget < cost) return { ok: false, reason: `Zu teuer Ausbau kostet ${cost} €.` };
state.budget -= cost;
b.upgrading = { left: 4 + cost / 60 }; // Umbauzeit; Stufe zählt erst danach
state.actionLog.push({ t: state.time, action: 'upgrade', type: b.type, level: b.level + 1 });
return { ok: true, cost, time: Math.round(b.upgrading.left) };
}
export function demolish(state, b) {
if (b.type === 'naturschutz') return { ok: false, reason: 'Ein Schutzgebiet wird nicht einfach wieder aufgehoben.' };
const def = BUILDINGS[b.type];
let invested = 0;
for (let i = 0; i < b.level; i++) invested += def.cost[i];
const refund = Math.round(invested * 0.3);
state.budget += refund;
state.buildings = state.buildings.filter(x => x !== b);
const slot = state.slots[b.slotIdx];
if (slot) slot.building = null;
// gesperrte Bauplätze eines Schutzgebiets wieder freigeben
if (b.blockedSlots) for (const i of b.blockedSlots) { if (state.slots[i]) state.slots[i].blocked = false; }
state.actionLog.push({ t: state.time, action: 'demolish', type: b.type });
if (def.opensBerg) {
// Wer gerade am Berg unterwegs ist, steigt um: Rest des Wegs zu Fuß
for (const v of state.visitors) {
if (v.pts.some(p => p.lift)) {
for (const p of v.pts) delete p.lift;
}
}
}
refreshInfoBoosts(state);
return { ok: true, refund, name: def.name };
}
// Tourismusinfo-Multiplikator (§10) + Synergien (§9.2) je Gebäude neu berechnen.
// Wird nach jedem Bau/Ausbau/Abriss aufgerufen.
function refreshInfoBoosts(state) {
const infos = state.buildings.filter(b => b.type === 'tourismusinfo');
for (const b of state.buildings) {
let boost = 1;
for (const inf of infos) {
if (inf === b) continue;
const r = BUILDINGS.tourismusinfo.range[inf.level - 1] * TILE2PX;
if (Math.hypot(inf.x - b.x, inf.y - b.y) <= r) {
boost += BUILDINGS.tourismusinfo.boost[inf.level - 1];
}
}
b.infoBoost = boost;
b.synergy = 1;
b.synergyList = [];
}
// Synergie-Paare: beide Partner bekommen den Bonus, wenn in Reichweite
for (const rule of SYNERGIES) {
for (const x of state.buildings) {
if (x.type !== rule.a) continue;
for (const y of state.buildings) {
if (y.type !== rule.b) continue;
if (Math.hypot(x.x - y.x, x.y - y.y) <= rule.radius) {
x.synergy += rule.bonus; x.synergyList.push(rule.why);
y.synergy += rule.bonus; y.synergyList.push(rule.why);
}
}
}
}
}
function clampEnv(v) {
return Math.max(5, Math.min(100, v));
}
// ---------- „Nächste Welle" (Tower-Defense-Klassiker) ----------
// Ruft die nächste Phase sofort Bonus fürs Risiko, restliche Spawns der
// laufenden Phase entfallen. In Phase 8 nicht mehr möglich.
export function callNextWave(state) {
if (state.ended) return { ok: false, reason: 'Die Partie ist vorbei.' };
if (state.waveStatus === 'pause') {
// Baupause überspringen: kleiner Bonus für die gesparte Zeit
const bonus = Math.round(Math.max(0, state.pauseLeft) * 2) + 10;
state.budget += bonus;
launchWave(state);
state.actionLog.push({ t: state.time, action: 'next_wave', bonus });
return { ok: true, bonus };
}
if (state.phase >= 8) return { ok: false, reason: 'Die letzte Welle läuft bereits.' };
// Welle früh rufen: Risiko-Bonus je noch aktivem Gast
const remaining = state.visitors.filter(v => v.wave === state.phase).length;
const bonus = 15 + remaining * 4;
state.budget += bonus;
prepareWave(state, state.phase + 1);
launchWave(state);
state.actionLog.push({ t: state.time, action: 'next_wave', bonus, early: true });
return { ok: true, bonus };
}
// ---------- Endauswertung (§12, §21) ----------
export function finish(state) {
if (state.ended) return;
state.ended = true;
const st = state.stats;
const avgStars = st.all.count ? st.all.stars / st.all.count : 0;
const nonCar = st.transports.foot + st.transports.bike + st.transports.bus;
const total = Math.max(1, st.spawned);
let lenkung = (nonCar / total) * 100;
if (state.buildings.some(b => b.type === 'bushaltestelle')) lenkung = Math.min(100, lenkung + 10);
const scores = {
rating: (avgStars / 5) * 100,
revenue: Math.min(100, (state.revenueTotal / 2500) * 100),
sustain: state.env,
lenkung,
finance: state.budget >= 0 ? 100 : Math.max(0, 100 * (1 + state.budget / 1000)),
};
const score = Math.round(
scores.rating * 0.35 + scores.revenue * 0.25 + scores.sustain * 0.20
+ scores.lenkung * 0.10 + scores.finance * 0.10,
);
state.result = {
avgStars, score, scores,
revenue: Math.round(state.revenueTotal),
env: Math.round(state.env),
budget: Math.round(state.budget),
visitors: st.spawned,
rated: st.all.count,
goals: GOALS.map(g => ({ label: g.label, done: g.check(state) })),
};
state.result.profile = PROFILES.find(p => p.check(state));
state.result.feedback = buildFeedback(state);
state.pendingCards.push({ kind: 'end' });
}
function buildFeedback(state) {
const st = state.stats;
const lines = [];
// stärkste und schwächste Gruppe
let best = null, worst = null;
for (const [id, g] of Object.entries(st.groups)) {
if (g.count < 3) continue;
if (!best || g.avg > best.g.avg) best = { id, g };
if (!worst || g.avg < worst.g.avg) worst = { id, g };
}
if (best && best.g.avg >= 3) {
lines.push(`Besonders wohl fühlten sich ${plural(best.id)}${best.g.avg.toFixed(1)} Sterne) `
+ 'dein Angebot passte zu ihren Bedürfnissen.');
} else if (best) {
lines.push('Keine Besuchergruppe wurde richtig glücklich die Region bot zu wenig passende '
+ 'Angebote entlang der Wege.');
}
// Wirtschaft
const topRev = Object.entries(st.buildingRevenue).sort((a, b) => b[1] - a[1])[0];
if (topRev) {
lines.push(`Wirtschaftlich trug ${BUILDINGS[topRev[0]].name} am meisten zur regionalen `
+ `Wertschöpfung bei (${Math.round(topRev[1])} € von ${Math.round(state.revenueTotal)} €).`);
}
if (state.budget < 0) {
lines.push('Das Budget rutschte ins Minus Betriebskosten laufen weiter, auch wenn keine Gäste da sind. '
+ 'Teure Infrastruktur braucht Zeit, bis sie sich rechnet (Amortisation).');
}
// Umwelt
if (state.env >= 65) {
lines.push('Der Umweltwert blieb hoch. Das zahlt sich doppelt aus: Naturgruppen bewerten besser, '
+ 'und die Landschaft bleibt als wichtigster Standortfaktor erhalten.');
} else if (state.env < 45) {
lines.push('Der Umweltwert ist stark gesunken Parkflächen, Lift und Ausbau haben Spuren hinterlassen. '
+ 'Naturnahe Gäste haben das in ihren Bewertungen deutlich gemacht.');
}
// Verkehr / Mobilität
if (state.stats.skippedNoParking > 2) {
lines.push(`${state.stats.skippedNoParking} Busgruppen fanden keinen Parkplatz und fuhren weiter `
+ 'Erreichbarkeit entscheidet, wer überhaupt Gast werden kann.');
}
if (state.traffic > 30) {
lines.push('Der Verkehr im Ort wurde zur Belastung empfindliche Gäste (Senioren, Familien) '
+ 'bewerteten schlechter. Bushaltestelle und Besucherlenkung könnten helfen.');
}
// Verbesserungstipp
if (worst && worst.g.avg < 3) {
lines.push(`Am schlechtesten schnitten ${plural(worst.id)} ab (Ø ${worst.g.avg.toFixed(1)} Sterne). `
+ `Ihnen fehlte: ${GROUPS[worst.id].wish}.`);
}
return lines;
}
function plural(id) {
return {
spaziergaenger: 'Spaziergänger', wanderer: 'Wanderer', familie: 'Familien',
radfahrer: 'Radfahrer', senioren: 'Seniorengruppen', schulklasse: 'Schulklassen',
busgruppe: 'Busreisegruppen', junge_gruppe: 'junge Reisegruppen', luxuspaar: 'wohlhabende Paare',
}[id] || id;
}
+79
View File
@@ -0,0 +1,79 @@
// Hintergrundmusik-Player: 15 Titel aus assets/music/ (neu betitelt),
// gesteuert über das Panel rechts oben (⏮ ⏯ ⏭, Lautstärke, Titel-Auswahl).
// Läuft über ein einzelnes Audio-Element (Streaming statt Volldecodierung);
// die saisonale Ambience (audio.js) pausiert, solange Musik spielt.
import { audio } from './audio.js';
export const TRACKS = [
{ file: 'pistenglueck.mp3', title: 'Pistenglück' },
{ file: 'alpenkindheit.mp3', title: 'Als ich klein war in den Bergen' },
{ file: 'alpenkindheit-abend.mp3', title: 'Alpenkindheit (Abendversion)' },
{ file: 'auf-skiern.mp3', title: 'Auf Skiern durchs Tal' },
{ file: 'bergrock.mp3', title: 'Bergrock' },
{ file: 'gletscherklang.mp3', title: 'Gletscherklang' },
{ file: 'winterpromenade.mp3', title: 'Winterpromenade' },
{ file: 'almgluehen.mp3', title: 'Almglühen' },
{ file: 'seeblick.mp3', title: 'Seeblick im Schnee' },
{ file: 'talnebel.mp3', title: 'Talnebel' },
{ file: 'schneegestoeber.mp3', title: 'Schneegestöber' },
{ file: 'bergfrieden.mp3', title: 'Bergfrieden' },
{ file: 'kaminabend.mp3', title: 'Kaminabend' },
{ file: 'erste-flocken.mp3', title: 'Erste Flocken' },
{ file: 'frostfunken.mp3', title: 'Frostfunken (Jingle)' },
];
export const music = {
el: null,
idx: 0,
playing: false,
onChange: null, // UI-Callback (Titel/Status aktualisieren)
_ensure() {
if (this.el) return;
this.el = new Audio();
this.el.volume = 0.28;
this.el.addEventListener('ended', () => this.next());
this.el.addEventListener('error', () => {
// Datei fehlt/kaputt → einfach weiter zum nächsten Titel
if (this.playing) this.next();
});
},
play(i = this.idx) {
this._ensure();
this.idx = ((i % TRACKS.length) + TRACKS.length) % TRACKS.length;
this.el.src = `assets/music/${TRACKS[this.idx].file}`;
this.el.play().catch(() => { this.playing = false; this.onChange?.(); });
this.playing = true;
audio._stopAmbience?.(); // Vogelgezwitscher pausiert, solange Musik läuft
this.onChange?.();
},
pause() {
this.el?.pause();
this.playing = false;
if (audio.musicOn) audio.startMusic(); // Ambience wieder aufnehmen
this.onChange?.();
},
toggle() {
if (this.playing) this.pause();
else this.play();
},
next() { this.play(this.idx + 1); },
prev() { this.play(this.idx - 1); },
setMuted(m) {
this._ensure();
this.el.muted = m;
},
setVolume(v) {
this._ensure();
this.el.volume = Math.max(0, Math.min(1, v));
},
current() { return TRACKS[this.idx]; },
};
+966
View File
@@ -0,0 +1,966 @@
// Diorama-Renderer: vorgerendertes 3D-Kartenbild (assets/maps/*) als Welt,
// darüber die dynamische Ebene Bauplatz-Slots, KI-Gebäudesprites (mit
// Deluxe-Variante ab Stufe 4), KI-Besuchergruppen, Seilbahn, Bedien-Bögen,
// Auslastung, schwebende Objekte, Saisontönung, Schneefall/Herbstlaub.
// Ersetzt den früheren Iso-Kachel-Renderer komplett.
import { BUILDINGS, GROUPS } from './data.js';
import { canPlace, TILE2PX } from './world.js';
import { audio } from './audio.js';
// Zeichenbreite der Gebäude-Sprites in Welt-Pixeln (Bildkoordinaten)
const BUILDING_W = {
wirtshaus: 62, cafe: 52, marktstand: 48, aussicht: 50, spielplatz: 58,
wc: 40, badesteg: 60, eisdiele: 52, radstation: 46, tourismusinfo: 44,
naturschutz: 52, museum: 64, parkplatz: 66, bushaltestelle: 48,
souvenir: 50, skilift: 56, almhuette: 56, wellness: 66,
};
const GROUP_W = {
spaziergaenger: 46, wanderer: 48, familie: 56, senioren: 52, schulklasse: 62,
busgruppe: 64, junge_gruppe: 56, luxuspaar: 46,
};
// Kleidung der Walker: Jeans-/Khaki-Hosen, Oberteile bunt gemischt
// (deterministisch je Gast + Figur, damit nichts flackert)
const PANTS_COLORS = ['#3e5a74', '#54627a', '#8a7d5a', '#4a4f57', '#6b5a45', '#46586b'];
const TOP_COLORS = ['#c9531f', '#2f6ea0', '#3f8f6b', '#a34a8e', '#e8892b',
'#8c6239', '#5b6ee1', '#b0356b', '#4a7f56', '#c94f4f', '#6d8a3a', '#996fb8'];
// Gruppen als Walker-Kompositionen: dx = entlang der Gehrichtung, dy = Tiefe
const WALK_GROUPS = {
spaziergaenger: [{ dx: -8, dy: 0 }, { dx: 7, dy: 3, shade: 18 }],
wanderer: [{ dx: -9, dy: 0, prop: 'pole', pack: true }, { dx: 8, dy: 3, shade: 16, prop: 'pole', pack: true }],
familie: [{ dx: -13, dy: 0 }, { dx: 1, dy: 3, shade: 16 }, { dx: 11, dy: 1, kid: true }, { dx: -3, dy: -3, kid: true, shade: 30 }],
senioren: [{ dx: -10, dy: 0, prop: 'cane', lean: 0.06 }, { dx: 3, dy: 3, shade: 14, prop: 'hat' }, { dx: 13, dy: -1, shade: 26 }],
schulklasse: [{ dx: -17, dy: 2 }, { dx: -5, dy: -2, kid: true }, { dx: 3, dy: 3, kid: true, shade: 20 }, { dx: 11, dy: -1, kid: true, shade: 35 }, { dx: 19, dy: 2, kid: true, shade: 50 }],
busgruppe: [{ dx: -15, dy: 0, prop: 'umbrella' }, { dx: -3, dy: 3, shade: 14, prop: 'hat' }, { dx: 5, dy: -2, shade: 26 }, { dx: 14, dy: 2, shade: 38 }, { dx: 22, dy: -1, shade: 50 }],
junge_gruppe: [{ dx: -12, dy: 0 }, { dx: -2, dy: 3, shade: 18 }, { dx: 8, dy: -2, shade: 34 }, { dx: 17, dy: 2, shade: 50 }],
luxuspaar: [{ dx: -7, dy: 0, prop: 'hat', lean: -0.04 }, { dx: 7, dy: 3, shade: 16, prop: 'bag' }],
};
const SEASON_TINT = {
spring: null,
summer: 'rgba(255,214,110,0.06)',
autumn: 'rgba(205,145,55,0.12)',
winter: 'rgba(228,238,248,0.42)',
};
export class SceneRenderer {
constructor(canvas, state, callbacks) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.state = state;
this.cb = callbacks; // {onTap(hit)}
this.cam = { x: 0, y: 0, scale: 1 };
this.pointers = new Map();
this.dragMoved = 0;
this.pinchDist = 0;
this.hover = null;
this.buildType = null;
this.selected = null;
this.reduceAnim = false;
this.debug = new URLSearchParams(location.search).has('debug');
this.time = 0;
this.simTime = 0;
this.flakes = [];
this.leaves = [];
this.floats = [];
this.fxAnims = [];
this.vel = { x: 0, y: 0 };
this.zoomTarget = null;
// Kartenbild + Sprites laden (fehlt eines → einfacher Fallback)
this.mapImg = new Image();
this.mapImg.src = state.map.image;
this.sprites = {};
const load = (key, src) => {
const img = new Image();
img.onload = () => { this.sprites[key] = img; };
img.src = src;
};
for (const id of Object.keys(BUILDINGS)) {
load(id, `assets/sprites/${id}.png`);
load(`${id}_2`, `assets/sprites/${id}_2.png`);
}
for (const id of Object.keys(GROUP_W)) {
load(`g_${id}`, `assets/sprites/groups/${id}.png`);
for (let f = 0; f < 4; f++) load(`g_${id}_w${f}`, `assets/sprites/groups/${id}_w${f}.png`);
}
load('g_radler', 'assets/sprites/radler.png');
load('slot', 'assets/sprites/slot.png');
load('bus', 'assets/sprites/bus.png');
load('baustelle', 'assets/sprites/baustelle.png');
this._bindInput();
this.resize();
this.resetCamera();
}
addFloat(text, color, x, y) {
this.floats.push({ text, color, x, y, t0: this.simTime });
if (this.floats.length > 14) this.floats.shift();
}
resize() {
const dpr = window.devicePixelRatio || 1;
this.dpr = dpr;
this.canvas.width = innerWidth * dpr;
this.canvas.height = innerHeight * dpr;
}
resetCamera() {
const m = this.state.map;
const fit = Math.min(innerWidth / m.w, innerHeight / m.h);
this.cam.scale = fit * 1.06;
this.cam.x = (innerWidth - m.w * this.cam.scale) / 2;
this.cam.y = (innerHeight - m.h * this.cam.scale) / 2;
this.zoomTarget = null;
this.vel = { x: 0, y: 0 };
}
// kleinster Zoom: Karte füllt den Bildschirm (nicht weiter herauszoomen)
_minScale() {
const m = this.state.map;
return Math.max(innerWidth / m.w, innerHeight / m.h) * 0.98;
}
worldToScreen(x, y) {
return { x: x * this.cam.scale + this.cam.x, y: y * this.cam.scale + this.cam.y };
}
screenToWorld(sx, sy) {
return { x: (sx - this.cam.x) / this.cam.scale, y: (sy - this.cam.y) / this.cam.scale };
}
setBuildType(type) { this.buildType = type; }
// ---------- Eingabe (Pan / Pinch / Tap) ----------
_bindInput() {
const cv = this.canvas;
cv.addEventListener('pointerdown', e => {
cv.setPointerCapture(e.pointerId);
this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
this.dragMoved = 0;
this.vel = { x: 0, y: 0 };
this.zoomTarget = null;
if (this.pointers.size === 2) {
const [a, b] = [...this.pointers.values()];
this.pinchDist = Math.hypot(a.x - b.x, a.y - b.y);
}
});
cv.addEventListener('pointermove', e => {
const p = this.pointers.get(e.pointerId);
this.hover = this.screenToWorld(e.clientX, e.clientY);
if (!p) return;
const dx = e.clientX - p.x, dy = e.clientY - p.y;
this.dragMoved += Math.abs(dx) + Math.abs(dy);
if (this.pointers.size === 1) {
this.cam.x += dx; this.cam.y += dy;
this.vel = { x: dx, y: dy };
}
p.x = e.clientX; p.y = e.clientY;
if (this.pointers.size === 2) {
const [a, b] = [...this.pointers.values()];
const d = Math.hypot(a.x - b.x, a.y - b.y);
if (this.pinchDist > 0) this._zoomAround((a.x + b.x) / 2, (a.y + b.y) / 2, d / this.pinchDist);
this.pinchDist = d;
}
});
cv.addEventListener('pointerup', e => {
const wasTap = this.dragMoved < 10 && this.pointers.size === 1;
this.pointers.delete(e.pointerId);
this.pinchDist = 0;
if (wasTap) {
this.vel = { x: 0, y: 0 };
const w = this.screenToWorld(e.clientX, e.clientY);
this.cb.onTap(this._hitTest(w.x, w.y));
if (this.debug) {
// Mini-Pfad-Editor: jeder Tap sammelt einen Punkt; Konsole zeigt
// das fertige points-Array für js/maps.js. Reset: __ttTrace = []
window.__ttTrace = window.__ttTrace || [];
window.__ttTrace.push([Math.round(w.x), Math.round(w.y)]);
console.log('points:', JSON.stringify(window.__ttTrace));
}
}
});
cv.addEventListener('pointercancel', e => this.pointers.delete(e.pointerId));
cv.addEventListener('wheel', e => {
e.preventDefault();
const cur = this.zoomTarget?.scale ?? this.cam.scale;
this.zoomTarget = {
scale: Math.min(3, Math.max(this._minScale(), cur * (e.deltaY < 0 ? 1.15 : 0.87))),
x: e.clientX, y: e.clientY,
};
}, { passive: false });
window.addEventListener('resize', () => this.resize());
}
_zoomAround(mx, my, factor) {
const next = Math.min(3, Math.max(this._minScale(), this.cam.scale * factor));
const real = next / this.cam.scale;
this.cam.x = mx - (mx - this.cam.x) * real;
this.cam.y = my - (my - this.cam.y) * real;
this.cam.scale = next;
}
_hitTest(x, y) {
// Gebäude/Slots zuerst, dann Besucher
let bestSlot = null, bestD = 48;
for (const slot of this.state.slots) {
const d = Math.hypot(slot.x - x, slot.y - y);
if (d < bestD) { bestD = d; bestSlot = slot; }
}
if (bestSlot) {
return bestSlot.building
? { kind: 'building', building: bestSlot.building, slot: bestSlot }
: { kind: 'slot', slot: bestSlot };
}
let bestV = null, bestVD = 46;
for (const v of this.state.visitors) {
const d = Math.hypot(v.x - x, v.y - y);
if (d < bestVD) { bestVD = d; bestV = v; }
}
if (bestV) return { kind: 'visitor', visitor: bestV };
return { kind: 'none', x, y };
}
// ---------- Zeichnen ----------
draw(now, dt, speed = 0) {
this.time = now;
const simDt = dt * speed;
this.simTime += simDt;
if (!this.pointers.size && (Math.abs(this.vel.x) > 0.3 || Math.abs(this.vel.y) > 0.3)) {
this.cam.x += this.vel.x; this.cam.y += this.vel.y;
this.vel.x *= 0.92; this.vel.y *= 0.92;
}
if (this.zoomTarget) {
const zt = this.zoomTarget;
const next = this.cam.scale + (zt.scale - this.cam.scale) * 0.22;
this._zoomAround(zt.x, zt.y, next / this.cam.scale);
if (Math.abs(this.cam.scale - zt.scale) < 0.003) this.zoomTarget = null;
}
const { ctx, cam, dpr } = this;
const m = this.state.map;
const season = this.state.seasonKey;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// weicher Studiohintergrund passend zum Diorama
const grad = ctx.createLinearGradient(0, 0, 0, innerHeight);
if (season === 'winter') { grad.addColorStop(0, '#dbe4ec'); grad.addColorStop(1, '#efeeea'); }
else { grad.addColorStop(0, '#eee3cd'); grad.addColorStop(1, '#e7dcc4'); }
ctx.fillStyle = grad;
ctx.fillRect(0, 0, innerWidth, innerHeight);
ctx.save();
ctx.translate(cam.x, cam.y);
ctx.scale(cam.scale, cam.scale);
if (this.mapImg.complete && this.mapImg.naturalWidth) {
ctx.drawImage(this.mapImg, 0, 0, m.w, m.h);
} else {
ctx.fillStyle = '#a9cb8d';
ctx.fillRect(0, 0, m.w, m.h);
}
// Saisontönung über dem Artwork
const tint = SEASON_TINT[season];
if (tint) {
ctx.fillStyle = tint;
ctx.fillRect(0, 0, m.w, m.h);
}
this._drawSlots();
this._drawLift();
if (!this.reduceAnim) this._drawServiceArcs();
// Tiefensortierung: Gebäude UND Besucher gemeinsam nach Fußpunkt
// wer weiter hinten (kleineres y) steht, wird zuerst gezeichnet
const depth = [
...this.state.buildings.map(b => ({ y: b.y + 10, draw: () => this._drawBuilding(b) })),
...this.state.visitors.map(v => ({ y: v.y + (v.mode === 'lift' ? 400 : 4), draw: () => this._drawVisitor(v, season === 'winter') })),
].sort((a, b) => a.y - b.y);
for (const d of depth) d.draw();
this._drawFx(simDt);
this._drawFloats();
this._drawRangePreview();
if (this.debug) this._drawDebug();
ctx.restore();
if (!this.reduceAnim && dt > 0) {
if (season === 'winter') this._particles(dt, 'snow');
if (season === 'autumn') this._particles(dt, 'leaf');
}
}
// ---------- Slots ----------
_drawSlots() {
const ctx = this.ctx;
const zoneHint = { road: '🅿️', lake: '🏖️', hang: '🚡', berg: '⛰️' };
// Bauplatz-Plattform (PNG mit Alpha) unter JEDEM Slot Gebäude stehen
// sichtbar auf ihrem Platz
const plate = this.sprites.slot;
if (plate) {
for (const slot of this.state.slots) {
if (slot.blocked && !slot.building) continue; // Schutzgebiet: keine Plattform
const w = 74;
const h = w * (plate.naturalHeight / plate.naturalWidth);
ctx.drawImage(plate, slot.x - w / 2, slot.y - h / 2, w, h);
}
}
for (const slot of this.state.slots) {
if (slot.building) continue;
// gesperrter Platz (Naturschutz): kleine Wildpflanzen statt Bauplatz
if (slot.blocked) {
ctx.font = '15px sans-serif';
ctx.textAlign = 'center';
ctx.globalAlpha = 0.9;
ctx.fillText('🌿', slot.x - 8, slot.y + 4);
ctx.fillText('🌸', slot.x + 9, slot.y + 8);
ctx.globalAlpha = 1;
continue;
}
const building = this.buildType && this.buildType !== 'demolish';
let ring = 'rgba(253,251,244,0.65)', fill = 'rgba(47,62,70,0.10)';
if (building) {
const ok = canPlace(this.state, this.buildType, slot.idx).ok;
ring = ok ? 'rgba(79,160,110,0.95)' : 'rgba(150,150,150,0.5)';
fill = ok ? 'rgba(123,180,110,0.28)' : 'rgba(120,120,120,0.12)';
}
const pulse = building ? 1 + Math.sin(this.simTime / 240) * 0.06 : 1;
ctx.beginPath();
ctx.ellipse(slot.x, slot.y + 5, 30 * pulse, 18 * pulse, 0, 0, 7);
ctx.fillStyle = fill;
ctx.fill();
ctx.setLineDash([7, 6]);
ctx.lineDashOffset = -((this.simTime / 120) % 13);
ctx.strokeStyle = ring;
ctx.lineWidth = 2.2;
ctx.stroke();
ctx.setLineDash([]);
ctx.lineWidth = 1;
if (zoneHint[slot.zone]) {
ctx.font = '17px sans-serif';
ctx.textAlign = 'center';
ctx.globalAlpha = 0.85;
ctx.fillText(zoneHint[slot.zone], slot.x, slot.y + 12);
ctx.globalAlpha = 1;
}
}
}
// ---------- Seilbahn ----------
_drawLift() {
const lift = this.state.buildings.find(b => b.type === 'skilift');
const m = this.state.map;
if (!lift || !m.lift) return;
const ctx = this.ctx;
const a = { x: lift.x, y: lift.y - 28 };
const b = { x: m.lift.topX, y: m.lift.topY - 16 };
// Stützen
ctx.strokeStyle = '#5a5f63';
ctx.lineWidth = 4;
for (let f = 0.25; f < 1; f += 0.25) {
const px = a.x + (b.x - a.x) * f;
const py = a.y + (b.y - a.y) * f;
ctx.beginPath();
ctx.moveTo(px, py + 26); ctx.lineTo(px, py);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(px - 8, py); ctx.lineTo(px + 8, py);
ctx.stroke();
}
// Seil + Bergstation
ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
ctx.fillStyle = '#5a5f63';
ctx.fillRect(b.x - 16, b.y - 6, 32, 18);
ctx.lineWidth = 1;
// leere Gondeln pendeln
for (let i = 0; i < 4; i++) {
let f = ((this.simTime / 26000) + i / 4) % 1;
if (f > 0.5) f = 1 - f;
f *= 2;
this._gondel(a.x + (b.x - a.x) * f, a.y + (b.y - a.y) * f);
}
}
_gondel(gx, gy, riderColor = null) {
const ctx = this.ctx;
ctx.strokeStyle = '#5a5f63';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(gx, gy); ctx.lineTo(gx, gy + 9); ctx.stroke();
ctx.lineWidth = 1;
ctx.fillStyle = riderColor ? '#e8892b' : '#d9cfb4';
ctx.strokeStyle = '#5a5f63';
ctx.beginPath();
ctx.roundRect(gx - 8, gy + 9, 16, 13, 3);
ctx.fill();
ctx.stroke();
if (riderColor) {
ctx.fillStyle = '#f0d4b8';
ctx.beginPath(); ctx.arc(gx, gy + 14, 3, 0, 7); ctx.fill();
}
}
// ---------- Bedien-Bögen (nur solange die Bedienung wirklich wirkt) ----------
_drawServiceArcs() {
const ctx = this.ctx;
const byId = new Map(this.state.visitors.map(v => [v.id, v]));
for (const b of this.state.buildings) {
if (!b.arcs?.length) continue;
for (const vid of b.arcs) {
const v = byId.get(vid);
if (!v || v.mode === 'lift') continue;
const mx = (b.x + v.x) / 2;
const my = Math.min(b.y, v.y) - 40 - Math.hypot(v.x - b.x, v.y - b.y) * 0.08;
ctx.setLineDash([]);
ctx.strokeStyle = 'rgba(47,62,70,0.28)';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(b.x, b.y - 40);
ctx.quadraticCurveTo(mx, my, v.x, v.y - 24);
ctx.stroke();
ctx.setLineDash([7, 9]);
ctx.lineDashOffset = -((this.simTime / 70) % 16);
ctx.strokeStyle = 'rgba(232,137,43,0.8)';
ctx.lineWidth = 2.2;
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(232,137,43,0.85)';
ctx.beginPath();
ctx.arc(v.x, v.y - 24, 2.6, 0, 7);
ctx.fill();
}
}
ctx.lineWidth = 1;
}
// ---------- Gebäude ----------
_drawBuilding(b) {
const ctx = this.ctx;
{
const def = BUILDINGS[b.type];
const isNew = b.construction > 0;
const busy = isNew || b.upgrading;
const deluxe = b.level >= 4 && this.sprites[`${b.type}_2`];
const img = deluxe || this.sprites[b.type];
const w = (BUILDING_W[b.type] || 52) * (1 + (b.level - 1) * 0.05);
this._shadow(b.x, b.y + 6, w * 0.46, w * 0.17);
const site = this.sprites.baustelle;
const drawSprite = (sp, ww) => {
const h = ww * (sp.naturalHeight / sp.naturalWidth);
ctx.drawImage(sp, b.x - ww / 2, b.y + 14 - h, ww, h);
};
if (isNew && site) {
// Neubau: nur Baustelle (Gerüst), noch kein fertiges Gebäude
drawSprite(site, w * 1.05);
} else if (img) {
// fertiges Gebäude (beim Ausbau zusätzlich Gerüst darüber)
drawSprite(img, w);
if (b.upgrading && site) { ctx.globalAlpha = 0.92; drawSprite(site, w * 1.08); ctx.globalAlpha = 1; }
} else {
ctx.font = `${Math.round(w * 0.5)}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillText(isNew ? '🏗️' : def.emoji, b.x, b.y);
}
// Fortschrittsbalken über der Baustelle
if (busy) {
const total = isNew ? (5 + def.cost[0] / 50) : (4 + def.cost[b.level] / 60);
const left = isNew ? b.construction : b.upgrading.left;
const p = Math.max(0, Math.min(1, 1 - left / total));
const by = b.y + 14 - w * (site ? 1.02 : 0.9) - 8;
ctx.fillStyle = 'rgba(47,62,70,0.45)';
ctx.fillRect(b.x - 18, by, 36, 6);
ctx.fillStyle = '#e8892b';
ctx.fillRect(b.x - 18, by, 36 * p, 6);
ctx.strokeStyle = 'rgba(253,251,244,0.8)';
ctx.lineWidth = 1;
ctx.strokeRect(b.x - 18, by, 36, 6);
ctx.font = '9px sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = '#2f3e46';
ctx.fillText(isNew ? 'Bau' : 'Ausbau', b.x, by - 3);
}
// Ausbaustufen-Pins
if (b.level > 1 && !busy) {
for (let i = 0; i < b.level; i++) {
ctx.fillStyle = '#e8892b';
ctx.beginPath();
ctx.arc(b.x - 14 + i * 7.5, b.y + 20, 2.8, 0, 7);
ctx.fill();
ctx.strokeStyle = 'rgba(253,251,244,0.9)';
ctx.stroke();
}
}
this._utilArc(b);
}
}
_utilArc(b) {
if (!b.util || b.util <= 0) return;
const ctx = this.ctx;
const w = BUILDING_W[b.type] || 100;
const cy = b.y - w * 0.9;
const start = Math.PI * 0.75, span = Math.PI * 1.5;
const full = b.util >= 1;
ctx.strokeStyle = 'rgba(47,62,70,0.25)';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(b.x, cy, 13, start, start + span);
ctx.stroke();
ctx.strokeStyle = full
? `rgba(192,57,43,${0.75 + 0.25 * Math.sin(this.simTime / 180)})`
: b.util >= 0.7 ? '#e8892b' : '#3f8f5e';
ctx.beginPath();
ctx.arc(b.x, cy, 13, start, start + span * Math.min(1, b.util));
ctx.stroke();
ctx.lineWidth = 1;
if (full) {
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = '#c0392b';
ctx.fillText('voll', b.x, cy + 4);
}
}
_shadow(x, y, rx, ry) {
this.ctx.fillStyle = 'rgba(47,62,70,0.16)';
this.ctx.beginPath();
this.ctx.ellipse(x, y, rx, ry, 0, 0, 7);
this.ctx.fill();
}
// ---------- Skelett-Walker (prozedural animierte Figuren) ----------
// Eine „richtig" animierte Figur: Hüfte/Knie/Fuß pro Bein, gegenläufig
// schwingende Arme mit Ellbogen, geneigter Oberkörper, nickender Kopf.
// phase ist an die zurückgelegte Strecke gekoppelt → Schritte = Tempo.
_walker(x, y, o) {
const ctx = this.ctx;
const H = o.h;
const legL = H * 0.46, thigh = legL * 0.52, shin = legL * 0.55;
const torsoL = H * 0.36;
const headR = H * (o.kid ? 0.15 : 0.12);
const t = o.phase;
const bob = Math.abs(Math.cos(t)) * H * 0.04;
const lean = 0.10 + (o.lean || 0);
const skin = '#f0d4b8';
const pants = o.pants || this._shade(o.color, -55);
ctx.save();
ctx.translate(x, y);
if (o.flip) ctx.scale(-1, 1);
const hip = { x: 0, y: -legL - bob };
const sh = { x: hip.x + Math.sin(lean) * torsoL, y: hip.y - Math.cos(lean) * torsoL };
const limb = (ox, oy, a1, l1, a2, l2, width, color) => {
// zweigliedrige Extremität: Winkel absolut, 0 = senkrecht nach unten
const mx = ox + Math.sin(a1) * l1, my = oy + Math.cos(a1) * l1;
const ex = mx + Math.sin(a2) * l2, ey = my + Math.cos(a2) * l2;
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(ox, oy); ctx.lineTo(mx, my); ctx.lineTo(ex, ey);
ctx.stroke();
return { x: ex, y: ey };
};
const leg = (ph, color) => {
const thighA = 0.58 * Math.sin(ph);
const knee = Math.max(0, Math.sin(ph + 0.8)) * 0.95; // beugt beim Vorschwingen
const foot = limb(hip.x, hip.y, thighA, thigh, thighA - knee, shin, H * 0.10, color);
// Schuh
ctx.fillStyle = this._shade(color, -25);
ctx.beginPath();
ctx.ellipse(foot.x + H * 0.035, foot.y - H * 0.015, H * 0.055, H * 0.03, 0, 0, 7);
ctx.fill();
};
const arm = (ph, color) => {
const armA = -0.5 * Math.sin(ph);
const elbow = 0.35 + 0.3 * Math.max(0, Math.sin(ph));
return limb(sh.x, sh.y + H * 0.03, armA, H * 0.16, armA + elbow, H * 0.15, H * 0.075, color);
};
// hinteres Bein + hinterer Arm (dunkler)
leg(t + Math.PI, this._shade(pants, -22));
arm(t + Math.PI, this._shade(o.color, -28));
// Rucksack (hinter dem Rücken)
if (o.pack) {
ctx.fillStyle = this._shade(o.color, -40);
ctx.beginPath();
ctx.roundRect(hip.x - H * 0.24, sh.y + H * 0.02, H * 0.15, H * 0.24, H * 0.05);
ctx.fill();
}
// Oberkörper (Kapsel) + Kopf
ctx.strokeStyle = o.color;
ctx.lineWidth = H * 0.17;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(hip.x, hip.y);
ctx.lineTo(sh.x, sh.y);
ctx.stroke();
const nod = Math.sin(t * 2) * H * 0.012;
const head = { x: sh.x + Math.sin(lean) * headR * 1.6, y: sh.y - headR * 1.15 + nod };
ctx.fillStyle = skin;
ctx.beginPath(); ctx.arc(head.x, head.y, headR, 0, 7); ctx.fill();
// Haar/Mütze
ctx.fillStyle = o.prop === 'hat' ? '#e8d9b0' : this._shade(o.color, 26);
ctx.beginPath(); ctx.arc(head.x, head.y - headR * 0.25, headR * (o.prop === 'hat' ? 1.25 : 0.95), Math.PI, 0); ctx.fill();
// vorderes Bein + vorderer Arm
leg(t, pants);
const hand = arm(t, o.color);
// Requisiten an der vorderen Hand
ctx.lineCap = 'round';
if (o.prop === 'cane' || o.prop === 'pole') {
ctx.strokeStyle = '#6b4f2f';
ctx.lineWidth = H * 0.04;
ctx.beginPath();
ctx.moveTo(hand.x, hand.y);
ctx.lineTo(hand.x + H * 0.10, o.prop === 'pole' ? H * 0.0 : 0);
ctx.stroke();
} else if (o.prop === 'umbrella') {
ctx.strokeStyle = '#5a5f63';
ctx.lineWidth = H * 0.04;
ctx.beginPath();
ctx.moveTo(hand.x, hand.y);
ctx.lineTo(hand.x + H * 0.05, sh.y - H * 0.42);
ctx.stroke();
ctx.fillStyle = '#e8892b';
ctx.beginPath();
ctx.moveTo(hand.x - H * 0.16, sh.y - H * 0.40);
ctx.quadraticCurveTo(hand.x + H * 0.05, sh.y - H * 0.62, hand.x + H * 0.26, sh.y - H * 0.40);
ctx.closePath();
ctx.fill();
} else if (o.prop === 'bag') {
ctx.strokeStyle = this._shade(o.color, -35);
ctx.lineWidth = H * 0.03;
ctx.beginPath(); ctx.moveTo(hand.x, hand.y); ctx.lineTo(hand.x, hand.y + H * 0.09); ctx.stroke();
ctx.fillStyle = '#8c2f4f';
ctx.beginPath();
ctx.roundRect(hand.x - H * 0.07, hand.y + H * 0.09, H * 0.14, H * 0.11, H * 0.03);
ctx.fill();
}
ctx.lineWidth = 1;
ctx.restore();
}
// ---------- Besucher ----------
_drawVisitor(v, winter) {
const ctx = this.ctx;
{
const g = GROUPS[v.type];
if (v.mode === 'lift') {
this._gondel(v.x, v.y - 34, g.color);
this._satBar(v.x, v.y - 54, v, g);
return;
}
// Busfahrt: hübscher Reisebus statt Figuren; beim Halt steigen sie aus
if (v.travel === 'bus' || v.travel === 'busstop') {
const bus = this.sprites.bus;
const flip2 = v.headRight === false;
this._shadow(v.x, v.y + 4, 34, 9);
if (bus) {
const bw = 78, bh = bw * (bus.naturalHeight / bus.naturalWidth);
const rumble = v.travel === 'bus' ? Math.sin(this.simTime / 60) * 0.8 : 0;
ctx.save();
ctx.translate(v.x, v.y + rumble);
if (flip2) ctx.scale(-1, 1);
ctx.drawImage(bus, -bw / 2, -bh + 4, bw, bh);
ctx.restore();
} else {
ctx.fillStyle = '#e8892b';
ctx.fillRect(v.x - 30, v.y - 22, 60, 22);
}
if (v.travel === 'busstop') {
// Aussteigende neben der Tür
const doorX = v.x + (flip2 ? -30 : 30);
const out = Math.min(3, Math.max(1, Math.round((2.6 - v.busWait) / 0.8)));
for (let k = 0; k < out; k++) {
this._walker(doorX + k * 9, v.y + 4 + (k % 2) * 3, {
h: 26, phase: this.simTime / 140 + k,
color: TOP_COLORS[(v.id * 7 + k) % TOP_COLORS.length],
pants: PANTS_COLORS[(v.id + k) % PANTS_COLORS.length],
flip: flip2,
});
}
}
this._satBar(v.x, v.y - 40, v, g);
return;
}
const flip = v.headRight === false;
const bob = Math.sin(this.simTime / 150 + v.wobble) * 1.2;
if (v.type === 'radfahrer') {
const img = this.sprites.g_radler;
this._shadow(v.x, v.y + 3, 20, 6);
if (img) {
for (const off of [-10, 12]) {
const roll = Math.sin(v.s / 5 + off) * 0.05;
ctx.save();
ctx.translate(v.x + off, v.y + Math.sin(v.s / 4 + off) * 1.2);
ctx.rotate(flip ? -roll : roll);
if (flip) ctx.scale(-1, 1);
ctx.drawImage(img, -16, -30, 32, 32);
ctx.restore();
}
} else {
this._dotGroup(v, g, 2);
}
} else {
// Gruppe aus animierten Skelett-Figuren (Schritte = Strecke)
const comp = WALK_GROUPS[v.type] || [{ dx: 0, dy: 0 }];
this._shadow(v.x, v.y + 3, 22, 7);
const dir = flip ? -1 : 1;
const sorted2 = [...comp].sort((a, b) => (a.dy || 0) - (b.dy || 0));
const paused = v.stopped;
for (let i = 0; i < sorted2.length; i++) {
const mMember = sorted2[i];
const H = mMember.kid ? 19 : 30;
const seed = v.id * 7 + i * 3;
const ph = paused
? Math.sin(this.simTime / 500 + i) * 0.25 // verweilen: leicht umsehen
: v.s / (mMember.kid ? 2.6 : 3.6) + i * 1.15 + v.wobble;
this._walker(v.x + (mMember.dx || 0) * dir, v.y + (mMember.dy || 0), {
h: H,
phase: ph,
color: TOP_COLORS[seed % TOP_COLORS.length],
pants: PANTS_COLORS[(seed + 2) % PANTS_COLORS.length],
flip,
kid: mMember.kid,
prop: mMember.prop,
pack: mMember.pack,
lean: mMember.lean || 0,
});
}
if (winter) {
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.beginPath();
ctx.ellipse(v.x, v.y - 18, 20, 5, 0, 0, 7);
ctx.fill();
}
if (paused) {
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.globalAlpha = 0.85;
ctx.fillText('⏸️', v.x + 16, v.y - 30);
ctx.globalAlpha = 1;
}
}
this._satBar(v.x, v.y - 46, v, g);
}
}
// Fallback ohne Sprite: kleine Figuren-Punkte in Gruppenfarbe
_dotGroup(v, g, n) {
const ctx = this.ctx;
for (let i = 0; i < n; i++) {
const ox = v.x + ((i % 3) - 1) * 9;
const oy = v.y + Math.floor(i / 3) * 7 - 3;
ctx.fillStyle = i === 0 ? g.color : this._shade(g.color, i * 12);
ctx.beginPath();
ctx.ellipse(ox, oy - 8, 4, 7, 0, 0, 7);
ctx.fill();
ctx.fillStyle = '#f0d4b8';
ctx.beginPath(); ctx.arc(ox, oy - 17, 3.4, 0, 7); ctx.fill();
}
}
_satBar(x, y, v, g) {
const ctx = this.ctx;
const ratio = Math.max(0, Math.min(1, v.satisfaction / g.satTarget));
ctx.fillStyle = 'rgba(47,62,70,0.4)';
ctx.fillRect(x - 13, y, 26, 4);
ctx.fillStyle = ratio >= 0.6 ? '#3f8f5e' : ratio >= 0.3 ? '#e8892b' : '#c0392b';
ctx.fillRect(x - 13, y, 26 * ratio, 4);
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(g.emoji, x, y - 4);
}
// ---------- schwebende Objekte ----------
_drawFx(simDt) {
const ctx = this.ctx;
while (this.state.fx.length) {
const fx = this.state.fx.shift();
if (fx.pop) {
// Abreise: Sterne-Pop am Ausgang (grün = glücklich, rot = vergrault)
const good = fx.stars >= 4;
this.addFloat('★'.repeat(fx.stars), good ? '#3f8f5e' : fx.stars >= 3 ? '#e8892b' : '#c0392b', fx.fx, fx.fy - 10);
if (this.simTime - (this._lastPop || 0) > 350) {
this._lastPop = this.simTime;
audio.pling(good ? 1.4 : 0.75);
}
continue;
}
this.fxAnims.push({ ...fx, t: 0 });
if (this.fxAnims.length > 90) this.fxAnims.shift();
// Dienstleistungs-Geräusch (Apfelbiss, Kaffee, Werkzeug …), gedrosselt
if (this.simTime - (this._lastPling || 0) > 300) {
this._lastPling = this.simTime;
audio.fx(fx.sound || 'pling');
}
}
const byId = new Map(this.state.visitors.map(v => [v.id, v]));
const DUR = 1300;
this.fxAnims = this.fxAnims.filter(a => a.t < DUR);
for (const a of this.fxAnims) {
a.t += simDt;
// Ankunft: Geldbetrag + Zufriedenheits-Herz beim Gast aufpoppen
if (!a.done && a.t >= DUR) {
a.done = true;
const tx2 = a.tx ?? a.fx, ty2 = a.ty ?? a.fy - 60;
if (a.amount > 0) this.addFloat(`+${a.amount}`, '#3f8f5e', tx2 + 14, ty2 + 24);
this.addFloat('💛', '#e8892b', tx2 - 12, ty2 + 18);
}
const p = Math.min(1, a.t / DUR);
const ease = p * p * (3 - 2 * p);
const v = byId.get(a.vid);
if (v) { a.tx = v.x; a.ty = v.y - 30; }
const tx = a.tx ?? a.fx, ty = a.ty ?? a.fy - 60;
const x = a.fx + (tx - a.fx) * ease;
const y = (a.fy - 50) + (ty - (a.fy - 50)) * ease - Math.sin(p * Math.PI) * 26;
// Objekt bleibt voll sichtbar, nur die letzten ~12 % blenden kurz aus
ctx.globalAlpha = p > 0.88 ? (1 - p) / 0.12 : 1;
const sz = 20 + Math.sin(p * Math.PI) * 6; // kleiner Zoom-Effekt beim Flug
ctx.font = `${sz}px sans-serif`;
ctx.textAlign = 'center';
// weiße Kontur für Kontrast auf jedem Untergrund
ctx.lineWidth = 3;
ctx.strokeStyle = 'rgba(253,251,244,0.85)';
ctx.strokeText(a.emoji, x, y);
ctx.fillStyle = '#2f3e46';
ctx.fillText(a.emoji, x, y);
ctx.globalAlpha = 1;
ctx.lineWidth = 1;
}
}
_drawFloats() {
const ctx = this.ctx;
const DUR = 1600;
this.floats = this.floats.filter(f => this.simTime - f.t0 < DUR);
for (const f of this.floats) {
const age = (this.simTime - f.t0) / DUR;
ctx.globalAlpha = 1 - age;
ctx.font = 'bold 18px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.lineWidth = 4;
ctx.strokeStyle = 'rgba(253,251,244,0.9)';
ctx.strokeText(f.text, f.x, f.y - 60 - age * 34);
ctx.fillStyle = f.color;
ctx.fillText(f.text, f.x, f.y - 60 - age * 34);
ctx.globalAlpha = 1;
ctx.lineWidth = 1;
}
}
// ---------- Reichweiten-Vorschau ----------
_drawRangePreview() {
const ctx = this.ctx;
const drawRange = (x, y, rTiles, stroke, fill) => {
ctx.beginPath();
ctx.ellipse(x, y, rTiles * TILE2PX, rTiles * TILE2PX * 0.72, 0, 0, 7);
ctx.fillStyle = fill;
ctx.fill();
ctx.setLineDash([8, 7]);
ctx.strokeStyle = stroke;
ctx.lineWidth = 2;
ctx.stroke();
ctx.setLineDash([]);
ctx.lineWidth = 1;
};
if (this.selected && this.state.buildings.includes(this.selected)) {
const b = this.selected;
drawRange(b.x, b.y, BUILDINGS[b.type].range[b.level - 1],
'rgba(47,110,160,0.6)', 'rgba(47,110,160,0.10)');
}
if (this.buildType && this.buildType !== 'demolish' && this.hover) {
const def = BUILDINGS[this.buildType];
// Vorschau am nächsten freien Slot unter dem Zeiger
let best = null, bestD = 70;
for (const slot of this.state.slots) {
if (slot.building) continue;
const d = Math.hypot(slot.x - this.hover.x, slot.y - this.hover.y);
if (d < bestD) { bestD = d; best = slot; }
}
if (best) {
const ok = canPlace(this.state, this.buildType, best.idx).ok;
drawRange(best.x, best.y, def.range[0],
ok ? 'rgba(79,127,94,0.8)' : 'rgba(200,60,50,0.6)',
ok ? 'rgba(123,160,91,0.14)' : 'rgba(200,60,50,0.08)');
}
}
}
// ---------- Partikel (Screen-Space) ----------
_particles(dt, kind) {
const ctx = this.ctx;
const arr = kind === 'snow' ? this.flakes : this.leaves;
const max = kind === 'snow' ? 70 : 24;
while (arr.length < max) {
arr.push({
x: Math.random() * innerWidth, y: Math.random() * innerHeight,
r: 1 + Math.random() * 2.2, v: 18 + Math.random() * 40, ph: Math.random() * 7,
});
}
for (const f of arr) {
f.y += f.v * dt / 1000;
f.x += Math.sin(this.time / 900 + f.ph) * 0.4;
if (f.y > innerHeight) { f.y = -4; f.x = Math.random() * innerWidth; }
if (kind === 'snow') {
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, 7); ctx.fill();
} else {
ctx.fillStyle = f.ph % 2 > 1 ? 'rgba(208,138,62,0.7)' : 'rgba(180,150,60,0.6)';
ctx.beginPath(); ctx.ellipse(f.x, f.y, f.r + 1, f.r * 0.6, f.ph, 0, 7); ctx.fill();
}
}
}
// ---------- Debug: Routen + Slot-Indizes (?debug) ----------
_drawDebug() {
const ctx = this.ctx;
for (const [id, r] of Object.entries(this.state.map.routes)) {
ctx.strokeStyle = 'rgba(200,30,30,0.85)';
ctx.lineWidth = 3;
ctx.beginPath();
r.points.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
ctx.stroke();
ctx.fillStyle = '#c01e1e';
for (const [x, y] of r.points) {
ctx.beginPath(); ctx.arc(x, y, 5, 0, 7); ctx.fill();
}
ctx.font = 'bold 20px sans-serif';
ctx.fillText(id, r.points[0][0], r.points[0][1] - 14);
}
ctx.font = 'bold 16px sans-serif';
for (const slot of this.state.slots) {
ctx.fillStyle = '#1e50c0';
ctx.fillText(`${slot.idx}·${slot.zone[0]}`, slot.x, slot.y - 28);
}
// Editor-Punkte (getappt im Debug-Modus)
const tr = window.__ttTrace || [];
if (tr.length) {
ctx.strokeStyle = '#0a9648';
ctx.lineWidth = 3;
ctx.beginPath();
tr.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
ctx.stroke();
ctx.fillStyle = '#0a9648';
for (const [x, y] of tr) {
ctx.beginPath(); ctx.arc(x, y, 6, 0, 7); ctx.fill();
}
}
ctx.lineWidth = 1;
}
_shade(hex, amt) {
if (hex.startsWith('rgb')) return hex;
const n = parseInt(hex.slice(1), 16);
const r = Math.max(0, Math.min(255, (n >> 16) + amt));
const g = Math.max(0, Math.min(255, ((n >> 8) & 255) + amt));
const b = Math.max(0, Math.min(255, (n & 255) + amt));
return `rgb(${r},${g},${b})`;
}
}
+35
View File
@@ -0,0 +1,35 @@
// Telemetrie-Stub für das Lehrer-Dashboard. Läuft jetzt lokal (localStorage);
// bei der Integration ins GeoGraSim-Backend wird flush() auf einen POST
// umgestellt und erhält Session-/Schüler-Kontext vom PHP-System.
const KEY = 'tourismusregion_telemetry';
export const telemetry = {
events: [],
_timeProvider: null,
init(timeProvider) {
this._timeProvider = timeProvider;
this.events = [];
},
log(type, data = {}) {
this.events.push({
ts: Date.now(),
gameTime: this._timeProvider ? this._timeProvider() : null,
type,
data,
});
this._persist();
},
_persist() {
try {
localStorage.setItem(KEY, JSON.stringify(this.events.slice(-500)));
} catch (e) { /* voller Speicher o. Privatmodus Telemetrie ist optional */ }
},
flush() {
return this.events;
},
};
+715
View File
@@ -0,0 +1,715 @@
// DOM-UI: HUD-Kennzahlen, Baumenü, Info-Panels (Gebäude/Besucher),
// Ereignis-Karten mit Auto-Pause (inkl. Entscheidungen und Social-Media-
// Feed), Meldungs-Log, Zeit-Graph, Ziele, Toasts, Onboarding, Endauswertung.
import { BUILDINGS, GROUPS, GOALS, currentAvg, GAME_LEN } from './data.js';
import { telemetry } from './telemetry.js';
import { audio } from './audio.js';
import { music, TRACKS } from './music.js';
const $ = id => document.getElementById(id);
const euro = n => Math.round(n).toLocaleString('de-AT');
export class UI {
constructor(state, api) {
this.state = state;
this.api = api; // {setSpeed, getSpeed, setBuildType, selectBuilding, upgradeSelected, demolishSelected, restart, save}
this.cardQueue = [];
this.cardOpen = false;
this.speedBeforeCard = 1;
this.collectedCards = [];
this.activeBuild = null;
this.selectedBuilding = null;
this.selectedVisitor = null;
this.toastTimer = null;
this.gMoney = $('graphMoneyCanvas').getContext('2d');
this.gSat = $('graphSatCanvas').getContext('2d');
this._buildGoals();
this.rebuildMenu();
this._bindTopbar();
this._bindOverlays();
this.updateKPIs();
}
// ---------- Baumenü ----------
rebuildMenu() {
const wrap = $('buildItems');
wrap.innerHTML = '';
for (const [type, def] of Object.entries(BUILDINGS)) {
if (!this.state.unlocked.has(type)) continue;
const el = document.createElement('div');
el.className = 'build-item';
el.dataset.type = type;
el.innerHTML = `
<span class="emoji">${def.emoji}</span>
<span class="info"><b>${def.name}</b><small>${def.desc}</small></span>
<span class="cost">${euro(def.cost[0])} €</span>`;
el.addEventListener('click', () => this._toggleBuild(type, el));
wrap.appendChild(el);
}
const locked = Object.values(BUILDINGS).filter(d => d.unlockPhase > this.state.phase).length;
if (locked > 0) {
const hint = document.createElement('div');
hint.className = 'locked-hint';
hint.textContent = `🔒 ${locked} weitere werden im Spielverlauf freigeschaltet.`;
wrap.appendChild(hint);
}
if (this.activeBuild) {
const el = wrap.querySelector(`[data-type="${this.activeBuild}"]`);
if (el) el.classList.add('active');
else { this.activeBuild = null; this.api.setBuildType(null); }
}
this.refreshBuildMenu();
}
_toggleBuild(type, el) {
audio.click();
document.querySelectorAll('.build-item').forEach(i => i.classList.remove('active'));
if (this.activeBuild === type) {
this.activeBuild = null;
} else {
this.activeBuild = type;
el.classList.add('active');
const def = BUILDINGS[type];
this.toast(`${def.emoji} ${def.name} gewählt tippe auf die Karte. Der Kreis zeigt die Reichweite.`);
this.hideInfoPanel();
}
this.api.setBuildType(this.activeBuild);
telemetry.log('build_select', { type: this.activeBuild });
}
clearBuildSelection() {
this.activeBuild = null;
this.api.setBuildType(null);
document.querySelectorAll('.build-item').forEach(i => i.classList.remove('active'));
}
refreshBuildMenu() {
document.querySelectorAll('.build-item').forEach(el => {
const def = BUILDINGS[el.dataset.type];
if (def) el.classList.toggle('disabled', this.state.budget < def.cost[0]);
});
}
// ---------- Topbar ----------
_bindTopbar() {
document.querySelectorAll('.speed-btn').forEach(btn => {
btn.addEventListener('click', () => {
audio.click();
const sp = Number(btn.dataset.speed);
this.api.setSpeed(sp);
this.markSpeed(sp);
telemetry.log('speed', { speed: sp });
});
});
$('btnHome').addEventListener('click', () => {
audio.click();
telemetry.log('home_click');
// Der Plattform-Wrapper biegt dieses Ziel auf das Cockpit um
window.location.href='../../schueler.html';
});
$('btnSound').addEventListener('click', e => {
// Master-Mute: Effekte, Musik-Player UND Saison-Ambience
const on = audio.toggleSound();
music.setMuted(!on);
if (!on) audio._stopAmbience?.();
else if (audio.musicOn && !music.playing) audio.startMusic();
e.currentTarget.classList.toggle('muted', !on);
telemetry.log('audio_toggle', { kind: 'sound', off: !on });
});
$('btnMusic').addEventListener('click', () => {
audio.click();
$('musicPanel').classList.toggle('hidden');
telemetry.log('music_panel', { open: !$('musicPanel').classList.contains('hidden') });
});
this._bindMusic();
$('btnAnim').addEventListener('click', e => {
const on = this.api.toggleReduceAnim();
e.currentTarget.classList.toggle('active', on);
this.toast(on ? '🐢 Reduzierte Animationen aktiviert.' : '🐢 Animationen wieder aktiviert.');
telemetry.log('reduce_anim', { on });
});
$('btnCamera').addEventListener('click', () => { audio.click(); this.api.resetCamera(); });
$('btnNextWave').addEventListener('click', () => this.api.nextWave());
$('btnHelp').addEventListener('click', () => { audio.click(); this.showOnboarding(); });
$('btnLog').addEventListener('click', () => { audio.click(); this._openLog(); });
}
// ---------- Musik-Player ----------
_bindMusic() {
const sel = $('muSelect');
sel.innerHTML = TRACKS.map((t, i) => `<option value="${i}">🎵 ${t.title}</option>`).join('');
music.onChange = () => {
$('muPlay').textContent = music.playing ? '⏸' : '▶';
sel.value = String(music.idx);
};
$('muPlay').addEventListener('click', () => { music.toggle(); telemetry.log('music', { action: 'toggle', track: music.current().title }); });
$('muNext').addEventListener('click', () => { music.next(); telemetry.log('music', { action: 'next', track: music.current().title }); });
$('muPrev').addEventListener('click', () => { music.prev(); telemetry.log('music', { action: 'prev', track: music.current().title }); });
$('muVol').addEventListener('input', e => music.setVolume(Number(e.target.value)));
sel.addEventListener('change', () => { music.play(Number(sel.value)); telemetry.log('music', { action: 'select', track: music.current().title }); });
}
markSpeed(sp) {
document.querySelectorAll('.speed-btn').forEach(b =>
b.classList.toggle('active', Number(b.dataset.speed) === sp));
}
// ---------- Ziele ----------
_buildGoals() {
const list = $('goalList');
list.innerHTML = '';
for (const g of GOALS) {
const li = document.createElement('li');
li.id = `goal-${g.id}`;
li.textContent = g.label;
list.appendChild(li);
}
}
// ---------- Kennzahlen + Ruf-Leiste + Wellen-Status ----------
updateKPIs() {
const s = this.state;
$('clock').textContent = `Welle ${s.phase}/8`;
$('seasonBadge').textContent = `${{ spring: '🌸', summer: '☀️', autumn: '🍂', winter: '❄️' }[s.seasonKey]} ${s.season}`;
$('kMoney').textContent = `${euro(s.budget)}`;
$('kMoney').classList.toggle('warn', s.budget < 100);
$('kEnv').textContent = Math.round(s.env);
$('kEnv').classList.toggle('warn', s.env < 45);
$('kVisitors').textContent = s.visitors.length;
$('kTraffic').textContent = s.traffic;
$('kTraffic').classList.toggle('warn', s.traffic > 30);
$('kRevenue').textContent = `${euro(s.revenueTotal)}`;
// Ruf: Ø der letzten Abreisen (TD-„Basis-HP"); pulst bei schlechter Abreise
const rep = s.recentStars.length
? s.recentStars.reduce((a, b) => a + b, 0) / s.recentStars.length : null;
const pct = rep === null ? 60 : Math.round((rep / 5) * 100);
$('repVal').textContent = rep === null ? '' : `${rep.toFixed(1)}`;
const fill = $('repFill');
fill.style.width = `${pct}%`;
fill.classList.toggle('low', rep !== null && rep < 2.5);
fill.classList.toggle('mid', rep !== null && rep >= 2.5 && rep < 3.5);
if (s.lastBadExit && s.lastBadExit !== this._lastBadShown) {
this._lastBadShown = s.lastBadExit;
const row = $('repRow');
row.classList.remove('pulse'); void row.offsetWidth; row.classList.add('pulse');
}
// Wellen-Status-Banner
const ws = $('waveStatus');
if (s.waveStatus === 'pause') {
ws.className = 'wave-status pause';
ws.textContent = `🔨 Baupause Welle ${s.phase} startet in ${Math.ceil(Math.max(0, s.pauseLeft))} s`;
} else {
const left = s.visitors.filter(v => v.wave === s.phase).length + s.spawnQueue.filter(e => e.type).length;
ws.className = 'wave-status running';
ws.textContent = `🌊 Welle ${s.phase} läuft noch ${left} Gäste`;
}
$('btnNextWave').disabled = s.ended || (s.phase >= 8 && s.waveStatus === 'running');
$('btnNextWave').textContent = s.waveStatus === 'pause' ? '⏩ Welle jetzt starten' : '⏩ Nächste Welle rufen';
for (const g of GOALS) {
$(`goal-${g.id}`)?.classList.toggle('done', g.check(s));
}
this.refreshBuildMenu();
this.refreshInfoPanel();
}
// ---------- Statistik-Overlay ----------
showStats() {
const s = this.state;
const rows = (title, items) => {
if (!items.length) return '';
const max = Math.max(...items.map(i => i.val), 1);
return `<div class="stat-group"><h3>${title}</h3>` + items.map(i =>
`<div class="stat-row"><span>${i.label}</span>`
+ `<div class="stat-bar"><div style="width:${Math.round(i.val / max * 100)}%;background:${i.color}"></div></div>`
+ `<b>${i.disp}</b></div>`).join('') + '</div>';
};
// Zufriedenheit je Gruppe
const groups = Object.entries(s.stats.groups)
.filter(([, g]) => g.count > 0)
.map(([id, g]) => ({ label: `${GROUPS[id].emoji} ${GROUPS[id].name}`, val: g.avg,
disp: `${g.avg.toFixed(1)}`, color: g.avg >= 3.5 ? '#3f8f5e' : g.avg >= 2.5 ? '#e8892b' : '#c0392b' }))
.sort((a, b) => b.val - a.val);
// Umsatz je Gebäudekategorie
const cats = {};
for (const [type, rev] of Object.entries(s.stats.buildingRevenue)) {
const c = BUILDINGS[type].cat;
cats[c] = (cats[c] || 0) + rev;
}
const catNames = { gastronomy: '🍽️ Gastronomie', shopping: '🛍️ Handel', nature: '🌿 Natur',
play: '🛝 Freizeit', service: '🛎️ Service', culture: '🏛️ Kultur', sport: '⛷️ Sport' };
const revItems = Object.entries(cats).map(([c, v]) => ({ label: catNames[c] || c, val: v,
disp: `${euro(v)}`, color: '#4f7f5e' })).sort((a, b) => b.val - a.val);
// Anreise-Mix
const tr = s.stats.transports;
const trItems = [['foot', '🚶 zu Fuß'], ['bike', '🚴 Rad'], ['bus', '🚌 Bus'], ['car', '🚗 Auto']]
.map(([k, l]) => ({ label: l, val: tr[k], disp: `${tr[k]}`, color: k === 'car' ? '#c9531f' : '#2f6ea0' }))
.filter(i => i.val > 0);
$('statsBody').innerHTML =
`<div class="stat-group"><h3>Heatmap der Besucherströme</h3>`
+ `<canvas id="heatCanvas" class="heat-canvas"></canvas></div>`
+ rows('Zufriedenheit je Gruppe (Ø Sterne)', groups)
+ rows('Wertschöpfung je Kategorie', revItems)
+ rows('Anreise der Gäste', trItems)
+ `<div class="stat-group"><h3>Bilanz</h3>`
+ `<div class="info-row"><span>Umwelt</span><b>${Math.round(s.env)}</b></div>`
+ `<div class="info-row"><span>Verkehr</span><b>${s.traffic}</b></div>`
+ `<div class="info-row"><span>Busgruppen ohne Parkplatz</span><b>${s.stats.skippedNoParking}</b></div>`
+ `<div class="info-row"><span>Gäste gesamt</span><b>${s.stats.spawned}</b></div></div>`;
this._drawHeatmap();
$('statsOverlay').classList.remove('hidden');
telemetry.log('stats_open', {});
}
// Heatmap: Kartenbild verkleinert + Besucherstrom-Dichte als Farbflecken
_drawHeatmap() {
const s = this.state;
const cv = $('heatCanvas');
if (!cv) return;
const W = 420, H = Math.round(W * s.map.h / s.map.w);
const dpr = window.devicePixelRatio || 1;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = W + 'px'; cv.style.height = H + 'px';
const ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (!this._heatImg) { this._heatImg = new Image(); }
const paint = () => {
ctx.clearRect(0, 0, W, H);
if (this._heatImg.complete && this._heatImg.naturalWidth) {
ctx.globalAlpha = 0.55; ctx.drawImage(this._heatImg, 0, 0, W, H); ctx.globalAlpha = 1;
} else { ctx.fillStyle = '#cfe0cf'; ctx.fillRect(0, 0, W, H); }
const HC = s.heatCols, HR = s.heatRows;
const max = Math.max(...s.heat, 1);
const cw = W / HC, ch = H / HR;
ctx.globalCompositeOperation = 'source-over';
for (let r = 0; r < HR; r++) {
for (let c = 0; c < HC; c++) {
const v = s.heat[r * HC + c] / max;
if (v < 0.04) continue;
const x = (c + 0.5) * cw, y = (r + 0.5) * ch;
const rad = ch * 1.6;
const g = ctx.createRadialGradient(x, y, 0, x, y, rad);
const a = Math.min(0.8, v * 1.1);
// grün → gelb → rot je nach Dichte
const col = v > 0.66 ? '220,60,40' : v > 0.33 ? '232,150,40' : '90,170,90';
g.addColorStop(0, `rgba(${col},${a})`);
g.addColorStop(1, `rgba(${col},0)`);
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(x, y, rad, 0, 7); ctx.fill();
}
}
// Legende
ctx.font = '10px sans-serif'; ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(47,62,70,0.7)';
ctx.fillText('wenig', 6, H - 6);
ctx.textAlign = 'right';
ctx.fillText('viel Verkehr →', W - 6, H - 6);
};
if (this._heatImg.src.endsWith(s.map.image)) paint();
else { this._heatImg.onload = paint; this._heatImg.src = s.map.image; paint(); }
}
// ---------- Info-Panels ----------
showBuildingPanel(b) {
this.selectedBuilding = b;
this.selectedVisitor = null;
this.refreshInfoPanel();
$('infoPanel').classList.remove('hidden');
}
showVisitorPanel(v) {
this.selectedVisitor = v;
this.selectedBuilding = null;
this.refreshInfoPanel();
$('infoPanel').classList.remove('hidden');
}
hideInfoPanel() {
this.selectedBuilding = null;
this.selectedVisitor = null;
this.api.selectBuilding(null);
$('infoPanel').classList.add('hidden');
}
refreshInfoPanel() {
const s = this.state;
if (this.selectedBuilding) {
const b = this.selectedBuilding;
if (!s.buildings.includes(b)) { this.hideInfoPanel(); return; }
const def = BUILDINGS[b.type];
const lvl = b.level - 1;
const name = def.levelNames ? def.levelNames[lvl] : def.name;
const stars = '★'.repeat(b.level) + '☆'.repeat(def.maxLevel - b.level);
const goodFor = Object.entries(def.target)
.filter(([, f]) => f >= 1).map(([gid]) => GROUPS[gid]?.emoji).filter(Boolean).join(' ');
const rev = s.stats.buildingRevenue[b.type];
const busy = b.construction > 0 ? '🏗️ Baustelle …'
: b.upgrading ? '🏗️ Ausbau läuft …' : '';
$('infoTitle').textContent = `${def.emoji} ${name} ${stars}`;
$('infoBody').innerHTML = `
${busy ? `<div class="info-row"><span>Status</span><b class="warn">${busy}</b></div>` : ''}
<div class="info-row"><span>Wirkung</span><b>+${def.sat[lvl]} Zufriedenheit</b></div>
${def.slow[lvl] ? `<div class="info-row"><span>Verweilen</span><b>${Math.round(def.slow[lvl] * 100)} % Tempo</b></div>` : ''}
<div class="info-row"><span>Reichweite</span><b>${def.range[lvl]} Felder</b></div>
<div class="info-row"><span>Kapazität</span><b>${def.capacity[lvl] >= 999 ? 'unbegrenzt' : `${def.capacity[lvl]} Gäste`}</b></div>
${def.capacity[lvl] < 999 ? `<div class="info-row"><span>Auslastung</span><b class="${(b.inRange || 0) > def.capacity[lvl] ? 'warn' : ''}">${b.inRange || 0} / ${def.capacity[lvl]}${(b.inRange || 0) > def.capacity[lvl] ? ' voll!' : ''}</b></div>` : ''}
<div class="info-row"><span>Betriebskosten</span><b>${def.opCost[lvl]} € / min</b></div>
${goodFor ? `<div class="info-row"><span>Passt für</span><b>${goodFor}</b></div>` : ''}
${rev ? `<div class="info-row"><span>Umsatz bisher</span><b>${euro(rev)} €</b></div>` : ''}
${b.infoBoost > 1 ? `<div class="info-row"><span>Info-Bonus</span><b>+${Math.round((b.infoBoost - 1) * 100)} %</b></div>` : ''}
${b.synergy > 1 ? `<div class="info-row"><span>🔗 Synergie</span><b class="syn">+${Math.round((b.synergy - 1) * 100)} %</b></div>`
+ (b.synergyList || []).map(w => `<div class="syn-why">✓ ${w}</div>`).join('') : ''}`;
const up = $('btnUpgrade');
if (b.level < def.maxLevel && !busy) {
up.classList.remove('hidden');
up.textContent = `⬆️ Ausbauen ${euro(def.cost[b.level])}`;
up.disabled = s.budget < def.cost[b.level];
} else {
up.classList.add('hidden');
}
$('btnDemolish').classList.toggle('hidden', b.type === 'naturschutz');
} else if (this.selectedVisitor) {
const v = this.selectedVisitor;
if (v.exited || !s.visitors.includes(v)) { this.hideInfoPanel(); return; }
const g = GROUPS[v.type];
const ratio = Math.max(0, v.satisfaction) / g.satTarget;
const stars = Math.max(1, Math.min(5, Math.ceil(ratio * 5)));
$('infoTitle').textContent = `${g.emoji} ${g.name}`;
$('infoBody').innerHTML = `
<div class="info-row"><span>Zufriedenheit</span><b>${Math.round(Math.max(0, v.satisfaction))} / ${g.satTarget}</b></div>
<div class="info-row"><span>Budget übrig</span><b>${euro(g.budget - v.spent)} €</b></div>
<div class="info-row"><span>Interessen</span><b>${g.likes}</b></div>
<div class="info-row"><span>Bewertung derzeit</span><b>${'⭐'.repeat(stars)}</b></div>`;
$('btnUpgrade').classList.add('hidden');
$('btnDemolish').classList.add('hidden');
}
}
// ---------- Zwei Verlaufs-Graphen in eigenen Feldern ----------
// Feste 020-min-Zeitachse, sichtbare Bodenlinie, alle Daten bleiben.
// Links: Budget (€). Rechts: Zufriedenheits-Erfüllung in % (Ziel 100 %,
// wie „zerstört" im Tower Defense) + Umweltwert auf derselben 0100-Skala.
drawGraph() {
const hist = this.state.history;
if (hist.budget.length < 2) return;
const bMax = Math.max(600, ...hist.budget);
const bMin = Math.min(0, ...hist.budget);
this._plotPanel(this.gMoney, {
title: '💰 Budget',
series: [{ data: hist.budget, color: 'rgb(79,127,94)', fill: true }],
vMin: bMin, vMax: bMax, unit: '€',
current: `${euro(this.state.budget)}`,
goals: [],
t: hist.t,
});
this._plotPanel(this.gSat, {
title: '😊 Zufriedenheit · 🌲 Umwelt',
series: [
{ data: hist.satPct, color: 'rgb(232,137,43)', fill: true },
{ data: hist.env, color: 'rgb(47,110,160)', fill: false },
],
vMin: 0, vMax: 110, unit: '%',
current: `${hist.satPct[hist.satPct.length - 1]} % · ${Math.round(this.state.env)}`,
goals: [
{ v: 100, label: 'Ziel 100 %' },
{ v: 45, label: 'Umwelt-Ziel 45' },
],
t: hist.t,
});
}
_plotPanel(ctx, o) {
const cv = ctx.canvas;
const dpr = window.devicePixelRatio || 1;
const w = cv.clientWidth, h = cv.clientHeight;
if (cv.width !== w * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const padL = 36, padR = 6, padT = 16, padB = 15;
const pw = w - padL - padR, ph = h - padT - padB;
const X = i => padL + (o.t[i] / 1200) * pw;
const Y = v => padT + ph - ((Math.max(o.vMin, Math.min(v, o.vMax)) - o.vMin) / (o.vMax - o.vMin || 1)) * ph;
// Titel + aktueller Wert
ctx.font = 'bold 10.5px sans-serif';
ctx.textAlign = 'left';
ctx.fillStyle = '#2f3e46';
ctx.fillText(o.title, 2, 10);
ctx.textAlign = 'right';
ctx.fillStyle = o.series[0].color;
ctx.fillText(o.current, w - 2, 10);
// Skala links (oben/unten)
ctx.font = '8.5px sans-serif';
ctx.fillStyle = 'rgba(47,62,70,0.6)';
ctx.textAlign = 'right';
ctx.fillText(`${o.vMax}${o.unit}`, padL - 3, padT + 7);
ctx.fillText(`${o.vMin}${o.unit}`, padL - 3, padT + ph);
// Ziellinien
for (const g of o.goals) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(47,62,70,0.35)';
ctx.beginPath(); ctx.moveTo(padL, Y(g.v)); ctx.lineTo(padL + pw, Y(g.v)); ctx.stroke();
ctx.setLineDash([]);
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(47,62,70,0.55)';
ctx.fillText(g.label, padL + 2, Y(g.v) - 2);
}
// Datenlinien (+ Füllung der ersten Serie)
for (const s of o.series) {
if (s.fill) {
ctx.beginPath();
ctx.moveTo(X(0), padT + ph);
s.data.forEach((v, i) => ctx.lineTo(X(i), Y(v)));
ctx.lineTo(X(s.data.length - 1), padT + ph);
ctx.closePath();
ctx.fillStyle = s.color.replace('rgb', 'rgba').replace(')', ',0.10)');
ctx.fill();
}
ctx.strokeStyle = s.color;
ctx.lineWidth = 1.8;
ctx.beginPath();
s.data.forEach((v, i) => (i ? ctx.lineTo(X(i), Y(v)) : ctx.moveTo(X(i), Y(v))));
ctx.stroke();
ctx.lineWidth = 1;
}
// BODEN: kräftige Grundlinie + Zeitachse
ctx.strokeStyle = 'rgba(47,62,70,0.45)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(padL, padT + ph);
ctx.lineTo(padL + pw, padT + ph);
ctx.stroke();
ctx.lineWidth = 1;
ctx.fillStyle = 'rgba(47,62,70,0.55)';
ctx.textAlign = 'center';
for (const min of [0, 5, 10, 15, 20]) {
const x = padL + (min / 20) * pw;
ctx.beginPath();
ctx.moveTo(x, padT + ph);
ctx.lineTo(x, padT + ph + 3);
ctx.stroke();
ctx.fillText(`${min}'`, x, h - 2);
}
}
// ---------- Karten ----------
showCard(card) {
// Tempo VOR der ersten Karte merken auch eine manuelle Pause (0)
// wird nach dem Schließen wiederhergestellt.
if (!this.cardOpen) this.speedBeforeCard = this.api.getSpeed();
this.cardQueue.push(card);
if (!this.cardOpen) this._nextCard();
}
_nextCard() {
const card = this.cardQueue.shift();
if (!card) return;
this.currentCard = card;
this.cardOpen = true;
this.api.setSpeed(0);
this.markSpeed(0);
$('cardIcon').textContent = card.icon || '️';
$('cardTitle').textContent = card.title || '';
$('cardText').textContent = card.text || '';
const list = $('cardList');
list.innerHTML = '';
list.classList.toggle('hidden', !card.items);
if (card.items) {
for (const f of card.items) {
const li = document.createElement('li');
li.innerHTML = `<span class="feed-stars">${'⭐'.repeat(f.stars)}</span>`
+ `<span class="feed-text">${f.emoji}${f.text}"</span>`;
list.appendChild(li);
}
$('cardText').textContent = `Ø ${card.avg.toFixed(1)} Sterne → Besucherfaktor `
+ `×${card.fame.toFixed(2)} für die nächste Phase.`;
}
const alt = $('cardAlt');
if (card.choices) {
$('cardOk').textContent = card.choices[0].label;
$('cardOk').disabled = card.choices[0].disabled || false;
alt.textContent = card.choices[1].label;
alt.classList.remove('hidden');
} else {
$('cardOk').textContent = 'Weiter';
$('cardOk').disabled = false;
alt.classList.add('hidden');
}
$('cardOverlay').classList.remove('hidden');
if (!card.transient) {
const fn = audio[card.sound || 'chime'];
if (card.sound !== 'none') (typeof fn === 'function' ? fn : audio.chime).call(audio);
this.collectedCards.push(card);
const badge = $('logBadge');
badge.textContent = this.collectedCards.length;
badge.classList.remove('hidden');
telemetry.log('event_card', { id: card.id || card.title });
}
}
_closeCard(choiceIdx = null) {
const card = this.currentCard;
if (card?.choices && choiceIdx !== null) card.choices[choiceIdx].onPick?.();
$('cardOverlay').classList.add('hidden');
this.cardOpen = false;
this.currentCard = null;
if (this.cardQueue.length) {
this._nextCard();
} else if (!this.state.ended) {
this.api.setSpeed(this.speedBeforeCard);
this.markSpeed(this.speedBeforeCard);
}
}
_bindOverlays() {
$('cardOk').addEventListener('click', () => {
audio.click();
this._closeCard(this.currentCard?.choices ? 0 : null);
});
$('cardAlt').addEventListener('click', () => { audio.click(); this._closeCard(1); });
$('logClose').addEventListener('click', () => {
audio.click();
$('logOverlay').classList.add('hidden');
});
$('infoClose').addEventListener('click', () => { audio.click(); this.hideInfoPanel(); });
$('btnUpgrade').addEventListener('click', () => this.api.upgradeSelected());
$('btnDemolish').addEventListener('click', () => this.api.demolishSelected());
$('btnSave').addEventListener('click', () => this.api.save());
$('btnRestart').addEventListener('click', () => this.api.restart());
$('btnStats').addEventListener('click', () => { audio.click(); this.showStats(); });
$('statsClose').addEventListener('click', () => { audio.click(); $('statsOverlay').classList.add('hidden'); });
}
_openLog() {
const list = $('logList');
list.innerHTML = this.collectedCards.length ? '' : '<li>Noch keine Meldungen.</li>';
for (const c of [...this.collectedCards].reverse()) {
const li = document.createElement('li');
const first = (c.text || (c.items ? 'Bewertungen der Gäste' : '')).split('\n')[0];
li.innerHTML = `${c.icon || '️'} <b>${c.title}</b><small>${first}</small>`;
li.addEventListener('click', () => {
$('logOverlay').classList.add('hidden');
this.showCard({ ...c, transient: true, choices: null });
});
list.appendChild(li);
}
$('logOverlay').classList.remove('hidden');
telemetry.log('log_open', { count: this.collectedCards.length });
}
// ---------- Endauswertung ----------
showEnd() {
const s = this.state;
const r = s.result;
$('endProfileIcon').textContent = r.profile.icon;
$('endProfileName').textContent = r.profile.name;
$('endProfileText').textContent = r.profile.text;
$('endStars').textContent = `${'⭐'.repeat(Math.round(r.avgStars))} Ø ${r.avgStars.toFixed(1)} Sterne · Gesamtscore ${r.score}/100`;
const bars = [
['Bewertung (35 %)', r.scores.rating, '#e8892b'],
['Wertschöpfung (25 %)', r.scores.revenue, '#4f7f5e'],
['Nachhaltigkeit (20 %)', r.scores.sustain, '#2f6ea0'],
['Besucherlenkung (10 %)', r.scores.lenkung, '#a34a8e'],
['Finanzstabilität (10 %)', r.scores.finance, '#8c6239'],
];
$('endBars').innerHTML = bars.map(([label, v, color]) => `
<div class="end-bar-row">
<span>${label}</span>
<div class="end-bar"><div style="width:${Math.round(v)}%;background:${color}"></div></div>
<b>${Math.round(v)}</b>
</div>`).join('');
$('endGoals').innerHTML = r.goals.map(g =>
`<li class="${g.done ? 'done' : ''}">${g.done ? '✓' : '✗'} ${g.label}</li>`).join('');
$('endFacts').textContent = `${r.visitors} Besuchergruppen · ${r.rated} Bewertungen · `
+ `${euro(r.revenue)} € Wertschöpfung · Umwelt ${r.env} · Budget ${euro(r.budget)}`;
$('endFeedback').innerHTML = r.feedback.map(l => `<p>${l}</p>`).join('');
$('endReflect').innerHTML = '<h3>🧠 Denk mal nach …</h3><ul>'
+ this._reflectionQuestions(s).map(q => `<li>${q}</li>`).join('') + '</ul>';
$('endOverlay').classList.remove('hidden');
audio.win();
telemetry.log('game_end', {
score: r.score, stars: r.avgStars, revenue: r.revenue,
env: r.env, budget: r.budget, profile: r.profile.id,
});
}
// Reflexionsfragen (§24): teils an das Ergebnis angepasst, für die
// Nachbesprechung im Unterricht (offene Fragen, keine „richtige" Antwort).
_reflectionQuestions(s) {
const q = [];
const st = s.stats;
// stärkste/schwächste Gruppe
let best = null, worst = null;
for (const [id, g] of Object.entries(st.groups)) {
if (g.count < 3) continue;
if (!best || g.avg > best.avg) best = { id, ...g };
if (!worst || g.avg < worst.avg) worst = { id, ...g };
}
if (worst && worst.avg < 3.2) {
q.push(`${GROUPS[worst.id].name} waren am unzufriedensten. Welche Infrastruktur `
+ `hätte ihnen geholfen und warum hast du sie nicht (rechtzeitig) gebaut?`);
}
if (s.env < 50) {
q.push('Der Umweltwert ist gesunken. Welche Entscheidungen haben der Natur '
+ 'geschadet? Wie ließe sich Tourismus nachhaltiger gestalten?');
} else {
q.push('Du hast die Umwelt gut geschützt. Wo musstest du dafür auf Einnahmen '
+ 'verzichten und war das die richtige Abwägung?');
}
if (s.traffic > 25 || st.skippedNoParking > 0) {
q.push('Verkehr und Erreichbarkeit waren ein Thema. Wie verändern Parkplatz, '
+ 'Bushaltestelle und Radweg, wer überhaupt zu Gast kommt und mit welchen Folgen?');
}
q.push('Warum lohnt sich dieselbe Infrastruktur an einem Standort mehr als an '
+ 'einem anderen? Nenne ein Beispiel aus deiner Partie.');
q.push('Wie hat die Saison (Frühling → Winter) verändert, was sich gelohnt hat?');
if (best) {
q.push(`Deine Region wurde zur „${s.result.profile.name}". Wolltest du das `
+ 'oder ist es „passiert"? Was würdest du beim nächsten Mal anders planen?');
}
return q.slice(0, 5);
}
// ---------- Onboarding ----------
showOnboarding() {
this.showCard({
icon: '🗺️',
title: 'So entwickelst du deine Tourismusregion',
transient: true,
text: '1. Besuchergruppen ziehen über die Wege durch die Region jede hat '
+ 'eigene Interessen (antippen zeigt sie!).\n\n'
+ '2. Wähle rechts ein Gebäude und baue es NAH AN DEN WEGEN. Der Kreis '
+ 'zeigt, wen es erreicht. Antippen: Info + Ausbau.\n\n'
+ '3. Zufriedene Gäste geben Geld aus und bewerten die Region gute '
+ 'Bewertungen bringen in der nächsten Welle mehr Gäste.\n\n'
+ '4. Achte auf Saison, Umweltwert und Budget: Betriebskosten laufen '
+ 'immer weiter!\n\n'
+ '5. Gäste kommen in 8 Wellen. Zwischen den Wellen ist Baupause '
+ 'baue und rüste dann auf (Bauen kostet Zeit!). „Nächste Welle rufen" '
+ 'startet früher, mit Bonus. Karte bewegen: '
+ 'ziehen · Zoom: zwei Finger / Mausrad · ⏸ pausiert.',
});
telemetry.log('onboarding_open');
}
toast(msg) {
const t = $('toast');
t.textContent = msg;
t.classList.remove('hidden');
clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => t.classList.add('hidden'), 3000);
}
}
+141
View File
@@ -0,0 +1,141 @@
// Wegkarten-Geometrie: Routen als Polylinien in Bild-Pixeln, feste
// Bauplatz-Slots mit Zonen, Liftumweg auf den Berg. Ersetzt das frühere
// Iso-Kachelgitter komplett (exklusiver Diorama-Look, Karten in maps.js).
// DOM-frei läuft auch im Headless-Test.
import { BUILDINGS } from './data.js';
import { MAPS, TILE2PX } from './maps.js';
export { MAPS, TILE2PX };
// deterministischer Zufall: gleiche Wellen für alle
export function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Karte für eine Partie instanzieren: Slots als Objekte, Routen aufgelöst
// (reverseOf-Varianten erzeugen die Gegenrichtung auf demselben Weg).
export function prepMap(mapId) {
const def = MAPS[mapId] || MAPS.talschleife;
const routes = {};
for (const [id, r] of Object.entries(def.routes)) {
if (r.reverseOf) {
routes[id] = { ...r, points: [...def.routes[r.reverseOf].points].reverse() };
} else {
routes[id] = r;
}
}
return {
...def,
routes,
slots: def.slots.map((s, i) => ({ ...s, idx: i, building: null })),
};
}
// kumulierte Längen einer Punktliste (für Bewegung per Bogenlänge)
export function cumLengths(pts) {
const cum = [0];
for (let i = 1; i < pts.length; i++) {
cum.push(cum[i - 1] + Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y));
}
return cum;
}
// ---------- Routenwahl für eine Besuchergruppe ----------
// Liefert {pts, cum, exit}. Mit gebautem Lift wird für berg-affine Gruppen
// der Seilbahn-Umweg eingefügt (rauf, kleine Runde oben, runter).
export function makeRoute(state, group, rnd, routePick = null) {
const map = state.map;
const ids = map.entryRoutes[group.entry] || Object.keys(map.routes);
let total = 0;
const weighted = ids.map(id => {
const w = map.routes[id].lake ? 0.4 + group.lakeAffinity * 1.6 : 1;
total += w;
return [id, w];
});
let pick = (routePick ?? rnd()) * total; // Konvoi: ganze Welle, gleiche Route
let routeId = weighted[0][0];
for (const [id, w] of weighted) { pick -= w; if (pick <= 0) { routeId = id; break; } }
const route = map.routes[routeId];
let pts = route.points.map(p => ({ x: p[0], y: p[1] }));
const lift = state.buildings.find(b => b.type === 'skilift');
if (lift && map.lift) {
const winter = state.seasonKey === 'winter';
const p = Math.min(0.95, group.bergAffinity * (winter ? 1.1 : 0.55));
if (rnd() < p) {
// Route an der liftnächsten Stelle unterbrechen und Umweg einfügen
let best = 0, bestD = Infinity;
for (let i = 0; i < pts.length; i++) {
const d = Math.hypot(pts[i].x - lift.x, pts[i].y - lift.y);
if (d < bestD) { bestD = d; best = i; }
}
pts = [
...pts.slice(0, best + 1),
...bergDetour(lift, map),
...pts.slice(best + 1),
];
}
}
return { pts, cum: cumLengths(pts), exit: route.exit };
}
// Seilbahn-Umweg: Zustieg → Fahrt hinauf (lift) → Runde oben → Fahrt hinab
export function bergDetour(lift, map) {
const top = { x: map.lift.topX, y: map.lift.topY };
const loop = (map.lift.loop || []).map(p => ({ x: p[0], y: p[1] }));
return [
{ x: lift.x, y: lift.y },
{ ...top, lift: true },
...loop,
{ ...top },
{ x: lift.x, y: lift.y, lift: true },
];
}
// ---------- Platzierung auf festen Slots ----------
export function canPlace(state, type, slotIdx) {
const def = BUILDINGS[type];
const slot = state.slots[slotIdx];
if (!def || !slot) return { ok: false, reason: 'Kein Bauplatz.' };
if (slot.building) return { ok: false, reason: 'Dieser Bauplatz ist schon belegt.' };
if (slot.blocked && type !== 'naturschutz') {
return { ok: false, reason: 'Naturschutzgebiet hier bleibt die Natur unberührt.' };
}
const p = def.place || {};
const liftBuilt = state.buildings.some(b => b.type === 'skilift');
if (p.zone === 'hang') {
if (slot.zone !== 'hang') return { ok: false, reason: 'Der Skilift braucht den Hang-Bauplatz unten am Berg.' };
if (p.unique && liftBuilt) return { ok: false, reason: 'Es gibt schon einen Lift baue ihn stattdessen aus.' };
} else if (p.zone === 'berg') {
if (slot.zone !== 'berg') return { ok: false, reason: 'Die Almhütte gehört auf den Berg (Plätze oben am Gipfelweg).' };
if (p.needsLift && !liftBuilt) return { ok: false, reason: 'Ohne Lift kommt hier niemand herauf baue zuerst den Skilift.' };
} else if (p.near === 'lake') {
if (slot.zone !== 'lake') return { ok: false, reason: 'Ein Badesteg braucht einen Platz direkt am Seeufer.' };
} else if (p.near === 'road') {
if (slot.zone !== 'road') return { ok: false, reason: 'Muss an die Zufahrtsstraße dort kommen Autos und Busse an.' };
} else {
// normale Gebäude: Standardplätze; Aussichtspunkt darf auch auf den Berg
if (slot.zone === 'lake') return { ok: false, reason: 'Direkt am Wasser passt nur ein Badesteg.' };
if (slot.zone === 'road') return { ok: false, reason: 'Die Plätze an der Zufahrt sind für Parkplatz und Bushaltestelle reserviert.' };
if (slot.zone === 'hang') return { ok: false, reason: 'Dieser Platz am Hang ist für den Skilift reserviert.' };
if (slot.zone === 'berg') {
if (type !== 'aussicht' && type !== 'almhuette') {
return { ok: false, reason: 'Hier oben passen nur Almhütte oder Aussichtspunkt.' };
}
if (!liftBuilt) return { ok: false, reason: 'Ohne Lift kommt hier oben niemand vorbei.' };
}
}
if (state.budget < def.cost[0]) {
return { ok: false, reason: `Zu teuer ${def.name} kostet ${def.cost[0]} €.` };
}
return { ok: true };
}