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>
@@ -0,0 +1,75 @@
|
||||
-- ============================================================
|
||||
-- Tourismusregion: Komplett-Integration (module_info + Glossar + Lehrplan)
|
||||
-- Stand: 2026-07-12 · Atlas · idempotent
|
||||
-- Schwester-Sim von Tourismustal (strikt getrennt): wellenbasierte
|
||||
-- Routen-Sim — Gäste ziehen über Wegkarten, Infrastruktur wirkt nur in
|
||||
-- Reichweite. Kern-Didaktik: Besucherlenkung, Mobilität, Standort am Weg.
|
||||
-- ============================================================
|
||||
|
||||
-- 1) Modul
|
||||
INSERT INTO module_info (
|
||||
module_id, title, subtitle, short_desc, long_desc, learning_goals,
|
||||
short_desc_easy, long_desc_easy, learning_goals_easy,
|
||||
duration_min, age_min, age_max, icon, card_image, page_url, play_url,
|
||||
status, sort_order
|
||||
) VALUES (
|
||||
'tourismusregion',
|
||||
'Tourismusregion',
|
||||
'Gäste lenken auf den Wegen deiner Region',
|
||||
'Gästegruppen ziehen über die Wege deiner Region – baue Wirtshäuser, Bahnen und Attraktionen in Reichweite, lenke Besucherströme und halte Umwelt und Budget im Lot.',
|
||||
'Acht Gästewellen ziehen über die Wegkarte deiner Region: Familien, Wandergruppen, Radfahrer:innen, Bus-Reisegruppen – jede mit eigenen Wünschen, eigenem Budget und eigenem Verkehrsmittel. Deine Gebäude wirken nur auf Gäste in ihrer Reichweite: Der Standort am Weg entscheidet. Zwischen den Wellen planst du in Baupausen, Ereignisse und A/B-Entscheidungen fordern dein Urteil. Fünf Wegkarten (Talschleife bis Passstraße), vier Saisonen und ein Endbericht mit Regionsprofil zeigen, ob du zufriedene Gäste, regionale Wertschöpfung, intakte Umwelt UND kluge Besucherlenkung geschafft hast.',
|
||||
'Besucherströme und Besucherlenkung verstehen · Standortwahl an Verkehrswegen begründen · Zielgruppen und ihre Mobilität unterscheiden (zu Fuß, Rad, Bus, Auto) · Wertschöpfung entlang einer Route erfassen · Zielkonflikte Umwelt/Verkehr/Wirtschaft abwägen (GW-Lehrplan 2023: 3.5–3.8, 3.17; übergreifend Verkehrs- und Mobilitätsbildung)',
|
||||
'Gäste wandern über die Wege. Baue Häuser an den Weg und lenke die Gäste gut.',
|
||||
'In deiner Region gibt es Wege. Auf den Wegen kommen Gäste: Familien, Wanderer, Rad-Fahrer, Bus-Gruppen. Baue Gast-Häuser und Attraktionen an die Wege. Nur Gäste in der Nähe nutzen sie. Nach 8 Gäste-Wellen siehst du: War deine Region gut?',
|
||||
'Du lernst: Wo baut man am besten? Wie lenkt man viele Besucher? Welche Gäste kommen wie an – zu Fuß, mit Rad oder Bus?',
|
||||
25, 12, 15,
|
||||
0xF09F97BAEFB88F,
|
||||
'card-tourismusregion.webp',
|
||||
'modul-tourismusregion',
|
||||
'tourismusregion',
|
||||
'beta', 95
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
title=VALUES(title), subtitle=VALUES(subtitle), short_desc=VALUES(short_desc),
|
||||
long_desc=VALUES(long_desc), learning_goals=VALUES(learning_goals),
|
||||
short_desc_easy=VALUES(short_desc_easy), long_desc_easy=VALUES(long_desc_easy),
|
||||
learning_goals_easy=VALUES(learning_goals_easy),
|
||||
duration_min=VALUES(duration_min), age_min=VALUES(age_min), age_max=VALUES(age_max),
|
||||
icon=VALUES(icon), card_image=VALUES(card_image), page_url=VALUES(page_url),
|
||||
play_url=VALUES(play_url), status=VALUES(status), sort_order=VALUES(sort_order);
|
||||
|
||||
-- 2) Glossar: neuer Kernbegriff Besucherlenkung
|
||||
INSERT INTO glossar (key_slug, title, short, text, short_easy, text_easy, category, sort_order, image_path) VALUES
|
||||
('besucherlenkung', 'Besucherlenkung',
|
||||
'Besucherströme gezielt steuern – durch Wege, Angebote, Beschilderung und Mobilität – damit weder Natur noch Orte überlastet werden.',
|
||||
'Besucherlenkung heißt: Eine Region steuert, WO und WIE sich Gäste bewegen – über Wegenetze, Attraktions-Standorte, Beschilderung, Parkraum und öffentliche Anreise (Bus statt Auto). Gute Lenkung verteilt Ströme, schützt sensible Naturräume und entlastet Ortskerne; schlechte Lenkung erzeugt Staus, Trampelpfade und Übertourismus-Hotspots. Werkzeuge reichen von Bushaltestellen und attraktiven Alternativrouten bis zu Obergrenzen an Ausflugszielen.',
|
||||
'Man lenkt, wohin die Gäste gehen – damit es nirgends zu voll wird.',
|
||||
'Viele Gäste an einem Ort sind ein Problem. Besucher-Lenkung heißt: Man verteilt die Gäste. Zum Beispiel mit Wegen, Schildern und Bussen. So bleibt die Natur heil und kein Ort ist zu voll.',
|
||||
'gesellschaft', 215,
|
||||
'assets/img/glossar/besucherlenkung.webp')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
title=VALUES(title), short=VALUES(short), text=VALUES(text),
|
||||
short_easy=VALUES(short_easy), text_easy=VALUES(text_easy),
|
||||
category=VALUES(category), sort_order=VALUES(sort_order), image_path=VALUES(image_path);
|
||||
|
||||
-- Modul-Zuordnung: neuer Begriff + passende bestehende Begriffe
|
||||
INSERT INTO glossar_modules (glossar_id, module_id)
|
||||
SELECT id, 'tourismusregion' FROM glossar WHERE key_slug IN (
|
||||
'besucherlenkung',
|
||||
'standortfaktor','angebot-nachfrage','wertschoepfung','infrastruktur',
|
||||
'saisonalitaet','uebertourismus','raumnutzungskonflikt','diversifizierung',
|
||||
'sanfter-tourismus','tagesausgaben','tourismus','nachhaltigkeit'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE module_id=VALUES(module_id);
|
||||
|
||||
-- 3) Lehrplan: Modul an bestehende Kompetenzen hängen
|
||||
-- (Anker hängen an den Kompetenzen und gelten damit automatisch mit.)
|
||||
INSERT INTO kompetenz_modules (kompetenz_id, module_id, level, strength)
|
||||
SELECT id, 'tourismusregion', 0, 'primaer' FROM kompetenzen WHERE key_slug='tourismus-raumentwicklung'
|
||||
ON DUPLICATE KEY UPDATE strength=VALUES(strength);
|
||||
INSERT INTO kompetenz_modules (kompetenz_id, module_id, level, strength)
|
||||
SELECT id, 'tourismusregion', 0, 'sekundaer' FROM kompetenzen WHERE key_slug='verkehrserziehung-sicherheit'
|
||||
ON DUPLICATE KEY UPDATE strength=VALUES(strength);
|
||||
INSERT INTO kompetenz_modules (kompetenz_id, module_id, level, strength)
|
||||
SELECT id, 'tourismusregion', 0, 'sekundaer' FROM kompetenzen WHERE key_slug='systemisches-denken'
|
||||
ON DUPLICATE KEY UPDATE strength=VALUES(strength);
|
||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -53,6 +53,7 @@ class Benchmark
|
||||
'fluggesellschaft' => self::scoreFluggesellschaft($results),
|
||||
'kofferdetektiv' => self::scoreKofferdetektiv($results),
|
||||
'tourismustal' => self::scoreTourismustal($results),
|
||||
'tourismusregion' => self::scoreTourismusregion($results),
|
||||
// Entscheidungstag: kein assessment-Submit-Pfad bekannt → null
|
||||
default => self::scoreFallbackStars($results),
|
||||
};
|
||||
@@ -459,6 +460,22 @@ class Benchmark
|
||||
return (int)round(max(0, min(100, $score)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tourismusregion — die Sim berechnet selbst einen gewichteten 0–100-Score
|
||||
* (Bewertung 35 % + Wertschöpfung 25 % + Umwelt 20 % + Besucherlenkung 10 %
|
||||
* + Finanzen 10 %, model.js finish()). Den übernehmen wir direkt.
|
||||
*/
|
||||
private static function scoreTourismusregion(array $r): ?int
|
||||
{
|
||||
$score = $r['score'] ?? null;
|
||||
if (is_numeric($score)) return (int)round(max(0, min(100, $score)));
|
||||
// Fallback: Zielerreichung
|
||||
$done = $r['goalsDone'] ?? null;
|
||||
$total = (int)($r['goalsTotal'] ?? 0);
|
||||
if (!is_numeric($done) || $total <= 0) return null;
|
||||
return (int)round(($done / $total) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generischer Fallback: Sterne aus 5-er-Skala.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# KONZEPT.md — „Tourismusregion entwickeln"
|
||||
|
||||
Browserbasierte Lernsimulation für **Geographie und wirtschaftliche Bildung**
|
||||
(Sekundarstufe I, Österreich). Eine Partie läuft **wellenbasiert** über
|
||||
**8 Gästewellen** mit Baupausen dazwischen (kein fixer Zeittakt) und
|
||||
überträgt die Logik eines Tower-Defense-Spiels friedlich auf die Entwicklung
|
||||
einer alpinen Tourismusregion. Basis: `.humaninput/pflichtenheft.txt`,
|
||||
Bau-Blaupause: `.humaninput/info.md` (Referenz: Tourismustal v1).
|
||||
|
||||
## Spielidee in einem Satz
|
||||
|
||||
Besuchergruppen ziehen sichtbar über Wege durch eine Iso-Landschaft; die
|
||||
Spielerin baut Infrastruktur **in Reichweite der Wege**, erhöht damit die
|
||||
Zufriedenheit passender Zielgruppen, erzeugt regionale Wertschöpfung — und
|
||||
muss Umwelt, Verkehr und Budget im Gleichgewicht halten.
|
||||
|
||||
## Tower-Defense-Übersetzung
|
||||
|
||||
| Tower Defense | Simulation |
|
||||
|---|---|
|
||||
| Gegnerwellen | Besuchergruppen je Welle (8 Wellen, Baupause dazwischen) |
|
||||
| Bauphase zwischen Wellen | Baupause: bauen & aufrüsten; „Nächste Welle rufen" startet früher, mit Bonus |
|
||||
| Bauzeit der Türme | Baustellenzeit: Bau/Ausbau dauert (Fortschrittsbalken) — währenddessen kein Betrieb, keine Kosten |
|
||||
| Türme | 18 Infrastrukturtypen mit je bis zu 5 Ausbaustufen |
|
||||
| Turm-Synergien | Benachbarte passende Gebäude verstärken sich gegenseitig |
|
||||
| Schaden | Zufriedenheitsgewinn (nur bei passender Zielgruppe/Saison/Lage) |
|
||||
| Turm-Kapazität | Jedes Gebäude bedient nur N nächstgelegene Gäste, der Rest geht leer aus |
|
||||
| Lebenspunkte | Zufriedenheitsbedarf der Gruppe |
|
||||
| Verlangsamung | Aufenthaltsverlängerer (Café, Marktstand, Promenade) |
|
||||
| Fluggegner | Spezialgruppen (Radfahrer schnell, Luxuspaar anspruchsvoll) |
|
||||
| Neue Pfade | Skilift erschließt Berg-Rundweg mit neuen Bauplätzen |
|
||||
| Geld | Gemeindebudget: Baukosten, Betriebskosten, Einnahmen |
|
||||
| Basis-HP | Ruf-Leiste: Ø der letzten Abreisen — steuert die Besucherzahl der Folgewelle |
|
||||
|
||||
## Didaktischer Kern (Lehrplan GWB 2023 als Mechanik)
|
||||
|
||||
- **Raum & Orientierung:** Lage wirkt — Reichweiten, Wegenähe, Hang/See/Ortskern.
|
||||
- **Wirtschaftliche Bildung:** Investition, Betriebskosten (laufend!), Amortisation,
|
||||
Zielgruppen, Angebot/Nachfrage.
|
||||
- **Tourismus & Saisonalität:** 4 Saisonen zu je 2 Wellen mit echten Faktoren
|
||||
(Badesteg ×1.6 im Sommer, Skilift ×1.5 im Winter).
|
||||
- **Nachhaltigkeit:** Umweltwert 0–100 (Start 75). Lift/Parkplatz kosten Umwelt,
|
||||
Naturschutz/ÖV verbessern sie; Naturgruppen reagieren auf den Umweltfaktor.
|
||||
Ein Naturschutzgebiet hebt die Umwelt, sperrt aber die umliegenden Bauplätze
|
||||
— Zielkonflikt Wirtschaft ↔ Schutz.
|
||||
- **Mobilität:** 3 Startpunkte (Straße West, Radweg Nord, Fußweg Süd),
|
||||
Parkplatz vs. Bushaltestelle als bewusster Zielkonflikt.
|
||||
- **Gesellschaft:** Verkehrsbelastung senkt Bewertungen empfindlicher Gruppen;
|
||||
Social-Media-Feed macht Folgen der Entscheidungen sichtbar; ein
|
||||
Statistik-Overlay mit **Heatmap der Besucherströme** zeigt, wo die Gäste
|
||||
wirklich langgehen — und wo Angebote ins Leere laufen.
|
||||
|
||||
## Wichtigste Designregel (Pflichtenheft §25)
|
||||
|
||||
Nicht „Gäste melken": Einnahmen sind **Folge** gelungener Zufriedenheit
|
||||
(Zufriedenheitsfaktor im Umsatz, Deckel = Gruppenbudget). Gewinnen heißt:
|
||||
mind. Ø 3,5 Sterne, Wertschöpfung ≥ 2500, Umwelt ≥ 45, Schulden > −1000.
|
||||
|
||||
## Ablauf einer Partie (fixe Wellenfolge, deterministischer Seed)
|
||||
|
||||
Jede Welle läuft, bis alle ihre Gäste abgereist sind; dann folgt eine
|
||||
**Baupause** (bauen & aufrüsten), danach startet die nächste Welle. „Nächste
|
||||
Welle rufen" überspringt die Pause oder holt die nächste Welle früher (Bonus).
|
||||
|
||||
| Welle | Saison | Inhalt |
|
||||
|---|---|---|
|
||||
| 1 | Frühling | Spaziergänger/Wanderer · Start: Wirtshaus, Café, Marktstand, Aussicht |
|
||||
| 2 | Frühling | Ankündigung Familien · frei: Spielplatz, WC, Badesteg |
|
||||
| 3 | Sommer | Familien + Radfahrer · frei: Eisdiele, Radstation, Tourismusinfo |
|
||||
| 4 | Sommer | Radfernweg eröffnet (Radwelle) · frei: Naturschutz · Entscheidung: Investor |
|
||||
| 5 | Herbst | Senioren + Busgruppen + Schulklassen · frei: Museum, Parkplatz, Bushaltestelle, Souvenirshop |
|
||||
| 6 | Herbst | Ankündigung Winter · Dorffest-Entscheidung · frei: Skilift, Almhütte |
|
||||
| 7 | Winter | Junge Gruppen + wohlhabende Paare · frei: Wellnesshaus |
|
||||
| 8 | Winter | Endwelle, starke Ströme → Endauswertung mit Regionsprofil + Lernfeedback |
|
||||
|
||||
Nach jeder Welle: Social-Media-Feed (echte Bewertungen der Abgereisten) und
|
||||
Besucherfaktor `0.7 + Ø-Sterne/5` für die Folgewelle.
|
||||
|
||||
## Endauswertung
|
||||
|
||||
`Score = Bewertung×0.35 + Wertschöpfung×0.25 + Nachhaltigkeit×0.20 +
|
||||
Besucherlenkung×0.10 + Finanzstabilität×0.10` → Regionsprofil (Nachhaltige
|
||||
Modellregion, Massentourismus-, Familien-, Aktiv-, Kultur-, Krisenregion) +
|
||||
rule-based formuliertes Lernfeedback und ergebnisangepasste Reflexionsfragen (§24) für die Nachbesprechung. Ergebnisse landen im Lehrer-Dashboard (dashboard.html, Klassenmodus mit CSV-Export). Ergebnis speicherbar (localStorage,
|
||||
optional PHP/MySQL via `api/save_score.php`).
|
||||
|
||||
## Technik (gemäß info.md, bewusst NICHT Three.js)
|
||||
|
||||
Vanilla-JS-ES-Module, Canvas 2D über **vorgerenderten 3D-Diorama-Wegkarten**
|
||||
(gpt-image-1; 5 Karten mit handvermessenen Routen-Polylinien und festen
|
||||
Bauplatz-Slots samt Slot-Sprite — klassisches Tower-Defense-Kartendesign),
|
||||
KI-Sprites für Gebäude (Deluxe-Variante ab Stufe 4), Baustellen, Busse und
|
||||
Besuchergruppen, prozedurale skelettale Walker-Figuren, WebAudio (Saison-
|
||||
Ambience + Interaktionssounds je Dienstleistung: Biss/Schluck/Werkzeug/Kasse/
|
||||
Platsch) + 15-Titel-Musik-Player, Telemetrie-Stub, deterministischer Zufall
|
||||
(mulberry32), Touch-first (Pan/Pinch/Tap, Buttons ≥ 44 px), Pause bei jeder
|
||||
Karte, Barrierearmut (Texte + Icons, hohe Kontraste, 🐢 reduzierte
|
||||
Animationen). Ein separater **Karten-Editor** (`editor.html`) legt Pfad-Kurven
|
||||
und Bauslots über die Artworks und speichert sie via `api/save_map.php` nach
|
||||
`js/maps-custom.json`. Zielgeräte: ältere iPads (Canvas 2D statt WebGL) und PCs.
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 311 KiB |
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 @@
|
||||
{}
|
||||
@@ -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'];
|
||||
@@ -0,0 +1,802 @@
|
||||
// Simulationskern: Spielzustand + tick(state, dt). Enthält Spawning,
|
||||
// Bewegung entlang der Routen, Infrastrukturwirkung (§11: Zielgruppen-,
|
||||
// Saison-, Umwelt-, Kapazitätsfaktor, Bremsen, Einwirkzeit), Ökonomie,
|
||||
// Bewertung beim Verlassen, Phasen/Saisonen, Ereignisse und Endauswertung.
|
||||
// Komplett DOM-frei – läuft auch im Headless-Test (tests/model-test.mjs).
|
||||
|
||||
import {
|
||||
GAME_LEN, PHASE_LEN, SEASON_LEN, SEASONS, SEASON_KEYS,
|
||||
CONTACT_NORM, MAX_GAIN_FACTOR, MAX_BRAKE, START_BUDGET, START_ENV,
|
||||
GROUPS, BUILDINGS, WAVES, EVENTS, FEED_TEXTS, PROFILES, GOALS, SYNERGIES, currentAvg,
|
||||
} from './data.js';
|
||||
import {
|
||||
prepMap, mulberry32, makeRoute, cumLengths, TILE2PX, canPlace as worldCanPlace,
|
||||
} from './world.js';
|
||||
|
||||
export { GOALS, currentAvg };
|
||||
export const canPlace = worldCanPlace;
|
||||
|
||||
const LIFT_SPEED = 1.4; // Kacheln/s in der Liftfahrt
|
||||
const REV_SCALE = 0.42; // globale Einnahmen-Skala (Balancing: Geld darf nicht trivial werden)
|
||||
|
||||
export function createState(opts = {}) {
|
||||
const seed = opts.seed ?? 20260703;
|
||||
const map = prepMap(opts.mapId || 'talschleife');
|
||||
const state = {
|
||||
map,
|
||||
slots: map.slots,
|
||||
playerName: opts.playerName || '',
|
||||
classCode: opts.classCode || '',
|
||||
time: 0, phase: 0, season: SEASONS[0], seasonKey: SEASON_KEYS[0],
|
||||
ended: false, result: null,
|
||||
budget: START_BUDGET, revenueTotal: 0, opCostTotal: 0,
|
||||
env: START_ENV, traffic: 0,
|
||||
fame: 1.0, festUntil: 0, natureBoost: 1,
|
||||
buildings: [], nextBid: 1,
|
||||
visitors: [], nextVid: 1,
|
||||
unlocked: new Set(),
|
||||
spawnQueue: [],
|
||||
phaseExitStars: [],
|
||||
feedPool: [],
|
||||
pendingCards: [],
|
||||
firedEvents: new Set(),
|
||||
stats: {
|
||||
all: { count: 0, stars: 0 },
|
||||
groups: {},
|
||||
buildingRevenue: {},
|
||||
transports: { foot: 0, bike: 0, bus: 0, car: 0 },
|
||||
exits: { E: 0, SE: 0 },
|
||||
spawned: 0, skippedNoParking: 0,
|
||||
},
|
||||
fx: [],
|
||||
heat: new Float32Array(48 * 32), heatCols: 48, heatRows: 32,
|
||||
history: { t: [], budget: [], rating: [], env: [], visitors: [], satPct: [] },
|
||||
lastSatPct: 0,
|
||||
actionLog: [],
|
||||
opAccum: 0, sampleAccum: 0,
|
||||
waveStatus: 'pause', pauseLeft: 22, waveStartTime: 0,
|
||||
recentStars: [],
|
||||
rng: mulberry32(seed + 7),
|
||||
};
|
||||
prepareWave(state, 1, { silent: true });
|
||||
return state;
|
||||
}
|
||||
|
||||
// ---------- Haupt-Tick ----------
|
||||
export function tick(state, dt) {
|
||||
if (state.ended || dt <= 0) return;
|
||||
state.time += dt;
|
||||
|
||||
// ---------- Wellen-Maschine (Tower-Defense-Rhythmus) ----------
|
||||
// Baupause → Welle läuft → alle Gäste der Welle abgereist → nächste
|
||||
// Baupause. „Nächste Welle rufen" startet früher (mit Bonus).
|
||||
if (state.waveStatus === 'pause') {
|
||||
state.pauseLeft -= dt;
|
||||
if (state.pauseLeft <= 0) launchWave(state);
|
||||
} else if (state.waveStatus === 'running') {
|
||||
const active = state.spawnQueue.length > 0
|
||||
|| state.visitors.some(v => v.wave === state.phase);
|
||||
if (!active) {
|
||||
if (state.phase >= 8) { finish(state); return; }
|
||||
prepareWave(state, state.phase + 1);
|
||||
state.waveStatus = 'pause';
|
||||
state.pauseLeft = 16;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Baustellen: Bau- und Ausbauzeit ----------
|
||||
for (const b of state.buildings) {
|
||||
if (b.construction > 0) {
|
||||
b.construction -= dt;
|
||||
if (b.construction <= 0) {
|
||||
b.construction = 0;
|
||||
state.pendingCards.push({ kind: 'built', type: b.type, level: b.level });
|
||||
}
|
||||
} else if (b.upgrading) {
|
||||
b.upgrading.left -= dt;
|
||||
if (b.upgrading.left <= 0) {
|
||||
b.level++;
|
||||
state.env = clampEnv(state.env + (BUILDINGS[b.type].env[b.level - 1] || 0));
|
||||
b.upgrading = null;
|
||||
refreshInfoBoosts(state);
|
||||
state.pendingCards.push({ kind: 'built', type: b.type, level: b.level });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spawnDue(state);
|
||||
moveAndAffect(state, dt);
|
||||
|
||||
// Betriebskosten alle 10 s (§18.7)
|
||||
state.opAccum += dt;
|
||||
while (state.opAccum >= 10) {
|
||||
state.opAccum -= 10;
|
||||
let cost = 0;
|
||||
for (const b of state.buildings) {
|
||||
if (b.construction > 0) continue; // Baustelle: noch keine Betriebskosten
|
||||
cost += BUILDINGS[b.type].opCost[b.level - 1];
|
||||
}
|
||||
cost /= 6; // pro Minute → pro 10 s
|
||||
state.budget -= cost;
|
||||
state.opCostTotal += cost;
|
||||
}
|
||||
|
||||
// Verkehr: Parkplätze + aktive Auto-/Busgruppen
|
||||
let traffic = 0;
|
||||
for (const b of state.buildings) {
|
||||
if (b.type === 'parkplatz') traffic += BUILDINGS.parkplatz.traffic[b.level - 1];
|
||||
}
|
||||
for (const v of state.visitors) {
|
||||
const tr = GROUPS[v.type].transport;
|
||||
if (tr === 'car') traffic += 1.5;
|
||||
else if (tr === 'bus') traffic += 2.5;
|
||||
}
|
||||
state.traffic = Math.round(traffic);
|
||||
|
||||
// Verlauf (für den Graphen) alle 5 s
|
||||
state.sampleAccum += dt;
|
||||
if (state.sampleAccum >= 5) {
|
||||
state.sampleAccum = 0;
|
||||
const h = state.history;
|
||||
h.t.push(state.time);
|
||||
h.budget.push(Math.round(state.budget));
|
||||
h.rating.push(Number(currentAvg(state).toFixed(2)));
|
||||
h.env.push(Math.round(state.env));
|
||||
h.visitors.push(state.visitors.length);
|
||||
// Live-Erfüllungsgrad: wie „voll" sind die Zufriedenheitsbalken gerade?
|
||||
// (Ziel wie im Tower Defense: 100 % = Gast komplett glücklich)
|
||||
if (state.visitors.length) {
|
||||
const mean = state.visitors.reduce((a, v) =>
|
||||
a + Math.max(0, Math.min(1.1, v.satisfaction / GROUPS[v.type].satTarget)), 0) / state.visitors.length;
|
||||
state.lastSatPct = Math.round(mean * 100);
|
||||
}
|
||||
h.satPct.push(state.lastSatPct || 0);
|
||||
}
|
||||
|
||||
if (state.time > 2400) finish(state); // Sicherheitsdeckel
|
||||
}
|
||||
|
||||
// ---------- Wellen-Vorbereitung: Ruhm, Feed, Freischaltungen, Plan ----------
|
||||
function prepareWave(state, phase, opts = {}) {
|
||||
// Besucherfaktor aus den Bewertungen der Vorphase (§8.4)
|
||||
if (state.phaseExitStars.length) {
|
||||
const avg = state.phaseExitStars.reduce((a, b) => a + b, 0) / state.phaseExitStars.length;
|
||||
state.fame = 0.7 + avg / 5;
|
||||
// Social-Media-Feed der Vorphase
|
||||
const picks = pickFeed(state);
|
||||
if (picks.length) {
|
||||
state.pendingCards.push({
|
||||
kind: 'feed', icon: '📱', phase: state.phase,
|
||||
title: `Bewertungen aus Phase ${state.phase}`,
|
||||
items: picks, avg, fame: state.fame,
|
||||
});
|
||||
}
|
||||
}
|
||||
state.phaseExitStars = [];
|
||||
state.feedPool = [];
|
||||
state.phase = phase;
|
||||
|
||||
// Saison hängt an der Welle (2 Wellen je Jahreszeit)
|
||||
const si = Math.min(3, Math.floor((phase - 1) / 2));
|
||||
if (SEASON_KEYS[si] !== state.seasonKey) {
|
||||
state.seasonKey = SEASON_KEYS[si];
|
||||
state.season = SEASONS[si];
|
||||
state.pendingCards.push({
|
||||
kind: 'season', icon: ['🌸', '☀️', '🍂', '❄️'][si],
|
||||
title: `Saisonwechsel: ${state.season}`,
|
||||
text: {
|
||||
summer: 'Der Sommer ist da! Badesee, Eis, Rad und Wandern sind jetzt besonders gefragt.',
|
||||
autumn: 'Herbst: Kulinarik, Kultur und Wandern haben Hochsaison – Badespaß ist vorbei.',
|
||||
winter: 'Winter! Jetzt zählen Lift, Almhütte, Gasthaus und Aussicht. Der See ist zugefroren.',
|
||||
}[state.seasonKey] || '',
|
||||
});
|
||||
}
|
||||
|
||||
// Ankündigungen/Entscheidungen dieser Welle
|
||||
for (const ev of EVENTS) {
|
||||
if (ev.wave === phase && !state.firedEvents.has(ev.id)) {
|
||||
state.firedEvents.add(ev.id);
|
||||
state.pendingCards.push({ kind: ev.type, ...ev });
|
||||
}
|
||||
}
|
||||
|
||||
// Freischaltungen
|
||||
const fresh = Object.entries(BUILDINGS)
|
||||
.filter(([id, d]) => d.unlockPhase === phase && !state.unlocked.has(id));
|
||||
for (const [id] of fresh) state.unlocked.add(id);
|
||||
|
||||
// Welle planen
|
||||
const wave = WAVES[phase] || {};
|
||||
const parkPlus = sumBonus(state, 'parkplatz', 'visitorPlus');
|
||||
const busPlus = sumBonus(state, 'bushaltestelle', 'busPlus');
|
||||
const hasParking = state.buildings.some(b => b.type === 'parkplatz');
|
||||
state.spawnQueue = [];
|
||||
const waveCounts = [];
|
||||
let slot = 0;
|
||||
const t0 = 0; // relativ – launchWave addiert die echte Startzeit
|
||||
for (const [type, base] of Object.entries(wave)) {
|
||||
const g = GROUPS[type];
|
||||
let mult = state.fame;
|
||||
if (g.transport === 'car') mult *= 1 + parkPlus;
|
||||
if (g.transport === 'bus') mult *= 1 + busPlus + parkPlus * 0.5;
|
||||
let n = Math.max(1, Math.round(base * mult));
|
||||
if (g.needsParking && !hasParking) {
|
||||
const skipped = Math.floor(n / 2);
|
||||
n -= skipped;
|
||||
state.stats.skippedNoParking += skipped;
|
||||
if (skipped > 0) state.feedPool.push({ stars: 1, emoji: g.emoji, text: FEED_TEXTS.noParking, prio: 3 });
|
||||
}
|
||||
waveCounts.push(`${n}× ${GROUPS[type].name}`);
|
||||
// Konvoi statt Streuung: Die Welle einer Gruppe kommt gebündelt kurz
|
||||
// hintereinander (alle 4–6 s) und nimmt dieselbe Route – so ist sie
|
||||
// als zusammengehörende Welle erkennbar.
|
||||
// Konvois innerhalb der Welle: Gruppe für Gruppe, eng beieinander
|
||||
const clusterStart = t0 + 1 + slot * (n * 1.2 + 9);
|
||||
const routePick = state.rng();
|
||||
state.spawnQueue.push({ t: Math.max(t0 + 0.5, clusterStart - 1.2), banner: { type, n } });
|
||||
for (let i = 0; i < n; i++) {
|
||||
state.spawnQueue.push({ t: clusterStart + i * (2.4 + state.rng() * 0.8), type, routePick });
|
||||
}
|
||||
slot++;
|
||||
}
|
||||
state.spawnQueue.sort((a, b) => a.t - b.t);
|
||||
|
||||
// Wellen-Ankündigung: Wer kommt? (Porträts neuer Gruppen) + neue Bauoptionen
|
||||
const newcomers = Object.keys(wave).filter(t => GROUPS[t].fromPhase === phase);
|
||||
let text = `Erwartete Gäste: ${waveCounts.join(' · ')}.`;
|
||||
for (const t of newcomers) {
|
||||
const g = GROUPS[t];
|
||||
text += `\n\n${g.emoji} ${g.name}: ${g.who}`;
|
||||
}
|
||||
if (fresh.length) {
|
||||
text += `\n\n🔓 Neu baubar: ${fresh.map(([, d]) => `${d.emoji} ${d.name}`).join(' · ')}`;
|
||||
}
|
||||
state.pendingCards.push({
|
||||
kind: 'phase', icon: '👥',
|
||||
title: phase === 1 ? 'Phase 1: Die ersten Gäste kommen' : `Phase ${phase}: Neue Welle im Anmarsch`,
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
// Welle wirklich starten: geplante Spawns auf die aktuelle Zeit schieben
|
||||
function launchWave(state) {
|
||||
for (const e of state.spawnQueue) e.t += state.time;
|
||||
state.waveStatus = 'running';
|
||||
state.waveStartTime = state.time;
|
||||
state.pauseLeft = 0;
|
||||
}
|
||||
|
||||
function sumBonus(state, type, key) {
|
||||
let sum = 0;
|
||||
for (const b of state.buildings) {
|
||||
if (b.type === type) sum += BUILDINGS[type][key][b.level - 1];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
// ---------- Spawning ----------
|
||||
function spawnDue(state) {
|
||||
if (state.waveStatus !== 'running') return;
|
||||
while (state.spawnQueue.length && state.spawnQueue[0].t <= state.time) {
|
||||
const { type, routePick, banner } = state.spawnQueue.shift();
|
||||
if (banner) {
|
||||
state.pendingCards.push({ kind: 'banner', type: banner.type, n: banner.n });
|
||||
continue;
|
||||
}
|
||||
if (state.visitors.length >= 120) continue; // Performance-Deckel
|
||||
const g = GROUPS[type];
|
||||
const route = makeRoute(state, g, state.rng, routePick);
|
||||
const hasParking = state.buildings.some(b => b.type === 'parkplatz');
|
||||
// Busgruppen fahren im Bus bis zur Haltestelle und steigen dort aus
|
||||
let travel = 'walk', busStopS = 0;
|
||||
if (g.transport === 'bus') {
|
||||
const stop = state.buildings.find(b => b.type === 'bushaltestelle');
|
||||
if (stop) {
|
||||
let bestD = Infinity;
|
||||
for (let i = 0; i < route.pts.length; i++) {
|
||||
const d = Math.hypot(route.pts[i].x - stop.x, route.pts[i].y - stop.y);
|
||||
if (d < bestD && route.cum[i] > 40) { bestD = d; busStopS = route.cum[i]; }
|
||||
}
|
||||
if (bestD < 260 && busStopS < route.cum[route.cum.length - 1] - 60) travel = 'bus';
|
||||
}
|
||||
}
|
||||
state.visitors.push({
|
||||
wave: state.phase,
|
||||
travel, busStopS, busWait: 0,
|
||||
id: state.nextVid++,
|
||||
type,
|
||||
pts: route.pts,
|
||||
cum: route.cum,
|
||||
exitLabel: route.exit,
|
||||
s: 0, seg: 0,
|
||||
x: route.pts[0].x, y: route.pts[0].y,
|
||||
satisfaction: g.needsParking && !hasParking ? -10 : 0,
|
||||
revFx: {},
|
||||
spent: 0,
|
||||
gained: {},
|
||||
hadService: false,
|
||||
fxNext: {},
|
||||
brake: 0,
|
||||
timeIn: 0,
|
||||
mode: 'walk',
|
||||
wobble: state.rng() * 7,
|
||||
});
|
||||
state.stats.spawned++;
|
||||
state.stats.transports[g.transport]++;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Bewegung + Infrastrukturwirkung ----------
|
||||
// Tower-Defense-Kapazität (§11.7, verschärft): Jedes Gebäude bedient nur so
|
||||
// viele Gäste, wie es Kapazität hat – die nächstgelegenen zuerst. Wer keinen
|
||||
// Platz bekommt, geht leer aus (sichtbar: Auslastungsbogen + Bedien-Bögen).
|
||||
function moveAndAffect(state, dt) {
|
||||
const bs = state.buildings;
|
||||
const servedBy = new Array(bs.length);
|
||||
for (let i = 0; i < bs.length; i++) {
|
||||
const b = bs[i];
|
||||
b.arcs = [];
|
||||
if (b.construction > 0 || b.upgrading) {
|
||||
// Baustelle: bedient niemanden
|
||||
b.inRange = 0; b.util = 0; b.serving = [];
|
||||
servedBy[i] = new Set();
|
||||
continue;
|
||||
}
|
||||
const R = range(b);
|
||||
const near = [];
|
||||
for (const v of state.visitors) {
|
||||
if (v.travel === 'bus') continue; // im Bus wird niemand bedient
|
||||
const d = dist(v, b);
|
||||
if (d <= R) near.push([d, v]);
|
||||
}
|
||||
near.sort((a, b2) => a[0] - b2[0]);
|
||||
const cap = BUILDINGS[b.type].capacity[b.level - 1];
|
||||
b.inRange = near.length;
|
||||
b.util = cap >= 999 ? 0 : near.length / cap;
|
||||
const taken = near.slice(0, cap);
|
||||
servedBy[i] = new Set(taken.map(x => x[1].id));
|
||||
b.serving = taken.slice(0, 10).map(x => x[1].id);
|
||||
}
|
||||
|
||||
const envFBase = 0.6 + state.env / 100; // §11.9
|
||||
const fest = state.time < state.festUntil ? 1.15 : 1;
|
||||
|
||||
for (const v of state.visitors) {
|
||||
const g = GROUPS[v.type];
|
||||
v.timeIn += dt;
|
||||
|
||||
// Wirkung aller Gebäude, die diesen Gast gerade bedienen
|
||||
let brakeProduct = 1;
|
||||
for (let i = 0; i < bs.length; i++) {
|
||||
if (!servedBy[i].has(v.id)) continue;
|
||||
const b = bs[i];
|
||||
const def = BUILDINGS[b.type];
|
||||
const lvl = b.level - 1;
|
||||
const tf = targetFactor(def, b.level, v.type);
|
||||
if (def.service) v.hadService = true;
|
||||
if (def.slow[lvl] > 0 && tf >= 0.3 && v.mode !== 'lift') {
|
||||
brakeProduct *= 1 - def.slow[lvl];
|
||||
}
|
||||
if (tf <= 0.05) continue;
|
||||
|
||||
const sf = def.season[state.seasonKey] ?? 1;
|
||||
const envF = 1 + g.envSens * (envFBase - 1);
|
||||
const natF = def.natureBoosted ? state.natureBoost : 1;
|
||||
const elevB = def.elevBonus && b.slotZone === 'berg' ? 1.3 : 1;
|
||||
const eff = def.sat[lvl] * tf * sf * envF * natF * (b.infoBoost || 1) * (b.synergy || 1) * elevB * fest;
|
||||
|
||||
const maxGain = def.sat[lvl] * tf * sf * MAX_GAIN_FACTOR;
|
||||
const got = v.gained[b.id] ?? 0;
|
||||
const add = Math.min((eff / CONTACT_NORM) * dt, Math.max(0, maxGain - got));
|
||||
if (add > 0) {
|
||||
v.satisfaction += add;
|
||||
v.gained[b.id] = got + add;
|
||||
// Bogen nur, solange die Bedienung wirklich etwas bringt
|
||||
if (b.arcs.length < 10 && !b.arcs.includes(v.id)) b.arcs.push(v.id);
|
||||
}
|
||||
|
||||
// sichtbare Interaktion: Objekt/Erlebnis schwebt vom Gebäude zum Gast –
|
||||
// beim ersten Kontakt und dann alle paar Sekunden wieder
|
||||
// Objekt-Flug nur für wirklich passende Zielgruppen (tf ≥ 0.5),
|
||||
// Produkt je Zielgruppe (itemsBy), Geräusch je Dienstleistung
|
||||
if (tf >= 0.5 && (v.gained[b.id] ?? 0) >= 2 && state.time >= (v.fxNext[b.id] ?? 0)) {
|
||||
v.fxNext[b.id] = state.time + 2.2 + state.rng() * 1.2;
|
||||
const emoji = def.itemsBy?.[v.type] || def.item || def.fx;
|
||||
if (emoji && state.fx.length < 90) {
|
||||
const amount = Math.round(v.revFx[b.id] || 0);
|
||||
if (amount > 0) v.revFx[b.id] = 0;
|
||||
state.fx.push({ emoji, fx: b.x, fy: b.y, vid: v.id, amount, sound: def.sound || 'pling' });
|
||||
}
|
||||
}
|
||||
|
||||
// Einnahmen als Folge von Zufriedenheit (§11.8, §25)
|
||||
const baseRev = def.revenue[lvl];
|
||||
if (baseRev > 0 && tf > 0.3) {
|
||||
const satF = Math.min(1.5, 0.5 + Math.max(0, v.satisfaction) / g.satTarget);
|
||||
const rev = Math.min((baseRev * REV_SCALE * tf * satF / CONTACT_NORM) * dt, g.budget - v.spent);
|
||||
if (rev > 0) {
|
||||
state.budget += rev;
|
||||
state.revenueTotal += rev;
|
||||
v.spent += rev;
|
||||
v.revFx[b.id] = (v.revFx[b.id] || 0) + rev;
|
||||
state.stats.buildingRevenue[b.type] = (state.stats.buildingRevenue[b.type] || 0) + rev;
|
||||
}
|
||||
}
|
||||
}
|
||||
v.brake = Math.min(MAX_BRAKE, 1 - brakeProduct);
|
||||
|
||||
// Bewegung entlang der Route (Bogenlänge in Bild-Pixeln)
|
||||
const liftSeg = v.pts[v.seg + 1]?.lift;
|
||||
v.mode = liftSeg ? 'lift' : 'walk';
|
||||
// starke Bremse ⇒ sichtbares Verweilen: Stop-and-Go statt Zeitlupe
|
||||
v.stopped = false;
|
||||
if (!liftSeg && v.travel === 'walk' && v.brake > 0.28) {
|
||||
const cycle = (state.time + v.id * 1.7) % 7;
|
||||
if (cycle < 2.8) v.stopped = true;
|
||||
}
|
||||
let speed = (liftSeg ? LIFT_SPEED : g.speed * (1 - v.brake * 0.55)) * TILE2PX;
|
||||
if (v.stopped) speed = 0;
|
||||
if (v.travel === 'bus') {
|
||||
speed = 2.6 * TILE2PX; // Busfahrt bis zur Haltestelle
|
||||
if (v.s >= v.busStopS) { v.travel = 'busstop'; v.busWait = 2.6; }
|
||||
} else if (v.travel === 'busstop') {
|
||||
speed = 0; // Halt: Fahrgäste steigen aus
|
||||
v.busWait -= dt;
|
||||
if (v.busWait <= 0) v.travel = 'walk';
|
||||
}
|
||||
v.s += speed * dt;
|
||||
|
||||
const total = v.cum[v.cum.length - 1];
|
||||
if (v.s >= total) {
|
||||
exitVisitor(state, v);
|
||||
} else {
|
||||
while (v.seg < v.pts.length - 2 && v.cum[v.seg + 1] <= v.s) v.seg++;
|
||||
const a = v.pts[v.seg], b2 = v.pts[v.seg + 1];
|
||||
const len = v.cum[v.seg + 1] - v.cum[v.seg] || 1;
|
||||
const f = (v.s - v.cum[v.seg]) / len;
|
||||
v.x = a.x + (b2.x - a.x) * f;
|
||||
v.y = a.y + (b2.y - a.y) * f;
|
||||
v.headRight = b2.x >= a.x;
|
||||
}
|
||||
}
|
||||
// Besucherstrom-Dichte fürs Heatmap-Raster akkumulieren (§15.7)
|
||||
const HC = state.heatCols, HR = state.heatRows;
|
||||
const mw = state.map.w, mh = state.map.h;
|
||||
for (const v of state.visitors) {
|
||||
if (v.exited || v.travel === 'bus') continue;
|
||||
const c = Math.max(0, Math.min(HC - 1, Math.floor(v.x / mw * HC)));
|
||||
const r = Math.max(0, Math.min(HR - 1, Math.floor(v.y / mh * HR)));
|
||||
state.heat[r * HC + c] += dt;
|
||||
}
|
||||
|
||||
state.visitors = state.visitors.filter(v => !v.exited);
|
||||
}
|
||||
|
||||
function dist(v, b) {
|
||||
return Math.hypot(v.x - b.x, v.y - b.y);
|
||||
}
|
||||
function range(b) {
|
||||
return BUILDINGS[b.type].range[b.level - 1] * TILE2PX;
|
||||
}
|
||||
function targetFactor(def, level, groupId) {
|
||||
const byLevel = def.targetByLevel?.[groupId];
|
||||
if (byLevel) return byLevel[level - 1];
|
||||
return def.target[groupId] ?? 0.4;
|
||||
}
|
||||
|
||||
// ---------- Abreise + Bewertung (§8, §18.5) ----------
|
||||
function exitVisitor(state, v) {
|
||||
v.exited = true;
|
||||
const g = GROUPS[v.type];
|
||||
let sat = v.satisfaction;
|
||||
const malus = { service: false, traffic: false, env: false, time: false };
|
||||
|
||||
if (g.needsService && !v.hadService && v.timeIn > 60) { sat -= 10; malus.service = true; }
|
||||
const tPen = state.traffic * 0.08 * g.trafficSens;
|
||||
if (tPen > 4) malus.traffic = true;
|
||||
sat -= tPen;
|
||||
if (state.env < 45 && g.envSens >= 0.7) malus.env = true;
|
||||
if (v.timeIn > g.maxTime) { sat *= 0.92; malus.time = true; }
|
||||
|
||||
const ratio = Math.max(0, sat) / g.satTarget;
|
||||
const stars = Math.max(1, Math.min(5, Math.ceil(ratio * 5)));
|
||||
|
||||
// sichtbares Abschluss-Feedback am Ausgang (wie „Gegner besiegt")
|
||||
if (state.fx.length < 90) {
|
||||
state.fx.push({ pop: true, stars, fx: v.x, fy: v.y });
|
||||
}
|
||||
state.recentStars.push(stars);
|
||||
if (state.recentStars.length > 12) state.recentStars.shift();
|
||||
if (stars <= 2) state.lastBadExit = state.time;
|
||||
|
||||
const st = state.stats;
|
||||
st.all.count++; st.all.stars += stars;
|
||||
st.groups[v.type] = st.groups[v.type] || { count: 0, stars: 0 };
|
||||
st.groups[v.type].count++;
|
||||
st.groups[v.type].stars += stars;
|
||||
st.groups[v.type].avg = st.groups[v.type].stars / st.groups[v.type].count;
|
||||
st.exits[v.exitLabel] = (st.exits[v.exitLabel] || 0) + 1;
|
||||
state.phaseExitStars.push(stars);
|
||||
|
||||
// Feed-Text erzeugen
|
||||
let text;
|
||||
if (malus.traffic && stars <= 3 && state.rng() < 0.5) text = FEED_TEXTS.traffic;
|
||||
else if (malus.env && stars <= 3 && state.rng() < 0.4) text = FEED_TEXTS.env;
|
||||
else {
|
||||
const pool = FEED_TEXTS[stars];
|
||||
text = pool[Math.floor(state.rng() * pool.length)]
|
||||
.replace('{best}', bestBuildingName(state, v))
|
||||
.replace('{missing}', g.wish);
|
||||
}
|
||||
state.feedPool.push({ stars, emoji: g.emoji, group: g.name, text, prio: stars === 5 || stars <= 2 ? 2 : 1 });
|
||||
}
|
||||
|
||||
function bestBuildingName(state, v) {
|
||||
let best = null, val = 3;
|
||||
for (const [bid, gain] of Object.entries(v.gained)) {
|
||||
if (gain > val) {
|
||||
const b = state.buildings.find(x => x.id === Number(bid));
|
||||
if (b) { best = b; val = gain; }
|
||||
}
|
||||
}
|
||||
if (!best) return 'die Landschaft';
|
||||
const def = BUILDINGS[best.type];
|
||||
return (def.levelNames ? def.levelNames[best.level - 1] : def.name);
|
||||
}
|
||||
|
||||
function pickFeed(state) {
|
||||
const sorted = [...state.feedPool].sort((a, b) => (b.prio - a.prio) || (state.rng() - 0.5));
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (const f of sorted) {
|
||||
if (seen.has(f.text)) continue;
|
||||
seen.add(f.text);
|
||||
out.push(f);
|
||||
if (out.length >= 3) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- Bauen / Ausbauen / Abreißen (auf festen Slots) ----------
|
||||
export function place(state, type, slotIdx) {
|
||||
const check = canPlace(state, type, slotIdx);
|
||||
if (!check.ok) return check;
|
||||
const def = BUILDINGS[type];
|
||||
const slot = state.slots[slotIdx];
|
||||
state.budget -= def.cost[0];
|
||||
const b = {
|
||||
id: state.nextBid++, type, level: 1,
|
||||
x: slot.x, y: slot.y, slotIdx, slotZone: slot.zone, infoBoost: 1,
|
||||
construction: 5 + def.cost[0] / 50, // Baustellenzeit in Sekunden
|
||||
upgrading: null, synergy: 1, synergyList: [],
|
||||
};
|
||||
state.buildings.push(b);
|
||||
slot.building = b;
|
||||
state.env = clampEnv(state.env + def.env[0]);
|
||||
state.actionLog.push({ t: state.time, action: 'build', type, level: 1 });
|
||||
|
||||
if (def.opensBerg) {
|
||||
state.pendingCards.push({
|
||||
kind: 'card', icon: '⛰️', title: 'Der Berg ist erschlossen!',
|
||||
text: 'Die Seilbahn bringt Gäste auf den Berg – oben warten neue Bauplätze '
|
||||
+ '(Almhütte, Aussichtspunkt). Wanderer und junge Gruppen lieben das, '
|
||||
+ 'im Winter läuft der Lift auf Hochtouren.',
|
||||
});
|
||||
}
|
||||
if (type === 'naturschutz') {
|
||||
state.natureBoost = 1.15;
|
||||
// Schutzgebiet sperrt umliegende freie Bauplätze (§10: Zielkonflikt
|
||||
// Wirtschaft ↔ Schutz). Reichweite in Pixeln.
|
||||
const R = (def.blocksRadius || 3.2) * TILE2PX;
|
||||
b.blockedSlots = [];
|
||||
for (const s of state.slots) {
|
||||
if (s === slot || s.building || s.zone === 'lake' || s.zone === 'road') continue;
|
||||
if (Math.hypot(s.x - slot.x, s.y - slot.y) <= R) {
|
||||
s.blocked = true;
|
||||
b.blockedSlots.push(s.idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshInfoBoosts(state);
|
||||
return { ok: true, building: b };
|
||||
}
|
||||
|
||||
export function upgrade(state, b) {
|
||||
const def = BUILDINGS[b.type];
|
||||
if (b.construction > 0 || b.upgrading) return { ok: false, reason: 'Hier wird gerade gebaut.' };
|
||||
if (b.level >= def.maxLevel) return { ok: false, reason: 'Bereits voll ausgebaut.' };
|
||||
const cost = def.cost[b.level];
|
||||
if (state.budget < cost) return { ok: false, reason: `Zu teuer – Ausbau kostet ${cost} €.` };
|
||||
state.budget -= cost;
|
||||
b.upgrading = { left: 4 + cost / 60 }; // Umbauzeit; Stufe zählt erst danach
|
||||
state.actionLog.push({ t: state.time, action: 'upgrade', type: b.type, level: b.level + 1 });
|
||||
return { ok: true, cost, time: Math.round(b.upgrading.left) };
|
||||
}
|
||||
|
||||
export function demolish(state, b) {
|
||||
if (b.type === 'naturschutz') return { ok: false, reason: 'Ein Schutzgebiet wird nicht einfach wieder aufgehoben.' };
|
||||
const def = BUILDINGS[b.type];
|
||||
let invested = 0;
|
||||
for (let i = 0; i < b.level; i++) invested += def.cost[i];
|
||||
const refund = Math.round(invested * 0.3);
|
||||
state.budget += refund;
|
||||
state.buildings = state.buildings.filter(x => x !== b);
|
||||
const slot = state.slots[b.slotIdx];
|
||||
if (slot) slot.building = null;
|
||||
// gesperrte Bauplätze eines Schutzgebiets wieder freigeben
|
||||
if (b.blockedSlots) for (const i of b.blockedSlots) { if (state.slots[i]) state.slots[i].blocked = false; }
|
||||
state.actionLog.push({ t: state.time, action: 'demolish', type: b.type });
|
||||
if (def.opensBerg) {
|
||||
// Wer gerade am Berg unterwegs ist, steigt um: Rest des Wegs zu Fuß
|
||||
for (const v of state.visitors) {
|
||||
if (v.pts.some(p => p.lift)) {
|
||||
for (const p of v.pts) delete p.lift;
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshInfoBoosts(state);
|
||||
return { ok: true, refund, name: def.name };
|
||||
}
|
||||
|
||||
// Tourismusinfo-Multiplikator (§10) + Synergien (§9.2) je Gebäude neu berechnen.
|
||||
// Wird nach jedem Bau/Ausbau/Abriss aufgerufen.
|
||||
function refreshInfoBoosts(state) {
|
||||
const infos = state.buildings.filter(b => b.type === 'tourismusinfo');
|
||||
for (const b of state.buildings) {
|
||||
let boost = 1;
|
||||
for (const inf of infos) {
|
||||
if (inf === b) continue;
|
||||
const r = BUILDINGS.tourismusinfo.range[inf.level - 1] * TILE2PX;
|
||||
if (Math.hypot(inf.x - b.x, inf.y - b.y) <= r) {
|
||||
boost += BUILDINGS.tourismusinfo.boost[inf.level - 1];
|
||||
}
|
||||
}
|
||||
b.infoBoost = boost;
|
||||
b.synergy = 1;
|
||||
b.synergyList = [];
|
||||
}
|
||||
// Synergie-Paare: beide Partner bekommen den Bonus, wenn in Reichweite
|
||||
for (const rule of SYNERGIES) {
|
||||
for (const x of state.buildings) {
|
||||
if (x.type !== rule.a) continue;
|
||||
for (const y of state.buildings) {
|
||||
if (y.type !== rule.b) continue;
|
||||
if (Math.hypot(x.x - y.x, x.y - y.y) <= rule.radius) {
|
||||
x.synergy += rule.bonus; x.synergyList.push(rule.why);
|
||||
y.synergy += rule.bonus; y.synergyList.push(rule.why);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clampEnv(v) {
|
||||
return Math.max(5, Math.min(100, v));
|
||||
}
|
||||
|
||||
// ---------- „Nächste Welle" (Tower-Defense-Klassiker) ----------
|
||||
// Ruft die nächste Phase sofort – Bonus fürs Risiko, restliche Spawns der
|
||||
// laufenden Phase entfallen. In Phase 8 nicht mehr möglich.
|
||||
export function callNextWave(state) {
|
||||
if (state.ended) return { ok: false, reason: 'Die Partie ist vorbei.' };
|
||||
if (state.waveStatus === 'pause') {
|
||||
// Baupause überspringen: kleiner Bonus für die gesparte Zeit
|
||||
const bonus = Math.round(Math.max(0, state.pauseLeft) * 2) + 10;
|
||||
state.budget += bonus;
|
||||
launchWave(state);
|
||||
state.actionLog.push({ t: state.time, action: 'next_wave', bonus });
|
||||
return { ok: true, bonus };
|
||||
}
|
||||
if (state.phase >= 8) return { ok: false, reason: 'Die letzte Welle läuft bereits.' };
|
||||
// Welle früh rufen: Risiko-Bonus je noch aktivem Gast
|
||||
const remaining = state.visitors.filter(v => v.wave === state.phase).length;
|
||||
const bonus = 15 + remaining * 4;
|
||||
state.budget += bonus;
|
||||
prepareWave(state, state.phase + 1);
|
||||
launchWave(state);
|
||||
state.actionLog.push({ t: state.time, action: 'next_wave', bonus, early: true });
|
||||
return { ok: true, bonus };
|
||||
}
|
||||
|
||||
// ---------- Endauswertung (§12, §21) ----------
|
||||
export function finish(state) {
|
||||
if (state.ended) return;
|
||||
state.ended = true;
|
||||
const st = state.stats;
|
||||
const avgStars = st.all.count ? st.all.stars / st.all.count : 0;
|
||||
|
||||
const nonCar = st.transports.foot + st.transports.bike + st.transports.bus;
|
||||
const total = Math.max(1, st.spawned);
|
||||
let lenkung = (nonCar / total) * 100;
|
||||
if (state.buildings.some(b => b.type === 'bushaltestelle')) lenkung = Math.min(100, lenkung + 10);
|
||||
|
||||
const scores = {
|
||||
rating: (avgStars / 5) * 100,
|
||||
revenue: Math.min(100, (state.revenueTotal / 2500) * 100),
|
||||
sustain: state.env,
|
||||
lenkung,
|
||||
finance: state.budget >= 0 ? 100 : Math.max(0, 100 * (1 + state.budget / 1000)),
|
||||
};
|
||||
const score = Math.round(
|
||||
scores.rating * 0.35 + scores.revenue * 0.25 + scores.sustain * 0.20
|
||||
+ scores.lenkung * 0.10 + scores.finance * 0.10,
|
||||
);
|
||||
|
||||
state.result = {
|
||||
avgStars, score, scores,
|
||||
revenue: Math.round(state.revenueTotal),
|
||||
env: Math.round(state.env),
|
||||
budget: Math.round(state.budget),
|
||||
visitors: st.spawned,
|
||||
rated: st.all.count,
|
||||
goals: GOALS.map(g => ({ label: g.label, done: g.check(state) })),
|
||||
};
|
||||
state.result.profile = PROFILES.find(p => p.check(state));
|
||||
state.result.feedback = buildFeedback(state);
|
||||
state.pendingCards.push({ kind: 'end' });
|
||||
}
|
||||
|
||||
function buildFeedback(state) {
|
||||
const st = state.stats;
|
||||
const lines = [];
|
||||
|
||||
// stärkste und schwächste Gruppe
|
||||
let best = null, worst = null;
|
||||
for (const [id, g] of Object.entries(st.groups)) {
|
||||
if (g.count < 3) continue;
|
||||
if (!best || g.avg > best.g.avg) best = { id, g };
|
||||
if (!worst || g.avg < worst.g.avg) worst = { id, g };
|
||||
}
|
||||
if (best && best.g.avg >= 3) {
|
||||
lines.push(`Besonders wohl fühlten sich ${plural(best.id)} (Ø ${best.g.avg.toFixed(1)} Sterne) – `
|
||||
+ 'dein Angebot passte zu ihren Bedürfnissen.');
|
||||
} else if (best) {
|
||||
lines.push('Keine Besuchergruppe wurde richtig glücklich – die Region bot zu wenig passende '
|
||||
+ 'Angebote entlang der Wege.');
|
||||
}
|
||||
|
||||
// Wirtschaft
|
||||
const topRev = Object.entries(st.buildingRevenue).sort((a, b) => b[1] - a[1])[0];
|
||||
if (topRev) {
|
||||
lines.push(`Wirtschaftlich trug ${BUILDINGS[topRev[0]].name} am meisten zur regionalen `
|
||||
+ `Wertschöpfung bei (${Math.round(topRev[1])} € von ${Math.round(state.revenueTotal)} €).`);
|
||||
}
|
||||
if (state.budget < 0) {
|
||||
lines.push('Das Budget rutschte ins Minus – Betriebskosten laufen weiter, auch wenn keine Gäste da sind. '
|
||||
+ 'Teure Infrastruktur braucht Zeit, bis sie sich rechnet (Amortisation).');
|
||||
}
|
||||
|
||||
// Umwelt
|
||||
if (state.env >= 65) {
|
||||
lines.push('Der Umweltwert blieb hoch. Das zahlt sich doppelt aus: Naturgruppen bewerten besser, '
|
||||
+ 'und die Landschaft bleibt als wichtigster Standortfaktor erhalten.');
|
||||
} else if (state.env < 45) {
|
||||
lines.push('Der Umweltwert ist stark gesunken – Parkflächen, Lift und Ausbau haben Spuren hinterlassen. '
|
||||
+ 'Naturnahe Gäste haben das in ihren Bewertungen deutlich gemacht.');
|
||||
}
|
||||
|
||||
// Verkehr / Mobilität
|
||||
if (state.stats.skippedNoParking > 2) {
|
||||
lines.push(`${state.stats.skippedNoParking} Busgruppen fanden keinen Parkplatz und fuhren weiter – `
|
||||
+ 'Erreichbarkeit entscheidet, wer überhaupt Gast werden kann.');
|
||||
}
|
||||
if (state.traffic > 30) {
|
||||
lines.push('Der Verkehr im Ort wurde zur Belastung – empfindliche Gäste (Senioren, Familien) '
|
||||
+ 'bewerteten schlechter. Bushaltestelle und Besucherlenkung könnten helfen.');
|
||||
}
|
||||
|
||||
// Verbesserungstipp
|
||||
if (worst && worst.g.avg < 3) {
|
||||
lines.push(`Am schlechtesten schnitten ${plural(worst.id)} ab (Ø ${worst.g.avg.toFixed(1)} Sterne). `
|
||||
+ `Ihnen fehlte: ${GROUPS[worst.id].wish}.`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function plural(id) {
|
||||
return {
|
||||
spaziergaenger: 'Spaziergänger', wanderer: 'Wanderer', familie: 'Familien',
|
||||
radfahrer: 'Radfahrer', senioren: 'Seniorengruppen', schulklasse: 'Schulklassen',
|
||||
busgruppe: 'Busreisegruppen', junge_gruppe: 'junge Reisegruppen', luxuspaar: 'wohlhabende Paare',
|
||||
}[id] || id;
|
||||
}
|
||||
@@ -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]; },
|
||||
};
|
||||
@@ -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})`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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 0–20-min-Zeitachse, sichtbare Bodenlinie, alle Daten bleiben.
|
||||
// Links: Budget (€). Rechts: Zufriedenheits-Erfüllung in % (Ziel 100 %,
|
||||
// wie „zerstört" im Tower Defense) + Umweltwert auf derselben 0–100-Skala.
|
||||
drawGraph() {
|
||||
const hist = this.state.history;
|
||||
if (hist.budget.length < 2) return;
|
||||
const bMax = Math.max(600, ...hist.budget);
|
||||
const bMin = Math.min(0, ...hist.budget);
|
||||
this._plotPanel(this.gMoney, {
|
||||
title: '💰 Budget',
|
||||
series: [{ data: hist.budget, color: 'rgb(79,127,94)', fill: true }],
|
||||
vMin: bMin, vMax: bMax, unit: '€',
|
||||
current: `${euro(this.state.budget)} €`,
|
||||
goals: [],
|
||||
t: hist.t,
|
||||
});
|
||||
this._plotPanel(this.gSat, {
|
||||
title: '😊 Zufriedenheit · 🌲 Umwelt',
|
||||
series: [
|
||||
{ data: hist.satPct, color: 'rgb(232,137,43)', fill: true },
|
||||
{ data: hist.env, color: 'rgb(47,110,160)', fill: false },
|
||||
],
|
||||
vMin: 0, vMax: 110, unit: '%',
|
||||
current: `${hist.satPct[hist.satPct.length - 1]} % · ${Math.round(this.state.env)}`,
|
||||
goals: [
|
||||
{ v: 100, label: 'Ziel 100 %' },
|
||||
{ v: 45, label: 'Umwelt-Ziel 45' },
|
||||
],
|
||||
t: hist.t,
|
||||
});
|
||||
}
|
||||
|
||||
_plotPanel(ctx, o) {
|
||||
const cv = ctx.canvas;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = cv.clientWidth, h = cv.clientHeight;
|
||||
if (cv.width !== w * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const padL = 36, padR = 6, padT = 16, padB = 15;
|
||||
const pw = w - padL - padR, ph = h - padT - padB;
|
||||
const X = i => padL + (o.t[i] / 1200) * pw;
|
||||
const Y = v => padT + ph - ((Math.max(o.vMin, Math.min(v, o.vMax)) - o.vMin) / (o.vMax - o.vMin || 1)) * ph;
|
||||
|
||||
// Titel + aktueller Wert
|
||||
ctx.font = 'bold 10.5px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = '#2f3e46';
|
||||
ctx.fillText(o.title, 2, 10);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillStyle = o.series[0].color;
|
||||
ctx.fillText(o.current, w - 2, 10);
|
||||
|
||||
// Skala links (oben/unten)
|
||||
ctx.font = '8.5px sans-serif';
|
||||
ctx.fillStyle = 'rgba(47,62,70,0.6)';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(`${o.vMax}${o.unit}`, padL - 3, padT + 7);
|
||||
ctx.fillText(`${o.vMin}${o.unit}`, padL - 3, padT + ph);
|
||||
|
||||
// Ziellinien
|
||||
for (const g of o.goals) {
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.strokeStyle = 'rgba(47,62,70,0.35)';
|
||||
ctx.beginPath(); ctx.moveTo(padL, Y(g.v)); ctx.lineTo(padL + pw, Y(g.v)); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = 'rgba(47,62,70,0.55)';
|
||||
ctx.fillText(g.label, padL + 2, Y(g.v) - 2);
|
||||
}
|
||||
|
||||
// Datenlinien (+ Füllung der ersten Serie)
|
||||
for (const s of o.series) {
|
||||
if (s.fill) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(X(0), padT + ph);
|
||||
s.data.forEach((v, i) => ctx.lineTo(X(i), Y(v)));
|
||||
ctx.lineTo(X(s.data.length - 1), padT + ph);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = s.color.replace('rgb', 'rgba').replace(')', ',0.10)');
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.strokeStyle = s.color;
|
||||
ctx.lineWidth = 1.8;
|
||||
ctx.beginPath();
|
||||
s.data.forEach((v, i) => (i ? ctx.lineTo(X(i), Y(v)) : ctx.moveTo(X(i), Y(v))));
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 1;
|
||||
}
|
||||
|
||||
// BODEN: kräftige Grundlinie + Zeitachse
|
||||
ctx.strokeStyle = 'rgba(47,62,70,0.45)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padL, padT + ph);
|
||||
ctx.lineTo(padL + pw, padT + ph);
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillStyle = 'rgba(47,62,70,0.55)';
|
||||
ctx.textAlign = 'center';
|
||||
for (const min of [0, 5, 10, 15, 20]) {
|
||||
const x = padL + (min / 20) * pw;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padT + ph);
|
||||
ctx.lineTo(x, padT + ph + 3);
|
||||
ctx.stroke();
|
||||
ctx.fillText(`${min}'`, x, h - 2);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Karten ----------
|
||||
showCard(card) {
|
||||
// Tempo VOR der ersten Karte merken – auch eine manuelle Pause (0)
|
||||
// wird nach dem Schließen wiederhergestellt.
|
||||
if (!this.cardOpen) this.speedBeforeCard = this.api.getSpeed();
|
||||
this.cardQueue.push(card);
|
||||
if (!this.cardOpen) this._nextCard();
|
||||
}
|
||||
|
||||
_nextCard() {
|
||||
const card = this.cardQueue.shift();
|
||||
if (!card) return;
|
||||
this.currentCard = card;
|
||||
this.cardOpen = true;
|
||||
this.api.setSpeed(0);
|
||||
this.markSpeed(0);
|
||||
|
||||
$('cardIcon').textContent = card.icon || 'ℹ️';
|
||||
$('cardTitle').textContent = card.title || '';
|
||||
$('cardText').textContent = card.text || '';
|
||||
const list = $('cardList');
|
||||
list.innerHTML = '';
|
||||
list.classList.toggle('hidden', !card.items);
|
||||
if (card.items) {
|
||||
for (const f of card.items) {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 || '—';
|
||||
|
||||