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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 02:45:08 +02:00
parent eb565d4c01
commit 0a2ebf46b4
55 changed files with 6820 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
# Tourismustal — Aufbausimulation für GeoGraSim
Skigebiet-/Tourismus-Aufbausimulation im Stil klassischer Aufbauspiele (Siedler-artig),
aber mit transparentem, erklärbarem Modell. Zielgruppe: 3. Klasse Sek I (1314 J.).
## Lehrplan-Anker (GW-Lehrplan 2023, AT)
- 3.53.7 Standortfaktoren und Folgen von Ansiedelungen
- 3.6 Tourismusregionen charakterisieren
- 3.17 Raumnutzungskonflikte und Raumplanung
- 3.15/3.16 Zentren und Peripherien, Lebensqualität
- 1.9/2.1 Klimawandel-Folgen (Schneegrenze, Szene 3)
- Zentrale Konzepte: Interessen & Macht, Leistungserstellung & Nachhaltigkeit
## Spielprinzip
- Kleine Iso-Karte (18×18) eines Alpentals: Berg oben, Talstraße mit Dorf,
Kirche, Fluss und Brücke unten. Kamera startet nah am Dorf.
- **Kleine Schritte (Siedler-Prinzip):** Am Anfang gibt es nur das Gasthaus.
Meilenstein-Karten schalten nach und nach frei: Lift → Bushaltestelle →
Hotel → Personalhaus → Beschneiung. Auch Kennzahlen, Skipass-Regler,
Ziele und Graph erscheinen erst, wenn sie relevant werden.
- **Laufende Zeit** mit Zeitskala (Pause/1×/2×/4×), 1 Tag = 2 s bei 1×.
Auto-Pause bei Event-Karten. Start im Sommer (ruhige Aufbauphase),
der erste Winter wird zur Bewährungsprobe.
- **Bekanntheit** wächst langsam das Tal muss sich herumsprechen; dämpft
das Wachstum und gibt dem Spiel ein ruhiges Pacing.
- Infrastruktur mit sichtbarem Kontext: Bushaltestelle nur an der Talstraße
(animierter Bus fährt), Beschneiung nur nahe am Fluss, Piste/Schneise
entsteht über dem Lift (Gondeln fahren).
- Wirtschaftskette sichtbar: Anreise → Kapazität (Lift/Wege) → Betten →
Personal (braucht Wohnraum) → Einnahmen minus Betriebskosten.
- „Tagesbericht": schlüsselt die Rechnung des Tages in einfacher Sprache
auf (Modell bleibt für Schüler nachvollziehbar und modellierbar).
## Szenen (data-driven, `js/data.js`)
1. **Pionierzeit 1975** — erstes Skigebiet aufbauen (MVP, umgesetzt)
2. **Boom 2000** — Wachstum vs. Naturschutz, Bürgerinitiative (geplant)
3. **2035 Schneegrenze steigt** — Umstieg auf Ganzjahrestourismus (geplant)
## GUI (Vorgabe)
iPad-first (80 % iPads, Rest PC), Touch-Targets ≥ 44 px. Karte vollflächig im
Hintergrund, UI schwebt darauf: oben Headline (Home, Sound, Musik, Onboarding,
Zeitskala), links Kennzahlen, rechts Bau-Menü, unten Zeit-Graphen, Events als
Center-Cards (gesammelt, über Glocke abrufbar). Stil: Nordic, flat —
Grün/Beige/etwas Orange. Platzhalter-Grafik per Canvas, später KI-Sprites.
## Telemetrie (Lehrer-Dashboard, später PHP/DB)
`js/telemetry.js` loggt: Session-Start, Bauentscheidungen (+Ablehnungen mit
Grund), Zeitskala-Nutzung, gelesene Events/Berichte, Kennzahlen-Verlauf.
Stub `flush()` wird bei Integration auf POST an Backend umgestellt.
## Stand 7.7.2026 (Feature-komplett)
Rechteckiges Spielfeld (Kernzone + Bergrahmen, randlos gefuellt, Kamera-Clamp, dynamische Zoom-Grenzen). Transparenz: Tagesbericht als Tabelle, Engpass-Zeile, Berater-Hinweise, Erst-Bau-Erklaerkarten, Sims-Gedankenblasen (1 Figur=10 Gaeste). Ausbau ueber Gebaeude-Klick, 4 Stufen mit Wimpelgirlande/Stern/Glanz. Personal als sichtbare pendelnde Figuren + Auslastungspunkte. Lage-Effekte (Lift=Basis, Einflusskreise mit Versorgungslinien, Hoehenlage-Schnee, Skiverbund, Synergie). Schrittweiser Schnee-Rueckzug pro Lift. Saisonale Betriebskosten gegen Pleite-Spirale. Tests: model-test/smoke/views (alle gruen).
## Roadmap
- [x] Grundgerüst: Iso-Renderer, Zeit-Engine, UI-Layout, Bau-System, Modell v1
- [x] Wetter/Schnee-System, Beschneiung (braucht Flussnähe), warmer Winter
- [x] Skipass-Preis mit Nachfrage-Elastizität (Angebot & Nachfrage)
- [x] Ziele + Frist (Ende 1979), Sieg-/Bilanz-Karten, Abriss-Werkzeug
- [x] Szenenwahl-Screen (Szene 2+3 als Platzhalter), Onboarding
- [x] Ambiente: Himmel, Bergkulisse, Wolken, Schneefall, Fluss, Dorf,
Pisten/Schneisen, Lift mit fahrenden Gondeln, Ski-Figuren
- [x] WebAudio-Sound + Ambient-Musik (ohne Dateien)
- [x] Tests: `node tests/model-test.mjs` (600-Tage-Balancing),
`node tests/smoke.mjs` + `node tests/views.mjs` (Playwright, braucht
Server auf Port 8123, z.B. `python3 -m http.server 8123`)
- [x] KI-Sprites (gpt-image-1, transparent) via `tools/generate-sprites.mjs`,
Renderer mit Vektor-Fallback
- [x] KI-Sounds + saisonale Ambience (ElevenLabs) via
`tools/generate-sounds.mjs`, WebAudio-Player mit Synth-Fallback
- [x] Spielgefühl: Kamera-Momentum, sanfter Zoom, geglättetes Höhenfeld,
Spielzeit-gekoppelte Animationen, Skifahrer-Verhalten (Gehen → Sessel →
Abfahrt), Iso-Bus auf Talstraße, schwebende Tagesbilanz
- [x] Spaß-Mechaniken: Entscheidungs-Karten mit A/B-Wahl und echten Folgen
(Skirennen, Sommerfest, Aufforstung, Reisegruppen) + Mini-Aufträge mit
Belohnung (6 Stück, sequenziell)
- [x] Szenen 2 + 3 spielbar (data-driven in `js/data.js`): Boom 2000
(Wachstum vs. Natur, Übertourismus, Vorbauten, alle Gebäude frei) und
Schneegrenze 2035 (steigende Warmwinter-Chance, schnellere Schmelze,
neues Gebäude Panoramaweg für Ganzjahrestourismus, Sommergäste-Ziel)
- [x] Talname wählbar (Startbildschirm), erscheint in Titelleiste
- [x] Sichtbare Saisonhöhepunkte: Skirennen (Slalomtore, Zielbanner,
Zuschauer) und Sommerfest (Wimpelkette, Marktstände) nach Entscheidung
- [x] INFO.md: Bauplan/Anleitung für weitere Sims (Layout, Stack, Pipelines)
- [ ] Balancing-Feinschliff mit echten Schülergruppen
- [ ] Integration ins PHP/DB-System (Telemetrie-POST, Login, Home-Button)
+64
View File
@@ -0,0 +1,64 @@
# Tourismustal — Lehrplan-Verankerung
Stand: 2026-07-11 · Schulstufe: **Sek I, Kern 3. Klasse / 7. Schulstufe** (MS + AHS-Unterstufe),
Klimawandel-Szene auch 4. Klasse. Primärquelle AT: **BGBl. II Nr. 1/2023** (GW-Lehrplan 2023).
Datengrundlage der Wirtschaftszahlen: [QUELLEN.md](QUELLEN.md).
## Österreich — Geografie und wirtschaftliche Bildung (GW 2023)
Der Lehrplan fordert **Orientierungs-, Urteils- und Handlungskompetenz** — die Sim bedient
alle drei: Kennzahlen lesen (Orientierung), Zielkonflikte abwägen (Urteil), bauen und
entscheiden mit echten Folgen (Handlung).
| Kompetenz (LP 2023) | Konkrete Umsetzung in der Sim |
|---|---|
| **3.17** Raumnutzung Freizeit/Tourismus, Nutzungskonflikte, Raumplanung | Kernmechanik: jede Bau-Entscheidung konkurriert mit Naturwert; Bürgerinitiative, Aufforstungs-Entscheidung, Zonen (Tal/Hang/Hochlagen) |
| **3.6** Tourismusregionen charakterisieren | Drei Szenen = drei Epochen einer Tourismusregion (1975 / 2000 / 2035) |
| **3.5** Standortfaktoren im Wandel | Lage zählt: Höhenlage=Schneesicherheit, Talnähe=Konsum, Bus nur an Straße, Beschneiung nur am Fluss |
| **3.7** Folgen einer Unternehmens-Ansiedelung | Personalbedarf → Wohnraum, Infrastrukturkette, demografische Wirkung |
| **3.8** Preisbildung Angebot/Nachfrage | Skipass-Regler mit Nachfrage-Elastizität, Tagesbericht schlüsselt auf |
| **3.16** Lebensqualität Zentren/Peripherie | Zufriedenheit Gäste + Dorf, Übertourismus-Event |
| **3.13** Wirtschaftliche Kenngrößen | Tagesbericht (Einnahmen/Ausgaben/Engpass), Graphen, Endbilanz |
| **4.2 / 4.10** Klimawandel & nachhaltiges Wirtschaften | Szene 3: Schneegrenze steigt (real: 5…−9 Tage/Dekade), Ganzjahres-Pivot |
| **2.8** Projektideen unter Knappheit (Entrepreneurship) | Budget, Rücklagen, Bank-Notbremse, einmalige Investitions-Chancen |
**Übergreifende Themen (Allgemeiner Teil 2023):** Nr. 13 Wirtschafts-, Finanz- und
Verbraucher/innenbildung · Nr. 2 Entrepreneurship Education (TRIO-Modell) · Nr. 11
Umweltbildung für nachhaltige Entwicklung (nennt „Simulationsspiele" ausdrücklich als
Methode) · Nr. 12 Verkehrs- und Mobilitätsbildung (Anreise/Bus).
## Deutschland
- **NRW** Kernlehrplan Erdkunde G9 (2019): Inhaltsfeld „Räumliche Voraussetzungen und
Auswirkungen des Tourismus" — Zielkonflikt Wachstum ↔ nachhaltige Entwicklung wörtlich.
- **Bayern** LehrplanPLUS Geographie Jg. 7: Alpen — naturräumliches Potenzial, touristische
Nutzung, Naturgefahren (→ Rettungsstation, Lawinen-Kontext).
- **DGfG-Bildungsstandards** (2020): Leitkonzept Mensch-Umwelt-System; Kompetenzbereiche
Beurteilung/Bewertung + Handlung.
## Schweiz (Lehrplan 21, Zyklus 3)
- **RZG.2.5 „Die Bedeutung des Tourismus einschätzen"** — Alpenraum, wirtschaftliche
Bedeutung, nachhaltiger Tourismus (zentralster Anker).
- **RZG.3.3** Raumplanung & Raumnutzungskonflikte · **RZG.3.1** natürliche Systeme
(Gebirge) und deren Nutzung.
## Sim-Mechanik → Lehrplan (Kurzmatrix)
| Mechanik | Anker |
|---|---|
| Skipass-Preisregler | AT 3.8 · Thema 13 |
| Personalhaus/Wohnraum | AT 3.7, 3.16 |
| Bürgerinitiative/Naturwert | AT 3.17 · NRW Zielkonflikt · CH RZG.3.3 |
| Beschneiung/Schneegrenze | AT 4.2, 4.10 · Bayern Naturgefahren |
| Sommer-Ausbau (Bikepark, Plattform …) | AT 4.10 · CH RZG.2.5 · Daten: QUELLEN.md |
| Übertourismus-Event | AT 3.6, 3.17 · CH RZG.2.5 |
| Bank-Notbremse/Rücklagen | AT 2.8 · Thema 2 (EE) · Thema 13 |
| Rettungsstation/Heli | Bayern Naturgefahren · AT 3.7 (weiche Standortfaktoren) |
## Glossar-Begriffe (im Spiel via „Gut zu wissen", in der Plattform im Glossar)
Standortfaktor · Angebot und Nachfrage · Wertschöpfung · Infrastruktur · Saisonalität ·
Sommerloch · Nächtigung · Ganzjahrestourismus · Übertourismus · Raumnutzungskonflikt ·
Beschneiung · Diversifizierung · Tagesausgaben · Sanfter Tourismus (+ bestehende:
Tourismus, Nachhaltigkeit).
+77
View File
@@ -0,0 +1,77 @@
# Tourismustal — Datenquellen (Sommer- vs. Wintertourismus DACH)
Recherche-Stand: **2026-07-10**. Alle Zahlen im Spiel (Wissens-Bausteine, Event-Karten,
Balance-Regeln) stammen aus diesen Quellen. Bei Plattform-Integration wandern die
Begriffe ins Glossar, die Quellen in `glossar_sources`.
## 1. Nächtigungsverhältnis Sommer : Winter
| Fakt | Wert | Quelle |
|---|---|---|
| Österreich gesamt: Sommer hat Winter bei Nächtigungen überholt | Sommer 2025: 83,4 Mio. vs. Winter 2024/25: 72,2 Mio. (≈54:46) | Statistik Austria, Ankünfte & Nächtigungen, 2025/26 — https://www.statistik.at/statistiken/tourismus-und-verkehr/tourismus/beherbergung/ankuenfte-naechtigungen ; Hochrechnung: https://b2b.austria.info/de-at/wissen/studien-und-berichte/statistik/naechtigungsstatistik-kalenderjahr-2025-hochrechnung/ |
| Tirol: Nächtigungen Winter:Sommer | 53:47 (TJ 2024/25: 26,4 vs. 23,2 Mio.) | top.tirol „Tourismus zählt", 2025 — https://www.top.tirol/news/tourismus-zaehlt ; Tirol Werbung Daten & Zahlen 2025 (PDF) — https://presse.tirol.at/app/uploads/2025/03/Daten-Zahlen-zum-Tourismus-in-Tirol-2025.pdf |
| Tirol: Beherbergungs-UMSATZ Winter:Sommer | ≈61,5:38,5 (2023/24: 2.165 vs. 1.355 Mio. €) — ⅔ des Umsatzes im Winter | Tirol Werbung, ebd. |
| Vorarlberg gesamt | 52:48 (TJ 2024, 9,4 Mio.) | Vorarlberg Tourismus, Zahlen & Fakten 2024 — https://gmbh.vorarlberg.travel/zahlen-und-fakten/ |
| **Lech**: extrem winterlastig | **84:16** Nächtigungen (TJ 2023, ~0,9 Mio.) | Statistik Austria „Ein Blick auf die Gemeinde Lech", 2024 — https://www.statistik.at/blickgem/G0804/g80113.pdf |
| Wilder Kaiser: echtes ~1:1 | Sommer 2019 erstmals >1,07 Mio. von 2,12 Mio.; Sommer +25 % in 3 Jahren | meinbezirk.at TVB-Bilanz — https://www.meinbezirk.at/kufstein/c-lokales/tvb-wilder-kaiser-erzielte-stolze-uebernachtungszahlen_a4332655 ; https://www.meinbezirk.at/kufstein/c-wirtschaft/erstmals-ueber-eine-million-naechtigungen-im-sommer-am-wilden-kaiser_a1928081 |
| Zell am See-Kaprun: Ganzjahresdestination | 53:47 | meinbezirk.at Rekordbilanz (2017/18) — https://www.meinbezirk.at/pinzgau/c-wirtschaft/zell-am-see-kaprun-neuer-rekord-27-millionen-naechtigungen_a2364680 |
| Serfaus-Fiss-Ladis: Sommer-Aufholjagd | Sommer +56…95 % in 10 Jahren (Winter +3038 %); ≈60:40 (Schätzung) | meinbezirk.at Landeck Sommer 2022 — https://www.meinbezirk.at/landeck/c-wirtschaft/sommer-mit-257-millionen-naechtigungen-im-bezirk-landeck_a5730841 ; brand-logic Fallstudie — https://www.brand-logic.com/en/spectrum/gekommen-um-zu-bleiben/ |
| Grindelwald (CH): Sommer-DOMINANZ | 64 % der Hotel-Logiernächte im Sommerhalbjahr | htr.ch — https://www.htr.ch/story/tourismus/grindelwald-im-sommer-praktisch-ausgebucht-41099 |
| Zermatt (CH) | ~60:40 Nächtigungen, aber >80 % der Wertschöpfung im Winter | PHBern/UZH E-Dossier Zermatt (2011, älter) — https://www.lds.geo.uzh.ch/dam/jcr:f6381f1a-7e32-4914-b718-0c83ecf3931a/Factsheet_Zermatt.pdf |
## 2. Saisonverkürzung (Schneedecke)
| Fakt | Wert | Quelle |
|---|---|---|
| Österreich: Schnee schmilzt früher | 5 bis 6 Tage/Dekade; Schneesaison seit 1970 ~1 Monat kürzer; tiefe Lagen 19602020 ca. 60 % Schneedeckendauer | GeoSphere Austria Klimaportal — https://klimaportal.geosphere.at/informationsportal-klimawandel/schnee.html ; https://www.forschung-und-wissen.de/nachrichten/umwelt/schneesaison-in-den-alpen-verkuerzt-sich-zunehmend-13377036 |
| Schweiz: Saison ~37 Tage kürzer seit 1970 | Beginn +12 Tage später, Ende 25 Tage früher (≈0,8 Tage/Jahr, bis 2.500 m) | Klein et al. (Uni Neuchâtel/SLF) via SRF — https://www.srf.ch/news/schweiz/schneesaison-ist-fast-40-tage-kuerzer-als-frueher |
| Schweiz: Schneehöhe sinkt | NovApr 8 cm/Dekade | SLF SPASS via SRF — https://www.srf.ch/wissen/klimawandel/neues-modell-zeigt-schweizer-schneedecke-verliert-bis-zu-8-zentimeter-pro-jahrzehnt-1 |
| Bayern: Schneedeckendauer Alpen | 11 Tage (Mittelgebirge 7 bis 9) | LfU Bayern — https://www.lfu.bayern.de/klima/klimaanpassung_bayern/tourismus/index.htm |
| Einordnung Skibetrieb | Betriebstage mit Beschneiung sinken deutlich langsamer als Naturschnee; „1 Tag/Jahr" gilt für Naturschnee < ~2.000 m | R. Steiger (Uni Innsbruck) via Bergzeit — https://www.bergzeit.de/magazin/klimawandel-skifahren-wintersport/ |
## 3. Bergbahnen: Sommergeschäft
| Fakt | Wert | Quelle |
|---|---|---|
| AT: Sommeranteil Seilbahn-Umsatz | >15 %; ~20 Mio. Sommer- vs. ~50 Mio. Winter-Ersteintritte | WKO Fachverband Seilbahnen — https://www.wko.at/oe/transport-verkehr/seilbahnen/zahlendatenfakten ; tirol.ORF.at 2024 — https://tirol.orf.at/stories/3259975/ |
| CH: Sommeranteil Verkehrsertrag | von 13 % auf ~2025 % in 10 Jahren (Sommerertrag 20142022 +45 %) | Seilbahnen Schweiz via SRF — https://www.srf.ch/news/schweiz/tourismus-in-der-schweiz-bergbahnen-machen-immer-mehr-umsatz-in-den-sommermonaten ; htr.ch — https://www.htr.ch/story/tourismus/seilbahnen-machen-immer-mehr-umsatz-im-sommer-41284 ; swissinfo — https://www.swissinfo.ch/ger/seilbahnen-machen-immer-mehr-umsatz-im-sommer/87673805 |
| **Kernzahl**: Ersatzbedarf | **1 fehlender Wintergast ≙ 1,34,2 Sommergäste** (Ertrag Bergbahn) | Seilbahnen Schweiz, ebd. |
| Jungfraubahn/Grindelwald: Extrembeispiel | Verkehrsertrag 2023 CHF 195,9 Mio., Wintersport-Segment nur ~41 Mio. (~20 %) | Jungfraubahn Geschäftsbericht 2023 — https://www.jungfrau.ch/geschaeftsbericht-2023/assets/pdfs/Jungfraubahn_GB2023_DE_WEB_offizielle-version.pdf |
## 4. Tagesausgaben / Wertschöpfung
| Fakt | Wert | Quelle |
|---|---|---|
| Wintergast Tagesausgaben AT | 207 €/Tag (2022/23, ohne Anreise) | T-MONA/Österreich Werbung — https://www.tourismuspresse.at/presseaussendung/TPT_20231005_TPT0006/studie-zum-winterurlaub-tagesausgaben-gestiegen-skifahren-anhaltend-beliebt |
| Winter vs. Sommer Tagesausgaben | 193 € vs. 156 €/Tag → **Sommergast ≈ 80 %** | ÖW-Daten via richardbauer.at — https://richardbauer.at/wie-viel-geld-geben-urlauber-pro-tag-aus/ |
| Beherbergungsumsatz je Nächtigung Tirol (errechnet) | Winter ~82 € vs. Sommer ~58 € (**~70 %**) — Schätzung aus Tirol-Zahlen | eigene Ableitung aus Tirol Werbung 2025 (s. o.) |
## 5. Lech konkret
- 84:16 Winter:Sommer (s. o., Statistik Austria Gemeindeblick 2024).
- Sommer wächst von niedriger Basis: Juli erstmals >50.000 Nächtigungen, +13 % —
vorarlberg.ORF.at https://vorarlberg.orf.at/v2/news/stories/2596511/ ;
Lech Zürs Tourismus Presse — https://www.lechzuers.com/de/presse/lech-zuers-tourismus-zieht-positive-winterbilanz-und-startet-mit-rueckenwind-in-den-sommer
- Landesrechnungshof-Prüfung 2025 (PDF bildbasiert, Zahlen nicht extrahiert) —
https://www.lrh-v.at/wp-content/uploads/2025/07/Presseunterlage-Lech-Zuers-Tourismus-GmbH.pdf
## Abgeleitete Sim-Regeln (Balance Szene 3)
1. Sommergast-Ertrag ≈ 7080 % eines Wintergast-Tags (Gesamtausgaben) — in der Sim via
`summerSpend` < Winterausgaben abgebildet.
2. Bergbahn-Sommerticket klein halten (real: 1 Wintergast ≙ 1,34,2 Sommergäste) —
in der Sim: Sommer-Bergfahrt 8 € vs. Skipass 1545 €. ✓ schon so
3. 1:1 bei GÄSTEN erreichbar (Wilder Kaiser), 1:1 beim UMSATZ kaum — Szene-3-Ziele
verlangen Sommergäste UND Kontostand, nicht „Sommer ersetzt Winter 1:1".
4. Sommer-Nachfrage wächst langsam & kumulativ (Serfaus: +95 % in 10 Jahren ≈ +7 %/Jahr
bei konsequentem Angebot) — Sim: `summerTrend` in Szene 3, wirkt nur mit gebauten
Sommer-Attraktionen.
5. Saisonverkürzung höhenabhängig (5…−9 Tage/Dekade < 2.000 m; Beschneiung puffert) —
Sim: warmWinterChance + warmWinterGrow + Höhenlagen-Bonus. ✓ schon so
## Ehrliche Lücken
- Orts-Splits Saalbach, Schladming, Mayrhofen, Alpbachtal, St. Anton: nicht gefunden.
- Serfaus 60:40 ist abgeleitet, keine offizielle Zahl.
- Tirol-Regionsprofil Serfaus + LRH-Lech-PDF bildbasiert → nicht maschinell lesbar.
- Deutschland: nur Schneedeckentage (LfU), keine Nächtigungs-Splits einzelner Skiorte.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

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