Tourismusregion: Komplett-Integration (zweite neue Sim, beta)

Wellenbasierte Routen-Sim (DefensegeograSimMac) nach dem Tourismustal-
Playbook integriert: Sim nach App/sims/tourismusregion (game.html,
Sprites 47MB->9.5MB, ohne Eigen-Backend/Editor/Dashboard/Keys),
Plattform-Hooks (GGS_LIVE_STATE mit Welle/Budget/Umwelt/Sterne/Lenkung,
Submit bei Partie-Ende mit fertigem 0-100-Score, GGS_TUTORIAL, Home-
Button, Musik leise). Wrapper + Modul-Detailseite. SQL: module_info
(beta, Karte), Glossar-Kernbegriff Besucherlenkung (+Bild, Leichte
Sprache) + 12 Zuordnungen, Lehrplan-Mapping auf tourismus-
raumentwicklung (primaer) + verkehrserziehung/systemisches-denken.
LEHRPLAN.md mit Schwerpunkt Besucherlenkung & Mobilitaet. Lehrer-
Backend: Benchmark (Sim-Score direkt), Live-Cockpit-Spalten,
needsHelp, Highlights.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 01:14:12 +02:00
parent 778634fab6
commit 8047778a5b
86 changed files with 5237 additions and 0 deletions
@@ -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.53.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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+9
View File
@@ -0,0 +1,9 @@
<?php
/**
* Modul-Detailseite: Tourismusregion
* URL: http://localhost/geograsim/App/modul-tourismusregion
*
* Logik im gemeinsamen Partial.
*/
$MODULE_ID = 'tourismusregion';
require __DIR__ . '/_partials/modul_detail.php';
+111
View File
@@ -0,0 +1,111 @@
<?php
/**
* Tourismusregion — Wrapper
* Laedt sims/tourismusregion/game.html und injiziert Base + Session-Mode.
*
* Integrations-Vertrag §6.1: mode + easy aus der Schueler-Session.
* Die Sim ist ES-Module-basiert mit relativen Pfaden (css/, js/, assets/) —
* statt Pfad-Rewrites setzen wir einen <base>-Tag auf den Sim-Ordner; damit
* loesen sich auch die zur Laufzeit gefetchten Audio-/Musik-Pfade korrekt auf.
*
* Stand 2026-07-11: Erst-Integration (beta) — Atlas.
*/
require_once __DIR__ . '/../php/config/app.php';
require_once __DIR__ . '/../php/config/db.php';
$db = getDB();
// ---- Mode-Check (Integrations-Vertrag §6.1) ----
$sessionId = $_COOKIE['ggs_session'] ?? null;
$mode = 'free';
$easy = false;
$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 <<<HTML
<!DOCTYPE html><html lang="de"><head>
<meta charset="utf-8"><title>Gesperrt · Tourismusregion</title>
<link rel="stylesheet" href="{$cssHref}">
<style>body{font-family:system-ui,sans-serif;padding:40px;max-width:640px;margin:auto;color:#333}
h1{color:#404040}.lock-card{background:#f5f3ea;border:1px solid #e2ddd1;padding:24px;border-radius:8px}</style>
</head><body>
<div class="lock-card">
<h1>🔒 Tourismusregion ist gesperrt.</h1>
<p>Deine Lehrperson hat dieses Modul für eure Klasse noch nicht freigegeben.</p>
<p>Im <a href="{$cockpit}">Cockpit</a> findest du die derzeit freigegebenen Module.</p>
</div>
</body></html>
HTML;
exit;
}
$gamePath = __DIR__ . '/../sims/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('</head>', "<script>$injection</script>\n</head>", $html);
}
// <base> auf den Sim-Ordner: loest css/, js/ (ES-Module), assets/…
// inklusive der zur Laufzeit gesetzten Audio-Quellen korrekt auf.
$base = BASE_PATH . '/sims/tourismusregion/';
$html = preg_replace('/<head>/', "<head>\n<base href=\"{$base}\">", $html, 1);
// Home-Button: auf das gerade gueltige Cockpit umbiegen
$cockpit = cockpit_href();
$html = str_replace("window.location.href='../../schueler.html'", "window.location.href='" . $cockpit . "'", $html);
if (function_exists('ggs_inject_live')) $html = ggs_inject_live($html, 'tourismusregion');
echo $html;
+2
View File
@@ -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;
};
+7
View File
@@ -74,6 +74,13 @@ $MODULE_HIGHLIGHTS = [
['key'=>'routes_completed','label'=>'Routen', 'agg'=>'max'],
['key'=>'avg_accuracy', 'label'=>'Genauigkeit','agg'=>'max','format'=>'percent'],
],
'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'],
+17
View File
@@ -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 0100-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.
*/
+100
View File
@@ -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 0100 (Start 75). Lift/Parkplatz kosten Umwelt,
Naturschutz/ÖV verbessern sie; Naturgruppen reagieren auf den Umweltfaktor.
Ein Naturschutzgebiet hebt die Umwelt, sperrt aber die umliegenden Bauplätze
— Zielkonflikt Wirtschaft ↔ Schutz.
- **Mobilität:** 3 Startpunkte (Straße West, Radweg Nord, Fußweg Süd),
Parkplatz vs. Bushaltestelle als bewusster Zielkonflikt.
- **Gesellschaft:** Verkehrsbelastung senkt Bewertungen empfindlicher Gruppen;
Social-Media-Feed macht Folgen der Entscheidungen sichtbar; ein
Statistik-Overlay mit **Heatmap der Besucherströme** zeigt, wo die Gäste
wirklich langgehen — und wo Angebote ins Leere laufen.
## Wichtigste Designregel (Pflichtenheft §25)
Nicht „Gäste melken": Einnahmen sind **Folge** gelungener Zufriedenheit
(Zufriedenheitsfaktor im Umsatz, Deckel = Gruppenbudget). Gewinnen heißt:
mind. Ø 3,5 Sterne, Wertschöpfung ≥ 2500, Umwelt ≥ 45, Schulden > 1000.
## Ablauf einer Partie (fixe Wellenfolge, deterministischer Seed)
Jede Welle läuft, bis alle ihre Gäste abgereist sind; dann folgt eine
**Baupause** (bauen & aufrüsten), danach startet die nächste Welle. „Nächste
Welle rufen" überspringt die Pause oder holt die nächste Welle früher (Bonus).
| Welle | Saison | Inhalt |
|---|---|---|
| 1 | Frühling | Spaziergänger/Wanderer · Start: Wirtshaus, Café, Marktstand, Aussicht |
| 2 | Frühling | Ankündigung Familien · frei: Spielplatz, WC, Badesteg |
| 3 | Sommer | Familien + Radfahrer · frei: Eisdiele, Radstation, Tourismusinfo |
| 4 | Sommer | Radfernweg eröffnet (Radwelle) · frei: Naturschutz · Entscheidung: Investor |
| 5 | Herbst | Senioren + Busgruppen + Schulklassen · frei: Museum, Parkplatz, Bushaltestelle, Souvenirshop |
| 6 | Herbst | Ankündigung Winter · Dorffest-Entscheidung · frei: Skilift, Almhütte |
| 7 | Winter | Junge Gruppen + wohlhabende Paare · frei: Wellnesshaus |
| 8 | Winter | Endwelle, starke Ströme → Endauswertung mit Regionsprofil + Lernfeedback |
Nach jeder Welle: Social-Media-Feed (echte Bewertungen der Abgereisten) und
Besucherfaktor `0.7 + Ø-Sterne/5` für die Folgewelle.
## Endauswertung
`Score = Bewertung×0.35 + Wertschöpfung×0.25 + Nachhaltigkeit×0.20 +
Besucherlenkung×0.10 + Finanzstabilität×0.10` → Regionsprofil (Nachhaltige
Modellregion, Massentourismus-, Familien-, Aktiv-, Kultur-, Krisenregion) +
rule-based formuliertes Lernfeedback und ergebnis­angepasste Reflexionsfragen (§24) für die Nachbesprechung. Ergebnisse landen im Lehrer-Dashboard (dashboard.html, Klassenmodus mit CSV-Export). Ergebnis speicherbar (localStorage,
optional PHP/MySQL via `api/save_score.php`).
## Technik (gemäß info.md, bewusst NICHT Three.js)
Vanilla-JS-ES-Module, Canvas 2D über **vorgerenderten 3D-Diorama-Wegkarten**
(gpt-image-1; 5 Karten mit handvermessenen Routen-Polylinien und festen
Bauplatz-Slots samt Slot-Sprite — klassisches Tower-Defense-Kartendesign),
KI-Sprites für Gebäude (Deluxe-Variante ab Stufe 4), Baustellen, Busse und
Besuchergruppen, prozedurale skelettale Walker-Figuren, WebAudio (Saison-
Ambience + Interaktionssounds je Dienstleistung: Biss/Schluck/Werkzeug/Kasse/
Platsch) + 15-Titel-Musik-Player, Telemetrie-Stub, deterministischer Zufall
(mulberry32), Touch-first (Pan/Pinch/Tap, Buttons ≥ 44 px), Pause bei jeder
Karte, Barrierearmut (Texte + Icons, hohe Kontraste, 🐢 reduzierte
Animationen). Ein separater **Karten-Editor** (`editor.html`) legt Pfad-Kurven
und Bauslots über die Artworks und speichert sie via `api/save_map.php` nach
`js/maps-custom.json`. Zielgeräte: ältere iPads (Canvas 2D statt WebGL) und PCs.
+32
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

+425
View File
@@ -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; }
+179
View File
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Tourismusregion entwickeln GeoGraSim</title>
<script>/*__TOURISMUSREGION_INJECTION__*/</script>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<canvas id="map"></canvas>
<header id="topbar" class="panel">
<button id="btnHome" class="icon-btn" title="Zurück zur Übersicht"></button>
<h1>Tourismusregion <span class="subtitle">entwickeln</span></h1>
<div id="waveStatus" class="wave-status">Welle 1 vorbereiten…</div>
<div class="top-right">
<div id="clock" class="clock">Welle 1/8</div>
<div class="speed" role="group" aria-label="Zeit">
<button data-speed="0" class="icon-btn speed-btn" title="Pause"></button>
<button data-speed="1" class="icon-btn speed-btn active" title="Normal">1×</button>
<button data-speed="2" class="icon-btn speed-btn" title="Doppelt">2×</button>
<button data-speed="3" class="icon-btn speed-btn" title="Dreifach">3×</button>
<button data-speed="4" class="icon-btn speed-btn" title="Vierfach">4×</button>
</div>
<button id="btnLog" class="icon-btn" title="Meldungen">🔔<span id="logBadge" class="badge hidden">0</span></button>
<button id="btnSound" class="icon-btn" title="Sound an/aus">🔊</button>
<button id="btnMusic" class="icon-btn" title="Musik-Player">🎵</button>
<button id="btnAnim" class="icon-btn" title="Reduzierte Animationen">🐢</button>
<button id="btnCamera" class="icon-btn" title="Ansicht zurücksetzen">🎯</button>
<button id="btnHelp" class="icon-btn" title="Anleitung">?</button>
</div>
</header>
<aside id="kpis" class="panel">
<div class="kpi-season" id="seasonBadge">🌸 Frühling</div>
<div id="repRow">
<div class="rep-head"><span>🏆 Ruf der Region</span><b id="repVal"></b></div>
<div class="rep-bar"><div id="repFill"></div></div>
</div>
<div class="kpi"><span>💰 Budget</span><b id="kMoney">500 €</b></div>
<div class="kpi"><span>🌲 Umwelt</span><b id="kEnv">75</b></div>
<div class="kpi"><span>🥾 Gäste jetzt</span><b id="kVisitors">0</b></div>
<div class="kpi"><span>🚗 Verkehr</span><b id="kTraffic">0</b></div>
<div class="kpi"><span>📈 Wertschöpfung</span><b id="kRevenue">0 €</b></div>
<div id="goals">
<h3>Ziele</h3>
<ul id="goalList"></ul>
</div>
<button id="btnStats" class="secondary slim">📊 Statistik</button>
<button id="btnNextWave" class="primary slim">⏩ Nächste Welle</button>
</aside>
<aside id="musicPanel" class="panel hidden">
<div class="music-row">
<button id="muPrev" class="icon-btn small" title="Voriger Titel"></button>
<button id="muPlay" class="icon-btn small" title="Abspielen/Pause"></button>
<button id="muNext" class="icon-btn small" title="Nächster Titel"></button>
<input id="muVol" type="range" min="0" max="1" step="0.05" value="0.28"
aria-label="Musik-Lautstärke">
</div>
<select id="muSelect" aria-label="Titel wählen"></select>
</aside>
<aside id="buildmenu" class="panel">
<h2>Bauen</h2>
<div id="buildItems"></div>
</aside>
<section id="infoPanel" class="panel hidden">
<div class="info-head">
<b id="infoTitle"></b>
<button id="infoClose" class="icon-btn small" title="Schließen"></button>
</div>
<div id="infoBody"></div>
<button id="btnUpgrade" class="primary slim hidden"></button>
<button id="btnDemolish" class="secondary slim">🧨 Abreißen (30 % zurück)</button>
</section>
<section id="graphMoney" class="panel graphbox">
<canvas id="graphMoneyCanvas"></canvas>
</section>
<section id="graphSat" class="panel graphbox">
<canvas id="graphSatCanvas"></canvas>
</section>
<div id="toast" class="panel hidden"></div>
<div id="cardOverlay" class="overlay hidden">
<div class="card panel">
<div id="cardIcon" class="card-icon"></div>
<h2 id="cardTitle"></h2>
<ul id="cardList" class="hidden"></ul>
<p id="cardText"></p>
<div class="card-buttons">
<button id="cardOk" class="primary">Weiter</button>
<button id="cardAlt" class="secondary hidden">Ablehnen</button>
</div>
</div>
</div>
<div id="logOverlay" class="overlay hidden">
<div class="card panel">
<h2>📨 Meldungen</h2>
<ul id="logList"></ul>
<button id="logClose" class="primary">Schließen</button>
</div>
</div>
<div id="statsOverlay" class="overlay hidden">
<div class="card panel end-card">
<h2>📊 Regions-Statistik</h2>
<div id="statsBody"></div>
<button id="statsClose" class="primary">Schließen</button>
</div>
</div>
<div id="startOverlay" class="overlay">
<div class="card panel">
<div class="card-icon">🏞️</div>
<h2>Tourismusregion entwickeln</h2>
<p class="intro">Besuchergruppen kommen in <b>8 Wellen</b> durch dein Tal.
Baue in den Baupausen die passende Infrastruktur, mach jede Gruppe
glücklich und halte Wirtschaft, Umwelt und Budget im Gleichgewicht.</p>
<input id="playerName" type="text" maxlength="40"
placeholder="Dein Name (für die Auswertung, optional)" autocomplete="off">
<input id="classCode" type="text" maxlength="20"
placeholder="Klassencode (optional)" autocomplete="off">
<h3 class="map-title">Wegkarte wählen</h3>
<div id="mapList"></div>
<button id="btnStart" class="primary">▶ Partie starten (8 Wellen)</button>
<p class="start-hint">Tipp: Gebäude wirken nur auf Gäste in ihrer Reichweite
baue nah an den Wegen!</p>
</div>
</div>
<div id="endOverlay" class="overlay hidden">
<div class="card panel end-card">
<div id="endProfileIcon" class="card-icon"></div>
<h2 id="endProfileName"></h2>
<p id="endProfileText" class="intro"></p>
<div id="endStars" class="end-stars"></div>
<div id="endBars"></div>
<ul id="endGoals" class="end-goals"></ul>
<p id="endFacts" class="end-facts"></p>
<div id="endFeedback" class="end-feedback"></div>
<div id="endReflect" class="end-reflect"></div>
<div class="card-buttons">
<button id="btnSave" class="primary">💾 Ergebnis speichern</button>
<button id="btnRestart" class="secondary">🔄 Nochmal spielen</button>
</div>
</div>
</div>
<script type="module" src="js/main.js"></script>
<script>
// Fallback: Per Doppelklick geöffnet (file://) laden ES-Module nicht.
window.addEventListener('load', function () {
setTimeout(function () {
if (window.__ttLoaded) return;
var card = document.querySelector('#startOverlay .card');
if (!card) return;
var warn = document.createElement('p');
warn.style.cssText = 'background:#fdecea;color:#a33227;border-radius:12px;'
+ 'padding:12px;font-size:14px;text-align:left;line-height:1.5';
warn.textContent = location.protocol === 'file:'
? '⚠️ Die Seite wurde als Datei geöffnet so kann die Simulation nicht '
+ 'starten. Bitte über einen Webserver aufrufen, z. B. XAMPP starten '
+ 'und http://localhost/DefensegeograSimMac/ öffnen.'
: '⚠️ Die Simulation konnte nicht geladen werden. Bitte Seite neu laden '
+ '(Strg/Cmd+R).';
card.appendChild(warn);
}, 1500);
});
</script>
</body>
</html>
+187
View File
@@ -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();
+614
View File
@@ -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;
}
+383
View File
@@ -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 = `<img src="${mdef.image}" alt=""><span><b>${mdef.icon} ${mdef.name}</b><small>${mdef.desc}</small></span>`;
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);
}
@@ -0,0 +1 @@
{}
+325
View File
@@ -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'];
+802
View File
@@ -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 46 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;
}
+79
View File
@@ -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]; },
};
+966
View File
@@ -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})`;
}
}
+35
View File
@@ -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;
},
};
+715
View File
@@ -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 = `
<span class="emoji">${def.emoji}</span>
<span class="info"><b>${def.name}</b><small>${def.desc}</small></span>
<span class="cost">${euro(def.cost[0])} €</span>`;
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) => `<option value="${i}">🎵 ${t.title}</option>`).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 `<div class="stat-group"><h3>${title}</h3>` + items.map(i =>
`<div class="stat-row"><span>${i.label}</span>`
+ `<div class="stat-bar"><div style="width:${Math.round(i.val / max * 100)}%;background:${i.color}"></div></div>`
+ `<b>${i.disp}</b></div>`).join('') + '</div>';
};
// 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 =
`<div class="stat-group"><h3>Heatmap der Besucherströme</h3>`
+ `<canvas id="heatCanvas" class="heat-canvas"></canvas></div>`
+ rows('Zufriedenheit je Gruppe (Ø Sterne)', groups)
+ rows('Wertschöpfung je Kategorie', revItems)
+ rows('Anreise der Gäste', trItems)
+ `<div class="stat-group"><h3>Bilanz</h3>`
+ `<div class="info-row"><span>Umwelt</span><b>${Math.round(s.env)}</b></div>`
+ `<div class="info-row"><span>Verkehr</span><b>${s.traffic}</b></div>`
+ `<div class="info-row"><span>Busgruppen ohne Parkplatz</span><b>${s.stats.skippedNoParking}</b></div>`
+ `<div class="info-row"><span>Gäste gesamt</span><b>${s.stats.spawned}</b></div></div>`;
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 ? `<div class="info-row"><span>Status</span><b class="warn">${busy}</b></div>` : ''}
<div class="info-row"><span>Wirkung</span><b>+${def.sat[lvl]} Zufriedenheit</b></div>
${def.slow[lvl] ? `<div class="info-row"><span>Verweilen</span><b>${Math.round(def.slow[lvl] * 100)} % Tempo</b></div>` : ''}
<div class="info-row"><span>Reichweite</span><b>${def.range[lvl]} Felder</b></div>
<div class="info-row"><span>Kapazität</span><b>${def.capacity[lvl] >= 999 ? 'unbegrenzt' : `${def.capacity[lvl]} Gäste`}</b></div>
${def.capacity[lvl] < 999 ? `<div class="info-row"><span>Auslastung</span><b class="${(b.inRange || 0) > def.capacity[lvl] ? 'warn' : ''}">${b.inRange || 0} / ${def.capacity[lvl]}${(b.inRange || 0) > def.capacity[lvl] ? ' voll!' : ''}</b></div>` : ''}
<div class="info-row"><span>Betriebskosten</span><b>${def.opCost[lvl]} € / min</b></div>
${goodFor ? `<div class="info-row"><span>Passt für</span><b>${goodFor}</b></div>` : ''}
${rev ? `<div class="info-row"><span>Umsatz bisher</span><b>${euro(rev)} €</b></div>` : ''}
${b.infoBoost > 1 ? `<div class="info-row"><span>Info-Bonus</span><b>+${Math.round((b.infoBoost - 1) * 100)} %</b></div>` : ''}
${b.synergy > 1 ? `<div class="info-row"><span>🔗 Synergie</span><b class="syn">+${Math.round((b.synergy - 1) * 100)} %</b></div>`
+ (b.synergyList || []).map(w => `<div class="syn-why">✓ ${w}</div>`).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 = `
<div class="info-row"><span>Zufriedenheit</span><b>${Math.round(Math.max(0, v.satisfaction))} / ${g.satTarget}</b></div>
<div class="info-row"><span>Budget übrig</span><b>${euro(g.budget - v.spent)} €</b></div>
<div class="info-row"><span>Interessen</span><b>${g.likes}</b></div>
<div class="info-row"><span>Bewertung derzeit</span><b>${'⭐'.repeat(stars)}</b></div>`;
$('btnUpgrade').classList.add('hidden');
$('btnDemolish').classList.add('hidden');
}
}
// ---------- Zwei Verlaufs-Graphen in eigenen Feldern ----------
// Feste 020-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 0100-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 = `<span class="feed-stars">${'⭐'.repeat(f.stars)}</span>`
+ `<span class="feed-text">${f.emoji}${f.text}"</span>`;
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 ? '' : '<li>Noch keine Meldungen.</li>';
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 || '️'} <b>${c.title}</b><small>${first}</small>`;
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]) => `
<div class="end-bar-row">
<span>${label}</span>
<div class="end-bar"><div style="width:${Math.round(v)}%;background:${color}"></div></div>
<b>${Math.round(v)}</b>
</div>`).join('');
$('endGoals').innerHTML = r.goals.map(g =>
`<li class="${g.done ? 'done' : ''}">${g.done ? '✓' : '✗'} ${g.label}</li>`).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 => `<p>${l}</p>`).join('');
$('endReflect').innerHTML = '<h3>🧠 Denk mal nach …</h3><ul>'
+ this._reflectionQuestions(s).map(q => `<li>${q}</li>`).join('') + '</ul>';
$('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);
}
}
+141
View File
@@ -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 };
}
+32
View File
@@ -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' ? ' <span style="color:#8a8a8a">⏸ Baupause</span>' : '';
return v + '<span style="color:#8a8a8a"> / ' + (s.waveTotal || 8) + '</span>' + 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 '<span style="color:'+color+';font-weight:700">'+Number(v).toLocaleString('de-AT')+' €</span>';
}},
{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 '<span style="color:'+color+';font-weight:700">'+v.toFixed(2)+'</span>';
}},
{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 '<span style="color:'+color+';font-weight:700">'+v+'/'+s.goalsTotal+'</span>';
}},
],
'tourismustal': [
{k:'scene', label:'Szene', align:'left', fmt:function(v){
return {pionierzeit:'⛏️ 1975', boom:'📈 Boom 2000', klimawandel:'🌡️ 2035'}[v] || v || '—';