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 ergebnisangepasste 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
+
+
+
+
+
+
+
+
+
+
+ 🌸 Frühling
+
+ 💰 Budget 500 €
+ 🌲 Umwelt 75
+ 🥾 Gäste jetzt 0
+ 🚗 Verkehr 0
+ 📈 Wertschöpfung 0 €
+
+ 📊 Statistik
+ ⏩ Nächste Welle
+
+
+
+
+
+
+
+
+
+ ✕
+
+
+
+ 🧨 Abreißen (30 % zurück)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Weiter
+ Ablehnen
+
+
+
+
+
+
+
📨 Meldungen
+
+
Schließen
+
+
+
+
+
+
📊 Regions-Statistik
+
+
Schließen
+
+
+
+
+
+
🏞️
+
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
+
+
▶ Partie starten (8 Wellen)
+
Tipp: Gebäude wirken nur auf Gäste in ihrer Reichweite –
+ baue nah an den Wegen!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 💾 Ergebnis speichern
+ 🔄 Nochmal spielen
+
+
+
+
+
+
+
+
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) => `🎵 ${t.title} `).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 … '
+ + this._reflectionQuestions(s).map(q => `${q} `).join('') + ' ';
+ $('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 || '—';