diff --git a/App/Don_t_Deploy/2026-07-12-tourismusregion-integration.sql b/App/Don_t_Deploy/2026-07-12-tourismusregion-integration.sql new file mode 100644 index 0000000..eea1a9b --- /dev/null +++ b/App/Don_t_Deploy/2026-07-12-tourismusregion-integration.sql @@ -0,0 +1,75 @@ +-- ============================================================ +-- Tourismusregion: Komplett-Integration (module_info + Glossar + Lehrplan) +-- Stand: 2026-07-12 · Atlas · idempotent +-- Schwester-Sim von Tourismustal (strikt getrennt): wellenbasierte +-- Routen-Sim — Gäste ziehen über Wegkarten, Infrastruktur wirkt nur in +-- Reichweite. Kern-Didaktik: Besucherlenkung, Mobilität, Standort am Weg. +-- ============================================================ + +-- 1) Modul +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 ( + 'tourismusregion', + 'Tourismusregion', + 'Gäste lenken auf den Wegen deiner Region', + 'Gästegruppen ziehen über die Wege deiner Region – baue Wirtshäuser, Bahnen und Attraktionen in Reichweite, lenke Besucherströme und halte Umwelt und Budget im Lot.', + 'Acht Gästewellen ziehen über die Wegkarte deiner Region: Familien, Wandergruppen, Radfahrer:innen, Bus-Reisegruppen – jede mit eigenen Wünschen, eigenem Budget und eigenem Verkehrsmittel. Deine Gebäude wirken nur auf Gäste in ihrer Reichweite: Der Standort am Weg entscheidet. Zwischen den Wellen planst du in Baupausen, Ereignisse und A/B-Entscheidungen fordern dein Urteil. Fünf Wegkarten (Talschleife bis Passstraße), vier Saisonen und ein Endbericht mit Regionsprofil zeigen, ob du zufriedene Gäste, regionale Wertschöpfung, intakte Umwelt UND kluge Besucherlenkung geschafft hast.', + 'Besucherströme und Besucherlenkung verstehen · Standortwahl an Verkehrswegen begründen · Zielgruppen und ihre Mobilität unterscheiden (zu Fuß, Rad, Bus, Auto) · Wertschöpfung entlang einer Route erfassen · Zielkonflikte Umwelt/Verkehr/Wirtschaft abwägen (GW-Lehrplan 2023: 3.5–3.8, 3.17; übergreifend Verkehrs- und Mobilitätsbildung)', + 'Gäste wandern über die Wege. Baue Häuser an den Weg und lenke die Gäste gut.', + 'In deiner Region gibt es Wege. Auf den Wegen kommen Gäste: Familien, Wanderer, Rad-Fahrer, Bus-Gruppen. Baue Gast-Häuser und Attraktionen an die Wege. Nur Gäste in der Nähe nutzen sie. Nach 8 Gäste-Wellen siehst du: War deine Region gut?', + 'Du lernst: Wo baut man am besten? Wie lenkt man viele Besucher? Welche Gäste kommen wie an – zu Fuß, mit Rad oder Bus?', + 25, 12, 15, + 0xF09F97BAEFB88F, + 'card-tourismusregion.webp', + 'modul-tourismusregion', + 'tourismusregion', + 'beta', 95 +) +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); + +-- 2) Glossar: neuer Kernbegriff Besucherlenkung +INSERT INTO glossar (key_slug, title, short, text, short_easy, text_easy, category, sort_order, image_path) VALUES +('besucherlenkung', 'Besucherlenkung', + 'Besucherströme gezielt steuern – durch Wege, Angebote, Beschilderung und Mobilität – damit weder Natur noch Orte überlastet werden.', + 'Besucherlenkung heißt: Eine Region steuert, WO und WIE sich Gäste bewegen – über Wegenetze, Attraktions-Standorte, Beschilderung, Parkraum und öffentliche Anreise (Bus statt Auto). Gute Lenkung verteilt Ströme, schützt sensible Naturräume und entlastet Ortskerne; schlechte Lenkung erzeugt Staus, Trampelpfade und Übertourismus-Hotspots. Werkzeuge reichen von Bushaltestellen und attraktiven Alternativrouten bis zu Obergrenzen an Ausflugszielen.', + 'Man lenkt, wohin die Gäste gehen – damit es nirgends zu voll wird.', + 'Viele Gäste an einem Ort sind ein Problem. Besucher-Lenkung heißt: Man verteilt die Gäste. Zum Beispiel mit Wegen, Schildern und Bussen. So bleibt die Natur heil und kein Ort ist zu voll.', + 'gesellschaft', 215, + 'assets/img/glossar/besucherlenkung.webp') +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), image_path=VALUES(image_path); + +-- Modul-Zuordnung: neuer Begriff + passende bestehende Begriffe +INSERT INTO glossar_modules (glossar_id, module_id) +SELECT id, 'tourismusregion' FROM glossar WHERE key_slug IN ( + 'besucherlenkung', + 'standortfaktor','angebot-nachfrage','wertschoepfung','infrastruktur', + 'saisonalitaet','uebertourismus','raumnutzungskonflikt','diversifizierung', + 'sanfter-tourismus','tagesausgaben','tourismus','nachhaltigkeit' +) +ON DUPLICATE KEY UPDATE module_id=VALUES(module_id); + +-- 3) Lehrplan: Modul an bestehende Kompetenzen hängen +-- (Anker hängen an den Kompetenzen und gelten damit automatisch mit.) +INSERT INTO kompetenz_modules (kompetenz_id, module_id, level, strength) +SELECT id, 'tourismusregion', 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, 'tourismusregion', 0, 'sekundaer' FROM kompetenzen WHERE key_slug='verkehrserziehung-sicherheit' +ON DUPLICATE KEY UPDATE strength=VALUES(strength); +INSERT INTO kompetenz_modules (kompetenz_id, module_id, level, strength) +SELECT id, 'tourismusregion', 0, 'sekundaer' FROM kompetenzen WHERE key_slug='systemisches-denken' +ON DUPLICATE KEY UPDATE strength=VALUES(strength); diff --git a/App/assets/img/card-tourismusregion.webp b/App/assets/img/card-tourismusregion.webp new file mode 100644 index 0000000..5e4f71a Binary files /dev/null and b/App/assets/img/card-tourismusregion.webp differ diff --git a/App/assets/img/glossar/besucherlenkung.webp b/App/assets/img/glossar/besucherlenkung.webp new file mode 100644 index 0000000..ca90df2 Binary files /dev/null and b/App/assets/img/glossar/besucherlenkung.webp differ diff --git a/App/pages/modul-tourismusregion.php b/App/pages/modul-tourismusregion.php new file mode 100644 index 0000000..8147557 --- /dev/null +++ b/App/pages/modul-tourismusregion.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; +$audioInfo = true; // Vorlese-Modus: Lehrer kann ihn pro Klasse sperren + +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, audio_info FROM class_modules WHERE class_id = ? AND module_id = ?'); + $cm->execute([$classId, 'tourismusregion']); + $cmRow = $cm->fetch(PDO::FETCH_ASSOC); + $mode = $cmRow ? $cmRow['mode'] : 'locked'; + if ($cmRow !== false && array_key_exists('audio_info', (array)$cmRow)) { + $audioInfo = !empty($cmRow['audio_info']); + } + $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'], 'tourismusregion']); + $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 · Tourismusregion + + + +
+

🔒 Tourismusregion 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/tourismusregion/game.html'; +if (!is_file($gamePath)) { + http_response_code(503); + echo 'Tourismusregion ist in Vorbereitung.'; + exit; +} + +$html = file_get_contents($gamePath); + +$modeJs = json_encode($mode); +$easyJs = $easy ? 'true' : 'false'; +$sessionIdJs = json_encode($sessionId); + +$audioInfoJs = $audioInfo ? 'true' : 'false'; +$injection = + "window.TT_SESSION_MODE = {$modeJs};\n" . + "window.TT_SESSION_ID = {$sessionIdJs};\n" . + "window.STUDENT_EASY = {$easyJs};\n" . + "window.TT_FEATURES = { audioInfo: {$audioInfoJs} };\n" . + "window.TT_API_BASE = '" . BASE_PATH . "/php/api';"; + +if (strpos($html, '/*__TOURISMUSREGION_INJECTION__*/') !== false) { + $html = str_replace('/*__TOURISMUSREGION_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/tourismusregion/'; +$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, 'tourismusregion'); +echo $html; diff --git a/App/php/api/live.php b/App/php/api/live.php index 09b84ab..41f28f1 100644 --- a/App/php/api/live.php +++ b/App/php/api/live.php @@ -623,6 +623,8 @@ if ($method === 'GET') { return ($state['attemptsThisStep'] ?? 0) >= 6; case 'tourismustal': return ($state['money'] ?? 1) < 0 || ($state['lossStreak'] ?? 0) >= 8; + case 'tourismusregion': + return ($state['budget'] ?? 1) < -400 || ($state['env'] ?? 100) < 30; } return false; }; diff --git a/App/php/api/results.php b/App/php/api/results.php index 5553995..1ce3a97 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'], ], + 'tourismusregion' => [ + ['key'=>'results.score', 'label'=>'Score', 'agg'=>'max'], + ['key'=>'results.avgStars', 'label'=>'Ø Sterne', 'agg'=>'max'], + ['key'=>'results.revenue', 'label'=>'Wertschöpfung','agg'=>'max', 'unit'=>'€'], + ['key'=>'results.env', 'label'=>'Umwelt', 'agg'=>'max'], + ['key'=>'results.goalsDone','label'=>'Ziele', 'agg'=>'max'], + ], 'tourismustal' => [ ['key'=>'results.money', 'label'=>'Konto', 'agg'=>'max', 'unit'=>'€'], ['key'=>'results.maxGuests', 'label'=>'Gäste-Rekord','agg'=>'max'], diff --git a/App/php/lib/Benchmark.php b/App/php/lib/Benchmark.php index 8397b74..be82c02 100644 --- a/App/php/lib/Benchmark.php +++ b/App/php/lib/Benchmark.php @@ -53,6 +53,7 @@ class Benchmark 'fluggesellschaft' => self::scoreFluggesellschaft($results), 'kofferdetektiv' => self::scoreKofferdetektiv($results), 'tourismustal' => self::scoreTourismustal($results), + 'tourismusregion' => self::scoreTourismusregion($results), // Entscheidungstag: kein assessment-Submit-Pfad bekannt → null default => self::scoreFallbackStars($results), }; @@ -459,6 +460,22 @@ class Benchmark return (int)round(max(0, min(100, $score))); } + /** + * Tourismusregion — die Sim berechnet selbst einen gewichteten 0–100-Score + * (Bewertung 35 % + Wertschöpfung 25 % + Umwelt 20 % + Besucherlenkung 10 % + * + Finanzen 10 %, model.js finish()). Den übernehmen wir direkt. + */ + private static function scoreTourismusregion(array $r): ?int + { + $score = $r['score'] ?? null; + if (is_numeric($score)) return (int)round(max(0, min(100, $score))); + // Fallback: Zielerreichung + $done = $r['goalsDone'] ?? null; + $total = (int)($r['goalsTotal'] ?? 0); + if (!is_numeric($done) || $total <= 0) return null; + return (int)round(($done / $total) * 100); + } + /** * Generischer Fallback: Sterne aus 5-er-Skala. */ diff --git a/App/sims/tourismusregion/KONZEPT.md b/App/sims/tourismusregion/KONZEPT.md new file mode 100644 index 0000000..843e20a --- /dev/null +++ b/App/sims/tourismusregion/KONZEPT.md @@ -0,0 +1,100 @@ +# KONZEPT.md — „Tourismusregion entwickeln" + +Browserbasierte Lernsimulation für **Geographie und wirtschaftliche Bildung** +(Sekundarstufe I, Österreich). Eine Partie läuft **wellenbasiert** über +**8 Gästewellen** mit Baupausen dazwischen (kein fixer Zeittakt) und +überträgt die Logik eines Tower-Defense-Spiels friedlich auf die Entwicklung +einer alpinen Tourismusregion. Basis: `.humaninput/pflichtenheft.txt`, +Bau-Blaupause: `.humaninput/info.md` (Referenz: Tourismustal v1). + +## Spielidee in einem Satz + +Besuchergruppen ziehen sichtbar über Wege durch eine Iso-Landschaft; die +Spielerin baut Infrastruktur **in Reichweite der Wege**, erhöht damit die +Zufriedenheit passender Zielgruppen, erzeugt regionale Wertschöpfung — und +muss Umwelt, Verkehr und Budget im Gleichgewicht halten. + +## Tower-Defense-Übersetzung + +| Tower Defense | Simulation | +|---|---| +| Gegnerwellen | Besuchergruppen je Welle (8 Wellen, Baupause dazwischen) | +| Bauphase zwischen Wellen | Baupause: bauen & aufrüsten; „Nächste Welle rufen" startet früher, mit Bonus | +| Bauzeit der Türme | Baustellenzeit: Bau/Ausbau dauert (Fortschrittsbalken) — währenddessen kein Betrieb, keine Kosten | +| Türme | 18 Infrastrukturtypen mit je bis zu 5 Ausbaustufen | +| Turm-Synergien | Benachbarte passende Gebäude verstärken sich gegenseitig | +| Schaden | Zufriedenheitsgewinn (nur bei passender Zielgruppe/Saison/Lage) | +| Turm-Kapazität | Jedes Gebäude bedient nur N nächstgelegene Gäste, der Rest geht leer aus | +| Lebenspunkte | Zufriedenheitsbedarf der Gruppe | +| Verlangsamung | Aufenthaltsverlängerer (Café, Marktstand, Promenade) | +| Fluggegner | Spezialgruppen (Radfahrer schnell, Luxuspaar anspruchsvoll) | +| Neue Pfade | Skilift erschließt Berg-Rundweg mit neuen Bauplätzen | +| Geld | Gemeindebudget: Baukosten, Betriebskosten, Einnahmen | +| Basis-HP | Ruf-Leiste: Ø der letzten Abreisen — steuert die Besucherzahl der Folgewelle | + +## Didaktischer Kern (Lehrplan GWB 2023 als Mechanik) + +- **Raum & Orientierung:** Lage wirkt — Reichweiten, Wegenähe, Hang/See/Ortskern. +- **Wirtschaftliche Bildung:** Investition, Betriebskosten (laufend!), Amortisation, + Zielgruppen, Angebot/Nachfrage. +- **Tourismus & Saisonalität:** 4 Saisonen zu je 2 Wellen mit echten Faktoren + (Badesteg ×1.6 im Sommer, Skilift ×1.5 im Winter). +- **Nachhaltigkeit:** Umweltwert 0–100 (Start 75). Lift/Parkplatz kosten Umwelt, + Naturschutz/ÖV verbessern sie; Naturgruppen reagieren auf den Umweltfaktor. + Ein Naturschutzgebiet hebt die Umwelt, sperrt aber die umliegenden Bauplätze + — Zielkonflikt Wirtschaft ↔ Schutz. +- **Mobilität:** 3 Startpunkte (Straße West, Radweg Nord, Fußweg Süd), + Parkplatz vs. Bushaltestelle als bewusster Zielkonflikt. +- **Gesellschaft:** Verkehrsbelastung senkt Bewertungen empfindlicher Gruppen; + Social-Media-Feed macht Folgen der Entscheidungen sichtbar; ein + Statistik-Overlay mit **Heatmap der Besucherströme** zeigt, wo die Gäste + wirklich langgehen — und wo Angebote ins Leere laufen. + +## Wichtigste Designregel (Pflichtenheft §25) + +Nicht „Gäste melken": Einnahmen sind **Folge** gelungener Zufriedenheit +(Zufriedenheitsfaktor im Umsatz, Deckel = Gruppenbudget). Gewinnen heißt: +mind. Ø 3,5 Sterne, Wertschöpfung ≥ 2500, Umwelt ≥ 45, Schulden > −1000. + +## Ablauf einer Partie (fixe Wellenfolge, deterministischer Seed) + +Jede Welle läuft, bis alle ihre Gäste abgereist sind; dann folgt eine +**Baupause** (bauen & aufrüsten), danach startet die nächste Welle. „Nächste +Welle rufen" überspringt die Pause oder holt die nächste Welle früher (Bonus). + +| Welle | Saison | Inhalt | +|---|---|---| +| 1 | Frühling | Spaziergänger/Wanderer · Start: Wirtshaus, Café, Marktstand, Aussicht | +| 2 | Frühling | Ankündigung Familien · frei: Spielplatz, WC, Badesteg | +| 3 | Sommer | Familien + Radfahrer · frei: Eisdiele, Radstation, Tourismusinfo | +| 4 | Sommer | Radfernweg eröffnet (Radwelle) · frei: Naturschutz · Entscheidung: Investor | +| 5 | Herbst | Senioren + Busgruppen + Schulklassen · frei: Museum, Parkplatz, Bushaltestelle, Souvenirshop | +| 6 | Herbst | Ankündigung Winter · Dorffest-Entscheidung · frei: Skilift, Almhütte | +| 7 | Winter | Junge Gruppen + wohlhabende Paare · frei: Wellnesshaus | +| 8 | Winter | Endwelle, starke Ströme → Endauswertung mit Regionsprofil + Lernfeedback | + +Nach jeder Welle: Social-Media-Feed (echte Bewertungen der Abgereisten) und +Besucherfaktor `0.7 + Ø-Sterne/5` für die Folgewelle. + +## Endauswertung + +`Score = Bewertung×0.35 + Wertschöpfung×0.25 + Nachhaltigkeit×0.20 + +Besucherlenkung×0.10 + Finanzstabilität×0.10` → Regionsprofil (Nachhaltige +Modellregion, Massentourismus-, Familien-, Aktiv-, Kultur-, Krisenregion) + +rule-based formuliertes Lernfeedback und ergebnis­angepasste Reflexionsfragen (§24) für die Nachbesprechung. Ergebnisse landen im Lehrer-Dashboard (dashboard.html, Klassenmodus mit CSV-Export). Ergebnis speicherbar (localStorage, +optional PHP/MySQL via `api/save_score.php`). + +## Technik (gemäß info.md, bewusst NICHT Three.js) + +Vanilla-JS-ES-Module, Canvas 2D über **vorgerenderten 3D-Diorama-Wegkarten** +(gpt-image-1; 5 Karten mit handvermessenen Routen-Polylinien und festen +Bauplatz-Slots samt Slot-Sprite — klassisches Tower-Defense-Kartendesign), +KI-Sprites für Gebäude (Deluxe-Variante ab Stufe 4), Baustellen, Busse und +Besuchergruppen, prozedurale skelettale Walker-Figuren, WebAudio (Saison- +Ambience + Interaktionssounds je Dienstleistung: Biss/Schluck/Werkzeug/Kasse/ +Platsch) + 15-Titel-Musik-Player, Telemetrie-Stub, deterministischer Zufall +(mulberry32), Touch-first (Pan/Pinch/Tap, Buttons ≥ 44 px), Pause bei jeder +Karte, Barrierearmut (Texte + Icons, hohe Kontraste, 🐢 reduzierte +Animationen). Ein separater **Karten-Editor** (`editor.html`) legt Pfad-Kurven +und Bauslots über die Artworks und speichert sie via `api/save_map.php` nach +`js/maps-custom.json`. Zielgeräte: ältere iPads (Canvas 2D statt WebGL) und PCs. diff --git a/App/sims/tourismusregion/LEHRPLAN.md b/App/sims/tourismusregion/LEHRPLAN.md new file mode 100644 index 0000000..2f6d167 --- /dev/null +++ b/App/sims/tourismusregion/LEHRPLAN.md @@ -0,0 +1,32 @@ +# Tourismusregion — Lehrplan-Verankerung + +Stand: 2026-07-12 · Schulstufe: **Sek I, Kern 3. Klasse / 7. Schulstufe** (MS + AHS-U). +Schwester-Modul von Tourismustal — gleiche Kompetenz-Verankerung +(`tourismus-raumentwicklung`, Anker D-A-CH siehe +[Tourismustal/LEHRPLAN.md](../tourismustal/LEHRPLAN.md) und QUELLEN.md dort), +aber **anderer didaktischer Schwerpunkt: Besucherlenkung & Mobilität.** + +## Was DIESES Modul anders lehrt als Tourismustal + +| Mechanik | Lehrplan-Bezug | +|---|---| +| Gäste ziehen als Gruppen über feste **Wege**; Gebäude wirken nur in **Reichweite** | AT 3.5/3.17: Standortentscheidung radikal verräumlicht — Lage am Weg ist ALLES | +| **9 Zielgruppen** mit eigenem Verkehrsmittel (zu Fuß/Rad/Bus/Auto), Budget, Wünschen | Übergreifendes Thema Nr. 12 **Verkehrs- und Mobilitätsbildung**; Zielgruppen-Marketing | +| **Lenkungs-Score**: Anteil Nicht-Auto-Anreise (+Bonus für Bushaltestelle) fließt zu 10 % in die Endnote | Besucherlenkung, nachhaltige Mobilität (CH RZG.2.5 nachhaltiger Tourismus) | +| Wellen + **Baupausen**, „nächste Welle früher rufen" gegen Bonus | Planungs- und Entscheidungskompetenz unter Zeitdruck (Kompetenz `systemisches-denken`) | +| **Umweltwert** sinkt durch Bau/Verkehr, Endprofil (z. B. „Krisenregion") | AT 3.17 Nutzungskonflikte; 4.10 nachhaltiges Wirtschaften | +| A/B-Entscheidungen (Investor Welle 4, Dorffest Welle 6) | Urteils-/Handlungskompetenz; Entrepreneurship (Thema 2) | +| **5 Wegkarten** (Talschleife → Passstraße) als wählbare Raumtypen | Raumtypen vergleichen; CH RZG.3.1 Nutzung natürlicher Systeme | +| Endbericht: Score aus Bewertung 35 % · Wertschöpfung 25 % · Umwelt 20 % · **Lenkung 10 %** · Finanzen 10 % + regelbasiertes Lernfeedback + Reflexionsfragen | AT 3.13 Kenngrößen; Reflexion (Plattform-Reflexionssystem) | + +## Kompetenz-Zuordnung (DB) + +- **primär:** `tourismus-raumentwicklung` (erbt alle 14 D-A-CH-Anker) +- **sekundär:** `verkehrserziehung-sicherheit`, `systemisches-denken` + +## Glossar-Schwerpunkt + +Neu für dieses Modul: **Besucherlenkung**. Dazu zugeordnet: Standortfaktor, +Angebot & Nachfrage, Wertschöpfung, Infrastruktur, Saisonalität, Übertourismus, +Raumnutzungskonflikt, Diversifizierung, Sanfter Tourismus, Tagesausgaben, +Tourismus, Nachhaltigkeit. diff --git a/App/sims/tourismusregion/assets/maps/kreuzung.png b/App/sims/tourismusregion/assets/maps/kreuzung.png new file mode 100644 index 0000000..cd5fb36 Binary files /dev/null and b/App/sims/tourismusregion/assets/maps/kreuzung.png differ diff --git a/App/sims/tourismusregion/assets/maps/passstrasse.png b/App/sims/tourismusregion/assets/maps/passstrasse.png new file mode 100644 index 0000000..50b3627 Binary files /dev/null and b/App/sims/tourismusregion/assets/maps/passstrasse.png differ diff --git a/App/sims/tourismusregion/assets/maps/seerunde.png b/App/sims/tourismusregion/assets/maps/seerunde.png new file mode 100644 index 0000000..312ab84 Binary files /dev/null and b/App/sims/tourismusregion/assets/maps/seerunde.png differ diff --git a/App/sims/tourismusregion/assets/maps/serpentinen.png b/App/sims/tourismusregion/assets/maps/serpentinen.png new file mode 100644 index 0000000..efa1148 Binary files /dev/null and b/App/sims/tourismusregion/assets/maps/serpentinen.png differ diff --git a/App/sims/tourismusregion/assets/maps/talschleife.png b/App/sims/tourismusregion/assets/maps/talschleife.png new file mode 100644 index 0000000..6a9c22d Binary files /dev/null and b/App/sims/tourismusregion/assets/maps/talschleife.png differ diff --git a/App/sims/tourismusregion/assets/sounds/ambience_summer.mp3 b/App/sims/tourismusregion/assets/sounds/ambience_summer.mp3 new file mode 100644 index 0000000..85f4cd2 Binary files /dev/null and b/App/sims/tourismusregion/assets/sounds/ambience_summer.mp3 differ diff --git a/App/sims/tourismusregion/assets/sounds/ambience_winter.mp3 b/App/sims/tourismusregion/assets/sounds/ambience_winter.mp3 new file mode 100644 index 0000000..9fdd678 Binary files /dev/null and b/App/sims/tourismusregion/assets/sounds/ambience_winter.mp3 differ diff --git a/App/sims/tourismusregion/assets/sounds/bite.mp3 b/App/sims/tourismusregion/assets/sounds/bite.mp3 new file mode 100644 index 0000000..5b594c2 Binary files /dev/null and b/App/sims/tourismusregion/assets/sounds/bite.mp3 differ diff --git a/App/sims/tourismusregion/assets/sounds/build.mp3 b/App/sims/tourismusregion/assets/sounds/build.mp3 new file mode 100644 index 0000000..ca6571f Binary files /dev/null and b/App/sims/tourismusregion/assets/sounds/build.mp3 differ diff --git a/App/sims/tourismusregion/assets/sounds/card.mp3 b/App/sims/tourismusregion/assets/sounds/card.mp3 new file mode 100644 index 0000000..2a61d31 Binary files /dev/null and b/App/sims/tourismusregion/assets/sounds/card.mp3 differ diff --git a/App/sims/tourismusregion/assets/sounds/cash.mp3 b/App/sims/tourismusregion/assets/sounds/cash.mp3 new file mode 100644 index 0000000..acf75a9 Binary files /dev/null and b/App/sims/tourismusregion/assets/sounds/cash.mp3 differ diff --git a/App/sims/tourismusregion/assets/sounds/click.mp3 b/App/sims/tourismusregion/assets/sounds/click.mp3 new file mode 100644 index 0000000..6239327 Binary files /dev/null and b/App/sims/tourismusregion/assets/sounds/click.mp3 differ diff --git a/App/sims/tourismusregion/assets/sounds/demolish.mp3 b/App/sims/tourismusregion/assets/sounds/demolish.mp3 new file mode 100644 index 0000000..ba9867c Binary files /dev/null and b/App/sims/tourismusregion/assets/sounds/demolish.mp3 differ diff --git a/App/sims/tourismusregion/assets/sounds/error.mp3 b/App/sims/tourismusregion/assets/sounds/error.mp3 new file mode 100644 index 0000000..3ec81e1 Binary files /dev/null and b/App/sims/tourismusregion/assets/sounds/error.mp3 differ diff --git a/App/sims/tourismusregion/assets/sounds/milestone.mp3 b/App/sims/tourismusregion/assets/sounds/milestone.mp3 new file mode 100644 index 0000000..801679a Binary files /dev/null and b/App/sims/tourismusregion/assets/sounds/milestone.mp3 differ diff --git a/App/sims/tourismusregion/assets/sounds/sip.mp3 b/App/sims/tourismusregion/assets/sounds/sip.mp3 new file mode 100644 index 0000000..569ab4e Binary files /dev/null and b/App/sims/tourismusregion/assets/sounds/sip.mp3 differ diff --git a/App/sims/tourismusregion/assets/sounds/splash.mp3 b/App/sims/tourismusregion/assets/sounds/splash.mp3 new file mode 100644 index 0000000..9bfb6e9 Binary files /dev/null and b/App/sims/tourismusregion/assets/sounds/splash.mp3 differ diff --git a/App/sims/tourismusregion/assets/sounds/success.mp3 b/App/sims/tourismusregion/assets/sounds/success.mp3 new file mode 100644 index 0000000..804e848 Binary files /dev/null and b/App/sims/tourismusregion/assets/sounds/success.mp3 differ diff --git a/App/sims/tourismusregion/assets/sounds/tool.mp3 b/App/sims/tourismusregion/assets/sounds/tool.mp3 new file mode 100644 index 0000000..44a60c0 Binary files /dev/null and b/App/sims/tourismusregion/assets/sounds/tool.mp3 differ diff --git a/App/sims/tourismusregion/assets/sounds/win.mp3 b/App/sims/tourismusregion/assets/sounds/win.mp3 new file mode 100644 index 0000000..e88e18f Binary files /dev/null and b/App/sims/tourismusregion/assets/sounds/win.mp3 differ diff --git a/App/sims/tourismusregion/assets/sprites/almhuette.png b/App/sims/tourismusregion/assets/sprites/almhuette.png new file mode 100644 index 0000000..5e05379 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/almhuette.png differ diff --git a/App/sims/tourismusregion/assets/sprites/almhuette_2.png b/App/sims/tourismusregion/assets/sprites/almhuette_2.png new file mode 100644 index 0000000..f7c19b0 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/almhuette_2.png differ diff --git a/App/sims/tourismusregion/assets/sprites/aussicht.png b/App/sims/tourismusregion/assets/sprites/aussicht.png new file mode 100644 index 0000000..ebb2b17 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/aussicht.png differ diff --git a/App/sims/tourismusregion/assets/sprites/badesteg.png b/App/sims/tourismusregion/assets/sprites/badesteg.png new file mode 100644 index 0000000..f209964 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/badesteg.png differ diff --git a/App/sims/tourismusregion/assets/sprites/badesteg_2.png b/App/sims/tourismusregion/assets/sprites/badesteg_2.png new file mode 100644 index 0000000..52ef745 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/badesteg_2.png differ diff --git a/App/sims/tourismusregion/assets/sprites/baustelle.png b/App/sims/tourismusregion/assets/sprites/baustelle.png new file mode 100644 index 0000000..93724be Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/baustelle.png differ diff --git a/App/sims/tourismusregion/assets/sprites/broadleaf.png b/App/sims/tourismusregion/assets/sprites/broadleaf.png new file mode 100644 index 0000000..89107aa Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/broadleaf.png differ diff --git a/App/sims/tourismusregion/assets/sprites/bus.png b/App/sims/tourismusregion/assets/sprites/bus.png new file mode 100644 index 0000000..dd4ecf3 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/bus.png differ diff --git a/App/sims/tourismusregion/assets/sprites/bushaltestelle.png b/App/sims/tourismusregion/assets/sprites/bushaltestelle.png new file mode 100644 index 0000000..382ed99 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/bushaltestelle.png differ diff --git a/App/sims/tourismusregion/assets/sprites/cafe.png b/App/sims/tourismusregion/assets/sprites/cafe.png new file mode 100644 index 0000000..2f5767f Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/cafe.png differ diff --git a/App/sims/tourismusregion/assets/sprites/cafe_2.png b/App/sims/tourismusregion/assets/sprites/cafe_2.png new file mode 100644 index 0000000..5dcd9e1 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/cafe_2.png differ diff --git a/App/sims/tourismusregion/assets/sprites/church.png b/App/sims/tourismusregion/assets/sprites/church.png new file mode 100644 index 0000000..29627ea Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/church.png differ diff --git a/App/sims/tourismusregion/assets/sprites/eisdiele.png b/App/sims/tourismusregion/assets/sprites/eisdiele.png new file mode 100644 index 0000000..a5434a0 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/eisdiele.png differ diff --git a/App/sims/tourismusregion/assets/sprites/eisdiele_2.png b/App/sims/tourismusregion/assets/sprites/eisdiele_2.png new file mode 100644 index 0000000..88084fe Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/eisdiele_2.png differ diff --git a/App/sims/tourismusregion/assets/sprites/fir.png b/App/sims/tourismusregion/assets/sprites/fir.png new file mode 100644 index 0000000..e1b5516 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/fir.png differ diff --git a/App/sims/tourismusregion/assets/sprites/fir_snow.png b/App/sims/tourismusregion/assets/sprites/fir_snow.png new file mode 100644 index 0000000..9da3c11 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/fir_snow.png differ diff --git a/App/sims/tourismusregion/assets/sprites/groups/busgruppe.png b/App/sims/tourismusregion/assets/sprites/groups/busgruppe.png new file mode 100644 index 0000000..5199ca1 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/groups/busgruppe.png differ diff --git a/App/sims/tourismusregion/assets/sprites/groups/familie.png b/App/sims/tourismusregion/assets/sprites/groups/familie.png new file mode 100644 index 0000000..9b10d01 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/groups/familie.png differ diff --git a/App/sims/tourismusregion/assets/sprites/groups/junge_gruppe.png b/App/sims/tourismusregion/assets/sprites/groups/junge_gruppe.png new file mode 100644 index 0000000..ea489a1 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/groups/junge_gruppe.png differ diff --git a/App/sims/tourismusregion/assets/sprites/groups/luxuspaar.png b/App/sims/tourismusregion/assets/sprites/groups/luxuspaar.png new file mode 100644 index 0000000..9f71f4b Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/groups/luxuspaar.png differ diff --git a/App/sims/tourismusregion/assets/sprites/groups/schulklasse.png b/App/sims/tourismusregion/assets/sprites/groups/schulklasse.png new file mode 100644 index 0000000..3d0f7e9 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/groups/schulklasse.png differ diff --git a/App/sims/tourismusregion/assets/sprites/groups/senioren.png b/App/sims/tourismusregion/assets/sprites/groups/senioren.png new file mode 100644 index 0000000..55ea10e Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/groups/senioren.png differ diff --git a/App/sims/tourismusregion/assets/sprites/groups/spaziergaenger.png b/App/sims/tourismusregion/assets/sprites/groups/spaziergaenger.png new file mode 100644 index 0000000..9fcd5bb Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/groups/spaziergaenger.png differ diff --git a/App/sims/tourismusregion/assets/sprites/groups/wanderer.png b/App/sims/tourismusregion/assets/sprites/groups/wanderer.png new file mode 100644 index 0000000..59e9a56 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/groups/wanderer.png differ diff --git a/App/sims/tourismusregion/assets/sprites/house.png b/App/sims/tourismusregion/assets/sprites/house.png new file mode 100644 index 0000000..9351ff0 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/house.png differ diff --git a/App/sims/tourismusregion/assets/sprites/marktstand.png b/App/sims/tourismusregion/assets/sprites/marktstand.png new file mode 100644 index 0000000..318657c Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/marktstand.png differ diff --git a/App/sims/tourismusregion/assets/sprites/museum.png b/App/sims/tourismusregion/assets/sprites/museum.png new file mode 100644 index 0000000..12b9d83 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/museum.png differ diff --git a/App/sims/tourismusregion/assets/sprites/museum_2.png b/App/sims/tourismusregion/assets/sprites/museum_2.png new file mode 100644 index 0000000..088e896 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/museum_2.png differ diff --git a/App/sims/tourismusregion/assets/sprites/naturschutz.png b/App/sims/tourismusregion/assets/sprites/naturschutz.png new file mode 100644 index 0000000..c5e76a9 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/naturschutz.png differ diff --git a/App/sims/tourismusregion/assets/sprites/parkplatz.png b/App/sims/tourismusregion/assets/sprites/parkplatz.png new file mode 100644 index 0000000..a6c810c Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/parkplatz.png differ diff --git a/App/sims/tourismusregion/assets/sprites/radler.png b/App/sims/tourismusregion/assets/sprites/radler.png new file mode 100644 index 0000000..8a17d8c Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/radler.png differ diff --git a/App/sims/tourismusregion/assets/sprites/radstation.png b/App/sims/tourismusregion/assets/sprites/radstation.png new file mode 100644 index 0000000..b053487 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/radstation.png differ diff --git a/App/sims/tourismusregion/assets/sprites/slot.png b/App/sims/tourismusregion/assets/sprites/slot.png new file mode 100644 index 0000000..6390209 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/slot.png differ diff --git a/App/sims/tourismusregion/assets/sprites/souvenir.png b/App/sims/tourismusregion/assets/sprites/souvenir.png new file mode 100644 index 0000000..f250bce Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/souvenir.png differ diff --git a/App/sims/tourismusregion/assets/sprites/spielplatz.png b/App/sims/tourismusregion/assets/sprites/spielplatz.png new file mode 100644 index 0000000..91af4d1 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/spielplatz.png differ diff --git a/App/sims/tourismusregion/assets/sprites/spielplatz_2.png b/App/sims/tourismusregion/assets/sprites/spielplatz_2.png new file mode 100644 index 0000000..6881450 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/spielplatz_2.png differ diff --git a/App/sims/tourismusregion/assets/sprites/tourismusinfo.png b/App/sims/tourismusregion/assets/sprites/tourismusinfo.png new file mode 100644 index 0000000..a393c69 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/tourismusinfo.png differ diff --git a/App/sims/tourismusregion/assets/sprites/wc.png b/App/sims/tourismusregion/assets/sprites/wc.png new file mode 100644 index 0000000..921106c Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/wc.png differ diff --git a/App/sims/tourismusregion/assets/sprites/wellness.png b/App/sims/tourismusregion/assets/sprites/wellness.png new file mode 100644 index 0000000..e0c8508 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/wellness.png differ diff --git a/App/sims/tourismusregion/assets/sprites/wellness_2.png b/App/sims/tourismusregion/assets/sprites/wellness_2.png new file mode 100644 index 0000000..814e3c1 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/wellness_2.png differ diff --git a/App/sims/tourismusregion/assets/sprites/wirtshaus.png b/App/sims/tourismusregion/assets/sprites/wirtshaus.png new file mode 100644 index 0000000..b84cf83 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/wirtshaus.png differ diff --git a/App/sims/tourismusregion/assets/sprites/wirtshaus_2.png b/App/sims/tourismusregion/assets/sprites/wirtshaus_2.png new file mode 100644 index 0000000..e23c635 Binary files /dev/null and b/App/sims/tourismusregion/assets/sprites/wirtshaus_2.png differ diff --git a/App/sims/tourismusregion/css/style.css b/App/sims/tourismusregion/css/style.css new file mode 100644 index 0000000..814b46a --- /dev/null +++ b/App/sims/tourismusregion/css/style.css @@ -0,0 +1,425 @@ +: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; } +#topbar h1 .subtitle { font-weight: 400; color: var(--muted); font-size: 15px; } +.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; font-variant-numeric: tabular-nums; } +.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.small { min-width: 34px; height: 34px; font-size: 14px; } +.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: 225px; + 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; +} +.kpi { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 2px; + border-bottom: 1px solid rgba(47, 62, 70, 0.08); + font-size: 14.5px; +} +.kpi b { font-variant-numeric: tabular-nums; } +.kpi b.warn { color: #c0392b; } + +#goals h3 { + margin: 10px 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: '✓ '; } + +/* Build menu (right) */ +#buildmenu { + top: 84px; + right: 10px; + width: 258px; + 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: 9px; + border-radius: 12px; + background: #fff; + margin-bottom: 8px; + border: 2px solid transparent; + cursor: pointer; + min-height: 54px; +} +.build-item.active { border-color: var(--orange); background: #fff7ee; } +.build-item.disabled { opacity: 0.45; } +.build-item .emoji { font-size: 24px; } +.build-item .info { flex: 1; } +.build-item .info b { display: block; font-size: 13.5px; } +.build-item .info small { color: var(--muted); font-size: 11px; line-height: 1.25; display: block; } +.build-item .cost { font-size: 12.5px; font-weight: 700; color: var(--green); white-space: nowrap; } +.locked-hint { font-size: 12px; color: var(--muted); text-align: center; padding: 6px; } + +/* Info panel (Gebäude / Besucher) */ +#infoPanel { + left: 10px; + bottom: 10px; + width: 268px; + padding: 12px 14px; + z-index: 20; +} +.info-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} +.info-head b { font-size: 15px; } +.info-row { + display: flex; + justify-content: space-between; + font-size: 13px; + padding: 3.5px 0; + border-bottom: 1px solid rgba(47, 62, 70, 0.07); +} +.info-row span { color: var(--muted); } +button.slim { padding: 9px 12px; font-size: 14px; margin-top: 8px; width: 100%; } + +/* Zwei Verlaufs-Graphen (unten, nebeneinander in eigenen Feldern) */ +.graphbox { + bottom: 10px; + width: min(330px, 26vw); + height: 150px; + padding: 8px 10px 8px; +} +#graphMoney { left: calc(50% - min(330px, 26vw) - 8px); } +#graphSat { left: calc(50% + 8px); } +.graphbox canvas { width: 100%; height: 100%; } + +/* Toast */ +#toast { + bottom: 165px; + left: 50%; + transform: translateX(-50%); + padding: 12px 20px; + font-size: 15px; + max-width: 80vw; + text-align: center; + z-index: 40; +} + +/* Overlays & Karten */ +.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(460px, 90vw); + max-height: 84vh; + 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; } +.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; } + +/* Social-Media-Feed in Karten */ +#cardList { list-style: none; margin: 10px 0 0; padding: 0; text-align: left; } +#cardList li { + background: #fff; + border-radius: 12px; + padding: 9px 12px; + margin-bottom: 8px; + border: 1.5px solid var(--beige-dark); + font-size: 14px; + line-height: 1.4; +} +.feed-stars { display: block; font-size: 12px; letter-spacing: 1px; } +.feed-text { color: var(--ink); } + +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.primary:disabled { opacity: 0.45; cursor: default; } +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; + cursor: pointer; +} +#logList li small { color: var(--muted); display: block; } + +/* Start */ +.intro { text-align: center !important; color: var(--muted); } +.start-hint { font-size: 12.5px; color: var(--muted); text-align: center !important; } +#startOverlay input, #playerName, #classCode { + 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; +} +#startOverlay input:focus { outline: none; border-color: var(--green); } +#btnStart { width: 100%; margin-top: 14px; } + +/* Endauswertung */ +.end-card { width: min(540px, 92vw) !important; } +.end-stars { font-size: 17px; font-weight: 700; margin: 8px 0 14px; } +.end-bar-row { + display: grid; + grid-template-columns: 150px 1fr 34px; + align-items: center; + gap: 10px; + font-size: 13px; + margin-bottom: 6px; + text-align: left; +} +.end-bar { background: var(--beige-dark); border-radius: 999px; height: 12px; overflow: hidden; } +.end-bar div { height: 100%; border-radius: 999px; } +.end-bar-row b { text-align: right; font-variant-numeric: tabular-nums; } +.end-goals { list-style: none; padding: 0; margin: 12px 0 4px; text-align: left; font-size: 13.5px; } +.end-goals li { color: #c0392b; padding: 2px 0; } +.end-goals li.done { color: var(--green); } +.end-facts { font-size: 12.5px; color: var(--muted); text-align: center !important; } +.end-feedback { + background: #fff; + border-radius: 12px; + border: 1.5px solid var(--beige-dark); + padding: 4px 14px; + margin-top: 8px; +} +.end-feedback p { font-size: 13.5px; line-height: 1.45; } + +/* Kleinere Screens (iPad Hochformat) */ +@media (max-width: 900px) { + #topbar h1 .subtitle { display: none; } + .clock { font-size: 12px; } + #kpis { width: 195px; } + #buildmenu { width: 228px; } + .graphbox { width: 240px; height: 120px; } + #graphMoney { left: calc(50% - 248px); } + #infoPanel { width: 240px; } +} + +/* Musik-Player (rechts oben, links neben dem Baumenü) */ +#musicPanel { + top: 84px; + right: 278px; + width: 236px; + padding: 10px 12px; + z-index: 15; +} +.music-row { display: flex; align-items: center; gap: 6px; } +#muVol { flex: 1; accent-color: var(--orange); height: 28px; min-width: 60px; } +#muSelect { + width: 100%; + margin-top: 8px; + padding: 8px 10px; + border: 2px solid var(--beige-dark); + border-radius: 10px; + font-size: 13px; + font-family: inherit; + color: var(--ink); + background: #fff; +} +@media (max-width: 900px) { + #musicPanel { right: 248px; width: 200px; } +} + +/* Kartenwahl im Start-Overlay */ +.map-title { margin: 14px 0 6px; font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; text-align: left; } +#mapList { display: flex; flex-direction: column; gap: 8px; max-height: 34vh; overflow-y: auto; } +.map-btn { + display: flex; gap: 10px; align-items: center; text-align: left; + border: 2px solid var(--beige-dark); background: #fff; border-radius: 14px; + padding: 8px 10px; cursor: pointer; font-family: inherit; min-height: 56px; +} +.map-btn img { width: 84px; height: 56px; object-fit: cover; border-radius: 8px; } +.map-btn b { display: block; font-size: 14px; color: var(--ink); } +.map-btn small { color: var(--muted); font-size: 11.5px; line-height: 1.25; display: block; } +.map-btn.active { border-color: var(--orange); background: #fff7ee; } + +/* Ruf-Leiste (Reputation, TD-„Basis-HP") */ +#repRow { margin: 4px 0 8px; } +.rep-head { display: flex; justify-content: space-between; font-size: 13px; font-weight: 600; margin-bottom: 3px; } +.rep-bar { height: 9px; border-radius: 999px; background: var(--beige-dark); overflow: hidden; } +#repFill { height: 100%; width: 60%; border-radius: 999px; background: var(--green); + transition: width .4s ease, background .4s ease; } +#repFill.mid { background: var(--orange); } +#repFill.low { background: #c0392b; } +#repRow.pulse { animation: repPulse .5s ease; } +@keyframes repPulse { 0% { transform: scale(1); } 40% { transform: scale(1.04); } 100% { transform: scale(1); } } + +/* Wellen-Status-Banner in der Topbar */ +.wave-status { + margin-left: 14px; padding: 5px 14px; border-radius: 999px; + background: var(--beige-dark); font-size: 13px; font-weight: 700; color: var(--ink); + white-space: nowrap; +} +.wave-status.running { background: #fbe3cd; color: #b4560f; } +.wave-status.pause { background: #dce9dd; color: #326046; } +@media (max-width: 900px) { .wave-status { display: none; } } + +/* Statistik-Overlay */ +#statsBody { text-align: left; } +.stat-group { margin-bottom: 10px; } +.stat-group h3 { font-size: 13px; margin: 6px 0 4px; color: var(--muted); + text-transform: uppercase; letter-spacing: .04em; } +.stat-row { display: grid; grid-template-columns: 130px 1fr 44px; align-items: center; + gap: 8px; font-size: 13px; margin-bottom: 4px; } +.stat-bar { background: var(--beige-dark); border-radius: 999px; height: 11px; overflow: hidden; } +.stat-bar div { height: 100%; border-radius: 999px; } +.stat-row b { text-align: right; font-variant-numeric: tabular-nums; } + +/* Synergie-Anzeige im Info-Panel */ +.info-row b.syn { color: #7b3fa0; } +.syn-why { font-size: 11px; color: #7b3fa0; padding: 1px 0 1px 4px; } + +.heat-canvas { display: block; border-radius: 10px; margin: 2px auto 4px; max-width: 100%; } + +/* Reflexionsfragen in der Endauswertung */ +.end-reflect { text-align:left; margin-top:10px; background:#f4f0e4; border-radius:12px; + border:1.5px solid var(--beige-dark); padding:6px 14px 10px; } +.end-reflect h3 { font-size:14px; margin:8px 0 4px; } +.end-reflect ul { margin:0 0 4px; padding-left:20px; } +.end-reflect li { font-size:13.5px; line-height:1.5; margin-bottom:5px; color:var(--ink); } + +.tools-links { text-align:center !important; font-size:12.5px; margin-top:8px; } +.tools-links a { color:var(--muted); text-decoration:none; } +.tools-links a:hover { color:var(--green); text-decoration:underline; } diff --git a/App/sims/tourismusregion/game.html b/App/sims/tourismusregion/game.html new file mode 100644 index 0000000..1678d23 --- /dev/null +++ b/App/sims/tourismusregion/game.html @@ -0,0 +1,179 @@ + + + + + + +Tourismusregion entwickeln – GeoGraSim + + + + + + + +
+ +

Tourismusregion entwickeln

+
Welle 1 vorbereiten…
+
+
Welle 1/8
+
+ + + + + +
+ + + + + + +
+
+ + + + + + + + + +
+ +
+
+ +
+ + + + + + + + + +
+
+
🏞️
+

Tourismusregion entwickeln

+

Besuchergruppen kommen in 8 Wellen durch dein Tal. + Baue in den Baupausen die passende Infrastruktur, mach jede Gruppe + glücklich und halte Wirtschaft, Umwelt und Budget im Gleichgewicht.

+ + +

Wegkarte wählen

+
+ +

Tipp: Gebäude wirken nur auf Gäste in ihrer Reichweite – + baue nah an den Wegen!

+
+
+ + + + + + + diff --git a/App/sims/tourismusregion/js/audio.js b/App/sims/tourismusregion/js/audio.js new file mode 100644 index 0000000..82838fa --- /dev/null +++ b/App/sims/tourismusregion/js/audio.js @@ -0,0 +1,187 @@ +// 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', 'ambience_summer', 'ambience_winter', + 'bite', 'sip', 'splash', 'tool', 'cash']; + +export const audio = { + ctx: null, + soundOn: true, + musicOn: true, + _buffers: {}, // Name → AudioBuffer + _raw: {}, // Name → ArrayBuffer (vor dem Decodieren) + _ambience: null, // laufender Ambience-Loop {src, gain, name} + _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; + // Ambience ggf. nachstarten, sobald sie decodiert ist + if (name === this._ambienceName() && this.musicOn && !this._ambience) { + this._startAmbience(); + } + }) + .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); + } + }, + + // kurzer, leiser Interaktions-Ton (Tower-Defense-„Treffer"), pitch = Faktor + pling(pitch = 1) { + if (!this.soundOn || !this.ensure()) return; + this._tone(620 * pitch, 0.09, 'triangle', 0.035); + }, + + // Dienstleistungs-Geräusch (Apfelbiss, Kaffee, Werkzeug …), dezent + fx(name) { + 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 = 0.28; + src.buffer = buf; + src.connect(g).connect(this.ctx.destination); + src.start(); + } else { + // Synth-Fallback je Art + switch (name) { + case 'bite': this._tone(140, 0.08, 'square', 0.04); break; + case 'sip': this._tone(320, 0.14, 'sine', 0.03); break; + case 'splash': this._tone(900, 0.10, 'sine', 0.03); this._tone(500, 0.12, 'sine', 0.02, 0.03); break; + case 'tool': this._tone(220, 0.05, 'sawtooth', 0.03); this._tone(260, 0.05, 'sawtooth', 0.03, 0.06); break; + case 'cash': this._tone(880, 0.06, 'triangle', 0.04); this._tone(1240, 0.10, 'triangle', 0.035, 0.06); break; + default: this.pling(); + } + } + }, + + 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); + } + }, + + // ---------- Saisonale Atmosphäre (läuft, wenn Musik an ist) ---------- + _ambienceName() { + return this._season === 'Winter' ? 'ambience_winter' : 'ambience_summer'; + }, + + setSeason(season) { + if (season === this._season) return; + this._season = season; + if (this.musicOn && this.ctx) this._startAmbience(); // Wechsel mit Fade + }, + + _startAmbience() { + const name = this._ambienceName(); + const buf = this._buffers[name]; + if (!buf) return; + this._stopAmbience(); + const src = this.ctx.createBufferSource(); + const g = this.ctx.createGain(); + src.buffer = buf; + src.loop = true; + g.gain.setValueAtTime(0, this.ctx.currentTime); + g.gain.linearRampToValueAtTime(0.18, this.ctx.currentTime + 2.5); + src.connect(g).connect(this.ctx.destination); + src.start(); + this._ambience = { src, gain: g, name }; + }, + + _stopAmbience() { + if (!this._ambience) return; + const { src, gain } = this._ambience; + gain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + 1.2); + setTimeout(() => { try { src.stop(); } catch (e) { /* schon gestoppt */ } }, 1400); + this._ambience = null; + }, + + startMusic() { + if (!this.musicOn || !this.ensure()) return; + this._startAmbience(); + }, + + 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/tourismusregion/js/data.js b/App/sims/tourismusregion/js/data.js new file mode 100644 index 0000000..98f77b7 --- /dev/null +++ b/App/sims/tourismusregion/js/data.js @@ -0,0 +1,614 @@ +// ALLE Spielinhalte data-driven: Besuchergruppen, Infrastrukturkatalog, +// Phasen-Wellen, Ereigniskarten, Entscheidungen, Feed-Texte, Regionsprofile. +// Zahlen folgen dem Pflichtenheft (§7, §10, §11, §20) und wurden mit dem +// Headless-Modelltest (tests/model-test.mjs) kalibriert. + +export const GAME_LEN = 1200; // 20 Minuten Echtzeit (Sekunden) +export const PHASE_LEN = 150; // 8 Phasen à 2:30 +export const SEASON_LEN = 300; // 4 Saisonen à 5:00 +export const SEASONS = ['Frühling', 'Sommer', 'Herbst', 'Winter']; +export const SEASON_KEYS = ['spring', 'summer', 'autumn', 'winter']; +export const CONTACT_NORM = 5; // Normkontaktzeit in Sekunden (§11.6) +export const MAX_GAIN_FACTOR = 1.2; // Deckel je Attraktion (× Grundwirkung) +export const MAX_BRAKE = 0.6; // Maximalbremse (§11.5) +export const START_BUDGET = 500; +export const START_ENV = 75; + +// ---------- Besuchergruppen (§7.4) ---------- +// speed in Kacheln/s · budget = max. Ausgaben · satTarget = Zufriedenheitsziel +// entry: W = Straße West, N = Radweg Nord, S = Fußweg Süd +export const GROUPS = { + spaziergaenger: { + name: 'Spaziergänger', emoji: '🚶', color: '#3f8f6b', size: 2, + speed: 0.55, budget: 30, satTarget: 60, maxTime: 160, + envSens: 0.8, trafficSens: 0.6, transport: 'foot', entry: 'S', + needsService: false, bergAffinity: 0.1, lakeAffinity: 0.7, + likes: 'Natur · Café · Aussicht', wish: 'ein gemütliches Café', + who: 'Einheimische und Gäste aus der Umgebung, oft Paare auf Tagesausflug. Kleines Budget (~30 €), aber wichtig für den Anfang: Sie erzählen weiter, wie schön es bei euch ist.', + fromPhase: 1, + }, + wanderer: { + name: 'Wanderer', emoji: '🥾', color: '#2f6ea0', size: 2, + speed: 0.62, budget: 45, satTarget: 75, maxTime: 200, + envSens: 1.0, trafficSens: 0.5, transport: 'foot', entry: 'S', + needsService: false, bergAffinity: 0.9, lakeAffinity: 0.4, + likes: 'Natur · Aussicht · Gasthaus', wish: 'ein Weg auf den Berg', + who: 'Naturfreunde mit Rucksack, kommen zu Fuß über den Südweg. Mittleres Budget (~45 €), sehr umweltbewusst – eine kaputte Landschaft vergrault sie sofort. Träumen von einem Weg auf den Berg.', + fromPhase: 1, + }, + familie: { + name: 'Familie', emoji: '👨‍👩‍👧‍👦', color: '#e8892b', size: 4, + speed: 0.5, budget: 90, satTarget: 100, maxTime: 180, + envSens: 0.7, trafficSens: 0.8, transport: 'car', entry: 'W', + needsService: true, bergAffinity: 0.25, lakeAffinity: 0.9, + likes: 'Spielplatz · See · Eis · WC', wish: 'ein Spielplatz für die Kinder', + who: 'Eltern mit zwei Kindern aus der Stadt, kommen mit dem Auto über die Weststraße. Solides Budget (~90 €), aber klare Ansprüche: Ohne Spielplatz, WC und Abkühlung am See wird der Ausflug zum Stresstest.', + fromPhase: 2, + }, + radfahrer: { + name: 'Radfahrer', emoji: '🚴', color: '#c9531f', size: 2, + speed: 1.35, budget: 40, satTarget: 60, maxTime: 90, + envSens: 0.8, trafficSens: 0.9, transport: 'bike', entry: 'N', + needsService: false, bergAffinity: 0.15, lakeAffinity: 0.4, + likes: 'Radstation · Café · Aussicht', wish: 'eine Radreparaturstation', + who: 'Sportliche Gäste auf dem Radweg von Norden – schnell unterwegs! Angebote wirken nur direkt an der Strecke. Kleines Budget (~40 €): Reparaturstation, Café, kurze Stopps.', + fromPhase: 3, + }, + senioren: { + name: 'Seniorengruppe', emoji: '👵', color: '#a34a8e', size: 3, + speed: 0.38, budget: 80, satTarget: 90, maxTime: 220, + envSens: 0.6, trafficSens: 0.9, transport: 'car', entry: 'W', + needsService: true, bergAffinity: 0.15, lakeAffinity: 0.5, + likes: 'Café · Kultur · Aussicht · Sitzbänke', wish: 'ein Museum oder Café', + who: 'Reiselustige Pensionistinnen und Pensionisten mit gutem Budget (~80 €) und viel Zeit. Gehen langsam, mögen keinen Verkehrslärm – dafür Café, Kultur und schöne Ausblicke.', + fromPhase: 5, + }, + schulklasse: { + name: 'Schulklasse', emoji: '🎒', color: '#2e8b8b', size: 6, + speed: 0.6, budget: 25, satTarget: 80, maxTime: 170, + envSens: 0.5, trafficSens: 0.4, transport: 'bus', entry: 'S', + needsService: true, bergAffinity: 0.4, lakeAffinity: 0.8, + likes: 'Erlebnis · Bildung · günstige Jause', wish: 'etwas zum Entdecken (Museum, Spielplatz)', + who: 'Eine Klasse mit Lehrpersonen auf Exkursion – knappes Budget (~25 €), aber die Gäste von morgen! Brauchen Erlebnis, etwas zum Lernen (Museum!) und eine günstige Jause.', + fromPhase: 5, + }, + busgruppe: { + name: 'Busreisegruppe', emoji: '🚌', color: '#8c6239', size: 6, + speed: 0.45, budget: 70, satTarget: 95, maxTime: 150, + envSens: 0.4, trafficSens: 0.5, transport: 'bus', entry: 'W', + needsService: true, needsParking: true, bergAffinity: 0.3, lakeAffinity: 0.4, + likes: 'Sehenswürdigkeit · Souvenirs · Gasthaus · WC', wish: 'ein Busparkplatz und ein WC', + who: 'Organisierte Tagesreisende mit Reiseleitung, mittleres Budget (~70 €). Achtung: Ohne Busparkplatz fährt der Bus einfach weiter! Programm: Sehenswürdigkeit, Souvenirs, Gasthaus, WC.', + fromPhase: 5, + }, + junge_gruppe: { + name: 'Junge Reisegruppe', emoji: '🤸', color: '#5b6ee1', size: 4, + speed: 0.8, budget: 60, satTarget: 105, maxTime: 170, + envSens: 0.5, trafficSens: 0.3, transport: 'car', entry: 'W', + needsService: false, bergAffinity: 0.95, lakeAffinity: 0.6, + likes: 'Action · Berg · Aussicht · günstige Gastro', wish: 'Action am Berg (Skilift!)', + who: 'Freundesgruppen auf der Suche nach Action am Berg. Mittleres Budget (~60 €), unempfindlich gegen Trubel – aber ohne Lift und Erlebnis ziehen sie weiter.', + fromPhase: 6, + }, + luxuspaar: { + name: 'Wohlhabendes Paar', emoji: '💎', color: '#b0356b', size: 2, + speed: 0.5, budget: 200, satTarget: 140, maxTime: 200, + envSens: 0.9, trafficSens: 0.8, transport: 'car', entry: 'W', + needsService: false, bergAffinity: 0.6, lakeAffinity: 0.5, + likes: 'feines Restaurant · Wellness · Aussicht · Kultur', + wish: 'ein wirklich gutes Restaurant (Wirtshaus Stufe 4+)', + who: 'Wohlhabende Paare mit hohen Erwartungen und großem Budget (~200 €). Sie kommen nur, wenn die Qualität stimmt: feines Restaurant (Wirtshaus Stufe 4+), Wellness, Kultur – und eine intakte Landschaft.', + fromPhase: 7, + }, +}; + +// ---------- Infrastrukturkatalog (§9/§10) ---------- +// Arrays sind je Ausbaustufe (Index 0 = Stufe 1). +// cost[0] = Baukosten, cost[i] = Kosten des Upgrades auf Stufe i+1. +// sat = Grundwirkung Zufriedenheit je Normkontakt · slow = Bremse (Anteil) +// opCost = Betriebskosten pro Minute · revenue = Basisumsatz je Normkontakt +// env = Umweltwirkung beim Erreichen der Stufe (Delta) · range in Kacheln +// target = Zielgruppenfaktor (fehlend ⇒ 0.4) · season = Saisonfaktor +export const BUILDINGS = { + wirtshaus: { + name: 'Wirtshaus', emoji: '🍽️', cat: 'gastronomy', unlockPhase: 1, item: '🍲', itemsBy: { luxuspaar: '🍷', schulklasse: '🍟' }, sound: 'bite', + desc: 'Stärkt fast alle Gäste. Solides Startgebäude.', + levelNames: ['Wirtshaus', 'Gasthof', 'Landgasthof', 'Restaurant', 'Gourmetrestaurant'], + cost: [100, 180, 320, 520, 900], + sat: [8, 13, 20, 28, 40], + slow: [0.02, 0.04, 0.06, 0.08, 0.1], + range: [4, 4.5, 5, 5.5, 6], + capacity: [5, 8, 12, 18, 25], + opCost: [4, 7, 12, 20, 35], + revenue: [7, 10, 15, 22, 32], + env: [-1, -1, -1, -1, -2], + target: { + wanderer: 1.2, spaziergaenger: 1.1, familie: 0.8, senioren: 1.1, + schulklasse: 0.6, busgruppe: 1.0, radfahrer: 0.9, junge_gruppe: 0.9, luxuspaar: 0.3, + }, + targetByLevel: { luxuspaar: [0.3, 0.3, 0.3, 1.2, 1.3] }, + season: { spring: 1, summer: 1, autumn: 1.1, winter: 1.2 }, + place: { near: 'path' }, + }, + cafe: { + name: 'Café', emoji: '☕', cat: 'gastronomy', unlockPhase: 1, item: '☕', itemsBy: { familie: '🧃', schulklasse: '🧃', senioren: '🍰' }, sound: 'sip', + desc: 'Bremst und erfreut – besonders Senioren, Spaziergänger, Paare.', + cost: [80, 140, 240, 400, 650], + sat: [5, 8, 12, 17, 24], + slow: [0.10, 0.15, 0.20, 0.25, 0.30], + range: [3.5, 4, 4.5, 5, 5.5], + capacity: [5, 7, 10, 14, 20], + opCost: [3, 5, 8, 13, 20], + revenue: [5, 7, 10, 14, 20], + env: [-1, 0, -1, -1, -1], + target: { + senioren: 1.4, spaziergaenger: 1.3, luxuspaar: 1.0, familie: 0.9, + radfahrer: 1.1, wanderer: 0.9, junge_gruppe: 0.8, busgruppe: 0.8, schulklasse: 0.5, + }, + season: { spring: 1.1, summer: 1, autumn: 1.1, winter: 1 }, + place: { near: 'path' }, + }, + marktstand: { + name: 'Marktstand', emoji: '🧺', cat: 'shopping', unlockPhase: 1, item: '🍎', itemsBy: { familie: '🧺', busgruppe: '🧺' }, sound: 'bite', + desc: 'Reine Bremse: Gäste bleiben länger, kleiner Umsatz.', + cost: [60, 100, 170, 280, 450], + sat: [1, 2, 3, 4, 5], + slow: [0.08, 0.12, 0.17, 0.22, 0.28], + range: [3, 3.5, 4, 4.5, 5], + capacity: [8, 12, 16, 22, 30], + opCost: [2, 3, 5, 8, 12], + revenue: [3, 5, 7, 10, 14], + env: [0, 0, 0, -1, -1], + target: { + spaziergaenger: 1.1, senioren: 1.2, familie: 1.0, busgruppe: 1.2, + wanderer: 0.8, luxuspaar: 0.8, schulklasse: 0.7, junge_gruppe: 0.8, radfahrer: 0.5, + }, + season: { spring: 1, summer: 1.1, autumn: 1.2, winter: 0.9 }, + place: { near: 'path' }, + }, + aussicht: { + name: 'Aussichtspunkt', emoji: '🔭', cat: 'nature', unlockPhase: 1, fx: '📷', sound: 'click', + desc: 'Landschaftserlebnis. Wirkt stärker in erhöhter Lage.', + cost: [90, 160, 280, 470, 780], + sat: [7, 12, 18, 26, 36], + slow: [0.04, 0.05, 0.06, 0.07, 0.08], + range: [4, 4.5, 5, 5.5, 6], + capacity: [8, 12, 16, 22, 30], + opCost: [1, 2, 3, 5, 8], + revenue: [0, 0, 1, 1, 2], + env: [0, 0, 0, -1, -1], + target: { + wanderer: 1.4, senioren: 1.2, luxuspaar: 1.2, spaziergaenger: 1.2, + busgruppe: 1.1, familie: 0.8, radfahrer: 0.9, junge_gruppe: 1.0, schulklasse: 0.8, + }, + season: { spring: 1, summer: 1.1, autumn: 1.2, winter: 1.1 }, + place: { near: 'path' }, elevBonus: true, natureBoosted: true, + }, + spielplatz: { + name: 'Spielplatz', emoji: '🛝', cat: 'play', unlockPhase: 2, fx: '🎈', sound: 'pling', + desc: 'Familien-Magnet, auch Schulklassen. Kaum Umsatz, viel Freude.', + cost: [120, 200, 340, 560, 900], + sat: [12, 18, 26, 35, 45], + slow: [0.08, 0.10, 0.12, 0.14, 0.16], + range: [4, 4.5, 5, 5.5, 6], + capacity: [6, 9, 13, 18, 25], + opCost: [2, 3, 5, 8, 12], + revenue: [0, 0, 0, 1, 1], + env: [0, 0, 0, -1, -1], + target: { familie: 1.0, schulklasse: 0.7, spaziergaenger: 0.3, junge_gruppe: 0.4 }, + season: { spring: 1.1, summer: 1.2, autumn: 1, winter: 0.7 }, + place: { near: 'path' }, + }, + wc: { + name: 'WC / Servicepunkt', emoji: '🚻', cat: 'service', unlockPhase: 2, maxLevel: 3, + desc: 'Unauffällig, aber wichtig: verhindert Frust bei langen Aufenthalten.', + cost: [60, 110, 190], + sat: [2, 3, 4], + slow: [0, 0, 0], + range: [5, 6, 7], + capacity: [12, 20, 30], + opCost: [2, 3, 5], + revenue: [0, 0, 0], + env: [0, 0, 0], + target: { familie: 1.2, senioren: 1.2, busgruppe: 1.2, schulklasse: 1.0 }, + season: { spring: 1, summer: 1, autumn: 1, winter: 1 }, + place: { near: 'path' }, service: true, + }, + badesteg: { + name: 'Badesteg', emoji: '🏖️', cat: 'nature', unlockPhase: 2, fx: '🏊', sound: 'splash', + desc: 'Badespaß am See – im Sommer der Hit, im Winter tot.', + cost: [110, 190, 320, 530, 860], + sat: [8, 13, 19, 27, 37], + slow: [0.10, 0.12, 0.15, 0.18, 0.22], + range: [4, 4.5, 5, 5.5, 6], + capacity: [6, 9, 13, 18, 25], + opCost: [2, 3, 5, 8, 12], + revenue: [2, 3, 4, 6, 8], + env: [-1, -1, -1, -2, -2], + target: { + familie: 1.3, junge_gruppe: 1.0, schulklasse: 1.0, spaziergaenger: 0.8, + wanderer: 0.5, luxuspaar: 0.6, + }, + season: { spring: 0.4, summer: 1.6, autumn: 0.3, winter: 0.1 }, + place: { near: 'lake' }, natureBoosted: true, + }, + eisdiele: { + name: 'Eisdiele', emoji: '🍦', cat: 'gastronomy', unlockPhase: 3, item: '🍦', sound: 'bite', + desc: 'Süße Bremse für Familien und Kinder, stark im Sommer.', + cost: [90, 150, 260, 430, 700], + sat: [6, 9, 13, 18, 25], + slow: [0.08, 0.11, 0.14, 0.17, 0.20], + range: [3.5, 4, 4.5, 5, 5.5], + capacity: [6, 9, 13, 18, 25], + opCost: [2, 4, 6, 10, 15], + revenue: [4, 6, 9, 13, 18], + env: [0, 0, -1, -1, -1], + target: { + familie: 1.4, schulklasse: 1.2, junge_gruppe: 1.0, spaziergaenger: 1.0, + radfahrer: 1.0, senioren: 0.9, + }, + season: { spring: 0.9, summer: 1.5, autumn: 0.7, winter: 0.3 }, + place: { near: 'path' }, + }, + radstation: { + name: 'Radreparaturstation', emoji: '🚲', cat: 'service', unlockPhase: 3, item: '🔧', sound: 'tool', + desc: 'Wirkt fast nur auf Radfahrer – dafür richtig stark.', + cost: [70, 120, 210, 350, 570], + sat: [15, 23, 32, 43, 56], + slow: [0.10, 0.12, 0.14, 0.16, 0.18], + range: [3.5, 4, 4.5, 5, 5.5], + capacity: [4, 6, 9, 13, 18], + opCost: [1, 2, 3, 5, 8], + revenue: [2, 3, 5, 7, 10], + env: [0, 0, 0, 0, 0], + target: { radfahrer: 1.0, junge_gruppe: 0.3 }, + season: { spring: 1.1, summer: 1.2, autumn: 1, winter: 0.4 }, + place: { near: 'path' }, + }, + tourismusinfo: { + name: 'Tourismusinfo', emoji: 'ℹ️', cat: 'service', unlockPhase: 3, maxLevel: 5, + desc: 'Multiplikator: verstärkt alle Attraktionen in ihrer Nähe.', + cost: [130, 220, 370, 600, 950], + sat: [2, 3, 4, 5, 6], + slow: [0.03, 0.04, 0.05, 0.06, 0.07], + range: [5, 5.5, 6, 6.5, 7], + capacity: [10, 14, 20, 28, 40], + opCost: [3, 5, 8, 12, 18], + revenue: [0, 0, 0, 0, 0], + env: [0, 0, 0, 0, 0], + boost: [0.05, 0.10, 0.16, 0.23, 0.32], // +% Wirkung benachbarter Gebäude + target: { busgruppe: 1.0, senioren: 1.0, familie: 0.8 }, + season: { spring: 1, summer: 1, autumn: 1, winter: 1 }, + place: { near: 'path' }, + }, + naturschutz: { + name: 'Naturschutzgebiet', emoji: '🌿', cat: 'nature', unlockPhase: 4, maxLevel: 1, fx: '🦋', sound: 'pling', + desc: 'Umwelt +20, Naturerlebnis +15 % – sperrt aber Bauplätze im Umkreis.', + cost: [300], + sat: [6], + slow: [0.05], + range: [5], + capacity: [999], + opCost: [2], + revenue: [0], + env: [20], + target: { wanderer: 1.3, spaziergaenger: 1.1, luxuspaar: 0.9, familie: 0.7, schulklasse: 0.9 }, + season: { spring: 1.2, summer: 1.1, autumn: 1.1, winter: 0.8 }, + place: { near: 'any', notVillage: true }, blocksRadius: 2.5, + }, + museum: { + name: 'Heimatmuseum', emoji: '🏛️', cat: 'culture', unlockPhase: 5, item: '🎟️', itemsBy: { schulklasse: '📚' }, sound: 'cash', + desc: 'Kultur für Senioren, Schulklassen und Busgruppen.', + cost: [200, 340, 560, 900, 1400], + sat: [10, 15, 22, 30, 40], + slow: [0.08, 0.10, 0.12, 0.14, 0.16], + range: [4, 4.5, 5, 5.5, 6], + capacity: [6, 9, 13, 18, 25], + opCost: [4, 6, 10, 15, 22], + revenue: [3, 5, 7, 10, 14], + env: [0, 0, 0, 0, 0], + target: { + senioren: 1.3, schulklasse: 1.2, busgruppe: 1.2, luxuspaar: 1.0, + wanderer: 0.5, familie: 0.6, + }, + season: { spring: 1, summer: 0.9, autumn: 1.2, winter: 1.2 }, + place: { near: 'path' }, + }, + parkplatz: { + name: 'Parkplatz', emoji: '🅿️', cat: 'service', unlockPhase: 5, + desc: 'Bringt mehr Auto- und Busgäste – erzeugt aber Verkehr und kostet Umwelt.', + cost: [150, 260, 430, 700, 1100], + sat: [1, 1, 2, 2, 3], + slow: [0, 0, 0, 0, 0], + range: [5, 5.5, 6, 6.5, 7], + capacity: [999, 999, 999, 999, 999], + opCost: [2, 3, 5, 8, 12], + revenue: [2, 3, 5, 7, 10], + env: [-8, -3, -3, -4, -5], + visitorPlus: [0.05, 0.10, 0.17, 0.25, 0.35], // + Anteil Auto-/Busgäste + traffic: [5, 10, 17, 28, 42], + target: { busgruppe: 1.2, familie: 0.8, senioren: 0.8, luxuspaar: 0.6 }, + season: { spring: 1, summer: 1, autumn: 1, winter: 1 }, + place: { near: 'road' }, + }, + bushaltestelle: { + name: 'Bushaltestelle', emoji: '🚏', cat: 'service', unlockPhase: 5, maxLevel: 5, + desc: 'Nachhaltige Anreise: mehr Gruppen, kaum Umweltbelastung.', + cost: [120, 210, 350, 570, 900], + sat: [1, 1, 2, 2, 3], + slow: [0, 0, 0, 0, 0], + range: [5, 5.5, 6, 6.5, 7], + capacity: [999, 999, 999, 999, 999], + opCost: [3, 5, 8, 12, 18], + revenue: [1, 2, 3, 4, 6], + env: [1, 1, 1, 1, 1], + busPlus: [0.04, 0.08, 0.14, 0.20, 0.28], // + Anteil Bus-Gäste + target: { busgruppe: 1.0, senioren: 1.0, schulklasse: 1.0 }, + season: { spring: 1, summer: 1, autumn: 1, winter: 1 }, + place: { near: 'road' }, + }, + souvenir: { + name: 'Souvenirshop', emoji: '🎁', cat: 'shopping', unlockPhase: 5, item: '🎁', itemsBy: { junge_gruppe: '🧢' }, sound: 'cash', + desc: 'Bremse + Umsatz, geliebt von Busgruppen.', + cost: [100, 170, 290, 480, 780], + sat: [4, 6, 9, 13, 18], + slow: [0.10, 0.13, 0.16, 0.20, 0.24], + range: [3.5, 4, 4.5, 5, 5.5], + capacity: [6, 9, 13, 18, 25], + opCost: [2, 4, 6, 10, 15], + revenue: [5, 7, 10, 14, 20], + env: [0, 0, -1, -1, -1], + target: { + busgruppe: 1.4, senioren: 1.0, familie: 0.8, luxuspaar: 0.7, + schulklasse: 0.8, junge_gruppe: 0.6, + }, + season: { spring: 1, summer: 1.1, autumn: 1.1, winter: 1.1 }, + place: { near: 'path' }, + }, + skilift: { + name: 'Skilift', emoji: '🚡', cat: 'sport', unlockPhase: 6, fx: '🎿', sound: 'pling', + desc: 'Erschließt den Berg: neuer Rundweg + Bauplätze oben. Teuer, stark im Winter, Umwelteingriff.', + levelNames: ['Schlepplift', 'Sessellift', '6er-Sessellift', 'Gondelbahn', 'Erlebnisbahn'], + cost: [800, 1300, 2100, 3500, 5500], + sat: [18, 26, 36, 48, 62], + slow: [0, 0, 0, 0, 0], + range: [4, 4.5, 5, 5.5, 6], + capacity: [8, 14, 22, 32, 45], + opCost: [15, 22, 32, 45, 60], + revenue: [10, 14, 20, 28, 40], + env: [-10, -2, -2, -3, -3], + target: { + junge_gruppe: 1.3, wanderer: 1.0, familie: 0.7, schulklasse: 0.9, + luxuspaar: 0.8, senioren: 0.3, + }, + targetByLevel: { senioren: [0.3, 0.3, 0.5, 1.0, 1.0] }, // Gondel hilft Senioren + season: { spring: 0.5, summer: 0.7, autumn: 0.6, winter: 1.5 }, + place: { zone: 'hang', unique: true }, opensBerg: true, + }, + almhuette: { + name: 'Almhütte', emoji: '🏔️', cat: 'gastronomy', unlockPhase: 6, item: '🧀', itemsBy: { wanderer: '🥨', junge_gruppe: '🥤' }, sound: 'bite', + desc: 'Einkehr am Berg – nur sinnvoll, wenn der Lift Gäste bringt.', + cost: [180, 300, 500, 820, 1300], + sat: [10, 15, 22, 30, 40], + slow: [0.10, 0.13, 0.16, 0.20, 0.24], + range: [4, 4.5, 5, 5.5, 6], + capacity: [5, 8, 12, 18, 25], + opCost: [4, 6, 10, 15, 22], + revenue: [6, 9, 13, 19, 27], + env: [-1, -1, -1, -1, -2], + target: { wanderer: 1.3, junge_gruppe: 1.2, luxuspaar: 0.9, schulklasse: 0.8, familie: 0.8, senioren: 0.9 }, + season: { spring: 0.8, summer: 1.1, autumn: 1.1, winter: 1.4 }, + place: { zone: 'berg', needsLift: true }, + }, + wellness: { + name: 'Wellnesshaus', emoji: '💆', cat: 'culture', unlockPhase: 7, item: '🌺', itemsBy: { luxuspaar: '💆' }, sound: 'sip', + desc: 'Ruhe und Luxus für anspruchsvolle Gäste.', + cost: [600, 1000, 1600, 2600, 4200], + sat: [14, 20, 28, 38, 50], + slow: [0.12, 0.14, 0.17, 0.20, 0.24], + range: [4, 4.5, 5, 5.5, 6], + capacity: [4, 6, 9, 13, 18], + opCost: [8, 12, 18, 26, 38], + revenue: [12, 17, 24, 34, 48], + env: [-3, -1, -2, -2, -2], + target: { luxuspaar: 1.4, senioren: 0.9, familie: 0.3, junge_gruppe: 0.4 }, + season: { spring: 1, summer: 0.9, autumn: 1.1, winter: 1.3 }, + place: { near: 'path' }, + }, +}; + +for (const def of Object.values(BUILDINGS)) { + def.maxLevel = def.maxLevel ?? def.cost.length; +} + +// ---------- Wellen je Phase (§20) ---------- +// Basisanzahl; skaliert mit Besucherfaktor (0.7 + Ø-Sterne/5) und +// Parkplatz-/Bushaltestellen-Bonus für Auto-/Busgruppen. +export const WAVES = { + 1: { spaziergaenger: 5, wanderer: 3 }, + 2: { spaziergaenger: 5, wanderer: 4, familie: 2 }, + 3: { spaziergaenger: 3, wanderer: 3, familie: 5, radfahrer: 4 }, + 4: { spaziergaenger: 2, wanderer: 3, familie: 4, radfahrer: 8 }, + 5: { spaziergaenger: 2, wanderer: 2, familie: 3, radfahrer: 3, senioren: 4, busgruppe: 3, schulklasse: 3 }, + 6: { spaziergaenger: 2, wanderer: 3, familie: 4, radfahrer: 3, senioren: 3, busgruppe: 3, schulklasse: 2, junge_gruppe: 3 }, + 7: { wanderer: 2, familie: 3, senioren: 2, busgruppe: 2, junge_gruppe: 5, luxuspaar: 3 }, + 8: { spaziergaenger: 2, wanderer: 3, familie: 4, radfahrer: 2, senioren: 3, busgruppe: 3, schulklasse: 2, junge_gruppe: 4, luxuspaar: 3 }, +}; + +// ---------- Ereignisse & Ankündigungen (§13) ---------- +// t in Sekunden. type: 'card' (Info/Ankündigung), 'decision' (A/B-Wahl). +export const EVENTS = [ + { + id: 'hint_familien', wave: 2, type: 'card', icon: '📣', + title: 'Tourismusverband meldet', + text: 'Immer mehr Familien entdecken die Region als Ausflugsziel. ' + + 'In etwa 5 Minuten werden deutlich mehr Familien erwartet.\n\n' + + 'Fachlicher Hinweis: Familien achten auf kindgerechte Angebote, ' + + 'kurze Wege und Sanitäranlagen.', + }, + { + id: 'hint_radweg', wave: 3, type: 'card', icon: '🚴', + title: 'Ankündigung: Radfernweg', + text: 'Ein Radfernweg wird eröffnet – in 2 Phasen kommen deutlich mehr ' + + 'Radfahrer durch den Ort.\n\nFachlicher Hinweis: Radfahrer sind schnell ' + + 'unterwegs. Angebote wirken nur, wenn sie nah am Radweg liegen.', + }, + { + id: 'decision_investor', wave: 4, type: 'decision', icon: '🏗️', + title: 'Ein Investor will einsteigen', + text: 'Ein Großinvestor bietet der Gemeinde 800 € Beteiligung – dafür will ' + + 'er großflächig am Seeufer bauen. Der Eingriff würde die Natur belasten ' + + '(Umwelt −10).\n\nRaumplanung heißt abwägen: kurzfristiges Geld gegen ' + + 'langfristige Qualität.', + yes: { label: 'Angebot annehmen (+800 €, Umwelt −10)', apply: s => { s.budget += 800; s.env = Math.max(5, s.env - 10); } }, + no: { label: 'Ablehnen (Umwelt +3)', apply: s => { s.env = Math.min(100, s.env + 3); } }, + }, + { + id: 'hint_bus', wave: 5, type: 'card', icon: '🚌', + title: 'Reiseveranstalter plant Tagesfahrten', + text: 'Ein Reiseveranstalter nimmt die Region ins Programm: Ab jetzt ' + + 'kommen Busreisegruppen und Schulklassen.\n\nFachlicher Hinweis: ' + + 'Busgruppen brauchen eine Parkmöglichkeit – sonst reisen sie ' + + 'verärgert wieder ab. WC und Gasthaus nicht vergessen.', + }, + { + id: 'hint_winter', wave: 6, type: 'card', icon: '❄️', + title: 'Der Winter naht', + text: 'Schneesicherheit ist im Tal unsicher, aber höher gelegene Bereiche ' + + 'bleiben attraktiv. Ein Lift würde den Hang erschließen und oben neue ' + + 'Bauplätze schaffen.\n\nFachlicher Hinweis: Ein Lift ist teuer und ' + + 'belastet die Umwelt – ganzjährige Nutzung (Wandern, Aussicht) ' + + 'verbessert die Rechnung.', + }, + { + id: 'decision_dorffest', wave: 6, type: 'decision', icon: '🎪', + title: 'Dorffest am Wochenende?', + text: 'Der Verein möchte ein Dorffest veranstalten. Kosten: 150 €. ' + + 'Dafür steigt die Stimmung: Alle Gäste der nächsten Zeit sind ' + + 'spürbar zufriedener.', + yes: { + label: 'Fest finanzieren (−150 €)', can: s => s.budget >= 150, + apply: s => { s.budget -= 150; s.festUntil = s.time + 120; }, + }, + no: { label: 'Diesmal nicht', apply: () => {} }, + }, + { + id: 'hint_luxus', wave: 7, type: 'card', icon: '💎', + title: 'Anspruchsvolle Gäste im Anmarsch', + text: 'Die Region wird bekannter: Junge Reisegruppen suchen Action am ' + + 'Berg, wohlhabende Paare erwarten Qualität – gutes Restaurant ' + + '(Wirtshaus Stufe 4+), Wellness, Kultur und eine intakte Landschaft.', + }, + { + id: 'hint_endphase', wave: 8, type: 'card', icon: '🏁', + title: 'Endphase', + text: 'Die letzten 2:30 Minuten! Starke Besucherströme kommen. Jetzt ' + + 'zählt: Läuft deine Region rund – für alle Gruppen, wirtschaftlich ' + + 'und ökologisch?', + }, +]; + +// ---------- Social-Media-Feed (§8.3) ---------- +export const FEED_TEXTS = { + 5: [ + '{best} war perfekt für unseren Ausflug – gerne wieder!', + 'Traumhafter Tag! Besonders {best} hat uns begeistert.', + 'Fünf Sterne! {best} und die Landschaft – ein Traum.', + ], + 4: [ + 'Sehr schöner Ausflug, {best} hat uns gut gefallen.', + 'Fast perfekt – {best} war toll, ein bisschen mehr Auswahl wäre schön.', + ], + 3: [ + 'Schöne Landschaft, aber {missing} haben wir vermisst.', + 'Ganz nett. {best} war okay, aber es fehlt noch etwas – z. B. {missing}.', + ], + 2: [ + 'Für uns gab es leider zu wenig zu tun – {missing} würde helfen.', + 'Enttäuschend wenig Angebot. {missing} fehlt.', + ], + 1: [ + 'Nie wieder. Es gab praktisch nichts für uns – {missing} fehlt völlig.', + 'Schade um die schöne Gegend – ohne Angebote fahren wir woanders hin.', + ], + traffic: 'Schöne Gegend, aber der Verkehr im Ort war wirklich mühsam.', + env: 'Die vielen Eingriffe in die Natur haben uns den Ausflug verleidet.', + noParking: 'Unser Bus fand keinen Parkplatz – wir mussten weiterfahren!', +}; + +// ---------- Regionsprofile (§12.3) ---------- +export const PROFILES = [ + { + id: 'krisenregion', name: 'Krisenregion', icon: '🌧️', + check: s => s.result.avgStars < 2.5 || s.budget < -1000 || s.env < 35, + text: 'Schlechte Bewertungen, Geldsorgen oder Umweltprobleme: Die Region hat ihre Richtung noch nicht gefunden.', + }, + { + id: 'nachhaltig', name: 'Nachhaltige Modellregion', icon: '🌍', + check: s => s.result.avgStars >= 3.5 && s.env >= 65, + text: 'Hohe Zufriedenheit UND intakte Umwelt – so sieht zukunftsfähiger Tourismus aus.', + }, + { + id: 'massentourismus', name: 'Massentourismusregion', icon: '🏗️', + check: s => s.revenueTotal >= 3000 && s.env < 50, + text: 'Wirtschaftlich stark, aber auf Kosten der Natur – wie lange geht das gut?', + }, + { + id: 'familienregion', name: 'Familienregion', icon: '👨‍👩‍👧‍👦', + check: s => (s.stats.groups.familie?.count ?? 0) >= 8 && (s.stats.groups.familie?.avg ?? 0) >= 3.8, + text: 'Familien fühlen sich hier besonders wohl – kindgerechte Angebote zahlen sich aus.', + }, + { + id: 'aktivregion', name: 'Aktivregion', icon: '🚵', + check: s => avgOf(s, ['radfahrer', 'wanderer', 'junge_gruppe']) >= 3.8, + text: 'Sport, Berg und Bewegung: Aktive Gäste lieben deine Region.', + }, + { + id: 'kulturregion', name: 'Kulturregion', icon: '🏛️', + check: s => s.buildings.some(b => b.type === 'museum') && avgOf(s, ['senioren', 'schulklasse', 'busgruppe']) >= 3.8, + text: 'Kultur und Dorfleben ziehen Senioren, Schulklassen und Gruppen an.', + }, + { + id: 'entwicklung', name: 'Ausflugsregion in Entwicklung', icon: '🌱', + check: () => true, + text: 'Eine solide Basis ist gelegt – die klare Spezialisierung fehlt noch.', + }, +]; + +function avgOf(s, ids) { + let sum = 0, n = 0; + for (const id of ids) { + const g = s.stats.groups[id]; + if (g?.count) { sum += g.stars; n += g.count; } + } + return n ? sum / n : 0; +} + +// ---------- Synergien (§9.2 synergyRules) ---------- +// Stehen zwei passende Gebäude nah beieinander (Pixel-Radius), verstärken sie +// sich gegenseitig. bonus = zusätzlicher Wirkungsfaktor (+X %). Beidseitig. +export const SYNERGIES = [ + { a: 'skilift', b: 'almhuette', radius: 260, bonus: 0.25, why: 'Almhütte + Lift: Einkehr direkt an der Bergstation' }, + { a: 'aussicht', b: 'naturschutz', radius: 200, bonus: 0.20, why: 'Aussicht + Naturschutz: unverbaute Landschaft' }, + { a: 'cafe', b: 'marktstand', radius: 160, bonus: 0.18, why: 'Café + Marktstand: gemütlicher Bummel' }, + { a: 'wirtshaus', b: 'souvenir', radius: 170, bonus: 0.15, why: 'Wirtshaus + Souvenirshop: Einkehr & Andenken' }, + { a: 'spielplatz', b: 'eisdiele', radius: 160, bonus: 0.22, why: 'Spielplatz + Eisdiele: Familienglück' }, + { a: 'badesteg', b: 'eisdiele', radius: 190, bonus: 0.18, why: 'Badesteg + Eisdiele: Sommer am See' }, + { a: 'museum', b: 'cafe', radius: 170, bonus: 0.15, why: 'Museum + Café: Kultur mit Pause' }, + { a: 'radstation', b: 'cafe', radius: 170, bonus: 0.15, why: 'Radstation + Café: Boxenstopp für Radler' }, + { a: 'skilift', b: 'aussicht', radius: 240, bonus: 0.18, why: 'Lift + Aussicht: Panorama vom Gipfel' }, + { a: 'wellness', b: 'aussicht', radius: 200, bonus: 0.15, why: 'Wellness + Aussicht: Erholung mit Weitblick' }, +]; + +// Ziele fürs Ziele-Panel (§12.1) +export const GOALS = [ + { id: 'rating', label: 'Ø Bewertung ≥ 3,5 Sterne', check: s => currentAvg(s) >= 3.5 }, + { id: 'revenue', label: 'Wertschöpfung ≥ 2500 €', check: s => s.revenueTotal >= 2500 }, + { id: 'env', label: 'Umweltwert ≥ 45', check: s => s.env >= 45 }, + { id: 'debt', label: 'Nicht unter −1000 € rutschen', check: s => s.budget > -1000 }, +]; + +export function currentAvg(s) { + const g = s.stats.all; + return g.count ? g.stars / g.count : 0; +} diff --git a/App/sims/tourismusregion/js/main.js b/App/sims/tourismusregion/js/main.js new file mode 100644 index 0000000..3b9227f --- /dev/null +++ b/App/sims/tourismusregion/js/main.js @@ -0,0 +1,383 @@ +// Einstiegspunkt: Start-Overlay → startGame(). Verdrahtet Modell, Renderer, +// Audio, Telemetrie und UI und treibt die Echtzeit-Partie an (Pause/1×; +// ?speed=4 nur zum Testen). Karten aus dem Modell werden hier angezeigt, +// Bauen/Auswählen läuft über Tile-Taps. + +import { createState, tick, place, upgrade, demolish, canPlace, callNextWave } from './model.js'; +import { BUILDINGS, GROUPS, GOALS, currentAvg } from './data.js'; +import { SceneRenderer } from './scene.js'; +import { MAPS, MAP_ORDER, applyMapOverrides } from './maps.js'; +import { UI } from './ui.js'; +import { telemetry } from './telemetry.js'; +import { audio } from './audio.js'; +import { music } from './music.js'; + +window.__ttLoaded = true; // Signal an den file://-Fallback in index.html + +const params = new URLSearchParams(location.search); +const TEST_SPEED = Math.max(1, Math.min(16, Number(params.get('speed')) || 1)); + +let state = null; +let ui = null; +let renderer = null; +let speed = 0; // 0 = Pause, 1 = läuft +let last = performance.now(); +let uiAccum = 0; +let endShown = false; + +// ---------- Start: Kartenwahl + Partie ---------- +let chosenMap = 'talschleife'; +const mapList = document.getElementById('mapList'); +for (const id of MAP_ORDER) { + const mdef = MAPS[id]; + const el = document.createElement('button'); + el.className = 'map-btn' + (id === chosenMap ? ' active' : ''); + el.dataset.map = id; + el.innerHTML = `${mdef.icon} ${mdef.name}${mdef.desc}`; + el.addEventListener('click', () => { + chosenMap = id; + document.querySelectorAll('.map-btn').forEach(b => b.classList.toggle('active', b === el)); + }); + mapList.appendChild(el); +} + +// Editor-Anpassungen (js/maps-custom.json) laden, bevor eine Partie startet +const overridesReady = fetch('js/maps-custom.json', { cache: 'no-store' }) + .then(r => (r.ok ? r.json() : null)) + .then(o => applyMapOverrides(o)) + .catch(() => {}); + +document.getElementById('btnStart').addEventListener('click', async () => { + await overridesReady; + document.getElementById('startOverlay').classList.add('hidden'); + startGame(); +}); + +function startGame() { + const name = document.getElementById('playerName').value.trim().slice(0, 40); + const code = document.getElementById('classCode').value.trim().slice(0, 20); + state = createState({ playerName: name, classCode: code, seed: 20260703, mapId: chosenMap }); + window.__ttState = state; // für Playwright-Tests lesbar + endShown = false; + + telemetry.init(() => Math.round(state?.time ?? 0)); + telemetry.log('session_start', { player: name, classCode: code }); + + ui = new UI(state, { + setSpeed: v => { speed = v; }, + getSpeed: () => speed, + setBuildType: t => renderer.setBuildType(t), + selectBuilding: b => { if (renderer) renderer.selected = b; }, + upgradeSelected, + demolishSelected, + resetCamera: () => renderer.resetCamera(), + nextWave: () => { + const wasPause = state.waveStatus === 'pause'; + const res = callNextWave(state); + if (!res.ok) { ui.toast(`❌ ${res.reason}`); audio.error(); return; } + ui.toast(wasPause + ? `⏩ Baupause übersprungen – Bonus: +${res.bonus} €` + : `⏩ Nächste Welle früh gerufen – Mut-Bonus: +${res.bonus} €`); + audio.success(); + ui.updateKPIs(); + telemetry.log('next_wave', { bonus: res.bonus, phase: state.phase }); + }, + toggleReduceAnim: () => { renderer.reduceAnim = !renderer.reduceAnim; return renderer.reduceAnim; }, + restart: () => location.reload(), + save: saveResult, + }); + renderer = new SceneRenderer(document.getElementById('map'), state, { onTap }); + window.__ttRenderer = renderer; // für Playwright-Tests (Welt → Screen) + + // Plattform-Standard: Onboarding jederzeit über ℹ abrufbar + window.GGS_TUTORIAL = { + simId: 'tourismusregion', + title: 'Tourismusregion entwickeln', + replay: () => ui.showOnboarding(), + }; + state._startedAt = Date.now(); + + audio.ensure(); + audio.click(); + audio.setSeason(state.season); + music.setVolume(0.09); // Plattform-Standard: Musik leise + music.play(Math.floor(Math.random() * 14)); // Hintergrundmusik ab Start + + speed = 1; + ui.markSpeed(1); + ui.updateKPIs(); + ui.showCard({ + icon: '🏞️', title: 'Willkommen in deiner Region!', sound: 'card', + text: 'Du entwickelst eine kleine Tourismusregion: Dorf, ' + + 'See, Berg – und drei Wege, auf denen Gäste ankommen.\n\n' + + 'Ziel: zufriedene Gäste (Ø 3,5 ★), regionale Wertschöpfung (2500 €), ' + + 'intakte Umwelt (≥ 45) und ein solides Budget.\n\n' + + 'Gebaut wird auf den markierten Bauplätzen am Weg – starte mit einem Wirtshaus!', + }); + if (!localStorage.getItem('tourismusregion_seen')) { + localStorage.setItem('tourismusregion_seen', '1'); + ui.showOnboarding(); + } + requestAnimationFrame(frame); +} + +// ---------- Interaktion auf der Karte ---------- +function onTap(hit) { + if (!state || state.ended) return; + + // Platzierungsmodus: Tap auf einen freien Slot + if (ui.activeBuild && (hit.kind === 'slot' || hit.kind === 'building')) { + const type = ui.activeBuild; + const slotIdx = hit.slot.idx; + const check = canPlace(state, type, slotIdx); + if (!check.ok) { + ui.toast(`❌ ${check.reason}`); + audio.error(); + telemetry.log('build_rejected', { type, slot: slotIdx, reason: check.reason }); + return; + } + const res = place(state, type, slotIdx); + const syn = res.building.synergyList || []; + ui.toast(syn.length + ? `✅ ${BUILDINGS[type].name} gebaut · 🔗 Synergie: ${syn[0]}!` + : `✅ ${BUILDINGS[type].name} gebaut – Reichweite im Blick behalten!`); + audio.build(); + if (syn.length) { audio.success(); renderer.addFloat('🔗 Synergie!', '#7b3fa0', res.building.x, res.building.y - 20); } + renderer.addFloat(`−${BUILDINGS[type].cost[0]} €`, '#c0392b', res.building.x, res.building.y); + ui.updateKPIs(); + ui.rebuildMenu(); + telemetry.log('build_placed', { type, slot: slotIdx, budgetLeft: Math.round(state.budget) }); + ui.clearBuildSelection(); + ui.showBuildingPanel(res.building); + renderer.selected = res.building; + return; + } + if (ui.activeBuild && hit.kind !== 'none') return; + + if (hit.kind === 'building') { + ui.showBuildingPanel(hit.building); + renderer.selected = hit.building; + audio.click(); + telemetry.log('select_building', { type: hit.building.type }); + } else if (hit.kind === 'visitor') { + ui.showVisitorPanel(hit.visitor); + renderer.selected = null; + audio.click(); + telemetry.log('select_visitor', { type: hit.visitor.type }); + } else { + ui.hideInfoPanel(); + renderer.selected = null; + } +} + +function upgradeSelected() { + const b = ui.selectedBuilding; + if (!b) return; + const res = upgrade(state, b); + if (!res.ok) { ui.toast(`❌ ${res.reason}`); audio.error(); return; } + const def = BUILDINGS[b.type]; + ui.toast(`⬆️ ${def.levelNames ? def.levelNames[b.level - 1] : def.name} – Stufe ${b.level}!`); + audio.build(); + renderer.addFloat(`−${res.cost} €`, '#c0392b', b.x, b.y); + ui.updateKPIs(); + telemetry.log('upgrade', { type: b.type, level: b.level }); +} + +function demolishSelected() { + const b = ui.selectedBuilding; + if (!b) return; + const res = demolish(state, b); + if (!res.ok) { ui.toast(`❌ ${res.reason}`); audio.error(); return; } + ui.toast(`🧨 ${res.name} abgerissen – ${res.refund} € zurück.`); + audio.demolish(); + ui.hideInfoPanel(); + renderer.selected = null; + ui.updateKPIs(); + telemetry.log('demolish', { type: res.name, refund: res.refund }); +} + +// ---------- Karten aus dem Modell anzeigen ---------- +function pumpCards() { + while (state.pendingCards.length) { + const card = state.pendingCards.shift(); + switch (card.kind) { + case 'decision': + ui.showCard({ + id: card.id, icon: card.icon, title: card.title, text: card.text, sound: 'card', + choices: [ + { + label: card.yes.label, + disabled: card.yes.can ? !card.yes.can(state) : false, + onPick: () => { + card.yes.apply(state); + ui.updateKPIs(); + telemetry.log('decision', { id: card.id, choice: 'yes' }); + }, + }, + { + label: card.no.label, + onPick: () => { + card.no.apply(state); + ui.updateKPIs(); + telemetry.log('decision', { id: card.id, choice: 'no' }); + }, + }, + ], + }); + break; + case 'banner': { + const g = GROUPS[card.type]; + const dir = { W: 'von Westen', N: 'über den Radweg', S: 'über den Fußweg' }[g.entry] || ''; + ui.toast(`${g.emoji} Welle: ${card.n}× ${g.name} ${dir} im Anmarsch!`); + audio.chime(); + break; + } + case 'feed': + ui.showCard({ ...card, sound: 'card' }); + break; + case 'built': + // Baustelle fertig – nur dezenter Toast, keine Pause + ui.toast(`✅ ${BUILDINGS[card.type].name} ist fertiggebaut und öffnet!`); + audio.success(); + break; + case 'phase': + ui.showCard({ ...card, sound: 'milestone' }); + ui.rebuildMenu(); + break; + case 'season': + ui.showCard({ ...card, sound: 'card' }); + audio.setSeason(state.season); + break; + case 'end': + endShown = true; + submitAssessment(); // Plattform: Ergebnis ans Lehrer-Cockpit + speed = 0; + ui.markSpeed(0); + ui.showEnd(); + break; + default: + ui.showCard({ ...card, sound: card.sound || 'card' }); + } + } +} + + +// ===== Live-View für Lehrkräfte (Plattform-Hook) ===== +// Wird vom live-client.js alle ~4 s an /api/live gesendet. +window.GGS_LIVE_STATE = function () { + if (!state) return null; + return { + scenarioId: state.map?.id, // Karten-Key = Lektions-Gruppierung + mapId: state.map?.id, + wave: state.phase, + waveTotal: 8, + waveStatus: state.waveStatus, + season: state.season, + budget: Math.round(state.budget), + revenue: Math.round(state.revenueTotal), + env: Math.round(state.env), + avgStars: Math.round(currentAvg(state) * 100) / 100, + visitors: state.stats?.spawned ?? 0, + buildings: state.buildings.length, + goalsDone: GOALS.filter(g => g.check(state)).length, + goalsTotal: GOALS.length, + ended: !!state.ended, + }; +}; + +// ===== Submit-Hook (Plattform) ===== +// Schreibt am Partie-Ende einen Eintrag in `assessments` (Lehrer-Cockpit). +let _submitted = false; +function submitAssessment() { + if (!window.TT_SESSION_ID || _submitted) return; + const r = state?.result; + if (!r) return; + _submitted = true; + const api = (window.TT_API_BASE || '../../php/api') + '/progress.php'; + const goalsDone = r.goals.filter(g => g.done).length; + try { + fetch(api, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ + sim_id: 'tourismusregion', + action: 'submit_assessment', + data: { + level: state.map?.id, + stars: Math.max(0, Math.min(5, Math.round(r.avgStars))), + score: r.score, + duration_ms: Date.now() - (state._startedAt || Date.now()), + completed: goalsDone === r.goals.length, + results: { + mapId: state.map?.id, + score: r.score, + avgStars: Math.round(r.avgStars * 100) / 100, + revenue: r.revenue, + env: r.env, + budget: r.budget, + visitors: r.visitors, + goalsDone, + goalsTotal: r.goals.length, + profile: r.profile?.id, + scores: r.scores, + }, + }, + }), + }).catch(() => {}); + } catch { /* Standalone ohne Plattform: ok */ } +} + +// ---------- Ergebnis speichern (PHP/MySQL, Fallback localStorage) ---------- +async function saveResult() { + const r = state.result; + if (!r) return; + submitAssessment(); // Plattform-Submit (falls noch nicht geschehen) + const payload = { + player_name: state.playerName || 'anonym', + class_code: state.classCode || '', + final_score: r.score, + avg_rating: Number(r.avgStars.toFixed(2)), + revenue: r.revenue, + environment_score: r.env, + region_profile: r.profile.id, + map_id: state.map.id, + telemetry: telemetry.flush().slice(-200), + }; + let saved = false; + try { + const res = await fetch('api/save_score.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + saved = res.ok && (await res.json()).ok; + } catch { /* kein Backend (z. B. python-Server) – localStorage reicht */ } + try { + const all = JSON.parse(localStorage.getItem('tourismusregion_results') || '[]'); + all.push({ ...payload, telemetry: undefined, ts: Date.now() }); + localStorage.setItem('tourismusregion_results', JSON.stringify(all.slice(-30))); + } catch { /* Privatmodus */ } + ui.toast(saved + ? '💾 Ergebnis am Server gespeichert.' + : '💾 Ergebnis lokal gespeichert (kein Server erreichbar).'); + telemetry.log('save_result', { server: saved }); +} + +// ---------- Game-Loop ---------- +function frame(now) { + const dtMs = Math.min(100, now - last); + last = now; + const simSpeed = speed * TEST_SPEED; + tick(state, (dtMs / 1000) * simSpeed); + pumpCards(); + + uiAccum += dtMs; + if (uiAccum > 250) { + uiAccum = 0; + ui.updateKPIs(); + ui.drawGraph(); + } + renderer.draw(now, dtMs, simSpeed); + requestAnimationFrame(frame); +} diff --git a/App/sims/tourismusregion/js/maps-custom.json b/App/sims/tourismusregion/js/maps-custom.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/App/sims/tourismusregion/js/maps-custom.json @@ -0,0 +1 @@ +{} diff --git a/App/sims/tourismusregion/js/maps.js b/App/sims/tourismusregion/js/maps.js new file mode 100644 index 0000000..4df901d --- /dev/null +++ b/App/sims/tourismusregion/js/maps.js @@ -0,0 +1,325 @@ +// Die 5 Wegkarten: je ein vorgerendertes 3D-Diorama (assets/maps/*.png, +// 1536×1024) mit handvermessenen Routen-Polylinien (Bild-Pixel), festen +// Bauplatz-Slots und optionalem Lift (Seilbahn zum Berg mit Rundweg oben). +// Zonen: standard · road (Parkplatz/Bus) · lake (Badesteg) · hang (Skilift) +// · berg (Almhütte/Aussicht, erst mit Lift erreichbar). + +export const TILE2PX = 36; // 1 „Feld" Reichweite = 36 Bild-Pixel + +export const MAPS = { + talschleife: { + id: 'talschleife', name: 'Talschleife', icon: '🏞️', + desc: 'Der Klassiker: ein Weg vom Westrand durchs Dorf zum Talausgang – See und Berg locken abseits.', + image: 'assets/maps/talschleife.png', w: 1536, h: 1024, + routes: { + westost: { + entry: 'W', exit: 'SE', + points: [[304, 340], [301, 354], [302, 370], [312, 379], [330, 382], + [346, 390], [359, 398], [347, 383], [344, 364], [353, 350], + [368, 338], [384, 333], [400, 335], [419, 347], [430, 357], + [440, 367], [447, 376], [455, 396], [461, 414], [471, 435], + [477, 455], [477, 473], [485, 488], [502, 502], [515, 516], + [532, 532], [549, 541], [564, 544], [572, 531], [579, 544], + [594, 551], [611, 555], [624, 567], [626, 581], [624, 565], + [610, 554], [595, 550], [577, 554], [563, 564], [557, 576], + [549, 591], [552, 611], [562, 627], [568, 640], [576, 658], + [578, 677], [578, 695], [588, 711], [600, 726], [606, 742], + [616, 754], [626, 766], [640, 773], [652, 784], [666, 786], + [686, 784], [705, 788], [716, 800], [715, 816], [728, 816], + [741, 826], [754, 836], [768, 840], [779, 851], [795, 860], + [814, 865], [829, 874], [836, 886], [838, 889]], + }, + gegenzug: { + entry: 'S', exit: 'E', reverseOf: 'westost', + }, + }, + entryRoutes: { W: ['westost'], N: ['westost'], S: ['gegenzug'] }, + slots: [ + { x: 300, y: 292, zone: 'road' }, + { x: 245, y: 425, zone: 'road' }, + { x: 430, y: 378, zone: 'standard' }, + { x: 588, y: 298, zone: 'standard' }, + { x: 634, y: 372, zone: 'standard' }, + { x: 697, y: 397, zone: 'standard' }, + { x: 485, y: 595, zone: 'standard' }, + { x: 700, y: 713, zone: 'standard' }, + { x: 776, y: 742, zone: 'standard' }, + { x: 357, y: 619, zone: 'standard' }, + { x: 1235, y: 546, zone: 'standard' }, + { x: 545, y: 655, zone: 'standard' }, + { x: 900, y: 788, zone: 'standard' }, + { x: 261, y: 520, zone: 'standard' }, + { x: 885, y: 615, zone: 'lake' }, + { x: 1005, y: 782, zone: 'lake' }, + { x: 987, y: 381, zone: 'hang' }, + { x: 888, y: 218, zone: 'berg' }, + { x: 1049, y: 296, zone: 'berg' }, + ], + lift: { topX: 952, topY: 168, loop: [[1000, 212], [935, 242]] }, + }, + + seerunde: { + id: 'seerunde', name: 'Seerunde', icon: '🏖️', + desc: 'Ein Rundweg um den großen Bergsee – Nord- oder Südufer? Beide Ströme wollen versorgt sein.', + image: 'assets/maps/seerunde.png', w: 1536, h: 1024, + routes: { + nordufer: { + entry: 'W', exit: 'E', + points: [[274, 784], [290, 768], [300, 756], [311, 746], [323, 733], + [336, 718], [348, 706], [359, 695], [368, 686], [378, 672], + [371, 658], [358, 646], [348, 636], [336, 629], [325, 622], + [308, 608], [302, 590], [310, 570], [322, 555], [336, 542], + [350, 532], [368, 523], [384, 519], [396, 509], [407, 499], + [420, 493], [436, 480], [445, 467], [451, 452], [454, 433], + [456, 411], [459, 389], [466, 370], [477, 353], [490, 338], + [510, 326], [522, 322], [535, 318], [550, 315], [566, 313], + [584, 312], [603, 311], [620, 311], [636, 311], [648, 311], + [668, 312], [683, 315], [698, 318], [714, 315], [734, 322], + [748, 328], [760, 332], [780, 338], [792, 340], [804, 343], + [818, 346], [830, 348], [850, 353], [866, 358], [882, 366], + [902, 372], [921, 374], [942, 377], [960, 380], [976, 384], + [991, 391], [1010, 406], [1020, 414], [1028, 424], [1035, 435], + [1040, 446], [1044, 459], [1045, 471], [1046, 484], [1050, 502], + [1063, 514], [1082, 522], [1100, 531], [1118, 543], [1130, 554], + [1144, 555], [1158, 562], [1167, 574], [1170, 588], [1170, 604], + [1163, 618], [1151, 633], [1137, 642], [1116, 652], [1100, 662], + [1086, 670], [1068, 676], [1052, 678], [1032, 684], [1014, 691], + [1000, 697], [977, 700], [965, 701], [942, 702], [930, 705], + [914, 712], [897, 720], [880, 723], [866, 728], [852, 738], + [837, 746], [822, 751], [814, 762], [808, 780], [796, 798], + [780, 813], [763, 821], [744, 824], [728, 831], [702, 837], + [689, 838], [664, 837], [649, 836], [636, 834], [624, 834], + [605, 831], [588, 826], [576, 816], [588, 812], [606, 814], + [588, 812], [572, 802], [563, 793], [563, 808], [550, 807], + [536, 799], [522, 785], [506, 770], [488, 756], [474, 746], + [457, 736], [449, 728], [440, 715], [429, 701], [415, 686], + [401, 678], [384, 679], [369, 690], [360, 699], [348, 710], + [336, 722], [323, 735], [311, 747], [300, 758], [292, 766], + [279, 779], [274, 784]], + }, + suedufer: { + entry: 'S', exit: 'SE', lake: true, + points: [[274, 784], [290, 768], [300, 756], [311, 746], [323, 733], + [336, 718], [349, 706], [360, 696], [370, 688], [387, 680], + [403, 686], [416, 696], [427, 702], [443, 715], [462, 732], + [473, 743], [486, 756], [497, 766], [506, 776], [521, 789], + [534, 796], [552, 805], [568, 808], [588, 812], [604, 815], + [589, 809], [579, 817], [596, 828], [614, 832], [636, 834], + [649, 836], [664, 837], [677, 838], [700, 838], [718, 834], + [735, 826], [750, 822], [770, 818], [788, 807], [797, 798], + [808, 780], [812, 763], [830, 753], [845, 745], [860, 735], + [874, 725], [892, 717], [910, 713], [927, 706], [947, 700], + [959, 699], [973, 699], [991, 698], [1014, 693], [1026, 688], + [1038, 684], [1055, 678], [1070, 676], [1086, 664], [1105, 654], + [1120, 650], [1140, 641], [1157, 626], [1167, 610], [1170, 593], + [1169, 576], [1158, 565], [1144, 561], [1131, 550], [1114, 538], + [1104, 532], [1090, 528], [1079, 522], [1060, 513], [1050, 502], + [1044, 481], [1042, 467], [1039, 450], [1031, 430], [1018, 412], + [1000, 398], [990, 391], [978, 386], [960, 380], [942, 377], + [921, 374], [902, 372], [882, 366], [866, 359], [840, 351], + [827, 348], [806, 345], [785, 340], [766, 334], [748, 327], + [732, 320], [716, 316], [698, 315], [681, 316], [664, 312], + [647, 311], [626, 310], [612, 310], [600, 310], [580, 311], + [562, 312], [545, 315], [534, 318], [518, 325], [503, 334], + [488, 344], [474, 355], [464, 366], [456, 385], [452, 408], + [452, 421], [453, 435], [452, 448], [448, 460], [443, 471], + [436, 481], [420, 494], [402, 502], [388, 512], [372, 522], + [356, 527], [337, 538], [324, 548], [310, 566], [303, 588], + [308, 607], [326, 622], [336, 629], [348, 636], [358, 645], + [368, 655], [381, 669], [368, 687], [357, 697], [347, 708], + [335, 720], [321, 734], [309, 746], [299, 758], [291, 766], + [279, 779], [274, 784]], + }, + }, + entryRoutes: { W: ['nordufer'], N: ['nordufer'], S: ['suedufer'] }, + slots: [ + { x: 255, y: 641, zone: 'road' }, + { x: 330, y: 800, zone: 'road' }, + { x: 395, y: 330, zone: 'standard' }, + { x: 520, y: 285, zone: 'standard' }, + { x: 700, y: 245, zone: 'standard' }, + { x: 966, y: 300, zone: 'standard' }, + { x: 1075, y: 385, zone: 'standard' }, + { x: 1230, y: 651, zone: 'standard' }, + { x: 620, y: 772, zone: 'standard' }, + { x: 900, y: 792, zone: 'standard' }, + { x: 1080, y: 720, zone: 'standard' }, + { x: 598, y: 346, zone: 'lake' }, + { x: 700, y: 655, zone: 'lake' }, + { x: 880, y: 635, zone: 'lake' }, + ], + }, + + serpentinen: { + id: 'serpentinen', name: 'Serpentinen', icon: '⛰️', + desc: 'Über den Berg ins Tal: langer Abstieg in Kehren – die Seilbahn verbindet Dorf und Gipfelterrasse.', + image: 'assets/maps/serpentinen.png', w: 1536, h: 1024, + routes: { + kehren: { + entry: 'W', exit: 'SE', + points: [[355, 229], [357, 244], [362, 268], [367, 280], [379, 299], + [388, 308], [396, 318], [403, 329], [411, 340], [419, 351], + [432, 364], [446, 372], [463, 379], [480, 385], [498, 389], + [515, 392], [532, 394], [546, 396], [564, 401], [584, 406], + [601, 408], [619, 407], [640, 404], [664, 398], [677, 394], + [690, 390], [704, 384], [720, 377], [734, 372], [748, 367], + [761, 364], [774, 361], [786, 360], [798, 358], [819, 360], + [838, 362], [858, 362], [879, 363], [900, 370], [918, 378], + [936, 380], [945, 370], [952, 356], [950, 373], [939, 383], + [923, 385], [904, 375], [920, 378], [936, 378], [926, 393], + [916, 408], [895, 416], [881, 423], [869, 430], [858, 436], + [840, 446], [822, 455], [806, 462], [791, 467], [777, 472], + [757, 484], [747, 491], [732, 506], [724, 520], [716, 532], + [704, 542], [691, 557], [678, 569], [664, 583], [650, 593], + [635, 601], [614, 607], [602, 608], [584, 607], [566, 605], + [550, 604], [536, 604], [515, 608], [499, 617], [494, 630], + [503, 643], [513, 656], [526, 669], [542, 676], [557, 688], + [572, 704], [586, 712], [580, 731], [570, 740], [560, 752], + [549, 770], [544, 787], [548, 802], [561, 819], [558, 833], + [544, 848], [542, 868], [551, 884], [568, 898], [584, 911], + [598, 922], [601, 925]], + }, + talwaerts: { entry: 'S', exit: 'E', reverseOf: 'kehren' }, + }, + entryRoutes: { W: ['kehren'], N: ['kehren'], S: ['talwaerts'] }, + slots: [ + { x: 548, y: 901, zone: 'road' }, + { x: 725, y: 890, zone: 'road' }, + { x: 538, y: 325, zone: 'standard' }, + { x: 665, y: 436, zone: 'standard' }, + { x: 835, y: 428, zone: 'standard' }, + { x: 1006, y: 480, zone: 'standard' }, + { x: 415, y: 646, zone: 'standard' }, + { x: 303, y: 578, zone: 'standard' }, + { x: 695, y: 616, zone: 'standard' }, + { x: 855, y: 636, zone: 'standard' }, + { x: 1090, y: 780, zone: 'standard' }, + { x: 1087, y: 627, zone: 'hang' }, + { x: 845, y: 292, zone: 'berg' }, + { x: 951, y: 312, zone: 'berg' }, + ], + lift: { topX: 898, topY: 292, loop: [[858, 300], [938, 315]] }, + }, + + kreuzung: { + id: 'kreuzung', name: 'Kreuzung', icon: '🛣️', + desc: 'Zwei Täler kreuzen sich am Dorfplatz: vier Ströme, ein Zentrum – wer deckt alle Richtungen ab?', + image: 'assets/maps/kreuzung.png', w: 1536, h: 1024, + routes: { + nordsued: { + entry: 'N', exit: 'SE', + points: [[487, 255], [498, 264], [511, 276], [528, 292], [538, 300], + [546, 308], [556, 316], [565, 325], [575, 333], [585, 342], + [595, 350], [605, 360], [615, 368], [624, 376], [642, 391], + [658, 406], [674, 418], [687, 430], [698, 440], [712, 456], + [716, 477], [712, 494], [705, 510], [695, 528], [682, 546], + [668, 565], [660, 575], [650, 585], [642, 595], [633, 604], + [624, 614], [616, 623], [607, 632], [598, 642], [590, 650], + [580, 660], [572, 668], [563, 677], [547, 694], [532, 710], + [516, 727], [504, 740], [493, 752], [490, 755]], + }, + westost: { + entry: 'W', exit: 'E', + points: [[135, 472], [158, 472], [174, 472], [189, 472], [207, 472], + [228, 472], [248, 472], [270, 472], [290, 472], [312, 472], + [332, 472], [354, 472], [374, 472], [396, 472], [416, 472], + [436, 472], [456, 472], [474, 472], [493, 472], [510, 471], + [527, 471], [543, 471], [559, 471], [575, 470], [591, 470], + [607, 470], [623, 470], [638, 471], [654, 471], [670, 471], + [686, 472], [701, 472], [717, 474], [732, 476], [748, 478], + [763, 481], [779, 484], [795, 488], [811, 492], [828, 495], + [844, 499], [862, 502], [879, 506], [897, 509], [916, 513], + [934, 517], [953, 521], [972, 524], [990, 528], [1010, 532], + [1028, 536], [1047, 539], [1066, 543], [1084, 547], [1103, 551], + [1122, 554], [1140, 558], [1160, 561], [1178, 564], [1197, 568], + [1216, 571], [1234, 574], [1253, 577], [1271, 580], [1289, 582], + [1306, 585], [1324, 587], [1340, 590], [1357, 592], [1373, 593], + [1387, 595], [1400, 596], [1418, 598], [1430, 600]], + }, + }, + entryRoutes: { W: ['westost'], N: ['nordsued'], S: ['nordsued'] }, + slots: [ + { x: 425, y: 285, zone: 'road' }, + { x: 300, y: 694, zone: 'road' }, + { x: 645, y: 218, zone: 'standard' }, + { x: 330, y: 380, zone: 'standard' }, + { x: 545, y: 435, zone: 'standard' }, + { x: 933, y: 546, zone: 'standard' }, + { x: 760, y: 665, zone: 'standard' }, + { x: 610, y: 782, zone: 'standard' }, + { x: 890, y: 795, zone: 'standard' }, + { x: 1140, y: 640, zone: 'standard' }, + { x: 1180, y: 478, zone: 'standard' }, + { x: 1405, y: 465, zone: 'standard' }, + { x: 391, y: 512, zone: 'lake' }, + { x: 310, y: 611, zone: 'lake' }, + ], + }, + + passstrasse: { + id: 'passstrasse', name: 'Passstraße', icon: '🛤️', + desc: 'Ein langer Anstieg zum Pass, kaum Bauplätze: Hier zählt jede Standortentscheidung doppelt.', + image: 'assets/maps/passstrasse.png', w: 1536, h: 1024, + routes: { + bergauf: { + entry: 'W', exit: 'E', + points: [[700, 859], [687, 855], [684, 840], [692, 824], [705, 814], + [707, 798], [697, 789], [686, 774], [670, 758], [656, 748], + [643, 740], [629, 733], [614, 725], [600, 718], [582, 705], + [570, 690], [564, 675], [564, 660], [572, 643], [592, 626], + [603, 619], [624, 606], [638, 599], [649, 592], [669, 582], + [686, 574], [702, 569], [710, 584], [695, 593], [672, 603], + [656, 611], [642, 620], [658, 612], [669, 606], [688, 598], + [710, 590], [723, 585], [743, 578], [757, 571], [766, 559], + [779, 548], [794, 541], [811, 537], [828, 532], [843, 527], + [863, 517], [880, 510], [897, 514], [913, 511], [925, 498], + [928, 479], [927, 460], [922, 444], [910, 432], [888, 418], + [870, 408], [856, 400], [842, 388], [837, 374], [847, 356], + [859, 345], [870, 336], [890, 324], [909, 315], [926, 311], + [937, 304]], + }, + bergab: { entry: 'S', exit: 'SE', reverseOf: 'bergauf' }, + }, + entryRoutes: { W: ['bergauf'], N: ['bergauf'], S: ['bergab'] }, + slots: [ + { x: 562, y: 815, zone: 'road' }, + { x: 760, y: 875, zone: 'road' }, + { x: 300, y: 565, zone: 'standard' }, + { x: 415, y: 660, zone: 'standard' }, + { x: 560, y: 600, zone: 'standard' }, + { x: 850, y: 548, zone: 'standard' }, + { x: 905, y: 400, zone: 'standard' }, + { x: 1016, y: 330, zone: 'standard' }, + ], + }, +}; + +// Editor-Overrides (js/maps-custom.json) über die eingebauten Daten legen: +// routes ersetzt die Punktlisten (entry/exit/lake-Flags bleiben erhalten, +// unbekannte Routen-IDs werden neu angelegt), slots ersetzt die Slot-Liste. +export function applyMapOverrides(overrides) { + if (!overrides) return; + for (const [mid, o] of Object.entries(overrides)) { + const def = MAPS[mid]; + if (!def) continue; + if (o.routes) { + for (const [rk, pts] of Object.entries(o.routes)) { + if (!Array.isArray(pts) || pts.length < 2) continue; + if (def.routes[rk]) { + def.routes[rk] = { ...def.routes[rk], points: pts, reverseOf: undefined }; + } else { + def.routes[rk] = { entry: 'W', exit: 'E', points: pts }; + } + } + // reverseOf-Varianten zeigen ggf. auf ersetzte Routen – bleibt gültig, + // solange die Basisroute existiert (prepMap löst sie auf) + } + if (Array.isArray(o.slots) && o.slots.length) { + def.slots = o.slots.map(s => ({ x: s.x, y: s.y, zone: s.zone || 'standard' })); + } + if (o.lift) def.lift = o.lift; + } +} + +// Zubringer-Reihenfolge im Startbildschirm +export const MAP_ORDER = ['talschleife', 'seerunde', 'serpentinen', 'kreuzung', 'passstrasse']; diff --git a/App/sims/tourismusregion/js/model.js b/App/sims/tourismusregion/js/model.js new file mode 100644 index 0000000..501c8ac --- /dev/null +++ b/App/sims/tourismusregion/js/model.js @@ -0,0 +1,802 @@ +// Simulationskern: Spielzustand + tick(state, dt). Enthält Spawning, +// Bewegung entlang der Routen, Infrastrukturwirkung (§11: Zielgruppen-, +// Saison-, Umwelt-, Kapazitätsfaktor, Bremsen, Einwirkzeit), Ökonomie, +// Bewertung beim Verlassen, Phasen/Saisonen, Ereignisse und Endauswertung. +// Komplett DOM-frei – läuft auch im Headless-Test (tests/model-test.mjs). + +import { + GAME_LEN, PHASE_LEN, SEASON_LEN, SEASONS, SEASON_KEYS, + CONTACT_NORM, MAX_GAIN_FACTOR, MAX_BRAKE, START_BUDGET, START_ENV, + GROUPS, BUILDINGS, WAVES, EVENTS, FEED_TEXTS, PROFILES, GOALS, SYNERGIES, currentAvg, +} from './data.js'; +import { + prepMap, mulberry32, makeRoute, cumLengths, TILE2PX, canPlace as worldCanPlace, +} from './world.js'; + +export { GOALS, currentAvg }; +export const canPlace = worldCanPlace; + +const LIFT_SPEED = 1.4; // Kacheln/s in der Liftfahrt +const REV_SCALE = 0.42; // globale Einnahmen-Skala (Balancing: Geld darf nicht trivial werden) + +export function createState(opts = {}) { + const seed = opts.seed ?? 20260703; + const map = prepMap(opts.mapId || 'talschleife'); + const state = { + map, + slots: map.slots, + playerName: opts.playerName || '', + classCode: opts.classCode || '', + time: 0, phase: 0, season: SEASONS[0], seasonKey: SEASON_KEYS[0], + ended: false, result: null, + budget: START_BUDGET, revenueTotal: 0, opCostTotal: 0, + env: START_ENV, traffic: 0, + fame: 1.0, festUntil: 0, natureBoost: 1, + buildings: [], nextBid: 1, + visitors: [], nextVid: 1, + unlocked: new Set(), + spawnQueue: [], + phaseExitStars: [], + feedPool: [], + pendingCards: [], + firedEvents: new Set(), + stats: { + all: { count: 0, stars: 0 }, + groups: {}, + buildingRevenue: {}, + transports: { foot: 0, bike: 0, bus: 0, car: 0 }, + exits: { E: 0, SE: 0 }, + spawned: 0, skippedNoParking: 0, + }, + fx: [], + heat: new Float32Array(48 * 32), heatCols: 48, heatRows: 32, + history: { t: [], budget: [], rating: [], env: [], visitors: [], satPct: [] }, + lastSatPct: 0, + actionLog: [], + opAccum: 0, sampleAccum: 0, + waveStatus: 'pause', pauseLeft: 22, waveStartTime: 0, + recentStars: [], + rng: mulberry32(seed + 7), + }; + prepareWave(state, 1, { silent: true }); + return state; +} + +// ---------- Haupt-Tick ---------- +export function tick(state, dt) { + if (state.ended || dt <= 0) return; + state.time += dt; + + // ---------- Wellen-Maschine (Tower-Defense-Rhythmus) ---------- + // Baupause → Welle läuft → alle Gäste der Welle abgereist → nächste + // Baupause. „Nächste Welle rufen" startet früher (mit Bonus). + if (state.waveStatus === 'pause') { + state.pauseLeft -= dt; + if (state.pauseLeft <= 0) launchWave(state); + } else if (state.waveStatus === 'running') { + const active = state.spawnQueue.length > 0 + || state.visitors.some(v => v.wave === state.phase); + if (!active) { + if (state.phase >= 8) { finish(state); return; } + prepareWave(state, state.phase + 1); + state.waveStatus = 'pause'; + state.pauseLeft = 16; + } + } + + // ---------- Baustellen: Bau- und Ausbauzeit ---------- + for (const b of state.buildings) { + if (b.construction > 0) { + b.construction -= dt; + if (b.construction <= 0) { + b.construction = 0; + state.pendingCards.push({ kind: 'built', type: b.type, level: b.level }); + } + } else if (b.upgrading) { + b.upgrading.left -= dt; + if (b.upgrading.left <= 0) { + b.level++; + state.env = clampEnv(state.env + (BUILDINGS[b.type].env[b.level - 1] || 0)); + b.upgrading = null; + refreshInfoBoosts(state); + state.pendingCards.push({ kind: 'built', type: b.type, level: b.level }); + } + } + } + + spawnDue(state); + moveAndAffect(state, dt); + + // Betriebskosten alle 10 s (§18.7) + state.opAccum += dt; + while (state.opAccum >= 10) { + state.opAccum -= 10; + let cost = 0; + for (const b of state.buildings) { + if (b.construction > 0) continue; // Baustelle: noch keine Betriebskosten + cost += BUILDINGS[b.type].opCost[b.level - 1]; + } + cost /= 6; // pro Minute → pro 10 s + state.budget -= cost; + state.opCostTotal += cost; + } + + // Verkehr: Parkplätze + aktive Auto-/Busgruppen + let traffic = 0; + for (const b of state.buildings) { + if (b.type === 'parkplatz') traffic += BUILDINGS.parkplatz.traffic[b.level - 1]; + } + for (const v of state.visitors) { + const tr = GROUPS[v.type].transport; + if (tr === 'car') traffic += 1.5; + else if (tr === 'bus') traffic += 2.5; + } + state.traffic = Math.round(traffic); + + // Verlauf (für den Graphen) alle 5 s + state.sampleAccum += dt; + if (state.sampleAccum >= 5) { + state.sampleAccum = 0; + const h = state.history; + h.t.push(state.time); + h.budget.push(Math.round(state.budget)); + h.rating.push(Number(currentAvg(state).toFixed(2))); + h.env.push(Math.round(state.env)); + h.visitors.push(state.visitors.length); + // Live-Erfüllungsgrad: wie „voll" sind die Zufriedenheitsbalken gerade? + // (Ziel wie im Tower Defense: 100 % = Gast komplett glücklich) + if (state.visitors.length) { + const mean = state.visitors.reduce((a, v) => + a + Math.max(0, Math.min(1.1, v.satisfaction / GROUPS[v.type].satTarget)), 0) / state.visitors.length; + state.lastSatPct = Math.round(mean * 100); + } + h.satPct.push(state.lastSatPct || 0); + } + + if (state.time > 2400) finish(state); // Sicherheitsdeckel +} + +// ---------- Wellen-Vorbereitung: Ruhm, Feed, Freischaltungen, Plan ---------- +function prepareWave(state, phase, opts = {}) { + // Besucherfaktor aus den Bewertungen der Vorphase (§8.4) + if (state.phaseExitStars.length) { + const avg = state.phaseExitStars.reduce((a, b) => a + b, 0) / state.phaseExitStars.length; + state.fame = 0.7 + avg / 5; + // Social-Media-Feed der Vorphase + const picks = pickFeed(state); + if (picks.length) { + state.pendingCards.push({ + kind: 'feed', icon: '📱', phase: state.phase, + title: `Bewertungen aus Phase ${state.phase}`, + items: picks, avg, fame: state.fame, + }); + } + } + state.phaseExitStars = []; + state.feedPool = []; + state.phase = phase; + + // Saison hängt an der Welle (2 Wellen je Jahreszeit) + const si = Math.min(3, Math.floor((phase - 1) / 2)); + if (SEASON_KEYS[si] !== state.seasonKey) { + state.seasonKey = SEASON_KEYS[si]; + state.season = SEASONS[si]; + state.pendingCards.push({ + kind: 'season', icon: ['🌸', '☀️', '🍂', '❄️'][si], + title: `Saisonwechsel: ${state.season}`, + text: { + summer: 'Der Sommer ist da! Badesee, Eis, Rad und Wandern sind jetzt besonders gefragt.', + autumn: 'Herbst: Kulinarik, Kultur und Wandern haben Hochsaison – Badespaß ist vorbei.', + winter: 'Winter! Jetzt zählen Lift, Almhütte, Gasthaus und Aussicht. Der See ist zugefroren.', + }[state.seasonKey] || '', + }); + } + + // Ankündigungen/Entscheidungen dieser Welle + for (const ev of EVENTS) { + if (ev.wave === phase && !state.firedEvents.has(ev.id)) { + state.firedEvents.add(ev.id); + state.pendingCards.push({ kind: ev.type, ...ev }); + } + } + + // Freischaltungen + const fresh = Object.entries(BUILDINGS) + .filter(([id, d]) => d.unlockPhase === phase && !state.unlocked.has(id)); + for (const [id] of fresh) state.unlocked.add(id); + + // Welle planen + const wave = WAVES[phase] || {}; + const parkPlus = sumBonus(state, 'parkplatz', 'visitorPlus'); + const busPlus = sumBonus(state, 'bushaltestelle', 'busPlus'); + const hasParking = state.buildings.some(b => b.type === 'parkplatz'); + state.spawnQueue = []; + const waveCounts = []; + let slot = 0; + const t0 = 0; // relativ – launchWave addiert die echte Startzeit + for (const [type, base] of Object.entries(wave)) { + const g = GROUPS[type]; + let mult = state.fame; + if (g.transport === 'car') mult *= 1 + parkPlus; + if (g.transport === 'bus') mult *= 1 + busPlus + parkPlus * 0.5; + let n = Math.max(1, Math.round(base * mult)); + if (g.needsParking && !hasParking) { + const skipped = Math.floor(n / 2); + n -= skipped; + state.stats.skippedNoParking += skipped; + if (skipped > 0) state.feedPool.push({ stars: 1, emoji: g.emoji, text: FEED_TEXTS.noParking, prio: 3 }); + } + waveCounts.push(`${n}× ${GROUPS[type].name}`); + // Konvoi statt Streuung: Die Welle einer Gruppe kommt gebündelt kurz + // hintereinander (alle 4–6 s) und nimmt dieselbe Route – so ist sie + // als zusammengehörende Welle erkennbar. + // Konvois innerhalb der Welle: Gruppe für Gruppe, eng beieinander + const clusterStart = t0 + 1 + slot * (n * 1.2 + 9); + const routePick = state.rng(); + state.spawnQueue.push({ t: Math.max(t0 + 0.5, clusterStart - 1.2), banner: { type, n } }); + for (let i = 0; i < n; i++) { + state.spawnQueue.push({ t: clusterStart + i * (2.4 + state.rng() * 0.8), type, routePick }); + } + slot++; + } + state.spawnQueue.sort((a, b) => a.t - b.t); + + // Wellen-Ankündigung: Wer kommt? (Porträts neuer Gruppen) + neue Bauoptionen + const newcomers = Object.keys(wave).filter(t => GROUPS[t].fromPhase === phase); + let text = `Erwartete Gäste: ${waveCounts.join(' · ')}.`; + for (const t of newcomers) { + const g = GROUPS[t]; + text += `\n\n${g.emoji} ${g.name}: ${g.who}`; + } + if (fresh.length) { + text += `\n\n🔓 Neu baubar: ${fresh.map(([, d]) => `${d.emoji} ${d.name}`).join(' · ')}`; + } + state.pendingCards.push({ + kind: 'phase', icon: '👥', + title: phase === 1 ? 'Phase 1: Die ersten Gäste kommen' : `Phase ${phase}: Neue Welle im Anmarsch`, + text, + }); +} + +// Welle wirklich starten: geplante Spawns auf die aktuelle Zeit schieben +function launchWave(state) { + for (const e of state.spawnQueue) e.t += state.time; + state.waveStatus = 'running'; + state.waveStartTime = state.time; + state.pauseLeft = 0; +} + +function sumBonus(state, type, key) { + let sum = 0; + for (const b of state.buildings) { + if (b.type === type) sum += BUILDINGS[type][key][b.level - 1]; + } + return sum; +} + +// ---------- Spawning ---------- +function spawnDue(state) { + if (state.waveStatus !== 'running') return; + while (state.spawnQueue.length && state.spawnQueue[0].t <= state.time) { + const { type, routePick, banner } = state.spawnQueue.shift(); + if (banner) { + state.pendingCards.push({ kind: 'banner', type: banner.type, n: banner.n }); + continue; + } + if (state.visitors.length >= 120) continue; // Performance-Deckel + const g = GROUPS[type]; + const route = makeRoute(state, g, state.rng, routePick); + const hasParking = state.buildings.some(b => b.type === 'parkplatz'); + // Busgruppen fahren im Bus bis zur Haltestelle und steigen dort aus + let travel = 'walk', busStopS = 0; + if (g.transport === 'bus') { + const stop = state.buildings.find(b => b.type === 'bushaltestelle'); + if (stop) { + let bestD = Infinity; + for (let i = 0; i < route.pts.length; i++) { + const d = Math.hypot(route.pts[i].x - stop.x, route.pts[i].y - stop.y); + if (d < bestD && route.cum[i] > 40) { bestD = d; busStopS = route.cum[i]; } + } + if (bestD < 260 && busStopS < route.cum[route.cum.length - 1] - 60) travel = 'bus'; + } + } + state.visitors.push({ + wave: state.phase, + travel, busStopS, busWait: 0, + id: state.nextVid++, + type, + pts: route.pts, + cum: route.cum, + exitLabel: route.exit, + s: 0, seg: 0, + x: route.pts[0].x, y: route.pts[0].y, + satisfaction: g.needsParking && !hasParking ? -10 : 0, + revFx: {}, + spent: 0, + gained: {}, + hadService: false, + fxNext: {}, + brake: 0, + timeIn: 0, + mode: 'walk', + wobble: state.rng() * 7, + }); + state.stats.spawned++; + state.stats.transports[g.transport]++; + } +} + +// ---------- Bewegung + Infrastrukturwirkung ---------- +// Tower-Defense-Kapazität (§11.7, verschärft): Jedes Gebäude bedient nur so +// viele Gäste, wie es Kapazität hat – die nächstgelegenen zuerst. Wer keinen +// Platz bekommt, geht leer aus (sichtbar: Auslastungsbogen + Bedien-Bögen). +function moveAndAffect(state, dt) { + const bs = state.buildings; + const servedBy = new Array(bs.length); + for (let i = 0; i < bs.length; i++) { + const b = bs[i]; + b.arcs = []; + if (b.construction > 0 || b.upgrading) { + // Baustelle: bedient niemanden + b.inRange = 0; b.util = 0; b.serving = []; + servedBy[i] = new Set(); + continue; + } + const R = range(b); + const near = []; + for (const v of state.visitors) { + if (v.travel === 'bus') continue; // im Bus wird niemand bedient + const d = dist(v, b); + if (d <= R) near.push([d, v]); + } + near.sort((a, b2) => a[0] - b2[0]); + const cap = BUILDINGS[b.type].capacity[b.level - 1]; + b.inRange = near.length; + b.util = cap >= 999 ? 0 : near.length / cap; + const taken = near.slice(0, cap); + servedBy[i] = new Set(taken.map(x => x[1].id)); + b.serving = taken.slice(0, 10).map(x => x[1].id); + } + + const envFBase = 0.6 + state.env / 100; // §11.9 + const fest = state.time < state.festUntil ? 1.15 : 1; + + for (const v of state.visitors) { + const g = GROUPS[v.type]; + v.timeIn += dt; + + // Wirkung aller Gebäude, die diesen Gast gerade bedienen + let brakeProduct = 1; + for (let i = 0; i < bs.length; i++) { + if (!servedBy[i].has(v.id)) continue; + const b = bs[i]; + const def = BUILDINGS[b.type]; + const lvl = b.level - 1; + const tf = targetFactor(def, b.level, v.type); + if (def.service) v.hadService = true; + if (def.slow[lvl] > 0 && tf >= 0.3 && v.mode !== 'lift') { + brakeProduct *= 1 - def.slow[lvl]; + } + if (tf <= 0.05) continue; + + const sf = def.season[state.seasonKey] ?? 1; + const envF = 1 + g.envSens * (envFBase - 1); + const natF = def.natureBoosted ? state.natureBoost : 1; + const elevB = def.elevBonus && b.slotZone === 'berg' ? 1.3 : 1; + const eff = def.sat[lvl] * tf * sf * envF * natF * (b.infoBoost || 1) * (b.synergy || 1) * elevB * fest; + + const maxGain = def.sat[lvl] * tf * sf * MAX_GAIN_FACTOR; + const got = v.gained[b.id] ?? 0; + const add = Math.min((eff / CONTACT_NORM) * dt, Math.max(0, maxGain - got)); + if (add > 0) { + v.satisfaction += add; + v.gained[b.id] = got + add; + // Bogen nur, solange die Bedienung wirklich etwas bringt + if (b.arcs.length < 10 && !b.arcs.includes(v.id)) b.arcs.push(v.id); + } + + // sichtbare Interaktion: Objekt/Erlebnis schwebt vom Gebäude zum Gast – + // beim ersten Kontakt und dann alle paar Sekunden wieder + // Objekt-Flug nur für wirklich passende Zielgruppen (tf ≥ 0.5), + // Produkt je Zielgruppe (itemsBy), Geräusch je Dienstleistung + if (tf >= 0.5 && (v.gained[b.id] ?? 0) >= 2 && state.time >= (v.fxNext[b.id] ?? 0)) { + v.fxNext[b.id] = state.time + 2.2 + state.rng() * 1.2; + const emoji = def.itemsBy?.[v.type] || def.item || def.fx; + if (emoji && state.fx.length < 90) { + const amount = Math.round(v.revFx[b.id] || 0); + if (amount > 0) v.revFx[b.id] = 0; + state.fx.push({ emoji, fx: b.x, fy: b.y, vid: v.id, amount, sound: def.sound || 'pling' }); + } + } + + // Einnahmen als Folge von Zufriedenheit (§11.8, §25) + const baseRev = def.revenue[lvl]; + if (baseRev > 0 && tf > 0.3) { + const satF = Math.min(1.5, 0.5 + Math.max(0, v.satisfaction) / g.satTarget); + const rev = Math.min((baseRev * REV_SCALE * tf * satF / CONTACT_NORM) * dt, g.budget - v.spent); + if (rev > 0) { + state.budget += rev; + state.revenueTotal += rev; + v.spent += rev; + v.revFx[b.id] = (v.revFx[b.id] || 0) + rev; + state.stats.buildingRevenue[b.type] = (state.stats.buildingRevenue[b.type] || 0) + rev; + } + } + } + v.brake = Math.min(MAX_BRAKE, 1 - brakeProduct); + + // Bewegung entlang der Route (Bogenlänge in Bild-Pixeln) + const liftSeg = v.pts[v.seg + 1]?.lift; + v.mode = liftSeg ? 'lift' : 'walk'; + // starke Bremse ⇒ sichtbares Verweilen: Stop-and-Go statt Zeitlupe + v.stopped = false; + if (!liftSeg && v.travel === 'walk' && v.brake > 0.28) { + const cycle = (state.time + v.id * 1.7) % 7; + if (cycle < 2.8) v.stopped = true; + } + let speed = (liftSeg ? LIFT_SPEED : g.speed * (1 - v.brake * 0.55)) * TILE2PX; + if (v.stopped) speed = 0; + if (v.travel === 'bus') { + speed = 2.6 * TILE2PX; // Busfahrt bis zur Haltestelle + if (v.s >= v.busStopS) { v.travel = 'busstop'; v.busWait = 2.6; } + } else if (v.travel === 'busstop') { + speed = 0; // Halt: Fahrgäste steigen aus + v.busWait -= dt; + if (v.busWait <= 0) v.travel = 'walk'; + } + v.s += speed * dt; + + const total = v.cum[v.cum.length - 1]; + if (v.s >= total) { + exitVisitor(state, v); + } else { + while (v.seg < v.pts.length - 2 && v.cum[v.seg + 1] <= v.s) v.seg++; + const a = v.pts[v.seg], b2 = v.pts[v.seg + 1]; + const len = v.cum[v.seg + 1] - v.cum[v.seg] || 1; + const f = (v.s - v.cum[v.seg]) / len; + v.x = a.x + (b2.x - a.x) * f; + v.y = a.y + (b2.y - a.y) * f; + v.headRight = b2.x >= a.x; + } + } + // Besucherstrom-Dichte fürs Heatmap-Raster akkumulieren (§15.7) + const HC = state.heatCols, HR = state.heatRows; + const mw = state.map.w, mh = state.map.h; + for (const v of state.visitors) { + if (v.exited || v.travel === 'bus') continue; + const c = Math.max(0, Math.min(HC - 1, Math.floor(v.x / mw * HC))); + const r = Math.max(0, Math.min(HR - 1, Math.floor(v.y / mh * HR))); + state.heat[r * HC + c] += dt; + } + + state.visitors = state.visitors.filter(v => !v.exited); +} + +function dist(v, b) { + return Math.hypot(v.x - b.x, v.y - b.y); +} +function range(b) { + return BUILDINGS[b.type].range[b.level - 1] * TILE2PX; +} +function targetFactor(def, level, groupId) { + const byLevel = def.targetByLevel?.[groupId]; + if (byLevel) return byLevel[level - 1]; + return def.target[groupId] ?? 0.4; +} + +// ---------- Abreise + Bewertung (§8, §18.5) ---------- +function exitVisitor(state, v) { + v.exited = true; + const g = GROUPS[v.type]; + let sat = v.satisfaction; + const malus = { service: false, traffic: false, env: false, time: false }; + + if (g.needsService && !v.hadService && v.timeIn > 60) { sat -= 10; malus.service = true; } + const tPen = state.traffic * 0.08 * g.trafficSens; + if (tPen > 4) malus.traffic = true; + sat -= tPen; + if (state.env < 45 && g.envSens >= 0.7) malus.env = true; + if (v.timeIn > g.maxTime) { sat *= 0.92; malus.time = true; } + + const ratio = Math.max(0, sat) / g.satTarget; + const stars = Math.max(1, Math.min(5, Math.ceil(ratio * 5))); + + // sichtbares Abschluss-Feedback am Ausgang (wie „Gegner besiegt") + if (state.fx.length < 90) { + state.fx.push({ pop: true, stars, fx: v.x, fy: v.y }); + } + state.recentStars.push(stars); + if (state.recentStars.length > 12) state.recentStars.shift(); + if (stars <= 2) state.lastBadExit = state.time; + + const st = state.stats; + st.all.count++; st.all.stars += stars; + st.groups[v.type] = st.groups[v.type] || { count: 0, stars: 0 }; + st.groups[v.type].count++; + st.groups[v.type].stars += stars; + st.groups[v.type].avg = st.groups[v.type].stars / st.groups[v.type].count; + st.exits[v.exitLabel] = (st.exits[v.exitLabel] || 0) + 1; + state.phaseExitStars.push(stars); + + // Feed-Text erzeugen + let text; + if (malus.traffic && stars <= 3 && state.rng() < 0.5) text = FEED_TEXTS.traffic; + else if (malus.env && stars <= 3 && state.rng() < 0.4) text = FEED_TEXTS.env; + else { + const pool = FEED_TEXTS[stars]; + text = pool[Math.floor(state.rng() * pool.length)] + .replace('{best}', bestBuildingName(state, v)) + .replace('{missing}', g.wish); + } + state.feedPool.push({ stars, emoji: g.emoji, group: g.name, text, prio: stars === 5 || stars <= 2 ? 2 : 1 }); +} + +function bestBuildingName(state, v) { + let best = null, val = 3; + for (const [bid, gain] of Object.entries(v.gained)) { + if (gain > val) { + const b = state.buildings.find(x => x.id === Number(bid)); + if (b) { best = b; val = gain; } + } + } + if (!best) return 'die Landschaft'; + const def = BUILDINGS[best.type]; + return (def.levelNames ? def.levelNames[best.level - 1] : def.name); +} + +function pickFeed(state) { + const sorted = [...state.feedPool].sort((a, b) => (b.prio - a.prio) || (state.rng() - 0.5)); + const out = []; + const seen = new Set(); + for (const f of sorted) { + if (seen.has(f.text)) continue; + seen.add(f.text); + out.push(f); + if (out.length >= 3) break; + } + return out; +} + +// ---------- Bauen / Ausbauen / Abreißen (auf festen Slots) ---------- +export function place(state, type, slotIdx) { + const check = canPlace(state, type, slotIdx); + if (!check.ok) return check; + const def = BUILDINGS[type]; + const slot = state.slots[slotIdx]; + state.budget -= def.cost[0]; + const b = { + id: state.nextBid++, type, level: 1, + x: slot.x, y: slot.y, slotIdx, slotZone: slot.zone, infoBoost: 1, + construction: 5 + def.cost[0] / 50, // Baustellenzeit in Sekunden + upgrading: null, synergy: 1, synergyList: [], + }; + state.buildings.push(b); + slot.building = b; + state.env = clampEnv(state.env + def.env[0]); + state.actionLog.push({ t: state.time, action: 'build', type, level: 1 }); + + if (def.opensBerg) { + state.pendingCards.push({ + kind: 'card', icon: '⛰️', title: 'Der Berg ist erschlossen!', + text: 'Die Seilbahn bringt Gäste auf den Berg – oben warten neue Bauplätze ' + + '(Almhütte, Aussichtspunkt). Wanderer und junge Gruppen lieben das, ' + + 'im Winter läuft der Lift auf Hochtouren.', + }); + } + if (type === 'naturschutz') { + state.natureBoost = 1.15; + // Schutzgebiet sperrt umliegende freie Bauplätze (§10: Zielkonflikt + // Wirtschaft ↔ Schutz). Reichweite in Pixeln. + const R = (def.blocksRadius || 3.2) * TILE2PX; + b.blockedSlots = []; + for (const s of state.slots) { + if (s === slot || s.building || s.zone === 'lake' || s.zone === 'road') continue; + if (Math.hypot(s.x - slot.x, s.y - slot.y) <= R) { + s.blocked = true; + b.blockedSlots.push(s.idx); + } + } + } + refreshInfoBoosts(state); + return { ok: true, building: b }; +} + +export function upgrade(state, b) { + const def = BUILDINGS[b.type]; + if (b.construction > 0 || b.upgrading) return { ok: false, reason: 'Hier wird gerade gebaut.' }; + if (b.level >= def.maxLevel) return { ok: false, reason: 'Bereits voll ausgebaut.' }; + const cost = def.cost[b.level]; + if (state.budget < cost) return { ok: false, reason: `Zu teuer – Ausbau kostet ${cost} €.` }; + state.budget -= cost; + b.upgrading = { left: 4 + cost / 60 }; // Umbauzeit; Stufe zählt erst danach + state.actionLog.push({ t: state.time, action: 'upgrade', type: b.type, level: b.level + 1 }); + return { ok: true, cost, time: Math.round(b.upgrading.left) }; +} + +export function demolish(state, b) { + if (b.type === 'naturschutz') return { ok: false, reason: 'Ein Schutzgebiet wird nicht einfach wieder aufgehoben.' }; + const def = BUILDINGS[b.type]; + let invested = 0; + for (let i = 0; i < b.level; i++) invested += def.cost[i]; + const refund = Math.round(invested * 0.3); + state.budget += refund; + state.buildings = state.buildings.filter(x => x !== b); + const slot = state.slots[b.slotIdx]; + if (slot) slot.building = null; + // gesperrte Bauplätze eines Schutzgebiets wieder freigeben + if (b.blockedSlots) for (const i of b.blockedSlots) { if (state.slots[i]) state.slots[i].blocked = false; } + state.actionLog.push({ t: state.time, action: 'demolish', type: b.type }); + if (def.opensBerg) { + // Wer gerade am Berg unterwegs ist, steigt um: Rest des Wegs zu Fuß + for (const v of state.visitors) { + if (v.pts.some(p => p.lift)) { + for (const p of v.pts) delete p.lift; + } + } + } + refreshInfoBoosts(state); + return { ok: true, refund, name: def.name }; +} + +// Tourismusinfo-Multiplikator (§10) + Synergien (§9.2) je Gebäude neu berechnen. +// Wird nach jedem Bau/Ausbau/Abriss aufgerufen. +function refreshInfoBoosts(state) { + const infos = state.buildings.filter(b => b.type === 'tourismusinfo'); + for (const b of state.buildings) { + let boost = 1; + for (const inf of infos) { + if (inf === b) continue; + const r = BUILDINGS.tourismusinfo.range[inf.level - 1] * TILE2PX; + if (Math.hypot(inf.x - b.x, inf.y - b.y) <= r) { + boost += BUILDINGS.tourismusinfo.boost[inf.level - 1]; + } + } + b.infoBoost = boost; + b.synergy = 1; + b.synergyList = []; + } + // Synergie-Paare: beide Partner bekommen den Bonus, wenn in Reichweite + for (const rule of SYNERGIES) { + for (const x of state.buildings) { + if (x.type !== rule.a) continue; + for (const y of state.buildings) { + if (y.type !== rule.b) continue; + if (Math.hypot(x.x - y.x, x.y - y.y) <= rule.radius) { + x.synergy += rule.bonus; x.synergyList.push(rule.why); + y.synergy += rule.bonus; y.synergyList.push(rule.why); + } + } + } + } +} + +function clampEnv(v) { + return Math.max(5, Math.min(100, v)); +} + +// ---------- „Nächste Welle" (Tower-Defense-Klassiker) ---------- +// Ruft die nächste Phase sofort – Bonus fürs Risiko, restliche Spawns der +// laufenden Phase entfallen. In Phase 8 nicht mehr möglich. +export function callNextWave(state) { + if (state.ended) return { ok: false, reason: 'Die Partie ist vorbei.' }; + if (state.waveStatus === 'pause') { + // Baupause überspringen: kleiner Bonus für die gesparte Zeit + const bonus = Math.round(Math.max(0, state.pauseLeft) * 2) + 10; + state.budget += bonus; + launchWave(state); + state.actionLog.push({ t: state.time, action: 'next_wave', bonus }); + return { ok: true, bonus }; + } + if (state.phase >= 8) return { ok: false, reason: 'Die letzte Welle läuft bereits.' }; + // Welle früh rufen: Risiko-Bonus je noch aktivem Gast + const remaining = state.visitors.filter(v => v.wave === state.phase).length; + const bonus = 15 + remaining * 4; + state.budget += bonus; + prepareWave(state, state.phase + 1); + launchWave(state); + state.actionLog.push({ t: state.time, action: 'next_wave', bonus, early: true }); + return { ok: true, bonus }; +} + +// ---------- Endauswertung (§12, §21) ---------- +export function finish(state) { + if (state.ended) return; + state.ended = true; + const st = state.stats; + const avgStars = st.all.count ? st.all.stars / st.all.count : 0; + + const nonCar = st.transports.foot + st.transports.bike + st.transports.bus; + const total = Math.max(1, st.spawned); + let lenkung = (nonCar / total) * 100; + if (state.buildings.some(b => b.type === 'bushaltestelle')) lenkung = Math.min(100, lenkung + 10); + + const scores = { + rating: (avgStars / 5) * 100, + revenue: Math.min(100, (state.revenueTotal / 2500) * 100), + sustain: state.env, + lenkung, + finance: state.budget >= 0 ? 100 : Math.max(0, 100 * (1 + state.budget / 1000)), + }; + const score = Math.round( + scores.rating * 0.35 + scores.revenue * 0.25 + scores.sustain * 0.20 + + scores.lenkung * 0.10 + scores.finance * 0.10, + ); + + state.result = { + avgStars, score, scores, + revenue: Math.round(state.revenueTotal), + env: Math.round(state.env), + budget: Math.round(state.budget), + visitors: st.spawned, + rated: st.all.count, + goals: GOALS.map(g => ({ label: g.label, done: g.check(state) })), + }; + state.result.profile = PROFILES.find(p => p.check(state)); + state.result.feedback = buildFeedback(state); + state.pendingCards.push({ kind: 'end' }); +} + +function buildFeedback(state) { + const st = state.stats; + const lines = []; + + // stärkste und schwächste Gruppe + let best = null, worst = null; + for (const [id, g] of Object.entries(st.groups)) { + if (g.count < 3) continue; + if (!best || g.avg > best.g.avg) best = { id, g }; + if (!worst || g.avg < worst.g.avg) worst = { id, g }; + } + if (best && best.g.avg >= 3) { + lines.push(`Besonders wohl fühlten sich ${plural(best.id)} (Ø ${best.g.avg.toFixed(1)} Sterne) – ` + + 'dein Angebot passte zu ihren Bedürfnissen.'); + } else if (best) { + lines.push('Keine Besuchergruppe wurde richtig glücklich – die Region bot zu wenig passende ' + + 'Angebote entlang der Wege.'); + } + + // Wirtschaft + const topRev = Object.entries(st.buildingRevenue).sort((a, b) => b[1] - a[1])[0]; + if (topRev) { + lines.push(`Wirtschaftlich trug ${BUILDINGS[topRev[0]].name} am meisten zur regionalen ` + + `Wertschöpfung bei (${Math.round(topRev[1])} € von ${Math.round(state.revenueTotal)} €).`); + } + if (state.budget < 0) { + lines.push('Das Budget rutschte ins Minus – Betriebskosten laufen weiter, auch wenn keine Gäste da sind. ' + + 'Teure Infrastruktur braucht Zeit, bis sie sich rechnet (Amortisation).'); + } + + // Umwelt + if (state.env >= 65) { + lines.push('Der Umweltwert blieb hoch. Das zahlt sich doppelt aus: Naturgruppen bewerten besser, ' + + 'und die Landschaft bleibt als wichtigster Standortfaktor erhalten.'); + } else if (state.env < 45) { + lines.push('Der Umweltwert ist stark gesunken – Parkflächen, Lift und Ausbau haben Spuren hinterlassen. ' + + 'Naturnahe Gäste haben das in ihren Bewertungen deutlich gemacht.'); + } + + // Verkehr / Mobilität + if (state.stats.skippedNoParking > 2) { + lines.push(`${state.stats.skippedNoParking} Busgruppen fanden keinen Parkplatz und fuhren weiter – ` + + 'Erreichbarkeit entscheidet, wer überhaupt Gast werden kann.'); + } + if (state.traffic > 30) { + lines.push('Der Verkehr im Ort wurde zur Belastung – empfindliche Gäste (Senioren, Familien) ' + + 'bewerteten schlechter. Bushaltestelle und Besucherlenkung könnten helfen.'); + } + + // Verbesserungstipp + if (worst && worst.g.avg < 3) { + lines.push(`Am schlechtesten schnitten ${plural(worst.id)} ab (Ø ${worst.g.avg.toFixed(1)} Sterne). ` + + `Ihnen fehlte: ${GROUPS[worst.id].wish}.`); + } + return lines; +} + +function plural(id) { + return { + spaziergaenger: 'Spaziergänger', wanderer: 'Wanderer', familie: 'Familien', + radfahrer: 'Radfahrer', senioren: 'Seniorengruppen', schulklasse: 'Schulklassen', + busgruppe: 'Busreisegruppen', junge_gruppe: 'junge Reisegruppen', luxuspaar: 'wohlhabende Paare', + }[id] || id; +} diff --git a/App/sims/tourismusregion/js/music.js b/App/sims/tourismusregion/js/music.js new file mode 100644 index 0000000..343f64c --- /dev/null +++ b/App/sims/tourismusregion/js/music.js @@ -0,0 +1,79 @@ +// Hintergrundmusik-Player: 15 Titel aus assets/music/ (neu betitelt), +// gesteuert über das Panel rechts oben (⏮ ⏯ ⏭, Lautstärke, Titel-Auswahl). +// Läuft über ein einzelnes Audio-Element (Streaming statt Volldecodierung); +// die saisonale Ambience (audio.js) pausiert, solange Musik spielt. + +import { audio } from './audio.js'; + +export const TRACKS = [ + { file: 'pistenglueck.mp3', title: 'Pistenglück' }, + { file: 'alpenkindheit.mp3', title: 'Als ich klein war in den Bergen' }, + { file: 'alpenkindheit-abend.mp3', title: 'Alpenkindheit (Abendversion)' }, + { file: 'auf-skiern.mp3', title: 'Auf Skiern durchs Tal' }, + { file: 'bergrock.mp3', title: 'Bergrock' }, + { file: 'gletscherklang.mp3', title: 'Gletscherklang' }, + { file: 'winterpromenade.mp3', title: 'Winterpromenade' }, + { file: 'almgluehen.mp3', title: 'Almglühen' }, + { file: 'seeblick.mp3', title: 'Seeblick im Schnee' }, + { file: 'talnebel.mp3', title: 'Talnebel' }, + { file: 'schneegestoeber.mp3', title: 'Schneegestöber' }, + { file: 'bergfrieden.mp3', title: 'Bergfrieden' }, + { file: 'kaminabend.mp3', title: 'Kaminabend' }, + { file: 'erste-flocken.mp3', title: 'Erste Flocken' }, + { file: 'frostfunken.mp3', title: 'Frostfunken (Jingle)' }, +]; + +export const music = { + el: null, + idx: 0, + playing: false, + onChange: null, // UI-Callback (Titel/Status aktualisieren) + + _ensure() { + if (this.el) return; + this.el = new Audio(); + this.el.volume = 0.28; + this.el.addEventListener('ended', () => this.next()); + this.el.addEventListener('error', () => { + // Datei fehlt/kaputt → einfach weiter zum nächsten Titel + if (this.playing) this.next(); + }); + }, + + play(i = this.idx) { + this._ensure(); + this.idx = ((i % TRACKS.length) + TRACKS.length) % TRACKS.length; + this.el.src = `assets/music/${TRACKS[this.idx].file}`; + this.el.play().catch(() => { this.playing = false; this.onChange?.(); }); + this.playing = true; + audio._stopAmbience?.(); // Vogelgezwitscher pausiert, solange Musik läuft + this.onChange?.(); + }, + + pause() { + this.el?.pause(); + this.playing = false; + if (audio.musicOn) audio.startMusic(); // Ambience wieder aufnehmen + this.onChange?.(); + }, + + toggle() { + if (this.playing) this.pause(); + else this.play(); + }, + + next() { this.play(this.idx + 1); }, + prev() { this.play(this.idx - 1); }, + + setMuted(m) { + this._ensure(); + this.el.muted = m; + }, + + setVolume(v) { + this._ensure(); + this.el.volume = Math.max(0, Math.min(1, v)); + }, + + current() { return TRACKS[this.idx]; }, +}; diff --git a/App/sims/tourismusregion/js/scene.js b/App/sims/tourismusregion/js/scene.js new file mode 100644 index 0000000..b072452 --- /dev/null +++ b/App/sims/tourismusregion/js/scene.js @@ -0,0 +1,966 @@ +// Diorama-Renderer: vorgerendertes 3D-Kartenbild (assets/maps/*) als Welt, +// darüber die dynamische Ebene – Bauplatz-Slots, KI-Gebäudesprites (mit +// Deluxe-Variante ab Stufe 4), KI-Besuchergruppen, Seilbahn, Bedien-Bögen, +// Auslastung, schwebende Objekte, Saisontönung, Schneefall/Herbstlaub. +// Ersetzt den früheren Iso-Kachel-Renderer komplett. + +import { BUILDINGS, GROUPS } from './data.js'; +import { canPlace, TILE2PX } from './world.js'; +import { audio } from './audio.js'; + +// Zeichenbreite der Gebäude-Sprites in Welt-Pixeln (Bildkoordinaten) +const BUILDING_W = { + wirtshaus: 62, cafe: 52, marktstand: 48, aussicht: 50, spielplatz: 58, + wc: 40, badesteg: 60, eisdiele: 52, radstation: 46, tourismusinfo: 44, + naturschutz: 52, museum: 64, parkplatz: 66, bushaltestelle: 48, + souvenir: 50, skilift: 56, almhuette: 56, wellness: 66, +}; +const GROUP_W = { + spaziergaenger: 46, wanderer: 48, familie: 56, senioren: 52, schulklasse: 62, + busgruppe: 64, junge_gruppe: 56, luxuspaar: 46, +}; + +// Kleidung der Walker: Jeans-/Khaki-Hosen, Oberteile bunt gemischt +// (deterministisch je Gast + Figur, damit nichts flackert) +const PANTS_COLORS = ['#3e5a74', '#54627a', '#8a7d5a', '#4a4f57', '#6b5a45', '#46586b']; +const TOP_COLORS = ['#c9531f', '#2f6ea0', '#3f8f6b', '#a34a8e', '#e8892b', + '#8c6239', '#5b6ee1', '#b0356b', '#4a7f56', '#c94f4f', '#6d8a3a', '#996fb8']; + +// Gruppen als Walker-Kompositionen: dx = entlang der Gehrichtung, dy = Tiefe +const WALK_GROUPS = { + spaziergaenger: [{ dx: -8, dy: 0 }, { dx: 7, dy: 3, shade: 18 }], + wanderer: [{ dx: -9, dy: 0, prop: 'pole', pack: true }, { dx: 8, dy: 3, shade: 16, prop: 'pole', pack: true }], + familie: [{ dx: -13, dy: 0 }, { dx: 1, dy: 3, shade: 16 }, { dx: 11, dy: 1, kid: true }, { dx: -3, dy: -3, kid: true, shade: 30 }], + senioren: [{ dx: -10, dy: 0, prop: 'cane', lean: 0.06 }, { dx: 3, dy: 3, shade: 14, prop: 'hat' }, { dx: 13, dy: -1, shade: 26 }], + schulklasse: [{ dx: -17, dy: 2 }, { dx: -5, dy: -2, kid: true }, { dx: 3, dy: 3, kid: true, shade: 20 }, { dx: 11, dy: -1, kid: true, shade: 35 }, { dx: 19, dy: 2, kid: true, shade: 50 }], + busgruppe: [{ dx: -15, dy: 0, prop: 'umbrella' }, { dx: -3, dy: 3, shade: 14, prop: 'hat' }, { dx: 5, dy: -2, shade: 26 }, { dx: 14, dy: 2, shade: 38 }, { dx: 22, dy: -1, shade: 50 }], + junge_gruppe: [{ dx: -12, dy: 0 }, { dx: -2, dy: 3, shade: 18 }, { dx: 8, dy: -2, shade: 34 }, { dx: 17, dy: 2, shade: 50 }], + luxuspaar: [{ dx: -7, dy: 0, prop: 'hat', lean: -0.04 }, { dx: 7, dy: 3, shade: 16, prop: 'bag' }], +}; + +const SEASON_TINT = { + spring: null, + summer: 'rgba(255,214,110,0.06)', + autumn: 'rgba(205,145,55,0.12)', + winter: 'rgba(228,238,248,0.42)', +}; + +export class SceneRenderer { + constructor(canvas, state, callbacks) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.state = state; + this.cb = callbacks; // {onTap(hit)} + this.cam = { x: 0, y: 0, scale: 1 }; + this.pointers = new Map(); + this.dragMoved = 0; + this.pinchDist = 0; + this.hover = null; + this.buildType = null; + this.selected = null; + this.reduceAnim = false; + this.debug = new URLSearchParams(location.search).has('debug'); + this.time = 0; + this.simTime = 0; + this.flakes = []; + this.leaves = []; + this.floats = []; + this.fxAnims = []; + this.vel = { x: 0, y: 0 }; + this.zoomTarget = null; + + // Kartenbild + Sprites laden (fehlt eines → einfacher Fallback) + this.mapImg = new Image(); + this.mapImg.src = state.map.image; + this.sprites = {}; + const load = (key, src) => { + const img = new Image(); + img.onload = () => { this.sprites[key] = img; }; + img.src = src; + }; + for (const id of Object.keys(BUILDINGS)) { + load(id, `assets/sprites/${id}.png`); + load(`${id}_2`, `assets/sprites/${id}_2.png`); + } + for (const id of Object.keys(GROUP_W)) { + load(`g_${id}`, `assets/sprites/groups/${id}.png`); + for (let f = 0; f < 4; f++) load(`g_${id}_w${f}`, `assets/sprites/groups/${id}_w${f}.png`); + } + load('g_radler', 'assets/sprites/radler.png'); + load('slot', 'assets/sprites/slot.png'); + load('bus', 'assets/sprites/bus.png'); + load('baustelle', 'assets/sprites/baustelle.png'); + + this._bindInput(); + this.resize(); + this.resetCamera(); + } + + addFloat(text, color, x, y) { + this.floats.push({ text, color, x, y, t0: this.simTime }); + if (this.floats.length > 14) this.floats.shift(); + } + + resize() { + const dpr = window.devicePixelRatio || 1; + this.dpr = dpr; + this.canvas.width = innerWidth * dpr; + this.canvas.height = innerHeight * dpr; + } + + resetCamera() { + const m = this.state.map; + const fit = Math.min(innerWidth / m.w, innerHeight / m.h); + this.cam.scale = fit * 1.06; + this.cam.x = (innerWidth - m.w * this.cam.scale) / 2; + this.cam.y = (innerHeight - m.h * this.cam.scale) / 2; + this.zoomTarget = null; + this.vel = { x: 0, y: 0 }; + } + + // kleinster Zoom: Karte füllt den Bildschirm (nicht weiter herauszoomen) + _minScale() { + const m = this.state.map; + return Math.max(innerWidth / m.w, innerHeight / m.h) * 0.98; + } + + worldToScreen(x, y) { + return { x: x * this.cam.scale + this.cam.x, y: y * this.cam.scale + this.cam.y }; + } + + screenToWorld(sx, sy) { + return { x: (sx - this.cam.x) / this.cam.scale, y: (sy - this.cam.y) / this.cam.scale }; + } + + setBuildType(type) { this.buildType = type; } + + // ---------- Eingabe (Pan / Pinch / Tap) ---------- + _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, y: 0 }; + 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); + this.hover = this.screenToWorld(e.clientX, e.clientY); + if (!p) return; + const dx = e.clientX - p.x, 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, 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) this._zoomAround((a.x + b.x) / 2, (a.y + b.y) / 2, d / this.pinchDist); + this.pinchDist = d; + } + }); + cv.addEventListener('pointerup', e => { + const wasTap = this.dragMoved < 10 && this.pointers.size === 1; + this.pointers.delete(e.pointerId); + this.pinchDist = 0; + if (wasTap) { + this.vel = { x: 0, y: 0 }; + const w = this.screenToWorld(e.clientX, e.clientY); + this.cb.onTap(this._hitTest(w.x, w.y)); + if (this.debug) { + // Mini-Pfad-Editor: jeder Tap sammelt einen Punkt; Konsole zeigt + // das fertige points-Array für js/maps.js. Reset: __ttTrace = [] + window.__ttTrace = window.__ttTrace || []; + window.__ttTrace.push([Math.round(w.x), Math.round(w.y)]); + console.log('points:', JSON.stringify(window.__ttTrace)); + } + } + }); + cv.addEventListener('pointercancel', e => this.pointers.delete(e.pointerId)); + cv.addEventListener('wheel', e => { + e.preventDefault(); + const cur = this.zoomTarget?.scale ?? this.cam.scale; + this.zoomTarget = { + scale: Math.min(3, 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(3, 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; + } + + _hitTest(x, y) { + // Gebäude/Slots zuerst, dann Besucher + let bestSlot = null, bestD = 48; + for (const slot of this.state.slots) { + const d = Math.hypot(slot.x - x, slot.y - y); + if (d < bestD) { bestD = d; bestSlot = slot; } + } + if (bestSlot) { + return bestSlot.building + ? { kind: 'building', building: bestSlot.building, slot: bestSlot } + : { kind: 'slot', slot: bestSlot }; + } + let bestV = null, bestVD = 46; + for (const v of this.state.visitors) { + const d = Math.hypot(v.x - x, v.y - y); + if (d < bestVD) { bestVD = d; bestV = v; } + } + if (bestV) return { kind: 'visitor', visitor: bestV }; + return { kind: 'none', x, y }; + } + + // ---------- Zeichnen ---------- + draw(now, dt, speed = 0) { + this.time = now; + const simDt = dt * speed; + this.simTime += simDt; + + 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; + } + + const { ctx, cam, dpr } = this; + const m = this.state.map; + const season = this.state.seasonKey; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + // weicher Studiohintergrund passend zum Diorama + const grad = ctx.createLinearGradient(0, 0, 0, innerHeight); + if (season === 'winter') { grad.addColorStop(0, '#dbe4ec'); grad.addColorStop(1, '#efeeea'); } + else { grad.addColorStop(0, '#eee3cd'); grad.addColorStop(1, '#e7dcc4'); } + ctx.fillStyle = grad; + ctx.fillRect(0, 0, innerWidth, innerHeight); + + ctx.save(); + ctx.translate(cam.x, cam.y); + ctx.scale(cam.scale, cam.scale); + + if (this.mapImg.complete && this.mapImg.naturalWidth) { + ctx.drawImage(this.mapImg, 0, 0, m.w, m.h); + } else { + ctx.fillStyle = '#a9cb8d'; + ctx.fillRect(0, 0, m.w, m.h); + } + // Saisontönung über dem Artwork + const tint = SEASON_TINT[season]; + if (tint) { + ctx.fillStyle = tint; + ctx.fillRect(0, 0, m.w, m.h); + } + + this._drawSlots(); + this._drawLift(); + if (!this.reduceAnim) this._drawServiceArcs(); + // Tiefensortierung: Gebäude UND Besucher gemeinsam nach Fußpunkt – + // wer weiter hinten (kleineres y) steht, wird zuerst gezeichnet + const depth = [ + ...this.state.buildings.map(b => ({ y: b.y + 10, draw: () => this._drawBuilding(b) })), + ...this.state.visitors.map(v => ({ y: v.y + (v.mode === 'lift' ? 400 : 4), draw: () => this._drawVisitor(v, season === 'winter') })), + ].sort((a, b) => a.y - b.y); + for (const d of depth) d.draw(); + this._drawFx(simDt); + this._drawFloats(); + this._drawRangePreview(); + if (this.debug) this._drawDebug(); + ctx.restore(); + + if (!this.reduceAnim && dt > 0) { + if (season === 'winter') this._particles(dt, 'snow'); + if (season === 'autumn') this._particles(dt, 'leaf'); + } + } + + // ---------- Slots ---------- + _drawSlots() { + const ctx = this.ctx; + const zoneHint = { road: '🅿️', lake: '🏖️', hang: '🚡', berg: '⛰️' }; + // Bauplatz-Plattform (PNG mit Alpha) unter JEDEM Slot – Gebäude stehen + // sichtbar auf ihrem Platz + const plate = this.sprites.slot; + if (plate) { + for (const slot of this.state.slots) { + if (slot.blocked && !slot.building) continue; // Schutzgebiet: keine Plattform + const w = 74; + const h = w * (plate.naturalHeight / plate.naturalWidth); + ctx.drawImage(plate, slot.x - w / 2, slot.y - h / 2, w, h); + } + } + for (const slot of this.state.slots) { + if (slot.building) continue; + // gesperrter Platz (Naturschutz): kleine Wildpflanzen statt Bauplatz + if (slot.blocked) { + ctx.font = '15px sans-serif'; + ctx.textAlign = 'center'; + ctx.globalAlpha = 0.9; + ctx.fillText('🌿', slot.x - 8, slot.y + 4); + ctx.fillText('🌸', slot.x + 9, slot.y + 8); + ctx.globalAlpha = 1; + continue; + } + const building = this.buildType && this.buildType !== 'demolish'; + let ring = 'rgba(253,251,244,0.65)', fill = 'rgba(47,62,70,0.10)'; + if (building) { + const ok = canPlace(this.state, this.buildType, slot.idx).ok; + ring = ok ? 'rgba(79,160,110,0.95)' : 'rgba(150,150,150,0.5)'; + fill = ok ? 'rgba(123,180,110,0.28)' : 'rgba(120,120,120,0.12)'; + } + const pulse = building ? 1 + Math.sin(this.simTime / 240) * 0.06 : 1; + ctx.beginPath(); + ctx.ellipse(slot.x, slot.y + 5, 30 * pulse, 18 * pulse, 0, 0, 7); + ctx.fillStyle = fill; + ctx.fill(); + ctx.setLineDash([7, 6]); + ctx.lineDashOffset = -((this.simTime / 120) % 13); + ctx.strokeStyle = ring; + ctx.lineWidth = 2.2; + ctx.stroke(); + ctx.setLineDash([]); + ctx.lineWidth = 1; + if (zoneHint[slot.zone]) { + ctx.font = '17px sans-serif'; + ctx.textAlign = 'center'; + ctx.globalAlpha = 0.85; + ctx.fillText(zoneHint[slot.zone], slot.x, slot.y + 12); + ctx.globalAlpha = 1; + } + } + } + + // ---------- Seilbahn ---------- + _drawLift() { + const lift = this.state.buildings.find(b => b.type === 'skilift'); + const m = this.state.map; + if (!lift || !m.lift) return; + const ctx = this.ctx; + const a = { x: lift.x, y: lift.y - 28 }; + const b = { x: m.lift.topX, y: m.lift.topY - 16 }; + // Stützen + ctx.strokeStyle = '#5a5f63'; + ctx.lineWidth = 4; + for (let f = 0.25; f < 1; f += 0.25) { + const px = a.x + (b.x - a.x) * f; + const py = a.y + (b.y - a.y) * f; + ctx.beginPath(); + ctx.moveTo(px, py + 26); ctx.lineTo(px, py); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(px - 8, py); ctx.lineTo(px + 8, py); + ctx.stroke(); + } + // Seil + Bergstation + ctx.lineWidth = 2.5; + ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); + ctx.fillStyle = '#5a5f63'; + ctx.fillRect(b.x - 16, b.y - 6, 32, 18); + ctx.lineWidth = 1; + // leere Gondeln pendeln + for (let i = 0; i < 4; i++) { + let f = ((this.simTime / 26000) + i / 4) % 1; + if (f > 0.5) f = 1 - f; + f *= 2; + this._gondel(a.x + (b.x - a.x) * f, a.y + (b.y - a.y) * f); + } + } + + _gondel(gx, gy, riderColor = null) { + const ctx = this.ctx; + ctx.strokeStyle = '#5a5f63'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(gx, gy); ctx.lineTo(gx, gy + 9); ctx.stroke(); + ctx.lineWidth = 1; + ctx.fillStyle = riderColor ? '#e8892b' : '#d9cfb4'; + ctx.strokeStyle = '#5a5f63'; + ctx.beginPath(); + ctx.roundRect(gx - 8, gy + 9, 16, 13, 3); + ctx.fill(); + ctx.stroke(); + if (riderColor) { + ctx.fillStyle = '#f0d4b8'; + ctx.beginPath(); ctx.arc(gx, gy + 14, 3, 0, 7); ctx.fill(); + } + } + + // ---------- Bedien-Bögen (nur solange die Bedienung wirklich wirkt) ---------- + _drawServiceArcs() { + const ctx = this.ctx; + const byId = new Map(this.state.visitors.map(v => [v.id, v])); + for (const b of this.state.buildings) { + if (!b.arcs?.length) continue; + for (const vid of b.arcs) { + const v = byId.get(vid); + if (!v || v.mode === 'lift') continue; + const mx = (b.x + v.x) / 2; + const my = Math.min(b.y, v.y) - 40 - Math.hypot(v.x - b.x, v.y - b.y) * 0.08; + ctx.setLineDash([]); + ctx.strokeStyle = 'rgba(47,62,70,0.28)'; + ctx.lineWidth = 4; + ctx.beginPath(); + ctx.moveTo(b.x, b.y - 40); + ctx.quadraticCurveTo(mx, my, v.x, v.y - 24); + ctx.stroke(); + ctx.setLineDash([7, 9]); + ctx.lineDashOffset = -((this.simTime / 70) % 16); + ctx.strokeStyle = 'rgba(232,137,43,0.8)'; + ctx.lineWidth = 2.2; + ctx.stroke(); + ctx.setLineDash([]); + ctx.fillStyle = 'rgba(232,137,43,0.85)'; + ctx.beginPath(); + ctx.arc(v.x, v.y - 24, 2.6, 0, 7); + ctx.fill(); + } + } + ctx.lineWidth = 1; + } + + // ---------- Gebäude ---------- + _drawBuilding(b) { + const ctx = this.ctx; + { + const def = BUILDINGS[b.type]; + const isNew = b.construction > 0; + const busy = isNew || b.upgrading; + const deluxe = b.level >= 4 && this.sprites[`${b.type}_2`]; + const img = deluxe || this.sprites[b.type]; + const w = (BUILDING_W[b.type] || 52) * (1 + (b.level - 1) * 0.05); + this._shadow(b.x, b.y + 6, w * 0.46, w * 0.17); + const site = this.sprites.baustelle; + const drawSprite = (sp, ww) => { + const h = ww * (sp.naturalHeight / sp.naturalWidth); + ctx.drawImage(sp, b.x - ww / 2, b.y + 14 - h, ww, h); + }; + if (isNew && site) { + // Neubau: nur Baustelle (Gerüst), noch kein fertiges Gebäude + drawSprite(site, w * 1.05); + } else if (img) { + // fertiges Gebäude (beim Ausbau zusätzlich Gerüst darüber) + drawSprite(img, w); + if (b.upgrading && site) { ctx.globalAlpha = 0.92; drawSprite(site, w * 1.08); ctx.globalAlpha = 1; } + } else { + ctx.font = `${Math.round(w * 0.5)}px sans-serif`; + ctx.textAlign = 'center'; + ctx.fillText(isNew ? '🏗️' : def.emoji, b.x, b.y); + } + // Fortschrittsbalken über der Baustelle + if (busy) { + const total = isNew ? (5 + def.cost[0] / 50) : (4 + def.cost[b.level] / 60); + const left = isNew ? b.construction : b.upgrading.left; + const p = Math.max(0, Math.min(1, 1 - left / total)); + const by = b.y + 14 - w * (site ? 1.02 : 0.9) - 8; + ctx.fillStyle = 'rgba(47,62,70,0.45)'; + ctx.fillRect(b.x - 18, by, 36, 6); + ctx.fillStyle = '#e8892b'; + ctx.fillRect(b.x - 18, by, 36 * p, 6); + ctx.strokeStyle = 'rgba(253,251,244,0.8)'; + ctx.lineWidth = 1; + ctx.strokeRect(b.x - 18, by, 36, 6); + ctx.font = '9px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillStyle = '#2f3e46'; + ctx.fillText(isNew ? 'Bau' : 'Ausbau', b.x, by - 3); + } + // Ausbaustufen-Pins + if (b.level > 1 && !busy) { + for (let i = 0; i < b.level; i++) { + ctx.fillStyle = '#e8892b'; + ctx.beginPath(); + ctx.arc(b.x - 14 + i * 7.5, b.y + 20, 2.8, 0, 7); + ctx.fill(); + ctx.strokeStyle = 'rgba(253,251,244,0.9)'; + ctx.stroke(); + } + } + this._utilArc(b); + } + } + + _utilArc(b) { + if (!b.util || b.util <= 0) return; + const ctx = this.ctx; + const w = BUILDING_W[b.type] || 100; + const cy = b.y - w * 0.9; + const start = Math.PI * 0.75, span = Math.PI * 1.5; + const full = b.util >= 1; + ctx.strokeStyle = 'rgba(47,62,70,0.25)'; + ctx.lineWidth = 4; + ctx.beginPath(); + ctx.arc(b.x, cy, 13, start, start + span); + ctx.stroke(); + ctx.strokeStyle = full + ? `rgba(192,57,43,${0.75 + 0.25 * Math.sin(this.simTime / 180)})` + : b.util >= 0.7 ? '#e8892b' : '#3f8f5e'; + ctx.beginPath(); + ctx.arc(b.x, cy, 13, start, start + span * Math.min(1, b.util)); + ctx.stroke(); + ctx.lineWidth = 1; + if (full) { + ctx.font = 'bold 10px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillStyle = '#c0392b'; + ctx.fillText('voll', b.x, cy + 4); + } + } + + _shadow(x, y, rx, ry) { + this.ctx.fillStyle = 'rgba(47,62,70,0.16)'; + this.ctx.beginPath(); + this.ctx.ellipse(x, y, rx, ry, 0, 0, 7); + this.ctx.fill(); + } + + // ---------- Skelett-Walker (prozedural animierte Figuren) ---------- + // Eine „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. + _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 = Math.abs(Math.cos(t)) * H * 0.04; + const lean = 0.10 + (o.lean || 0); + const skin = '#f0d4b8'; + const pants = o.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) => { + // zweigliedrige Extremität: Winkel absolut, 0 = senkrecht nach unten + 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 = 0.58 * Math.sin(ph); + const knee = Math.max(0, Math.sin(ph + 0.8)) * 0.95; // beugt beim Vorschwingen + const foot = limb(hip.x, hip.y, thighA, thigh, thighA - knee, shin, H * 0.10, color); + // Schuh + 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 = -0.5 * Math.sin(ph); + const elbow = 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 (hinter dem Rücken) + if (o.pack) { + ctx.fillStyle = this._shade(o.color, -40); + ctx.beginPath(); + ctx.roundRect(hip.x - H * 0.24, sh.y + H * 0.02, H * 0.15, H * 0.24, H * 0.05); + ctx.fill(); + } + // Oberkörper (Kapsel) + 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(); + const nod = 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(); + // Haar/Mütze + ctx.fillStyle = o.prop === 'hat' ? '#e8d9b0' : this._shade(o.color, 26); + ctx.beginPath(); ctx.arc(head.x, head.y - headR * 0.25, headR * (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 an der vorderen Hand + ctx.lineCap = 'round'; + if (o.prop === 'cane' || o.prop === 'pole') { + ctx.strokeStyle = '#6b4f2f'; + ctx.lineWidth = H * 0.04; + ctx.beginPath(); + ctx.moveTo(hand.x, hand.y); + ctx.lineTo(hand.x + H * 0.10, o.prop === 'pole' ? H * 0.0 : 0); + 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'; + ctx.beginPath(); + ctx.roundRect(hand.x - H * 0.07, hand.y + H * 0.09, H * 0.14, H * 0.11, H * 0.03); + ctx.fill(); + } + ctx.lineWidth = 1; + ctx.restore(); + } + + // ---------- Besucher ---------- + _drawVisitor(v, winter) { + const ctx = this.ctx; + { + const g = GROUPS[v.type]; + if (v.mode === 'lift') { + this._gondel(v.x, v.y - 34, g.color); + this._satBar(v.x, v.y - 54, v, g); + return; + } + // Busfahrt: hübscher Reisebus statt Figuren; beim Halt steigen sie aus + if (v.travel === 'bus' || v.travel === 'busstop') { + const bus = this.sprites.bus; + const flip2 = v.headRight === false; + this._shadow(v.x, v.y + 4, 34, 9); + if (bus) { + const bw = 78, bh = bw * (bus.naturalHeight / bus.naturalWidth); + const rumble = v.travel === 'bus' ? Math.sin(this.simTime / 60) * 0.8 : 0; + ctx.save(); + ctx.translate(v.x, v.y + rumble); + if (flip2) ctx.scale(-1, 1); + ctx.drawImage(bus, -bw / 2, -bh + 4, bw, bh); + ctx.restore(); + } else { + ctx.fillStyle = '#e8892b'; + ctx.fillRect(v.x - 30, v.y - 22, 60, 22); + } + if (v.travel === 'busstop') { + // Aussteigende neben der Tür + const doorX = v.x + (flip2 ? -30 : 30); + const out = Math.min(3, Math.max(1, Math.round((2.6 - v.busWait) / 0.8))); + for (let k = 0; k < out; k++) { + this._walker(doorX + k * 9, v.y + 4 + (k % 2) * 3, { + h: 26, phase: this.simTime / 140 + k, + color: TOP_COLORS[(v.id * 7 + k) % TOP_COLORS.length], + pants: PANTS_COLORS[(v.id + k) % PANTS_COLORS.length], + flip: flip2, + }); + } + } + this._satBar(v.x, v.y - 40, v, g); + return; + } + const flip = v.headRight === false; + const bob = Math.sin(this.simTime / 150 + v.wobble) * 1.2; + if (v.type === 'radfahrer') { + const img = this.sprites.g_radler; + this._shadow(v.x, v.y + 3, 20, 6); + if (img) { + for (const off of [-10, 12]) { + const roll = Math.sin(v.s / 5 + off) * 0.05; + ctx.save(); + ctx.translate(v.x + off, v.y + Math.sin(v.s / 4 + off) * 1.2); + ctx.rotate(flip ? -roll : roll); + if (flip) ctx.scale(-1, 1); + ctx.drawImage(img, -16, -30, 32, 32); + ctx.restore(); + } + } else { + this._dotGroup(v, g, 2); + } + } else { + // Gruppe aus animierten Skelett-Figuren (Schritte = Strecke) + const comp = WALK_GROUPS[v.type] || [{ dx: 0, dy: 0 }]; + this._shadow(v.x, v.y + 3, 22, 7); + const dir = flip ? -1 : 1; + const sorted2 = [...comp].sort((a, b) => (a.dy || 0) - (b.dy || 0)); + const paused = v.stopped; + for (let i = 0; i < sorted2.length; i++) { + const mMember = sorted2[i]; + const H = mMember.kid ? 19 : 30; + const seed = v.id * 7 + i * 3; + const ph = paused + ? Math.sin(this.simTime / 500 + i) * 0.25 // verweilen: leicht umsehen + : v.s / (mMember.kid ? 2.6 : 3.6) + i * 1.15 + v.wobble; + this._walker(v.x + (mMember.dx || 0) * dir, v.y + (mMember.dy || 0), { + h: H, + phase: ph, + color: TOP_COLORS[seed % TOP_COLORS.length], + pants: PANTS_COLORS[(seed + 2) % PANTS_COLORS.length], + flip, + kid: mMember.kid, + prop: mMember.prop, + pack: mMember.pack, + lean: mMember.lean || 0, + }); + } + if (winter) { + ctx.fillStyle = 'rgba(255,255,255,0.18)'; + ctx.beginPath(); + ctx.ellipse(v.x, v.y - 18, 20, 5, 0, 0, 7); + ctx.fill(); + } + if (paused) { + ctx.font = '11px sans-serif'; + ctx.textAlign = 'center'; + ctx.globalAlpha = 0.85; + ctx.fillText('⏸️', v.x + 16, v.y - 30); + ctx.globalAlpha = 1; + } + } + this._satBar(v.x, v.y - 46, v, g); + } + } + + // Fallback ohne Sprite: kleine Figuren-Punkte in Gruppenfarbe + _dotGroup(v, g, n) { + const ctx = this.ctx; + for (let i = 0; i < n; i++) { + const ox = v.x + ((i % 3) - 1) * 9; + const oy = v.y + Math.floor(i / 3) * 7 - 3; + ctx.fillStyle = i === 0 ? g.color : this._shade(g.color, i * 12); + ctx.beginPath(); + ctx.ellipse(ox, oy - 8, 4, 7, 0, 0, 7); + ctx.fill(); + ctx.fillStyle = '#f0d4b8'; + ctx.beginPath(); ctx.arc(ox, oy - 17, 3.4, 0, 7); ctx.fill(); + } + } + + _satBar(x, y, v, g) { + const ctx = this.ctx; + const ratio = Math.max(0, Math.min(1, v.satisfaction / g.satTarget)); + ctx.fillStyle = 'rgba(47,62,70,0.4)'; + ctx.fillRect(x - 13, y, 26, 4); + ctx.fillStyle = ratio >= 0.6 ? '#3f8f5e' : ratio >= 0.3 ? '#e8892b' : '#c0392b'; + ctx.fillRect(x - 13, y, 26 * ratio, 4); + ctx.font = '12px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(g.emoji, x, y - 4); + } + + // ---------- schwebende Objekte ---------- + _drawFx(simDt) { + const ctx = this.ctx; + while (this.state.fx.length) { + const fx = this.state.fx.shift(); + if (fx.pop) { + // Abreise: Sterne-Pop am Ausgang (grün = glücklich, rot = vergrault) + const good = fx.stars >= 4; + this.addFloat('★'.repeat(fx.stars), good ? '#3f8f5e' : fx.stars >= 3 ? '#e8892b' : '#c0392b', fx.fx, fx.fy - 10); + if (this.simTime - (this._lastPop || 0) > 350) { + this._lastPop = this.simTime; + audio.pling(good ? 1.4 : 0.75); + } + continue; + } + this.fxAnims.push({ ...fx, t: 0 }); + if (this.fxAnims.length > 90) this.fxAnims.shift(); + // Dienstleistungs-Geräusch (Apfelbiss, Kaffee, Werkzeug …), gedrosselt + if (this.simTime - (this._lastPling || 0) > 300) { + this._lastPling = this.simTime; + audio.fx(fx.sound || 'pling'); + } + } + const byId = new Map(this.state.visitors.map(v => [v.id, v])); + const DUR = 1300; + this.fxAnims = this.fxAnims.filter(a => a.t < DUR); + for (const a of this.fxAnims) { + a.t += simDt; + // Ankunft: Geldbetrag + Zufriedenheits-Herz beim Gast aufpoppen + if (!a.done && a.t >= DUR) { + a.done = true; + const tx2 = a.tx ?? a.fx, ty2 = a.ty ?? a.fy - 60; + if (a.amount > 0) this.addFloat(`+${a.amount} €`, '#3f8f5e', tx2 + 14, ty2 + 24); + this.addFloat('💛', '#e8892b', tx2 - 12, ty2 + 18); + } + const p = Math.min(1, a.t / DUR); + const ease = p * p * (3 - 2 * p); + const v = byId.get(a.vid); + if (v) { a.tx = v.x; a.ty = v.y - 30; } + const tx = a.tx ?? a.fx, ty = a.ty ?? a.fy - 60; + const x = a.fx + (tx - a.fx) * ease; + const y = (a.fy - 50) + (ty - (a.fy - 50)) * ease - Math.sin(p * Math.PI) * 26; + // Objekt bleibt voll sichtbar, nur die letzten ~12 % blenden kurz aus + ctx.globalAlpha = p > 0.88 ? (1 - p) / 0.12 : 1; + const sz = 20 + Math.sin(p * Math.PI) * 6; // kleiner Zoom-Effekt beim Flug + ctx.font = `${sz}px sans-serif`; + ctx.textAlign = 'center'; + // weiße Kontur für Kontrast auf jedem Untergrund + ctx.lineWidth = 3; + ctx.strokeStyle = 'rgba(253,251,244,0.85)'; + ctx.strokeText(a.emoji, x, y); + ctx.fillStyle = '#2f3e46'; + ctx.fillText(a.emoji, x, y); + ctx.globalAlpha = 1; + ctx.lineWidth = 1; + } + } + + _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; + ctx.globalAlpha = 1 - age; + ctx.font = 'bold 18px -apple-system, sans-serif'; + ctx.textAlign = 'center'; + ctx.lineWidth = 4; + ctx.strokeStyle = 'rgba(253,251,244,0.9)'; + ctx.strokeText(f.text, f.x, f.y - 60 - age * 34); + ctx.fillStyle = f.color; + ctx.fillText(f.text, f.x, f.y - 60 - age * 34); + ctx.globalAlpha = 1; + ctx.lineWidth = 1; + } + } + + // ---------- Reichweiten-Vorschau ---------- + _drawRangePreview() { + const ctx = this.ctx; + const drawRange = (x, y, rTiles, stroke, fill) => { + ctx.beginPath(); + ctx.ellipse(x, y, rTiles * TILE2PX, rTiles * TILE2PX * 0.72, 0, 0, 7); + ctx.fillStyle = fill; + ctx.fill(); + ctx.setLineDash([8, 7]); + ctx.strokeStyle = stroke; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.setLineDash([]); + ctx.lineWidth = 1; + }; + if (this.selected && this.state.buildings.includes(this.selected)) { + const b = this.selected; + drawRange(b.x, b.y, BUILDINGS[b.type].range[b.level - 1], + 'rgba(47,110,160,0.6)', 'rgba(47,110,160,0.10)'); + } + if (this.buildType && this.buildType !== 'demolish' && this.hover) { + const def = BUILDINGS[this.buildType]; + // Vorschau am nächsten freien Slot unter dem Zeiger + let best = null, bestD = 70; + for (const slot of this.state.slots) { + if (slot.building) continue; + const d = Math.hypot(slot.x - this.hover.x, slot.y - this.hover.y); + if (d < bestD) { bestD = d; best = slot; } + } + if (best) { + const ok = canPlace(this.state, this.buildType, best.idx).ok; + drawRange(best.x, best.y, def.range[0], + ok ? 'rgba(79,127,94,0.8)' : 'rgba(200,60,50,0.6)', + ok ? 'rgba(123,160,91,0.14)' : 'rgba(200,60,50,0.08)'); + } + } + } + + // ---------- Partikel (Screen-Space) ---------- + _particles(dt, kind) { + const ctx = this.ctx; + const arr = kind === 'snow' ? this.flakes : this.leaves; + const max = kind === 'snow' ? 70 : 24; + while (arr.length < max) { + arr.push({ + x: Math.random() * innerWidth, y: Math.random() * innerHeight, + r: 1 + Math.random() * 2.2, v: 18 + Math.random() * 40, ph: Math.random() * 7, + }); + } + for (const f of arr) { + f.y += f.v * dt / 1000; + f.x += Math.sin(this.time / 900 + f.ph) * 0.4; + if (f.y > innerHeight) { f.y = -4; f.x = Math.random() * innerWidth; } + if (kind === 'snow') { + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, 7); ctx.fill(); + } else { + ctx.fillStyle = f.ph % 2 > 1 ? 'rgba(208,138,62,0.7)' : 'rgba(180,150,60,0.6)'; + ctx.beginPath(); ctx.ellipse(f.x, f.y, f.r + 1, f.r * 0.6, f.ph, 0, 7); ctx.fill(); + } + } + } + + // ---------- Debug: Routen + Slot-Indizes (?debug) ---------- + _drawDebug() { + const ctx = this.ctx; + for (const [id, r] of Object.entries(this.state.map.routes)) { + ctx.strokeStyle = 'rgba(200,30,30,0.85)'; + ctx.lineWidth = 3; + ctx.beginPath(); + r.points.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); + ctx.stroke(); + ctx.fillStyle = '#c01e1e'; + for (const [x, y] of r.points) { + ctx.beginPath(); ctx.arc(x, y, 5, 0, 7); ctx.fill(); + } + ctx.font = 'bold 20px sans-serif'; + ctx.fillText(id, r.points[0][0], r.points[0][1] - 14); + } + ctx.font = 'bold 16px sans-serif'; + for (const slot of this.state.slots) { + ctx.fillStyle = '#1e50c0'; + ctx.fillText(`${slot.idx}·${slot.zone[0]}`, slot.x, slot.y - 28); + } + // Editor-Punkte (getappt im Debug-Modus) + const tr = window.__ttTrace || []; + if (tr.length) { + ctx.strokeStyle = '#0a9648'; + ctx.lineWidth = 3; + ctx.beginPath(); + tr.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y))); + ctx.stroke(); + ctx.fillStyle = '#0a9648'; + for (const [x, y] of tr) { + ctx.beginPath(); ctx.arc(x, y, 6, 0, 7); ctx.fill(); + } + } + 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/tourismusregion/js/telemetry.js b/App/sims/tourismusregion/js/telemetry.js new file mode 100644 index 0000000..e94e8f4 --- /dev/null +++ b/App/sims/tourismusregion/js/telemetry.js @@ -0,0 +1,35 @@ +// 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 = 'tourismusregion_telemetry'; + +export const telemetry = { + events: [], + _timeProvider: null, + + init(timeProvider) { + this._timeProvider = timeProvider; + this.events = []; + }, + + log(type, data = {}) { + this.events.push({ + ts: Date.now(), + gameTime: this._timeProvider ? this._timeProvider() : 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 */ } + }, + + flush() { + return this.events; + }, +}; diff --git a/App/sims/tourismusregion/js/ui.js b/App/sims/tourismusregion/js/ui.js new file mode 100644 index 0000000..312135b --- /dev/null +++ b/App/sims/tourismusregion/js/ui.js @@ -0,0 +1,715 @@ +// DOM-UI: HUD-Kennzahlen, Baumenü, Info-Panels (Gebäude/Besucher), +// Ereignis-Karten mit Auto-Pause (inkl. Entscheidungen und Social-Media- +// Feed), Meldungs-Log, Zeit-Graph, Ziele, Toasts, Onboarding, Endauswertung. + +import { BUILDINGS, GROUPS, GOALS, currentAvg, GAME_LEN } from './data.js'; +import { telemetry } from './telemetry.js'; +import { audio } from './audio.js'; +import { music, TRACKS } from './music.js'; + +const $ = id => document.getElementById(id); +const euro = n => Math.round(n).toLocaleString('de-AT'); + +export class UI { + constructor(state, api) { + this.state = state; + this.api = api; // {setSpeed, getSpeed, setBuildType, selectBuilding, upgradeSelected, demolishSelected, restart, save} + this.cardQueue = []; + this.cardOpen = false; + this.speedBeforeCard = 1; + this.collectedCards = []; + this.activeBuild = null; + this.selectedBuilding = null; + this.selectedVisitor = null; + this.toastTimer = null; + this.gMoney = $('graphMoneyCanvas').getContext('2d'); + this.gSat = $('graphSatCanvas').getContext('2d'); + + this._buildGoals(); + this.rebuildMenu(); + this._bindTopbar(); + this._bindOverlays(); + this.updateKPIs(); + } + + // ---------- Baumenü ---------- + rebuildMenu() { + const wrap = $('buildItems'); + wrap.innerHTML = ''; + for (const [type, def] of Object.entries(BUILDINGS)) { + if (!this.state.unlocked.has(type)) continue; + const el = document.createElement('div'); + el.className = 'build-item'; + el.dataset.type = type; + el.innerHTML = ` + ${def.emoji} + ${def.name}${def.desc} + ${euro(def.cost[0])} €`; + el.addEventListener('click', () => this._toggleBuild(type, el)); + wrap.appendChild(el); + } + const locked = Object.values(BUILDINGS).filter(d => d.unlockPhase > this.state.phase).length; + if (locked > 0) { + const hint = document.createElement('div'); + hint.className = 'locked-hint'; + hint.textContent = `🔒 ${locked} weitere werden im Spielverlauf freigeschaltet.`; + wrap.appendChild(hint); + } + 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(); + } + + _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'); + const def = BUILDINGS[type]; + this.toast(`${def.emoji} ${def.name} gewählt – tippe auf die Karte. Der Kreis zeigt die Reichweite.`); + this.hideInfoPanel(); + } + 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.budget < def.cost[0]); + }); + } + + // ---------- 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 Cockpit um + window.location.href='../../schueler.html'; + }); + $('btnSound').addEventListener('click', e => { + // Master-Mute: Effekte, Musik-Player UND Saison-Ambience + const on = audio.toggleSound(); + music.setMuted(!on); + if (!on) audio._stopAmbience?.(); + else if (audio.musicOn && !music.playing) audio.startMusic(); + e.currentTarget.classList.toggle('muted', !on); + telemetry.log('audio_toggle', { kind: 'sound', off: !on }); + }); + $('btnMusic').addEventListener('click', () => { + audio.click(); + $('musicPanel').classList.toggle('hidden'); + telemetry.log('music_panel', { open: !$('musicPanel').classList.contains('hidden') }); + }); + this._bindMusic(); + $('btnAnim').addEventListener('click', e => { + const on = this.api.toggleReduceAnim(); + e.currentTarget.classList.toggle('active', on); + this.toast(on ? '🐢 Reduzierte Animationen aktiviert.' : '🐢 Animationen wieder aktiviert.'); + telemetry.log('reduce_anim', { on }); + }); + $('btnCamera').addEventListener('click', () => { audio.click(); this.api.resetCamera(); }); + $('btnNextWave').addEventListener('click', () => this.api.nextWave()); + $('btnHelp').addEventListener('click', () => { audio.click(); this.showOnboarding(); }); + $('btnLog').addEventListener('click', () => { audio.click(); this._openLog(); }); + } + + // ---------- Musik-Player ---------- + _bindMusic() { + const sel = $('muSelect'); + sel.innerHTML = TRACKS.map((t, i) => ``).join(''); + music.onChange = () => { + $('muPlay').textContent = music.playing ? '⏸' : '▶'; + sel.value = String(music.idx); + }; + $('muPlay').addEventListener('click', () => { music.toggle(); telemetry.log('music', { action: 'toggle', track: music.current().title }); }); + $('muNext').addEventListener('click', () => { music.next(); telemetry.log('music', { action: 'next', track: music.current().title }); }); + $('muPrev').addEventListener('click', () => { music.prev(); telemetry.log('music', { action: 'prev', track: music.current().title }); }); + $('muVol').addEventListener('input', e => music.setVolume(Number(e.target.value))); + sel.addEventListener('change', () => { music.play(Number(sel.value)); telemetry.log('music', { action: 'select', track: music.current().title }); }); + } + + markSpeed(sp) { + document.querySelectorAll('.speed-btn').forEach(b => + b.classList.toggle('active', Number(b.dataset.speed) === sp)); + } + + // ---------- Ziele ---------- + _buildGoals() { + const list = $('goalList'); + list.innerHTML = ''; + for (const g of GOALS) { + const li = document.createElement('li'); + li.id = `goal-${g.id}`; + li.textContent = g.label; + list.appendChild(li); + } + } + + // ---------- Kennzahlen + Ruf-Leiste + Wellen-Status ---------- + updateKPIs() { + const s = this.state; + $('clock').textContent = `Welle ${s.phase}/8`; + $('seasonBadge').textContent = `${{ spring: '🌸', summer: '☀️', autumn: '🍂', winter: '❄️' }[s.seasonKey]} ${s.season}`; + $('kMoney').textContent = `${euro(s.budget)} €`; + $('kMoney').classList.toggle('warn', s.budget < 100); + $('kEnv').textContent = Math.round(s.env); + $('kEnv').classList.toggle('warn', s.env < 45); + $('kVisitors').textContent = s.visitors.length; + $('kTraffic').textContent = s.traffic; + $('kTraffic').classList.toggle('warn', s.traffic > 30); + $('kRevenue').textContent = `${euro(s.revenueTotal)} €`; + + // Ruf: Ø der letzten Abreisen (TD-„Basis-HP"); pulst bei schlechter Abreise + const rep = s.recentStars.length + ? s.recentStars.reduce((a, b) => a + b, 0) / s.recentStars.length : null; + const pct = rep === null ? 60 : Math.round((rep / 5) * 100); + $('repVal').textContent = rep === null ? '–' : `${rep.toFixed(1)} ★`; + const fill = $('repFill'); + fill.style.width = `${pct}%`; + fill.classList.toggle('low', rep !== null && rep < 2.5); + fill.classList.toggle('mid', rep !== null && rep >= 2.5 && rep < 3.5); + if (s.lastBadExit && s.lastBadExit !== this._lastBadShown) { + this._lastBadShown = s.lastBadExit; + const row = $('repRow'); + row.classList.remove('pulse'); void row.offsetWidth; row.classList.add('pulse'); + } + + // Wellen-Status-Banner + const ws = $('waveStatus'); + if (s.waveStatus === 'pause') { + ws.className = 'wave-status pause'; + ws.textContent = `🔨 Baupause – Welle ${s.phase} startet in ${Math.ceil(Math.max(0, s.pauseLeft))} s`; + } else { + const left = s.visitors.filter(v => v.wave === s.phase).length + s.spawnQueue.filter(e => e.type).length; + ws.className = 'wave-status running'; + ws.textContent = `🌊 Welle ${s.phase} läuft – noch ${left} Gäste`; + } + $('btnNextWave').disabled = s.ended || (s.phase >= 8 && s.waveStatus === 'running'); + $('btnNextWave').textContent = s.waveStatus === 'pause' ? '⏩ Welle jetzt starten' : '⏩ Nächste Welle rufen'; + + for (const g of GOALS) { + $(`goal-${g.id}`)?.classList.toggle('done', g.check(s)); + } + this.refreshBuildMenu(); + this.refreshInfoPanel(); + } + + // ---------- Statistik-Overlay ---------- + showStats() { + const s = this.state; + const rows = (title, items) => { + if (!items.length) return ''; + const max = Math.max(...items.map(i => i.val), 1); + return `

${title}

` + items.map(i => + `
${i.label}` + + `
` + + `${i.disp}
`).join('') + '
'; + }; + // Zufriedenheit je Gruppe + const groups = Object.entries(s.stats.groups) + .filter(([, g]) => g.count > 0) + .map(([id, g]) => ({ label: `${GROUPS[id].emoji} ${GROUPS[id].name}`, val: g.avg, + disp: `${g.avg.toFixed(1)}★`, color: g.avg >= 3.5 ? '#3f8f5e' : g.avg >= 2.5 ? '#e8892b' : '#c0392b' })) + .sort((a, b) => b.val - a.val); + // Umsatz je Gebäudekategorie + const cats = {}; + for (const [type, rev] of Object.entries(s.stats.buildingRevenue)) { + const c = BUILDINGS[type].cat; + cats[c] = (cats[c] || 0) + rev; + } + const catNames = { gastronomy: '🍽️ Gastronomie', shopping: '🛍️ Handel', nature: '🌿 Natur', + play: '🛝 Freizeit', service: '🛎️ Service', culture: '🏛️ Kultur', sport: '⛷️ Sport' }; + const revItems = Object.entries(cats).map(([c, v]) => ({ label: catNames[c] || c, val: v, + disp: `${euro(v)}€`, color: '#4f7f5e' })).sort((a, b) => b.val - a.val); + // Anreise-Mix + const tr = s.stats.transports; + const trItems = [['foot', '🚶 zu Fuß'], ['bike', '🚴 Rad'], ['bus', '🚌 Bus'], ['car', '🚗 Auto']] + .map(([k, l]) => ({ label: l, val: tr[k], disp: `${tr[k]}`, color: k === 'car' ? '#c9531f' : '#2f6ea0' })) + .filter(i => i.val > 0); + + $('statsBody').innerHTML = + `

Heatmap der Besucherströme

` + + `
` + + rows('Zufriedenheit je Gruppe (Ø Sterne)', groups) + + rows('Wertschöpfung je Kategorie', revItems) + + rows('Anreise der Gäste', trItems) + + `

Bilanz

` + + `
Umwelt${Math.round(s.env)}
` + + `
Verkehr${s.traffic}
` + + `
Busgruppen ohne Parkplatz${s.stats.skippedNoParking}
` + + `
Gäste gesamt${s.stats.spawned}
`; + this._drawHeatmap(); + $('statsOverlay').classList.remove('hidden'); + telemetry.log('stats_open', {}); + } + + // Heatmap: Kartenbild verkleinert + Besucherstrom-Dichte als Farbflecken + _drawHeatmap() { + const s = this.state; + const cv = $('heatCanvas'); + if (!cv) return; + const W = 420, H = Math.round(W * s.map.h / s.map.w); + const dpr = window.devicePixelRatio || 1; + cv.width = W * dpr; cv.height = H * dpr; + cv.style.width = W + 'px'; cv.style.height = H + 'px'; + const ctx = cv.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + if (!this._heatImg) { this._heatImg = new Image(); } + const paint = () => { + ctx.clearRect(0, 0, W, H); + if (this._heatImg.complete && this._heatImg.naturalWidth) { + ctx.globalAlpha = 0.55; ctx.drawImage(this._heatImg, 0, 0, W, H); ctx.globalAlpha = 1; + } else { ctx.fillStyle = '#cfe0cf'; ctx.fillRect(0, 0, W, H); } + const HC = s.heatCols, HR = s.heatRows; + const max = Math.max(...s.heat, 1); + const cw = W / HC, ch = H / HR; + ctx.globalCompositeOperation = 'source-over'; + for (let r = 0; r < HR; r++) { + for (let c = 0; c < HC; c++) { + const v = s.heat[r * HC + c] / max; + if (v < 0.04) continue; + const x = (c + 0.5) * cw, y = (r + 0.5) * ch; + const rad = ch * 1.6; + const g = ctx.createRadialGradient(x, y, 0, x, y, rad); + const a = Math.min(0.8, v * 1.1); + // grün → gelb → rot je nach Dichte + const col = v > 0.66 ? '220,60,40' : v > 0.33 ? '232,150,40' : '90,170,90'; + g.addColorStop(0, `rgba(${col},${a})`); + g.addColorStop(1, `rgba(${col},0)`); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(x, y, rad, 0, 7); ctx.fill(); + } + } + // Legende + ctx.font = '10px sans-serif'; ctx.textAlign = 'left'; + ctx.fillStyle = 'rgba(47,62,70,0.7)'; + ctx.fillText('wenig', 6, H - 6); + ctx.textAlign = 'right'; + ctx.fillText('viel Verkehr →', W - 6, H - 6); + }; + if (this._heatImg.src.endsWith(s.map.image)) paint(); + else { this._heatImg.onload = paint; this._heatImg.src = s.map.image; paint(); } + } + + // ---------- Info-Panels ---------- + showBuildingPanel(b) { + this.selectedBuilding = b; + this.selectedVisitor = null; + this.refreshInfoPanel(); + $('infoPanel').classList.remove('hidden'); + } + + showVisitorPanel(v) { + this.selectedVisitor = v; + this.selectedBuilding = null; + this.refreshInfoPanel(); + $('infoPanel').classList.remove('hidden'); + } + + hideInfoPanel() { + this.selectedBuilding = null; + this.selectedVisitor = null; + this.api.selectBuilding(null); + $('infoPanel').classList.add('hidden'); + } + + refreshInfoPanel() { + const s = this.state; + if (this.selectedBuilding) { + const b = this.selectedBuilding; + if (!s.buildings.includes(b)) { this.hideInfoPanel(); return; } + const def = BUILDINGS[b.type]; + const lvl = b.level - 1; + const name = def.levelNames ? def.levelNames[lvl] : def.name; + const stars = '★'.repeat(b.level) + '☆'.repeat(def.maxLevel - b.level); + const goodFor = Object.entries(def.target) + .filter(([, f]) => f >= 1).map(([gid]) => GROUPS[gid]?.emoji).filter(Boolean).join(' '); + const rev = s.stats.buildingRevenue[b.type]; + const busy = b.construction > 0 ? '🏗️ Baustelle …' + : b.upgrading ? '🏗️ Ausbau läuft …' : ''; + $('infoTitle').textContent = `${def.emoji} ${name} ${stars}`; + $('infoBody').innerHTML = ` + ${busy ? `
Status${busy}
` : ''} +
Wirkung+${def.sat[lvl]} Zufriedenheit
+ ${def.slow[lvl] ? `
Verweilen−${Math.round(def.slow[lvl] * 100)} % Tempo
` : ''} +
Reichweite${def.range[lvl]} Felder
+
Kapazität${def.capacity[lvl] >= 999 ? 'unbegrenzt' : `${def.capacity[lvl]} Gäste`}
+ ${def.capacity[lvl] < 999 ? `
Auslastung${b.inRange || 0} / ${def.capacity[lvl]}${(b.inRange || 0) > def.capacity[lvl] ? ' – voll!' : ''}
` : ''} +
Betriebskosten${def.opCost[lvl]} € / min
+ ${goodFor ? `
Passt für${goodFor}
` : ''} + ${rev ? `
Umsatz bisher${euro(rev)} €
` : ''} + ${b.infoBoost > 1 ? `
Info-Bonus+${Math.round((b.infoBoost - 1) * 100)} %
` : ''} + ${b.synergy > 1 ? `
🔗 Synergie+${Math.round((b.synergy - 1) * 100)} %
` + + (b.synergyList || []).map(w => `
✓ ${w}
`).join('') : ''}`; + const up = $('btnUpgrade'); + if (b.level < def.maxLevel && !busy) { + up.classList.remove('hidden'); + up.textContent = `⬆️ Ausbauen – ${euro(def.cost[b.level])} €`; + up.disabled = s.budget < def.cost[b.level]; + } else { + up.classList.add('hidden'); + } + $('btnDemolish').classList.toggle('hidden', b.type === 'naturschutz'); + } else if (this.selectedVisitor) { + const v = this.selectedVisitor; + if (v.exited || !s.visitors.includes(v)) { this.hideInfoPanel(); return; } + const g = GROUPS[v.type]; + const ratio = Math.max(0, v.satisfaction) / g.satTarget; + const stars = Math.max(1, Math.min(5, Math.ceil(ratio * 5))); + $('infoTitle').textContent = `${g.emoji} ${g.name}`; + $('infoBody').innerHTML = ` +
Zufriedenheit${Math.round(Math.max(0, v.satisfaction))} / ${g.satTarget}
+
Budget übrig${euro(g.budget - v.spent)} €
+
Interessen${g.likes}
+
Bewertung derzeit${'⭐'.repeat(stars)}
`; + $('btnUpgrade').classList.add('hidden'); + $('btnDemolish').classList.add('hidden'); + } + } + + // ---------- Zwei Verlaufs-Graphen in eigenen Feldern ---------- + // Feste 0–20-min-Zeitachse, sichtbare Bodenlinie, alle Daten bleiben. + // Links: Budget (€). Rechts: Zufriedenheits-Erfüllung in % (Ziel 100 %, + // wie „zerstört" im Tower Defense) + Umweltwert auf derselben 0–100-Skala. + drawGraph() { + const hist = this.state.history; + if (hist.budget.length < 2) return; + const bMax = Math.max(600, ...hist.budget); + const bMin = Math.min(0, ...hist.budget); + this._plotPanel(this.gMoney, { + title: '💰 Budget', + series: [{ data: hist.budget, color: 'rgb(79,127,94)', fill: true }], + vMin: bMin, vMax: bMax, unit: '€', + current: `${euro(this.state.budget)} €`, + goals: [], + t: hist.t, + }); + this._plotPanel(this.gSat, { + title: '😊 Zufriedenheit · 🌲 Umwelt', + series: [ + { data: hist.satPct, color: 'rgb(232,137,43)', fill: true }, + { data: hist.env, color: 'rgb(47,110,160)', fill: false }, + ], + vMin: 0, vMax: 110, unit: '%', + current: `${hist.satPct[hist.satPct.length - 1]} % · ${Math.round(this.state.env)}`, + goals: [ + { v: 100, label: 'Ziel 100 %' }, + { v: 45, label: 'Umwelt-Ziel 45' }, + ], + t: hist.t, + }); + } + + _plotPanel(ctx, o) { + const cv = ctx.canvas; + const dpr = window.devicePixelRatio || 1; + const w = cv.clientWidth, h = cv.clientHeight; + if (cv.width !== w * dpr) { cv.width = w * dpr; cv.height = h * dpr; } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + + const padL = 36, padR = 6, padT = 16, padB = 15; + const pw = w - padL - padR, ph = h - padT - padB; + const X = i => padL + (o.t[i] / 1200) * pw; + const Y = v => padT + ph - ((Math.max(o.vMin, Math.min(v, o.vMax)) - o.vMin) / (o.vMax - o.vMin || 1)) * ph; + + // Titel + aktueller Wert + ctx.font = 'bold 10.5px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillStyle = '#2f3e46'; + ctx.fillText(o.title, 2, 10); + ctx.textAlign = 'right'; + ctx.fillStyle = o.series[0].color; + ctx.fillText(o.current, w - 2, 10); + + // Skala links (oben/unten) + ctx.font = '8.5px sans-serif'; + ctx.fillStyle = 'rgba(47,62,70,0.6)'; + ctx.textAlign = 'right'; + ctx.fillText(`${o.vMax}${o.unit}`, padL - 3, padT + 7); + ctx.fillText(`${o.vMin}${o.unit}`, padL - 3, padT + ph); + + // Ziellinien + for (const g of o.goals) { + ctx.setLineDash([4, 4]); + ctx.strokeStyle = 'rgba(47,62,70,0.35)'; + ctx.beginPath(); ctx.moveTo(padL, Y(g.v)); ctx.lineTo(padL + pw, Y(g.v)); ctx.stroke(); + ctx.setLineDash([]); + ctx.textAlign = 'left'; + ctx.fillStyle = 'rgba(47,62,70,0.55)'; + ctx.fillText(g.label, padL + 2, Y(g.v) - 2); + } + + // Datenlinien (+ Füllung der ersten Serie) + for (const s of o.series) { + if (s.fill) { + ctx.beginPath(); + ctx.moveTo(X(0), padT + ph); + s.data.forEach((v, i) => ctx.lineTo(X(i), Y(v))); + ctx.lineTo(X(s.data.length - 1), padT + ph); + ctx.closePath(); + ctx.fillStyle = s.color.replace('rgb', 'rgba').replace(')', ',0.10)'); + ctx.fill(); + } + ctx.strokeStyle = s.color; + ctx.lineWidth = 1.8; + ctx.beginPath(); + s.data.forEach((v, i) => (i ? ctx.lineTo(X(i), Y(v)) : ctx.moveTo(X(i), Y(v)))); + ctx.stroke(); + ctx.lineWidth = 1; + } + + // BODEN: kräftige Grundlinie + Zeitachse + ctx.strokeStyle = 'rgba(47,62,70,0.45)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(padL, padT + ph); + ctx.lineTo(padL + pw, padT + ph); + ctx.stroke(); + ctx.lineWidth = 1; + ctx.fillStyle = 'rgba(47,62,70,0.55)'; + ctx.textAlign = 'center'; + for (const min of [0, 5, 10, 15, 20]) { + const x = padL + (min / 20) * pw; + ctx.beginPath(); + ctx.moveTo(x, padT + ph); + ctx.lineTo(x, padT + ph + 3); + ctx.stroke(); + ctx.fillText(`${min}'`, x, h - 2); + } + } + + // ---------- Karten ---------- + showCard(card) { + // Tempo VOR der ersten Karte merken – auch eine manuelle Pause (0) + // wird nach dem Schließen wiederhergestellt. + if (!this.cardOpen) this.speedBeforeCard = this.api.getSpeed(); + this.cardQueue.push(card); + if (!this.cardOpen) this._nextCard(); + } + + _nextCard() { + const card = this.cardQueue.shift(); + if (!card) return; + this.currentCard = card; + this.cardOpen = true; + this.api.setSpeed(0); + this.markSpeed(0); + + $('cardIcon').textContent = card.icon || 'ℹ️'; + $('cardTitle').textContent = card.title || ''; + $('cardText').textContent = card.text || ''; + const list = $('cardList'); + list.innerHTML = ''; + list.classList.toggle('hidden', !card.items); + if (card.items) { + for (const f of card.items) { + const li = document.createElement('li'); + li.innerHTML = `${'⭐'.repeat(f.stars)}` + + `${f.emoji} „${f.text}"`; + list.appendChild(li); + } + $('cardText').textContent = `Ø ${card.avg.toFixed(1)} Sterne → Besucherfaktor ` + + `×${card.fame.toFixed(2)} für die nächste Phase.`; + } + 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 if (!this.state.ended) { + this.api.setSpeed(this.speedBeforeCard); + this.markSpeed(this.speedBeforeCard); + } + } + + _bindOverlays() { + $('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'); + }); + $('infoClose').addEventListener('click', () => { audio.click(); this.hideInfoPanel(); }); + $('btnUpgrade').addEventListener('click', () => this.api.upgradeSelected()); + $('btnDemolish').addEventListener('click', () => this.api.demolishSelected()); + $('btnSave').addEventListener('click', () => this.api.save()); + $('btnRestart').addEventListener('click', () => this.api.restart()); + $('btnStats').addEventListener('click', () => { audio.click(); this.showStats(); }); + $('statsClose').addEventListener('click', () => { audio.click(); $('statsOverlay').classList.add('hidden'); }); + } + + _openLog() { + const list = $('logList'); + list.innerHTML = this.collectedCards.length ? '' : '
  • Noch keine Meldungen.
  • '; + for (const c of [...this.collectedCards].reverse()) { + const li = document.createElement('li'); + const first = (c.text || (c.items ? 'Bewertungen der Gäste' : '')).split('\n')[0]; + li.innerHTML = `${c.icon || 'ℹ️'} ${c.title}${first}`; + li.addEventListener('click', () => { + $('logOverlay').classList.add('hidden'); + this.showCard({ ...c, transient: true, choices: null }); + }); + list.appendChild(li); + } + $('logOverlay').classList.remove('hidden'); + telemetry.log('log_open', { count: this.collectedCards.length }); + } + + // ---------- Endauswertung ---------- + showEnd() { + const s = this.state; + const r = s.result; + $('endProfileIcon').textContent = r.profile.icon; + $('endProfileName').textContent = r.profile.name; + $('endProfileText').textContent = r.profile.text; + $('endStars').textContent = `${'⭐'.repeat(Math.round(r.avgStars))} Ø ${r.avgStars.toFixed(1)} Sterne · Gesamtscore ${r.score}/100`; + + const bars = [ + ['Bewertung (35 %)', r.scores.rating, '#e8892b'], + ['Wertschöpfung (25 %)', r.scores.revenue, '#4f7f5e'], + ['Nachhaltigkeit (20 %)', r.scores.sustain, '#2f6ea0'], + ['Besucherlenkung (10 %)', r.scores.lenkung, '#a34a8e'], + ['Finanzstabilität (10 %)', r.scores.finance, '#8c6239'], + ]; + $('endBars').innerHTML = bars.map(([label, v, color]) => ` +
    + ${label} +
    + ${Math.round(v)} +
    `).join(''); + + $('endGoals').innerHTML = r.goals.map(g => + `
  • ${g.done ? '✓' : '✗'} ${g.label}
  • `).join(''); + $('endFacts').textContent = `${r.visitors} Besuchergruppen · ${r.rated} Bewertungen · ` + + `${euro(r.revenue)} € Wertschöpfung · Umwelt ${r.env} · Budget ${euro(r.budget)} €`; + $('endFeedback').innerHTML = r.feedback.map(l => `

    ${l}

    `).join(''); + $('endReflect').innerHTML = '

    🧠 Denk mal nach …

    '; + $('endOverlay').classList.remove('hidden'); + audio.win(); + telemetry.log('game_end', { + score: r.score, stars: r.avgStars, revenue: r.revenue, + env: r.env, budget: r.budget, profile: r.profile.id, + }); + } + + // Reflexionsfragen (§24): teils an das Ergebnis angepasst, für die + // Nachbesprechung im Unterricht (offene Fragen, keine „richtige" Antwort). + _reflectionQuestions(s) { + const q = []; + const st = s.stats; + // stärkste/schwächste Gruppe + let best = null, worst = null; + for (const [id, g] of Object.entries(st.groups)) { + if (g.count < 3) continue; + if (!best || g.avg > best.avg) best = { id, ...g }; + if (!worst || g.avg < worst.avg) worst = { id, ...g }; + } + if (worst && worst.avg < 3.2) { + q.push(`${GROUPS[worst.id].name} waren am unzufriedensten. Welche Infrastruktur ` + + `hätte ihnen geholfen – und warum hast du sie nicht (rechtzeitig) gebaut?`); + } + if (s.env < 50) { + q.push('Der Umweltwert ist gesunken. Welche Entscheidungen haben der Natur ' + + 'geschadet? Wie ließe sich Tourismus nachhaltiger gestalten?'); + } else { + q.push('Du hast die Umwelt gut geschützt. Wo musstest du dafür auf Einnahmen ' + + 'verzichten – und war das die richtige Abwägung?'); + } + if (s.traffic > 25 || st.skippedNoParking > 0) { + q.push('Verkehr und Erreichbarkeit waren ein Thema. Wie verändern Parkplatz, ' + + 'Bushaltestelle und Radweg, wer überhaupt zu Gast kommt – und mit welchen Folgen?'); + } + q.push('Warum lohnt sich dieselbe Infrastruktur an einem Standort mehr als an ' + + 'einem anderen? Nenne ein Beispiel aus deiner Partie.'); + q.push('Wie hat die Saison (Frühling → Winter) verändert, was sich gelohnt hat?'); + if (best) { + q.push(`Deine Region wurde zur „${s.result.profile.name}". Wolltest du das – ` + + 'oder ist es „passiert"? Was würdest du beim nächsten Mal anders planen?'); + } + return q.slice(0, 5); + } + + // ---------- Onboarding ---------- + showOnboarding() { + this.showCard({ + icon: '🗺️', + title: 'So entwickelst du deine Tourismusregion', + transient: true, + text: '1. Besuchergruppen ziehen über die Wege durch die Region – jede hat ' + + 'eigene Interessen (antippen zeigt sie!).\n\n' + + '2. Wähle rechts ein Gebäude und baue es NAH AN DEN WEGEN. Der Kreis ' + + 'zeigt, wen es erreicht. Antippen: Info + Ausbau.\n\n' + + '3. Zufriedene Gäste geben Geld aus und bewerten die Region – gute ' + + 'Bewertungen bringen in der nächsten Welle mehr Gäste.\n\n' + + '4. Achte auf Saison, Umweltwert und Budget: Betriebskosten laufen ' + + 'immer weiter!\n\n' + + '5. Gäste kommen in 8 Wellen. Zwischen den Wellen ist Baupause – ' + + 'baue und rüste dann auf (Bauen kostet Zeit!). „Nächste Welle rufen" ' + + 'startet früher, mit Bonus. Karte bewegen: ' + + 'ziehen · Zoom: zwei Finger / Mausrad · ⏸ pausiert.', + }); + telemetry.log('onboarding_open'); + } + + toast(msg) { + const t = $('toast'); + t.textContent = msg; + t.classList.remove('hidden'); + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => t.classList.add('hidden'), 3000); + } +} diff --git a/App/sims/tourismusregion/js/world.js b/App/sims/tourismusregion/js/world.js new file mode 100644 index 0000000..69b15ba --- /dev/null +++ b/App/sims/tourismusregion/js/world.js @@ -0,0 +1,141 @@ +// Wegkarten-Geometrie: Routen als Polylinien in Bild-Pixeln, feste +// Bauplatz-Slots mit Zonen, Liftumweg auf den Berg. Ersetzt das frühere +// Iso-Kachelgitter komplett (exklusiver Diorama-Look, Karten in maps.js). +// DOM-frei – läuft auch im Headless-Test. + +import { BUILDINGS } from './data.js'; +import { MAPS, TILE2PX } from './maps.js'; + +export { MAPS, TILE2PX }; + +// deterministischer Zufall: gleiche Wellen für alle +export function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// Karte für eine Partie instanzieren: Slots als Objekte, Routen aufgelöst +// (reverseOf-Varianten erzeugen die Gegenrichtung auf demselben Weg). +export function prepMap(mapId) { + const def = MAPS[mapId] || MAPS.talschleife; + const routes = {}; + for (const [id, r] of Object.entries(def.routes)) { + if (r.reverseOf) { + routes[id] = { ...r, points: [...def.routes[r.reverseOf].points].reverse() }; + } else { + routes[id] = r; + } + } + return { + ...def, + routes, + slots: def.slots.map((s, i) => ({ ...s, idx: i, building: null })), + }; +} + +// kumulierte Längen einer Punktliste (für Bewegung per Bogenlänge) +export function cumLengths(pts) { + const cum = [0]; + for (let i = 1; i < pts.length; i++) { + cum.push(cum[i - 1] + Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y)); + } + return cum; +} + +// ---------- Routenwahl für eine Besuchergruppe ---------- +// Liefert {pts, cum, exit}. Mit gebautem Lift wird für berg-affine Gruppen +// der Seilbahn-Umweg eingefügt (rauf, kleine Runde oben, runter). +export function makeRoute(state, group, rnd, routePick = null) { + const map = state.map; + const ids = map.entryRoutes[group.entry] || Object.keys(map.routes); + let total = 0; + const weighted = ids.map(id => { + const w = map.routes[id].lake ? 0.4 + group.lakeAffinity * 1.6 : 1; + total += w; + return [id, w]; + }); + let pick = (routePick ?? rnd()) * total; // Konvoi: ganze Welle, gleiche Route + let routeId = weighted[0][0]; + for (const [id, w] of weighted) { pick -= w; if (pick <= 0) { routeId = id; break; } } + const route = map.routes[routeId]; + let pts = route.points.map(p => ({ x: p[0], y: p[1] })); + + const lift = state.buildings.find(b => b.type === 'skilift'); + if (lift && map.lift) { + const winter = state.seasonKey === 'winter'; + const p = Math.min(0.95, group.bergAffinity * (winter ? 1.1 : 0.55)); + if (rnd() < p) { + // Route an der liftnächsten Stelle unterbrechen und Umweg einfügen + let best = 0, bestD = Infinity; + for (let i = 0; i < pts.length; i++) { + const d = Math.hypot(pts[i].x - lift.x, pts[i].y - lift.y); + if (d < bestD) { bestD = d; best = i; } + } + pts = [ + ...pts.slice(0, best + 1), + ...bergDetour(lift, map), + ...pts.slice(best + 1), + ]; + } + } + return { pts, cum: cumLengths(pts), exit: route.exit }; +} + +// Seilbahn-Umweg: Zustieg → Fahrt hinauf (lift) → Runde oben → Fahrt hinab +export function bergDetour(lift, map) { + const top = { x: map.lift.topX, y: map.lift.topY }; + const loop = (map.lift.loop || []).map(p => ({ x: p[0], y: p[1] })); + return [ + { x: lift.x, y: lift.y }, + { ...top, lift: true }, + ...loop, + { ...top }, + { x: lift.x, y: lift.y, lift: true }, + ]; +} + +// ---------- Platzierung auf festen Slots ---------- +export function canPlace(state, type, slotIdx) { + const def = BUILDINGS[type]; + const slot = state.slots[slotIdx]; + if (!def || !slot) return { ok: false, reason: 'Kein Bauplatz.' }; + if (slot.building) return { ok: false, reason: 'Dieser Bauplatz ist schon belegt.' }; + if (slot.blocked && type !== 'naturschutz') { + return { ok: false, reason: 'Naturschutzgebiet – hier bleibt die Natur unberührt.' }; + } + + const p = def.place || {}; + const liftBuilt = state.buildings.some(b => b.type === 'skilift'); + + if (p.zone === 'hang') { + if (slot.zone !== 'hang') return { ok: false, reason: 'Der Skilift braucht den Hang-Bauplatz unten am Berg.' }; + if (p.unique && liftBuilt) return { ok: false, reason: 'Es gibt schon einen Lift – baue ihn stattdessen aus.' }; + } else if (p.zone === 'berg') { + if (slot.zone !== 'berg') return { ok: false, reason: 'Die Almhütte gehört auf den Berg (Plätze oben am Gipfelweg).' }; + if (p.needsLift && !liftBuilt) return { ok: false, reason: 'Ohne Lift kommt hier niemand herauf – baue zuerst den Skilift.' }; + } else if (p.near === 'lake') { + if (slot.zone !== 'lake') return { ok: false, reason: 'Ein Badesteg braucht einen Platz direkt am Seeufer.' }; + } else if (p.near === 'road') { + if (slot.zone !== 'road') return { ok: false, reason: 'Muss an die Zufahrtsstraße – dort kommen Autos und Busse an.' }; + } else { + // normale Gebäude: Standardplätze; Aussichtspunkt darf auch auf den Berg + if (slot.zone === 'lake') return { ok: false, reason: 'Direkt am Wasser passt nur ein Badesteg.' }; + if (slot.zone === 'road') return { ok: false, reason: 'Die Plätze an der Zufahrt sind für Parkplatz und Bushaltestelle reserviert.' }; + if (slot.zone === 'hang') return { ok: false, reason: 'Dieser Platz am Hang ist für den Skilift reserviert.' }; + if (slot.zone === 'berg') { + if (type !== 'aussicht' && type !== 'almhuette') { + return { ok: false, reason: 'Hier oben passen nur Almhütte oder Aussichtspunkt.' }; + } + if (!liftBuilt) return { ok: false, reason: 'Ohne Lift kommt hier oben niemand vorbei.' }; + } + } + if (state.budget < def.cost[0]) { + return { ok: false, reason: `Zu teuer – ${def.name} kostet ${def.cost[0]} €.` }; + } + return { ok: true }; +} diff --git a/App/teacher.html b/App/teacher.html index feacacd..2a2b745 100644 --- a/App/teacher.html +++ b/App/teacher.html @@ -592,6 +592,38 @@ function _liveBar(val, min, max, goodWhen) { } var LIVE_PRIMARY_FIELDS = { + 'tourismusregion': [ + {k:'mapId', label:'Karte', align:'left', fmt:function(v){ + return {talschleife:'🏞 Talschleife', seerunde:'🚣 Seerunde', serpentinen:'⛰ Serpentinen', + kreuzung:'✚ Kreuzung', passstrasse:'🛣 Passstraße'}[v] || v || '—'; + }}, + {k:'wave', label:'Welle', fmt:function(v, row){ + var s = row.state || {}; + if (v === undefined) return '—'; + var status = s.waveStatus === 'pause' ? ' ⏸ Baupause' : ''; + return v + ' / ' + (s.waveTotal || 8) + '' + status; + }}, + {k:'season', label:'Saison', align:'center'}, + {k:'budget', label:'Budget', fmt:function(v){ + if (v === undefined) return '—'; + var color = v < 0 ? '#c0392b' : '#5a8a5e'; + return ''+Number(v).toLocaleString('de-AT')+' €'; + }}, + {k:'revenue', label:'Wertsch.', unit:'€'}, + {k:'avgStars', label:'Ø ★', fmt:function(v){ + if (v === undefined) return '—'; + var color = v >= 3.5 ? '#5a8a5e' : v >= 2.5 ? '#c9913a' : '#c07a6b'; + return ''+v.toFixed(2)+''; + }}, + {k:'env', label:'Umwelt', fmt:function(v){ return _liveBar(v, 0, 100, 'high'); }}, + {k:'visitors', label:'Gäste'}, + {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+''; + }}, + ], 'tourismustal': [ {k:'scene', label:'Szene', align:'left', fmt:function(v){ return {pionierzeit:'⛏️ 1975', boom:'📈 Boom 2000', klimawandel:'🌡️ 2035'}[v] || v || '—';