Tourismustal: Komplett-Integration (Sim + Wrapper + SQL + Lehrer-Backend)

Sim nach App/sims/tourismustal umgezogen (Source of Truth), game.html mit
Injection-Marker. Neu in der Sim: 5 Sommergebaeude (Bikepark mit Flowtrails,
Hochseilgarten, Fahrradverleih als Verstaerker, Aussichtsplattform,
Erlebnisspielplatz), Rettungsstation mit Notarzthubschrauber, Ausbau-
Bauanimation, Event-Pacing, Schulden-Notbremse (Bank-Zwangsverkauf),
Berater-Tipps (Ruecklagen), Sommer-Trend-Mechanik (Serfaus-Effekt) und
Wissens-Bausteine mit echten DACH-Tourismusdaten (QUELLEN.md).

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 02:45:08 +02:00
parent eb565d4c01
commit 0a2ebf46b4
55 changed files with 6820 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
// 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', 'amb_birds', 'amb_cow', 'amb_cowbell', 'amb_wind'];
export const audio = {
ctx: null,
soundOn: true,
musicOn: true,
_buffers: {}, // Name → AudioBuffer
_raw: {}, // Name → ArrayBuffer (vor dem Decodieren)
_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;
})
.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);
}
},
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);
}
},
// ---------- Gelegentliche Naturlaute (statt Dauer-Ambience) ----------
// Alle 1025 s ein kurzer Laut passend zur Saison: Vögel, Kuhglocken,
// ein Muhen im Winter meist nur ein Windhauch.
_ambTimer: null,
setSeason(season) {
this._season = season;
},
_pickAmbience() {
const r = Math.random();
if (this._season === 'Winter') {
return r < 0.6 ? 'amb_wind' : r < 0.85 ? 'amb_cowbell' : 'amb_birds';
}
return r < 0.45 ? 'amb_birds' : r < 0.75 ? 'amb_cowbell' : 'amb_cow';
},
_scheduleAmbience() {
this._ambTimer = setTimeout(() => {
if (this.soundOn && this.ctx) {
const name = this._pickAmbience();
const buf = this._buffers[name];
if (buf) {
const src = this.ctx.createBufferSource();
const g = this.ctx.createGain();
g.gain.value = 0.22;
src.buffer = buf;
src.connect(g).connect(this.ctx.destination);
src.start();
}
}
this._scheduleAmbience();
}, 10000 + Math.random() * 15000);
},
startMusic() {
if (!this.ensure()) return;
clearTimeout(this._ambTimer);
this._scheduleAmbience();
},
_stopAmbience() {
clearTimeout(this._ambTimer);
this._ambTimer = null;
},
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();
+751
View File
@@ -0,0 +1,751 @@
// Spieldaten: Gebäude, Szenarien, Meilensteine, Entscheidungen, Aufträge,
// Event-Karten. Alle drei Szenen sind data-driven über SCENARIOS definiert.
export const BUILDINGS = {
lift: {
name: 'Sessellift', emoji: '🚡', cost: 12000, upkeep: 120,
zone: 'mid', staff: 4, cap: 200, pisteLen: 5,
desc: 'Bringt bis zu 200 Gäste pro Tag auf die Piste. Nur am Hang baubar.',
},
gondola: {
name: 'Gondelbahn', emoji: '🚠', cost: 22000, upkeep: 220,
zone: 'mid', staff: 5, cap: 300, pisteLen: 8,
desc: 'Bis in die Hochlagen: 300 Gäste pro Tag und eine lange Abfahrt.',
},
hotel: {
name: 'Hotel', emoji: '🏨', cost: 9000, upkeep: 90,
zone: 'valley', staff: 5, beds: 60,
desc: '60 Betten Übernachtungsgäste bringen mehr Einnahmen.',
},
restaurant: {
name: 'Gasthaus', emoji: '🍽️', cost: 4500, upkeep: 45,
zone: 'any', staff: 3, spendBonus: 7,
desc: 'Gäste geben mehr aus und sind zufriedener.',
},
snow: {
name: 'Beschneiung', emoji: '❄️', cost: 6000, upkeep: 15,
zone: 'mid', staff: 1, snowPower: 2.5,
desc: 'Erzeugt Schnee in warmen Wintern. Braucht Wasser: nahe am Fluss bauen! Betrieb kostet 45 €/Tag.',
},
rescue: {
name: 'Rettungsstation', emoji: '⛑️', cost: 7000, upkeep: 50,
zone: 'valley', staff: 2, satBonus: 3,
desc: 'Bergrettung mit Notarzthubschrauber: verunglückte Ski-Gäste werden automatisch geborgen und alle fühlen sich sicherer.',
},
access: {
name: 'Bushaltestelle', emoji: '🚌', cost: 3000, upkeep: 30,
zone: 'valley', staff: 1, access: 150,
desc: '150 weitere Gäste pro Tag. Muss direkt an der Talstraße stehen.',
},
staffhouse: {
name: 'Personalhaus', emoji: '🏠', cost: 5000, upkeep: 25,
zone: 'valley', staff: 0, housing: 12,
desc: 'Wohnraum für 12 Mitarbeiter/innen ohne Wohnung kein Personal.',
},
badesee: {
name: 'Badesee', emoji: '🏖️', cost: 8000, upkeep: 60,
zone: 'valley', staff: 2, summerCap: 100, satBonus: 4,
desc: 'Badespaß für 100 Sommergäste. Braucht Wasser: nahe am Fluss bauen!',
},
sled: {
name: 'Sommerrodelbahn', emoji: '🛷', cost: 9000, upkeep: 70,
zone: 'mid', staff: 2, summerCap: 140, satBonus: 3,
desc: 'Rasant den Hang hinunter 140 Sommergäste, ganz ohne Schnee.',
},
trail: {
name: 'Panoramaweg', emoji: '🥾', cost: 7000, upkeep: 40,
zone: 'mid', staff: 1, summerCap: 120,
desc: 'Wander- und Bikeweg am Hang bis zu 120 Sommergäste, unabhängig vom Schnee.',
scenes: ['klimawandel'],
},
bikepark: {
name: 'Bikepark', emoji: '🚵', cost: 10000, upkeep: 80,
zone: 'mid', staff: 2, summerCap: 160, trailLen: 5,
desc: 'Flowtrails am Hang: bis 160 Sommergäste. Braucht eine Bahn in der Nähe (max. 4 Felder), die die Bikes hinaufbringt.',
},
ropes: {
name: 'Hochseilgarten', emoji: '🧗', cost: 6500, upkeep: 45,
zone: 'mid', staff: 2, summerCap: 90, satBonus: 2,
desc: 'Klettern in Baumwipfel-Höhe 90 Sommergäste, besonders schonend für die Natur.',
},
bikerental: {
name: 'Fahrradverleih', emoji: '🚲', cost: 3500, upkeep: 25,
zone: 'valley', staff: 1, boost: 0.25,
desc: 'Räder & E-Bikes: Bikepark und Panoramaweg im Umkreis (4 Felder) bekommen +25 % Gäste.',
},
platform: {
name: 'Aussichtsplattform', emoji: '🌉', cost: 8000, upkeep: 30,
zone: 'high', staff: 1, summerCap: 130, satBonus: 2,
desc: 'Skywalk am Gipfel: 130 Ausflugsgäste. Nur in den Hochlagen nahe einer Bahn baubar.',
},
playground: {
name: 'Erlebnisspielplatz', emoji: '🎠', cost: 5500, upkeep: 35,
zone: 'valley', staff: 2, summerCap: 80, satBonus: 4,
desc: 'Wasserspiele & Kletterburg für Familien 80 Sommergäste und viel Zufriedenheit.',
},
};
// Start im Sommer: ruhige Aufbauphase, der erste Winter wird zur Bewährungsprobe
export const SEASONS = ['Sommer', 'Herbst', 'Winter', 'Frühling'];
export const DAYS_PER_SEASON = 30;
// ---------- Aufträge je Szene ----------
const QUESTS_PIONIER = [
{ id: 'q1', icon: '🥾', title: 'Die ersten Gäste', desc: '40 Gäste an einem Tag', reward: 2000, check: s => s.guestsToday >= 40 },
{ id: 'q2', icon: '🎿', title: 'Ski-Opening', desc: '100 Gäste an einem Wintertag', reward: 4000, check: s => s.season === 'Winter' && s.guestsToday >= 100 },
{ id: 'q3', icon: '😊', title: 'Gern gesehen', desc: 'Zufriedenheit über 80 %', reward: 3000, check: s => s.satisfaction >= 80 },
{ id: 'q4', icon: '🛏️', title: 'Volles Haus', desc: '50 Übernachtungsgäste an einem Tag', reward: 5000, check: s => s.overnightToday >= 50 },
{ id: 'q5', icon: '🌲', title: 'Grünes Tal', desc: 'Naturwert über 80 % mit mind. 5 Betrieben', reward: 4000, check: s => s.nature >= 80 && s.buildings.length >= 5 },
{ id: 'q6', icon: '🏆', title: 'Ausgebucht', desc: '250 Gäste an einem Tag', reward: 8000, check: s => s.guestsToday >= 250 },
];
const QUESTS_BOOM = [
{ id: 'b1', icon: '📈', title: 'Boom nutzen', desc: '350 Gäste an einem Tag', reward: 6000, check: s => s.guestsToday >= 350 },
{ id: 'b2', icon: '👷', title: 'Gut besetzt', desc: 'Alle Stellen besetzt bei mind. 15 Beschäftigten', reward: 5000, check: s => s.staffNeeded >= 15 && s.staffAvailable >= s.staffNeeded },
{ id: 'b3', icon: '🌲', title: 'Gegensteuern', desc: 'Naturwert wieder über 70 %', reward: 6000, check: s => s.nature >= 70 },
{ id: 'b4', icon: '😊', title: 'Qualität statt Masse', desc: 'Zufriedenheit über 78 % bei 300+ Gästen', reward: 8000, check: s => s.satisfaction >= 78 && s.guestsToday >= 300 },
];
const QUESTS_KLIMA = [
{ id: 'k1', icon: '🥾', title: 'Neue Wege', desc: 'Baue einen Panoramaweg', reward: 4000, check: s => s.buildings.some(b => b.type === 'trail') },
{ id: 'k2', icon: '☀️', title: 'Sommerfrische', desc: '150 Gäste an einem Sommertag', reward: 5000, check: s => s.season === 'Sommer' && s.guestsToday >= 150 },
{ id: 'k3', icon: '❄️', title: 'Schneesicher', desc: '60 cm Schnee dank Beschneiung', reward: 5000, check: s => s.snow >= 60 && s.buildings.some(b => b.type === 'snow') },
{ id: 'k4', icon: '🌍', title: 'Ganzjahresziel', desc: '200 Gäste an einem Sommertag', reward: 8000, check: s => s.season === 'Sommer' && s.guestsToday >= 200 },
];
// ---------- Szenarien ----------
export const SCENARIOS = {
pionierzeit: {
id: 'pionierzeit',
name: ' Pionierzeit 1975',
startYear: 1975,
startMoney: 30000,
startAwareness: 0.35,
startNature: 88,
baseAccess: 120,
baseStaffHousing: 8,
demand: { Winter: 290, 'Frühling': 60, Sommer: 140, Herbst: 85 },
summerSpend: 12,
overnightSpend: 30,
wage: 24,
snowCannonRun: 45,
warmWinterChance: 0.3,
deadlineDay: 600,
goalsTitle: 'Ziele bis Ende 1979',
prebuilt: [],
startUnlocked: ['restaurant'], // kleine Schritte!
stageRow: 8, // Hochlagen anfangs wolkenverhangen
quests: QUESTS_PIONIER,
goals: [
{ id: 'guests', label: '220 Gäste an einem Tag', check: s => s.stats.maxGuests >= 220 },
{ id: 'money', label: '200.000 € auf dem Konto', check: s => s.money >= 200000 },
{ id: 'sat', label: 'Zufriedenheit mind. 70 %', check: s => s.satisfaction >= 70 },
{ id: 'nature', label: 'Naturwert mind. 55 %', check: s => s.nature >= 55 },
],
intro: {
id: 'intro', icon: '👋', title: 'Willkommen im Tal!',
text: 'Sommer 1975. Du übernimmst die Entwicklung eines kleinen, stillen '
+ 'Alpentals.\n\nFang klein an: Baue zuerst ein Gasthaus im Tal, in der '
+ 'Nähe des Dorfes. Dann kommen die ersten Wanderer und alles Weitere '
+ 'ergibt sich Schritt für Schritt.',
},
},
boom: {
id: 'boom',
name: ' Boom 2000',
startYear: 2000,
startMoney: 45000,
startAwareness: 0.9, // das Tal ist längst bekannt
startNature: 68, // und trägt schon Spuren
baseAccess: 200,
baseStaffHousing: 10,
demand: { Winter: 500, 'Frühling': 100, Sommer: 240, Herbst: 150 },
summerSpend: 16,
overnightSpend: 38,
wage: 28,
snowCannonRun: 55,
warmWinterChance: 0.35,
deadlineDay: 480,
goalsTitle: 'Ziele bis Ende 2003',
prebuilt: ['restaurant', 'lift', 'access', 'hotel'],
startUnlocked: 'all',
stageRow: 0,
quests: QUESTS_BOOM,
goals: [
{ id: 'guests', label: '450 Gäste an einem Tag', check: s => s.stats.maxGuests >= 450 },
{ id: 'money', label: '500.000 € auf dem Konto', check: s => s.money >= 500000 },
{ id: 'sat', label: 'Zufriedenheit mind. 75 %', check: s => s.satisfaction >= 75 },
{ id: 'nature', label: 'Naturwert mind. 60 %', check: s => s.nature >= 60 },
],
intro: {
id: 'intro', icon: '📈', title: 'Der Boom ist da!',
text: 'Sommer 2000. Das Tal ist längst kein Geheimtipp mehr die Gäste '
+ 'strömen, und alle wollen ausbauen.\n\nAber die Natur trägt schon '
+ 'Spuren, und die Einheimischen murren. Schaffst du Wachstum, OHNE '
+ 'das Tal zu verkaufen? Qualität und Naturwert entscheiden.',
},
},
klimawandel: {
id: 'klimawandel',
name: ' Schneegrenze 2035',
startYear: 2035,
startMoney: 40000,
startAwareness: 0.8,
startNature: 75,
baseAccess: 180,
baseStaffHousing: 10,
demand: { Winter: 360, 'Frühling': 110, Sommer: 220, Herbst: 160 },
summerSpend: 18,
overnightSpend: 40,
wage: 30,
snowCannonRun: 60,
warmWinterChance: 0.5, // warme Winter sind die neue Normalität …
warmWinterGrow: 0.06, // … und werden jedes Jahr häufiger
meltExtra: 1.2, // Schnee schmilzt schneller
summerTrend: 0.07, // Sommernachfrage +7 %/Jahr bei gutem Angebot (Serfaus-Effekt)
deadlineDay: 600,
goalsTitle: 'Ziele bis Ende 2039',
prebuilt: ['restaurant', 'lift', 'access'],
startUnlocked: 'all',
stageRow: 0,
quests: QUESTS_KLIMA,
goals: [
{ id: 'summer', label: '200 Gäste an einem Sommertag', check: s => s.stats.maxSummerGuests >= 200 },
{ id: 'money', label: '400.000 € auf dem Konto', check: s => s.money >= 400000 },
{ id: 'sat', label: 'Zufriedenheit mind. 70 %', check: s => s.satisfaction >= 70 },
{ id: 'nature', label: 'Naturwert mind. 65 %', check: s => s.nature >= 65 },
],
intro: {
id: 'intro', icon: '🌡️', title: 'Die Schneegrenze steigt',
text: 'Sommer 2035. Die Winter werden wärmer, Schnee ist keine '
+ 'Selbstverständlichkeit mehr. Messdaten zeigen: In den Alpen '
+ 'schmilzt der Schnee 59 Tage pro Jahrzehnt früher seit 1970 ist '
+ 'die Schneesaison rund einen Monat kürzer geworden.\n\n'
+ 'Dein Tal muss sich neu erfinden: Ganzjahrestourismus! Wer sein '
+ 'Sommerangebot konsequent ausbaut, dessen Sommernachfrage wächst '
+ 'Jahr für Jahr (wie in echten Vorbild-Orten). Beschneiung '
+ 'überbrückt aber sie kostet. Findest du die Balance?',
},
},
};
// Meilensteine: schalten Gebäude Schritt für Schritt frei (nur Szene 1
// in den anderen Szenen ist bereits alles freigeschaltet)
export const MILESTONES = [
{
id: 'm-lift', unlock: 'lift',
when: s => s.buildings.some(b => b.type === 'restaurant') && s.day >= 13,
icon: '🚡', title: 'Neu: Sessellift!',
text: 'Dein Gasthaus spricht sich herum die ersten Gäste sind da!\n\n'
+ 'Sie fragen schon: „Kommt man hier auch auf den Berg?"\n\n'
+ 'Du kannst jetzt einen Sessellift bauen. Er gehört an den Hang '
+ '(weiter oben) die Piste entsteht automatisch darüber.',
},
{
id: 'm-access', unlock: 'access',
when: s => s.buildings.some(b => b.type === 'lift'),
icon: '🚌', title: 'Neu: Bushaltestelle!',
text: 'Der Lift läuft! Aber viele Gäste kommen gar nicht erst ins Tal '
+ 'es gibt zu wenige Anreisemöglichkeiten.\n\n'
+ 'Baue eine Bushaltestelle direkt an der Talstraße dann bringt der '
+ 'Bus bis zu 150 zusätzliche Gäste pro Tag.',
},
{
id: 'm-hotel', unlock: 'hotel',
when: s => s.stats.maxGuests >= 90,
icon: '🏨', title: 'Neu: Hotel!',
text: 'Fast 100 Gäste an einem Tag! Viele würden gern länger bleiben '
+ 'Übernachtungsgäste geben deutlich mehr Geld aus als Tagesgäste.\n\n'
+ 'Du kannst jetzt ein Hotel im Tal bauen (60 Betten).',
},
{
id: 'm-staff', unlock: 'staffhouse',
when: s => s.staffNeeded > s.staffAvailable,
icon: '👷', title: 'Neu: Personalhaus!',
text: 'Deine Betriebe finden nicht genug Mitarbeiter/innen im Tal gibt '
+ 'es zu wenig Wohnraum!\n\nOhne Personal laufen Lift und Hotel nur mit '
+ 'halber Kraft. Ein Personalhaus schafft Wohnraum für 12 Beschäftigte.',
},
{
id: 'm-summer', unlock: ['badesee', 'sled'],
when: s => s.stats.maxSummerGuests >= 70,
icon: '☀️', title: 'Neu: Sommer-Attraktionen!',
text: 'Immer mehr Gäste kommen auch im Sommer ins Tal sie wollen mehr '
+ 'erleben als nur wandern.\n\n'
+ 'Du kannst jetzt einen Badesee anlegen (nahe am Fluss!) und eine '
+ 'Sommerrodelbahn an den Hang bauen. Beide machen das Tal unabhängiger '
+ 'vom Schnee.',
},
{
id: 'm-expand', unlock: 'gondola',
when: s => s.stats.maxGuests >= 140,
apply: s => { s.stageRow = 0; },
icon: '🏔️', title: 'Die Hochlagen sind erschlossen!',
text: 'Dein Tal brummt die Gemeinde gibt die Hochlagen frei!\n\n'
+ 'Der Nebel über dem oberen Berg lichtet sich: Dort gibt es Almen zum '
+ 'Bebauen (auch ein Berg-Gasthaus lohnt sich!) und du kannst jetzt '
+ 'eine Gondelbahn bis in die Hochlagen bauen: 300 Gäste pro Tag und '
+ 'eine herrlich lange Abfahrt.',
},
{
id: 'm-snow', unlock: 'snow',
when: s => s.season === 'Winter' && s.weather.warmWinter
&& s.buildings.some(b => b.type === 'lift'),
icon: '❄️', title: 'Neu: Beschneiung!',
text: 'Dieser Winter ist ungewöhnlich warm es fällt kaum Schnee, und '
+ 'ohne Schnee bleiben die Skigäste aus.\n\n'
+ 'Beschneiungsanlagen können helfen. Sie brauchen aber Wasser (nahe am '
+ 'Fluss bauen!) und kosten im Betrieb 45 € pro Tag.',
},
{
id: 'm-summer2', unlock: ['bikepark', 'ropes', 'bikerental', 'platform', 'playground'],
when: s => s.stats.maxSummerGuests >= 110,
icon: '🚵', title: 'Neu: Der Sommer boomt!',
text: 'Über 100 Sommergäste an einem Tag dein Tal spricht sich als '
+ 'Sommerziel herum!\n\nJetzt baubar: Bikepark (braucht eine Bahn in der '
+ 'Nähe), Hochseilgarten, Fahrradverleih, Erlebnisspielplatz und in den '
+ 'Hochlagen eine Aussichtsplattform.\n\nEchte Vorbilder zeigen: Wer das '
+ 'Sommerangebot konsequent ausbaut, verdoppelt seine Sommergäste in '
+ 'zehn Jahren (Serfaus-Fiss-Ladis: +95 %).',
},
{
id: 'm-rescue', unlock: 'rescue',
when: s => s.buildings.some(b => b.type === 'lift') && s.stats.maxGuests >= 120,
icon: '⛑️', title: 'Neu: Rettungsstation!',
text: 'Immer mehr Gäste sind auf den Pisten und ab und zu verfährt sich '
+ 'jemand weit abseits und stürzt.\n\nEine Rettungsstation mit '
+ 'Notarzthubschrauber birgt Verunglückte automatisch. Sicherheit ist '
+ 'auch ein Standortfaktor und die Gäste danken es dir.',
},
];
// Berater: wiederkehrende Hinweise (Toast) bei erkennbaren Problemen
// beantworten „warum läuft es schlecht?", mit Cooldown in Spieltagen
export const ADVISOR = [
{
id: 'a-loss', cooldown: 25,
when: s => s.lossStreak >= 5,
text: '💸 Du machst seit Tagen Verlust: Instandhaltung & Löhne laufen weiter, auch wenn kaum Gäste da sind. Details: 📋 Tagesbericht.',
},
{
id: 'a-reserve', cooldown: 45,
when: s => s.money >= 0 && s.money < 4000 && s.buildings.length >= 2,
text: '💰 Kaum Rücklagen! Halte etwa 5.000 € zurück für einmalige Chancen (Skirennen, Sommerfest) und schlechte Tage.',
},
{
id: 'a-debt', cooldown: 20,
when: s => s.money < -5000,
text: '🏦 Hohe Schulden! Reiß ungenutzte Gebäude ab (30 % zurück) ab 15.000 € Schulden verkauft die Bank von selbst!',
},
{
id: 'a-snow', cooldown: 20,
when: s => s.season === 'Winter' && s.snow < 12
&& s.buildings.some(b => b.type === 'lift' || b.type === 'gondola')
&& !s.buildings.some(b => b.type === 'snow'),
text: '❄️ Wenig Schnee = wenig Skigäste! Eine Beschneiungsanlage hilft (braucht Fluss und Kälte).',
},
{
id: 'a-access', cooldown: 25,
when: s => s.bottleneckStreak >= 3 && s.bottleneck === 'Anreise (Bus)',
text: '🚌 Mehr Gäste wollten kommen, als anreisen können Bushaltestelle bauen oder ausbauen ⬆️!',
},
{
id: 'a-cap', cooldown: 25,
when: s => s.bottleneckStreak >= 3 && (s.bottleneck === 'Liftplätze' || s.bottleneck === 'Wege & Attraktionen'),
text: '🚡 Dein Angebot ist voll ausgelastet eine weitere Bahn/Attraktion oder ein Ausbau ⬆️ schafft Platz.',
},
{
id: 'a-staff', cooldown: 25,
when: s => s.bottleneck === 'Personal fehlt' && s.bottleneckStreak >= 3,
text: '👷 Personalmangel bremst alle Betriebe ein Personalhaus schafft Wohnraum für 12 Angestellte.',
},
];
// Erklär-Karte beim ERSTEN Bau jedes Gebäudetyps: was bringt es, was
// kostet es, worauf achten? (Lernprogramm: jede Wirkung wird vorgestellt)
export const BUILD_INFO = {
restaurant: {
icon: '🍽️', title: 'Dein Gasthaus ist eröffnet!',
text: 'Gäste kehren jetzt ein (Symbol 🍽️ überm Kopf) und geben dabei '
+ 'mehr Geld aus schau auf die grünen 🥨 +12 €. Außerdem steigt die '
+ 'Zufriedenheit.\n\nLaufende Kosten: 45 €/Tag + 3 Angestellte.\n\n'
+ '📍 LAGE ZÄHLT: Steht das Gasthaus im Einflusskreis (3 Felder) von '
+ 'Lift, Badesee, Rodelbahn oder Hotel, kehren deren Gäste gleich mit '
+ 'ein (+2 €/Gast je Nachbarschaft). Der Kreis wird beim Bauen angezeigt. '
+ 'Und: Jede Figur im Tal steht für etwa 10 echte Gäste.',
},
lift: {
icon: '🚡', title: 'Der Sessellift läuft!',
text: 'Im Winter bringt er bis zu 200 Skigäste auf die Piste den '
+ 'Skipass-Preis bestimmst du links mit dem Regler. Im Sommer kaufen '
+ 'Wanderer Bergfahrten (🎫 8 €).\n\nKosten: 120 €/Tag Instandhaltung '
+ '+ 4 Angestellte. Ohne Schnee (links: ❄️-Anzeige) bleiben Skigäste aus!\n\n'
+ '📍 LAGE ZÄHLT: Weiter OBEN gebaut ist der Lift schneesicherer '
+ '(längere Skisaison), weiter UNTEN konsumieren die Gäste mehr '
+ '(+2 €/Gast). Zwei Bahnen nahe beieinander bilden einen Skiverbund '
+ 'und locken zusätzliche Gäste an.',
},
gondola: {
icon: '🚠', title: 'Die Gondelbahn schwebt!',
text: 'Bis zu 300 Gäste pro Tag und eine herrlich lange Abfahrt aus den '
+ 'Hochlagen.\n\nKosten: 220 €/Tag + 5 Angestellte die teuerste, '
+ 'aber stärkste Bahn im Tal.\n\n📍 LAGE ZÄHLT: Auch hier gilt hoch '
+ 'gebaut = schneesicher, tief gebaut = konsumfreundlich; nahe Bahnen '
+ 'bilden einen Skiverbund.',
},
access: {
icon: '🚌', title: 'Der Bus hält jetzt im Tal!',
text: 'Bis zu 150 zusätzliche Gäste können pro Tag anreisen beobachte, '
+ 'wie Gruppen an der Haltestelle aussteigen.\n\nWenn unter der '
+ 'Gäste-Zahl „Engpass: Anreise" steht, lohnt sich ein Ausbau ⬆️.',
},
hotel: {
icon: '🏨', title: 'Das Hotel empfängt Gäste!',
text: '60 Betten: Die Hälfte deiner Tagesgäste würde gern übernachten '
+ 'jede Übernachtung bringt 30 € extra.\n\nKosten: 90 €/Tag + 5 '
+ 'Angestellte. Denk an Personal-Wohnraum!',
},
staffhouse: {
icon: '🏠', title: 'Wohnraum für dein Personal!',
text: '12 Angestellte finden hier ein Zuhause. Betriebe ohne Personal '
+ 'laufen nur mit halber Kraft die Personal-Zeile links zeigt, ob '
+ 'alle Stellen besetzt sind.\n\n📍 LAGE ZÄHLT: Baue es nahe bei den '
+ 'Betrieben (max. 4 Felder, Kreis beim Bauen) liegt es weiter weg, '
+ 'wirkt es nur zu zwei Dritteln.',
},
snow: {
icon: '❄️', title: 'Die Beschneiung steht bereit!',
text: 'Sobald es kälter als 2 °C ist und Schnee fehlt, springt sie an '
+ '(+2,5 cm/Tag) Betrieb kostet dann 45 €/Tag.\n\nIst es zu warm, '
+ 'kann auch sie nicht schneien (Wettervorhersage links!).',
},
badesee: {
icon: '🏖️', title: 'Der Badesee glitzert!',
text: 'Bis zu 100 Sommergäste planschen hier (🏖️ überm Kopf) und zahlen '
+ 'Eintritt (🎫 +5 €). Dazu steigt die Zufriedenheit.\n\nIm Winter '
+ 'ruht der Betrieb die Kosten (60 €/Tag) laufen weiter.',
},
sled: {
icon: '🛷', title: 'Die Sommerrodelbahn saust!',
text: 'Bis zu 140 Sommergäste, ganz ohne Schnee (🛷 überm Kopf, 🎫 +7 €). '
+ 'Mit Ausbau ⬆️ wird die Bahn länger und schafft +50 %.\n\n'
+ 'Kosten: 70 €/Tag + 2 Angestellte.',
},
trail: {
icon: '🥾', title: 'Der Panoramaweg ist markiert!',
text: 'Bis zu 120 Sommergäste wandern hier völlig schneeunabhängig. '
+ 'Genau das braucht das Tal, wenn die Winter wärmer werden.\n\n'
+ 'Kosten: 40 €/Tag + 1 Angestellte/r.',
},
bikepark: {
icon: '🚵', title: 'Der Bikepark ist eröffnet!',
text: 'Die Flowtrails schlängeln sich den Hang hinunter bis zu 160 '
+ 'Sommergäste (🚵 überm Kopf, 🎫 +9 €).\n\nKosten: 80 €/Tag + 2 '
+ 'Angestellte. Die Trails kosten Naturwert Aufforstung gleicht aus.\n\n'
+ '📍 LAGE ZÄHLT: Die Bahn daneben bringt die Bikes hinauf genau so '
+ 'nutzen echte Skigebiete (Saalbach, Serfaus) ihre Lifte im Sommer. '
+ 'Ein Fahrradverleih im Umkreis bringt +25 % Gäste.',
},
ropes: {
icon: '🧗', title: 'Der Hochseilgarten ist gespannt!',
text: 'Klettern, Balancieren, Ziplines 90 Sommergäste (🎫 +8 €), und die '
+ 'Bäume bleiben stehen: kaum Naturverbrauch.\n\n'
+ 'Kosten: 45 €/Tag + 2 Angestellte. Familien lieben ihn die '
+ 'Zufriedenheit steigt.',
},
bikerental: {
icon: '🚲', title: 'Der Fahrradverleih hat geöffnet!',
text: 'Räder und E-Bikes für alle, die keins mitbringen.\n\n'
+ '📍 LAGE ZÄHLT: Bikepark und Panoramaweg im Umkreis (4 Felder) '
+ 'bekommen +25 % Gäste der Verleih ist ein Verstärker, keine eigene '
+ 'Attraktion.\n\nKosten: 25 €/Tag + 1 Angestellte/r.',
},
platform: {
icon: '🌉', title: 'Die Aussichtsplattform schwebt!',
text: 'Der Skywalk zieht bis zu 130 Ausflugsgäste an sie kommen NUR '
+ 'für die Aussicht und fahren mit der Bahn hinauf (🎫 +6 €).\n\n'
+ 'Das ist Ausflugstourismus: Die Jungfraubahn (Schweiz) macht so nur '
+ 'noch ~20 % ihres Umsatzes mit Wintersport der Rest ist '
+ 'Ganzjahres-Ausflugsgeschäft.\n\nKosten: 30 €/Tag + 1 Angestellte/r.',
},
playground: {
icon: '🎠', title: 'Der Erlebnisspielplatz tobt!',
text: 'Wasserspiele, Kletterburg, Riesenrutsche 80 Sommergäste und ein '
+ 'großes Plus bei der Zufriedenheit.\n\nFamilien sind DER '
+ 'Sommer-Wachstumsmotor: Serfaus-Fiss-Ladis wurde mit Familienangeboten '
+ 'zur Vorzeige-Sommerdestination.\n\nKosten: 35 €/Tag + 2 Angestellte.',
},
rescue: {
icon: '⛑️', title: 'Die Rettungsstation ist einsatzbereit!',
text: 'Fährt ein Ski-Gast weit abseits und stürzt, startet automatisch der '
+ 'Notarzthubschrauber, birgt ihn und fliegt zurück zur Station.\n\n'
+ 'Kosten: 50 €/Tag + 2 Angestellte. Gäste fühlen sich sicherer '
+ '(mehr Zufriedenheit).',
},
};
// Kurz-Hinweise: dezente Toasts ohne Pause erzählen, was gerade
// passiert (Gäste kommen, konsumieren, es entsteht Bedarf …)
export const HINTS = [
{
id: 'h-guests', scenes: ['pionierzeit'],
when: s => s.buildings.length > 0 && s.guestsToday >= 8,
text: '🥾 Die ersten Wanderer sind da schau, wie sie durchs Tal spazieren!',
},
{
id: 'h-konsum', scenes: ['pionierzeit'],
when: s => s.guestsToday >= 18,
text: '💶 Es klingelt in der Kasse: Die Gäste kehren ein und geben Geld aus.',
},
{
id: 'h-bus', scenes: ['pionierzeit'],
when: s => s.day >= 8 && s.buildings.length > 0,
text: '🚌 Mancher Gast käme gern aber ohne Bus ist die Anreise mühsam …',
},
{
id: 'h-berg', scenes: ['pionierzeit'],
when: s => s.day >= 11 && s.buildings.some(b => b.type === 'restaurant'),
text: '⛰️ Immer öfter fragen Gäste: „Kommt man hier auch auf den Berg?"',
},
];
// Entscheidungs-Karten: Situationen mit echter A/B-Wahl (Urteils- und
// Handlungskompetenz!). Erscheinen einmalig, wenn die Bedingung erfüllt ist.
export const DECISIONS = [
{
id: 'd-race', icon: '🏁', title: 'Skirennen im Tal?',
when: s => s.season === 'Winter' && s.snow > 20 && s.day > 100
&& s.buildings.some(b => b.type === 'lift'),
text: 'Der Skiverband fragt an: Ein Landesrennen würde das Tal weit über '
+ 'die Region hinaus bekannt machen kostet aber 5.000 € für '
+ 'Absperrungen, Helfer und Preisgeld.',
yes: {
label: 'Ausrichten (5.000 €)',
can: s => s.money >= 5000,
apply: s => {
s.money -= 5000;
s.awareness = Math.min(1, s.awareness + 0.15);
s.satisfaction = Math.min(100, s.satisfaction + 8);
s.race = { until: s.day + 5 }; // sichtbares Rennen auf der Piste!
},
toast: '🏁 Renntag! Schau auf die Piste das Tal ist in aller Munde!',
},
no: { label: 'Ablehnen', apply: () => {}, toast: 'Der Verband fragt woanders an.' },
},
{
id: 'd-fest', icon: '🎪', title: 'Sommerfest im Dorf?',
when: s => s.season === 'Sommer' && s.day > 40,
text: 'Das Dorf möchte ein großes Sommerfest mit Musik und Handwerksmarkt '
+ 'ausrichten und bittet dich um 3.000 € Unterstützung.',
yes: {
label: 'Unterstützen (3.000 €)',
can: s => s.money >= 3000,
apply: s => {
s.money -= 3000;
s.awareness = Math.min(1, s.awareness + 0.1);
s.satisfaction = Math.min(100, s.satisfaction + 6);
s.fest = { until: s.day + 5 }; // Marktstände & Wimpel im Dorf!
},
toast: '🎪 Das Fest ist im Gange schau ins Dorf!',
},
no: {
label: 'Diesmal nicht', apply: s => {
s.satisfaction = Math.max(10, s.satisfaction - 3);
}, toast: 'Das Dorf ist etwas enttäuscht.',
},
},
{
id: 'd-forest', icon: '🌱', title: 'Aufforstungs-Projekt?',
when: s => s.nature < 65,
text: 'Die Bürgerinitiative schlägt vor, gemeinsam Bäume zu pflanzen und '
+ 'Almwiesen zu pflegen. Deine Beteiligung: 4.000 €.',
yes: {
label: 'Mitmachen (4.000 €)',
can: s => s.money >= 4000,
apply: s => {
s.money -= 4000;
s.nature = Math.min(100, s.nature + 12);
},
toast: '🌱 Das Tal wird sichtbar grüner und die Gäste danken es.',
},
no: {
label: 'Ablehnen', apply: s => {
s.satisfaction = Math.max(10, s.satisfaction - 5);
}, toast: 'Die Initiative ist verärgert.',
},
},
{
id: 'd-tourbus', icon: '🚍', title: 'Reisegruppe mit Rabatt?',
when: s => s.buildings.some(b => b.type === 'access') && s.day > 70,
text: 'Ein Reiseveranstalter bietet an, regelmäßig Busgruppen zu schicken '
+ 'aber nur mit 30 % Gruppenrabatt. Viele Gäste, weniger Geld pro Kopf, '
+ 'und es wird voller.',
yes: {
label: 'Annehmen',
apply: s => {
s.money += 2500;
s.awareness = Math.min(1, s.awareness + 0.06);
s.satisfaction = Math.max(10, s.satisfaction - 5);
},
toast: '🚍 Die Gruppen kommen es klingelt in der Kasse, aber es wird eng.',
},
no: { label: 'Ablehnen', apply: () => {}, toast: 'Du setzt weiter auf Individualgäste.' },
},
];
// Event-Karten: erscheinen einmalig, sobald ihre Bedingung erfüllt ist.
// Optional per `scenes` auf einzelne Szenarien beschränkt.
export const EVENTS = [
{
id: 'first-winter', icon: '⛄', title: 'Der erste Winter naht',
scenes: ['pionierzeit'],
when: s => s.season === 'Winter',
text: 'Es wird kalt im Tal! Im Winter kommen Skigäste aber nur, wenn es '
+ 'einen Lift und genug Schnee gibt.\n\n'
+ 'Behalte die Schnee-Anzeige links im Blick.',
},
{
id: 'overtourism', icon: '🚗', title: 'Es wird eng im Tal',
scenes: ['boom'],
when: s => s.guestsToday > 500,
text: 'Über 500 Gäste an einem Tag! Die Straße ist verstopft, die '
+ 'Einheimischen finden keinen Parkplatz mehr.\n\n'
+ 'Mehr ist nicht immer besser: Achte auf Zufriedenheit und Naturwert '
+ 'sonst kommt der Boom ins Kippen.',
},
{
id: 'no-snow-winter', icon: '🌧️', title: 'Grüner Winter',
scenes: ['klimawandel'],
when: s => s.season === 'Winter' && s.snow < 10 && s.day > 70,
text: 'Mitten im Winter: Regen statt Schnee. So sieht die neue Realität '
+ 'aus und die Skigäste bleiben aus.\n\n'
+ 'Beschneiung hilft kurzfristig. Langfristig braucht das Tal Angebote, '
+ 'die keinen Schnee brauchen.',
},
{
id: 'sommerloch', icon: '📊', title: 'Das Sommerloch',
scenes: ['boom', 'klimawandel'],
when: s => s.season === 'Sommer' && s.day > 25
&& !s.buildings.some(b => (BUILDINGS[b.type].summerCap || 0) > 0),
text: 'Dein Tal lebt fast nur vom Winter im Sommer bleiben die Betten '
+ 'leer. Fachleute nennen das „Sommerloch".\n\n'
+ 'Echte Zahlen: Lech am Arlberg hat 84 % seiner Übernachtungen im '
+ 'Winter und nur 16 % im Sommer. Ganz Österreich zeigt, dass es anders '
+ 'geht dort hat der Sommer den Winter bei den Übernachtungen sogar '
+ 'überholt (83 zu 72 Millionen, 2025).\n\n'
+ 'Badesee, Sommerrodelbahn und Panoramaweg füllen das Loch.',
},
{
id: 'ganzjahres-daten', icon: '📈', title: 'Was sagen die Daten?',
scenes: ['klimawandel'],
when: s => s.buildings.filter(b => (BUILDINGS[b.type].summerCap || 0) > 0).length >= 2,
text: 'Dein Tal wird zur Ganzjahresdestination wie echte Vorbilder: '
+ 'Serfaus-Fiss-Ladis hat seine Sommer-Übernachtungen in 10 Jahren fast '
+ 'verdoppelt, am Wilden Kaiser kommen Sommer und Winter gleich viele '
+ 'Gäste.\n\nAber Achtung: Ein Sommergast gibt nur rund 80 % dessen aus, '
+ 'was ein Wintergast ausgibt (156 vs. 193 € pro Tag). Und eine Bergbahn '
+ 'braucht 1,3 bis 4,2 Sommergäste, um EINEN fehlenden Wintergast zu '
+ 'ersetzen.\n\nGleich viele Gäste heißt also nicht gleich viel Geld '
+ 'plane entsprechend!',
},
{
id: 'low-money', icon: '💸', title: 'Die Kasse ist fast leer',
when: s => s.money < 3000,
text: 'Achtung: Gebäude kosten auch im Betrieb Geld (Instandhaltung, Löhne). '
+ 'Warte, bis Einnahmen hereinkommen, bevor du weiter baust.\n\n'
+ 'Tipp: Mit dem Abriss-Werkzeug bekommst du 30 % der Baukosten zurück.',
},
{
id: 'nature-low', icon: '🌲', title: 'Bürgerinitiative besorgt',
when: s => s.nature < 45,
text: 'Eine Bürgerinitiative protestiert: Zu viel Verbauung schadet Natur '
+ 'und Landschaftsbild und genau deshalb kommen viele Gäste!\n\n'
+ 'Sinkt der Naturwert weiter, bleiben Gäste aus.',
},
{
id: 'too-expensive', icon: '🎟️', title: 'Zu teuer!',
when: s => s.price >= 38 && s.satisfaction < 55,
text: 'Die Gäste beschweren sich über den Skipass-Preis. Ein hoher Preis '
+ 'bringt mehr Geld pro Gast aber weniger Gäste kommen, und die '
+ 'Zufriedenheit sinkt.\n\nDas ist Angebot und Nachfrage!',
},
{
id: 'broke', icon: '🏦', title: 'Das Konto ist überzogen!',
when: s => s.money < -1000,
text: 'Die Gemeinde streckt dir das Geld vor, aber so kann es nicht '
+ 'weitergehen. Reiß ungenutzte Gebäude ab (30 % zurück) oder senke die '
+ 'laufenden Kosten, bis wieder Einnahmen kommen.',
},
];
// ---------------------------------------------------------------------------
// Lehrplan-Wissen je Gebäude: kurze, verdaubare Bausteine, verankert im
// GW-Lehrplan 2023 (Standortfaktoren, Angebot/Nachfrage, Wertschöpfung,
// Nachhaltigkeit, Klimaanpassung). Erscheinen im Gebäude-Klick-Dialog.
export const KNOWLEDGE = {
restaurant: [
{ term: 'Wertschöpfung', text: 'Jeder Gast, der einkehrt, lässt Geld im Tal. Diese vor Ort erwirtschafteten Einnahmen nennt man Wertschöpfung.' },
{ term: 'Standortfaktor Lage', text: 'Steht das Gasthaus nah bei Lift, Hotel oder Attraktion, kehren deren Gäste gleich mit ein gute Lage bringt mehr Umsatz.' },
],
lift: [
{ term: 'Standortfaktor Höhenlage', text: 'Weiter oben ist es kälter, der Schnee bleibt länger die Skisaison wird länger. Höhe ist ein Standortfaktor.' },
{ term: 'Kapazität', text: 'Ein Lift befördert nur begrenzt viele Gäste pro Tag. Ist er voll, kommen trotz Nachfrage nicht mehr hinauf.' },
{ term: 'Sommergeschäft der Bergbahnen', text: 'Real machen Seilbahnen nur 1525 % ihres Umsatzes im Sommer. Fällt ein Wintergast aus, braucht die Bahn 1,3 bis 4,2 Sommergäste als Ersatz (Seilbahnen Schweiz).' },
],
gondola: [
{ term: 'Infrastruktur', text: 'Bahnen sind touristische Infrastruktur die Grundausstattung, die eine Tourismusregion erst möglich macht.' },
{ term: 'Standortfaktor Höhenlage', text: 'Die Gondel erschließt die Hochlagen: mehr Kapazität und schneesicherer als tief gelegene Lifte.' },
],
hotel: [
{ term: 'Übernachtungstourismus', text: 'Übernachtungsgäste bringen mehr Wertschöpfung als Tagesgäste sie bleiben länger, essen und kaufen im Ort ein.' },
{ term: 'Tagesausgaben', text: 'Ein Wintergast gibt in Österreich im Schnitt 193 € pro Tag aus, ein Sommergast 156 € also nur rund 80 %. Gleich viele Gäste heißt darum nicht gleich viel Einnahmen!' },
{ term: 'Saisonalität & Auslastung', text: 'Betten kosten auch leer Geld (Personal, Heizung, Kredit). Je gleichmäßiger sich Sommer und Winter füllen, desto wirtschaftlicher arbeitet ein Hotel.' },
],
access: [
{ term: 'Erreichbarkeit', text: 'Erreichbarkeit ist ein zentraler Standortfaktor. Ohne gute Anreise kommen viele Gäste gar nicht erst an.' },
{ term: 'Nachhaltige Mobilität', text: 'Anreise mit dem Bus statt mit vielen Autos entlastet Straßen und Umwelt ein Beitrag zu nachhaltigem Tourismus.' },
],
staffhouse: [
{ term: 'Saisonarbeit & Wohnraum', text: 'Tourismus braucht viele Arbeitskräfte, oft in Saisonarbeit. Ohne leistbaren Wohnraum finden die Betriebe kein Personal.' },
],
snow: [
{ term: 'Klimawandel-Anpassung', text: 'Beschneiung sichert die Saison, wenn die Winter wärmer werden braucht aber viel Wasser und Energie.' },
{ term: 'Schneesaison wird kürzer', text: 'Messdaten: In den Alpen schmilzt der Schnee 59 Tage pro Jahrzehnt früher; seit 1970 ist die Schneesaison rund einen Monat kürzer (GeoSphere Austria, SLF Schweiz). Unterhalb von ca. 2.000 m trifft es Skigebiete am härtesten.' },
{ term: 'Zielkonflikt Nachhaltigkeit', text: 'Kunstschnee kostet Ressourcen. Nachhaltiger Tourismus fragt: Wie lange lohnt sich das noch?' },
],
badesee: [
{ term: 'Diversifizierung', text: 'Sommerangebote machen das Tal unabhängiger vom Schnee mehrere Standbeine nennt man Diversifizierung. Echte Vorbilder: Am Wilden Kaiser (Tirol) kommen heute etwa gleich viele Gäste im Sommer wie im Winter.' },
{ term: 'Sommerloch', text: 'In Skiorten heißt die gästearme Zeit „Sommerloch". Lech am Arlberg z. B. hat 84 % seiner Übernachtungen im Winter nur 16 % im Sommer (Statistik Austria).' },
],
sled: [
{ term: 'Ganzjahrestourismus', text: 'Attraktionen ohne Schnee verlängern die Saison. Serfaus-Fiss-Ladis hat seine Sommer-Übernachtungen in 10 Jahren fast verdoppelt (+95 %) mit Familienangeboten wie Rodelbahnen und Erlebniswegen.' },
],
trail: [
{ term: 'Sanfter Tourismus', text: 'Wandern greift wenig in die Natur ein und braucht keinen Schnee zukunftsfähig, wenn das Klima wärmer wird.' },
{ term: 'Nächtigung', text: 'Übernachtungen („Nächtigungen") sind die Maßeinheit der Tourismusstatistik. In ganz Österreich hat der Sommer den Winter dabei schon überholt: 83 zu 72 Millionen (2025).' },
],
rescue: [
{ term: 'Naturgefahren', text: 'Im Gebirge drohen Lawinen, Abstürze und Wetterstürze. Tourismusregionen brauchen Rettungsdienste, um Gäste zu schützen.' },
{ term: 'Standortfaktor Sicherheit', text: 'Ein gutes Rettungswesen macht eine Region attraktiver Sicherheit ist ein weicher Standortfaktor.' },
],
bikepark: [
{ term: 'Sommernutzung von Winter-Infrastruktur', text: 'Bikeparks nutzen die Lifte im Sommer weiter so verdienen die teuren Bahnen ganzjährig. Real machen Seilbahnen 1525 % ihres Umsatzes im Sommer, Tendenz steigend.' },
{ term: 'Zielkonflikt', text: 'Trails bedeuten Eingriff in den Hang: Wer den Sommer ausbaut, muss den Naturwert im Blick behalten wie beim Winterausbau auch.' },
],
ropes: [
{ term: 'Sanfter Tourismus', text: 'Ein Hochseilgarten nutzt den Wald, ohne ihn zu roden Wertschöpfung mit kleinem Fußabdruck.' },
],
bikerental: [
{ term: 'Wertschöpfungskette', text: 'Verleih, Bahn, Trail, Einkehr: Ein Gast gibt an vielen Stationen Geld aus. Je mehr Glieder die Kette hat, desto mehr bleibt im Tal.' },
{ term: 'Komplementärangebot', text: 'Der Verleih verkauft kein eigenes Erlebnis er macht andere Angebote zugänglicher. Solche Ergänzungen heißen Komplementärangebote.' },
],
platform: [
{ term: 'Ausflugstourismus', text: 'Ausflugsgäste bleiben nur Stunden, kommen aber ganzjährig und wetterunabhängiger. Die Jungfraubahn (CH) lebt zu ~80 % vom Ausflugs- statt vom Wintersportgeschäft.' },
{ term: 'Inszenierung der Landschaft', text: 'Plattformen, Hängebrücken, Skywalks machen Aussicht zum Produkt die Landschaft selbst wird zur Attraktion.' },
],
playground: [
{ term: 'Zielgruppe Familien', text: 'Familien sind der Wachstumsmotor des Sommertourismus: Serfaus-Fiss-Ladis hat mit Familienangeboten seine Sommernächtigungen in 10 Jahren fast verdoppelt (+95 %).' },
],
};
File diff suppressed because it is too large Load Diff
+399
View File
@@ -0,0 +1,399 @@
// Einstiegspunkt: Szenenwahl → Spielstart. Verdrahtet Modell, Renderer,
// Audio und UI und treibt die laufende Zeit an (einstellbare Zeitskala).
import { createState, simulateDay, canPlace, place, demolish, upgrade, buildingReport } from './model.js';
import { BUILDINGS, EVENTS, MILESTONES, DECISIONS, HINTS, ADVISOR, BUILD_INFO } from './data.js';
import { IsoRenderer } from './iso.js';
import { UI } from './ui.js';
import { telemetry } from './telemetry.js';
import { audio } from './audio.js';
import { player } from './player.js';
window.__ttLoaded = true; // Signal an den file://-Fallback in index.html
const MS_PER_DAY = 2000; // 1 Tag = 2 s bei Geschwindigkeit 1× ruhiges Tempo
// Ereignis-Pacing: Schonfrist zum Ankommen + Mindestabstand zwischen
// Ereignis-/Entscheidungskarten, damit am Anfang nicht mehrere Karten im
// Sekundentakt aufploppen. Dringende Warnungen umgehen die Sperre.
const EVENT_GRACE_DAYS = 20; // erste ~2/3 Saison ohne Zufalls-Karten
const EVENT_COOLDOWN = 18; // Mindestabstand in Spieltagen
const URGENT_EVENTS = new Set(['low-money', 'broke', 'first-winter', 'no-snow-winter']);
let speed = 0;
let acc = 0;
let last = performance.now();
let state = null;
let ui = null;
let renderer = null;
// ---------- Szenenwahl & Spielstart ----------
document.querySelectorAll('.scene-btn:not(.locked)').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('startOverlay').classList.add('hidden');
startGame(btn.dataset.scene);
});
});
function startGame(sceneId) {
state = createState(sceneId);
const nameInput = document.getElementById('valleyName');
if (nameInput?.value.trim()) state.valleyName = nameInput.value.trim().slice(0, 24);
document.getElementById('valleyTitle').textContent = state.valleyName;
document.title = `${state.valleyName} GeoGraSim`;
window.__ttState = state; // für Playwright-Tests lesbar
window.__ttRenderer = () => renderer;
telemetry.init(() => state?.day);
telemetry.log('scene_start', { scene: sceneId, valley: state.valleyName });
ui = new UI(state, {
setSpeed: v => { speed = v; },
getSpeed: () => speed,
setBuildType: t => renderer.setBuildType(t),
});
// Plattform-Standard: Onboarding jederzeit über den -Header-Button abrufbar
window.GGS_TUTORIAL = {
simId: 'tourismustal',
title: 'Willkommen im Tourismustal',
replay: () => ui.showOnboarding(),
};
state._startedAt = Date.now();
renderer = new IsoRenderer(document.getElementById('map'), state, { onTileTap });
audio.ensure();
audio.click();
audio.startMusic(); // gelegentliche Naturlaute
player.init(); // Hintergrundmusik aus .humaninput/songs/
player.play();
ui.syncPlayerUI();
speed = 1;
advanceDay(); // Tag 1 sofort: Kennzahlen sind da
ui.updateClock();
ui.showCard({ ...state.scenario.intro, sound: 'card' });
if (!localStorage.getItem('tourismustal_seen')) {
localStorage.setItem('tourismustal_seen', '1');
ui.showOnboarding();
}
requestAnimationFrame(frame);
}
// ---------- Bauen / Abreißen ----------
// Klick auf ein bestehendes Gebäude (ohne aktives Bau-Werkzeug) → Report
function inspectBuilding(c, r) {
const report = buildingReport(state, c, r);
if (!report) return;
const cur = speed;
if (cur > 0) ui.speedBeforeCard = cur;
ui.showBuildingCard(report, {
money: state.money,
onUpgrade: (rep) => {
const res = upgrade(state, c, r);
if (!res.ok) { ui.toast(`${res.reason}`); audio.error(); return; }
const roman = ['', 'I', 'II', 'III', 'IV'][res.level];
audio.build();
renderer.addFloat('🏗️ Umbau …', '#e8892b', c + 0.5, r - 0.5, true);
ui.updateKPIs();
ui.toast(`🏗️ ${res.name} wird auf Stufe ${roman} umgebaut (${res.cost.toLocaleString('de-AT')} €) schau zu!`);
telemetry.log('upgrade', { c, r, cost: res.cost, level: res.level });
ui._closeBuilding(); // Karte schließen → man sieht die Baustelle
},
onDemolish: () => {
const res = demolish(state, c, r);
if (!res.ok) { ui.toast(`${res.reason}`); audio.error(); return; }
ui.toast(`🧨 ${res.name} abgerissen ${res.refund.toLocaleString('de-AT')} € zurück.`);
audio.demolish();
ui.updateKPIs();
telemetry.log('demolish', { c, r, refund: res.refund });
},
});
}
function onTileTap(c, r) {
// Ohne Bau-Werkzeug: Gebäude anklicken öffnet den Report
if (!ui.activeBuild) {
if (state.map[r]?.[c]?.building) inspectBuilding(c, r);
return;
}
const type = ui.activeBuild;
const check = canPlace(state, type, c, r);
if (!check.ok) {
ui.toast(`${check.reason}`);
audio.error();
telemetry.log('build_rejected', { type, c, r, reason: check.reason });
return;
}
place(state, type, c, r);
ui.toast(`${BUILDINGS[type].name} gebaut.`);
ui.clearBuildSelection(); // Baumodus verlassen → sicher navigieren
audio.build();
ui.updateKPIs();
telemetry.log('build_placed', { type, c, r, moneyLeft: state.money });
// Lernprogramm: beim ERSTEN Bau jedes Typs erklärt eine Karte die Wirkung
const infoId = `bi-${type}`;
if (BUILD_INFO[type] && !state.firedEvents.has(infoId)) {
state.firedEvents.add(infoId);
ui.showCard({ id: infoId, ...BUILD_INFO[type], sound: 'card' });
}
}
// ===== Live-View für Lehrkräfte (Plattform-Hook) =====
// Wird vom live-client.js alle ~4 s an /api/live gesendet. Klein halten,
// stabile Schlüssel, didaktisch sinnvoll (Cockpit-Spalten).
window.GGS_LIVE_STATE = function () {
if (!state) return null;
return {
scene: state.scenario.id,
scenarioId: state.scenario.id, // Plattform-Konvention für Lektions-Gruppierung
day: state.day,
year: state.year,
season: state.season,
money: state.money,
guestsToday: state.guestsToday,
maxGuests: state.stats.maxGuests,
maxSummerGuests: state.stats.maxSummerGuests,
satisfaction: state.satisfaction,
nature: Math.round(state.nature),
snow: Math.round(state.snow),
price: state.price,
buildings: state.buildings.length,
staffGap: Math.max(0, state.staffNeeded - state.staffAvailable),
lossStreak: state.lossStreak,
goalsDone: state.scenario.goals.filter(g => g.check(state)).length,
goalsTotal: state.scenario.goals.length,
questsDone: state.questIndex,
bottleneck: state.bottleneck,
};
};
// ===== Submit-Hook (Plattform) =====
// Schreibt bei Zielerreichung (Sieg) und bei der Zwischenbilanz (Deadline)
// einen Eintrag in `assessments`, damit das Lehrer-Cockpit Ergebnisse sieht.
function submitAssessment(completed) {
if (!window.TT_SESSION_ID) return; // nur mit Schüler-Session
const api = (window.TT_API_BASE || '../../php/api') + '/progress.php';
const goals = state.scenario.goals.filter(g => g.check(state)).length;
const total = state.scenario.goals.length;
const stars = Math.min(5, goals + (completed ? 1 : 0));
try {
fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
sim_id: 'tourismustal',
action: 'submit_assessment',
data: {
level: state.scenario.id,
stars,
score: Math.round((goals / total) * 100),
duration_ms: Date.now() - (state._startedAt || Date.now()),
completed,
results: {
scene: state.scenario.id,
day: state.day,
year: state.year,
money: state.money,
maxGuests: state.stats.maxGuests,
maxSummerGuests: state.stats.maxSummerGuests,
satisfaction: state.satisfaction,
nature: Math.round(state.nature),
goalsDone: goals,
goalsTotal: total,
questsDone: state.questIndex,
questsTotal: state.scenario.quests.length,
buildings: state.buildings.length,
valleyName: state.valleyName,
},
},
}),
}).catch(() => {});
} catch { /* offline/Standalone: still ok */ }
}
// ---------- Karten: Meilensteine, Sieg, Entscheidungen, Events ----------
function checkEvents() {
// Meilensteine: Gebäude Schritt für Schritt freischalten (Szene 1)
for (const m of MILESTONES) {
if (state.firedEvents.has(m.id)) continue;
const unlocks = Array.isArray(m.unlock) ? m.unlock : [m.unlock];
if (unlocks.every(u => state.unlocked.has(u))) { state.firedEvents.add(m.id); continue; }
if (m.when(state)) {
state.firedEvents.add(m.id);
unlocks.forEach(u => state.unlocked.add(u));
m.apply?.(state);
ui.rebuildMenu();
ui.applyProgress();
ui.showCard({ id: m.id, icon: m.icon, title: m.title, text: m.text, sound: 'milestone' });
state.lastInterruptDay = state.day; // nach großer Karte erst wieder Ruhe
telemetry.log('milestone', { id: m.id, unlock: m.unlock });
return; // eine Karte pro Tag reicht
}
}
if (state.goalsDone && !state.firedEvents.has('win')) {
state.firedEvents.add('win');
ui.showCard({
id: 'win', icon: '🏆', title: 'Alle Ziele erreicht!', sound: 'win',
text: `Großartig! Nach ${state.day} Tagen hast du alle Ziele dieser Szene `
+ `geschafft mit ${state.stats.maxGuests} Gästen am besten Tag, `
+ `${state.money.toLocaleString('de-AT')} € am Konto und einem Naturwert von `
+ `${Math.round(state.nature)} %.\n\nDu kannst weiterspielen und dein Tal ausbauen.`,
});
telemetry.log('goals_done', { day: state.day, money: state.money });
submitAssessment(true);
return;
}
if (state.deadlineMissed && !state.firedEvents.has('deadline')) {
state.firedEvents.add('deadline');
const done = state.scenario.goals.filter(g => g.check(state)).length;
ui.showCard({
id: 'deadline', icon: '⏰', title: 'Zwischenbilanz',
text: `Die Zeit dieser Szene ist um. Du hast ${done} von `
+ `${state.scenario.goals.length} Zielen erreicht.\n\n`
+ `Kein Problem du kannst weiterspielen und die restlichen Ziele noch schaffen!`,
});
telemetry.log('deadline', { goalsDone: done });
submitAssessment(false);
return;
}
// Pacing-Fenster: erst nach Schonfrist und mit Mindestabstand zur letzten Karte
const calmEnough = state.day >= EVENT_GRACE_DAYS
&& (state.day - state.lastInterruptDay) >= EVENT_COOLDOWN;
// Entscheidungs-Karten: A/B-Wahl mit echten Folgen (je einmal, zeitversetzt)
if (calmEnough) for (const d of DECISIONS) {
if (state.firedEvents.has(d.id)) continue;
if (d.when(state) && Math.random() < 0.3) {
state.firedEvents.add(d.id);
state.lastInterruptDay = state.day;
const yesOk = !d.yes.can || d.yes.can(state);
ui.showCard({
id: d.id, icon: d.icon, title: d.title, text: d.text,
choices: [
{
label: d.yes.label, disabled: !yesOk,
onPick: () => {
d.yes.apply(state);
ui.toast(d.yes.toast);
ui.updateKPIs();
telemetry.log('decision', { id: d.id, choice: 'yes' });
},
},
{
label: d.no.label,
onPick: () => {
d.no.apply(state);
ui.toast(d.no.toast);
ui.updateKPIs();
telemetry.log('decision', { id: d.id, choice: 'no' });
},
},
],
});
return;
}
}
for (const ev of EVENTS) {
if (state.firedEvents.has(ev.id)) continue;
if (ev.scenes && !ev.scenes.includes(state.scenario.id)) continue;
const urgent = URGENT_EVENTS.has(ev.id);
if (!urgent && !calmEnough) continue; // nur dringende Events umgehen die Sperre
if (ev.when(state)) {
state.firedEvents.add(ev.id);
if (!urgent) state.lastInterruptDay = state.day;
ui.showCard(ev);
break; // max. eine neue Karte pro Tag
}
}
}
// Mini-Aufträge: immer genau einer aktiv, Belohnung bei Erfüllung
function checkQuest() {
const quests = state.scenario.quests;
const quest = quests[state.questIndex];
if (!quest) { ui.updateQuest(null); return; }
ui.updateQuest(quest);
if (quest.check(state)) {
state.money += quest.reward;
state.questIndex += 1;
state.lastInterruptDay = state.day; // Auftrags-Karte zählt als Unterbrechung
const next = quests[state.questIndex];
const b = state.buildings[0];
if (b) renderer.addFloat(`+${quest.reward.toLocaleString('de-AT')}`, '#e8892b', b.c + 0.5, b.r - 0.5);
ui.showCard({
id: quest.id, icon: quest.icon, sound: 'success',
title: `Auftrag erfüllt: ${quest.title}!`,
text: `${quest.desc} geschafft!\n\nBelohnung: ${quest.reward.toLocaleString('de-AT')}`
+ `${next ? `\n\nNeuer Auftrag: ${next.icon} ${next.title} ${next.desc}` : '\n\nDas war der letzte Auftrag stark!'}`,
});
telemetry.log('quest_done', { id: quest.id, day: state.day });
ui.updateQuest(next || null);
}
}
// ---------- Zeit ----------
function advanceDay() {
simulateDay(state);
// Bank-Zwangsverkauf (Überschuldung): als Karte erklären, was passiert ist
if (state.forcedSale && state.forcedSale.day === state.day) {
const fs = state.forcedSale;
state.forcedSale = null;
ui.showCard({
id: `forced-${state.day}`, icon: '🏦', title: 'Die Bank greift ein!',
text: `Deine Schulden sind über 15.000 € gestiegen. Die Bank hat `
+ `${fs.name}" zwangsverkauft nur ${fs.refund.toLocaleString('de-AT')} € Erlös.\n\n`
+ 'Wer baut, muss die LAUFENDEN Kosten mitplanen: Instandhaltung und '
+ 'Löhne fallen jeden Tag an, auch ohne Gäste. Baue erst wieder, wenn '
+ 'die Einnahmen stimmen.',
sound: 'card',
});
telemetry.log('forced_sale', { name: fs.name, day: state.day });
}
ui.updateKPIs();
ui.updateClock();
ui.drawGraph();
// Tagesbilanz als aufsteigende Zahl über dem ersten Betrieb
if (state.buildings.length && state.lastNet !== 0) {
const b = state.buildings[0];
const txt = `${state.lastNet > 0 ? '+' : ''}${state.lastNet.toLocaleString('de-AT')}`;
renderer.addFloat(txt, state.lastNet > 0 ? '#3f8f5e' : '#c0392b', b.c + 0.5, b.r);
}
audio.setSeason(state.season);
// Berater: erklärt wiederkehrend, WARUM es gerade schlecht läuft
for (const adv of ADVISOR) {
if (!adv.when(state)) continue;
if (state.day - (state.advisorLast[adv.id] ?? -999) < adv.cooldown) continue;
state.advisorLast[adv.id] = state.day;
ui.toast(adv.text, 6500);
telemetry.log('advisor', { id: adv.id });
break;
}
for (const h of HINTS) {
if (state.firedEvents.has(h.id)) continue;
if (h.scenes && !h.scenes.includes(state.scenario.id)) continue;
if (h.when(state)) {
state.firedEvents.add(h.id);
ui.toast(h.text, 5000);
telemetry.log('hint', { id: h.id });
break;
}
}
checkQuest();
checkEvents();
}
function frame(now) {
const dt = now - last;
last = now;
acc += dt * speed;
while (acc >= MS_PER_DAY) {
acc -= MS_PER_DAY;
advanceDay();
}
renderer.draw(now, dt, speed);
requestAnimationFrame(frame);
}
+808
View File
@@ -0,0 +1,808 @@
// Spielzustand und Simulationsmodell. Bewusst klein und nachvollziehbar
// gehalten: jede Tagesrechnung wird in state.report als Klartext abgelegt,
// damit Schüler/innen das Modell verstehen können ("Tagesbericht").
// Gebäude werden Schritt für Schritt freigeschaltet (Meilensteine).
import { BUILDINGS, SCENARIOS, SEASONS, DAYS_PER_SEASON, KNOWLEDGE } from './data.js';
// Bebaubare Kernzone (das eigentliche Tal) plus ein breiter, unbebaubarer
// Bergrahmen ringsum. Dadurch füllt die Iso-Landschaft immer das ganze
// rechteckige Fenster (keine leeren Ecken) man bleibt in der Rautensicht.
const MARGIN = 14;
const CORE_COLS = 22;
const CORE_ROWS = 26;
export const MAP_COLS = CORE_COLS + MARGIN * 2; // 38
export const MAP_ROWS = CORE_ROWS + MARGIN * 2; // 42
export const CORE = {
c0: MARGIN, r0: MARGIN, c1: MARGIN + CORE_COLS - 1, r1: MARGIN + CORE_ROWS - 1,
};
export const ROAD_ROW = MARGIN + 23; // Talstraße; alles darüber ist Berg
export const PISTE_LEN = 5; // Standard-Pistenlänge (Sessellift)
// Liegt (c,r) in der bebaubaren Kernzone?
export function inCore(c, r) {
return c >= CORE.c0 && c <= CORE.c1 && r >= CORE.r0 && r <= CORE.r1;
}
// Deterministischer Zufall, damit die Karte für alle gleich aussieht
// und Playwright-Tests reproduzierbar bleiben.
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function genMap() {
const rnd = mulberry32(1975);
// 1) Höhenfeld. Kernzone: Tal unten, Berg oben. Rahmen: hohe Berge, die
// nach außen ansteigen so ist das rechteckige Fenster stets gefüllt.
let elev = [];
for (let r = 0; r < MAP_ROWS; r++) {
elev.push([]);
for (let c = 0; c < MAP_COLS; c++) {
const cr = r - CORE.r0, cc = c - CORE.c0; // core-relative
let h;
if (inCore(c, r)) {
const t = (CORE_ROWS - 1 - cr) / (CORE_ROWS - 1);
h = Math.pow(Math.max(0, t - 0.18) / 0.82, 1.25) * 9.5;
} else {
// Rahmenberge steigen nach außen an aber NUR nach hinten/zur Seite.
// Zur Kamera hin (vorne/unten, großes c+r) öffnet sich das Tal flach,
// sonst verdecken Vordergrund-Berge nur das Spielfeld.
const dx = Math.max(0, CORE.c0 - c, c - CORE.c1);
const dy = Math.max(0, CORE.r0 - r, r - CORE.r1);
const d = Math.max(dx, dy);
const backEdge = CORE.c0 + CORE.r0; // hinterste Tal-Ecke (klein)
const frontEdge = CORE.c1 + CORE.r1; // vorderste Tal-Ecke (groß)
// 1 hinten → 0 vorne: blendet die Rahmenhöhe zur Kamera hin aus
const taper = Math.max(0, Math.min(1,
(frontEdge - (c + r)) / (frontEdge - backEdge)));
h = (8 + d * 0.9) * taper; // hinten Gebirge, vorne Talboden
}
elev[r].push(h + (rnd() - 0.5) * (h > 0.5 ? 1.6 : 0.6));
}
}
for (let pass = 0; pass < 2; pass++) {
const sm = elev.map(row => row.slice());
for (let r = 0; r < MAP_ROWS; r++) {
for (let c = 0; c < MAP_COLS; c++) {
const n = [elev[r - 1]?.[c], elev[r + 1]?.[c], elev[r][c - 1], elev[r][c + 1]]
.filter(v => v !== undefined);
sm[r][c] = (elev[r][c] * 2 + n.reduce((a, b) => a + b, 0)) / (2 + n.length);
}
}
elev = sm;
}
// 2) Kacheln mit Terrain je Höhe
const tiles = [];
for (let r = 0; r < MAP_ROWS; r++) {
const row = [];
for (let c = 0; c < MAP_COLS; c++) {
const e = Math.max(0, Math.round(elev[r][c]));
let terrain;
if (e >= 8) terrain = 'rock';
else if (e >= 6) terrain = rnd() < 0.32 ? 'rock' : 'alm'; // viel Alm oben
else if (e >= 3) terrain = rnd() < 0.5 ? 'forest' : 'alm';
else if (e >= 1) terrain = rnd() < 0.22 ? 'forest' : 'meadow';
else terrain = rnd() < 0.3 ? 'field' : 'meadow';
row.push({
c, r, elev: e, terrain,
building: null, deco: null,
border: !inCore(c, r), // Rahmen = nicht bebaubar
tint: Math.round((rnd() - 0.5) * 10),
});
}
tiles.push(row);
}
// Fluss: mäandert durch die Kernzone von Nord nach Süd
let riverC = CORE.c0 + 12;
for (let r = CORE.r0; r <= CORE.r1; r++) {
riverC = Math.max(CORE.c0 + 3, Math.min(CORE.c1 - 3,
riverC + Math.round((rnd() - 0.45) * 2)));
const t = tiles[r][riverC];
t.terrain = 'water';
t.deco = null;
if (r > CORE.r0) t.elev = Math.min(t.elev, tiles[r - 1][riverC].elev);
}
// Fluss läuft im flachen Vordergrund weiter, driftet nach rechts und mündet
// in einen Bergsee in der vorderen rechten Ecke. Reines Sperr-/Kulissenland.
const lakeC = MAP_COLS - 6, lakeR = MAP_ROWS - 6; // vom Kartenrand eingerückt
let frc = riverC;
for (let r = CORE.r1 + 1; r < MAP_ROWS - 1; r++) {
frc = Math.min(lakeC, frc + 2); // steuert Richtung Bergsee
const wide = r - CORE.r1 > 3 ? 1 : 0; // wird zur Mündung breiter
for (let w = 0; w <= wide; w++) {
const t = tiles[r]?.[frc + w];
if (t) { t.terrain = 'water'; t.deco = null; t.building = null; t.elev = 0; }
}
}
for (let r = 0; r < MAP_ROWS - 1; r++) { // elliptischer Bergsee
for (let c = 0; c < MAP_COLS - 1; c++) {
if (((c - lakeC) / 4) ** 2 + ((r - lakeR) / 3) ** 2 <= 1) {
const t = tiles[r][c];
t.terrain = 'water'; t.deco = null; t.building = null; t.elev = 0;
}
}
}
// Talstraße quert die ganze Karte von West nach Ost (läuft an beiden Enden
// ins Gelände hinaus, endet nicht mehr im flachen Vordergrund), mit Brücke
// über den Fluss. Straßentiles werden auf Talniveau eingeschnitten.
for (let c = 0; c < MAP_COLS; c++) {
const t = tiles[ROAD_ROW][c];
t.road = true;
t.bridge = t.terrain === 'water';
if (!t.bridge) t.elev = Math.min(t.elev, 1);
}
// Bestehendes Dorf im Tal, beidseits der Straße (Kulisse), core-relativ
[[22, 6, 'church'], [22, 8, 'house'], [24, 7, 'house'], [24, 9, 'house'], [21, 7, 'house']]
.forEach(([cr, cc, deco]) => {
const t = tiles[CORE.r0 + cr]?.[CORE.c0 + cc];
if (!t || t.terrain === 'water' || t.road) return;
t.terrain = 'village';
t.deco = deco;
});
return tiles;
}
export function createState(sceneId = 'pionierzeit') {
const S = SCENARIOS[sceneId] || SCENARIOS.pionierzeit;
// Freischaltung: Szene 1 startet klein, spätere Szenen mit allem
const unlocked = S.startUnlocked === 'all'
? new Set(Object.keys(BUILDINGS).filter(k =>
!BUILDINGS[k].scenes || BUILDINGS[k].scenes.includes(S.id)))
: new Set(S.startUnlocked);
const state = {
scenario: S,
map: genMap(),
buildings: [], // {type, c, r}
pistes: [], // {c, r, owner:{c,r}} Abfahrten oberhalb der Lifte
trails: [], // {c, r, owner:{c,r}} Bike-Trails unterhalb des Bikeparks
unlocked,
day: 0,
year: S.startYear,
season: SEASONS[0],
money: S.startMoney,
price: 25, // Skipass-Preis (Regler)
snow: 0, // Schneehöhe in cm (Start im Sommer)
weather: { warmWinter: false },
guestsToday: 0,
awareness: S.startAwareness,
satisfaction: 70,
nature: S.startNature,
staffNeeded: 0,
staffAvailable: S.baseStaffHousing,
stats: { maxGuests: 0, maxSummerGuests: 0 },
demandToday: 0,
bottleneck: null, // warum kamen weniger Gäste als wollten?
bottleneckStreak: 0,
lossStreak: 0, // Tage in Folge mit Verlust
advisorLast: {}, // Cooldowns der Berater-Hinweise
overnightToday: 0,
questIndex: 0,
goalsDone: false,
deadlineMissed: false,
valleyName: 'Tourismustal',
stageRow: S.stageRow ? CORE.r0 + S.stageRow : 0, // Hochlagen-Sperre (core-relativ)
race: null, // sichtbares Skirennen {until}
fest: null, // sichtbares Sommerfest {until}
history: { money: [], guests: [] },
report: ['Noch kein Tag simuliert.'],
firedEvents: new Set(),
lastInterruptDay: -999, // letzter Tag mit Ereignis-/Entscheidungskarte (Pacing)
rng: mulberry32(4242), // Wetter-Zufall, deterministisch
};
// Bestehende Infrastruktur späterer Szenen (Boom/Klimawandel)
// möglichst nah am Dorf platziert, damit sie im Startbild sichtbar ist
const anchor = { c: CORE.c0 + 8, r: CORE.r0 + 22 };
const candidates = [];
for (let r = 0; r < MAP_ROWS; r++) {
for (let c = 0; c < MAP_COLS; c++) {
candidates.push({ c, r, d: (c - anchor.c) ** 2 + (r - anchor.r) ** 2 });
}
}
candidates.sort((a, b) => a.d - b.d);
for (const type of S.prebuilt) {
const spot = candidates.find(t => canPlace(state, type, t.c, t.r).ok);
if (spot) place(state, type, spot.c, spot.r);
}
state.money = S.startMoney; // Vorbauten kosten nichts …
state.nature = S.startNature; // … ihre Spuren stecken schon im Startwert
return state;
}
export function zoneOf(tile) {
if (tile.elev <= 1) return 'valley';
if (tile.elev <= 5) return 'mid';
return 'high';
}
// Großzügige, überlappende Bauzonen: Höhe 2 zählt als Tal UND Hang,
// Höhe 6 als Hang UND Hochlage so gibt es überall genug Platz.
const ZONE_RANGE = { valley: [0, 2], mid: [2, 6], high: [6, 99] };
function nearWater(state, c, r, dist) {
for (let rr = Math.max(0, r - dist); rr <= Math.min(MAP_ROWS - 1, r + dist); rr++) {
for (let cc = Math.max(0, c - dist); cc <= Math.min(MAP_COLS - 1, c + dist); cc++) {
if (state.map[rr][cc].terrain === 'water') return true;
}
}
return false;
}
// Prüft Platzierung; gibt {ok, reason} zurück, damit die UI (und die
// Telemetrie) den Ablehnungsgrund kennt didaktisch wertvoll.
export function canPlace(state, type, c, r) {
const def = BUILDINGS[type];
const tile = state.map[r]?.[c];
if (!def || !tile) return { ok: false, reason: 'Außerhalb der Karte.' };
if (!inCore(c, r)) return { ok: false, reason: 'Hier ist unwegsames Hochgebirge im Tal bauen.' };
if (!state.unlocked.has(type)) return { ok: false, reason: 'Noch nicht freigeschaltet.' };
if (tile.building) return { ok: false, reason: 'Hier steht schon ein Gebäude.' };
if (tile.terrain === 'rock') return { ok: false, reason: 'Auf Fels kann nicht gebaut werden.' };
if (tile.terrain === 'water') return { ok: false, reason: 'Im Fluss kann nicht gebaut werden.' };
if (tile.terrain === 'village') return { ok: false, reason: 'Hier steht das alte Dorf.' };
if (tile.road) return { ok: false, reason: 'Auf der Straße kann nicht gebaut werden.' };
if (r < state.stageRow) return { ok: false, reason: 'Die Hochlagen sind noch nicht erschlossen.' };
if (state.money < def.cost) return { ok: false, reason: 'Nicht genug Geld.' };
if (def.zone !== 'any') {
const [lo, hi] = ZONE_RANGE[def.zone];
// Lift/Gondel wirken wie eine eigene Basis: in ihrem Umkreis (4 Felder)
// darf auch am Gebirge alles gebaut werden (außer Bus & Bad).
const nearLift = ['lift', 'gondola', 'restaurant', 'hotel', 'staffhouse'].includes(type)
&& state.buildings.some(b =>
(b.type === 'lift' || b.type === 'gondola') && dist(b, { c, r }) <= 4);
if (!nearLift && (tile.elev < lo || tile.elev > hi)) {
const where = def.zone === 'valley' ? 'im Tal (unten)' : 'am Hang (weiter oben)';
return { ok: false, reason: `${def.name} kann nur ${where} gebaut werden oder im Umkreis eines Lifts.` };
}
}
if (type === 'snow' && !nearWater(state, c, r, 3)) {
return { ok: false, reason: 'Beschneiung braucht Wasser näher am Fluss bauen!' };
}
if (type === 'badesee' && !nearWater(state, c, r, 2)) {
return { ok: false, reason: 'Der Badesee braucht Wasser näher am Fluss bauen!' };
}
if ((type === 'bikepark' || type === 'platform')
&& !state.buildings.some(b =>
(b.type === 'lift' || b.type === 'gondola') && dist(b, { c, r }) <= 4)) {
return { ok: false, reason: `${def.name} braucht eine Bahn in der Nähe (max. 4 Felder) sie bringt die Gäste hinauf.` };
}
if (type === 'access') {
let nearRoad = false;
for (let rr = r - 1; rr <= r + 1 && !nearRoad; rr++) {
for (let cc = c - 1; cc <= c + 1; cc++) {
if (state.map[rr]?.[cc]?.road) { nearRoad = true; break; }
}
}
if (!nearRoad) return { ok: false, reason: 'Die Bushaltestelle braucht eine Straße direkt neben der Talstraße bauen!' };
}
return { ok: true };
}
export function place(state, type, c, r) {
const def = BUILDINGS[type];
const tile = state.map[r][c];
const wasForest = tile.terrain === 'forest';
tile.building = type;
state.buildings.push({ type, c, r, alt: tile.elev });
state.money -= def.cost;
// Bauen kostet Naturwert Rodung von Wald deutlich mehr
state.nature = Math.max(15, state.nature - (wasForest ? 5 : 2));
if (def.pisteLen) {
// Piste entsteht hangaufwärts über der Talstation (Lift & Gondel)
for (let i = 1; i <= def.pisteLen; i++) {
const t = state.map[r - i]?.[c];
if (t && t.terrain !== 'water') {
state.pistes.push({ c, r: r - i, owner: { c, r } });
}
}
}
if (def.trailLen) {
// Bike-Trails schlängeln sich talwärts unter der Basisstation (Bikepark).
// Sichtbar groß, aber nur 1 Feld Gebäude und sie kosten extra Natur.
let cc = c;
for (let i = 1; i <= def.trailLen; i++) {
cc += (i % 2 ? 1 : -1);
const t = state.map[r + i]?.[Math.max(1, Math.min(MAP_COLS - 2, cc))];
if (t && t.terrain !== 'water' && !t.building) {
state.trails.push({ c: t.c, r: t.r, owner: { c, r } });
}
}
state.nature = Math.max(15, state.nature - 2);
}
return { wasForest };
}
export function demolish(state, c, r) {
const tile = state.map[r][c];
if (!tile.building) return { ok: false, reason: 'Hier steht kein Gebäude.' };
const def = BUILDINGS[tile.building];
const refund = Math.round(def.cost * 0.3);
state.money += refund;
state.nature = Math.min(100, state.nature + 1);
state.buildings = state.buildings.filter(b => !(b.c === c && b.r === r));
state.pistes = state.pistes.filter(p => !(p.owner.c === c && p.owner.r === r));
state.trails = state.trails.filter(t => !(t.owner.c === c && t.owner.r === r));
const name = def.name;
tile.building = null;
tile.level = null;
return { ok: true, refund, name };
}
// Jede Ausbaustufe verstärkt die Kennwerte um +50 % (Stufe I/II/III)
export const MAX_LEVEL = 4;
export const UPGRADABLE = Object.keys(BUILDINGS);
function effVal(b, key) {
const v = BUILDINGS[b.type][key] || 0;
return Math.round(v * (1 + 0.5 * ((b.level || 1) - 1)));
}
function dist(a, b) {
return Math.max(Math.abs(a.c - b.c), Math.abs(a.r - b.r));
}
function sum(state, key) {
return state.buildings.reduce((a, b) => a + effVal(b, key), 0);
}
// Wie stark ist ein Gebäude in dieser Saison in Betrieb? (0.4 = Sparflamme)
const WINTER_ONLY = ['lift', 'gondola', 'snow'];
const SUMMER_ONLY = ['badesee', 'sled', 'trail',
'bikepark', 'ropes', 'bikerental', 'platform', 'playground'];
// Ist an einem Lift auf Höhe `alt` genug Schnee zum Skifahren?
// Höher gelegene Lifte behalten länger Schnee (Rückzug von unten nach oben).
export function liftSkiable(state, alt) {
return state.snow + (((alt ?? 3) - 3) * 5) > 10;
}
export function seasonFactor(type, season) {
const winter = season === 'Winter';
if (WINTER_ONLY.includes(type)) return winter ? 1 : 0.4;
if (SUMMER_ONLY.includes(type)) return winter ? 0.4 : 1;
return 1; // Ganzjahresbetriebe (Hotel, Gasthaus, Bus, Personalhaus)
}
// Voller Bericht zu EINEM Gebäude: Werte, Auslastung, Tageskosten, Nutzen.
// Grundlage der Inspektions-/Ausbaukarte (Lernprogramm: alles sichtbar).
export function buildingReport(state, c, r) {
const b = state.buildings.find(x => x.c === c && x.r === r);
if (!b) return null;
const def = BUILDINGS[b.type];
const level = b.level || 1;
const sf = seasonFactor(b.type, state.season);
const dailyUpkeep = Math.round(effVal(b, 'upkeep') * sf);
const dailyWage = Math.round(effVal(b, 'staff') * sf) * state.scenario.wage;
const lines = [];
if (def.cap) lines.push(`Förderleistung: ${effVal(b, 'cap')} Gäste/Tag`);
if (def.beds) lines.push(`Betten: ${effVal(b, 'beds')}`);
if (def.access) lines.push(`Anreise: +${effVal(b, 'access')} Gäste/Tag`);
if (def.housing) lines.push(`Wohnraum: ${effVal(b, 'housing')} Angestellte`);
if (def.summerCap) lines.push(`Sommergäste: bis ${effVal(b, 'summerCap')}/Tag`);
if (def.spendBonus) lines.push(`Mehrumsatz: +${effVal(b, 'spendBonus')} €/Gast`);
if (def.snowPower) lines.push(`Beschneiung: +${effVal(b, 'snowPower').toFixed(1)} cm/Tag`);
if (def.boost) lines.push(`Verstärker: Bikepark & Panoramaweg im Umkreis +${Math.round(def.boost * 100)} % Gäste`);
if (def.trailLen) lines.push(`Flowtrails: ${def.trailLen} Abschnitte talwärts`);
// Aktuelle Probleme dieses Gebäudes (erscheinen im Klick-Dialog)
const problems = [];
if (b.underConstruction)
problems.push('🏗️ Wird gerade umgebaut gleich ist die neue Stufe fertig.');
if (def.staff > 0 && state.staffNeeded > state.staffAvailable)
problems.push('👷 Personalmangel: läuft nur mit halber Kraft. Ein Personalhaus schafft Wohnraum.');
if ((b.type === 'lift' || b.type === 'gondola') && state.season === 'Winter'
&& !liftSkiable(state, b.alt ?? 3))
problems.push('❄️ Zu wenig Schnee zum Skifahren kaum Skigäste. Höher gelegen ist schneesicherer, eine Beschneiung hilft.');
if (sf < 1)
problems.push('💤 Nebensaison: ruht gerade die laufenden Kosten laufen (reduziert) weiter.');
return {
type: b.type, name: def.name, emoji: def.emoji, level,
alt: b.alt ?? 3,
dailyUpkeep, dailyWage,
dailyCost: dailyUpkeep + dailyWage,
sparflamme: sf < 1,
stats: lines,
problems,
knowledge: KNOWLEDGE[b.type] || [],
canUpgrade: level < MAX_LEVEL && !b.underConstruction,
nextCost: level < MAX_LEVEL ? upgradeCost(b.type, level) : null,
};
}
export function upgradeCost(type, level) {
// Ausbaukosten steigen je Stufe: I→II 50 %, II→III 75 %, III→IV 100 %
return Math.round(BUILDINGS[type].cost * (0.25 + 0.25 * level));
}
export function upgrade(state, c, r) {
const tile = state.map[r]?.[c];
const b = state.buildings.find(x => x.c === c && x.r === r);
if (!tile?.building || !b) return { ok: false, reason: 'Hier steht kein Gebäude.' };
const def = BUILDINGS[b.type];
const level = b.level || 1;
if (level >= MAX_LEVEL) return { ok: false, reason: 'Schon auf Ausbaustufe III (Maximum).' };
if (b.underConstruction) return { ok: false, reason: 'Wird bereits umgebaut bitte warten.' };
const cost = upgradeCost(b.type, level);
if (state.money < cost) return { ok: false, reason: `Ausbau kostet ${cost.toLocaleString('de-AT')} nicht genug Geld.` };
state.money -= cost;
// Sichtbarer Umbau: der Renderer animiert ~10 s Bauzeit und schaltet dann die
// neue Stufe scharf (setzt b.level/tile.level). Bis dahin gilt die alte Stufe.
b.underConstruction = true;
b.constructTo = level + 1;
state.nature = Math.max(15, state.nature - 1);
return { ok: true, cost, name: def.name, level: level + 1, building: true };
}
export function count(state, type) {
return state.buildings.filter(b => b.type === type).length;
}
export const DAYS_PER_YEAR = 120;
const MONTHS = ['Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
'Jänner', 'Februar', 'März', 'April', 'Mai', 'Juni'];
// Saison je Monat: Skisaison (Winter) von Oktober bis März bis Ostern
const MONTH_SEASON = ['Sommer', 'Sommer', 'Herbst', 'Winter', 'Winter', 'Winter',
'Winter', 'Winter', 'Winter', 'Frühling', 'Sommer', 'Sommer'];
const MONTH_TEMP = [22, 21, 15, 7, 2, -2, -4, -3, 1, 9, 15, 19];
// day ist hier 0-basiert (Aufrufer übergeben state.day - 1)
export function dateOfDay(day) {
const d = ((day % DAYS_PER_YEAR) + DAYS_PER_YEAR) % DAYS_PER_YEAR;
const monthIdx = Math.floor(d / 10);
return {
monthIdx,
month: MONTHS[monthIdx],
dayInMonth: (d % 10) * 3 + 1, // 1., 4., 7., … 28. wie ein Kalender
};
}
export function seasonOfDay(day) {
return MONTH_SEASON[dateOfDay(day).monthIdx];
}
// Wetter eines Tages deterministisch aus der Tagesnummer berechnet,
// damit die 3-Tage-Vorhersage exakt stimmt und Tests reproduzierbar sind.
export function weatherOfDay(day, S) {
const season = seasonOfDay(day - 1);
const { monthIdx } = dateOfDay(day - 1);
const yearIdx = Math.floor((day - 1) / DAYS_PER_YEAR);
const warmChance = Math.min(0.9,
S.warmWinterChance + (S.warmWinterGrow || 0) * yearIdx);
const warmWinter = mulberry32(yearIdx * 7919 + 13)() < warmChance;
const r1 = mulberry32(day * 31 + 7)();
const r2 = mulberry32(day * 17 + 3)();
const temp = Math.round(MONTH_TEMP[monthIdx]
+ (season === 'Winter' && warmWinter ? 6 : 0)
+ (S.meltExtra ? 1 : 0)
+ (r1 - 0.5) * 8);
let kind = '☀️';
let snowfall = 0;
if (temp <= 1) {
if (r2 < (warmWinter ? 0.25 : 0.55)) {
kind = '🌨️';
snowfall = 4 + r2 * 18;
} else kind = r2 < 0.8 ? '⛅' : '☀️';
} else if (r2 < (season === 'Winter' ? 0.35 : 0.2)) kind = '🌧️';
else kind = r2 < 0.55 ? '⛅' : '☀️';
return { temp, kind, snowfall, warmWinter, season };
}
export function simulateDay(state) {
const S = state.scenario;
state.day += 1;
state.season = seasonOfDay(state.day - 1);
state.year = S.startYear + Math.floor((state.day - 1) / DAYS_PER_YEAR);
const date = dateOfDay(state.day - 1);
state.dateStr = `${date.dayInMonth}. ${date.month} ${state.year}`;
const rep = [];
const winter = state.season === 'Winter';
// --- Wetter & Schnee (temperaturbasiert, mit echter Vorhersage) ---
const w = weatherOfDay(state.day, S);
state.temp = w.temp;
state.weatherToday = w.kind;
state.weather.warmWinter = w.warmWinter;
state.forecast = [1, 2, 3].map(i => weatherOfDay(state.day + i, S));
state.snow += w.snowfall;
if (w.temp > 2) state.snow -= (w.temp - 2) * 0.5; // Schmelze über 2 °C
let cannonCost = 0;
let cannonActive = 0;
// Beschneiung springt an, wenn Schnee knapp wird braucht aber Kälte!
if (winter && state.snow < 45 && w.temp <= 2) {
cannonActive = count(state, 'snow');
state.snow += sum(state, 'snowPower'); // Stufe II/III schneit mehr
cannonCost = cannonActive * S.snowCannonRun;
}
state.snow = Math.max(0, Math.min(100, state.snow));
// --- Personal (Winterbetriebe brauchen im Sommer weniger Personal) ---
state.staffNeeded = Math.round(state.buildings.reduce((a, b) =>
a + effVal(b, 'staff') * seasonFactor(b.type, state.season), 0));
// Lage zählt: Ein Personalhaus weit weg von den Betrieben (>4 Felder)
// wirkt nur zu zwei Dritteln
const workplaces = state.buildings.filter(b => (BUILDINGS[b.type].staff || 0) > 0);
let housing = 0;
let farHouses = 0;
for (const b of state.buildings) {
if (b.type !== 'staffhouse') continue;
const near = !workplaces.length || workplaces.some(wp => dist(b, wp) <= 6);
if (!near) farHouses++;
housing += Math.round(effVal(b, 'housing') * (near ? 1 : 2 / 3));
}
state.staffAvailable = S.baseStaffHousing + housing;
const staffFactor = state.staffNeeded === 0
? 1 : Math.min(1, state.staffAvailable / state.staffNeeded);
const staffWorking = Math.min(state.staffNeeded, state.staffAvailable);
// --- Kapazitäten ---
const access = S.baseAccess + sum(state, 'access');
// Lage zählt: Höher gebaute Bahnen sind schneesicherer (+5 cm gefühlter
// Schnee je Höhenstufe über 3), tief gebaute verlieren früher
const bahnen = state.buildings.filter(b => b.type === 'lift' || b.type === 'gondola');
let liftCap = 0;
let effLiftCap = 0;
for (const b of bahnen) {
const cap = effVal(b, 'cap');
liftCap += cap;
const okSnow = Math.max(0, Math.min(1, (state.snow + ((b.alt ?? 3) - 3) * 5) / 28));
effLiftCap += cap * okSnow;
}
effLiftCap = Math.round(effLiftCap);
// Skiverbund: nahe Bahnen (≤4 Felder) teilen sich die Gäste → attraktiver
let verbund = 0;
for (let i = 0; i < bahnen.length; i++) {
for (let j = i + 1; j < bahnen.length; j++) {
if (dist(bahnen[i], bahnen[j]) <= 4) verbund++;
}
}
verbund = Math.min(2, verbund);
const liftCount = bahnen.length;
// Sommer-Kapazität: Fahrradverleih verstärkt Bikepark & Panoramaweg im
// Umkreis von 4 Feldern (+25 % Gäste) Komplementärangebot, keine eigene
// Attraktion (siehe QUELLEN.md, Wertschöpfungskette).
let summerCap = 0;
const rentals = state.buildings.filter(b => b.type === 'bikerental');
for (const b of state.buildings) {
const base = effVal(b, 'summerCap');
if (!base) continue;
const boosted = (b.type === 'bikepark' || b.type === 'trail')
&& rentals.some(x => dist(x, b) <= 4);
summerCap += Math.round(base * (boosted ? 1 + (BUILDINGS.bikerental.boost || 0.25) : 1));
}
const terrainCap = winter
? effLiftCap
: 80 + liftCount * 60 + summerCap;
// --- Nachfrage ---
const natureFactor = 0.5 + state.nature / 200; // 0.575 … 1.0
const satFactor = 0.6 + state.satisfaction / 250; // 0.6 … 1.0
const snowFactor = winter
? Math.max(0.15, liftCap > 0 ? effLiftCap / liftCap : Math.min(1, state.snow / 30))
: 1;
const verbundFactor = winter ? 1 + 0.08 * verbund : 1;
const priceFactor = winter
? Math.max(0.45, Math.min(1.3, 1.25 - (state.price - 15) * 0.025))
: 1;
// Bekanntheit wächst langsam schneller, wenn die Gäste zufrieden sind
state.awareness = Math.min(1,
state.awareness + 0.006 * (state.satisfaction / 70));
// Tageswetter: Regen hält Gäste ab, Sonne lockt
const weatherFactor = w.kind === '🌧️' ? 0.7 : w.kind === '☀️' ? 1.08 : 1;
// Sommer-Trend (Szene 3): Sommernachfrage wächst über die Jahre aber nur,
// wenn das Tal konsequent Sommerangebote aufbaut. Reale Vorlage: Serfaus-
// Fiss-Ladis +95 % Sommernächtigungen in 10 Jahren (≈ +7 %/Jahr, QUELLEN.md).
let trendFactor = 1;
if (!winter && S.summerTrend) {
const years = (state.day - 1) / DAYS_PER_YEAR;
const summerOffers = state.buildings
.filter(b => (BUILDINGS[b.type].summerCap || 0) > 0).length;
trendFactor = 1 + S.summerTrend * years * Math.min(1, summerOffers / 3);
}
let demand = S.demand[state.season] * state.awareness * natureFactor
* satFactor * snowFactor * priceFactor * weatherFactor * verbundFactor * trendFactor;
if (winter && liftCap === 0) demand = 20; // eslint-hint: liftCount oben // ohne Lift kein Skigebiet
// Ganz am Anfang: ohne Gasthaus bleibt das Tal unbekannt
if (state.buildings.length === 0) demand = Math.min(demand, 15);
const guests = Math.round(Math.min(demand, access, terrainCap) * staffFactor);
state.guestsToday = Math.max(0, guests);
// Engpass-Diagnose: WARUM kamen weniger, als wollten? (Transparenz!)
state.demandToday = Math.round(demand);
let bottleneck = null;
if (staffFactor < 1) bottleneck = 'Personal fehlt';
else if (state.demandToday > guests + 2) {
if (access <= Math.min(state.demandToday, terrainCap)) bottleneck = 'Anreise (Bus)';
else bottleneck = winter ? 'Liftplätze' : 'Wege & Attraktionen';
}
state.bottleneckStreak = bottleneck && bottleneck === state.bottleneck
? state.bottleneckStreak + 1 : (bottleneck ? 1 : 0);
state.bottleneck = bottleneck;
state.stats.maxGuests = Math.max(state.stats.maxGuests, state.guestsToday);
if (state.season === 'Sommer') {
state.stats.maxSummerGuests = Math.max(state.stats.maxSummerGuests, state.guestsToday);
}
// abgelaufene Feste/Rennen aufräumen
if (state.race && state.day > state.race.until) state.race = null;
if (state.fest && state.day > state.fest.until) state.fest = null;
// --- Geld ---
let spendBonus = Math.min(15, sum(state, 'spendBonus'));
// Lage zählt: Gasthaus im Einflusskreis (3 Felder) von Bahn/Attraktion/
// Hotel → deren Gäste kehren gleich mit ein (+2 €/Gast je Synergie)
const restaurants = state.buildings.filter(b => b.type === 'restaurant');
const magnets = state.buildings.filter(b =>
['lift', 'gondola', 'badesee', 'sled', 'trail', 'hotel'].includes(b.type));
let synergies = 0;
for (const m of magnets) {
if (restaurants.some(rst => dist(rst, m) <= 5)) synergies++;
}
const synergyBonus = Math.min(6, synergies * 2);
spendBonus += synergyBonus;
// Talnahe Bahn (Höhe ≤3): Gäste sind schneller dort und konsumieren mehr
const lowLift = winter && bahnen.some(b => (b.alt ?? 3) <= 3);
const lowLiftBonus = lowLift ? 2 : 0;
const beds = sum(state, 'beds');
const overnight = Math.min(Math.round(guests * 0.5), beds);
state.overnightToday = overnight;
const perGuest = (winter ? state.price : S.summerSpend) + spendBonus + lowLiftBonus;
// Außerhalb des Winters kaufen viele Gäste eine Bergfahrt mit dem Lift
const liftRiders = (!winter && liftCount > 0)
? Math.round(guests * 0.4) : 0;
const liftTicketIncome = liftRiders * 8;
const income = guests * perGuest + overnight * S.overnightSpend + liftTicketIncome;
// Betriebskosten je Gast (Liftstrom, Reinigung, Wäsche …): 55 % vom Umsatz
const opsCost = Math.round(income * 0.58);
const upkeep = Math.round(state.buildings.reduce((a, b) =>
a + effVal(b, 'upkeep') * seasonFactor(b.type, state.season), 0));
const wages = staffWorking * S.wage;
state.money = Math.round(state.money + income - opsCost - upkeep - wages - cannonCost);
state.lastNet = Math.round(income - opsCost - upkeep - wages - cannonCost);
state.lossStreak = state.lastNet < 0 ? state.lossStreak + 1 : 0;
// --- Zufriedenheit: Gedränge, Gastro, Preis, Schnee ---
const crowding = terrainCap > 0 ? guests / terrainCap : 0;
let satTarget = 70;
if (count(state, 'restaurant') > 0) satTarget += 12;
satTarget += Math.min(8, sum(state, 'satBonus'));
if (crowding > 0.9) satTarget -= 20;
if (staffFactor < 1) satTarget -= 15;
if (winter) {
satTarget -= (state.price - 25) * 0.6;
if (state.snow < 15) satTarget -= 10;
}
satTarget += (state.nature - 70) / 4;
state.satisfaction = Math.round(
Math.min(100, Math.max(10, state.satisfaction + (satTarget - state.satisfaction) * 0.08))
);
// --- Natur erholt sich langsam ---
state.nature = Math.min(100,
state.nature + 0.04 - state.buildings.length * 0.004);
// --- Umbau-Fertigstellung (Fallback) ---
// Primär animiert der Renderer den Umbau (10 s Spielzeit = 5 Tage bei 1×)
// und schaltet die Stufe scharf. Headless (Tests) übernimmt das Modell
// nach 6 Tagen einen Tag später, damit im Spiel der Renderer gewinnt.
for (const b of state.buildings) {
if (!b.underConstruction) continue;
b._cDays = (b._cDays || 0) + 1;
if (b._cDays >= 6) {
b.level = b.constructTo;
const t = state.map[b.r]?.[b.c];
if (t) t.level = b.constructTo;
delete b.underConstruction; delete b.constructTo; delete b._cDays; delete b._cStart;
}
}
// --- Notbremse Überschuldung ---
// Ab 15.000 € Schulden verkauft die Bank das Gebäude mit den höchsten
// laufenden Kosten (30 % Erlös), max. alle 30 Tage. Verhindert die endlose
// Schuldenspirale des "alles sofort bauen"-Spielstils didaktisch sichtbar.
if (state.money < -15000 && state.buildings.length > 1
&& state.day - (state.lastForcedSaleDay ?? -999) >= 15) {
let worst = null;
for (const b of state.buildings) {
const up = effVal(b, 'upkeep') + effVal(b, 'staff') * S.wage;
if (!worst || up > worst.up) worst = { b, up };
}
if (worst) {
const res = demolish(state, worst.b.c, worst.b.r);
if (res.ok) {
state.lastForcedSaleDay = state.day;
state.forcedSale = { name: res.name, refund: res.refund, day: state.day };
}
}
}
// --- Ziele ---
if (!state.goalsDone && S.goals.every(g => g.check(state))) {
state.goalsDone = true;
}
if (!state.goalsDone && state.day === S.deadlineDay) {
state.deadlineMissed = true;
}
// --- Verlauf & Bericht ---
state.history.money.push(state.money);
state.history.guests.push(state.guestsToday);
if (state.history.money.length > 240) {
state.history.money.shift();
state.history.guests.shift();
}
// --- Strukturierter Tagesbericht (wird in ui.js als Tabelle gerendert) ---
const nachfrageFaktoren = [
{ name: 'Bekanntheit', mult: state.awareness },
{ name: 'Natur', mult: natureFactor },
{ name: 'Zufriedenheit', mult: satFactor },
{ name: 'Wetter', mult: weatherFactor },
];
if (winter) {
nachfrageFaktoren.push({ name: 'Schnee', mult: snowFactor });
nachfrageFaktoren.push({ name: 'Skipass-Preis', mult: priceFactor });
if (verbund > 0) nachfrageFaktoren.push({ name: 'Skiverbund', mult: verbundFactor });
}
const einnahmen = [
{ label: `Gäste (${guests} × ${perGuest} €)`, amount: guests * perGuest },
];
if (overnight) einnahmen.push({ label: `Übernachtungen (${overnight} × ${S.overnightSpend} €)`, amount: overnight * S.overnightSpend });
if (liftRiders) einnahmen.push({ label: `Bergfahrten (${liftRiders} × 8 €)`, amount: liftTicketIncome });
const ausgaben = [
{ label: 'Betrieb (58 % vom Umsatz)', amount: opsCost },
{ label: 'Instandhaltung', amount: upkeep },
{ label: 'Löhne', amount: wages },
];
if (cannonCost) ausgaben.push({ label: 'Beschneiung', amount: cannonCost });
const hinweise = [];
if (winter && count(state, 'snow') > 0 && !cannonActive && state.snow < 45 && w.temp > 2)
hinweise.push('Zu warm für die Beschneiung (braucht ≤ 2 °C).');
if (winter && liftCap > 0 && effLiftCap < liftCap)
hinweise.push(`Höhenlage: nur ${effLiftCap} von ${liftCap} Liftplätzen sind schneesicher höher gebaute Bahnen halten länger.`);
if (synergyBonus > 0) hinweise.push(`Gasthaus-Synergie: ${synergies} Nachbarschaft(en) → +${synergyBonus} €/Gast.`);
if (lowLiftBonus > 0) hinweise.push('Talnahe Bahn: Gäste konsumieren +2 €/Gast.');
if (farHouses > 0) hinweise.push(`${farHouses} Personalhaus/-häuser stehen weiter als 6 Felder weg (nur ⅔ Wirkung).`);
if (staffFactor < 1) hinweise.push(`Nur ${state.staffAvailable} von ${state.staffNeeded} Stellen besetzt Betriebe auf ${Math.round(staffFactor * 100)} %.`);
state.reportData = {
date: state.dateStr,
season: state.season,
weather: `${w.kind} ${w.temp} °C`,
snow: winter ? Math.round(state.snow) : null,
warmWinter: state.weather.warmWinter,
demandBase: Math.round(S.demand[state.season]),
demandFactors: nachfrageFaktoren,
demandResult: Math.round(demand),
access, terrainCap, capLabel: winter ? 'Liftplätze' : 'Wege & Attraktionen',
bottleneck, bottleneckLost: Math.max(0, state.demandToday - guests),
guests, overnight,
einnahmen, ausgaben,
income, expenses: opsCost + upkeep + wages + cannonCost,
net: state.lastNet,
hinweise,
};
// Kompakte Textfassung (Fallback / Log)
state.report = [`${state.dateStr} ${guests} Gäste, Netto ${state.lastNet}`];
return state;
}
+77
View File
@@ -0,0 +1,77 @@
// Hintergrundmusik-Player: spielt die Lieder aus assets/music/
// (HTML5-Audio). Die Original-Dateinamen sind kryptisch hier bekommen
// sie stimmige Titel fürs Tal.
const DIR = 'assets/music/';
export const TRACKS = [
{ file: 'Geluk op de Piste.mp3', title: 'Glück auf der Piste' },
{ file: 'Sur les skis en Autriche.mp3', title: 'Auf Skiern durch Österreich' },
{ file: 'ski rock 1.mp3', title: 'Pistenrock' },
{ file: 'När jag var liten i Österrike SW.mp3', title: 'Heimweh nach dem Tal' },
{ file: 'När jag var liten i Österrike SW (1).mp3', title: 'Kindheit in den Bergen' },
{ file: 'När jag var liten i Österrike SW (2).mp3', title: 'Almsommer' },
{ file: 'sweet cold tunes.mp3', title: 'Schneegestöber' },
{ file: 'sweet cold tunes (1).mp3', title: 'Erster Schnee' },
{ file: 'sweet cold tunes 2.mp3', title: 'Gipfelmelodie' },
{ file: 'sweet cold tunes 3.mp3', title: 'Winterruhe' },
{ file: 'sweet cold tunes 3 (1).mp3', title: 'Spuren im Pulverschnee' },
{ file: 'sweet cold tunes 3 (2).mp3', title: 'Hüttenabend' },
{ file: 'sweet cold tunes 3 (3).mp3', title: 'Talstation um sieben' },
{ file: 'sweet cold tunes 3 (4).mp3', title: 'Nordwandlicht' },
{ file: 'sweet cold tunes 3 (5).mp3', title: 'Seilbahnschweben' },
{ file: 'sweet cold tunes 3 (6).mp3', title: 'Föhn überm Grat' },
];
export const player = {
el: null, // HTMLAudioElement
idx: 0,
playing: false,
onChange: null, // UI-Callback bei jedem Zustandswechsel
init() {
this.el = new Audio();
this.el.volume = Number(localStorage.getItem('tt_volume') ?? 0.35);
this.idx = Number(localStorage.getItem('tt_track') ?? 0) % TRACKS.length;
this.el.addEventListener('ended', () => this.next());
this._load();
},
_load() {
this.el.src = DIR + encodeURIComponent(TRACKS[this.idx].file);
localStorage.setItem('tt_track', this.idx);
},
_notify() { this.onChange?.(); },
current() { return TRACKS[this.idx]; },
play() {
this.el.play().then(() => {
this.playing = true;
this._notify();
}).catch(() => { /* Autoplay blockiert Nutzer startet manuell */ });
},
pause() {
this.el.pause();
this.playing = false;
this._notify();
},
toggle() { this.playing ? this.pause() : this.play(); },
next() { this.select((this.idx + 1) % TRACKS.length); },
prev() { this.select((this.idx - 1 + TRACKS.length) % TRACKS.length); },
select(i) {
this.idx = i % TRACKS.length;
this._load();
if (this.playing) this.play(); else this._notify();
},
setVolume(v) {
this.el.volume = v;
localStorage.setItem('tt_volume', v);
},
};
+36
View File
@@ -0,0 +1,36 @@
// 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 = 'tourismustal_telemetry';
export const telemetry = {
events: [],
_dayProvider: null,
init(dayProvider) {
this._dayProvider = dayProvider;
this.log('session_start', { ua: navigator.userAgent });
},
log(type, data = {}) {
this.events.push({
ts: Date.now(),
day: this._dayProvider ? this._dayProvider() : 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 */ }
},
// Später: POST an das GeoGraSim-Backend (PHP/DB)
flush() {
return this.events;
},
};
+663
View File
@@ -0,0 +1,663 @@
// UI-Logik: Kennzahlen, Bau-Menü (inkl. Abriss), Event-Karten (mit
// Auto-Pause), Meldungs-Log, Graphen, Skipass-Regler, Ziele, Toasts,
// Onboarding, Szenenwahl und Topbar-Buttons.
import { BUILDINGS } from './data.js';
import { seasonOfDay, DAYS_PER_YEAR } from './model.js';
import { telemetry } from './telemetry.js';
import { audio } from './audio.js';
import { player, TRACKS } from './player.js';
const $ = id => document.getElementById(id);
export class UI {
constructor(state, api) {
this.state = state;
this.api = api; // {setSpeed, getSpeed, setBuildType}
this.cardQueue = [];
this.cardOpen = false;
this.speedBeforeCard = 1;
this.collectedCards = [];
this.activeBuild = null;
this.toastTimer = null;
$('sceneName').textContent = state.scenario.name;
this._buildMenu();
this._bindTopbar();
this._initPlayer();
this._bindOverlays();
this._bindPrice();
this._buildGoals();
this.updateKPIs();
}
// ---------- Bau-Menü (zeigt nur Freigeschaltetes kleine Schritte) ----------
_buildMenu() {
this.rebuildMenu();
}
rebuildMenu() {
const wrap = $('buildItems');
wrap.innerHTML = '';
for (const [type, def] of Object.entries(BUILDINGS)) {
if (!this.state.unlocked.has(type)) continue;
wrap.appendChild(this._buildItem(type, def.emoji, def.name, def.desc,
`${def.cost.toLocaleString('de-AT')}`));
}
// Hinweis: Ausbauen & Abreißen laufen jetzt über Klick aufs Gebäude
// aktive Auswahl wiederherstellen
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();
}
// Kennzahlen & Panels erscheinen erst, wenn sie relevant werden
applyProgress() {
const s = this.state;
const hasLift = s.unlocked.has('lift') && s.buildings.some(b => b.type === 'lift');
$('kpiRowSnow').classList.toggle('hidden', !s.unlocked.has('lift'));
$('priceRow').classList.toggle('hidden', !hasLift);
$('priceSlider').classList.toggle('hidden', !hasLift);
$('goals').classList.toggle('hidden', !hasLift);
$('graphs').classList.toggle('hidden', s.buildings.length < 2);
$('kpiRowNature').classList.toggle('hidden', s.buildings.length < 2);
$('kpiRowStaff').classList.toggle('hidden', !s.unlocked.has('staffhouse') && s.staffNeeded <= s.staffAvailable);
$('quest').classList.toggle('hidden', s.buildings.length < 1 || this.questDone === true);
}
_buildItem(type, emoji, name, desc, cost) {
const el = document.createElement('div');
el.className = 'build-item';
el.dataset.type = type;
el.innerHTML = `
<span class="emoji">${emoji}</span>
<span class="info"><b>${name}</b><small>${desc}</small></span>
<span class="cost">${cost}</span>`;
el.addEventListener('click', () => this._toggleBuild(type, el));
return el;
}
_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');
this.toast(type === 'demolish'
? '🧨 Abriss gewählt tippe auf ein Gebäude.'
: type === 'upgrade'
? '⬆️ Ausbau gewählt tippe auf ein Gebäude.'
: `${BUILDINGS[type].emoji} ${BUILDINGS[type].name} gewählt tippe auf die Karte zum Bauen.`);
}
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.money < def.cost);
});
}
// ---------- 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 gültige Cockpit um
// (Schüler → /schueler, Lehrperson → /teacher). Standalone: Fallback.
window.location.href='../../schueler.html';
});
$('btnSound').addEventListener('click', e => {
const on = audio.toggleSound();
e.currentTarget.classList.toggle('muted', !on);
telemetry.log('audio_toggle', { kind: 'sound', off: !on });
});
$('btnMusic').addEventListener('click', () => {
audio.click();
$('playerPanel').classList.toggle('hidden');
telemetry.log('player_panel', { open: !$('playerPanel').classList.contains('hidden') });
});
$('btnHelp').addEventListener('click', () => { audio.click(); this.showOnboarding(); });
$('btnLog').addEventListener('click', () => { audio.click(); this._openLog(); });
$('btnReport').addEventListener('click', () => {
audio.click();
const cur = this.api.getSpeed();
if (cur > 0) this.speedBeforeCard = cur;
this.showReport();
telemetry.log('report_open');
});
}
// ---------- Musik-Player ----------
_initPlayer() {
const sel = $('trackSelect');
TRACKS.forEach((t, i) => {
const o = document.createElement('option');
o.value = i;
o.textContent = `${t.title}`;
sel.appendChild(o);
});
sel.addEventListener('change', () => {
player.select(Number(sel.value));
player.play();
telemetry.log('music', { action: 'select', track: player.current().title });
});
$('plPlay').addEventListener('click', () => { player.toggle(); });
$('plNext').addEventListener('click', () => { player.next(); audio.click(); });
$('plPrev').addEventListener('click', () => { player.prev(); audio.click(); });
const vol = $('plVol');
vol.addEventListener('input', () => player.setVolume(Number(vol.value)));
player.onChange = () => {
$('plPlay').textContent = player.playing ? '⏸' : '▶';
sel.value = player.idx;
$('btnMusic').classList.toggle('muted', !player.playing);
};
}
syncPlayerUI() {
$('plVol').value = player.el ? player.el.volume : 0.35;
player.onChange?.();
}
markSpeed(sp) {
document.querySelectorAll('.speed-btn').forEach(b =>
b.classList.toggle('active', Number(b.dataset.speed) === sp));
}
// ---------- Skipass-Preis ----------
_bindPrice() {
const slider = $('priceSlider');
slider.addEventListener('input', () => {
this.state.price = Number(slider.value);
$('kPrice').textContent = `${this.state.price}`;
});
slider.addEventListener('change', () => {
telemetry.log('price_change', { price: this.state.price });
this.toast(this.state.price >= 35
? '🎟️ Hoher Preis: mehr Geld pro Gast, aber weniger Gäste.'
: this.state.price <= 18
? '🎟️ Niedriger Preis: viele Gäste, aber wenig Einnahmen.'
: '🎟️ Preis angepasst.');
});
}
// ---------- Ziele ----------
_buildGoals() {
document.querySelector('#goals h3').textContent = this.state.scenario.goalsTitle;
const list = $('goalList');
for (const g of this.state.scenario.goals) {
const li = document.createElement('li');
li.id = `goal-${g.id}`;
li.textContent = g.label;
list.appendChild(li);
}
}
updateGoals() {
for (const g of this.state.scenario.goals) {
$(`goal-${g.id}`)?.classList.toggle('done', g.check(this.state));
}
}
// ---------- Kennzahlen ----------
updateKPIs() {
const s = this.state;
$('seasonBadge').textContent = `${s.season} · ${s.year}`;
// Jahreskreis-Marker + Wetter mit Vorhersage
const dayOfYear = ((s.day - 1) % DAYS_PER_YEAR) / DAYS_PER_YEAR;
$('yearMarker').style.left = `${Math.round(dayOfYear * 100)}%`;
if (s.weatherToday) {
$('weatherNow').textContent = `${s.weatherToday} ${s.temp}°`;
$('forecast').textContent = (s.forecast || [])
.map(f => `${f.kind}${f.temp}°`).join(' ');
}
$('kMoney').textContent = `${s.money.toLocaleString('de-AT')}`;
$('kMoney').classList.toggle('warn', s.money < 3000);
$('kGuests').textContent = s.guestsToday;
// Transparenz: WARUM kamen nicht alle? (Engpass-Diagnose aus dem Modell)
const gap = $('kGap');
if (s.bottleneck && s.buildings.length) {
gap.textContent = `⚠️ ${s.demandToday} wollten kommen Engpass: ${s.bottleneck}`;
gap.classList.remove('hidden');
} else {
gap.classList.add('hidden');
}
$('kSnow').textContent = `${Math.round(s.snow)} cm`;
$('kSnow').classList.toggle('warn', s.season === 'Winter' && s.snow < 15);
$('kSat').textContent = `${s.satisfaction} %`;
$('kNature').textContent = `${Math.round(s.nature)} %`;
$('kNature').classList.toggle('warn', s.nature < 45);
$('kStaff').textContent = `${Math.min(s.staffNeeded, s.staffAvailable)} / ${s.staffNeeded}`;
$('kStaff').classList.toggle('warn', s.staffNeeded > s.staffAvailable);
this.refreshBuildMenu();
this.updateGoals();
this.applyProgress();
}
updateClock() {
$('clock').textContent = this.state.dateStr || '';
}
// ---------- Graphen (Geld links, Gäste rechts) ----------
drawGraph() {
this._plot('graphMoney', this.state.history.money, '#4f7f5e',
v => `${v.toLocaleString('de-AT')}`, true);
this._plot('graphCanvas', this.state.history.guests, '#e8892b',
v => `${v}`, false);
}
_plot(id, arr, color, fmt, fill) {
const cv = $(id);
if (!cv) return;
const dpr = window.devicePixelRatio || 1;
const w = cv.clientWidth, h = cv.clientHeight;
if (!w || !h) return;
if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) {
cv.width = Math.round(w * dpr);
cv.height = Math.round(h * dpr);
}
const ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
if (arr.length < 2) return;
// Alles auf die Zeichenfläche begrenzen nichts kann mehr „ausrauschen"
ctx.save();
ctx.beginPath(); ctx.rect(0, 0, w, h); ctx.clip();
const PAD = 8; // fester Rand oben/unten
// Jahreszeiten als blasse Bänder + Jahresgrenzen als Linien
const SEASON_BG = {
Sommer: 'rgba(168,198,95,0.16)',
Herbst: 'rgba(216,160,78,0.16)',
Winter: 'rgba(169,198,216,0.22)',
'Frühling': 'rgba(181,216,160,0.16)',
};
const n = arr.length;
const firstDay = this.state.day - n + 1;
let runStart = 0;
let runSeason = seasonOfDay(firstDay - 1);
for (let i = 1; i <= n; i++) {
const sn = i < n ? seasonOfDay(firstDay + i - 1) : null;
if (sn !== runSeason) {
ctx.fillStyle = SEASON_BG[runSeason];
const x0 = (runStart / (n - 1)) * w;
const x1 = (Math.min(i, n - 1) / (n - 1)) * w;
ctx.fillRect(x0, 0, x1 - x0, h);
runStart = i;
runSeason = sn;
}
const d = firstDay + i - 1;
if (d > firstDay && (d - 1) % DAYS_PER_YEAR === 0) {
const x = ((i - 1) / (n - 1)) * w;
ctx.strokeStyle = 'rgba(47,62,70,0.35)';
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
}
}
ctx.strokeStyle = 'rgba(47,62,70,0.08)';
for (let i = 1; i <= 3; i++) {
const y = (h / 4) * i;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
// Eigene Skala mit etwas Kopf-/Fußraum; 0 bleibt sichtbar, wenn nötig.
let max = Math.max(...arr);
let min = Math.min(...arr, 0);
const pad = (max - min) * 0.12 || 1;
max += pad; min -= (min < 0 ? pad : 0);
const span = max - min || 1;
const yOf = v => {
const y = (h - PAD) - ((v - min) / span) * (h - 2 * PAD);
return Math.max(PAD, Math.min(h - PAD, y));
};
if (min < 0) {
const y0 = yOf(0);
ctx.strokeStyle = 'rgba(163,50,39,0.45)';
ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.moveTo(0, y0); ctx.lineTo(w, y0); ctx.stroke();
ctx.setLineDash([]);
}
const pts = arr.map((v, i) => [(i / (n - 1)) * w, yOf(v)]);
if (fill) {
ctx.beginPath();
ctx.moveTo(pts[0][0], h);
pts.forEach(p => ctx.lineTo(p[0], p[1]));
ctx.lineTo(pts[n - 1][0], h);
ctx.closePath();
ctx.fillStyle = 'rgba(79,127,94,0.12)';
ctx.fill();
}
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
pts.forEach((p, i) => (i === 0 ? ctx.moveTo(p[0], p[1]) : ctx.lineTo(p[0], p[1])));
ctx.stroke();
ctx.lineWidth = 1;
ctx.restore(); // Clip aufheben
// aktueller Wert oben rechts
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'right';
ctx.fillStyle = color;
ctx.fillText(fmt(arr[n - 1]), w - 4, 12);
// dynamische Skala: Höchst- und Tiefstwert (und 0-Linie) links beschriften
const dMax = Math.max(...arr), dMin = Math.min(...arr);
ctx.font = '10px sans-serif';
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(47,62,70,0.5)';
if (dMax !== arr[n - 1]) { // nicht doppelt mit dem Wert oben rechts
ctx.textBaseline = 'top';
ctx.fillText(fmt(dMax), 3, Math.max(1, yOf(dMax)));
}
ctx.textBaseline = 'bottom';
ctx.fillText(fmt(dMin), 3, Math.min(h - 1, yOf(dMin)));
if (min < 0) {
ctx.fillStyle = 'rgba(163,50,39,0.7)';
ctx.textBaseline = 'middle';
ctx.fillText(fmt(0), 3, yOf(0));
}
ctx.textBaseline = 'alphabetic';
}
// ---------- Tagesbericht als Tabelle ----------
showReport() {
const d = this.state.reportData;
if (!d) return;
const eur = v => `${Math.round(v).toLocaleString('de-AT')}`;
const row = (l, r, cls = '') => `<tr class="${cls}"><td>${l}</td><td>${r}</td></tr>`;
let html = `<div class="rp-head"><b>${d.date}</b>`
+ `<span>${d.weather}${d.snow != null ? ` · ❄️ ${d.snow} cm${d.warmWinter ? ' (warm!)' : ''}` : ''}</span></div>`;
// Nachfrage: Basiswert × Faktoren = wollten kommen
html += `<div class="rp-sec">Wer wollte kommen?</div><table class="rp-tbl">`;
html += row(`Grund-Nachfrage (${d.season})`, d.demandBase);
for (const f of d.demandFactors) {
const bad = f.mult < 0.9;
html += `<tr><td class="rp-factor ${bad ? 'bad' : ''}">× ${f.name}</td>`
+ `<td class="rp-factor ${bad ? 'bad' : ''}">×${f.mult.toFixed(2)}</td></tr>`;
}
html += row('= wollten kommen', `${d.demandResult}`, 'sum');
html += `</table>`;
// Kapazität / Engpass
html += `<div class="rp-sec">Wie viele konnten?</div><table class="rp-tbl">`;
html += row('Anreise möglich (Bus &amp; Straße)', d.access);
html += row(d.capLabel, d.terrainCap);
html += row('→ Gäste gekommen', `${d.guests}${d.overnight ? ` (${d.overnight} ü.N.)` : ''}`, 'sum');
html += `</table>`;
if (d.bottleneck) {
html += `<div class="rp-bottleneck">⚠️ Engpass: <b>${d.bottleneck}</b> `
+ `${d.bottleneckLost} Gäste kamen deshalb nicht. Hier lohnt sich Ausbau!</div>`;
}
// Einnahmen / Ausgaben
html += `<div class="rp-sec">Einnahmen</div><table class="rp-tbl">`;
for (const e of d.einnahmen) html += row(e.label, eur(e.amount));
html += row('Summe', eur(d.income), 'sum');
html += `</table>`;
html += `<div class="rp-sec">Ausgaben</div><table class="rp-tbl">`;
for (const e of d.ausgaben) html += row(e.label, ` ${eur(e.amount)}`);
html += row('Summe', ` ${eur(d.expenses)}`, 'sum');
html += `</table>`;
html += `<div class="rp-net ${d.net >= 0 ? 'pos' : 'neg'}">`
+ `<span>Tagesgewinn</span><span>${d.net >= 0 ? '+' : ''}${eur(d.net)}</span></div>`;
for (const h of d.hinweise) html += `<div class="rp-hint">💡 ${h}</div>`;
$('rpBody').innerHTML = html;
this.api.setSpeed(0);
this.markSpeed(0);
$('reportOverlay').classList.remove('hidden');
}
// ---------- Event-Karten ----------
showCard(card) {
this.cardQueue.push(card);
if (!this.cardOpen) this._nextCard();
}
_nextCard() {
const card = this.cardQueue.shift();
if (!card) return;
this.currentCard = card;
this.cardOpen = true;
const cur = this.api.getSpeed();
if (cur > 0) this.speedBeforeCard = cur;
this.api.setSpeed(0); // Auto-Pause, solange die Karte offen ist
this.markSpeed(0);
$('cardIcon').textContent = card.icon;
$('cardTitle').textContent = card.title;
$('cardText').textContent = card.text;
// Entscheidungs-Karte: zwei Buttons, sonst nur „Weiter"
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 {
this.api.setSpeed(this.speedBeforeCard || 1);
this.markSpeed(this.speedBeforeCard || 1);
}
}
// ---------- Gebäude-Inspektion (Klick aufs Gebäude) ----------
showBuildingCard(report, handlers) {
this._biHandlers = handlers;
this._biReport = report;
const roman = ['', 'I', 'II', 'III', 'IV'];
$('biIcon').textContent = report.emoji;
$('biTitle').textContent = report.name;
$('biLevel').textContent = `Ausbaustufe ${roman[report.level]}`
+ (report.alt !== undefined ? ` · Höhenlage ${report.alt}` : '');
const list = $('biStats');
list.innerHTML = '';
for (const s of report.stats) {
const li = document.createElement('li');
li.textContent = s;
list.appendChild(li);
}
$('biCost').innerHTML = `Laufende Kosten: <b>${report.dailyCost.toLocaleString('de-AT')} €/Tag</b> `
+ `(${report.dailyUpkeep} € Instandhaltung + ${report.dailyWage} € Löhne)`
+ (report.sparflamme ? '<br>💤 Läuft in dieser Saison auf Sparflamme reduzierte Kosten.' : '');
// Aktuelle Probleme dieses Gebäudes
const probs = $('biProblems');
if (report.problems && report.problems.length) {
probs.innerHTML = '<div class="bi-sec">⚠️ Das hakt gerade</div>'
+ report.problems.map(p => `<div class="bi-prob">${p}</div>`).join('');
probs.classList.remove('hidden');
} else { probs.innerHTML = ''; probs.classList.add('hidden'); }
// Lehrplan-Wissen (Fachbegriffe im Kontext)
const know = $('biKnowledge');
if (report.knowledge && report.knowledge.length) {
know.innerHTML = '<div class="bi-sec">📚 Gut zu wissen</div>'
+ report.knowledge.map(k => `<div class="bi-know"><b>${k.term}:</b> ${k.text}</div>`).join('');
know.classList.remove('hidden');
} else { know.innerHTML = ''; know.classList.add('hidden'); }
const up = $('biUpgrade');
if (report.canUpgrade) {
const afford = handlers.money >= report.nextCost;
up.textContent = `⬆️ Ausbauen auf Stufe ${roman[report.level + 1]} (${report.nextCost.toLocaleString('de-AT')} €)`;
up.disabled = !afford;
up.classList.remove('hidden');
} else {
up.textContent = '★ Maximale Ausbaustufe erreicht';
up.disabled = true;
up.classList.remove('hidden');
}
this.api.setSpeed(0);
this.markSpeed(0);
$('buildingOverlay').classList.remove('hidden');
telemetry.log('inspect', { type: report.type, level: report.level });
}
_closeBuilding(resume = true) {
$('buildingOverlay').classList.add('hidden');
if (resume) { this.api.setSpeed(this.speedBeforeCard || 1); this.markSpeed(this.speedBeforeCard || 1); }
}
_bindOverlays() {
$('biClose').addEventListener('click', () => { audio.click(); this._closeBuilding(); });
$('biUpgrade').addEventListener('click', () => {
audio.click();
this._biHandlers?.onUpgrade?.(this._biReport);
});
$('biDemolish').addEventListener('click', () => {
audio.click();
this._biHandlers?.onDemolish?.(this._biReport);
this._closeBuilding();
});
$('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');
});
$('rpClose').addEventListener('click', () => {
audio.click();
$('reportOverlay').classList.add('hidden');
this.api.setSpeed(this.speedBeforeCard || 1);
this.markSpeed(this.speedBeforeCard || 1);
});
}
// ---------- Auftrag (Quest) ----------
updateQuest(quest) {
if (!quest) {
$('quest').classList.add('hidden');
this.questDone = true;
return;
}
this.currentQuest = quest;
$('questIcon').textContent = quest.icon;
$('questTitle').textContent = quest.title;
$('questDesc').textContent = quest.desc;
$('questReward').textContent = `Belohnung: ${quest.reward.toLocaleString('de-AT')}`;
}
_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');
li.innerHTML = `${c.icon} <b>${c.title}</b><small>${c.text.split('\n')[0]}</small>`;
li.addEventListener('click', () => {
$('logOverlay').classList.add('hidden');
this.showCard({ ...c, transient: true });
});
list.appendChild(li);
}
$('logOverlay').classList.remove('hidden');
telemetry.log('log_open', { count: this.collectedCards.length });
}
// ---------- Onboarding ----------
showOnboarding() {
this.showCard({
icon: '🏔️', transient: true,
title: 'Willkommen! (1/3) So spielst du',
text: 'Rechts ein Gebäude wählen, dann auf die Karte tippen fertig. '
+ 'Nach dem Bau legst du das Werkzeug automatisch ab.\n\n'
+ 'Oben läuft die Zeit (⏸ bis 4×). Karte bewegen: ziehen · '
+ 'Zoomen: zwei Finger oder Mausrad.\n\n'
+ 'Am Anfang gibt es nur das Gasthaus alles Weitere schaltet sich '
+ 'Schritt für Schritt frei.',
});
this.showCard({
icon: '🚶', transient: true,
title: 'Die Gäste (2/3) dein Kreislauf',
text: 'Jede Figur steht für etwa 10 Gäste. Sie kommen mit dem Bus, '
+ 'wandern, kehren ein und reisen wieder ab.\n\n'
+ 'Die Symbole überm Kopf zeigen, was sie vorhaben:\n'
+ '🍽️ einkehren · 🎿 Ski fahren · 🥾 wandern · 🏖️ baden · 🛷 rodeln\n'
+ '😋 konsumiert gerade (grüne +€!) · ❄️ „kein Schnee!" · '
+ '👋 reist ab · 😞 reist unzufrieden ab\n\n'
+ 'Wer konsumiert, bringt Geld wer nicht kommen kann, keins.',
});
this.showCard({
icon: '🔍', transient: true,
title: 'Durchblick (3/3) warum läuft es (nicht)?',
text: 'Links stehen deine Kennzahlen. Kommen weniger Gäste als möglich, '
+ 'erscheint dort der GRUND (z.B. „Engpass: Anreise").\n\n'
+ 'Der 📋 Tagesbericht rechnet dir jeden Tag im Detail vor. '
+ 'Beim ersten Bau jedes Gebäudes erklärt dir eine Karte, was es '
+ 'bringt und was es kostet.\n\n'
+ 'Der Graph unten zeigt die Jahreszeiten farbig das blaue Band ist '
+ 'die Skisaison (Oktober bis Ostern).\n\n'
+ 'Ab und zu taucht ein Ereignis oder eine Entscheidung auf in Ruhe, '
+ 'das Spiel pausiert dabei. Diese Anleitung öffnest du jederzeit wieder '
+ 'über oben rechts.',
});
telemetry.log('onboarding_open');
}
toast(msg, dur = 2600) {
const t = $('toast');
t.textContent = msg;
t.classList.remove('hidden');
clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => t.classList.add('hidden'), dur);
}
}