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:
@@ -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 10–25 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();
|
||||
@@ -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 5–9 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 15–25 % 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 5–9 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 15–25 % 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
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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 & 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user