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
@@ -0,0 +1,82 @@
# Tourismustal — Lehrplan-Recherche D-A-CH (Stand 2026-07-08)
Primärquellengestützt. AT-Belege aus **BGBl. II Nr. 1/2023** (RIS) bzw. layoutierte
Fassung V11 auf gwb.schule.at. Basis für die SQL-Anker + LEHRPLAN.md.
## AT — GW-Lehrplan 2023, Sek I
**Kompetenzmodell = 3 Kompetenzen** (nicht das dt. 6-Dimensionen-Modell!):
Orientierungs-, Urteils-, Handlungskompetenz. Die vielen „Kompetenzbereiche" sind
klassen-/themenbezogene Teilbereiche, keine Modellsäulen. Anforderungsbereiche
(kognitive Stufen): Reproduktion, Transfer, Reflexion, Problemlösung.
Zentrale fachliche Konzepte: Gemeinsamkeiten/Unterschiede · Veränderung/Wandel ·
Leistungserstellung & Nachhaltigkeit · Kooperation/Konkurrenz · Vernetzung & Märkte ·
Ökonomische Prinzipien & Entscheidungsfindung.
**Kern-Deskriptoren 3. Klasse (7. Schulstufe) „Leben und Wirtschaften in Österreich":**
- **3.17** (WICHTIGSTE Fundstelle) — Raumnutzungen für Wohnen, Arbeit, Verkehr,
Freizeit und **Tourismus** vergleichen + **Nutzungskonflikte** über **Raumplanung** lösen.
- **3.6** — Industrie-, **Tourismusregionen**, landw. Regionen charakterisieren/verorten.
- **3.5** — Wandel von **Standortfaktoren/-entscheidungen** und Folgen erörtern.
- **3.7** — Folgen der **Ansiedelung eines Unternehmens** (Infrastruktur, Demografie).
- **3.8** — **Preisbildung: Angebot & Nachfrage** + Produktionskosten.
- **3.16** — **Lebensqualität** zentraler/peripherer Räume vergleichen/bewerten.
- **3.13** — wirtsch. Kenngrößen: Wachstum/BIP, Beschäftigung, alt. Wohlstandsindikatoren.
- **3.11/3.12** — Bedeutung von Unternehmen / unternehmerischer Innovation.
**Klima-Schicht 4. Klasse „globalisierte Welt":**
- **4.2** — Folgen des **Klimawandels** aufs Leben und Wirtschaften.
- **4.10** — nachhaltige Wirtschaftsaktivität bei Klimawandel/Ressourcenknappheit.
- **4.5** — EU-Politikfeld **Regionalentwicklung**.
**Wirtschaftliche Bildung / Entrepreneurship:** 2.7 (Angebot/Nachfrage→Preis),
2.8 (**Projektidee** unter Knappheit/Preisgestaltung → Entrepreneurship Education,
„Unternehmerisches Denken und Handeln"). EE verankert über übergreifendes Thema Nr. 2,
TRIO-Modell (Aff/Lindner 2005): Core / Entrepreneurial Culture („Trans") / Civic.
**Übergreifende Themen (13 Stück, Allgemeiner Teil):** von der Sim abgedeckt:
- Kern: **13** Wirtschafts-, Finanz- und Verbraucher/innenbildung · **2** Entrepreneurship
Education · **11** Umweltbildung für nachhaltige Entwicklung · **12** Verkehrs- und
Mobilitätsbildung.
- Mittel: **7** Politische Bildung · **1** Berufsorientierung · **5** Interkulturelle Bildung.
- Nr. 11 nennt „Rollen-, Plan- sowie **Simulationsspiele**" ausdrücklich als Methode.
**Vorbehalt:** „Alpen/alpin" steht im Sek-I-GW-Text NICHT wörtlich → im Modul über
„Tourismusregion in Österreich" framen, nicht als Zitat „alpin" ausgeben.
## DE — Deutschland (Fach Geographie/Erdkunde, Kl. 7/8)
- **NRW** Kernlehrplan Erdkunde: eigenes Inhaltsfeld Tourismus; wörtlich „**Zielkonflikt
zwischen wirtschaftlichem Wachstum durch Tourismus und nachhaltiger Entwicklung**" +
„Raumnutzungskonflikte … Gefährdung des Naturraums".
- **Bayern** LehrplanPLUS Geo Jg. 7: **Alpen** explizit — naturräumliches Potenzial +
touristische Nutzung, Naturgefahren/Schutz.
- **DGfG-Bildungsstandards** (MSA 2020): 6 Kompetenzbereiche (Fachwissen · Räuml.
Orientierung · Erkenntnisgewinnung/Methoden · Kommunikation · Beurteilung/Bewertung ·
Handlung); Leitkonzept **Mensch-Umwelt-System**.
## CH — Schweiz (Lehrplan 21, RZG, Zyklus 3)
- **RZG.2.5 „Die Bedeutung des Tourismus einschätzen"** (Alpenraum, wirtschaftliche
Bedeutung, Einfluss auf Gebirgsregionen, **nachhaltiger Tourismus**) — zentralster Anker.
- **RZG.3.3** Raumplanung, **Raumnutzungskonflikte**, nachhaltige Raumentwicklung.
- **RZG.3.1** natürliche Systeme + Nutzung (Gebirge; Tourismus/Siedlung/Nutzungswandel).
- **RZG.3.2** wirtsch. Prozesse/Globalisierung · **RZG.1.4** Ressourcen/Energie.
## MAPPING Sim-Mechanik → AT-Anker
| Mechanik | Anker | Kompetenz |
|---|---|---|
| Skipass-Preisregler | 3.8 (Angebot/Nachfrage) | Orientierung+Urteil |
| Personalhaus/Wohnraum | 3.7, 3.16 | Urteil+Handlung |
| Bürgerinitiative/Naturwert | 3.17 (Nutzungskonflikt) | Urteil+Handlung |
| Beschneiung/Schneegrenze | 4.2, 4.10 | Orientierung+Urteil |
| Übertourismus-Event | 3.6, 3.17, 3.13 | Urteil+Handlung |
| Standortaufbau/Investition | 3.5, 3.11/3.12, 2.8 | Handlung |
| Ganzjahrestourismus-Umbau | 4.10, 3.5, 4.5 | Urteil+Handlung |
| Endabrechnung/KPI | 3.13 | Urteil |
**Ankersatz:** Primär 3. Klasse/7. Schulstufe, Kern **3.17**, gestützt durch
3.5/3.6/3.7/3.8/3.13/3.16; Klima-Schicht 4.2/4.10. Fördert alle drei Kompetenzen;
übergreifende Themen 13, 2, 11, 12, 7.
Referenz-Konvention für SQL: `reference = "BMBWF GW-Lehrplan Sek I 2023, Kompetenz 3.17"`,
`reference_url = https://www.ris.bka.gv.at/eli/bgbl/II/2023/1`.
@@ -0,0 +1,147 @@
-- ============================================================
-- Tourismustal: Glossar-Begriffe + Modul-Zuordnung + Quellen
-- Stand: 2026-07-11 · Atlas
-- 13 neue Begriffe (mit Leichter Sprache), Zuordnung auch für die
-- bestehenden Begriffe 'tourismus' und 'nachhaltigkeit'.
-- Quellen: siehe App/sims/tourismustal/QUELLEN.md (Recherche 2026-07-10).
-- Idempotent: Begriffe per ON DUPLICATE, Quellen per DELETE+INSERT.
-- ============================================================
INSERT INTO glossar (key_slug, title, short, text, short_easy, text_easy, category, sort_order) VALUES
('standortfaktor', 'Standortfaktor',
'Eigenschaften eines Ortes, die ihn für eine Nutzung geeignet machen z. B. Höhenlage, Erreichbarkeit, Landschaft.',
'Standortfaktoren sind die Eigenschaften eines Ortes, die darüber entscheiden, ob sich dort etwas ansiedeln kann: Verkehrsanbindung, Gelände, Klima, Arbeitskräfte, Nähe zu Kunden. Im Alpentourismus zählen z. B. Höhenlage (Schneesicherheit), Erreichbarkeit (Straße, Bus) und Landschaftsbild. Man unterscheidet harte (messbare) und weiche Faktoren (z. B. Image, Sicherheit).',
'Was einen Ort gut für etwas macht.',
'Ein Standort-Faktor sagt: Ist dieser Ort gut für ein Geschäft oder Hotel? Wichtig sind zum Beispiel: Kommt man leicht hin? Liegt genug Schnee? Ist die Landschaft schön?',
'wirtschaft', 200),
('angebot-nachfrage', 'Angebot und Nachfrage',
'Das Zusammenspiel von Anbietern und Käufern bestimmt den Preis: Viel Nachfrage bei wenig Angebot macht teuer und umgekehrt.',
'Auf Märkten treffen Angebot (was verkauft wird) und Nachfrage (was Menschen kaufen wollen) aufeinander daraus entsteht der Preis. Steigt der Preis, kaufen weniger Menschen; sinkt er, kommen mehr. Im Tourismus sichtbar am Skipass: Ein hoher Preis bringt mehr Geld pro Gast, aber weniger Gäste kommen. Der GW-Lehrplan nennt das Preisbildung auf Märkten.',
'Was viele wollen und selten ist, wird teuer.',
'Angebot ist: Was es zu kaufen gibt. Nachfrage ist: Was die Menschen kaufen wollen. Beides zusammen macht den Preis. Ist der Ski-Pass sehr teuer, kommen weniger Gäste.',
'wirtschaft', 201),
('wertschoepfung', 'Wertschöpfung',
'Der wirtschaftliche Wert, der vor Ort entsteht im Tourismus alles, was Gäste in der Region ausgeben.',
'Wertschöpfung ist der Wert, der durch Arbeit und Leistungen in einer Region entsteht. Im Tourismus: Übernachtung, Essen, Tickets, Verleih alles, was Gäste vor Ort ausgeben und was Löhne und Einkommen im Tal sichert. Wichtig: Ein Wintergast gibt in Österreich im Schnitt 193 € pro Tag aus, ein Sommergast 156 € gleich viele Gäste bedeuten also nicht gleich viel Wertschöpfung.',
'Das Geld, das Gäste in einem Ort ausgeben und lassen.',
'Wert-Schöpfung heißt: In einem Ort wird Geld verdient. Gäste zahlen für Schlafen, Essen und Tickets. Davon leben die Menschen im Ort.',
'wirtschaft', 202),
('infrastruktur', 'Infrastruktur',
'Die Grundausstattung eines Ortes: Straßen, Bahnen, Wasser, Strom im Tourismus auch Lifte, Wege und Unterkünfte.',
'Infrastruktur ist alles, was ein Ort braucht, damit Leben und Wirtschaft funktionieren: Verkehrswege, Versorgung, Kommunikation. Touristische Infrastruktur sind Bahnen, Pisten, Wege, Hotels. Sie ist teuer im Bau UND im Betrieb eine Seilbahn kostet auch dann Geld, wenn niemand fährt. Deshalb versuchen Regionen, ihre Infrastruktur ganzjährig auszulasten.',
'Alles, was ein Ort zum Funktionieren braucht.',
'Infra-Struktur ist die Grund-Ausstattung: Straßen, Strom, Wasser. Im Urlaubs-Ort auch: Lifte, Wege und Hotels. Das alles kostet Geld, auch wenn gerade keine Gäste da sind.',
'wirtschaft', 203),
('saisonalitaet', 'Saisonalität',
'Starke Schwankung von Gästezahlen im Jahresverlauf Hochsaison, Nebensaison, Zwischensaison.',
'Tourismus schwankt mit den Jahreszeiten: In Skiorten ist der Winter Hochsaison, dazwischen liegen schwache Monate. Saisonalität ist teuer, weil Betten, Bahnen und Personal auch in schwachen Zeiten Geld kosten. Je gleichmäßiger sich die Saisonen füllen, desto wirtschaftlicher arbeitet eine Region deshalb ist Ganzjahrestourismus das große Ziel vieler Orte.',
'Mal kommen viele Gäste, mal fast keine je nach Jahreszeit.',
'Saison heißt: die Zeit mit vielen Gästen. In Ski-Orten ist das der Winter. In anderen Monaten ist wenig los. Das ist ein Problem, weil Hotels und Lifte immer Geld kosten.',
'wirtschaft', 204),
('sommerloch', 'Sommerloch (Nebensaison)',
'Die gästearme Zeit in Wintersport-Orten: Betten leer, Bahnen still z. B. hat Lech am Arlberg nur 16 % seiner Nächtigungen im Sommer.',
'In winterlastigen Skiorten heißt die schwache Sommerzeit umgangssprachlich „Sommerloch". Extrembeispiel Lech am Arlberg: 84 % der Übernachtungen im Winter, nur 16 % im Sommer (Statistik Austria, 2023). Andere Orte zeigen das Gegenmodell: Am Wilden Kaiser kommen Sommer und Winter etwa gleich viele Gäste, und in ganz Österreich hat der Sommer den Winter bei den Nächtigungen überholt (83 zu 72 Mio., 2025).',
'Die Zeit im Sommer, wenn in Ski-Orten fast keine Gäste kommen.',
'Viele Ski-Orte leben nur vom Winter. Im Sommer sind die Betten leer. Das nennt man Sommer-Loch. Kluge Orte bauen darum auch Sommer-Angebote: Badesee, Wander-Wege, Rad-Strecken.',
'wirtschaft', 205),
('naechtigung', 'Nächtigung',
'Eine Übernachtung eines Gastes die Maßeinheit der Tourismusstatistik.',
'Die Tourismusstatistik zählt Nächtigungen (Übernachtungen) und Ankünfte (angereiste Gäste). Österreich zählte 2025 rund 83 Mio. Sommer- und 72 Mio. Winter-Nächtigungen der Sommer liegt bei den Nächtigungen vorn, beim Umsatz aber weiter der Winter, weil Wintergäste pro Tag mehr ausgeben. Wer Zeitungsberichte über Tourismus verstehen will, muss diese Zahl lesen können.',
'Eine Nacht, die ein Gast in einem Ort schläft.',
'Eine Nächtigung ist: Ein Gast schläft eine Nacht im Hotel. Damit zählt man, wie gut der Tourismus läuft. Österreich hat im Jahr über 150 Millionen Nächtigungen.',
'wirtschaft', 206),
('ganzjahrestourismus', 'Ganzjahrestourismus',
'Die Strategie, Gäste in allen Jahreszeiten anzusprechen statt nur von einer Saison zu leben.',
'Ganzjahrestourismus verteilt das Geschäft auf alle Jahreszeiten: Ski im Winter, Wandern, Bike und Familienangebote im Sommer. Vorbilder: Serfaus-Fiss-Ladis steigerte die Sommernächtigungen in zehn Jahren um bis zu 95 %, Zell am See-Kaprun und der Wilde Kaiser erreichen fast 1:1. Wichtig: Die Umstellung dauert Jahre und ersetzt die Winter-Wertschöpfung nur teilweise Bergbahnen brauchen 1,3 bis 4,2 Sommergäste pro fehlendem Wintergast.',
'Ein Ort will das ganze Jahr Gäste haben, nicht nur im Winter.',
'Ganz-Jahres-Tourismus heißt: Gäste kommen im Winter UND im Sommer. Dafür braucht ein Ort Sommer-Angebote wie Badesee oder Rad-Wege. Das dauert viele Jahre.',
'wirtschaft', 207),
('uebertourismus', 'Übertourismus',
'Wenn zu viele Gäste einen Ort überlasten: verstopfte Straßen, gestresste Einheimische, leidende Natur.',
'Übertourismus (Overtourism) entsteht, wenn Besucherzahlen die Belastungsgrenzen eines Ortes übersteigen: Verkehr, Lärm, Preise steigen, Lebensqualität und Naturwert sinken und am Ende bleiben genau die Gäste aus, die wegen der Ruhe kamen. Gegenmittel sind Besucherlenkung, Obergrenzen und Qualitäts- statt Massenstrategie.',
'Zu viele Gäste auf einmal der Ort wird überlastet.',
'Über-Tourismus heißt: Es kommen zu viele Gäste. Die Straßen sind voll. Die Natur leidet. Die Menschen im Ort sind genervt. Dann ist Urlaub dort nicht mehr schön.',
'gesellschaft', 208),
('raumnutzungskonflikt', 'Raumnutzungskonflikt',
'Wenn verschiedene Nutzungen um dieselbe Fläche konkurrieren z. B. Skigebiet gegen Naturschutz.',
'Fläche ist knapp: Wohnen, Verkehr, Freizeit, Tourismus und Naturschutz wollen oft dasselbe Stück Land. Ein neuer Lift bedeutet Rodung, ein Hotel verbraucht Wiese. Raumplanung versucht, solche Konflikte fair zu lösen mit Zonen, Schutzgebieten und Beteiligung der Bevölkerung (z. B. Bürgerinitiativen). Der GW-Lehrplan 2023 nennt genau das in Kompetenz 3.17.',
'Streit darüber, wofür ein Stück Land genutzt wird.',
'Ein Raum-Nutzungs-Konflikt ist ein Streit um Land: Die einen wollen einen Lift bauen. Die anderen wollen den Wald schützen. Die Raum-Planung sucht eine faire Lösung.',
'gesellschaft', 209),
('beschneiung', 'Beschneiung',
'Künstliche Schneeerzeugung mit Wasser und Druckluft Anpassung an wärmere Winter, aber ressourcenintensiv.',
'Schneekanonen zerstäuben Wasser bei Minusgraden zu Schnee. Sie sichern die Saison, wenn Naturschnee fehlt brauchen aber viel Wasser und Energie und funktionieren nur bei Kälte. Hintergrund: Die Schneesaison in den Alpen ist seit 1970 rund einen Monat kürzer geworden (59 Tage pro Jahrzehnt), unterhalb von ca. 2.000 m trifft es Skigebiete am härtesten (GeoSphere Austria, SLF).',
'Maschinen machen Schnee, wenn zu wenig echter Schnee liegt.',
'Schnee-Kanonen machen aus Wasser Kunst-Schnee. Das hilft, wenn der Winter zu warm ist. Aber es braucht sehr viel Wasser und Strom. Und es geht nur, wenn es kalt genug ist.',
'klima', 210),
('diversifizierung', 'Diversifizierung',
'Auf mehrere Standbeine setzen statt auf eines im Tourismus: Sommer UND Winter, mehrere Zielgruppen.',
'Wer nur ein Produkt hat, ist verletzlich fällt es aus, bricht alles zusammen. Diversifizierung verteilt das Risiko auf mehrere Angebote: Ein Skiort mit Bikepark, Badesee und Wanderwegen übersteht schneearme Winter besser. Das ist unternehmerisches Denken im Sinn der Entrepreneurship Education: Chancen erkennen, Risiken streuen.',
'Nicht alles auf eine Karte setzen mehrere Angebote haben.',
'Diversifizierung heißt: viele verschiedene Angebote haben. Ein Ski-Ort mit Badesee und Rad-Wegen hat auch dann Gäste, wenn kein Schnee liegt.',
'wirtschaft', 211),
('tagesausgaben', 'Tagesausgaben pro Gast',
'Wie viel ein Gast pro Urlaubstag ausgibt Wintergäste in Österreich ~193 €, Sommergäste ~156 €.',
'Die Tagesausgaben umfassen Unterkunft, Essen, Tickets und Einkäufe. In Österreich gibt ein Wintergast im Schnitt 193 € pro Tag aus, ein Sommergast 156 € (Österreich Werbung/T-MONA) der Sommergast bringt also nur rund 80 % der Wertschöpfung. Darum gilt: Gleich viele Sommergäste ersetzen den Winterumsatz nicht 1:1 für Bergbahnen braucht es sogar 1,3 bis 4,2 Sommergäste pro Wintergast.',
'Wie viel Geld ein Gast an einem Urlaubs-Tag ausgibt.',
'Ein Winter-Gast gibt in Österreich etwa 193 Euro am Tag aus. Ein Sommer-Gast etwa 156 Euro. Winter-Gäste bringen also mehr Geld.',
'wirtschaft', 212),
('sanfter-tourismus', 'Sanfter Tourismus',
'Tourismus mit möglichst kleinem Fußabdruck: Natur erleben, ohne sie zu verbrauchen.',
'Sanfter Tourismus setzt auf Wandern, Rad, Naturerlebnis und öffentliche Anreise statt auf immer mehr Erschließung. Er schont Landschaft und Klima genau die Ressourcen, von denen der Alpentourismus lebt. Beispiele: Panoramawege, Hochseilgärten (nutzen den Wald, ohne ihn zu roden), Bergsteigerdörfer. Gegenstück zum harten, infrastrukturintensiven Massentourismus.',
'Urlaub, der die Natur wenig belastet.',
'Sanfter Tourismus schont die Natur: wandern, Rad fahren, mit dem Bus anreisen. So bleibt die Landschaft schön auch für die Gäste von morgen.',
'gesellschaft', 213)
ON DUPLICATE KEY UPDATE
title=VALUES(title), short=VALUES(short), text=VALUES(text),
short_easy=VALUES(short_easy), text_easy=VALUES(text_easy),
category=VALUES(category), sort_order=VALUES(sort_order);
-- ---------- Modul-Zuordnung ----------
INSERT INTO glossar_modules (glossar_id, module_id)
SELECT id, 'tourismustal' FROM glossar WHERE key_slug IN (
'standortfaktor','angebot-nachfrage','wertschoepfung','infrastruktur',
'saisonalitaet','sommerloch','naechtigung','ganzjahrestourismus',
'uebertourismus','raumnutzungskonflikt','beschneiung','diversifizierung',
'tagesausgaben','sanfter-tourismus',
'tourismus','nachhaltigkeit'
)
ON DUPLICATE KEY UPDATE module_id=VALUES(module_id);
-- ---------- Quellen (DELETE+INSERT = idempotent) ----------
DELETE gs FROM glossar_sources gs
JOIN glossar g ON g.id = gs.glossar_id
WHERE g.key_slug IN ('sommerloch','naechtigung','ganzjahrestourismus','beschneiung','tagesausgaben');
INSERT INTO glossar_sources (glossar_id, ref_num, label, url, access_date)
SELECT id, 1, 'Statistik Austria: Ein Blick auf die Gemeinde Lech (2024)',
'https://www.statistik.at/blickgem/G0804/g80113.pdf', '2026-07-10'
FROM glossar WHERE key_slug='sommerloch';
INSERT INTO glossar_sources (glossar_id, ref_num, label, url, access_date)
SELECT id, 2, 'Statistik Austria: Ankünfte & Nächtigungen (2025/26)',
'https://www.statistik.at/statistiken/tourismus-und-verkehr/tourismus/beherbergung/ankuenfte-naechtigungen', '2026-07-10'
FROM glossar WHERE key_slug='sommerloch';
INSERT INTO glossar_sources (glossar_id, ref_num, label, url, access_date)
SELECT id, 1, 'Statistik Austria: Ankünfte & Nächtigungen (2025/26)',
'https://www.statistik.at/statistiken/tourismus-und-verkehr/tourismus/beherbergung/ankuenfte-naechtigungen', '2026-07-10'
FROM glossar WHERE key_slug='naechtigung';
INSERT INTO glossar_sources (glossar_id, ref_num, label, url, access_date)
SELECT id, 1, 'brand-logic: Fallstudie Serfaus-Fiss-Ladis',
'https://www.brand-logic.com/en/spectrum/gekommen-um-zu-bleiben/', '2026-07-10'
FROM glossar WHERE key_slug='ganzjahrestourismus';
INSERT INTO glossar_sources (glossar_id, ref_num, label, url, access_date)
SELECT id, 2, 'Seilbahnen Schweiz via SRF: Bergbahnen machen immer mehr Umsatz im Sommer (2023)',
'https://www.srf.ch/news/schweiz/tourismus-in-der-schweiz-bergbahnen-machen-immer-mehr-umsatz-in-den-sommermonaten', '2026-07-10'
FROM glossar WHERE key_slug='ganzjahrestourismus';
INSERT INTO glossar_sources (glossar_id, ref_num, label, url, access_date)
SELECT id, 1, 'GeoSphere Austria: Klimaportal Schnee',
'https://klimaportal.geosphere.at/informationsportal-klimawandel/schnee.html', '2026-07-10'
FROM glossar WHERE key_slug='beschneiung';
INSERT INTO glossar_sources (glossar_id, ref_num, label, url, access_date)
SELECT id, 2, 'Klein et al. (Uni Neuchâtel/SLF) via SRF: Schneesaison fast 40 Tage kürzer',
'https://www.srf.ch/news/schweiz/schneesaison-ist-fast-40-tage-kuerzer-als-frueher', '2026-07-10'
FROM glossar WHERE key_slug='beschneiung';
INSERT INTO glossar_sources (glossar_id, ref_num, label, url, access_date)
SELECT id, 1, 'Österreich Werbung/T-MONA: Studie zum Winterurlaub Tagesausgaben (2023)',
'https://www.tourismuspresse.at/presseaussendung/TPT_20231005_TPT0006/studie-zum-winterurlaub-tagesausgaben-gestiegen-skifahren-anhaltend-beliebt', '2026-07-10'
FROM glossar WHERE key_slug='tagesausgaben';
@@ -0,0 +1,176 @@
-- ============================================================
-- Tourismustal: Lehrplan-Verankerung (Kompetenz + Anker D-A-CH)
-- Stand: 2026-07-11 · Atlas
-- Primärquelle AT: BGBl. II Nr. 1/2023 (GW-Lehrplan Sek I 2023),
-- Recherche + Zitate: App/sims/tourismustal/QUELLEN.md und
-- .dontDeploy/tourismustal-lehrplan-recherche-2026-07-08.md
-- Anker hängen an Kompetenzen, NICHT direkt am Modul (Schema-Konvention).
-- Idempotent: Kompetenz per ON DUPLICATE, Anker per DELETE+INSERT.
-- ============================================================
-- 1) Neue Kompetenz
INSERT INTO kompetenzen (key_slug, title, description, category, age_min, age_max, sort_order)
VALUES ('tourismus-raumentwicklung', 'Tourismus und regionale Raumentwicklung',
'Tourismusregionen charakterisieren und verorten, Standortfaktoren und Standortentscheidungen erörtern, Preisbildung über Angebot und Nachfrage verstehen, Raumnutzungskonflikte zwischen Wirtschaft, Bevölkerung und Natur über Raumplanung abwägen und nachhaltige, klimaangepasste Entwicklungsstrategien (Ganzjahrestourismus, Diversifizierung) beurteilen. Umfasst Wertschöpfung, Saisonalität, Übertourismus und unternehmerisches Denken im Sinn der Entrepreneurship Education.',
'raum', 12, 15, 150)
ON DUPLICATE KEY UPDATE title=VALUES(title), description=VALUES(description),
category=VALUES(category), age_min=VALUES(age_min), age_max=VALUES(age_max),
sort_order=VALUES(sort_order);
-- 2) Modul-Zuordnung (primär: neue Kompetenz; sekundär: bestehende)
INSERT INTO kompetenz_modules (kompetenz_id, module_id, level, strength)
SELECT id, 'tourismustal', 0, 'primaer' FROM kompetenzen WHERE key_slug='tourismus-raumentwicklung'
ON DUPLICATE KEY UPDATE strength=VALUES(strength);
INSERT INTO kompetenz_modules (kompetenz_id, module_id, level, strength)
SELECT id, 'tourismustal', 0, 'sekundaer' FROM kompetenzen WHERE key_slug='klimawandel'
ON DUPLICATE KEY UPDATE strength=VALUES(strength);
INSERT INTO kompetenz_modules (kompetenz_id, module_id, level, strength)
SELECT id, 'tourismustal', 0, 'sekundaer' FROM kompetenzen WHERE key_slug='systemisches-denken'
ON DUPLICATE KEY UPDATE strength=VALUES(strength);
-- 3) Anker (DELETE+INSERT, nur die der neuen Kompetenz)
DELETE FROM lehrplan_anchors
WHERE kompetenz_id = (SELECT id FROM kompetenzen WHERE key_slug='tourismus-raumentwicklung');
-- ---- Österreich (GW-Lehrplan Sek I 2023, BGBl. II Nr. 1/2023) ----
INSERT INTO lehrplan_anchors
(kompetenz_id, country, anchor_type, region, school_type, grade, subject, title, reference, reference_url, quote, sort_order)
SELECT k.id, 'AT', 'fach', NULL, 'MS / AHS-U', '3. Klasse (7. Schulstufe)',
'Geografie und wirtschaftliche Bildung',
'Raumnutzung für Freizeit und Tourismus Nutzungskonflikte und Raumplanung',
'BMBWF GW-Lehrplan Sek I 2023, Kompetenz 3.17',
'https://www.ris.bka.gv.at/eli/bgbl/II/2023/1',
'Die Schülerinnen und Schüler können mit Hilfe von (Geo-)Medien die Raumnutzungen für Wohnen, Arbeit, Verkehr, Freizeitaktivitäten und Tourismus vergleichen sowie Lösungsansätze der Raumplanung bei Nutzungskonflikten erörtern.', 10
FROM kompetenzen k WHERE k.key_slug='tourismus-raumentwicklung';
INSERT INTO lehrplan_anchors
(kompetenz_id, country, anchor_type, region, school_type, grade, subject, title, reference, reference_url, quote, sort_order)
SELECT k.id, 'AT', 'fach', NULL, 'MS / AHS-U', '3. Klasse (7. Schulstufe)',
'Geografie und wirtschaftliche Bildung',
'Tourismusregionen charakterisieren und verorten',
'BMBWF GW-Lehrplan Sek I 2023, Kompetenz 3.6',
'https://www.ris.bka.gv.at/eli/bgbl/II/2023/1',
'Industrieregionen, Tourismusregionen und landwirtschaftlich geprägte Regionen charakterisieren und verorten.', 20
FROM kompetenzen k WHERE k.key_slug='tourismus-raumentwicklung';
INSERT INTO lehrplan_anchors
(kompetenz_id, country, anchor_type, region, school_type, grade, subject, title, reference, reference_url, quote, sort_order)
SELECT k.id, 'AT', 'fach', NULL, 'MS / AHS-U', '3. Klasse (7. Schulstufe)',
'Geografie und wirtschaftliche Bildung',
'Wandel von Standortfaktoren und Standortentscheidungen',
'BMBWF GW-Lehrplan Sek I 2023, Kompetenz 3.5',
'https://www.ris.bka.gv.at/eli/bgbl/II/2023/1',
'Den Wandel von Standortfaktoren und Standortentscheidungen sowie ihre Folgen für die weitere Entwicklung Österreichs erörtern.', 30
FROM kompetenzen k WHERE k.key_slug='tourismus-raumentwicklung';
INSERT INTO lehrplan_anchors
(kompetenz_id, country, anchor_type, region, school_type, grade, subject, title, reference, reference_url, quote, sort_order)
SELECT k.id, 'AT', 'fach', NULL, 'MS / AHS-U', '3. Klasse (7. Schulstufe)',
'Geografie und wirtschaftliche Bildung',
'Folgen der Ansiedelung eines Unternehmens in einer Region',
'BMBWF GW-Lehrplan Sek I 2023, Kompetenz 3.7',
'https://www.ris.bka.gv.at/eli/bgbl/II/2023/1',
'Persönliche, gesellschaftliche, ökonomische und ökologische Folgen der Ansiedelung eines Unternehmens in einer Region, ua. neue Anforderungen hinsichtlich Infrastruktur sowie demografische und strukturelle Veränderungen erkennen.', 40
FROM kompetenzen k WHERE k.key_slug='tourismus-raumentwicklung';
INSERT INTO lehrplan_anchors
(kompetenz_id, country, anchor_type, region, school_type, grade, subject, title, reference, reference_url, quote, sort_order)
SELECT k.id, 'AT', 'fach', NULL, 'MS / AHS-U', '3. Klasse (7. Schulstufe)',
'Geografie und wirtschaftliche Bildung',
'Preisbildung auf Märkten: Angebot und Nachfrage',
'BMBWF GW-Lehrplan Sek I 2023, Kompetenz 3.8',
'https://www.ris.bka.gv.at/eli/bgbl/II/2023/1',
'Die Preisbildung auf Märkten unter Berücksichtigung von Angebot und Nachfrage sowie Produktionskosten erläutern.', 50
FROM kompetenzen k WHERE k.key_slug='tourismus-raumentwicklung';
INSERT INTO lehrplan_anchors
(kompetenz_id, country, anchor_type, region, school_type, grade, subject, title, reference, reference_url, quote, sort_order)
SELECT k.id, 'AT', 'fach', NULL, 'MS / AHS-U', '3. Klasse (7. Schulstufe)',
'Geografie und wirtschaftliche Bildung',
'Lebensqualität in zentralen und peripheren Räumen',
'BMBWF GW-Lehrplan Sek I 2023, Kompetenz 3.16',
'https://www.ris.bka.gv.at/eli/bgbl/II/2023/1',
'Die Gestaltung von zentralen und peripheren Lebensräumen vergleichen und deren Lebensqualität individuell bewerten.', 60
FROM kompetenzen k WHERE k.key_slug='tourismus-raumentwicklung';
INSERT INTO lehrplan_anchors
(kompetenz_id, country, anchor_type, region, school_type, grade, subject, title, reference, reference_url, quote, sort_order)
SELECT k.id, 'AT', 'fach', NULL, 'MS / AHS-U', '4. Klasse (8. Schulstufe)',
'Geografie und wirtschaftliche Bildung',
'Klimawandel-Folgen für Leben und Wirtschaften',
'BMBWF GW-Lehrplan Sek I 2023, Kompetenz 4.2 / 4.10',
'https://www.ris.bka.gv.at/eli/bgbl/II/2023/1',
'Folgen der Überschreitung von Belastungsgrenzen der Erde, des Klimawandels oder des Artensterbens auf das Leben und Wirtschaften erörtern; nachhaltige wirtschaftliche Aktivitäten vor dem Hintergrund von Klimawandel und Ressourcenknappheit diskutieren.', 70
FROM kompetenzen k WHERE k.key_slug='tourismus-raumentwicklung';
INSERT INTO lehrplan_anchors
(kompetenz_id, country, anchor_type, region, school_type, grade, subject, title, reference, reference_url, quote, sort_order)
SELECT k.id, 'AT', 'fach', NULL, 'MS / AHS-U', '2. Klasse (6. Schulstufe)',
'Geografie und wirtschaftliche Bildung',
'Projektideen unter Knappheit entwickeln (Entrepreneurship Education)',
'BMBWF GW-Lehrplan Sek I 2023, Kompetenz 2.8',
'https://www.ris.bka.gv.at/eli/bgbl/II/2023/1',
'Einfache Projektideen unter Analyse von Angebot, Nachfrage, Ressourceneinsatz, Knappheit und Preisgestaltung entwickeln, umsetzen und Auswirkungen reflektieren. Anwendungsbereich: Unternehmerisches Denken und Handeln.', 80
FROM kompetenzen k WHERE k.key_slug='tourismus-raumentwicklung';
INSERT INTO lehrplan_anchors
(kompetenz_id, country, anchor_type, region, school_type, grade, subject, title, reference, reference_url, quote, sort_order)
SELECT k.id, 'AT', 'uebergreifendes_thema', NULL, 'MS / AHS-U', '1.-4. Klasse',
'Übergreifende Themen (Allgemeiner Teil)',
'Wirtschafts-, Finanz- und Verbraucher/innenbildung · Entrepreneurship Education · Umweltbildung für nachhaltige Entwicklung',
'Lehrplan 2023, Allgemeiner Teil, übergreifende Themen Nr. 13, 2 und 11',
'https://www.ris.bka.gv.at/eli/bgbl/II/2023/1',
'Übergreifendes Thema Nr. 11 nennt Rollen-, Plan- sowie Simulationsspiele ausdrücklich als geeignete Methode.', 90
FROM kompetenzen k WHERE k.key_slug='tourismus-raumentwicklung';
-- ---- Deutschland ----
INSERT INTO lehrplan_anchors
(kompetenz_id, country, anchor_type, region, school_type, grade, subject, title, reference, reference_url, quote, sort_order)
SELECT k.id, 'DE', 'fach', 'Nordrhein-Westfalen', 'Gymnasium Sek I (G9)', 'Jg. 7/8',
'Erdkunde',
'Räumliche Voraussetzungen und Auswirkungen des Tourismus',
'Kernlehrplan NRW Erdkunde G9 (2019), Inhaltsfeld Tourismus',
'https://www.schulentwicklung.nrw.de/lehrplaene/lehrplan/200/g9_ek_klp_3408_2019_06_23.pdf',
'Den Zielkonflikt zwischen wirtschaftlichem Wachstum durch Tourismus und nachhaltiger Entwicklung in Tourismusregionen erörtern; Raumnutzungskonflikte und Gefährdung des Naturraums.', 100
FROM kompetenzen k WHERE k.key_slug='tourismus-raumentwicklung';
INSERT INTO lehrplan_anchors
(kompetenz_id, country, anchor_type, region, school_type, grade, subject, title, reference, reference_url, quote, sort_order)
SELECT k.id, 'DE', 'fach', 'Bayern', 'Gymnasium', 'Jg. 7',
'Geographie',
'Alpen: naturräumliches Potenzial und touristische Nutzung',
'LehrplanPLUS Bayern, Geographie Jg. 7',
'https://www.lehrplanplus.bayern.de/fachlehrplan/gymnasium/7/geographie',
'Naturräumliches Potenzial und touristische Nutzung der Alpen, Naturgefahren und Schutzmaßnahmen.', 110
FROM kompetenzen k WHERE k.key_slug='tourismus-raumentwicklung';
INSERT INTO lehrplan_anchors
(kompetenz_id, country, anchor_type, region, school_type, grade, subject, title, reference, reference_url, quote, sort_order)
SELECT k.id, 'DE', 'fach', NULL, 'Sek I (alle Schulformen)', 'bis MSA',
'Geographie',
'Mensch-Umwelt-System: Beurteilung und Bewertung von Raumnutzung',
'DGfG-Bildungsstandards Geographie (10. Aufl. 2020)',
'https://vgdh.geographie.de/wp-content/docs/Bildungsstandards_Geographie_2020_Web.pdf',
'Kompetenzbereiche Fachwissen, Beurteilung/Bewertung und Handlung; Leitkonzept Mensch-Umwelt-System.', 120
FROM kompetenzen k WHERE k.key_slug='tourismus-raumentwicklung';
-- ---- Schweiz (Lehrplan 21, Zyklus 3) ----
INSERT INTO lehrplan_anchors
(kompetenz_id, country, anchor_type, region, school_type, grade, subject, title, reference, reference_url, quote, sort_order)
SELECT k.id, 'CH', 'fach', NULL, 'Sekundarstufe I', 'Zyklus 3',
'Räume, Zeiten, Gesellschaften (RZG)',
'Die Bedeutung des Tourismus einschätzen',
'Lehrplan 21, RZG.2.5',
'https://ag.lehrplan.ch/index.php?code=a|6|4|2|0|5',
'Tourismusformen im schweizerischen Alpenraum, wirtschaftliche Bedeutung, Einfluss auf Gebirgsregionen, nachhaltiger Tourismus.', 130
FROM kompetenzen k WHERE k.key_slug='tourismus-raumentwicklung';
INSERT INTO lehrplan_anchors
(kompetenz_id, country, anchor_type, region, school_type, grade, subject, title, reference, reference_url, quote, sort_order)
SELECT k.id, 'CH', 'fach', NULL, 'Sekundarstufe I', 'Zyklus 3',
'Räume, Zeiten, Gesellschaften (RZG)',
'Raumplanung, Raumnutzungskonflikte und nachhaltige Raumentwicklung',
'Lehrplan 21, RZG.3.3 / RZG.3.1',
'https://ag.lehrplan.ch/',
'Raumnutzung und Raumplanung analysieren; natürliche Systeme (Gebirge) und deren Nutzung durch Landwirtschaft, Tourismus und Siedlung; Nutzungswandel.', 140
FROM kompetenzen k WHERE k.key_slug='tourismus-raumentwicklung';
@@ -0,0 +1,37 @@
-- ============================================================
-- Tourismustal: Modul-Registrierung (module_info)
-- Stand: 2026-07-11 · Atlas
-- Idempotent (ON DUPLICATE KEY UPDATE), Emoji als Hex-Literal.
-- Karte: App/assets/img/card-tourismustal.webp (liegt im Repo)
-- ============================================================
INSERT INTO module_info (
module_id, title, subtitle, short_desc, long_desc, learning_goals,
short_desc_easy, long_desc_easy, learning_goals_easy,
duration_min, age_min, age_max, icon, card_image, page_url, play_url,
status, sort_order
) VALUES (
'tourismustal',
'Tourismustal',
'Entwickle ein Alpental zur Tourismusregion',
'Baue aus einem stillen Alpental Schritt für Schritt eine Tourismusregion auf und halte Wirtschaft, Personal, Schnee und Natur im Gleichgewicht.',
'Du übernimmst 1975 die Entwicklung eines kleinen Alpentals: Gasthaus, Lift, Hotel, Personal jede Entscheidung kostet Geld und wirkt auf Natur und Zufriedenheit. Der Skipass-Preis regelt Angebot und Nachfrage, der Tagesbericht rechnet dir jede Bilanz vor. In drei Szenen erlebst du die echte Geschichte des Alpentourismus: Pionierzeit 1975, Boom 2000 mit Übertourismus und Bürgerinitiative, und Schneegrenze 2035 wo nur besteht, wer den Ganzjahrestourismus schafft. Mit echten Wirtschaftsdaten aus Österreich und der Schweiz.',
'Standortfaktoren und Standortentscheidungen erörtern · Tourismusregionen charakterisieren · Preisbildung über Angebot und Nachfrage verstehen · Raumnutzungskonflikte und Raumplanung abwägen · Folgen des Klimawandels für das Wirtschaften erkennen · unternehmerisch denken und nachhaltig entscheiden (GW-Lehrplan 2023: 3.53.8, 3.16, 3.17, 4.2, 4.10)',
'Baue ein Berg-Tal zu einem Urlaubs-Ort aus. Achte auf Geld, Schnee, Natur und zufriedene Gäste.',
'Du baust ein kleines Berg-Tal auf: erst ein Gasthaus, dann Lift und Hotel. Alles kostet Geld. Die Natur darf nicht kaputt gehen. In drei Zeiten spielst du: früher (1975), im Boom (2000) und in der Zukunft (2035), wenn es weniger Schnee gibt.',
'Du lernst: Wo baut man was am besten? Wie entsteht ein Preis? Warum braucht ein Ort Gäste im Sommer UND im Winter? Was macht der Klima-Wandel mit dem Schnee?',
30, 12, 15,
0xF09F8F94EFB88F,
'card-tourismustal.webp',
'modul-tourismustal',
'tourismustal',
'beta', 94
)
ON DUPLICATE KEY UPDATE
title=VALUES(title), subtitle=VALUES(subtitle), short_desc=VALUES(short_desc),
long_desc=VALUES(long_desc), learning_goals=VALUES(learning_goals),
short_desc_easy=VALUES(short_desc_easy), long_desc_easy=VALUES(long_desc_easy),
learning_goals_easy=VALUES(learning_goals_easy),
duration_min=VALUES(duration_min), age_min=VALUES(age_min), age_max=VALUES(age_max),
icon=VALUES(icon), card_image=VALUES(card_image), page_url=VALUES(page_url),
play_url=VALUES(play_url), status=VALUES(status), sort_order=VALUES(sort_order);
+9
View File
@@ -0,0 +1,9 @@
<?php
/**
* Modul-Detailseite: Tourismustal
* URL: http://localhost/geograsim/App/modul-tourismustal
*
* Logik im gemeinsamen Partial.
*/
$MODULE_ID = 'tourismustal';
require __DIR__ . '/_partials/modul_detail.php';
+105
View File
@@ -0,0 +1,105 @@
<?php
/**
* Tourismustal — Wrapper
* Laedt sims/tourismustal/game.html und injiziert Base + Session-Mode.
*
* Integrations-Vertrag §6.1: mode + easy aus der Schueler-Session.
* Die Sim ist ES-Module-basiert mit relativen Pfaden (css/, js/, assets/) —
* statt Pfad-Rewrites setzen wir einen <base>-Tag auf den Sim-Ordner; damit
* loesen sich auch die zur Laufzeit gefetchten Audio-/Musik-Pfade korrekt auf.
*
* Stand 2026-07-11: Erst-Integration (beta) — Atlas.
*/
require_once __DIR__ . '/../php/config/app.php';
require_once __DIR__ . '/../php/config/db.php';
$db = getDB();
// ---- Mode-Check (Integrations-Vertrag §6.1) ----
$sessionId = $_COOKIE['ggs_session'] ?? null;
$mode = 'free';
$easy = false;
if ($sessionId) {
$stmt = $db->prepare('SELECT class_id, display_name FROM student_sessions WHERE id = ?');
$stmt->execute([$sessionId]);
$sess = $stmt->fetch(PDO::FETCH_ASSOC);
if ($sess && !empty($sess['class_id'])) {
$classId = (int)$sess['class_id'];
$cm = $db->prepare('SELECT mode FROM class_modules WHERE class_id = ? AND module_id = ?');
$cm->execute([$classId, 'tourismustal']);
$cmRow = $cm->fetch(PDO::FETCH_ASSOC);
$mode = $cmRow ? $cmRow['mode'] : 'locked';
$so = $db->prepare(
'SELECT sm.mode FROM student_modules sm
JOIN students s ON s.id = sm.student_id
WHERE s.class_id = ? AND s.display_name = ? AND sm.module_id = ?'
);
$so->execute([$classId, $sess['display_name'], 'tourismustal']);
$soRow = $so->fetch(PDO::FETCH_ASSOC);
if ($soRow) $mode = $soRow['mode'];
$eq = $db->prepare('SELECT easy_language FROM students WHERE class_id = ? AND display_name = ?');
$eq->execute([$classId, $sess['display_name']]);
$eqRow = $eq->fetch(PDO::FETCH_ASSOC);
if ($eqRow) $easy = !empty($eqRow['easy_language']);
}
}
if ($mode === 'locked') {
http_response_code(403);
header('Content-Type: text/html; charset=utf-8');
$cssHref = BASE_PATH . '/assets/css/design-system.css';
$cockpit = BASE_PATH . '/schueler';
echo <<<HTML
<!DOCTYPE html><html lang="de"><head>
<meta charset="utf-8"><title>Gesperrt · Tourismustal</title>
<link rel="stylesheet" href="{$cssHref}">
<style>body{font-family:system-ui,sans-serif;padding:40px;max-width:640px;margin:auto;color:#333}
h1{color:#404040}.lock-card{background:#f5f3ea;border:1px solid #e2ddd1;padding:24px;border-radius:8px}</style>
</head><body>
<div class="lock-card">
<h1>🔒 Tourismustal ist gesperrt.</h1>
<p>Deine Lehrperson hat dieses Modul für eure Klasse noch nicht freigegeben.</p>
<p>Im <a href="{$cockpit}">Cockpit</a> findest du die derzeit freigegebenen Module.</p>
</div>
</body></html>
HTML;
exit;
}
$gamePath = __DIR__ . '/../sims/tourismustal/game.html';
if (!is_file($gamePath)) {
http_response_code(503);
echo 'Tourismustal ist in Vorbereitung.';
exit;
}
$html = file_get_contents($gamePath);
$modeJs = json_encode($mode);
$easyJs = $easy ? 'true' : 'false';
$sessionIdJs = json_encode($sessionId);
$injection =
"window.TT_SESSION_MODE = {$modeJs};\n" .
"window.TT_SESSION_ID = {$sessionIdJs};\n" .
"window.STUDENT_EASY = {$easyJs};\n" .
"window.TT_API_BASE = '" . BASE_PATH . "/php/api';";
if (strpos($html, '/*__TOURISMUSTAL_INJECTION__*/') !== false) {
$html = str_replace('/*__TOURISMUSTAL_INJECTION__*/', $injection, $html);
} else {
$html = str_replace('</head>', "<script>$injection</script>\n</head>", $html);
}
// <base> auf den Sim-Ordner: loest css/, js/ (ES-Module), assets/…
// inklusive der zur Laufzeit gesetzten Audio-Quellen korrekt auf.
$base = BASE_PATH . '/sims/tourismustal/';
$html = preg_replace('/<head>/', "<head>\n<base href=\"{$base}\">", $html, 1);
// Home-Button: auf das gerade gueltige Cockpit umbiegen
$cockpit = cockpit_href();
$html = str_replace("window.location.href='../../schueler.html'", "window.location.href='" . $cockpit . "'", $html);
if (function_exists('ggs_inject_live')) $html = ggs_inject_live($html, 'tourismustal');
echo $html;
+2
View File
@@ -621,6 +621,8 @@ if ($method === 'GET') {
return ($state['balance'] ?? 1000) < 0;
case 'eu-werkstatt':
return ($state['attemptsThisStep'] ?? 0) >= 6;
case 'tourismustal':
return ($state['money'] ?? 1) < 0 || ($state['lossStreak'] ?? 0) >= 8;
}
return false;
};
+7
View File
@@ -74,6 +74,13 @@ $MODULE_HIGHLIGHTS = [
['key'=>'routes_completed','label'=>'Routen', 'agg'=>'max'],
['key'=>'avg_accuracy', 'label'=>'Genauigkeit','agg'=>'max','format'=>'percent'],
],
'tourismustal' => [
['key'=>'results.money', 'label'=>'Konto', 'agg'=>'max', 'unit'=>'€'],
['key'=>'results.maxGuests', 'label'=>'Gäste-Rekord','agg'=>'max'],
['key'=>'results.maxSummerGuests', 'label'=>'Sommer-Rekord','agg'=>'max'],
['key'=>'results.nature', 'label'=>'Naturwert', 'agg'=>'max', 'unit'=>'%'],
['key'=>'results.goalsDone', 'label'=>'Ziele', 'agg'=>'max'],
],
];
// Hilfsfunktion: holt einen Wert aus einem JSON-Pfad ("results.money" → $json['results']['money']).
+22
View File
@@ -52,6 +52,7 @@ class Benchmark
'fluss' => self::scoreFluss($results),
'fluggesellschaft' => self::scoreFluggesellschaft($results),
'kofferdetektiv' => self::scoreKofferdetektiv($results),
'tourismustal' => self::scoreTourismustal($results),
// Entscheidungstag: kein assessment-Submit-Pfad bekannt → null
default => self::scoreFallbackStars($results),
};
@@ -437,6 +438,27 @@ class Benchmark
return (int)round(max(0, min(100, $base + $clean + $sparse - $malus)));
}
/**
* Tourismustal — Zielerreichung × Zufriedenheit × Naturwert Schulden-Malus.
* 50 % Szenen-Ziele + 25 % Gäste-Zufriedenheit + 25 % Naturwert,
* 15 wenn das Konto im Minus endet (Rücklagen sind Teil der Didaktik).
* Felder aus dem Submit-Hook (game.html → progress.php submit_assessment).
*/
private static function scoreTourismustal(array $r): ?int
{
$goalsDone = $r['goalsDone'] ?? null;
$goalsTotal = (int)($r['goalsTotal'] ?? 0);
if (!is_numeric($goalsDone) || $goalsTotal <= 0) return null;
$sat = (float)($r['satisfaction'] ?? 0);
$nature = (float)($r['nature'] ?? 0);
$money = (float)($r['money'] ?? 0);
$score = ($goalsDone / $goalsTotal) * 50
+ ($sat / 100) * 25
+ ($nature / 100) * 25
- ($money < 0 ? 15 : 0);
return (int)round(max(0, min(100, $score)));
}
/**
* Generischer Fallback: Sterne aus 5-er-Skala.
*/
+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);
}
}
+25
View File
@@ -592,6 +592,31 @@ function _liveBar(val, min, max, goodWhen) {
}
var LIVE_PRIMARY_FIELDS = {
'tourismustal': [
{k:'scene', label:'Szene', align:'left', fmt:function(v){
return {pionierzeit:'⛏️ 1975', boom:'📈 Boom 2000', klimawandel:'🌡️ 2035'}[v] || v || '—';
}},
{k:'season', label:'Saison', align:'center'},
{k:'year', label:'Jahr'},
{k:'money', label:'Konto', fmt:function(v){
if (v === undefined) return '—';
var color = v < 0 ? '#c0392b' : v < 4000 ? '#c9913a' : '#5a8a5e';
return '<span style="color:'+color+';font-weight:700">'+Number(v).toLocaleString('de-AT')+' €</span>';
}},
{k:'guestsToday', label:'Gäste'},
{k:'satisfaction', label:'Zufr.', fmt:function(v){ return _liveBar(v, 0, 100, 'high'); }},
{k:'nature', label:'Natur', fmt:function(v){ return _liveBar(v, 0, 100, 'high'); }},
{k:'goalsDone', label:'Ziele', fmt:function(v, row){
var s = row.state || {};
if (v === undefined || !s.goalsTotal) return '—';
var color = v === s.goalsTotal ? '#5a8a5e' : v >= 2 ? '#c9913a' : '#8a8a8a';
return '<span style="color:'+color+';font-weight:700">'+v+'/'+s.goalsTotal+'</span>';
}},
{k:'buildings', label:'Geb.'},
{k:'bottleneck', label:'Engpass', align:'left', fmt:function(v){
return v ? '<span style="color:#c07a6b">⚠️ '+v+'</span>' : '<span style="color:#8a8a8a">—</span>';
}},
],
klima: [
{k:'level', label:'Stufe'},
{k:'tick', label:'Tick'},