diff --git a/.dontDeploy/tourismustal-lehrplan-recherche-2026-07-08.md b/.dontDeploy/tourismustal-lehrplan-recherche-2026-07-08.md new file mode 100644 index 0000000..2d01585 --- /dev/null +++ b/.dontDeploy/tourismustal-lehrplan-recherche-2026-07-08.md @@ -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`. diff --git a/App/Don_t_Deploy/2026-07-11-tourismustal-glossar.sql b/App/Don_t_Deploy/2026-07-11-tourismustal-glossar.sql new file mode 100644 index 0000000..4105521 --- /dev/null +++ b/App/Don_t_Deploy/2026-07-11-tourismustal-glossar.sql @@ -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 (5–9 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'; diff --git a/App/Don_t_Deploy/2026-07-11-tourismustal-lehrplan.sql b/App/Don_t_Deploy/2026-07-11-tourismustal-lehrplan.sql new file mode 100644 index 0000000..9ac81e2 --- /dev/null +++ b/App/Don_t_Deploy/2026-07-11-tourismustal-lehrplan.sql @@ -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'; diff --git a/App/Don_t_Deploy/2026-07-11-tourismustal-module-info.sql b/App/Don_t_Deploy/2026-07-11-tourismustal-module-info.sql new file mode 100644 index 0000000..a0990b8 --- /dev/null +++ b/App/Don_t_Deploy/2026-07-11-tourismustal-module-info.sql @@ -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.5–3.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); diff --git a/App/pages/modul-tourismustal.php b/App/pages/modul-tourismustal.php new file mode 100644 index 0000000..00516e9 --- /dev/null +++ b/App/pages/modul-tourismustal.php @@ -0,0 +1,9 @@ +-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 << +Gesperrt · Tourismustal + + + +
+

🔒 Tourismustal ist gesperrt.

+

Deine Lehrperson hat dieses Modul für eure Klasse noch nicht freigegeben.

+

Im Cockpit findest du die derzeit freigegebenen Module.

+
+ +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('', "\n", $html); +} + +// 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('//', "\n", $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; diff --git a/App/php/api/live.php b/App/php/api/live.php index 06a6fd7..09b84ab 100644 --- a/App/php/api/live.php +++ b/App/php/api/live.php @@ -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; }; diff --git a/App/php/api/results.php b/App/php/api/results.php index 8fa7e3e..5553995 100644 --- a/App/php/api/results.php +++ b/App/php/api/results.php @@ -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']). diff --git a/App/php/lib/Benchmark.php b/App/php/lib/Benchmark.php index c53c967..8397b74 100644 --- a/App/php/lib/Benchmark.php +++ b/App/php/lib/Benchmark.php @@ -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. */ diff --git a/App/sims/tourismustal/KONZEPT.md b/App/sims/tourismustal/KONZEPT.md new file mode 100644 index 0000000..2f48ba9 --- /dev/null +++ b/App/sims/tourismustal/KONZEPT.md @@ -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 (13–14 J.). + +## Lehrplan-Anker (GW-Lehrplan 2023, AT) + +- 3.5–3.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) diff --git a/App/sims/tourismustal/LEHRPLAN.md b/App/sims/tourismustal/LEHRPLAN.md new file mode 100644 index 0000000..aac04d3 --- /dev/null +++ b/App/sims/tourismustal/LEHRPLAN.md @@ -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). diff --git a/App/sims/tourismustal/QUELLEN.md b/App/sims/tourismustal/QUELLEN.md new file mode 100644 index 0000000..07867d3 --- /dev/null +++ b/App/sims/tourismustal/QUELLEN.md @@ -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 +30–38 %); ≈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 1960–2020 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 | Nov–Apr −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 ~20–25 % in 10 Jahren (Sommerertrag 2014–2022 +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,3–4,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 ≈ 70–80 % eines Wintergast-Tags (Gesamtausgaben) — in der Sim via + `summerSpend` < Winterausgaben abgebildet. +2. Bergbahn-Sommerticket klein halten (real: 1 Wintergast ≙ 1,3–4,2 Sommergäste) — + in der Sim: Sommer-Bergfahrt 8 € vs. Skipass 15–45 €. ✓ 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. diff --git a/App/sims/tourismustal/assets/sounds/amb_birds.mp3 b/App/sims/tourismustal/assets/sounds/amb_birds.mp3 new file mode 100644 index 0000000..72219a5 Binary files /dev/null and b/App/sims/tourismustal/assets/sounds/amb_birds.mp3 differ diff --git a/App/sims/tourismustal/assets/sounds/amb_cow.mp3 b/App/sims/tourismustal/assets/sounds/amb_cow.mp3 new file mode 100644 index 0000000..61a2363 Binary files /dev/null and b/App/sims/tourismustal/assets/sounds/amb_cow.mp3 differ diff --git a/App/sims/tourismustal/assets/sounds/amb_cowbell.mp3 b/App/sims/tourismustal/assets/sounds/amb_cowbell.mp3 new file mode 100644 index 0000000..9b2777e Binary files /dev/null and b/App/sims/tourismustal/assets/sounds/amb_cowbell.mp3 differ diff --git a/App/sims/tourismustal/assets/sounds/amb_wind.mp3 b/App/sims/tourismustal/assets/sounds/amb_wind.mp3 new file mode 100644 index 0000000..b8b99e2 Binary files /dev/null and b/App/sims/tourismustal/assets/sounds/amb_wind.mp3 differ diff --git a/App/sims/tourismustal/assets/sounds/build.mp3 b/App/sims/tourismustal/assets/sounds/build.mp3 new file mode 100644 index 0000000..ca6571f Binary files /dev/null and b/App/sims/tourismustal/assets/sounds/build.mp3 differ diff --git a/App/sims/tourismustal/assets/sounds/card.mp3 b/App/sims/tourismustal/assets/sounds/card.mp3 new file mode 100644 index 0000000..2a61d31 Binary files /dev/null and b/App/sims/tourismustal/assets/sounds/card.mp3 differ diff --git a/App/sims/tourismustal/assets/sounds/click.mp3 b/App/sims/tourismustal/assets/sounds/click.mp3 new file mode 100644 index 0000000..6239327 Binary files /dev/null and b/App/sims/tourismustal/assets/sounds/click.mp3 differ diff --git a/App/sims/tourismustal/assets/sounds/demolish.mp3 b/App/sims/tourismustal/assets/sounds/demolish.mp3 new file mode 100644 index 0000000..ba9867c Binary files /dev/null and b/App/sims/tourismustal/assets/sounds/demolish.mp3 differ diff --git a/App/sims/tourismustal/assets/sounds/error.mp3 b/App/sims/tourismustal/assets/sounds/error.mp3 new file mode 100644 index 0000000..3ec81e1 Binary files /dev/null and b/App/sims/tourismustal/assets/sounds/error.mp3 differ diff --git a/App/sims/tourismustal/assets/sounds/milestone.mp3 b/App/sims/tourismustal/assets/sounds/milestone.mp3 new file mode 100644 index 0000000..801679a Binary files /dev/null and b/App/sims/tourismustal/assets/sounds/milestone.mp3 differ diff --git a/App/sims/tourismustal/assets/sounds/success.mp3 b/App/sims/tourismustal/assets/sounds/success.mp3 new file mode 100644 index 0000000..804e848 Binary files /dev/null and b/App/sims/tourismustal/assets/sounds/success.mp3 differ diff --git a/App/sims/tourismustal/assets/sounds/win.mp3 b/App/sims/tourismustal/assets/sounds/win.mp3 new file mode 100644 index 0000000..e88e18f Binary files /dev/null and b/App/sims/tourismustal/assets/sounds/win.mp3 differ diff --git a/App/sims/tourismustal/assets/sprites/access.png b/App/sims/tourismustal/assets/sprites/access.png new file mode 100644 index 0000000..69fdafc Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/access.png differ diff --git a/App/sims/tourismustal/assets/sprites/badesee.png b/App/sims/tourismustal/assets/sprites/badesee.png new file mode 100644 index 0000000..9bc07d9 Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/badesee.png differ diff --git a/App/sims/tourismustal/assets/sprites/bikepark.png b/App/sims/tourismustal/assets/sprites/bikepark.png new file mode 100644 index 0000000..8f6c7b5 Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/bikepark.png differ diff --git a/App/sims/tourismustal/assets/sprites/bikerental.png b/App/sims/tourismustal/assets/sprites/bikerental.png new file mode 100644 index 0000000..2d99ecc Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/bikerental.png differ diff --git a/App/sims/tourismustal/assets/sprites/broadleaf.png b/App/sims/tourismustal/assets/sprites/broadleaf.png new file mode 100644 index 0000000..c0ff5bd Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/broadleaf.png differ diff --git a/App/sims/tourismustal/assets/sprites/church.png b/App/sims/tourismustal/assets/sprites/church.png new file mode 100644 index 0000000..a4a540e Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/church.png differ diff --git a/App/sims/tourismustal/assets/sprites/fir.png b/App/sims/tourismustal/assets/sprites/fir.png new file mode 100644 index 0000000..7d4298b Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/fir.png differ diff --git a/App/sims/tourismustal/assets/sprites/fir_snow.png b/App/sims/tourismustal/assets/sprites/fir_snow.png new file mode 100644 index 0000000..cb0c5e7 Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/fir_snow.png differ diff --git a/App/sims/tourismustal/assets/sprites/hotel.png b/App/sims/tourismustal/assets/sprites/hotel.png new file mode 100644 index 0000000..0ae480f Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/hotel.png differ diff --git a/App/sims/tourismustal/assets/sprites/hotel2.png b/App/sims/tourismustal/assets/sprites/hotel2.png new file mode 100644 index 0000000..b641cda Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/hotel2.png differ diff --git a/App/sims/tourismustal/assets/sprites/house.png b/App/sims/tourismustal/assets/sprites/house.png new file mode 100644 index 0000000..045fbed Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/house.png differ diff --git a/App/sims/tourismustal/assets/sprites/platform.png b/App/sims/tourismustal/assets/sprites/platform.png new file mode 100644 index 0000000..7ac20e6 Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/platform.png differ diff --git a/App/sims/tourismustal/assets/sprites/playground.png b/App/sims/tourismustal/assets/sprites/playground.png new file mode 100644 index 0000000..2a832fe Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/playground.png differ diff --git a/App/sims/tourismustal/assets/sprites/rescue.png b/App/sims/tourismustal/assets/sprites/rescue.png new file mode 100644 index 0000000..89647a3 Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/rescue.png differ diff --git a/App/sims/tourismustal/assets/sprites/restaurant.png b/App/sims/tourismustal/assets/sprites/restaurant.png new file mode 100644 index 0000000..777db5a Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/restaurant.png differ diff --git a/App/sims/tourismustal/assets/sprites/ropes.png b/App/sims/tourismustal/assets/sprites/ropes.png new file mode 100644 index 0000000..83bbb2b Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/ropes.png differ diff --git a/App/sims/tourismustal/assets/sprites/sled.png b/App/sims/tourismustal/assets/sprites/sled.png new file mode 100644 index 0000000..8db5825 Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/sled.png differ diff --git a/App/sims/tourismustal/assets/sprites/snow.png b/App/sims/tourismustal/assets/sprites/snow.png new file mode 100644 index 0000000..4531fc2 Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/snow.png differ diff --git a/App/sims/tourismustal/assets/sprites/staffhouse.png b/App/sims/tourismustal/assets/sprites/staffhouse.png new file mode 100644 index 0000000..2f3cb12 Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/staffhouse.png differ diff --git a/App/sims/tourismustal/assets/sprites/trail.png b/App/sims/tourismustal/assets/sprites/trail.png new file mode 100644 index 0000000..c174e7e Binary files /dev/null and b/App/sims/tourismustal/assets/sprites/trail.png differ diff --git a/App/sims/tourismustal/css/style.css b/App/sims/tourismustal/css/style.css new file mode 100644 index 0000000..648742b --- /dev/null +++ b/App/sims/tourismustal/css/style.css @@ -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; } +} diff --git a/App/sims/tourismustal/game.html b/App/sims/tourismustal/game.html new file mode 100644 index 0000000..1e3baf5 --- /dev/null +++ b/App/sims/tourismustal/game.html @@ -0,0 +1,199 @@ + + + + + + +Tourismustal – GeoGraSim + + + + + + + +
+ +

Tourismustal

+
+
+
+ + + + +
+ + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+
+
🏔️
+

Tourismustal

+

Entwickle ein Alpental zur Tourismusregion – und halte + Wirtschaft, Personal, Schnee und Natur im Gleichgewicht.

+ +
+ + + +
+
+
+ + + + + diff --git a/App/sims/tourismustal/js/audio.js b/App/sims/tourismustal/js/audio.js new file mode 100644 index 0000000..8444dae --- /dev/null +++ b/App/sims/tourismustal/js/audio.js @@ -0,0 +1,157 @@ +// Klang: KI-generierte Samples (ElevenLabs, assets/sounds/*.mp3) mit +// WebAudio abgespielt; fehlt eine Datei, greift ein kleiner Synth-Fallback. +// Dazu saisonale Atmosphäre (Sommerwiese/Winterwind) als Loop. +// AudioContext entsteht erst nach der ersten Nutzer-Geste (iOS/Safari). + +const SAMPLES = ['click', 'build', 'demolish', 'success', 'error', + 'card', 'milestone', 'win', 'amb_birds', 'amb_cow', 'amb_cowbell', 'amb_wind']; + +export const audio = { + ctx: null, + soundOn: true, + musicOn: true, + _buffers: {}, // Name → AudioBuffer + _raw: {}, // Name → ArrayBuffer (vor dem Decodieren) + _season: 'Sommer', + + // MP3s sofort laden (ohne AudioContext), decodiert wird bei ensure() + preload() { + for (const name of SAMPLES) { + fetch(`assets/sounds/${name}.mp3`) + .then(r => (r.ok ? r.arrayBuffer() : null)) + .then(buf => { if (buf) { this._raw[name] = buf; this._decode(name); } }) + .catch(() => {}); + } + }, + + _decode(name) { + if (!this.ctx || !this._raw[name] || this._buffers[name]) return; + this.ctx.decodeAudioData(this._raw[name].slice(0)) + .then(b => { + this._buffers[name] = b; + }) + .catch(() => {}); + }, + + ensure() { + if (!this.ctx) { + const AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return false; + this.ctx = new AC(); + for (const name of Object.keys(this._raw)) this._decode(name); + } + if (this.ctx.state === 'suspended') this.ctx.resume(); + return true; + }, + + // ---------- Effekte ---------- + play(name, gain = 0.5) { + if (!this.soundOn || !this.ensure()) return; + const buf = this._buffers[name]; + if (buf) { + const src = this.ctx.createBufferSource(); + const g = this.ctx.createGain(); + g.gain.value = gain; + src.buffer = buf; + src.connect(g).connect(this.ctx.destination); + src.start(); + } else { + this._synthFallback(name); + } + }, + + click() { this.play('click', 0.35); }, + success() { this.play('success', 0.5); }, + error() { this.play('error', 0.45); }, + chime() { this.play('card', 0.45); }, + card() { this.chime(); }, + build() { this.play('build', 0.55); }, + demolish() { this.play('demolish', 0.55); }, + milestone() { this.play('milestone', 0.55); }, + win() { this.play('win', 0.6); }, + + _tone(freq, dur, type = 'sine', gain = 0.08, delay = 0) { + const t0 = this.ctx.currentTime + delay; + const osc = this.ctx.createOscillator(); + const g = this.ctx.createGain(); + osc.type = type; + osc.frequency.value = freq; + g.gain.setValueAtTime(0, t0); + g.gain.linearRampToValueAtTime(gain, t0 + 0.02); + g.gain.exponentialRampToValueAtTime(0.001, t0 + dur); + osc.connect(g).connect(this.ctx.destination); + osc.start(t0); + osc.stop(t0 + dur + 0.05); + }, + + _synthFallback(name) { + switch (name) { + case 'click': this._tone(660, 0.08, 'triangle', 0.05); break; + case 'success': case 'build': case 'milestone': case 'win': + this._tone(523, 0.12, 'triangle', 0.07); + this._tone(784, 0.18, 'triangle', 0.07, 0.09); + break; + case 'error': case 'demolish': this._tone(180, 0.22, 'sawtooth', 0.05); break; + default: this._tone(880, 0.3, 'sine', 0.05); + } + }, + + // ---------- Gelegentliche Naturlaute (statt Dauer-Ambience) ---------- + // Alle 10–25 s ein kurzer Laut passend zur Saison: Vögel, Kuhglocken, + // ein Muhen – im Winter meist nur ein Windhauch. + _ambTimer: null, + + setSeason(season) { + this._season = season; + }, + + _pickAmbience() { + const r = Math.random(); + if (this._season === 'Winter') { + return r < 0.6 ? 'amb_wind' : r < 0.85 ? 'amb_cowbell' : 'amb_birds'; + } + return r < 0.45 ? 'amb_birds' : r < 0.75 ? 'amb_cowbell' : 'amb_cow'; + }, + + _scheduleAmbience() { + this._ambTimer = setTimeout(() => { + if (this.soundOn && this.ctx) { + const name = this._pickAmbience(); + const buf = this._buffers[name]; + if (buf) { + const src = this.ctx.createBufferSource(); + const g = this.ctx.createGain(); + g.gain.value = 0.22; + src.buffer = buf; + src.connect(g).connect(this.ctx.destination); + src.start(); + } + } + this._scheduleAmbience(); + }, 10000 + Math.random() * 15000); + }, + + startMusic() { + if (!this.ensure()) return; + clearTimeout(this._ambTimer); + this._scheduleAmbience(); + }, + + _stopAmbience() { + clearTimeout(this._ambTimer); + this._ambTimer = null; + }, + + toggleMusic() { + this.musicOn = !this.musicOn; + if (this.musicOn) this.startMusic(); else this._stopAmbience(); + return this.musicOn; + }, + + toggleSound() { + this.soundOn = !this.soundOn; + return this.soundOn; + }, +}; + +audio.preload(); diff --git a/App/sims/tourismustal/js/data.js b/App/sims/tourismustal/js/data.js new file mode 100644 index 0000000..56b6732 --- /dev/null +++ b/App/sims/tourismustal/js/data.js @@ -0,0 +1,751 @@ +// Spieldaten: Gebäude, Szenarien, Meilensteine, Entscheidungen, Aufträge, +// Event-Karten. Alle drei Szenen sind data-driven über SCENARIOS definiert. + +export const BUILDINGS = { + lift: { + name: 'Sessellift', emoji: '🚡', cost: 12000, upkeep: 120, + zone: 'mid', staff: 4, cap: 200, pisteLen: 5, + desc: 'Bringt bis zu 200 Gäste pro Tag auf die Piste. Nur am Hang baubar.', + }, + gondola: { + name: 'Gondelbahn', emoji: '🚠', cost: 22000, upkeep: 220, + zone: 'mid', staff: 5, cap: 300, pisteLen: 8, + desc: 'Bis in die Hochlagen: 300 Gäste pro Tag und eine lange Abfahrt.', + }, + hotel: { + name: 'Hotel', emoji: '🏨', cost: 9000, upkeep: 90, + zone: 'valley', staff: 5, beds: 60, + desc: '60 Betten – Übernachtungsgäste bringen mehr Einnahmen.', + }, + restaurant: { + name: 'Gasthaus', emoji: '🍽️', cost: 4500, upkeep: 45, + zone: 'any', staff: 3, spendBonus: 7, + desc: 'Gäste geben mehr aus und sind zufriedener.', + }, + snow: { + name: 'Beschneiung', emoji: '❄️', cost: 6000, upkeep: 15, + zone: 'mid', staff: 1, snowPower: 2.5, + desc: 'Erzeugt Schnee in warmen Wintern. Braucht Wasser: nahe am Fluss bauen! Betrieb kostet 45 €/Tag.', + }, + rescue: { + name: 'Rettungsstation', emoji: '⛑️', cost: 7000, upkeep: 50, + zone: 'valley', staff: 2, satBonus: 3, + desc: 'Bergrettung mit Notarzthubschrauber: verunglückte Ski-Gäste werden automatisch geborgen – und alle fühlen sich sicherer.', + }, + access: { + name: 'Bushaltestelle', emoji: '🚌', cost: 3000, upkeep: 30, + zone: 'valley', staff: 1, access: 150, + desc: '150 weitere Gäste pro Tag. Muss direkt an der Talstraße stehen.', + }, + staffhouse: { + name: 'Personalhaus', emoji: '🏠', cost: 5000, upkeep: 25, + zone: 'valley', staff: 0, housing: 12, + desc: 'Wohnraum für 12 Mitarbeiter/innen – ohne Wohnung kein Personal.', + }, + badesee: { + name: 'Badesee', emoji: '🏖️', cost: 8000, upkeep: 60, + zone: 'valley', staff: 2, summerCap: 100, satBonus: 4, + desc: 'Badespaß für 100 Sommergäste. Braucht Wasser: nahe am Fluss bauen!', + }, + sled: { + name: 'Sommerrodelbahn', emoji: '🛷', cost: 9000, upkeep: 70, + zone: 'mid', staff: 2, summerCap: 140, satBonus: 3, + desc: 'Rasant den Hang hinunter – 140 Sommergäste, ganz ohne Schnee.', + }, + trail: { + name: 'Panoramaweg', emoji: '🥾', cost: 7000, upkeep: 40, + zone: 'mid', staff: 1, summerCap: 120, + desc: 'Wander- und Bikeweg am Hang – bis zu 120 Sommergäste, unabhängig vom Schnee.', + scenes: ['klimawandel'], + }, + bikepark: { + name: 'Bikepark', emoji: '🚵', cost: 10000, upkeep: 80, + zone: 'mid', staff: 2, summerCap: 160, trailLen: 5, + desc: 'Flowtrails am Hang: bis 160 Sommergäste. Braucht eine Bahn in der Nähe (max. 4 Felder), die die Bikes hinaufbringt.', + }, + ropes: { + name: 'Hochseilgarten', emoji: '🧗', cost: 6500, upkeep: 45, + zone: 'mid', staff: 2, summerCap: 90, satBonus: 2, + desc: 'Klettern in Baumwipfel-Höhe – 90 Sommergäste, besonders schonend für die Natur.', + }, + bikerental: { + name: 'Fahrradverleih', emoji: '🚲', cost: 3500, upkeep: 25, + zone: 'valley', staff: 1, boost: 0.25, + desc: 'Räder & E-Bikes: Bikepark und Panoramaweg im Umkreis (4 Felder) bekommen +25 % Gäste.', + }, + platform: { + name: 'Aussichtsplattform', emoji: '🌉', cost: 8000, upkeep: 30, + zone: 'high', staff: 1, summerCap: 130, satBonus: 2, + desc: 'Skywalk am Gipfel: 130 Ausflugsgäste. Nur in den Hochlagen nahe einer Bahn baubar.', + }, + playground: { + name: 'Erlebnisspielplatz', emoji: '🎠', cost: 5500, upkeep: 35, + zone: 'valley', staff: 2, summerCap: 80, satBonus: 4, + desc: 'Wasserspiele & Kletterburg für Familien – 80 Sommergäste und viel Zufriedenheit.', + }, +}; + +// Start im Sommer: ruhige Aufbauphase, der erste Winter wird zur Bewährungsprobe +export const SEASONS = ['Sommer', 'Herbst', 'Winter', 'Frühling']; +export const DAYS_PER_SEASON = 30; + +// ---------- Aufträge je Szene ---------- +const QUESTS_PIONIER = [ + { id: 'q1', icon: '🥾', title: 'Die ersten Gäste', desc: '40 Gäste an einem Tag', reward: 2000, check: s => s.guestsToday >= 40 }, + { id: 'q2', icon: '🎿', title: 'Ski-Opening', desc: '100 Gäste an einem Wintertag', reward: 4000, check: s => s.season === 'Winter' && s.guestsToday >= 100 }, + { id: 'q3', icon: '😊', title: 'Gern gesehen', desc: 'Zufriedenheit über 80 %', reward: 3000, check: s => s.satisfaction >= 80 }, + { id: 'q4', icon: '🛏️', title: 'Volles Haus', desc: '50 Übernachtungsgäste an einem Tag', reward: 5000, check: s => s.overnightToday >= 50 }, + { id: 'q5', icon: '🌲', title: 'Grünes Tal', desc: 'Naturwert über 80 % mit mind. 5 Betrieben', reward: 4000, check: s => s.nature >= 80 && s.buildings.length >= 5 }, + { id: 'q6', icon: '🏆', title: 'Ausgebucht', desc: '250 Gäste an einem Tag', reward: 8000, check: s => s.guestsToday >= 250 }, +]; + +const QUESTS_BOOM = [ + { id: 'b1', icon: '📈', title: 'Boom nutzen', desc: '350 Gäste an einem Tag', reward: 6000, check: s => s.guestsToday >= 350 }, + { id: 'b2', icon: '👷', title: 'Gut besetzt', desc: 'Alle Stellen besetzt bei mind. 15 Beschäftigten', reward: 5000, check: s => s.staffNeeded >= 15 && s.staffAvailable >= s.staffNeeded }, + { id: 'b3', icon: '🌲', title: 'Gegensteuern', desc: 'Naturwert wieder über 70 %', reward: 6000, check: s => s.nature >= 70 }, + { id: 'b4', icon: '😊', title: 'Qualität statt Masse', desc: 'Zufriedenheit über 78 % bei 300+ Gästen', reward: 8000, check: s => s.satisfaction >= 78 && s.guestsToday >= 300 }, +]; + +const QUESTS_KLIMA = [ + { id: 'k1', icon: '🥾', title: 'Neue Wege', desc: 'Baue einen Panoramaweg', reward: 4000, check: s => s.buildings.some(b => b.type === 'trail') }, + { id: 'k2', icon: '☀️', title: 'Sommerfrische', desc: '150 Gäste an einem Sommertag', reward: 5000, check: s => s.season === 'Sommer' && s.guestsToday >= 150 }, + { id: 'k3', icon: '❄️', title: 'Schneesicher', desc: '60 cm Schnee dank Beschneiung', reward: 5000, check: s => s.snow >= 60 && s.buildings.some(b => b.type === 'snow') }, + { id: 'k4', icon: '🌍', title: 'Ganzjahresziel', desc: '200 Gäste an einem Sommertag', reward: 8000, check: s => s.season === 'Sommer' && s.guestsToday >= 200 }, +]; + +// ---------- Szenarien ---------- +export const SCENARIOS = { + pionierzeit: { + id: 'pionierzeit', + name: '– Pionierzeit 1975', + startYear: 1975, + startMoney: 30000, + startAwareness: 0.35, + startNature: 88, + baseAccess: 120, + baseStaffHousing: 8, + demand: { Winter: 290, 'Frühling': 60, Sommer: 140, Herbst: 85 }, + summerSpend: 12, + overnightSpend: 30, + wage: 24, + snowCannonRun: 45, + warmWinterChance: 0.3, + deadlineDay: 600, + goalsTitle: 'Ziele bis Ende 1979', + prebuilt: [], + startUnlocked: ['restaurant'], // kleine Schritte! + stageRow: 8, // Hochlagen anfangs wolkenverhangen + quests: QUESTS_PIONIER, + goals: [ + { id: 'guests', label: '220 Gäste an einem Tag', check: s => s.stats.maxGuests >= 220 }, + { id: 'money', label: '200.000 € auf dem Konto', check: s => s.money >= 200000 }, + { id: 'sat', label: 'Zufriedenheit mind. 70 %', check: s => s.satisfaction >= 70 }, + { id: 'nature', label: 'Naturwert mind. 55 %', check: s => s.nature >= 55 }, + ], + intro: { + id: 'intro', icon: '👋', title: 'Willkommen im Tal!', + text: 'Sommer 1975. Du übernimmst die Entwicklung eines kleinen, stillen ' + + 'Alpentals.\n\nFang klein an: Baue zuerst ein Gasthaus im Tal, in der ' + + 'Nähe des Dorfes. Dann kommen die ersten Wanderer – und alles Weitere ' + + 'ergibt sich Schritt für Schritt.', + }, + }, + + boom: { + id: 'boom', + name: '– Boom 2000', + startYear: 2000, + startMoney: 45000, + startAwareness: 0.9, // das Tal ist längst bekannt + startNature: 68, // und trägt schon Spuren + baseAccess: 200, + baseStaffHousing: 10, + demand: { Winter: 500, 'Frühling': 100, Sommer: 240, Herbst: 150 }, + summerSpend: 16, + overnightSpend: 38, + wage: 28, + snowCannonRun: 55, + warmWinterChance: 0.35, + deadlineDay: 480, + goalsTitle: 'Ziele bis Ende 2003', + prebuilt: ['restaurant', 'lift', 'access', 'hotel'], + startUnlocked: 'all', + stageRow: 0, + quests: QUESTS_BOOM, + goals: [ + { id: 'guests', label: '450 Gäste an einem Tag', check: s => s.stats.maxGuests >= 450 }, + { id: 'money', label: '500.000 € auf dem Konto', check: s => s.money >= 500000 }, + { id: 'sat', label: 'Zufriedenheit mind. 75 %', check: s => s.satisfaction >= 75 }, + { id: 'nature', label: 'Naturwert mind. 60 %', check: s => s.nature >= 60 }, + ], + intro: { + id: 'intro', icon: '📈', title: 'Der Boom ist da!', + text: 'Sommer 2000. Das Tal ist längst kein Geheimtipp mehr – die Gäste ' + + 'strömen, und alle wollen ausbauen.\n\nAber die Natur trägt schon ' + + 'Spuren, und die Einheimischen murren. Schaffst du Wachstum, OHNE ' + + 'das Tal zu verkaufen? Qualität und Naturwert entscheiden.', + }, + }, + + klimawandel: { + id: 'klimawandel', + name: '– Schneegrenze 2035', + startYear: 2035, + startMoney: 40000, + startAwareness: 0.8, + startNature: 75, + baseAccess: 180, + baseStaffHousing: 10, + demand: { Winter: 360, 'Frühling': 110, Sommer: 220, Herbst: 160 }, + summerSpend: 18, + overnightSpend: 40, + wage: 30, + snowCannonRun: 60, + warmWinterChance: 0.5, // warme Winter sind die neue Normalität … + warmWinterGrow: 0.06, // … und werden jedes Jahr häufiger + meltExtra: 1.2, // Schnee schmilzt schneller + summerTrend: 0.07, // Sommernachfrage +7 %/Jahr bei gutem Angebot (Serfaus-Effekt) + deadlineDay: 600, + goalsTitle: 'Ziele bis Ende 2039', + prebuilt: ['restaurant', 'lift', 'access'], + startUnlocked: 'all', + stageRow: 0, + quests: QUESTS_KLIMA, + goals: [ + { id: 'summer', label: '200 Gäste an einem Sommertag', check: s => s.stats.maxSummerGuests >= 200 }, + { id: 'money', label: '400.000 € auf dem Konto', check: s => s.money >= 400000 }, + { id: 'sat', label: 'Zufriedenheit mind. 70 %', check: s => s.satisfaction >= 70 }, + { id: 'nature', label: 'Naturwert mind. 65 %', check: s => s.nature >= 65 }, + ], + intro: { + id: 'intro', icon: '🌡️', title: 'Die Schneegrenze steigt', + text: 'Sommer 2035. Die Winter werden wärmer, Schnee ist keine ' + + 'Selbstverständlichkeit mehr. Messdaten zeigen: In den Alpen ' + + 'schmilzt der Schnee 5–9 Tage pro Jahrzehnt früher – seit 1970 ist ' + + 'die Schneesaison rund einen Monat kürzer geworden.\n\n' + + 'Dein Tal muss sich neu erfinden: Ganzjahrestourismus! Wer sein ' + + 'Sommerangebot konsequent ausbaut, dessen Sommernachfrage wächst ' + + 'Jahr für Jahr (wie in echten Vorbild-Orten). Beschneiung ' + + 'überbrückt – aber sie kostet. Findest du die Balance?', + }, + }, +}; + +// Meilensteine: schalten Gebäude Schritt für Schritt frei (nur Szene 1 – +// in den anderen Szenen ist bereits alles freigeschaltet) +export const MILESTONES = [ + { + id: 'm-lift', unlock: 'lift', + when: s => s.buildings.some(b => b.type === 'restaurant') && s.day >= 13, + icon: '🚡', title: 'Neu: Sessellift!', + text: 'Dein Gasthaus spricht sich herum – die ersten Gäste sind da!\n\n' + + 'Sie fragen schon: „Kommt man hier auch auf den Berg?"\n\n' + + 'Du kannst jetzt einen Sessellift bauen. Er gehört an den Hang ' + + '(weiter oben) – die Piste entsteht automatisch darüber.', + }, + { + id: 'm-access', unlock: 'access', + when: s => s.buildings.some(b => b.type === 'lift'), + icon: '🚌', title: 'Neu: Bushaltestelle!', + text: 'Der Lift läuft! Aber viele Gäste kommen gar nicht erst ins Tal – ' + + 'es gibt zu wenige Anreisemöglichkeiten.\n\n' + + 'Baue eine Bushaltestelle direkt an der Talstraße – dann bringt der ' + + 'Bus bis zu 150 zusätzliche Gäste pro Tag.', + }, + { + id: 'm-hotel', unlock: 'hotel', + when: s => s.stats.maxGuests >= 90, + icon: '🏨', title: 'Neu: Hotel!', + text: 'Fast 100 Gäste an einem Tag! Viele würden gern länger bleiben – ' + + 'Übernachtungsgäste geben deutlich mehr Geld aus als Tagesgäste.\n\n' + + 'Du kannst jetzt ein Hotel im Tal bauen (60 Betten).', + }, + { + id: 'm-staff', unlock: 'staffhouse', + when: s => s.staffNeeded > s.staffAvailable, + icon: '👷', title: 'Neu: Personalhaus!', + text: 'Deine Betriebe finden nicht genug Mitarbeiter/innen – im Tal gibt ' + + 'es zu wenig Wohnraum!\n\nOhne Personal laufen Lift und Hotel nur mit ' + + 'halber Kraft. Ein Personalhaus schafft Wohnraum für 12 Beschäftigte.', + }, + { + id: 'm-summer', unlock: ['badesee', 'sled'], + when: s => s.stats.maxSummerGuests >= 70, + icon: '☀️', title: 'Neu: Sommer-Attraktionen!', + text: 'Immer mehr Gäste kommen auch im Sommer ins Tal – sie wollen mehr ' + + 'erleben als nur wandern.\n\n' + + 'Du kannst jetzt einen Badesee anlegen (nahe am Fluss!) und eine ' + + 'Sommerrodelbahn an den Hang bauen. Beide machen das Tal unabhängiger ' + + 'vom Schnee.', + }, + { + id: 'm-expand', unlock: 'gondola', + when: s => s.stats.maxGuests >= 140, + apply: s => { s.stageRow = 0; }, + icon: '🏔️', title: 'Die Hochlagen sind erschlossen!', + text: 'Dein Tal brummt – die Gemeinde gibt die Hochlagen frei!\n\n' + + 'Der Nebel über dem oberen Berg lichtet sich: Dort gibt es Almen zum ' + + 'Bebauen (auch ein Berg-Gasthaus lohnt sich!) – und du kannst jetzt ' + + 'eine Gondelbahn bis in die Hochlagen bauen: 300 Gäste pro Tag und ' + + 'eine herrlich lange Abfahrt.', + }, + { + id: 'm-snow', unlock: 'snow', + when: s => s.season === 'Winter' && s.weather.warmWinter + && s.buildings.some(b => b.type === 'lift'), + icon: '❄️', title: 'Neu: Beschneiung!', + text: 'Dieser Winter ist ungewöhnlich warm – es fällt kaum Schnee, und ' + + 'ohne Schnee bleiben die Skigäste aus.\n\n' + + 'Beschneiungsanlagen können helfen. Sie brauchen aber Wasser (nahe am ' + + 'Fluss bauen!) und kosten im Betrieb 45 € pro Tag.', + }, + { + id: 'm-summer2', unlock: ['bikepark', 'ropes', 'bikerental', 'platform', 'playground'], + when: s => s.stats.maxSummerGuests >= 110, + icon: '🚵', title: 'Neu: Der Sommer boomt!', + text: 'Über 100 Sommergäste an einem Tag – dein Tal spricht sich als ' + + 'Sommerziel herum!\n\nJetzt baubar: Bikepark (braucht eine Bahn in der ' + + 'Nähe), Hochseilgarten, Fahrradverleih, Erlebnisspielplatz – und in den ' + + 'Hochlagen eine Aussichtsplattform.\n\nEchte Vorbilder zeigen: Wer das ' + + 'Sommerangebot konsequent ausbaut, verdoppelt seine Sommergäste in ' + + 'zehn Jahren (Serfaus-Fiss-Ladis: +95 %).', + }, + { + id: 'm-rescue', unlock: 'rescue', + when: s => s.buildings.some(b => b.type === 'lift') && s.stats.maxGuests >= 120, + icon: '⛑️', title: 'Neu: Rettungsstation!', + text: 'Immer mehr Gäste sind auf den Pisten – und ab und zu verfährt sich ' + + 'jemand weit abseits und stürzt.\n\nEine Rettungsstation mit ' + + 'Notarzthubschrauber birgt Verunglückte automatisch. Sicherheit ist ' + + 'auch ein Standortfaktor – und die Gäste danken es dir.', + }, +]; + +// Berater: wiederkehrende Hinweise (Toast) bei erkennbaren Problemen – +// beantworten „warum läuft es schlecht?", mit Cooldown in Spieltagen +export const ADVISOR = [ + { + id: 'a-loss', cooldown: 25, + when: s => s.lossStreak >= 5, + text: '💸 Du machst seit Tagen Verlust: Instandhaltung & Löhne laufen weiter, auch wenn kaum Gäste da sind. Details: 📋 Tagesbericht.', + }, + { + id: 'a-reserve', cooldown: 45, + when: s => s.money >= 0 && s.money < 4000 && s.buildings.length >= 2, + text: '💰 Kaum Rücklagen! Halte etwa 5.000 € zurück – für einmalige Chancen (Skirennen, Sommerfest) und schlechte Tage.', + }, + { + id: 'a-debt', cooldown: 20, + when: s => s.money < -5000, + text: '🏦 Hohe Schulden! Reiß ungenutzte Gebäude ab (30 % zurück) – ab 15.000 € Schulden verkauft die Bank von selbst!', + }, + { + id: 'a-snow', cooldown: 20, + when: s => s.season === 'Winter' && s.snow < 12 + && s.buildings.some(b => b.type === 'lift' || b.type === 'gondola') + && !s.buildings.some(b => b.type === 'snow'), + text: '❄️ Wenig Schnee = wenig Skigäste! Eine Beschneiungsanlage hilft (braucht Fluss und Kälte).', + }, + { + id: 'a-access', cooldown: 25, + when: s => s.bottleneckStreak >= 3 && s.bottleneck === 'Anreise (Bus)', + text: '🚌 Mehr Gäste wollten kommen, als anreisen können – Bushaltestelle bauen oder ausbauen ⬆️!', + }, + { + id: 'a-cap', cooldown: 25, + when: s => s.bottleneckStreak >= 3 && (s.bottleneck === 'Liftplätze' || s.bottleneck === 'Wege & Attraktionen'), + text: '🚡 Dein Angebot ist voll ausgelastet – eine weitere Bahn/Attraktion oder ein Ausbau ⬆️ schafft Platz.', + }, + { + id: 'a-staff', cooldown: 25, + when: s => s.bottleneck === 'Personal fehlt' && s.bottleneckStreak >= 3, + text: '👷 Personalmangel bremst alle Betriebe – ein Personalhaus schafft Wohnraum für 12 Angestellte.', + }, +]; + +// Erklär-Karte beim ERSTEN Bau jedes Gebäudetyps: was bringt es, was +// kostet es, worauf achten? (Lernprogramm: jede Wirkung wird vorgestellt) +export const BUILD_INFO = { + restaurant: { + icon: '🍽️', title: 'Dein Gasthaus ist eröffnet!', + text: 'Gäste kehren jetzt ein (Symbol 🍽️ überm Kopf) und geben dabei ' + + 'mehr Geld aus – schau auf die grünen 🥨 +12 €. Außerdem steigt die ' + + 'Zufriedenheit.\n\nLaufende Kosten: 45 €/Tag + 3 Angestellte.\n\n' + + '📍 LAGE ZÄHLT: Steht das Gasthaus im Einflusskreis (3 Felder) von ' + + 'Lift, Badesee, Rodelbahn oder Hotel, kehren deren Gäste gleich mit ' + + 'ein (+2 €/Gast je Nachbarschaft). Der Kreis wird beim Bauen angezeigt. ' + + 'Und: Jede Figur im Tal steht für etwa 10 echte Gäste.', + }, + lift: { + icon: '🚡', title: 'Der Sessellift läuft!', + text: 'Im Winter bringt er bis zu 200 Skigäste auf die Piste – den ' + + 'Skipass-Preis bestimmst du links mit dem Regler. Im Sommer kaufen ' + + 'Wanderer Bergfahrten (🎫 8 €).\n\nKosten: 120 €/Tag Instandhaltung ' + + '+ 4 Angestellte. Ohne Schnee (links: ❄️-Anzeige) bleiben Skigäste aus!\n\n' + + '📍 LAGE ZÄHLT: Weiter OBEN gebaut ist der Lift schneesicherer ' + + '(längere Skisaison), weiter UNTEN konsumieren die Gäste mehr ' + + '(+2 €/Gast). Zwei Bahnen nahe beieinander bilden einen Skiverbund ' + + 'und locken zusätzliche Gäste an.', + }, + gondola: { + icon: '🚠', title: 'Die Gondelbahn schwebt!', + text: 'Bis zu 300 Gäste pro Tag und eine herrlich lange Abfahrt aus den ' + + 'Hochlagen.\n\nKosten: 220 €/Tag + 5 Angestellte – die teuerste, ' + + 'aber stärkste Bahn im Tal.\n\n📍 LAGE ZÄHLT: Auch hier gilt – hoch ' + + 'gebaut = schneesicher, tief gebaut = konsumfreundlich; nahe Bahnen ' + + 'bilden einen Skiverbund.', + }, + access: { + icon: '🚌', title: 'Der Bus hält jetzt im Tal!', + text: 'Bis zu 150 zusätzliche Gäste können pro Tag anreisen – beobachte, ' + + 'wie Gruppen an der Haltestelle aussteigen.\n\nWenn unter der ' + + 'Gäste-Zahl „Engpass: Anreise" steht, lohnt sich ein Ausbau ⬆️.', + }, + hotel: { + icon: '🏨', title: 'Das Hotel empfängt Gäste!', + text: '60 Betten: Die Hälfte deiner Tagesgäste würde gern übernachten – ' + + 'jede Übernachtung bringt 30 € extra.\n\nKosten: 90 €/Tag + 5 ' + + 'Angestellte. Denk an Personal-Wohnraum!', + }, + staffhouse: { + icon: '🏠', title: 'Wohnraum für dein Personal!', + text: '12 Angestellte finden hier ein Zuhause. Betriebe ohne Personal ' + + 'laufen nur mit halber Kraft – die Personal-Zeile links zeigt, ob ' + + 'alle Stellen besetzt sind.\n\n📍 LAGE ZÄHLT: Baue es nahe bei den ' + + 'Betrieben (max. 4 Felder, Kreis beim Bauen) – liegt es weiter weg, ' + + 'wirkt es nur zu zwei Dritteln.', + }, + snow: { + icon: '❄️', title: 'Die Beschneiung steht bereit!', + text: 'Sobald es kälter als 2 °C ist und Schnee fehlt, springt sie an ' + + '(+2,5 cm/Tag) – Betrieb kostet dann 45 €/Tag.\n\nIst es zu warm, ' + + 'kann auch sie nicht schneien (Wettervorhersage links!).', + }, + badesee: { + icon: '🏖️', title: 'Der Badesee glitzert!', + text: 'Bis zu 100 Sommergäste planschen hier (🏖️ überm Kopf) und zahlen ' + + 'Eintritt (🎫 +5 €). Dazu steigt die Zufriedenheit.\n\nIm Winter ' + + 'ruht der Betrieb – die Kosten (60 €/Tag) laufen weiter.', + }, + sled: { + icon: '🛷', title: 'Die Sommerrodelbahn saust!', + text: 'Bis zu 140 Sommergäste, ganz ohne Schnee (🛷 überm Kopf, 🎫 +7 €). ' + + 'Mit Ausbau ⬆️ wird die Bahn länger und schafft +50 %.\n\n' + + 'Kosten: 70 €/Tag + 2 Angestellte.', + }, + trail: { + icon: '🥾', title: 'Der Panoramaweg ist markiert!', + text: 'Bis zu 120 Sommergäste wandern hier – völlig schneeunabhängig. ' + + 'Genau das braucht das Tal, wenn die Winter wärmer werden.\n\n' + + 'Kosten: 40 €/Tag + 1 Angestellte/r.', + }, + bikepark: { + icon: '🚵', title: 'Der Bikepark ist eröffnet!', + text: 'Die Flowtrails schlängeln sich den Hang hinunter – bis zu 160 ' + + 'Sommergäste (🚵 überm Kopf, 🎫 +9 €).\n\nKosten: 80 €/Tag + 2 ' + + 'Angestellte. Die Trails kosten Naturwert – Aufforstung gleicht aus.\n\n' + + '📍 LAGE ZÄHLT: Die Bahn daneben bringt die Bikes hinauf – genau so ' + + 'nutzen echte Skigebiete (Saalbach, Serfaus) ihre Lifte im Sommer. ' + + 'Ein Fahrradverleih im Umkreis bringt +25 % Gäste.', + }, + ropes: { + icon: '🧗', title: 'Der Hochseilgarten ist gespannt!', + text: 'Klettern, Balancieren, Ziplines – 90 Sommergäste (🎫 +8 €), und die ' + + 'Bäume bleiben stehen: kaum Naturverbrauch.\n\n' + + 'Kosten: 45 €/Tag + 2 Angestellte. Familien lieben ihn – die ' + + 'Zufriedenheit steigt.', + }, + bikerental: { + icon: '🚲', title: 'Der Fahrradverleih hat geöffnet!', + text: 'Räder und E-Bikes für alle, die keins mitbringen.\n\n' + + '📍 LAGE ZÄHLT: Bikepark und Panoramaweg im Umkreis (4 Felder) ' + + 'bekommen +25 % Gäste – der Verleih ist ein Verstärker, keine eigene ' + + 'Attraktion.\n\nKosten: 25 €/Tag + 1 Angestellte/r.', + }, + platform: { + icon: '🌉', title: 'Die Aussichtsplattform schwebt!', + text: 'Der Skywalk zieht bis zu 130 Ausflugsgäste an – sie kommen NUR ' + + 'für die Aussicht und fahren mit der Bahn hinauf (🎫 +6 €).\n\n' + + 'Das ist Ausflugstourismus: Die Jungfraubahn (Schweiz) macht so nur ' + + 'noch ~20 % ihres Umsatzes mit Wintersport – der Rest ist ' + + 'Ganzjahres-Ausflugsgeschäft.\n\nKosten: 30 €/Tag + 1 Angestellte/r.', + }, + playground: { + icon: '🎠', title: 'Der Erlebnisspielplatz tobt!', + text: 'Wasserspiele, Kletterburg, Riesenrutsche – 80 Sommergäste und ein ' + + 'großes Plus bei der Zufriedenheit.\n\nFamilien sind DER ' + + 'Sommer-Wachstumsmotor: Serfaus-Fiss-Ladis wurde mit Familienangeboten ' + + 'zur Vorzeige-Sommerdestination.\n\nKosten: 35 €/Tag + 2 Angestellte.', + }, + rescue: { + icon: '⛑️', title: 'Die Rettungsstation ist einsatzbereit!', + text: 'Fährt ein Ski-Gast weit abseits und stürzt, startet automatisch der ' + + 'Notarzthubschrauber, birgt ihn und fliegt zurück zur Station.\n\n' + + 'Kosten: 50 €/Tag + 2 Angestellte. Gäste fühlen sich sicherer ' + + '(mehr Zufriedenheit).', + }, +}; + +// Kurz-Hinweise: dezente Toasts ohne Pause – erzählen, was gerade +// passiert (Gäste kommen, konsumieren, es entsteht Bedarf …) +export const HINTS = [ + { + id: 'h-guests', scenes: ['pionierzeit'], + when: s => s.buildings.length > 0 && s.guestsToday >= 8, + text: '🥾 Die ersten Wanderer sind da – schau, wie sie durchs Tal spazieren!', + }, + { + id: 'h-konsum', scenes: ['pionierzeit'], + when: s => s.guestsToday >= 18, + text: '💶 Es klingelt in der Kasse: Die Gäste kehren ein und geben Geld aus.', + }, + { + id: 'h-bus', scenes: ['pionierzeit'], + when: s => s.day >= 8 && s.buildings.length > 0, + text: '🚌 Mancher Gast käme gern – aber ohne Bus ist die Anreise mühsam …', + }, + { + id: 'h-berg', scenes: ['pionierzeit'], + when: s => s.day >= 11 && s.buildings.some(b => b.type === 'restaurant'), + text: '⛰️ Immer öfter fragen Gäste: „Kommt man hier auch auf den Berg?"', + }, +]; + +// Entscheidungs-Karten: Situationen mit echter A/B-Wahl (Urteils- und +// Handlungskompetenz!). Erscheinen einmalig, wenn die Bedingung erfüllt ist. +export const DECISIONS = [ + { + id: 'd-race', icon: '🏁', title: 'Skirennen im Tal?', + when: s => s.season === 'Winter' && s.snow > 20 && s.day > 100 + && s.buildings.some(b => b.type === 'lift'), + text: 'Der Skiverband fragt an: Ein Landesrennen würde das Tal weit über ' + + 'die Region hinaus bekannt machen – kostet aber 5.000 € für ' + + 'Absperrungen, Helfer und Preisgeld.', + yes: { + label: 'Ausrichten (5.000 €)', + can: s => s.money >= 5000, + apply: s => { + s.money -= 5000; + s.awareness = Math.min(1, s.awareness + 0.15); + s.satisfaction = Math.min(100, s.satisfaction + 8); + s.race = { until: s.day + 5 }; // sichtbares Rennen auf der Piste! + }, + toast: '🏁 Renntag! Schau auf die Piste – das Tal ist in aller Munde!', + }, + no: { label: 'Ablehnen', apply: () => {}, toast: 'Der Verband fragt woanders an.' }, + }, + { + id: 'd-fest', icon: '🎪', title: 'Sommerfest im Dorf?', + when: s => s.season === 'Sommer' && s.day > 40, + text: 'Das Dorf möchte ein großes Sommerfest mit Musik und Handwerksmarkt ' + + 'ausrichten und bittet dich um 3.000 € Unterstützung.', + yes: { + label: 'Unterstützen (3.000 €)', + can: s => s.money >= 3000, + apply: s => { + s.money -= 3000; + s.awareness = Math.min(1, s.awareness + 0.1); + s.satisfaction = Math.min(100, s.satisfaction + 6); + s.fest = { until: s.day + 5 }; // Marktstände & Wimpel im Dorf! + }, + toast: '🎪 Das Fest ist im Gange – schau ins Dorf!', + }, + no: { + label: 'Diesmal nicht', apply: s => { + s.satisfaction = Math.max(10, s.satisfaction - 3); + }, toast: 'Das Dorf ist etwas enttäuscht.', + }, + }, + { + id: 'd-forest', icon: '🌱', title: 'Aufforstungs-Projekt?', + when: s => s.nature < 65, + text: 'Die Bürgerinitiative schlägt vor, gemeinsam Bäume zu pflanzen und ' + + 'Almwiesen zu pflegen. Deine Beteiligung: 4.000 €.', + yes: { + label: 'Mitmachen (4.000 €)', + can: s => s.money >= 4000, + apply: s => { + s.money -= 4000; + s.nature = Math.min(100, s.nature + 12); + }, + toast: '🌱 Das Tal wird sichtbar grüner – und die Gäste danken es.', + }, + no: { + label: 'Ablehnen', apply: s => { + s.satisfaction = Math.max(10, s.satisfaction - 5); + }, toast: 'Die Initiative ist verärgert.', + }, + }, + { + id: 'd-tourbus', icon: '🚍', title: 'Reisegruppe mit Rabatt?', + when: s => s.buildings.some(b => b.type === 'access') && s.day > 70, + text: 'Ein Reiseveranstalter bietet an, regelmäßig Busgruppen zu schicken – ' + + 'aber nur mit 30 % Gruppenrabatt. Viele Gäste, weniger Geld pro Kopf, ' + + 'und es wird voller.', + yes: { + label: 'Annehmen', + apply: s => { + s.money += 2500; + s.awareness = Math.min(1, s.awareness + 0.06); + s.satisfaction = Math.max(10, s.satisfaction - 5); + }, + toast: '🚍 Die Gruppen kommen – es klingelt in der Kasse, aber es wird eng.', + }, + no: { label: 'Ablehnen', apply: () => {}, toast: 'Du setzt weiter auf Individualgäste.' }, + }, +]; + +// Event-Karten: erscheinen einmalig, sobald ihre Bedingung erfüllt ist. +// Optional per `scenes` auf einzelne Szenarien beschränkt. +export const EVENTS = [ + { + id: 'first-winter', icon: '⛄', title: 'Der erste Winter naht', + scenes: ['pionierzeit'], + when: s => s.season === 'Winter', + text: 'Es wird kalt im Tal! Im Winter kommen Skigäste – aber nur, wenn es ' + + 'einen Lift und genug Schnee gibt.\n\n' + + 'Behalte die Schnee-Anzeige links im Blick.', + }, + { + id: 'overtourism', icon: '🚗', title: 'Es wird eng im Tal', + scenes: ['boom'], + when: s => s.guestsToday > 500, + text: 'Über 500 Gäste an einem Tag! Die Straße ist verstopft, die ' + + 'Einheimischen finden keinen Parkplatz mehr.\n\n' + + 'Mehr ist nicht immer besser: Achte auf Zufriedenheit und Naturwert – ' + + 'sonst kommt der Boom ins Kippen.', + }, + { + id: 'no-snow-winter', icon: '🌧️', title: 'Grüner Winter', + scenes: ['klimawandel'], + when: s => s.season === 'Winter' && s.snow < 10 && s.day > 70, + text: 'Mitten im Winter: Regen statt Schnee. So sieht die neue Realität ' + + 'aus – und die Skigäste bleiben aus.\n\n' + + 'Beschneiung hilft kurzfristig. Langfristig braucht das Tal Angebote, ' + + 'die keinen Schnee brauchen.', + }, + { + id: 'sommerloch', icon: '📊', title: 'Das Sommerloch', + scenes: ['boom', 'klimawandel'], + when: s => s.season === 'Sommer' && s.day > 25 + && !s.buildings.some(b => (BUILDINGS[b.type].summerCap || 0) > 0), + text: 'Dein Tal lebt fast nur vom Winter – im Sommer bleiben die Betten ' + + 'leer. Fachleute nennen das „Sommerloch".\n\n' + + 'Echte Zahlen: Lech am Arlberg hat 84 % seiner Übernachtungen im ' + + 'Winter und nur 16 % im Sommer. Ganz Österreich zeigt, dass es anders ' + + 'geht – dort hat der Sommer den Winter bei den Übernachtungen sogar ' + + 'überholt (83 zu 72 Millionen, 2025).\n\n' + + 'Badesee, Sommerrodelbahn und Panoramaweg füllen das Loch.', + }, + { + id: 'ganzjahres-daten', icon: '📈', title: 'Was sagen die Daten?', + scenes: ['klimawandel'], + when: s => s.buildings.filter(b => (BUILDINGS[b.type].summerCap || 0) > 0).length >= 2, + text: 'Dein Tal wird zur Ganzjahresdestination – wie echte Vorbilder: ' + + 'Serfaus-Fiss-Ladis hat seine Sommer-Übernachtungen in 10 Jahren fast ' + + 'verdoppelt, am Wilden Kaiser kommen Sommer und Winter gleich viele ' + + 'Gäste.\n\nAber Achtung: Ein Sommergast gibt nur rund 80 % dessen aus, ' + + 'was ein Wintergast ausgibt (156 vs. 193 € pro Tag). Und eine Bergbahn ' + + 'braucht 1,3 bis 4,2 Sommergäste, um EINEN fehlenden Wintergast zu ' + + 'ersetzen.\n\nGleich viele Gäste heißt also nicht gleich viel Geld – ' + + 'plane entsprechend!', + }, + { + id: 'low-money', icon: '💸', title: 'Die Kasse ist fast leer', + when: s => s.money < 3000, + text: 'Achtung: Gebäude kosten auch im Betrieb Geld (Instandhaltung, Löhne). ' + + 'Warte, bis Einnahmen hereinkommen, bevor du weiter baust.\n\n' + + 'Tipp: Mit dem Abriss-Werkzeug bekommst du 30 % der Baukosten zurück.', + }, + { + id: 'nature-low', icon: '🌲', title: 'Bürgerinitiative besorgt', + when: s => s.nature < 45, + text: 'Eine Bürgerinitiative protestiert: Zu viel Verbauung schadet Natur ' + + 'und Landschaftsbild – und genau deshalb kommen viele Gäste!\n\n' + + 'Sinkt der Naturwert weiter, bleiben Gäste aus.', + }, + { + id: 'too-expensive', icon: '🎟️', title: 'Zu teuer!', + when: s => s.price >= 38 && s.satisfaction < 55, + text: 'Die Gäste beschweren sich über den Skipass-Preis. Ein hoher Preis ' + + 'bringt mehr Geld pro Gast – aber weniger Gäste kommen, und die ' + + 'Zufriedenheit sinkt.\n\nDas ist Angebot und Nachfrage!', + }, + { + id: 'broke', icon: '🏦', title: 'Das Konto ist überzogen!', + when: s => s.money < -1000, + text: 'Die Gemeinde streckt dir das Geld vor, aber so kann es nicht ' + + 'weitergehen. Reiß ungenutzte Gebäude ab (30 % zurück) oder senke die ' + + 'laufenden Kosten, bis wieder Einnahmen kommen.', + }, +]; + +// --------------------------------------------------------------------------- +// Lehrplan-Wissen je Gebäude: kurze, verdaubare Bausteine, verankert im +// GW-Lehrplan 2023 (Standortfaktoren, Angebot/Nachfrage, Wertschöpfung, +// Nachhaltigkeit, Klimaanpassung). Erscheinen im Gebäude-Klick-Dialog. +export const KNOWLEDGE = { + restaurant: [ + { term: 'Wertschöpfung', text: 'Jeder Gast, der einkehrt, lässt Geld im Tal. Diese vor Ort erwirtschafteten Einnahmen nennt man Wertschöpfung.' }, + { term: 'Standortfaktor Lage', text: 'Steht das Gasthaus nah bei Lift, Hotel oder Attraktion, kehren deren Gäste gleich mit ein – gute Lage bringt mehr Umsatz.' }, + ], + lift: [ + { term: 'Standortfaktor Höhenlage', text: 'Weiter oben ist es kälter, der Schnee bleibt länger – die Skisaison wird länger. Höhe ist ein Standortfaktor.' }, + { term: 'Kapazität', text: 'Ein Lift befördert nur begrenzt viele Gäste pro Tag. Ist er voll, kommen trotz Nachfrage nicht mehr hinauf.' }, + { term: 'Sommergeschäft der Bergbahnen', text: 'Real machen Seilbahnen nur 15–25 % ihres Umsatzes im Sommer. Fällt ein Wintergast aus, braucht die Bahn 1,3 bis 4,2 Sommergäste als Ersatz (Seilbahnen Schweiz).' }, + ], + gondola: [ + { term: 'Infrastruktur', text: 'Bahnen sind touristische Infrastruktur – die Grundausstattung, die eine Tourismusregion erst möglich macht.' }, + { term: 'Standortfaktor Höhenlage', text: 'Die Gondel erschließt die Hochlagen: mehr Kapazität und schneesicherer als tief gelegene Lifte.' }, + ], + hotel: [ + { term: 'Übernachtungstourismus', text: 'Übernachtungsgäste bringen mehr Wertschöpfung als Tagesgäste – sie bleiben länger, essen und kaufen im Ort ein.' }, + { term: 'Tagesausgaben', text: 'Ein Wintergast gibt in Österreich im Schnitt 193 € pro Tag aus, ein Sommergast 156 € – also nur rund 80 %. Gleich viele Gäste heißt darum nicht gleich viel Einnahmen!' }, + { term: 'Saisonalität & Auslastung', text: 'Betten kosten auch leer Geld (Personal, Heizung, Kredit). Je gleichmäßiger sich Sommer und Winter füllen, desto wirtschaftlicher arbeitet ein Hotel.' }, + ], + access: [ + { term: 'Erreichbarkeit', text: 'Erreichbarkeit ist ein zentraler Standortfaktor. Ohne gute Anreise kommen viele Gäste gar nicht erst an.' }, + { term: 'Nachhaltige Mobilität', text: 'Anreise mit dem Bus statt mit vielen Autos entlastet Straßen und Umwelt – ein Beitrag zu nachhaltigem Tourismus.' }, + ], + staffhouse: [ + { term: 'Saisonarbeit & Wohnraum', text: 'Tourismus braucht viele Arbeitskräfte, oft in Saisonarbeit. Ohne leistbaren Wohnraum finden die Betriebe kein Personal.' }, + ], + snow: [ + { term: 'Klimawandel-Anpassung', text: 'Beschneiung sichert die Saison, wenn die Winter wärmer werden – braucht aber viel Wasser und Energie.' }, + { term: 'Schneesaison wird kürzer', text: 'Messdaten: In den Alpen schmilzt der Schnee 5–9 Tage pro Jahrzehnt früher; seit 1970 ist die Schneesaison rund einen Monat kürzer (GeoSphere Austria, SLF Schweiz). Unterhalb von ca. 2.000 m trifft es Skigebiete am härtesten.' }, + { term: 'Zielkonflikt Nachhaltigkeit', text: 'Kunstschnee kostet Ressourcen. Nachhaltiger Tourismus fragt: Wie lange lohnt sich das noch?' }, + ], + badesee: [ + { term: 'Diversifizierung', text: 'Sommerangebote machen das Tal unabhängiger vom Schnee – mehrere Standbeine nennt man Diversifizierung. Echte Vorbilder: Am Wilden Kaiser (Tirol) kommen heute etwa gleich viele Gäste im Sommer wie im Winter.' }, + { term: 'Sommerloch', text: 'In Skiorten heißt die gästearme Zeit „Sommerloch". Lech am Arlberg z. B. hat 84 % seiner Übernachtungen im Winter – nur 16 % im Sommer (Statistik Austria).' }, + ], + sled: [ + { term: 'Ganzjahrestourismus', text: 'Attraktionen ohne Schnee verlängern die Saison. Serfaus-Fiss-Ladis hat seine Sommer-Übernachtungen in 10 Jahren fast verdoppelt (+95 %) – mit Familienangeboten wie Rodelbahnen und Erlebniswegen.' }, + ], + trail: [ + { term: 'Sanfter Tourismus', text: 'Wandern greift wenig in die Natur ein und braucht keinen Schnee – zukunftsfähig, wenn das Klima wärmer wird.' }, + { term: 'Nächtigung', text: 'Übernachtungen („Nächtigungen") sind die Maßeinheit der Tourismusstatistik. In ganz Österreich hat der Sommer den Winter dabei schon überholt: 83 zu 72 Millionen (2025).' }, + ], + rescue: [ + { term: 'Naturgefahren', text: 'Im Gebirge drohen Lawinen, Abstürze und Wetterstürze. Tourismusregionen brauchen Rettungsdienste, um Gäste zu schützen.' }, + { term: 'Standortfaktor Sicherheit', text: 'Ein gutes Rettungswesen macht eine Region attraktiver – Sicherheit ist ein weicher Standortfaktor.' }, + ], + bikepark: [ + { term: 'Sommernutzung von Winter-Infrastruktur', text: 'Bikeparks nutzen die Lifte im Sommer weiter – so verdienen die teuren Bahnen ganzjährig. Real machen Seilbahnen 15–25 % ihres Umsatzes im Sommer, Tendenz steigend.' }, + { term: 'Zielkonflikt', text: 'Trails bedeuten Eingriff in den Hang: Wer den Sommer ausbaut, muss den Naturwert im Blick behalten – wie beim Winterausbau auch.' }, + ], + ropes: [ + { term: 'Sanfter Tourismus', text: 'Ein Hochseilgarten nutzt den Wald, ohne ihn zu roden – Wertschöpfung mit kleinem Fußabdruck.' }, + ], + bikerental: [ + { term: 'Wertschöpfungskette', text: 'Verleih, Bahn, Trail, Einkehr: Ein Gast gibt an vielen Stationen Geld aus. Je mehr Glieder die Kette hat, desto mehr bleibt im Tal.' }, + { term: 'Komplementärangebot', text: 'Der Verleih verkauft kein eigenes Erlebnis – er macht andere Angebote zugänglicher. Solche Ergänzungen heißen Komplementärangebote.' }, + ], + platform: [ + { term: 'Ausflugstourismus', text: 'Ausflugsgäste bleiben nur Stunden, kommen aber ganzjährig und wetterunabhängiger. Die Jungfraubahn (CH) lebt zu ~80 % vom Ausflugs- statt vom Wintersportgeschäft.' }, + { term: 'Inszenierung der Landschaft', text: 'Plattformen, Hängebrücken, Skywalks machen Aussicht zum Produkt – die Landschaft selbst wird zur Attraktion.' }, + ], + playground: [ + { term: 'Zielgruppe Familien', text: 'Familien sind der Wachstumsmotor des Sommertourismus: Serfaus-Fiss-Ladis hat mit Familienangeboten seine Sommernächtigungen in 10 Jahren fast verdoppelt (+95 %).' }, + ], +}; diff --git a/App/sims/tourismustal/js/iso.js b/App/sims/tourismustal/js/iso.js new file mode 100644 index 0000000..0ca24d4 --- /dev/null +++ b/App/sims/tourismustal/js/iso.js @@ -0,0 +1,2435 @@ +// Isometrischer Canvas-Renderer inkl. Touch-/Maus-Eingabe (Pan, Pinch-Zoom, +// Tap). Liebevolle Vektor-Grafik im Nordic-Flat-Stil: echte Iso-Häuser mit +// Giebel und Dachschrägen, Kaminrauch, Schatten, Grasbüschel, Figuren mit +// Beinchen. Himmel mit Bergkulisse und Wolken, Schneefall im Winter. + +import { MAP_COLS, MAP_ROWS, ROAD_ROW, PISTE_LEN, CORE, canPlace, UPGRADABLE, liftSkiable, seasonFactor } from './model.js'; +import { BUILDINGS } from './data.js'; + +const TILE_W = 80; +const TILE_H = 40; +const TW2 = TILE_W / 2; +const TH2 = TILE_H / 2; +const ELEV_Y = 15; + +const TERRAIN_COLORS = { + meadow: '#a9c98b', + field: '#dccf9e', + alm: '#c2d39a', + forest: '#4f7f5e', + rock: '#b8b2a6', + water: '#7fb2c9', + village: '#cfc4a5', +}; + +// KI-generierte Sprites (Nordic-Flat, transparent). Fehlt eine Datei, +// greift automatisch die Vektor-Zeichnung als Fallback. +// w = Zeichenbreite in px, bottom = Abstand Unterkante Motiv → Kachelmitte +const SPRITE_DEFS = { + restaurant: { w: 78, bottom: 10 }, + hotel: { w: 88, bottom: 10 }, + hotel2: { w: 104, bottom: 11 }, + staffhouse: { w: 64, bottom: 9 }, + house: { w: 54, bottom: 8 }, + church: { w: 66, bottom: 9 }, + access: { w: 52, bottom: 8 }, + rescue: { w: 60, bottom: 9 }, + snow: { w: 44, bottom: 7 }, + bikepark: { w: 58, bottom: 8 }, + ropes: { w: 58, bottom: 8 }, + bikerental: { w: 50, bottom: 8 }, + platform: { w: 62, bottom: 9 }, + playground: { w: 58, bottom: 9 }, + // Hinweis: Bus bleibt bewusst Vektor (dynamische Fahrtrichtung). + trail: { w: 48, bottom: 8 }, + badesee: { w: 74, bottom: 12 }, + bus: { w: 46, bottom: 3 }, + fir: { w: 42, bottom: 6 }, + fir_snow: { w: 42, bottom: 6 }, + broadleaf: { w: 44, bottom: 6 }, +}; + +export class IsoRenderer { + constructor(canvas, state, callbacks) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.state = state; + this.cb = callbacks; // {onTileTap(c,r)} + this.cam = { x: 0, y: 0, scale: 1 }; + this.pointers = new Map(); + this.dragMoved = 0; + this.pinchDist = 0; + this.hoverTile = null; + this.buildType = null; // aktiver Platzierungs-/Abrissmodus + this.guests = []; // sichtbare Gäste-Figuren + this.staff = []; // sichtbare Personal-Figuren (pendeln) + this.time = 0; // echte Zeit (Wolken, Schneefall) + this.simTime = 0; // Spielzeit – skaliert mit der Zeitskala + this.simSpeed = 0; + this.flakes = []; // Schneeflocken (Screen-Space) + this.tracks = []; // Skispuren im Schnee {x1,y1,x2,y2,t} + this.heli = null; // aktiver Rettungshubschrauber {x,y,phase,victim,home} + this.floats = []; // aufsteigende Texte (z.B. Tageseinnahmen) + this.vel = { x: 0, y: 0 }; // Kamera-Schwung nach dem Ziehen + this.zoomTarget = null; // sanfter Mausrad-Zoom + this.clouds = [ + { x: 0.15, y: 70, w: 130, s: 6, a: 0.75 }, + { x: 0.45, y: 40, w: 190, s: 4, a: 0.85 }, + { x: 0.78, y: 95, w: 110, s: 8, a: 0.6 }, + { x: 0.3, y: 130, w: 80, s: 11, a: 0.4 }, + { x: 0.9, y: 55, w: 150, s: 7, a: 0.7 }, + ]; + this.birds = [ + { y: 90, s: 26, ph: 0 }, + { y: 130, s: 21, ph: 2.1 }, + { y: 60, s: 31, ph: 4.4 }, + ]; + + // Sprites laden (Fallback: Vektor-Zeichnung) + this.sprites = {}; + for (const [name, def] of Object.entries(SPRITE_DEFS)) { + const img = new Image(); + img.onload = () => { this.sprites[name] = { img, ...def }; }; + img.src = `assets/sprites/${name}.png`; + } + + this._bindInput(); + this.resize(); + this._centerCamera(); + } + + // Aufsteigender Kurztext in Weltkoordinaten (z.B. "+1.240 €") + addFloat(text, color, c, r, small = false) { + this.floats.push({ text, color, c, r, small, t0: this.simTime }); + if (this.floats.length > 10) this.floats.shift(); + } + + resize() { + const dpr = window.devicePixelRatio || 1; + this.dpr = dpr; + this.canvas.width = innerWidth * dpr; + this.canvas.height = innerHeight * dpr; + // Zoom-Grenzen: am weitesten rein = ~8 Kacheln Breite (schärfegrenze), + // am weitesten raus = ganze Kernzone im Blick, Rand bleibt gefüllt. + this.maxScale = innerWidth / (8 * TILE_W); + this.minScale = Math.max(0.55, innerWidth / ((CORE.c1 - CORE.c0 + 8) * TILE_W)); + if (this.cam) this.cam.scale = Math.max(this.minScale, Math.min(this.maxScale, this.cam.scale)); + } + + _centerCamera() { + // Start nah beim Dorf – klein anfangen, Überblick durch Zoomen + const village = this.tileToWorld(CORE.c0 + 7.5, CORE.r0 + 22.5, 1); + this.cam.scale = Math.min(this.maxScale, Math.max(this.minScale, innerWidth / 1100)); + this.cam.x = innerWidth / 2 - village.x * this.cam.scale; + this.cam.y = innerHeight * 0.62 - village.y * this.cam.scale; + } + + // Kamera so begrenzen, dass der Blick über der gefüllten Kernzone bleibt + // (das rechteckige Fenster ist dadurch immer voll Landschaft, nie leer). + _clampCamera() { + const mid = this.tileToWorld((CORE.c0 + CORE.c1) / 2, (CORE.r0 + CORE.r1) / 2, 2); + // erlaubter Bereich für den Weltpunkt in Bildschirmmitte + const halfSpanX = (CORE.c1 - CORE.c0) / 2 * TW2; + const halfSpanY = (CORE.r1 - CORE.r0) / 2 * TH2; + const wx = (innerWidth / 2 - this.cam.x) / this.cam.scale; + const wy = (innerHeight / 2 - this.cam.y) / this.cam.scale; + const cx = Math.max(mid.x - halfSpanX, Math.min(mid.x + halfSpanX, wx)); + const cy = Math.max(mid.y - halfSpanY, Math.min(mid.y + halfSpanY, wy)); + this.cam.x = innerWidth / 2 - cx * this.cam.scale; + this.cam.y = innerHeight / 2 - cy * this.cam.scale; + } + + tileToWorld(c, r, elev = 0) { + return { + x: (c - r) * TW2, + y: (c + r) * TH2 - elev * ELEV_Y, + }; + } + + // Glatt interpolierte Geländehöhe an einer Bruch-Position (x = Spalte, + // y = Reihe). Figuren nutzen sie, damit sie nicht von Kachel zu Kachel auf + // und ab hüpfen, sondern über eine gedachte Ebene gleiten (bilinear). + elevAt(x, y) { + const map = this.state.map; + const x0 = Math.floor(x), y0 = Math.floor(y); + const fx = x - x0, fy = y - y0; + const at = (cc, rr) => map[Math.max(0, Math.min(MAP_ROWS - 1, rr))] + ?.[Math.max(0, Math.min(MAP_COLS - 1, cc))]?.elev ?? 0; + const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * fx; + const bot = at(x0, y0 + 1) + (at(x0 + 1, y0 + 1) - at(x0, y0 + 1)) * fx; + return top + (bot - top) * fy; + } + + screenToTile(sx, sy) { + const wx = (sx - this.cam.x) / this.cam.scale; + const wy = (sy - this.cam.y) / this.cam.scale; + // Höhenbewusst: von der höchsten Stufe abwärts prüfen, welche Kachel + // ihre OBERSEITE genau unter dem Zeiger hat – sonst zeigt der Cursor + // am Hang mehrere Felder daneben. + for (let e = 9; e >= 0; e--) { + const wyE = wy + e * ELEV_Y; + const c = Math.floor((wx / TW2 + wyE / TH2) / 2 + 0.5); + const r = Math.floor((wyE / TH2 - wx / TW2) / 2 + 0.5); + if (c < 0 || r < 0 || c >= MAP_COLS || r >= MAP_ROWS) continue; + // >= statt ===: Punkte auf der Seitenwand gehören zur höheren Kachel, + // sonst gibt es „tote" Streifen zwischen den Höhenstufen + if (this.state.map[r][c].elev >= e) return { c, r }; + } + return null; + } + + setBuildType(type) { + this.buildType = type; + } + + // ---------- Eingabe ---------- + _bindInput() { + const cv = this.canvas; + cv.addEventListener('pointerdown', e => { + cv.setPointerCapture(e.pointerId); + this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + this.dragMoved = 0; + this.vel.x = 0; this.vel.y = 0; // Momentum stoppen + this.zoomTarget = null; + if (this.pointers.size === 2) { + const [a, b] = [...this.pointers.values()]; + this.pinchDist = Math.hypot(a.x - b.x, a.y - b.y); + } + }); + cv.addEventListener('pointermove', e => { + const p = this.pointers.get(e.pointerId); + if (!p) { + // Maus-Hover am PC: Platzierungsvorschau + this.hoverTile = this.screenToTile(e.clientX, e.clientY); + return; + } + this.hoverTile = this.screenToTile(e.clientX, e.clientY); + const dx = e.clientX - p.x; + const dy = e.clientY - p.y; + this.dragMoved += Math.abs(dx) + Math.abs(dy); + if (this.pointers.size === 1) { + this.cam.x += dx; + this.cam.y += dy; + this.vel.x = dx; // für Momentum beim Loslassen + this.vel.y = dy; + } + p.x = e.clientX; + p.y = e.clientY; + if (this.pointers.size === 2) { + const [a, b] = [...this.pointers.values()]; + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (this.pinchDist > 0) { + const mx = (a.x + b.x) / 2; + const my = (a.y + b.y) / 2; + this._zoomAround(mx, my, d / this.pinchDist); + } + this.pinchDist = d; + } + }); + const up = e => { + const wasTap = this.dragMoved < 10 && this.pointers.size === 1; + this.pointers.delete(e.pointerId); + this.pinchDist = 0; + if (wasTap) { + this.vel.x = 0; this.vel.y = 0; + const t = this.screenToTile(e.clientX, e.clientY); + if (t) this.cb.onTileTap(t.c, t.r); + } + // sonst: Schwung aus der letzten Bewegung mitnehmen (Momentum) + }; + cv.addEventListener('pointerup', up); + cv.addEventListener('pointercancel', e => this.pointers.delete(e.pointerId)); + cv.addEventListener('wheel', e => { + e.preventDefault(); + // sanft: Zielwert setzen, die Animation macht draw() + const cur = this.zoomTarget?.scale ?? this.cam.scale; + this.zoomTarget = { + scale: Math.min(this.maxScale, Math.max(this.minScale, cur * (e.deltaY < 0 ? 1.15 : 0.87))), + x: e.clientX, + y: e.clientY, + }; + }, { passive: false }); + window.addEventListener('resize', () => this.resize()); + } + + _zoomAround(mx, my, factor) { + const next = Math.min(this.maxScale, Math.max(this.minScale, this.cam.scale * factor)); + const real = next / this.cam.scale; + this.cam.x = mx - (mx - this.cam.x) * real; + this.cam.y = my - (my - this.cam.y) * real; + this.cam.scale = next; + } + + // ---------- Gäste-Figuren: individueller Tagesablauf ---------- + // Gäste reisen mit dem Bus an (Gruppen steigen an der Haltestelle aus), + // kommen aus dem Hotel oder zu Fuß über die Talstraße. Dann folgen sie + // einer Routine: spazieren/wandern → einkehren (verweilen) → Bergfahrt/ + // Skifahren (eigene Abfahrtsroute, Liftwechsel) → … → Abreise zu Fuß. + RIDE_MS = 12000; // muss zur Sesselgeschwindigkeit passen + + _busInfo() { + if (!this.state.buildings.some(b => b.type === 'access')) return null; + let f = (this.simTime / 26000) % 1; + if (f > 0.5) f = 1 - f; + f *= 2; + return { c: CORE.c0 + 0.5 + f * (CORE.c1 - CORE.c0 - 1), r: ROAD_ROW }; + } + + _spawnPoint() { + const s = this.state; + const stop = s.buildings.find(b => b.type === 'access'); + const hotel = s.buildings.find(b => b.type === 'hotel'); + const r = Math.random(); + if (hotel && r < 0.35) return { x: hotel.c + 0.5, y: hotel.r + 1 }; + if (stop && r < 0.85) return { x: stop.c + 0.3, y: stop.r + 0.8 }; + return { x: Math.random() < 0.5 ? CORE.c0 + 0.2 : CORE.c1 - 0.2, y: ROAD_ROW }; + } + + _exitPoint(g) { + const stop = this.state.buildings.find(b => b.type === 'access'); + if (stop) return { c: stop.c + 0.3, r: stop.r + 0.8 }; + return { c: g.x < (CORE.c0 + CORE.c1) / 2 ? CORE.c0 + 0.2 : CORE.c1 - 0.2, r: ROAD_ROW }; + } + + _spawnGuest(at) { + const p = at || this._spawnPoint(); + this.guests.push({ + x: p.x, y: p.y, tx: p.x, ty: p.y, + mode: 'dwell', wait: 0.4 + Math.random() * 0.8, + route: [], ride: 0, lift: null, done: false, + speed: 0.55 + Math.random() * 0.35, + phase: Math.random() * 7, + stridePhase: Math.random() * 6, + kid: Math.random() < 0.18, + pack: Math.random() < 0.5, + prop: ['pole', 'umbrella', 'bag', null, null, null][Math.floor(Math.random() * 6)], + hue: ['#e8892b', '#2f6ea0', '#a34a8e', '#3f8f6b', '#c9531f'][Math.floor(Math.random() * 5)], + }); + } + + _pisteLenOf(b) { + return BUILDINGS[b.type]?.pisteLen || PISTE_LEN; + } + + _rideMsOf(g) { + return this.RIDE_MS * (this._pisteLenOf(g.lift) / PISTE_LEN); + } + + _goLift(g, lifts) { + g.lift = lifts[Math.floor(Math.random() * lifts.length)]; // Liftwechsel! + g.mode = 'toLift'; + g.tx = g.lift.c + 0.3; + g.ty = g.lift.r + 0.5; + this._say(g, liftSkiable(this.state, g.lift.alt) ? '\u{1F3BF}' : '\u{1F6A0}'); + } + + // Gedankenblase (Sims-Stil): zeigt Absicht/Stimmung der Figur + _say(g, icon, ms = 2800) { + g.bubble = { icon, until: this.simTime + ms }; + } + + // Nächster Punkt im Tagesablauf + _nextActivity(g) { + const s = this.state; + const lifts = s.buildings.filter(b => b.type === 'lift' || b.type === 'gondola'); + const skiLifts = lifts.filter(b => liftSkiable(s, b.alt)); // je Höhenlage + const anySki = skiLifts.length > 0; + const restaurants = s.buildings.filter(b => b.type === 'restaurant'); + const attractions = s.buildings.filter(b => + b.type === 'trail' || (s.season === 'Sommer' && b.type === 'badesee') + || (s.season !== 'Winter' + && ['sled', 'bikepark', 'ropes', 'platform', 'playground'].includes(b.type))); + const r = Math.random(); + if (anySki && r < 0.6) return this._goLift(g, skiLifts); + if (lifts.length && r < 0.22) return this._goLift(g, lifts); + if (restaurants.length && r < 0.45) { // einkehren + const t = restaurants[Math.floor(Math.random() * restaurants.length)]; + g.mode = 'walk'; g.longDwell = true; g.visit = t; + g.tx = t.c + 0.4; g.ty = t.r + 0.9; + this._say(g, '\u{1F37D}\uFE0F'); + return; + } + if (attractions.length && r < 0.68) { // Attraktion besuchen + const t = attractions[Math.floor(Math.random() * attractions.length)]; + g.mode = 'walk'; g.longDwell = true; g.visit = t; + g.tx = t.c + 0.4; g.ty = t.r + 0.8; + this._say(g, { + badesee: '\u{1F3D6}\uFE0F', sled: '\u{1F6F7}', trail: '\u{1F97E}', + bikepark: '\u{1F6B5}', ropes: '\u{1F9D7}', platform: '\u{1F4F8}', playground: '\u{1F3A0}', + }[t.type] || '\u{1F97E}'); + return; + } + // Winter ohne Skimöglichkeit: Enttäuschung zeigen (kein Schnee/Lift) + if (s.season === 'Winter' && !anySki && Math.random() < 0.25) { + this._say(g, lifts.length ? '\u2744\uFE0F' : '\u{1F6A0}'); + } + const d = this._randomDestination(); // spazieren + g.mode = 'walk'; g.longDwell = false; + g.tx = d.c; g.ty = d.r; + } + + // Individuelle Abfahrtsroute: Wegpunkte mit seitlichem Versatz + _startSki(g) { + const s = this.state; + const len = this._pisteLenOf(g.lift); + const top = g.lift.r - len; + g.route = []; + const steps = len > 5 ? 5 : 3; + let side = Math.random() < 0.5 ? 1 : -1; + for (let i = 1; i <= steps; i++) { + // weite Schwünge: abwechselnd stark seitlich ausholen + g.route.push({ + c: Math.max(1, Math.min(MAP_COLS - 2, + g.lift.c + side * (1.5 + Math.random() * 2.8))), + r: top + (len * i) / steps, + }); + side = -side; + } + // manchmal unten quer zum NÄCHSTEN Lift fahren (Liftwechsel auf Skiern) + const others = s.buildings.filter(b => + (b.type === 'lift' || b.type === 'gondola') && b !== g.lift); + if (others.length && Math.random() < 0.4) { + const nxt = others[Math.floor(Math.random() * others.length)]; + g.route.push({ c: nxt.c + 0.3, r: nxt.r + 0.4 }); + g.nextLift = nxt; + } else { + g.route.push({ c: g.lift.c + (Math.random() - 0.5) * 0.8, r: g.lift.r + 0.4 }); + g.nextLift = null; + } + g.mode = 'ski'; + const n = g.route.shift(); + g.tx = n.c; g.ty = n.r; + } + + syncGuests(dt) { + const s = this.state; + const target = Math.min(45, Math.round(s.guestsToday / 10)); // 1 Figur = 10 Gäste + const active = this.guests.filter(g => g.mode !== 'leave').length; + + // Anreise: Gruppen steigen aus dem Bus, sonst einzeln (Hotel/Straße) + this._spawnCd = (this._spawnCd ?? 0) - dt; + this._busCd = (this._busCd ?? 0) - dt; + const bus = this._busInfo(); + const stop = s.buildings.find(b => b.type === 'access'); + if (bus && stop && this._busCd <= 0 && Math.abs(bus.c - stop.c) < 0.5 && active < target) { + const n = Math.min(3, target - active); + for (let i = 0; i < n; i++) { + this._spawnGuest({ x: stop.c + 0.2 + i * 0.25, y: stop.r + 0.8 + i * 0.15 }); + } + this._busCd = 8; + } else if (active < target && this._spawnCd <= 0) { + this._spawnGuest(); + this._spawnCd = 0.7; + } + // Abreise: überzählige Gäste gehen zur Haltestelle/Straße und verschwinden dort + if (active > target + 1) { + const g = this.guests.find(x => (x.mode === 'walk' || x.mode === 'dwell')); + if (g) { + const e = this._exitPoint(g); + g.mode = 'leave'; + g.tx = e.c; g.ty = e.r; + this._say(g, this.state.satisfaction < 55 ? '\u{1F61E}' : '\u{1F44B}', 4000); + } + } + this.guests = this.guests.filter(g => !g.done); + + const lifts = s.buildings.filter(b => b.type === 'lift' || b.type === 'gondola'); + + for (const g of this.guests) { + if (g.lift && !lifts.includes(g.lift)) { // Lift wurde abgerissen + g.lift = null; + if (g.mode === 'ride' || g.mode === 'ski' || g.mode === 'toLift') g.mode = 'walk'; + } + // Skifahrer nur auf Schnee: sobald sie unter die Schneegrenze geraten + // (ihre AKTUELLE Kachel ist zu tief/grün), werden sie wieder Fußgänger + // und laufen talwärts weiter – sonst „Ski auf Wiese", sieht komisch aus. + if (g.mode === 'ski') { + const ct = s.map[Math.round(g.y)]?.[Math.round(g.x)]; + if (!ct || ct.terrain === 'water' || !liftSkiable(s, ct.elev)) { + g.mode = 'walk'; g.route = []; g.nextLift = null; g.longDwell = false; + g.tx = g.x + (Math.random() - 0.5) * 2; g.ty = g.y + 1.5; + } + } + if (g.mode === 'crashed') { g.moving = false; continue; } // liegt, wartet auf Bergung + + if (g.mode === 'dwell') { // verweilen (essen, schauen) + g.wait -= dt; + g.moving = false; + if (g.wait <= 0) this._nextActivity(g); + continue; + } + + if (g.mode === 'ride') { // sitzt sichtbar im Sessel + g.ride += dt * 1000; + const len = this._pisteLenOf(g.lift); + const f = Math.min(1, g.ride / this._rideMsOf(g)); + g.x = g.lift.c; + g.y = g.lift.r - len * f; + g.moving = false; + if (f >= 1) { + if (liftSkiable(s, g.lift.alt)) this._startSki(g); + else { // Wanderer: oben aussteigen + g.mode = 'walk'; g.longDwell = true; + g.tx = g.lift.c + (Math.random() - 0.5) * 3; + g.ty = g.lift.r - len + 1 + Math.random() * 2; + } + } + continue; + } + + // Bewegung (walk / toLift / ski / leave) + const dx = g.tx - g.x; + const dy = g.ty - g.y; + const dist = Math.hypot(dx, dy); + const speed = g.mode === 'ski' ? g.speed * 1.6 : g.speed; + g.moving = dist >= 0.15; + if (dist < 0.15) { + if (g.mode === 'leave') { g.done = true; continue; } + if (g.mode === 'toLift') { + g.mode = 'ride'; g.ride = 0; + if (Math.random() < 0.5) { + const price = s.season === 'Winter' ? s.price : 8; + this.addFloat(`🎫 +${price} €`, '#3f8f5e', g.lift.c + 0.3, g.lift.r - 0.4, true); + } + continue; + } + if (g.mode === 'ski') { + if (g.route.length) { // nächster Wegpunkt + const n = g.route.shift(); + g.tx = n.c; g.ty = n.r; + } else if (g.nextLift) { // quergefahren → gleich einsteigen + g.lift = g.nextLift; + g.nextLift = null; + g.mode = 'ride'; g.ride = 0; + } else this._nextActivity(g); // unten angekommen + continue; + } + // walk angekommen → verweilen; Besuch macht Einnahmen sichtbar + if (g.visit) { + const V = { + restaurant: ['🥨', 12], badesee: ['🏖️', 5], sled: ['🛷', 7], trail: ['🥾', 3], + bikepark: ['🚵', 9], ropes: ['🧗', 8], platform: ['📸', 6], playground: ['🎠', 4], + }; + const v = V[g.visit.type]; + if (v) this.addFloat(`${v[0]} +${v[1]} €`, '#3f8f5e', g.visit.c + 0.4, g.visit.r - 0.3, true); + this._say(g, '\u{1F60B}', 3200); + g.visit = null; + } + g.mode = 'dwell'; + g.wait = g.longDwell ? 3 + Math.random() * 4 : 1 + Math.random() * 2; + continue; + } + const px = g.x, py = g.y; + g.x += (dx / dist) * speed * dt; + g.y += (dy / dist) * speed * dt; + if (g.mode === 'ski') g.x += Math.sin(this.simTime / 250 + g.phase) * 0.012; + g.vx = g.x - px; g.vy = g.y - py; // für Blickrichtung + g.stridePhase += Math.hypot(g.vx, g.vy) * 18; // Schritte = Strecke + // Spuren hinterlassen: Skispuren (Winter) bzw. Fußspuren (Sommer) + const leaves = g.mode === 'ski' + || (g.mode === 'walk' && this.state.season !== 'Winter'); + if (leaves) { + g.trackDist = (g.trackDist || 0) + Math.hypot(g.x - px, g.y - py); + const step = g.mode === 'ski' ? 0.3 : 0.5; + if (g.trackDist > step) { + g.trackDist = 0; + this.tracks.push({ + x1: g.lastTx ?? px, y1: g.lastTy ?? py, x2: g.x, y2: g.y, + t: this.simTime, foot: g.mode !== 'ski', + }); + g.lastTx = g.x; g.lastTy = g.y; + if (this.tracks.length > 500) this.tracks.shift(); + } + } else { g.lastTx = g.lastTy = undefined; } + } + } + + _randomDestination() { + const s = this.state; + if (s.buildings.length && Math.random() < 0.6) { + const t = s.buildings[Math.floor(Math.random() * s.buildings.length)]; + return { c: t.c + 0.5, r: t.r + 1.2 }; + } + return { c: CORE.c0 + 3 + Math.random() * (CORE.c1 - CORE.c0 - 6), r: CORE.r1 - 2 - Math.random() * 5 }; + } + + // ---------- Personal-Figuren (pendeln Wohnung ↔ Betrieb) ---------- + syncStaff(dt) { + const s = this.state; + const workplaces = s.buildings.filter(b => (BUILDINGS[b.type].staff || 0) > 0); + const homes = s.buildings.filter(b => b.type === 'staffhouse'); + // 1 Figur je 2 besetzte Stellen, gedeckelt + const working = Math.min(s.staffNeeded, s.staffAvailable); + const target = Math.min(24, Math.round(working / 2)); + + while (this.staff.length < target && workplaces.length) { + const home = homes.length + ? homes[Math.floor(Math.random() * homes.length)] + : { c: CORE.c0 + 7, r: CORE.r1 - 1 }; // Dorf, wenn kein Personalhaus + const wp = workplaces[Math.floor(Math.random() * workplaces.length)]; + this.staff.push({ + x: home.c + 0.5, y: home.r + 1, home, wp, + mode: 'toWork', tx: wp.c + 0.5, ty: wp.r + 1, + wait: 0, stridePhase: Math.random() * 6, + speed: 0.6 + Math.random() * 0.3, + vx: 0, vy: 0, + hue: ['#4f7f5e', '#c9531f', '#5a6b7a'][Math.floor(Math.random() * 3)], + }); + } + if (this.staff.length > target) this.staff.length = target; + + for (const w of this.staff) { + // Ziel-Gebäude könnte abgerissen sein + if (!s.buildings.includes(w.wp)) { w.wp = workplaces[0]; if (!w.wp) { w.done = true; continue; } } + if (w.mode === 'work' || w.mode === 'rest') { + w.wait -= dt; w.moving = false; + if (w.wait <= 0) { + if (w.mode === 'work') { w.mode = 'toHome'; w.tx = w.home.c + 0.5; w.ty = w.home.r + 1; } + else { w.mode = 'toWork'; w.tx = w.wp.c + 0.5; w.ty = w.wp.r + 1; } + } + continue; + } + const dx = w.tx - w.x, dy = w.ty - w.y; + const d = Math.hypot(dx, dy); + w.moving = d >= 0.12; + if (d < 0.12) { + w.mode = w.mode === 'toWork' ? 'work' : 'rest'; + w.wait = 2 + Math.random() * 3; + } else { + const px = w.x, py = w.y; + w.x += (dx / d) * w.speed * dt; + w.y += (dy / d) * w.speed * dt; + w.vx = w.x - px; w.vy = w.y - py; + w.stridePhase += d ? w.speed * dt * 18 : 0; + } + } + this.staff = this.staff.filter(w => !w.done); + } + + _drawStaff() { + for (const w of this.staff) { + if (w.mode === 'work' || w.mode === 'rest') continue; // drinnen, unsichtbar + const { x, y } = this.tileToWorld(w.x, w.y, this.elevAt(w.x, w.y)); + const svx = (w.vx ?? 0) - (w.vy ?? 0); + this._shadow(x, y + 1, 4, 1.8); + this._walker(x, y, { + h: 15, color: w.hue, phase: w.stridePhase || 0, + still: w.moving === false, flip: svx < 0, + prop: 'hat', pack: false, worker: true, + }); + } + } + + // Rettungseinsatz: selten stürzt ein Skifahrer weit abseits. Gibt es eine + // Rettungsstation, startet automatisch der Notarzthubschrauber, birgt ihn + // und fliegt zurück. Ohne Station steht der Gast nach einer Weile selbst auf. + _updateRescue(dt) { + const s = this.state; + const hasStation = s.buildings.some(b => b.type === 'rescue'); + const incident = this.heli || this.guests.some(g => g.mode === 'crashed'); + if (!incident) { + for (const g of this.guests) { + if (g.mode === 'ski' && Math.random() < 0.02 * dt) { + g.mode = 'crashed'; g.crashAt = this.simTime; + g.route = []; g.nextLift = null; g.moving = false; + this._say(g, '\u{1F198}', 9e8); // 🆘 bleibt sichtbar + break; + } + } + } + // Ohne Station: Selbst-Genesung nach ~20 s (Spielzeit) + if (!hasStation) { + for (const g of this.guests) { + if (g.mode === 'crashed' && this.simTime - g.crashAt > 20000) { + g.mode = 'walk'; g.bubble = null; + const d = this._randomDestination(); g.tx = d.c; g.ty = d.r; + } + } + } + // Heli-Einsatz starten + if (!this.heli && hasStation) { + const victim = this.guests.find(g => g.mode === 'crashed'); + if (victim) { + const st = s.buildings.find(b => b.type === 'rescue'); + const home = { x: st.c + 0.5, y: st.r - 0.5 }; + this.heli = { x: home.x, y: home.y, phase: 'out', face: 1, + bob: 0, hoverT: 0, ang: 0, victim, home }; + } + } + // Heli langsam & natürlich: hinfliegen → über dem Gast kreisen → zurück + if (this.heli) { + const h = this.heli; + h.bob += dt; + const SPD = 2.6 * dt; // gemächliches Tempo + // Nase nach der BILDSCHIRM-Bewegung ausrichten: screen-x ∝ (Spalte − Reihe) + const faceBy = (px, py) => { + const sdx = (h.x - px) - (h.y - py); + if (Math.abs(sdx) > 0.0015) h.face = sdx > 0 ? 1 : -1; + }; + if (h.phase === 'hover') { + h.hoverT += dt; h.ang += dt * 1.6; + const px = h.x, py = h.y; + h.x = h.victim.x + Math.cos(h.ang) * 1.1; // kreist über dem Gast + h.y = h.victim.y + Math.sin(h.ang) * 0.7; + faceBy(px, py); + if (h.hoverT > 3) { h.victim.done = true; h.phase = 'back'; } + } else { + const t = h.phase === 'out' ? h.victim : h.home; + const dx = t.x - h.x, dy = t.y - h.y, dist = Math.hypot(dx, dy); + if (dist < (h.phase === 'out' ? 1.1 : 0.3)) { + if (h.phase === 'out') { h.phase = 'hover'; h.hoverT = 0; } + else this.heli = null; // an der Station gelandet + } else { + const px = h.x, py = h.y; + h.x += (dx / dist) * SPD; h.y += (dy / dist) * SPD; + faceBy(px, py); + } + } + } + } + + _drawHeli() { + if (!this.heli) return; + const ctx = this.ctx; + const h = this.heli; + const fly = 9 + Math.sin(h.bob * 3) * 0.35; // sanftes Wippen + const { x, y } = this.tileToWorld(h.x, h.y, fly); + const g = this.tileToWorld(h.x, h.y, this.elevAt(h.x, h.y)); + this._shadow(g.x, g.y, 9, 3.6); + const f = h.face || 1; // 1 = Nase nach rechts + + // Heckausleger (hinten) + Finne + drehender Heckrotor + ctx.strokeStyle = '#2f3e46'; ctx.lineWidth = 3; + ctx.beginPath(); ctx.moveTo(x - 3 * f, y - 1); ctx.lineTo(x - 22 * f, y - 4); ctx.stroke(); + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(x - 22 * f, y - 4); ctx.lineTo(x - 24 * f, y - 9); ctx.stroke(); + const trot = this.time / 25; + ctx.strokeStyle = 'rgba(47,62,70,0.6)'; ctx.lineWidth = 1.6; + ctx.beginPath(); + ctx.moveTo(x - 24 * f - Math.cos(trot) * 4, y - 7 - Math.sin(trot) * 4); + ctx.lineTo(x - 24 * f + Math.cos(trot) * 4, y - 7 + Math.sin(trot) * 4); + ctx.stroke(); + + // Kufen mit Streben + ctx.strokeStyle = '#2f3e46'; ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(x - 9, y + 8); ctx.lineTo(x + 11, y + 8); + ctx.moveTo(x - 5, y + 5); ctx.lineTo(x - 6, y + 8); + ctx.moveTo(x + 6, y + 5); ctx.lineTo(x + 7, y + 8); + ctx.stroke(); + + // Rumpf (tropfenförmig) + Bauchschattierung + ctx.fillStyle = '#e8892b'; + ctx.beginPath(); ctx.ellipse(x, y, 12, 7, 0, 0, 7); ctx.fill(); + ctx.fillStyle = this._shade('#e8892b', -16); + ctx.beginPath(); ctx.ellipse(x, y + 2.5, 11, 4, 0, 0, Math.PI); ctx.fill(); + // Cockpit-Verglasung vorne (Nasenrichtung) mit Rahmen + Türlinie + ctx.fillStyle = 'rgba(200,224,232,0.95)'; + ctx.beginPath(); ctx.ellipse(x + 6.5 * f, y - 0.5, 4.4, 4, 0, 0, 7); ctx.fill(); + ctx.strokeStyle = 'rgba(47,62,70,0.5)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.ellipse(x + 6.5 * f, y - 0.5, 4.4, 4, 0, 0, 7); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(x - 1 * f, y - 5); ctx.lineTo(x - 1 * f, y + 4); ctx.stroke(); + // rotes Kreuz auf der Seite + ctx.strokeStyle = '#c0392b'; ctx.lineWidth = 2.2; + ctx.beginPath(); + ctx.moveTo(x - 5.5 * f, y - 1); ctx.lineTo(x - 1.5 * f, y - 1); + ctx.moveTo(x - 3.5 * f, y - 3); ctx.lineTo(x - 3.5 * f, y + 1); ctx.stroke(); + + // Rotor-Mast + Nabe + drehender Hauptrotor (lang) + Rotor-Unschärfe + ctx.strokeStyle = '#2f3e46'; ctx.lineWidth = 1.6; + ctx.beginPath(); ctx.moveTo(x, y - 6.5); ctx.lineTo(x, y - 10); ctx.stroke(); + ctx.fillStyle = '#2f3e46'; + ctx.beginPath(); ctx.arc(x, y - 10, 1.6, 0, 7); ctx.fill(); + ctx.strokeStyle = 'rgba(47,62,70,0.12)'; ctx.lineWidth = 3; + ctx.beginPath(); ctx.ellipse(x, y - 10, 20, 5, 0, 0, 7); ctx.stroke(); + const rot = this.time / 55; + ctx.strokeStyle = 'rgba(47,62,70,0.55)'; ctx.lineWidth = 2; + for (let i = 0; i < 2; i++) { + const a = rot + i * Math.PI; + ctx.beginPath(); + ctx.moveTo(x - Math.cos(a) * 20, y - 10 - Math.sin(a) * 5); + ctx.lineTo(x + Math.cos(a) * 20, y - 10 + Math.sin(a) * 5); + ctx.stroke(); + } + ctx.lineWidth = 1; + } + + // Sichtbarer Umbau: ~10 s Bauzeit (Spielzeit), danach wird die neue Stufe + // scharf geschaltet. Läuft für ALLE Baustellen (auch außerhalb des Bildes). + CONSTRUCT_MS = 10000; + _updateConstruction() { + for (const b of this.state.buildings) { + if (!b.underConstruction) continue; + if (b._cStart === undefined) b._cStart = this.simTime; + if (this.simTime - b._cStart >= this.CONSTRUCT_MS) { + b.level = b.constructTo; + const tile = this.state.map[b.r]?.[b.c]; + if (tile) tile.level = b.constructTo; + delete b.underConstruction; delete b.constructTo; delete b._cStart; + } + } + } + + _drawConstruction() { + const ctx = this.ctx; + for (const b of this.state.buildings) { + if (!b.underConstruction) continue; + const tile = this.state.map[b.r]?.[b.c]; + if (!tile) continue; + const { x, y } = this.tileToWorld(b.c, b.r, tile.elev); + const p = Math.max(0, Math.min(1, (this.simTime - (b._cStart ?? this.simTime)) / this.CONSTRUCT_MS)); + // Gerüst: vier Eckstangen + zwei Querlatten + ctx.strokeStyle = 'rgba(122,112,92,0.85)'; ctx.lineWidth = 1.3; + for (const dx of [-11, 11]) { + ctx.beginPath(); ctx.moveTo(x + dx, y + 6); ctx.lineTo(x + dx, y - 30); ctx.stroke(); + } + ctx.beginPath(); + ctx.moveTo(x - 11, y - 8); ctx.lineTo(x + 11, y - 8); + ctx.moveTo(x - 11, y - 21); ctx.lineTo(x + 11, y - 21); + ctx.stroke(); + // Staub-Püffchen + ctx.fillStyle = 'rgba(200,190,170,0.5)'; + for (let i = 0; i < 3; i++) { + const t = (this.simTime / 500 + i / 3) % 1; + ctx.beginPath(); ctx.arc(x - 8 + i * 8, y + 3 - t * 9, 1.4 + t * 2, 0, 7); ctx.fill(); + } + // Kranausleger + wippender Haken + const hook = Math.sin(this.simTime / 420) * 3; + ctx.strokeStyle = '#2f3e46'; ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x + 11, y - 30); ctx.lineTo(x + 2, y - 30); + ctx.lineTo(x + 2, y - 22 + hook); ctx.stroke(); + // Fortschrittsbalken über der Baustelle + const bw = 26, bx = x - bw / 2, by = y - 40; + ctx.fillStyle = 'rgba(47,62,70,0.22)'; ctx.fillRect(bx, by, bw, 4); + ctx.fillStyle = '#e8892b'; ctx.fillRect(bx, by, bw * p, 4); + ctx.strokeStyle = 'rgba(47,62,70,0.4)'; ctx.lineWidth = 0.8; + ctx.strokeRect(bx, by, bw, 4); + ctx.lineWidth = 1; + } + } + + // ---------- Zeichnen ---------- + draw(now, dt, speed = 0) { + this.time = now; + this.simSpeed = speed; + const simDt = dt * speed; // Spielzeit: Figuren, Lift, Bus … + this.simTime += simDt; // … laufen mit der Zeitskala mit + if (simDt > 0) { this.syncGuests(simDt / 1000); this.syncStaff(simDt / 1000); this._updateRescue(simDt / 1000); this._updateConstruction(); } + + // Kamera: Schwung nach dem Ziehen + sanfter Mausrad-Zoom + if (!this.pointers.size && (Math.abs(this.vel.x) > 0.3 || Math.abs(this.vel.y) > 0.3)) { + this.cam.x += this.vel.x; + this.cam.y += this.vel.y; + this.vel.x *= 0.92; + this.vel.y *= 0.92; + } + if (this.zoomTarget) { + const zt = this.zoomTarget; + const next = this.cam.scale + (zt.scale - this.cam.scale) * 0.22; + this._zoomAround(zt.x, zt.y, next / this.cam.scale); + if (Math.abs(this.cam.scale - zt.scale) < 0.003) this.zoomTarget = null; + } + this._clampCamera(); // Blick über der gefüllten Kernzone halten + + const { ctx, cam, dpr } = this; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, innerWidth, innerHeight); + + this._drawSky(); + + ctx.save(); + ctx.translate(cam.x, cam.y); + ctx.scale(cam.scale, cam.scale); + + const winter = this.state.season === 'Winter'; + const snowline = winter + ? (this.state.snow > 10 ? 1 : 3) + : this.state.season === 'Frühling' ? 6 : 99; + + // Culling: nur Kacheln zeichnen, die im Fenster liegen (großer Rahmen!) + const vw = innerWidth / cam.scale, vh = innerHeight / cam.scale; + const ox = -cam.x / cam.scale, oy = -cam.y / cam.scale; + for (let r = 0; r < MAP_ROWS; r++) { + for (let c = 0; c < MAP_COLS; c++) { + const wx = (c - r) * TW2, wy = (c + r) * TH2; + if (wx < ox - TILE_W || wx > ox + vw + TILE_W) continue; + if (wy < oy - 160 || wy > oy + vh + TILE_H) continue; + this._drawTile(this.state.map[r][c], snowline, winter); + } + } + this._drawTracks(winter); + this._drawSledRuns(); + if (this.state.stageRow > 0) this._drawFog(); + this._drawPreview(); + this._drawBus(); + this._drawFestivals(winter); + this._drawStaffAlerts(); + this._drawConstruction(); + this._drawRiders(); + this._drawStaff(); + this._drawGuests(winter); + this._drawHeli(); + this._drawOccupancy(); + this._drawFloats(); + ctx.restore(); + + if (winter && dt > 0) this._drawSnowfall(dt); + } + + _drawSky() { + const ctx = this.ctx; + const w = innerWidth, h = innerHeight; + const winter = this.state.season === 'Winter'; + const grad = ctx.createLinearGradient(0, 0, 0, h); + if (winter) { grad.addColorStop(0, '#c9dbe4'); grad.addColorStop(1, '#e9e9e2'); } + else { grad.addColorStop(0, '#bcd8e8'); grad.addColorStop(1, '#ece7d6'); } + ctx.fillStyle = grad; + ctx.fillRect(0, 0, w, h); + + // Ferne Bergsilhouetten mit leichter Parallaxe zur Kamera + const par = this.cam.x * 0.06; + ctx.fillStyle = winter ? 'rgba(160,180,190,0.5)' : 'rgba(120,150,140,0.35)'; + this._ridge(ctx, par, h * 0.34, 220, 90); + ctx.fillStyle = winter ? 'rgba(190,205,212,0.6)' : 'rgba(150,175,160,0.4)'; + this._ridge(ctx, par * 1.6 + 300, h * 0.4, 170, 60); + + // Wolken: weiche Häufchen aus mehreren Puffs, die leicht atmen + for (const cl of this.clouds) { + const x = ((cl.x * innerWidth + this.time / (60 * cl.s)) % (innerWidth + 260)) - 130; + const yb = cl.y + Math.sin(this.time / 5000 + cl.s) * 3; + ctx.fillStyle = `rgba(255,255,255,${cl.a})`; + ctx.beginPath(); + ctx.ellipse(x, yb, cl.w * 0.5, cl.w * 0.13, 0, 0, 7); + ctx.ellipse(x + cl.w * 0.24, yb - cl.w * 0.09, cl.w * 0.3, cl.w * 0.11, 0, 0, 7); + ctx.ellipse(x - cl.w * 0.26, yb - cl.w * 0.05, cl.w * 0.26, cl.w * 0.09, 0, 0, 7); + ctx.ellipse(x + cl.w * 0.05, yb - cl.w * 0.13, cl.w * 0.2, cl.w * 0.08, 0, 0, 7); + ctx.fill(); + } + + // Sommer-Vögel ziehen mit flatternden Flügeln über den Himmel + if (!winter) { + ctx.strokeStyle = 'rgba(60,75,85,0.7)'; + ctx.lineWidth = 1.4; + ctx.lineCap = 'round'; + for (const b of this.birds) { + const bx = ((this.time / (14 * b.s)) + b.ph * 300) % (innerWidth + 200) - 100; + const by = b.y + Math.sin(this.time / 900 + b.ph) * 8; + const flap = Math.sin(this.time / 120 + b.ph) * 3.5; + ctx.beginPath(); + ctx.moveTo(bx - 5, by - flap); + ctx.quadraticCurveTo(bx, by, bx, by); + ctx.quadraticCurveTo(bx, by, bx + 5, by - flap); + ctx.stroke(); + } + ctx.lineWidth = 1; + } + } + + _ridge(ctx, offset, baseY, spread, height) { + const w = innerWidth; + ctx.beginPath(); + ctx.moveTo(-50, baseY + 60); + for (let x = -50; x <= w + 50; x += spread) { + const peak = baseY - height * (0.5 + 0.5 * Math.sin((x + offset) / 130)); + ctx.lineTo(x, peak); + ctx.lineTo(x + spread / 2, baseY - height * 0.15); + } + ctx.lineTo(w + 50, baseY + 60); + ctx.closePath(); + ctx.fill(); + } + + _drawSnowfall(dt) { + const ctx = this.ctx; + while (this.flakes.length < 70) { + this.flakes.push({ + x: Math.random() * innerWidth, + y: Math.random() * innerHeight, + r: 1 + Math.random() * 2, + v: 20 + Math.random() * 40, + drift: Math.random() * 2 - 1, + }); + } + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + for (const f of this.flakes) { + f.y += f.v * dt / 1000; + f.x += f.drift * 12 * dt / 1000; + if (f.y > innerHeight) { f.y = -4; f.x = Math.random() * innerWidth; } + ctx.beginPath(); + ctx.arc(f.x, f.y, f.r, 0, 7); + ctx.fill(); + } + } + + _diamond(x, y) { + const ctx = this.ctx; + ctx.beginPath(); + ctx.moveTo(x, y - TH2); + ctx.lineTo(x + TW2, y); + ctx.lineTo(x, y + TH2); + ctx.lineTo(x - TW2, y); + ctx.closePath(); + } + + _drawTile(tile, snowline, winter) { + const ctx = this.ctx; + const { x, y } = this.tileToWorld(tile.c, tile.r, tile.elev); + const snowy = tile.elev >= snowline && tile.terrain !== 'water'; + let color = TERRAIN_COLORS[tile.terrain]; + if (snowy) color = tile.terrain === 'forest' ? '#8fae94' : '#eef4f1'; + color = this._shade(color, tile.tint); + + // Höhenkanten für Tiefenwirkung; Kartenrand bekommt tiefe Klippen + const edge = tile.r === MAP_ROWS - 1 || tile.c === MAP_COLS - 1; + if (tile.elev > 0 || edge) { + const h = tile.elev * ELEV_Y + (edge ? 30 : 0); + ctx.fillStyle = this._shade(color, -34); + ctx.beginPath(); + ctx.moveTo(x - TW2, y); + ctx.lineTo(x, y + TH2); + ctx.lineTo(x, y + TH2 + h); + ctx.lineTo(x - TW2, y + h); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = this._shade(color, -18); + ctx.beginPath(); + ctx.moveTo(x + TW2, y); + ctx.lineTo(x, y + TH2); + ctx.lineTo(x, y + TH2 + h); + ctx.lineTo(x + TW2, y + h); + ctx.closePath(); + ctx.fill(); + } + + this._diamond(x, y); + if (tile.terrain === 'water') { + const shimmer = Math.sin(this.simTime / 500 + tile.r) * 8; + ctx.fillStyle = this._shade('#7fb2c9', shimmer); + } else { + ctx.fillStyle = color; + } + ctx.fill(); + ctx.strokeStyle = 'rgba(47,62,70,0.06)'; + ctx.stroke(); + + // kleine Details je Untergrund + if (!snowy && !tile.road) this._tileDetail(tile, x, y); + if (tile.terrain === 'water' && !tile.bridge) this._waterStreak(tile, x, y); + + // Talstraße (bzw. Brücke über den Fluss) + if (tile.road) this._drawRoad(tile, x, y, snowy); + + // Piste: weiß, solange am zugehörigen Lift Schnee liegt (Höhenlage!), + // sonst als grüne Schneise – so weicht das Weiß von unten nach oben. + const piste = this.state.pistes.find(p => p.c === tile.c && p.r === tile.r); + if (piste) { + const ownerTile = this.state.map[piste.owner.r]?.[piste.owner.c]; + const skiable = ownerTile && liftSkiable(this.state, ownerTile.elev); + this._diamond(x, y); + ctx.fillStyle = skiable + ? 'rgba(255,255,255,0.65)' + : 'rgba(216,228,178,0.7)'; + ctx.fill(); + } + const isPiste = !!piste; + + // Bike-Trail (Bikepark): brauner Singletrail, der sich talwärts schlängelt + if (this.state.trails?.some(t => t.c === tile.c && t.r === tile.r) && !tile.building) { + ctx.strokeStyle = 'rgba(122,90,58,0.75)'; + ctx.lineWidth = 2.4; + ctx.beginPath(); + ctx.moveTo(x - 11, y - 5); + ctx.quadraticCurveTo(x + 7, y - 3, x - 4, y + 5); + ctx.stroke(); + ctx.strokeStyle = 'rgba(122,90,58,0.35)'; + ctx.lineWidth = 4.5; + ctx.beginPath(); + ctx.moveTo(x - 11, y - 5); + ctx.quadraticCurveTo(x + 7, y - 3, x - 4, y + 5); + ctx.stroke(); + ctx.lineWidth = 1; + } + + if (tile.terrain === 'forest' && !tile.building && !isPiste) { + this._drawTree(x, y, snowy, tile.tint); + } + if (tile.deco) this._drawDeco(tile.deco, x, y); + if (tile.building) this._drawBuilding(tile.building, x, y, tile); + + // Noch nicht erschlossene Hochlagen liegen unter Nebel + if (tile.r < this.state.stageRow) { + this._diamond(x, y); + ctx.fillStyle = 'rgba(238,243,245,0.55)'; + ctx.fill(); + } + } + + // Ausbaustufe markant zeigen: Wimpelgirlande + Stern-Abzeichen; höhere + // Stufen bekommen einen warmen Glanz. (x,y) = Ankerpunkt oben am Gebäude. + _badge(x, y, level) { + if ((level || 1) < 2) return; + const ctx = this.ctx; + const roman = ['', 'I', 'II', 'III', 'IV'][level] || String(level); + + // Warmer Glanz hinter höher ausgebauten Gebäuden (Stufe III/IV) + if (level >= 3) { + const g = ctx.createRadialGradient(x - 6, y + 14, 2, x - 6, y + 14, 34); + g.addColorStop(0, `rgba(255,210,120,${level >= 4 ? 0.28 : 0.18})`); + g.addColorStop(1, 'rgba(255,210,120,0)'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(x - 6, y + 14, 34, 0, 7); ctx.fill(); + } + + // Wimpelgirlande über dem Dach – ein Bogen mit (level-1) Wimpeln + const span = 18 + level * 3; + const ax = x - 12 - span / 2, bx = x - 12 + span / 2, ty = y - 6; + ctx.strokeStyle = '#6b4f2f'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(ax, ty); + ctx.quadraticCurveTo((ax + bx) / 2, ty + 7, bx, ty); + ctx.stroke(); + const flags = level + 1; + for (let i = 1; i < flags; i++) { + const f = i / flags; + const fx = (1 - f) * (1 - f) * ax + 2 * (1 - f) * f * ((ax + bx) / 2) + f * f * bx; + const fy = (1 - f) * (1 - f) * ty + 2 * (1 - f) * f * (ty + 7) + f * f * ty; + ctx.fillStyle = ['#e8892b', '#7ba05b', '#c0392b', '#2f6ea0'][i % 4]; + ctx.beginPath(); + ctx.moveTo(fx - 2.5, fy); ctx.lineTo(fx + 2.5, fy); ctx.lineTo(fx, fy + 5); + ctx.closePath(); ctx.fill(); + } + + // Stern-Abzeichen mit Stufe + ctx.fillStyle = level >= 4 ? '#e8892b' : level >= 3 ? '#e8b93b' : '#e8b93b'; + ctx.strokeStyle = '#a8801f'; + ctx.lineWidth = 1; + this._star(x, y, 7, 3.2, 5); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = level >= 4 ? '#fff' : '#5a4310'; + ctx.font = 'bold 6px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(roman, x, y + 2.2); + } + + _star(cx, cy, rO, rI, points) { + const ctx = this.ctx; + ctx.beginPath(); + for (let i = 0; i < points * 2; i++) { + const r = i % 2 ? rI : rO; + const a = (Math.PI / points) * i - Math.PI / 2; + const px = cx + Math.cos(a) * r, py = cy + Math.sin(a) * r; + i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); + } + ctx.closePath(); + } + + // Rodelbahnen: Schiene, Stützen und Schlitten ÜBER dem Gelände + _drawSledRuns() { + const ctx = this.ctx; + for (const b of this.state.buildings) { + if (b.type !== 'sled') continue; + const tile = this.state.map[b.r][b.c]; + const { x, y } = this.tileToWorld(b.c, b.r, tile.elev); + const level = tile.level || 1; + const segs = [5, 8, 10][Math.min(2, level - 1)]; + const pts = []; + for (let i = 0; i <= segs; i++) { + const rr = Math.min(MAP_ROWS - 1, b.r + i); + const cc = b.c + Math.sin(i * 1.6 + tile.tint) * 0.5; + const tt = this.state.map[rr]?.[Math.round(Math.max(0, Math.min(MAP_COLS - 1, cc)))]; + pts.push(this.tileToWorld(cc, rr, tt ? tt.elev : 0)); + } + // Stützen + ctx.strokeStyle = '#8a8378'; + ctx.lineWidth = 1.5; + for (let i = 1; i < segs; i++) { + ctx.beginPath(); + ctx.moveTo(pts[i].x, pts[i].y + 3); + ctx.lineTo(pts[i].x, pts[i].y - 4); + ctx.stroke(); + } + // Doppelschiene + ctx.strokeStyle = '#9aa4ab'; + ctx.lineWidth = 2.2; + for (const off of [-1.6, 1.6]) { + ctx.beginPath(); + ctx.moveTo(pts[0].x + off, pts[0].y - 4); + for (let i = 1; i <= segs; i++) ctx.lineTo(pts[i].x + off, pts[i].y - 4); + ctx.stroke(); + } + ctx.lineWidth = 1; + // Zielplattform am unteren Ende + ctx.fillStyle = '#d9cfb4'; + ctx.fillRect(pts[segs].x - 7, pts[segs].y - 6, 14, 5); + // Schlitten mit Kurvenneigung + const drawSled = (phase, riderHue) => { + const pr = ((this.simTime / (2200 * segs)) + phase) % 1; + const fi = pr * segs; + const i0 = Math.min(segs - 1, Math.floor(fi)); + const f = fi - i0; + const sx = pts[i0].x + (pts[i0 + 1].x - pts[i0].x) * f; + const sy = pts[i0].y + (pts[i0 + 1].y - pts[i0].y) * f - 5; + const ang = Math.atan2(pts[i0 + 1].y - pts[i0].y, pts[i0 + 1].x - pts[i0].x); + ctx.save(); + ctx.translate(sx, sy); + ctx.rotate(ang * 0.5); + ctx.fillStyle = '#c9531f'; + ctx.fillRect(-4.5, -2, 9, 4); + ctx.fillStyle = riderHue; + ctx.beginPath(); ctx.ellipse(0, -4, 2, 2.8, -0.2, 0, 7); ctx.fill(); + ctx.fillStyle = '#f0d4b8'; + ctx.beginPath(); ctx.arc(0.5, -7, 1.7, 0, 7); ctx.fill(); + ctx.restore(); + }; + drawSled(0, '#2f6ea0'); + if (level >= 2) drawSled(0.5, '#a34a8e'); + if (level >= 3) drawSled(0.25, '#3f8f6b'); + this._badge(x + 14, y - 22, level); + } + } + + // Skispuren im Schnee – verblassen nach ~90 Spielsekunden + _drawTracks(winter) { + const ctx = this.ctx; + const DUR = 90000; + this.tracks = this.tracks.filter(t => { + if (this.simTime - t.t > DUR) return false; + if (!t.foot && (!winter || this.state.snow < 8)) return false; // Skispur schmilzt + return true; + }); + ctx.lineWidth = 1.1; + for (const t of this.tracks) { + const age = (this.simTime - t.t) / DUR; + const a = this.tileToWorld(t.x1, t.y1, this.elevAt(t.x1, t.y1)); + const b = this.tileToWorld(t.x2, t.y2, this.elevAt(t.x2, t.y2)); + if (t.foot) { + // Wanderer-Fußspur: gedämpfte Trittpunkte auf dem Pfad + ctx.fillStyle = `rgba(120,100,70,${0.28 * (1 - age)})`; + ctx.beginPath(); ctx.arc(b.x - 1, b.y, 1.1, 0, 7); ctx.fill(); + ctx.beginPath(); ctx.arc(b.x + 1, b.y + 0.6, 1.1, 0, 7); ctx.fill(); + } else { + ctx.strokeStyle = `rgba(96,118,134,${0.4 * (1 - age)})`; + ctx.beginPath(); + ctx.moveTo(a.x - 1.2, a.y); ctx.lineTo(b.x - 1.2, b.y); + ctx.moveTo(a.x + 1.2, a.y); ctx.lineTo(b.x + 1.2, b.y); + ctx.stroke(); + } + } + ctx.lineWidth = 1; + } + + // Treibende Nebelschwaden über dem gesperrten Hochgebirge + _drawFog() { + const ctx = this.ctx; + const rFog = Math.max(1, this.state.stageRow - 3); + ctx.fillStyle = 'rgba(255,255,255,0.45)'; + for (let i = 0; i < 5; i++) { + const c = ((this.time / (4000 + i * 900)) + i * 4.7) % (MAP_COLS + 8) - 4; + const rr = rFog + (i % 3); + const t = this.state.map[rr]?.[Math.max(0, Math.min(MAP_COLS - 1, Math.round(c)))]; + const { x, y } = this.tileToWorld(c, rr, t ? t.elev : 6); + ctx.beginPath(); + ctx.ellipse(x, y - 12, 65, 16, 0, 0, 7); + ctx.ellipse(x + 45, y - 5, 48, 12, 0, 0, 7); + ctx.fill(); + } + } + + _tileDetail(tile, x, y) { + const ctx = this.ctx; + const t = tile.tint; + if (tile.terrain === 'meadow' || tile.terrain === 'alm') { + // Grasbüschel + ctx.strokeStyle = 'rgba(74,110,60,0.5)'; + ctx.lineWidth = 1; + for (let i = 0; i < 3; i++) { + const gx = x + ((t * 7 + i * 23) % 36) - 18; + const gy = y + ((t * 5 + i * 13) % 14) - 7; + ctx.beginPath(); + ctx.moveTo(gx, gy); ctx.lineTo(gx - 2, gy - 4); + ctx.moveTo(gx, gy); ctx.lineTo(gx + 2, gy - 4); + ctx.stroke(); + } + // Blümchen im Sommer + if (this.state.season === 'Sommer' && (t % 3) === 0) { + ctx.fillStyle = (t % 2) ? '#f4f1e4' : '#e8a45b'; + ctx.beginPath(); + ctx.arc(x + t, y + 4, 1.6, 0, 7); + ctx.fill(); + } + } else if (tile.terrain === 'field') { + // Ackerfurchen + ctx.strokeStyle = 'rgba(140,120,80,0.4)'; + for (let i = -1; i <= 1; i++) { + ctx.beginPath(); + ctx.moveTo(x - TW2 * 0.5 + i * 8, y - TH2 * 0.25 + i * 8 * 0.5); + ctx.lineTo(x + TW2 * 0.35 + i * 8, y + TH2 * 0.55 - (TW2 * 0.35 - i * 8) / TW2 * 0 + i * 8 * 0.5 - TH2 * 0.35); + ctx.stroke(); + } + } else if (tile.terrain === 'rock') { + ctx.strokeStyle = 'rgba(80,75,65,0.35)'; + ctx.beginPath(); + ctx.moveTo(x - 8 + t, y - 2); + ctx.lineTo(x + t, y + 3); + ctx.lineTo(x + 7 + t, y + 1); + ctx.stroke(); + } + } + + _drawRoad(tile, x, y, snowy) { + const ctx = this.ctx; + // Fahrbahn folgt der Ost-West-Richtung (Nachbar c−1 ↔ c+1) + const a = [x - TW2 * 0.5, y - TH2 * 0.5]; + const b = [x + TW2 * 0.5, y + TH2 * 0.5]; + ctx.lineCap = 'round'; + if (tile.bridge) { + // Holzbrücke: etwas breiter, mit Geländer + ctx.strokeStyle = '#8a6a42'; + ctx.lineWidth = 15; + ctx.beginPath(); ctx.moveTo(...a); ctx.lineTo(...b); ctx.stroke(); + ctx.strokeStyle = '#6b4f2f'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(a[0], a[1] - 8); ctx.lineTo(b[0], b[1] - 8); + ctx.moveTo(a[0], a[1] + 8); ctx.lineTo(b[0], b[1] + 8); + ctx.stroke(); + } else { + ctx.strokeStyle = snowy ? '#d3d8d5' : '#b5a98e'; + ctx.lineWidth = 13; + ctx.beginPath(); ctx.moveTo(...a); ctx.lineTo(...b); ctx.stroke(); + // Mittellinie + ctx.strokeStyle = 'rgba(255,255,255,0.6)'; + ctx.lineWidth = 1.2; + ctx.setLineDash([5, 6]); + ctx.beginPath(); ctx.moveTo(...a); ctx.lineTo(...b); ctx.stroke(); + ctx.setLineDash([]); + } + ctx.lineWidth = 1; + ctx.lineCap = 'butt'; + } + + // Kleiner Bus pendelt auf der Talstraße, sobald es eine Haltestelle gibt. + // Als echter Iso-Körper entlang der Straßenachse gezeichnet. + _drawBus() { + const s = this.state; + if (!s.buildings.some(b => b.type === 'access')) return; + const ctx = this.ctx; + let f = (this.simTime / 26000) % 1; + if (f > 0.5) f = 1 - f; + f *= 2; + const c = CORE.c0 + 0.5 + f * (CORE.c1 - CORE.c0 - 1); + const r = ROAD_ROW; + const tile = s.map[r][Math.min(MAP_COLS - 1, Math.round(c))]; + const { x, y } = this.tileToWorld(c, r, tile.elev); + + // Iso-Quader entlang der Fahrbahn (dc-Achse). Bewusst Vektor gehalten. + const P = (dc, dr, up = 0) => + [x + (dc - dr) * TW2, y + (dc + dr) * TH2 - up]; + const L = 0.34, W = 0.11, H = 13, body = '#e8892b'; + const winB = H * 0.46, winT = H * 0.82; + + this._shadow(x, y + 2, 15, 5); + + // Räder zuerst (Körper deckt die Oberseiten ab) – mit Radkappe + for (const dc of [-L * 0.55, L * 0.55]) { + const wp = P(dc, W); + ctx.fillStyle = '#2f3e46'; + ctx.beginPath(); ctx.arc(wp[0], wp[1] + 1, 2.9, 0, 7); ctx.fill(); + ctx.fillStyle = '#8a939a'; + ctx.beginPath(); ctx.arc(wp[0], wp[1] + 1, 1.2, 0, 7); ctx.fill(); + } + + // lange Seitenwand (zum Betrachter) + ctx.fillStyle = this._shade(body, -18); + ctx.beginPath(); + ctx.moveTo(...P(-L, W)); ctx.lineTo(...P(L, W)); + ctx.lineTo(...P(L, W, H)); ctx.lineTo(...P(-L, W, H)); + ctx.closePath(); ctx.fill(); + // drei Seitenfenster mit Stegen + ctx.fillStyle = 'rgba(223,232,236,0.92)'; + for (let i = 0; i < 3; i++) { + const a = -L * 0.78 + i * (L * 1.52 / 3), b = a + L * 1.52 / 3 - 0.04; + ctx.beginPath(); + ctx.moveTo(...P(a, W, winB)); ctx.lineTo(...P(b, W, winB)); + ctx.lineTo(...P(b, W, winT)); ctx.lineTo(...P(a, W, winT)); + ctx.closePath(); ctx.fill(); + } + // Türlinie + ctx.strokeStyle = this._shade(body, -34); ctx.lineWidth = 0.8; + ctx.beginPath(); ctx.moveTo(...P(L * 0.06, W)); ctx.lineTo(...P(L * 0.06, W, winB)); ctx.stroke(); + + // Front (Fahrtrichtung, +L) + ctx.fillStyle = body; + ctx.beginPath(); + ctx.moveTo(...P(L, W)); ctx.lineTo(...P(L, -W)); + ctx.lineTo(...P(L, -W, H)); ctx.lineTo(...P(L, W, H)); + ctx.closePath(); ctx.fill(); + // Windschutzscheibe + ctx.fillStyle = 'rgba(223,232,236,0.92)'; + ctx.beginPath(); + ctx.moveTo(...P(L, W * 0.7, winB)); ctx.lineTo(...P(L, -W * 0.7, winB)); + ctx.lineTo(...P(L, -W * 0.7, winT)); ctx.lineTo(...P(L, W * 0.7, winT)); + ctx.closePath(); ctx.fill(); + // Scheinwerfer + ctx.fillStyle = '#ffe6a0'; + const hl1 = P(L, W * 0.6, H * 0.3), hl2 = P(L, -W * 0.6, H * 0.3); + ctx.beginPath(); ctx.arc(hl1[0], hl1[1], 1.1, 0, 7); ctx.arc(hl2[0], hl2[1], 1.1, 0, 7); ctx.fill(); + // Stoßstange + ctx.strokeStyle = '#2f3e46'; ctx.lineWidth = 1.4; + ctx.beginPath(); ctx.moveTo(...P(L, W, H * 0.16)); ctx.lineTo(...P(L, -W, H * 0.16)); ctx.stroke(); + + // Dach + Highlight-Kante + ctx.fillStyle = this._shade(body, 24); + ctx.beginPath(); + ctx.moveTo(...P(-L, W, H)); ctx.lineTo(...P(L, W, H)); + ctx.lineTo(...P(L, -W, H)); ctx.lineTo(...P(-L, -W, H)); + ctx.closePath(); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(...P(-L, W, H)); ctx.lineTo(...P(L, W, H)); ctx.stroke(); + + // --- Feinheiten --- + // Zierstreifen unter den Fenstern + ctx.strokeStyle = this._shade(body, 32); ctx.lineWidth = 1.3; + ctx.beginPath(); ctx.moveTo(...P(-L, W, winB - 1)); ctx.lineTo(...P(L, W, winB - 1)); ctx.stroke(); + // Radkästen (Bogen über den Rädern) + ctx.strokeStyle = this._shade(body, -36); ctx.lineWidth = 1.5; + for (const dc of [-L * 0.55, L * 0.55]) { + const wp = P(dc, W); + ctx.beginPath(); + ctx.moveTo(wp[0] - 3.7, wp[1] + 1); + ctx.quadraticCurveTo(wp[0], wp[1] - 4.2, wp[0] + 3.7, wp[1] + 1); + ctx.stroke(); + } + // Türgriff + ctx.strokeStyle = this._shade(body, -42); ctx.lineWidth = 1; + const gh = P(L * 0.16, W, winB * 0.7); + ctx.beginPath(); ctx.moveTo(gh[0] - 2, gh[1]); ctx.lineTo(gh[0] + 2, gh[1]); ctx.stroke(); + // Außenspiegel vorne + ctx.fillStyle = '#2f3e46'; + const mir = P(L, W * 1.4, H * 0.62); + ctx.beginPath(); ctx.arc(mir[0], mir[1], 1.3, 0, 7); ctx.fill(); + // Rücklicht hinten + ctx.fillStyle = '#c0392b'; + const rl = P(-L, W, H * 0.3); + ctx.beginPath(); ctx.arc(rl[0], rl[1], 1.2, 0, 7); ctx.fill(); + ctx.lineWidth = 1; + } + + _waterStreak(tile, x, y) { + const ctx = this.ctx; + const p = (this.simTime / 900 + tile.r * 0.7) % 1; + ctx.strokeStyle = 'rgba(255,255,255,0.45)'; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(x - 12 + p * 10, y - 3 + p * 4); + ctx.lineTo(x + 2 + p * 10, y + 2 + p * 4); + ctx.stroke(); + ctx.lineWidth = 1; + } + + _shadow(x, y, rx, ry = null) { + const ctx = this.ctx; + ctx.fillStyle = 'rgba(47,62,70,0.14)'; + ctx.beginPath(); + ctx.ellipse(x, y + 2, rx, ry ?? rx * 0.45, 0, 0, 7); + ctx.fill(); + } + + // KI-Sprite zeichnen, falls geladen; sonst false (→ Vektor-Fallback) + _sprite(name, x, y, scale = 1, flip = false) { + const s = this.sprites[name]; + if (!s) return false; + const w = s.w * scale; + this._shadow(x, y + 2, w * 0.38, w * 0.16); + // Motiv endet im 1024er-Bild bei ~88 % der Höhe + const top = y + s.bottom - w * 0.88; + if (flip) { + const ctx = this.ctx; + ctx.save(); ctx.translate(x, 0); ctx.scale(-1, 1); + ctx.drawImage(s.img, -w / 2, top, w, w); + ctx.restore(); + } else { + this.ctx.drawImage(s.img, x - w / 2, top, w, w); + } + return true; + } + + _drawTree(x, y, snowy, tint) { + const ctx = this.ctx; + const deciduous = (tint % 4) === 1 && y > 0; // etwas Abwechslung + const autumn = this.state.season === 'Herbst'; + // KI-Sprites, wenn vorhanden (Herbst-Laubbaum bleibt Vektor: orange!) + if (snowy && this._sprite('fir_snow', x, y)) return; + if (!snowy && !deciduous && this._sprite('fir', x, y)) return; + if (!snowy && deciduous && !autumn && this._sprite('broadleaf', x, y)) return; + this._shadow(x, y, 9); + if (deciduous) { + ctx.fillStyle = '#6b4f2f'; + ctx.fillRect(x - 1.5, y - 10, 3, 10); + ctx.fillStyle = snowy ? '#e7efe9' : autumn ? '#d08a3e' : '#5f9455'; + ctx.beginPath(); + ctx.arc(x, y - 16, 8, 0, 7); + ctx.arc(x - 5, y - 12, 5, 0, 7); + ctx.arc(x + 5, y - 12, 5, 0, 7); + ctx.fill(); + } else { + // Tanne mit zwei Etagen + ctx.fillStyle = '#6b4f2f'; + ctx.fillRect(x - 1.5, y - 8, 3, 8); + const green = snowy ? '#e7efe9' : '#3c6b4f'; + ctx.fillStyle = green; + ctx.beginPath(); + ctx.moveTo(x, y - 30); ctx.lineTo(x + 8, y - 16); ctx.lineTo(x - 8, y - 16); + ctx.closePath(); ctx.fill(); + ctx.fillStyle = this._shade(green === '#e7efe9' ? '#e7efe9' : '#3c6b4f', snowy ? -8 : 6); + ctx.beginPath(); + ctx.moveTo(x, y - 22); ctx.lineTo(x + 10, y - 7); ctx.lineTo(x - 10, y - 7); + ctx.closePath(); ctx.fill(); + } + } + + // Punkt auf der Kachel: dc/dr in Kachel-Einheiten (−0.5 … 0.5) + _pt(x, y, dc, dr) { + return [x + (dc - dr) * TW2, y + (dc + dr) * TH2]; + } + + // Liebevolles Alpenhaus: zwei sichtbare Wände, Giebel, zwei Dachschrägen, + // Fenster, Tür, optional Kamin mit Rauch. + _alpineHouse(x, y, o) { + const ctx = this.ctx; + const w2 = o.w / 2, d2 = o.d / 2, h = o.h, rh = o.rh; + const N = this._pt(x, y, -w2, -d2); + const E = this._pt(x, y, w2, -d2); + const S = this._pt(x, y, w2, d2); + const W = this._pt(x, y, -w2, d2); + const up = p => [p[0], p[1] - h]; + const Nt = up(N), Et = up(E), St = up(S), Wt = up(W); + const G1 = [(Wt[0] + St[0]) / 2, (Wt[1] + St[1]) / 2 - rh]; // Giebel vorne links + const G2 = [(Nt[0] + Et[0]) / 2, (Nt[1] + Et[1]) / 2 - rh]; // Giebel hinten rechts + + this._shadow(x, y + 4, o.w * TW2 * 0.75, o.d * TH2 * 0.75); + + // linke Wand (Giebelseite, dunkler) + ctx.fillStyle = this._shade(o.wall, -22); + ctx.beginPath(); + ctx.moveTo(...W); ctx.lineTo(...S); ctx.lineTo(...St); ctx.lineTo(...G1); ctx.lineTo(...Wt); + ctx.closePath(); ctx.fill(); + + // rechte Wand (hell) + ctx.fillStyle = o.wall; + ctx.beginPath(); + ctx.moveTo(...S); ctx.lineTo(...E); ctx.lineTo(...Et); ctx.lineTo(...St); + ctx.closePath(); ctx.fill(); + + // Tür auf der Giebelseite + if (o.door !== false) { + const dx = W[0] + (S[0] - W[0]) * 0.45; + const dy = W[1] + (S[1] - W[1]) * 0.45; + ctx.fillStyle = 'rgba(60,45,30,0.85)'; + ctx.fillRect(dx - 3, dy - 9, 6, 9); + } + + // Fenster auf der rechten Wand (warm erleuchtet) + const wins = o.windows ?? 1; + for (let i = 0; i < wins; i++) { + for (let row = 0; row < (o.floors ?? 1); row++) { + const f = (i + 1) / (wins + 1); + const wx = S[0] + (E[0] - S[0]) * f; + const wy = S[1] + (E[1] - S[1]) * f - h * (0.35 + row * 0.38); + ctx.fillStyle = '#f4c86a'; + ctx.fillRect(wx - 2.5, wy - 3, 5, 6); + ctx.strokeStyle = 'rgba(47,62,70,0.4)'; + ctx.strokeRect(wx - 2.5, wy - 3, 5, 6); + } + } + + // Dach: rechte Schräge sichtbar, linke als schmale Silhouette + ctx.fillStyle = this._shade(o.roof, -18); + ctx.beginPath(); + ctx.moveTo(...Wt); ctx.lineTo(...Nt); ctx.lineTo(...G2); ctx.lineTo(...G1); + ctx.closePath(); ctx.fill(); + ctx.fillStyle = o.roof; + ctx.beginPath(); + ctx.moveTo(...St); ctx.lineTo(...Et); ctx.lineTo(...G2); ctx.lineTo(...G1); + ctx.closePath(); ctx.fill(); + // Firstlinie + ctx.strokeStyle = this._shade(o.roof, -30); + ctx.beginPath(); ctx.moveTo(...G1); ctx.lineTo(...G2); ctx.stroke(); + + // Kamin mit Rauch + if (o.chimney) { + const cx = G1[0] + (G2[0] - G1[0]) * 0.7; + const cy = G1[1] + (G2[1] - G1[1]) * 0.7; + ctx.fillStyle = '#8a8378'; + ctx.fillRect(cx - 2.5, cy - 8, 5, 8); + for (let i = 0; i < 3; i++) { + const t = (this.simTime / 1400 + i / 3) % 1; + ctx.fillStyle = `rgba(230,230,225,${(1 - t) * 0.4})`; + ctx.beginPath(); + ctx.arc(cx + Math.sin(t * 6) * 2 + t * 4, cy - 10 - t * 16, 2 + t * 3, 0, 7); + ctx.fill(); + } + } + } + + _drawDeco(kind, x, y) { + if (this._sprite(kind, x, y)) return; + if (kind === 'house') { + this._alpineHouse(x, y, { + w: 0.5, d: 0.4, h: 11, rh: 7, + wall: '#e5dcc6', roof: '#7a5a3a', windows: 1, chimney: true, + }); + } else if (kind === 'church') { + this._alpineHouse(x, y, { + w: 0.5, d: 0.42, h: 13, rh: 8, + wall: '#efe9da', roof: '#8c8073', windows: 1, door: false, + }); + const ctx = this.ctx; + // Kirchturm mit Zwiebelhaube + ctx.fillStyle = '#efe9da'; + ctx.fillRect(x + 6, y - 36, 8, 24); + ctx.fillStyle = 'rgba(47,62,70,0.5)'; + ctx.fillRect(x + 8.5, y - 32, 3, 4); + ctx.fillStyle = '#8c8073'; + ctx.beginPath(); + ctx.moveTo(x + 4, y - 36); + ctx.quadraticCurveTo(x + 10, y - 48, x + 16, y - 36); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = '#8c8073'; + ctx.beginPath(); ctx.moveTo(x + 10, y - 46); ctx.lineTo(x + 10, y - 50); ctx.stroke(); + } + } + + _drawBuilding(type, x, y, tile) { + const ctx = this.ctx; + const level = tile.level || 1; + // KI-Sprites für Gebäude (Bahnen & Rodelbahn bleiben Vektor: dynamisch) + const spriteName = type === 'hotel' && level >= 2 ? 'hotel2' : type; + const spriteScale = type === 'hotel' + ? (level >= 3 ? 1.15 : 1) + : [1, 1.15, 1.3][Math.min(2, level - 1)]; + if (type !== 'lift' && type !== 'gondola' && type !== 'sled' + && this._sprite(spriteName, x, y, spriteScale)) { + this._badge(x + 18, y - 34, level); + if (type === 'badesee' && this.state.season === 'Sommer') { + // Schwimmer: wippende Köpfe + kleine Planscher im See + for (let i = 0; i < 3; i++) { + const p = this.simTime / 1400 + i * 2.2; + const sx = x + Math.sin(p * 0.7 + i) * 12; + const sy = y - 8 + Math.cos(p * 0.5 + i * 2) * 4; + ctx.fillStyle = '#f0d4b8'; + ctx.beginPath(); + ctx.arc(sx, sy + Math.sin(p * 3) * 0.8, 1.8, 0, 7); + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.7)'; + ctx.beginPath(); + ctx.arc(sx, sy + 2, 3.5, 0.2, Math.PI - 0.2); + ctx.stroke(); + } + } + if (type === 'snow' && this.state.season === 'Winter' && this.state.snow < 45) { + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + for (let i = 0; i < 3; i++) { + const p = (this.simTime / 700 + i / 3) % 1; + ctx.beginPath(); + ctx.arc(x + 10 + p * 16, y - 24 - p * 10 + i * 2, 1.5 + p, 0, 7); + ctx.fill(); + } + } + return; + } + switch (type) { + case 'restaurant': + this._alpineHouse(x, y, { + w: 0.62, d: 0.5, h: 16, rh: 10, + wall: '#f2e0c0', roof: '#b0563a', windows: 2, chimney: true, + }); + // Sonnenschirm davor (außer im Winter) + if (this.state.season !== 'Winter') { + ctx.fillStyle = '#e8892b'; + ctx.beginPath(); + ctx.moveTo(x - 18, y + 12); ctx.lineTo(x - 10, y + 6); ctx.lineTo(x - 4, y + 12); + ctx.closePath(); ctx.fill(); + ctx.strokeStyle = '#6b4f2f'; + ctx.beginPath(); ctx.moveTo(x - 11, y + 10); ctx.lineTo(x - 11, y + 17); ctx.stroke(); + } + break; + default: + // Fallback für Gebäude ohne Sprite und ohne Spezialzeichnung: + // kleines Alpenhaus + Emoji-Schild, bis der Sprite geladen ist. + this._alpineHouse(x, y, { + w: 0.55, d: 0.45, h: 13, rh: 8, + wall: '#ece4d2', roof: '#8a6f4d', windows: 1, + }); + ctx.font = '12px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(BUILDINGS[type]?.emoji || '❓', x, y - 24); + break; + case 'rescue': + this._alpineHouse(x, y, { + w: 0.6, d: 0.5, h: 15, rh: 9, + wall: '#f2ede2', roof: '#b0563a', windows: 2, chimney: false, + }); + // rotes Kreuz an der Wand (Bergrettung) + ctx.strokeStyle = '#c0392b'; ctx.lineWidth = 2.4; + ctx.beginPath(); + ctx.moveTo(x - 3, y - 4); ctx.lineTo(x + 3, y - 4); + ctx.moveTo(x, y - 7); ctx.lineTo(x, y - 1); ctx.stroke(); + ctx.lineWidth = 1; + break; + case 'hotel': + this._alpineHouse(x, y, { + w: 0.72, d: 0.55, h: 27, rh: 10, + wall: '#ede6d6', roof: '#8c2f2f', windows: 3, floors: 2, chimney: true, + }); + break; + case 'staffhouse': + this._alpineHouse(x, y, { + w: 0.55, d: 0.45, h: 14, rh: 8, + wall: '#b9c9a0', roof: '#44513f', windows: 2, chimney: true, + }); + break; + case 'lift': + case 'gondola': { + // Talstation + Seil über die Piste bis zur Bergstation, mit + // leeren Sesseln (bzw. Gondelkabinen); Fahrgäste: _drawRiders + const ends = this._liftEnds(tile); + this._alpineHouse(x, y, { + w: 0.5, d: 0.4, h: 10, rh: 6, + wall: '#d9cfb4', roof: '#5a5f63', windows: 1, door: false, + }); + ctx.strokeStyle = '#5a5f63'; + ctx.lineWidth = 2.5; + for (let f = 0.22; f < 1; f += 0.2) { + const px = ends.a.x + (ends.b.x - ends.a.x) * f; + const py = ends.aGround + (ends.bGround - ends.aGround) * f; + const cy = ends.a.y + (ends.b.y - ends.a.y) * f; + ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px, cy); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(px - 5, cy); ctx.lineTo(px + 5, cy); ctx.stroke(); + } + ctx.fillStyle = '#5a5f63'; + const stW = 18 + (level - 1) * 6; + ctx.fillRect(ends.b.x - stW / 2, ends.b.y - 4, stW, 11); + ctx.beginPath(); + ctx.moveTo(ends.a.x, ends.a.y); + ctx.lineTo(ends.b.x, ends.b.y); + ctx.stroke(); + if (level >= 2) { + // Stufe II: zweites Seil (Doppelbahn) – deutlich erkennbar + ctx.beginPath(); + ctx.moveTo(ends.a.x + 5, ends.a.y + 3); + ctx.lineTo(ends.b.x + 5, ends.b.y + 3); + ctx.stroke(); + } + ctx.lineWidth = 1; + // leere Sessel/Kabinen fahren gemächlich mit (Stufe II: mehr davon) + const len = BUILDINGS[type].pisteLen || PISTE_LEN; + const cycle = 24000 * (len / PISTE_LEN); + const n = (type === 'gondola' ? 3 : 4) + (level - 1) * 2; + for (let i = 0; i < n; i++) { + let f = ((this.simTime / cycle) + i / n) % 1; + if (f > 0.5) f = 1 - f; + f *= 2; + const gx = ends.a.x + (ends.b.x - ends.a.x) * f; + const gy = ends.a.y + (ends.b.y - ends.a.y) * f; + // Stufe II: jede zweite Einheit fährt am zweiten Seil + const off = level >= 2 && i % 2 ? 5 : 0; + if (type === 'gondola') this._cabin(gx + off, gy + off * 0.6); + else this._chair(gx + off, gy + off * 0.6); + } + this._badge(x + 14, y - 24, level); + break; + } + case 'snow': { + this._shadow(x, y, 8); + ctx.strokeStyle = '#5a5f63'; + ctx.lineWidth = 3; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + 6, y - 16); ctx.stroke(); + ctx.lineWidth = 1; + ctx.fillStyle = '#dfe8ec'; + ctx.beginPath(); ctx.ellipse(x + 8, y - 18, 6, 4, -0.5, 0, 7); ctx.fill(); + ctx.fillStyle = '#5a5f63'; + ctx.fillRect(x - 4, y - 3, 8, 4); + if (this.state.season === 'Winter' && this.state.snow < 45) { + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + for (let i = 0; i < 3; i++) { + const p = (this.simTime / 700 + i / 3) % 1; + ctx.beginPath(); + ctx.arc(x + 10 + p * 16, y - 20 - p * 10 + i * 2, 1.5 + p, 0, 7); + ctx.fill(); + } + } + break; + } + case 'badesee': { + this._shadow(x, y + 2, 20, 8); + ctx.fillStyle = '#7fc4d9'; + ctx.beginPath(); ctx.ellipse(x, y - 2, 20, 10, 0, 0, 7); ctx.fill(); + ctx.strokeStyle = '#e8dfc0'; + ctx.lineWidth = 3; + ctx.beginPath(); ctx.ellipse(x, y - 2, 21, 11, 0, 0, 7); ctx.stroke(); + ctx.lineWidth = 1; + // Steg + Sonnenschirm + ctx.fillStyle = '#8a6a42'; + ctx.fillRect(x - 3, y - 8, 14, 3); + ctx.fillStyle = '#e8892b'; + ctx.beginPath(); + ctx.moveTo(x - 14, y - 16); ctx.lineTo(x - 6, y - 12); ctx.lineTo(x - 14, y - 8); + ctx.closePath(); ctx.fill(); + break; + } + case 'sled': { + // Nur die Starthütte hier – Schiene & Schlitten zeichnet + // _drawSledRuns() NACH allen Kacheln (sonst übermalt der Boden + // der tieferliegenden Felder die hangabwärts führende Bahn!) + this._shadow(x, y + 2, 12, 5); + this._alpineHouse(x - 6, y - 2, { + w: 0.34, d: 0.3, h: 9, rh: 6, + wall: '#d9cfb4', roof: '#8a6a42', windows: 0, door: false, + }); + break; + } + case 'trail': { + // Wegweiser + Pfadsteine (Fallback, falls Sprite fehlt) + this._shadow(x, y, 10, 4); + ctx.strokeStyle = '#6b4f2f'; + ctx.lineWidth = 3; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y - 22); ctx.stroke(); + ctx.lineWidth = 1; + ctx.fillStyle = '#e8892b'; + ctx.beginPath(); + ctx.moveTo(x - 1, y - 20); ctx.lineTo(x + 13, y - 20); + ctx.lineTo(x + 17, y - 17); ctx.lineTo(x + 13, y - 14); ctx.lineTo(x - 1, y - 14); + ctx.closePath(); ctx.fill(); + ctx.fillStyle = '#d9cfb4'; + ctx.beginPath(); + ctx.moveTo(x + 1, y - 12); ctx.lineTo(x - 13, y - 12); + ctx.lineTo(x - 17, y - 9); ctx.lineTo(x - 13, y - 6); ctx.lineTo(x + 1, y - 6); + ctx.closePath(); ctx.fill(); + ctx.fillStyle = 'rgba(180,168,140,0.8)'; + for (let i = 0; i < 4; i++) { + ctx.beginPath(); + ctx.ellipse(x - 10 + i * 7, y + 4 + (i % 2) * 3, 3, 1.6, 0, 0, 7); + ctx.fill(); + } + break; + } + case 'access': { + this._shadow(x, y + 2, 16, 7); + // Wartehäuschen + ctx.fillStyle = '#d9cfb4'; + ctx.fillRect(x - 14, y - 14, 18, 14); + ctx.fillStyle = '#5a5f63'; + ctx.beginPath(); + ctx.moveTo(x - 16, y - 14); ctx.lineTo(x - 5, y - 20); ctx.lineTo(x + 6, y - 14); + ctx.closePath(); ctx.fill(); + ctx.fillStyle = 'rgba(47,62,70,0.35)'; + ctx.fillRect(x - 11, y - 10, 12, 7); + // Haltestellen-Schild + ctx.strokeStyle = '#5a5f63'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(x + 12, y + 2); ctx.lineTo(x + 12, y - 18); ctx.stroke(); + ctx.lineWidth = 1; + ctx.fillStyle = '#e8892b'; + ctx.beginPath(); ctx.arc(x + 12, y - 21, 5, 0, 7); ctx.fill(); + ctx.fillStyle = '#fff'; + ctx.font = 'bold 6px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('BUS', x + 12, y - 19); + break; + } + } + } + + // Seil-Endpunkte eines Lifts: a = Talstation, b = Bergstation (Screen-Koordinaten) + _liftEnds(tile) { + const base = this.tileToWorld(tile.c, tile.r, tile.elev); + const len = BUILDINGS[tile.building]?.pisteLen || PISTE_LEN; + const topR = Math.max(0, tile.r - len); + const topTile = this.state.map[topR]?.[tile.c]; + const top = this.tileToWorld(tile.c, topR, topTile ? topTile.elev : tile.elev); + return { + a: { x: base.x, y: base.y - 16 }, + b: { x: top.x, y: top.y - 26 }, + aGround: base.y, + bGround: top.y, + }; + } + + // Sessel eines Sessellifts (hängt am Seilpunkt gx/gy) + _chair(gx, gy, riderHue = null) { + const ctx = this.ctx; + ctx.strokeStyle = '#5a5f63'; + ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.moveTo(gx, gy); ctx.lineTo(gx, gy + 6); ctx.stroke(); + // Sitzfläche + Lehne + ctx.beginPath(); + ctx.moveTo(gx - 4, gy + 6); ctx.lineTo(gx + 4, gy + 6); + ctx.moveTo(gx + 4, gy + 6); ctx.lineTo(gx + 4, gy + 1); + ctx.stroke(); + ctx.lineWidth = 1; + if (riderHue) { + // sitzender Fahrgast: Körper + Kopf + angewinkelte Beine + ctx.fillStyle = riderHue; + ctx.beginPath(); ctx.ellipse(gx, gy + 3.5, 2.6, 3.6, 0, 0, 7); ctx.fill(); + ctx.fillStyle = '#f0d4b8'; + ctx.beginPath(); ctx.arc(gx, gy - 1.5, 2.2, 0, 7); ctx.fill(); + ctx.strokeStyle = '#3a4750'; + ctx.beginPath(); + ctx.moveTo(gx - 1, gy + 6); ctx.lineTo(gx - 1, gy + 9); + ctx.moveTo(gx + 1.5, gy + 6); ctx.lineTo(gx + 1.5, gy + 9); + ctx.stroke(); + } + } + + // Geschlossene Gondelkabine (hängt am Seilpunkt gx/gy) + _cabin(gx, gy, riderHue = null) { + const ctx = this.ctx; + ctx.strokeStyle = '#5a5f63'; + ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.moveTo(gx, gy); ctx.lineTo(gx, gy + 3); ctx.stroke(); + ctx.lineWidth = 1; + ctx.fillStyle = '#c9531f'; + ctx.beginPath(); + ctx.roundRect ? ctx.roundRect(gx - 5, gy + 3, 10, 10, 2) : ctx.rect(gx - 5, gy + 3, 10, 10); + ctx.fill(); + ctx.fillStyle = riderHue || 'rgba(223,232,236,0.9)'; + ctx.fillRect(gx - 3, gy + 5, 6, 4); + } + + // Fahrgäste in Sessellift/Gondel (Modus 'ride') sichtbar unterwegs + _drawRiders() { + for (const g of this.guests) { + if (g.mode !== 'ride' || !g.lift) continue; + const tile = this.state.map[g.lift.r]?.[g.lift.c]; + if (!tile) continue; + const ends = this._liftEnds(tile); + const f = Math.min(1, g.ride / this._rideMsOf(g)); + const gx = ends.a.x + (ends.b.x - ends.a.x) * f; + const gy = ends.a.y + (ends.b.y - ends.a.y) * f; + if (g.lift.type === 'gondola') this._cabin(gx, gy, g.hue); + else this._chair(gx, gy, g.hue); + } + } + + _drawPreview() { + const t = this.hoverTile; + if (!t || !this.buildType) return; + const tile = this.state.map[t.r]?.[t.c]; + if (!tile) return; + const demolishMode = this.buildType === 'demolish'; + const upgradeMode = this.buildType === 'upgrade'; + const ok = demolishMode + ? !!tile.building + : upgradeMode + ? !!tile.building && UPGRADABLE.includes(tile.building) && (tile.level || 1) < 3 + : canPlace(this.state, this.buildType, t.c, t.r).ok; + const { x, y } = this.tileToWorld(t.c, t.r, tile.elev); + + // Lange Elemente zeigen ihren ganzen Verlauf schon in der Vorschau + const fill = ok ? 'rgba(123,160,91,0.28)' : 'rgba(200,60,50,0.25)'; + const def = BUILDINGS[this.buildType]; + + // Einflusskreis: Gasthaus 5 Felder (Synergie), Personalhaus 6 (Personal) + const RADIUS = { restaurant: 5, staffhouse: 6 }[this.buildType]; + if (RADIUS) { + this.ctx.strokeStyle = 'rgba(79,127,94,0.7)'; + this.ctx.fillStyle = 'rgba(123,160,91,0.10)'; + this.ctx.setLineDash([7, 5]); + this.ctx.lineWidth = 1.8; + this.ctx.beginPath(); + this.ctx.ellipse(x, y, (RADIUS + 0.5) * TW2, (RADIUS + 0.5) * TH2, 0, 0, 7); + this.ctx.fill(); + this.ctx.stroke(); + this.ctx.setLineDash([]); + this.ctx.lineWidth = 1; + // Versorgungslinien: welche Gebäude im Kreis bedient/genutzt werden? + const serves = this.buildType === 'restaurant' + ? ['lift', 'gondola', 'badesee', 'sled', 'trail', 'hotel'] + : null; // staffhouse: alle Betriebe mit Personal + this.ctx.strokeStyle = 'rgba(232,137,43,0.9)'; + this.ctx.lineWidth = 2; + for (const b of this.state.buildings) { + const inRange = Math.max(Math.abs(b.c - t.c), Math.abs(b.r - t.r)) <= RADIUS; + if (!inRange) continue; + const hit = serves ? serves.includes(b.type) : (BUILDINGS[b.type].staff || 0) > 0; + if (!hit) continue; + const bt = this.state.map[b.r][b.c]; + const p = this.tileToWorld(b.c, b.r, bt.elev); + this.ctx.beginPath(); + this.ctx.moveTo(x, y - 4); this.ctx.lineTo(p.x, p.y - 8); + this.ctx.stroke(); + this.ctx.fillStyle = 'rgba(232,137,43,0.9)'; + this.ctx.beginPath(); this.ctx.arc(p.x, p.y - 8, 3, 0, 7); this.ctx.fill(); + } + this.ctx.lineWidth = 1; + } + if (def?.pisteLen) { + // Pistenkorridor hangaufwärts (Lift/Gondel) + for (let i = 1; i <= def.pisteLen; i++) { + const tt = this.state.map[t.r - i]?.[t.c]; + if (!tt) break; + const p = this.tileToWorld(t.c, t.r - i, tt.elev); + this._diamond(p.x, p.y); + this.ctx.fillStyle = fill; + this.ctx.fill(); + } + } else if (this.buildType === 'sled') { + // Bahnverlauf hangabwärts (Stufe I = 5 Felder) + for (let i = 1; i <= 5; i++) { + const rr = Math.min(MAP_ROWS - 1, t.r + i); + const cc = t.c + Math.sin(i * 1.6 + tile.tint) * 0.5; + const tt = this.state.map[rr]?.[Math.round(Math.max(0, Math.min(MAP_COLS - 1, cc)))]; + if (!tt) break; + const p = this.tileToWorld(cc, rr, tt.elev); + this._diamond(p.x, p.y); + this.ctx.fillStyle = fill; + this.ctx.fill(); + } + } + + this._diamond(x, y); + this.ctx.fillStyle = ok ? 'rgba(123,160,91,0.5)' : 'rgba(200,60,50,0.45)'; + this.ctx.fill(); + this.ctx.strokeStyle = ok ? '#4f7f5e' : '#a33227'; + this.ctx.lineWidth = 2; + this.ctx.stroke(); + this.ctx.lineWidth = 1; + } + + // ---------- Skelett-Walker (portiert aus der Tower-Defense-App) ---------- + // „Richtig" animierte Figur: Hüfte/Knie/Fuß pro Bein, gegenläufig + // schwingende Arme mit Ellbogen, geneigter Oberkörper, nickender Kopf. + // phase ist an die zurückgelegte Strecke gekoppelt → Schritte = Tempo. + _rrect(x, y, w, h, r) { + const ctx = this.ctx; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(x, y, w, h, r); + else ctx.rect(x, y, w, h); + ctx.fill(); + } + + _walker(x, y, o) { + const ctx = this.ctx; + const H = o.h; + const legL = H * 0.46, thigh = legL * 0.52, shin = legL * 0.55; + const torsoL = H * 0.36; + const headR = H * (o.kid ? 0.15 : 0.12); + const t = o.phase; + const bob = o.still ? 0 : Math.abs(Math.cos(t)) * H * 0.04; + const lean = o.still ? 0.02 : 0.12; + const skin = '#f0d4b8'; + const pants = this._shade(o.color, -55); + + ctx.save(); + ctx.translate(x, y); + if (o.flip) ctx.scale(-1, 1); + + const hip = { x: 0, y: -legL - bob }; + const sh = { x: hip.x + Math.sin(lean) * torsoL, y: hip.y - Math.cos(lean) * torsoL }; + + const limb = (ox, oy, a1, l1, a2, l2, width, color) => { + const mx = ox + Math.sin(a1) * l1, my = oy + Math.cos(a1) * l1; + const ex = mx + Math.sin(a2) * l2, ey = my + Math.cos(a2) * l2; + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(ox, oy); ctx.lineTo(mx, my); ctx.lineTo(ex, ey); + ctx.stroke(); + return { x: ex, y: ey }; + }; + const leg = (ph, color) => { + const thighA = o.still ? 0 : 0.58 * Math.sin(ph); + const knee = o.still ? 0 : Math.max(0, Math.sin(ph + 0.8)) * 0.95; + const foot = limb(hip.x, hip.y, thighA, thigh, thighA - knee, shin, H * 0.10, color); + ctx.fillStyle = this._shade(color, -25); + ctx.beginPath(); + ctx.ellipse(foot.x + H * 0.035, foot.y - H * 0.015, H * 0.055, H * 0.03, 0, 0, 7); + ctx.fill(); + }; + const arm = (ph, color) => { + const armA = o.still ? 0.08 : -0.5 * Math.sin(ph); + const elbow = o.still ? 0.15 : 0.35 + 0.3 * Math.max(0, Math.sin(ph)); + return limb(sh.x, sh.y + H * 0.03, armA, H * 0.16, armA + elbow, H * 0.15, H * 0.075, color); + }; + + // hinteres Bein + hinterer Arm (dunkler) + leg(t + Math.PI, this._shade(pants, -22)); + arm(t + Math.PI, this._shade(o.color, -28)); + // Rucksack + if (o.pack) { + ctx.fillStyle = this._shade(o.color, -40); + this._rrect(hip.x - H * 0.24, sh.y + H * 0.02, H * 0.15, H * 0.24, H * 0.05); + } + // Oberkörper + Kopf + ctx.strokeStyle = o.color; + ctx.lineWidth = H * 0.17; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(hip.x, hip.y); + ctx.lineTo(sh.x, sh.y); + ctx.stroke(); + // Personal trägt eine Warnweste (deutlich als Arbeiter erkennbar) + if (o.worker) { + ctx.strokeStyle = '#f4c20d'; + ctx.lineWidth = H * 0.09; + ctx.beginPath(); + ctx.moveTo(hip.x + Math.sin(lean) * torsoL * 0.35, hip.y - Math.cos(lean) * torsoL * 0.35); + ctx.lineTo(sh.x, sh.y); + ctx.stroke(); + } + const nod = o.still ? 0 : Math.sin(t * 2) * H * 0.012; + const head = { x: sh.x + Math.sin(lean) * headR * 1.6, y: sh.y - headR * 1.15 + nod }; + ctx.fillStyle = skin; + ctx.beginPath(); ctx.arc(head.x, head.y, headR, 0, 7); ctx.fill(); + // Kopfbedeckung: Personal = weißer Bauhelm, sonst Mütze/Sonnenhut/Haar + ctx.fillStyle = o.worker ? '#f4f1e4' : (o.prop === 'hat' ? '#e8d9b0' : this._shade(o.color, 26)); + ctx.beginPath(); + ctx.arc(head.x, head.y - headR * 0.25, headR * (o.worker || o.prop === 'hat' ? 1.25 : 0.95), Math.PI, 0); + ctx.fill(); + + // vorderes Bein + vorderer Arm + leg(t, pants); + const hand = arm(t, o.color); + + // Requisiten in der vorderen Hand + ctx.lineCap = 'round'; + if (o.prop === 'pole') { + ctx.strokeStyle = '#6b4f2f'; + ctx.lineWidth = H * 0.04; + ctx.beginPath(); + ctx.moveTo(hand.x, hand.y - H * 0.3); + ctx.lineTo(hand.x + H * 0.08, hand.y + H * 0.28); + ctx.stroke(); + } else if (o.prop === 'umbrella') { + ctx.strokeStyle = '#5a5f63'; + ctx.lineWidth = H * 0.04; + ctx.beginPath(); + ctx.moveTo(hand.x, hand.y); + ctx.lineTo(hand.x + H * 0.05, sh.y - H * 0.42); + ctx.stroke(); + ctx.fillStyle = '#e8892b'; + ctx.beginPath(); + ctx.moveTo(hand.x - H * 0.16, sh.y - H * 0.40); + ctx.quadraticCurveTo(hand.x + H * 0.05, sh.y - H * 0.62, hand.x + H * 0.26, sh.y - H * 0.40); + ctx.closePath(); + ctx.fill(); + } else if (o.prop === 'bag') { + ctx.strokeStyle = this._shade(o.color, -35); + ctx.lineWidth = H * 0.03; + ctx.beginPath(); ctx.moveTo(hand.x, hand.y); ctx.lineTo(hand.x, hand.y + H * 0.09); ctx.stroke(); + ctx.fillStyle = '#8c2f4f'; + this._rrect(hand.x - H * 0.07, hand.y + H * 0.09, H * 0.14, H * 0.11, H * 0.03); + } + ctx.lineWidth = 1; + ctx.restore(); + } + + _drawGuests(winter) { + const ctx = this.ctx; + // Painter-Sortierung, damit sich Figuren korrekt überlappen + const sorted = [...this.guests].sort((a, b) => (a.x + a.y) - (b.x + b.y)); + for (const g of sorted) { + if (g.mode === 'ride') continue; // sitzt in der Bahn + const { x, y } = this.tileToWorld(g.x, g.y, this.elevAt(g.x, g.y)); + const skiing = g.mode === 'ski'; + const svx = (g.vx ?? 0) - (g.vy ?? 0); + const svy = ((g.vx ?? 0) + (g.vy ?? 0)) * 0.5; + const facing = svx >= 0 ? 1 : -1; + this._shadow(x, y + 1, 4.5, 2); + + if (skiing) { + // Ski zeigen in Fahrtrichtung, Körper lehnt in die Kurve + const ang = Math.atan2(svy, svx || 0.01); + ctx.save(); + ctx.translate(x, y); + ctx.rotate(ang); + ctx.strokeStyle = '#c9531f'; + ctx.lineWidth = 1.7; + ctx.beginPath(); + ctx.moveTo(-7, -1.6); ctx.lineTo(8, -1.6); + ctx.moveTo(-7, 1.6); ctx.lineTo(8, 1.6); + ctx.stroke(); + ctx.restore(); + ctx.strokeStyle = '#5a5f63'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x - 3 * facing, y - 8); ctx.lineTo(x - 7 * facing, y - 1); + ctx.moveTo(x + 2 * facing, y - 8); ctx.lineTo(x - 2 * facing, y - 1); + ctx.stroke(); + const lean = Math.sin(this.simTime / 250 + g.phase) * 2.5; + ctx.fillStyle = g.hue; + ctx.beginPath(); + ctx.ellipse(x + lean * 0.5, y - 7, 3.2, 4.6, lean * 0.09, 0, 7); + ctx.fill(); + ctx.fillStyle = '#f0d4b8'; + ctx.beginPath(); ctx.arc(x + lean * 0.7, y - 13, 2.7, 0, 7); ctx.fill(); + ctx.fillStyle = this._shade(g.hue, 20); + ctx.beginPath(); ctx.arc(x + lean * 0.7, y - 14.3, 2.3, Math.PI, 0); ctx.fill(); + continue; + } + + // Wanderer als Skelett-Walker (im Sommer mit Hut/Schirm/Tasche) + this._walker(x, y, { + h: g.kid ? 12.5 : 17, + color: g.hue, + phase: g.stridePhase || 0, + still: g.moving === false, + flip: facing < 0, + pack: g.pack, + prop: winter ? null : g.prop, + kid: g.kid, + }); + this._drawBubble(g, x, y - (g.kid ? 16 : 21)); + } + } + + // Sims-artige Gedankenblase mit verständlichem Symbol + _drawBubble(g, x, y) { + if (!g.bubble || this.simTime > g.bubble.until) return; + const ctx = this.ctx; + const lift = Math.sin(this.simTime / 300) * 1.2; + const by = y - 10 + lift; + ctx.fillStyle = 'rgba(255,255,255,0.95)'; + ctx.strokeStyle = 'rgba(47,62,70,0.35)'; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(x - 8, by - 8, 16, 15, 6); + else ctx.rect(x - 8, by - 8, 16, 15); + ctx.fill(); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x - 2, by + 6.5); + ctx.lineTo(x, by + 11); + ctx.lineTo(x + 3, by + 6.5); + ctx.closePath(); + ctx.fill(); + ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillStyle = '#2f3e46'; + ctx.fillText(g.bubble.icon, x, by + 3.5); + } + + // Sichtbare Saisonhöhepunkte: Skirennen auf der Piste, Sommerfest im Dorf + _drawFestivals(winter) { + const s = this.state; + const ctx = this.ctx; + + // Skirennen: Slalomtore auf der Piste + Zuschauer an der Talstation + if (s.race && winter) { + const lift = s.buildings.find(b => b.type === 'lift' || b.type === 'gondola'); + if (lift) { + const gates = s.pistes.filter(p => p.owner.c === lift.c && p.owner.r === lift.r); + gates.forEach((p, i) => { + const t = s.map[p.r][p.c]; + const { x, y } = this.tileToWorld(p.c + (i % 2 ? 0.22 : -0.22), p.r, t.elev); + this._flag(x, y, i % 2 ? '#c0392b' : '#2f6ea0'); + }); + const base = s.map[lift.r][lift.c]; + const b = this.tileToWorld(lift.c, lift.r + 0.8, base.elev); + ctx.strokeStyle = '#5a5f63'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(b.x - 22, b.y); ctx.lineTo(b.x - 22, b.y - 20); + ctx.moveTo(b.x + 22, b.y); ctx.lineTo(b.x + 22, b.y - 20); + ctx.stroke(); + ctx.lineWidth = 1; + ctx.fillStyle = '#e8892b'; + ctx.fillRect(b.x - 22, b.y - 20, 44, 8); + ctx.fillStyle = '#fff'; + ctx.font = 'bold 6px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('ZIEL', b.x, b.y - 14); + for (let i = 0; i < 8; i++) { + const px = b.x - 26 + i * 7.5; + const py = b.y + 8 + (i % 2) * 3; + ctx.fillStyle = ['#e8892b', '#2f6ea0', '#a34a8e', '#3f8f6b'][i % 4]; + ctx.beginPath(); ctx.ellipse(px, py - 4, 2.4, 4.2, 0, 0, 7); ctx.fill(); + ctx.fillStyle = '#f0d4b8'; + ctx.beginPath(); ctx.arc(px, py - 9.5, 2, 0, 7); ctx.fill(); + } + } + } + + // Sommerfest: großes Festzelt, Bühne, Girlanden, Menschenmenge, Musik + if (s.fest && !winter) { + const cx = CORE.c0 + 7.5, cr = CORE.r0 + 21.5; + const ct = this.state.map[Math.floor(cr)]?.[Math.round(cx)]; + const { x: fx, y: fy } = this.tileToWorld(cx, cr, ct ? ct.elev : 1); + + // Wimpelketten von einem Mast zu zwei Seiten + const mast = { x: fx, y: fy - 46 }; + ctx.strokeStyle = '#6b4f2f'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(fx, fy); ctx.lineTo(mast.x, mast.y); ctx.stroke(); + for (const dir of [-1, 1]) { + const end = { x: fx + dir * 62, y: fy - 6 }; + ctx.strokeStyle = '#6b4f2f'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(mast.x, mast.y); + ctx.quadraticCurveTo((mast.x + end.x) / 2, (mast.y + end.y) / 2 + 14, end.x, end.y); + ctx.stroke(); + for (let i = 1; i < 9; i++) { + const f = i / 9; + const gx = (1 - f) * (1 - f) * mast.x + 2 * (1 - f) * f * ((mast.x + end.x) / 2) + f * f * end.x; + const gy = (1 - f) * (1 - f) * mast.y + 2 * (1 - f) * f * ((mast.y + end.y) / 2 + 14) + f * f * end.y; + ctx.fillStyle = ['#e8892b', '#7ba05b', '#2f6ea0', '#c0392b'][i % 4]; + ctx.beginPath(); + ctx.moveTo(gx - 3, gy); ctx.lineTo(gx + 3, gy); ctx.lineTo(gx, gy + 6); + ctx.closePath(); ctx.fill(); + } + } + + // Großes Festzelt (Streifen-Dach) mittig + this._shadow(fx, fy + 4, 34, 13); + const tentW = 30, tentH = 20; + ctx.fillStyle = '#f4ede0'; + ctx.fillRect(fx - tentW, fy - tentH, tentW * 2, tentH); + // gestreiftes Spitzdach + for (let i = -3; i < 3; i++) { + ctx.fillStyle = i % 2 ? '#c0392b' : '#f4ede0'; + ctx.beginPath(); + ctx.moveTo(fx + i * (tentW / 3), fy - tentH); + ctx.lineTo(fx + (i + 1) * (tentW / 3), fy - tentH); + ctx.lineTo(fx, fy - tentH - 16); + ctx.closePath(); ctx.fill(); + } + // Wimpel auf der Spitze + ctx.fillStyle = '#e8892b'; + ctx.beginPath(); + ctx.moveTo(fx, fy - tentH - 16); ctx.lineTo(fx + 10, fy - tentH - 13); + ctx.lineTo(fx, fy - tentH - 10); ctx.closePath(); ctx.fill(); + // Zelteingang + ctx.fillStyle = '#8a5a2f'; + ctx.fillRect(fx - 5, fy - 12, 10, 12); + + // Kleine Bühne mit Musiker-Note daneben + ctx.fillStyle = '#8a6a42'; + ctx.fillRect(fx + tentW - 2, fy - 8, 18, 8); + const bob = Math.sin(this.simTime / 220) * 2; + ctx.fillStyle = '#2f3e46'; + ctx.font = '11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('🎵', fx + tentW + 7, fy - 12 + bob); + + // Menschenmenge davor (wuselnd) + for (let i = 0; i < 14; i++) { + const ang = i * 1.7; + const rx = fx + Math.cos(ang) * (14 + (i % 5) * 5); + const ry = fy + 12 + Math.sin(ang) * 7 + (i % 3) * 2; + const j = Math.sin(this.simTime / 180 + i) * 0.8; + ctx.fillStyle = ['#e8892b', '#2f6ea0', '#a34a8e', '#3f8f6b', '#c0392b'][i % 5]; + ctx.beginPath(); ctx.ellipse(rx, ry - 4 + j, 2, 3.4, 0, 0, 7); ctx.fill(); + ctx.fillStyle = '#f0d4b8'; + ctx.beginPath(); ctx.arc(rx, ry - 8 + j, 1.6, 0, 7); ctx.fill(); + } + } + } + // Auslastungs-Punkte ueber Gebaeuden: Personal (besetzt/frei) + Gaeste + _drawOccupancy() { + if (this.cam.scale < 1.1) return; + const s = this.state; + const ctx = this.ctx; + const levelMul = b => 1 + 0.5 * ((b.level || 1) - 1); + let avail = s.staffAvailable; + const guestActive = s.guestsToday > 0; + for (const b of s.buildings) { + const def = BUILDINGS[b.type]; + const need = Math.round((def.staff || 0) * levelMul(b) * seasonFactor(b.type, s.season)); + const tile = s.map[b.r][b.c]; + const { x, y } = this.tileToWorld(b.c, b.r, tile.elev); + let yy = y - 34; + if (need > 0) { + const filled = Math.max(0, Math.min(need, avail)); + avail -= filled; + const dw = 4.2, total = need * dw; + for (let i = 0; i < need; i++) { + ctx.fillStyle = i < filled ? '#2f6ea0' : '#c0392b'; + ctx.beginPath(); + ctx.arc(x - total / 2 + i * dw + dw / 2, yy, 1.7, 0, 7); + ctx.fill(); + } + yy -= 6; + } + const servesGuests = ['restaurant', 'hotel', 'lift', 'gondola', 'badesee', 'sled', 'trail'].includes(b.type); + const seasonOk = (b.type === 'badesee' || b.type === 'sled' || b.type === 'trail') ? s.season !== 'Winter' : true; + if (servesGuests && guestActive && seasonOk) { + const pulse = 0.5 + 0.5 * Math.sin(this.time / 350 + b.c); + ctx.fillStyle = 'rgba(63,143,94,' + (0.5 + 0.5 * pulse) + ')'; + ctx.beginPath(); ctx.arc(x, yy, 2, 0, 7); ctx.fill(); + } + } + } + + + // Personalmangel-Marker über unterbesetzten Betrieben (rotes „!") + _drawStaffAlerts() { + const s = this.state; + if (s.staffNeeded <= s.staffAvailable) return; + const ctx = this.ctx; + const pulse = 0.6 + 0.4 * Math.sin(this.time / 300); + for (const b of s.buildings) { + if ((BUILDINGS[b.type].staff || 0) === 0) continue; + const t = s.map[b.r][b.c]; + const { x, y } = this.tileToWorld(b.c, b.r, t.elev); + ctx.globalAlpha = pulse; + ctx.fillStyle = '#c0392b'; + ctx.beginPath(); ctx.arc(x, y - 30, 6, 0, 7); ctx.fill(); + ctx.fillStyle = '#fff'; + ctx.font = 'bold 9px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('!', x, y - 27); + ctx.globalAlpha = 1; + } + } + + // kleine Slalom-Fahne + _flag(x, y, color) { + const ctx = this.ctx; + ctx.strokeStyle = '#5a5f63'; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y - 14); ctx.stroke(); + ctx.lineWidth = 1; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(x, y - 14); ctx.lineTo(x + 8, y - 11.5); ctx.lineTo(x, y - 9); + ctx.closePath(); ctx.fill(); + } + + // Aufsteigende Texte (Tageseinnahmen, Gebäude-Einnahmen) + _drawFloats() { + const ctx = this.ctx; + const DUR = 1600; + this.floats = this.floats.filter(f => this.simTime - f.t0 < DUR); + for (const f of this.floats) { + const age = (this.simTime - f.t0) / DUR; + const tile = this.state.map[Math.floor(f.r)]?.[Math.floor(f.c)]; + const { x, y } = this.tileToWorld(f.c, f.r, tile ? tile.elev : 0); + ctx.globalAlpha = 1 - age; + ctx.font = f.small + ? 'bold 10px -apple-system, sans-serif' + : 'bold 13px -apple-system, sans-serif'; + ctx.textAlign = 'center'; + ctx.lineWidth = 3; + ctx.strokeStyle = 'rgba(253,251,244,0.9)'; + ctx.strokeText(f.text, x, y - 34 - age * 26); + ctx.fillStyle = f.color; + ctx.fillText(f.text, x, y - 34 - age * 26); + ctx.globalAlpha = 1; + ctx.lineWidth = 1; + } + } + + _shade(hex, amt) { + if (hex.startsWith('rgb')) return hex; + const n = parseInt(hex.slice(1), 16); + const r = Math.max(0, Math.min(255, (n >> 16) + amt)); + const g = Math.max(0, Math.min(255, ((n >> 8) & 255) + amt)); + const b = Math.max(0, Math.min(255, (n & 255) + amt)); + return `rgb(${r},${g},${b})`; + } +} diff --git a/App/sims/tourismustal/js/main.js b/App/sims/tourismustal/js/main.js new file mode 100644 index 0000000..5cd2dad --- /dev/null +++ b/App/sims/tourismustal/js/main.js @@ -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); +} diff --git a/App/sims/tourismustal/js/model.js b/App/sims/tourismustal/js/model.js new file mode 100644 index 0000000..b42e8d0 --- /dev/null +++ b/App/sims/tourismustal/js/model.js @@ -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; +} diff --git a/App/sims/tourismustal/js/player.js b/App/sims/tourismustal/js/player.js new file mode 100644 index 0000000..ceee3b8 --- /dev/null +++ b/App/sims/tourismustal/js/player.js @@ -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); + }, +}; diff --git a/App/sims/tourismustal/js/telemetry.js b/App/sims/tourismustal/js/telemetry.js new file mode 100644 index 0000000..c7f152c --- /dev/null +++ b/App/sims/tourismustal/js/telemetry.js @@ -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; + }, +}; diff --git a/App/sims/tourismustal/js/ui.js b/App/sims/tourismustal/js/ui.js new file mode 100644 index 0000000..90714f7 --- /dev/null +++ b/App/sims/tourismustal/js/ui.js @@ -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 = ` + ${emoji} + ${name}${desc} + ${cost}`; + 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 = '') => `${l}${r}`; + + let html = `
${d.date}` + + `${d.weather}${d.snow != null ? ` · ❄️ ${d.snow} cm${d.warmWinter ? ' (warm!)' : ''}` : ''}
`; + + // Nachfrage: Basiswert × Faktoren = wollten kommen + html += `
Wer wollte kommen?
`; + html += row(`Grund-Nachfrage (${d.season})`, d.demandBase); + for (const f of d.demandFactors) { + const bad = f.mult < 0.9; + html += `` + + ``; + } + html += row('= wollten kommen', `${d.demandResult}`, 'sum'); + html += `
× ${f.name}×${f.mult.toFixed(2)}
`; + + // Kapazität / Engpass + html += `
Wie viele konnten?
`; + html += row('Anreise möglich (Bus & Straße)', d.access); + html += row(d.capLabel, d.terrainCap); + html += row('→ Gäste gekommen', `${d.guests}${d.overnight ? ` (${d.overnight} ü.N.)` : ''}`, 'sum'); + html += `
`; + if (d.bottleneck) { + html += `
⚠️ Engpass: ${d.bottleneck} – ` + + `${d.bottleneckLost} Gäste kamen deshalb nicht. Hier lohnt sich Ausbau!
`; + } + + // Einnahmen / Ausgaben + html += `
Einnahmen
`; + for (const e of d.einnahmen) html += row(e.label, eur(e.amount)); + html += row('Summe', eur(d.income), 'sum'); + html += `
`; + html += `
Ausgaben
`; + for (const e of d.ausgaben) html += row(e.label, `− ${eur(e.amount)}`); + html += row('Summe', `− ${eur(d.expenses)}`, 'sum'); + html += `
`; + + html += `
` + + `Tagesgewinn${d.net >= 0 ? '+' : ''}${eur(d.net)}
`; + + for (const h of d.hinweise) html += `
💡 ${h}
`; + + $('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: ${report.dailyCost.toLocaleString('de-AT')} €/Tag ` + + `(${report.dailyUpkeep} € Instandhaltung + ${report.dailyWage} € Löhne)` + + (report.sparflamme ? '
💤 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 = '
⚠️ Das hakt gerade
' + + report.problems.map(p => `
${p}
`).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 = '
📚 Gut zu wissen
' + + report.knowledge.map(k => `
${k.term}: ${k.text}
`).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 + ? '' : '
  • Noch keine Meldungen.
  • '; + for (const c of [...this.collectedCards].reverse()) { + const li = document.createElement('li'); + li.innerHTML = `${c.icon} ${c.title}${c.text.split('\n')[0]}`; + 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); + } +} diff --git a/App/teacher.html b/App/teacher.html index f902aa6..6c589cd 100644 --- a/App/teacher.html +++ b/App/teacher.html @@ -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 ''+Number(v).toLocaleString('de-AT')+' €'; + }}, + {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 ''+v+'/'+s.goalsTotal+''; + }}, + {k:'buildings', label:'Geb.'}, + {k:'bottleneck', label:'Engpass', align:'left', fmt:function(v){ + return v ? '⚠️ '+v+'' : ''; + }}, + ], klima: [ {k:'level', label:'Stufe'}, {k:'tick', label:'Tick'},