Files
geograsim/App/sims/logistik/game.html
T
Adminator fc8996f064 Atlas: DSGVO-Tile-Proxy + Logistik-Phase-bis-8 + Sounds-Anleitung
Platform:
- OSM-DE als dritter Tile-Proxy-Provider (deutsche Beschriftungen)
- logistik.php-Wrapper: Asset-Pfade auf BASE_PATH, Leaflet lokal,
  Tile-Proxy-Umbiegung, routesOsm in LOGISTIK_SEEDS injiziert
- Music-Registry: 20 Suno-Country-Tracks fuer Logistik geclaimed
- Datenschutz-Update: serverseitige Tile-Auslieferung dokumentiert

Logistik (Instanz-Lieferungen, parallel):
- Phase 0 bis 8 inkl. Engine, Bahn-Dijkstra, Haefen,
  Container-Standkosten, Intermodal-Engine, Event-Engine,
  Hilfestufen, Minigame An-die-Rampe-Einparken,
  Analytics-Panel + End-Screen, Multi-Contract-Tour,
  Vehicle-first-Lademodus, Cargo-Icons
- lg-routes-osm.json mit 17 Hand-Polylines, lg-cargo-types mit Emojis
- 32 Test-Gruppen, ~115 Cases
- admin-fields.json fuer Admin-UI-Integration

Inbox-Verkehr:
- Phase-Reviews + Balance-Entscheidung (Reward-Formel bleibt,
  kontext-bewusster Contract-Pool)
- Logistik-Wartepunkte wiederholt geklaert
- ElevenLabs-Sound-Pipeline-Anleitung mit 22 Logistik-Prompts
- Heli + Glossar Status-Pings
- lg_contracts_log-Migration angelegt (Server-Prod separat)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 08:15:00 +02:00

3216 lines
142 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logistik Europa — Simulation</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="../../assets/fonts/inter.css">
<link rel="stylesheet" href="../../assets/css/design-system.css">
<style>
/* ===== Modul-spezifische Styles =====
Regel: Alles, was auch andere Module brauchen könnten, gehört ins
Design-System (mit Atlas absprechen). Hier nur Logistik-Spezifisches. */
html, body { height: 100%; margin: 0; padding: 0; font-family: 'Inter', system-ui, sans-serif; background: var(--ggs-bg); color: var(--ggs-text); touch-action: manipulation; }
.lg-layout { display: grid; grid-template-rows: auto 1fr auto auto; height: 100vh; }
.lg-main { display: grid; grid-template-columns: 240px 1fr 240px; gap: 8px; padding: 8px; min-height: 0; }
.lg-panel { background: var(--ggs-white); border-radius: var(--ggs-radius-md); padding: 12px; overflow-y: auto; box-shadow: var(--ggs-shadow-sm); }
.lg-map { background: var(--ggs-white); border-radius: var(--ggs-radius-md); overflow: hidden; box-shadow: var(--ggs-shadow-sm); position: relative; }
#map { width: 100%; height: 100%; }
.lg-status { display: flex; gap: 16px; align-items: center; padding: 8px 16px; background: var(--ggs-white); border-top: 1px solid var(--ggs-border); font-size: 0.9rem; }
.lg-didaktik { max-height: 120px; overflow-y: auto; padding: 8px 16px; background: var(--ggs-bg); border-top: 1px solid var(--ggs-border); font-size: 0.82rem; }
.lg-status b { font-variant-numeric: tabular-nums; }
/* iPad Landscape (1180×820) — Touch-Ziele ≥ 36 px */
@media (min-width: 900px) and (max-width: 1199px) {
.lg-main { grid-template-columns: 215px 1fr 215px; }
}
@media (max-width: 899px) {
.lg-main { grid-template-columns: 1fr; grid-template-rows: auto auto 300px; }
}
/* Marker-Popups */
.lg-popup h4 { margin: 0 0 4px; font-size: 0.95rem; }
.lg-popup .lg-popup-meta { font-size: 0.78rem; color: #666; }
.lg-popup .lg-popup-type { display: inline-block; padding: 1px 6px; background: #f0ece3; border-radius: 3px; font-size: 0.72rem; margin-top: 4px; }
/* Auftrags- und Fahrzeug-Karten in den Panels */
.lg-panel h3 { font-size: 0.95rem; margin: 0 0 8px; color: var(--ggs-text); }
.lg-card { padding: 10px; margin-bottom: 8px; border: 1px solid var(--ggs-border); border-radius: 6px; cursor: pointer; transition: background 0.15s, border-color 0.15s; min-height: 36px; }
.lg-card:hover { background: #fafaf6; }
.lg-card.selected { border-color: #1f4b37; background: #e8f0eb; }
.lg-card.busy { opacity: 0.7; cursor: default; }
.lg-card-title { font-weight: 600; font-size: 0.88rem; margin-bottom: 2px; }
.lg-card-meta { font-size: 0.78rem; color: #666; }
.lg-card-state { display: inline-block; padding: 2px 8px; background: #f0ece3; border-radius: 10px; font-size: 0.74rem; margin-top: 4px; font-weight: 600; }
/* Farbleitsystem — Auftragszustände (Phase 7-UX) */
.lg-card-state.OPEN { background: #d6ebd0; color: #2d6d3d; } /* grün — verfügbar */
.lg-card-state.PICKUP_PENDING,
.lg-card-state.LOADING,
.lg-card-state.IN_TRANSIT,
.lg-card-state.AT_TRANSFER,
.lg-card-state.UNLOADING { background: #fbe6c2; color: #8a5a1e; } /* orange — in Bearbeitung */
.lg-card-state.DELIVERED { background: #cfe0f0; color: #1e4f7a; } /* blau — abgeschlossen */
.lg-card-state.LATE { background: #f3d0d0; color: #b32d2d; } /* hellrot — verspätet */
.lg-card-state.FAILED,
.lg-card-state.CANCELLED { background: #e2ddd1; color: #5e5320; } /* grau — nichts mehr */
.lg-card-state.WAITING,
.lg-card-state.RESERVED,
.lg-card-state.ASSIGNED { background: #eae3d2; color: #6a5a2e; } /* beige — wartet */
/* Farbleitsystem — Fahrzeug-Zustände */
.lg-card-state.IDLE { background: #d6ebd0; color: #2d6d3d; } /* grün — bereit */
.lg-card-state.MOVING { background: #fbe6c2; color: #8a5a1e; } /* orange — fährt */
.lg-card-state.BLOCKED,
.lg-card-state.BROKEN { background: #f3d0d0; color: #b32d2d; }
/* Card-Hintergrund passend zum OPEN/in-Bearbeitung-Status */
.lg-card.state-open { background: #f3faf0; }
.lg-card.state-busy-soft { background: #fffaf0; }
.lg-hint { padding: 8px 10px; margin-bottom: 8px; background: #fff8d6; border-left: 3px solid #d6b94e; border-radius: 4px; font-size: 0.82rem; color: #5e5320; }
/* Notification-Toast */
.lg-toast { position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%); padding: 8px 16px; background: #1f4b37; color: #fff; border-radius: 4px; font-size: 0.85rem; box-shadow: 0 2px 8px rgba(0,0,0,.2); z-index: 1000; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
.lg-toast.visible { opacity: 1; }
/* Event-Panel im Didaktik-Bereich (Phase 6) */
.lg-event-panel { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.lg-event-pill { padding: 3px 8px; background: #fff8d6; border: 1px solid #d6b94e; border-radius: 12px; font-size: 0.78rem; color: #5e5320; }
.lg-event-pill.severe { background: #f3e3e3; border-color: #b32d2d; color: #b32d2d; }
/* Trip-Info in Vehicle-Card (Phase 7-UX) */
.lg-trip-info { margin-top: 6px; padding: 6px 8px; background: #e8f0eb; border-left: 3px solid #1f4b37; border-radius: 3px; font-size: 0.75rem; }
.lg-trip-info.pickup { background: #fff8d6; border-left-color: #d6b94e; color: #5e5320; }
.lg-trip-info.loading { background: #f0ece3; border-left-color: #8a7a4f; color: #5e5320; }
.lg-trip-info .lg-trip-phase { font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; font-size: 0.68rem; }
.lg-progress-bar { height: 4px; background: #e2ddd1; border-radius: 2px; margin-top: 4px; overflow: hidden; }
.lg-progress-fill { height: 100%; background: #1f4b37; transition: width 0.3s; }
.lg-progress-fill.pickup { background: #d6b94e; }
/* Analytics-Panel (Phase 7) — Toggle-Button liegt im Header (ggs-btn-ghost) */
.lg-analytics-panel { position: absolute; top: 8px; right: 8px; width: 240px; max-height: calc(100% - 16px); overflow-y: auto; background: #fff; border: 1px solid #e2ddd1; border-radius: 6px; padding: 12px; box-shadow: 0 4px 12px rgba(0,0,0,.15); z-index: 1100; font-size: 0.82rem; }
.lg-analytics-panel h4 { margin: 0 0 8px; font-size: 0.9rem; color: var(--ggs-text); }
.lg-analytics-panel dl { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: 3px 10px; }
.lg-analytics-panel dt { color: #666; }
.lg-analytics-panel dd { margin: 0; font-variant-numeric: tabular-nums; font-weight: 600; }
/* End-Screen (Phase 7) */
.lg-endscreen-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); display: none; align-items: center; justify-content: center; z-index: 2500; }
.lg-endscreen-overlay.open { display: flex; }
.lg-endscreen-modal { background: #fff; border-radius: 10px; padding: 24px 28px; max-width: 520px; width: 92%; box-shadow: 0 12px 36px rgba(0,0,0,.35); }
.lg-endscreen-modal h2 { margin: 0 0 8px; font-size: 1.4rem; }
.lg-endscreen-modal.success h2 { color: #2d6d3d; }
.lg-endscreen-modal.fail h2 { color: #b32d2d; }
.lg-endscreen-summary { margin: 12px 0; padding: 10px 14px; background: #f7f5ef; border-radius: 6px; }
.lg-endscreen-summary dl { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; margin: 0; font-size: 0.9rem; }
.lg-endscreen-summary dt { color: #666; }
.lg-endscreen-summary dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; }
.lg-endscreen-actions { text-align: right; margin-top: 12px; display: flex; gap: 8px; justify-content: flex-end; }
/* Card-Selection (klickt Vehicle ↔ Contract zusammen) */
.lg-card.linked { border-color: #1f4b37; box-shadow: 0 0 0 2px #2d6d4d44; }
.lg-card.linked-pair{ border-color: #1e4f7a; box-shadow: 0 0 0 2px #3a82c444; }
/* Container-Symbol-Reihe (Phase 7-UX) */
.lg-containers { white-space: nowrap; letter-spacing: -2px; }
/* Laderaum (Phase 8 — Multi-Contract) */
.lg-loadmode-banner { background: #fff4c9; border: 1px solid #d6b94e; border-radius: 4px; padding: 8px 10px; margin-top: 6px; font-size: 0.78rem; color: #5e5320; }
.lg-cargo-hold { margin-top: 8px; padding: 8px; background: #f7f5ef; border: 1px solid #c0b8a3; border-radius: 5px; }
.lg-cargo-hold-title { font-size: 0.74rem; color: #666; margin-bottom: 6px; letter-spacing: 0.3px; text-transform: uppercase; }
.lg-cargo-slots { display: flex; flex-wrap: wrap; gap: 2px; }
.lg-slot { width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; font-size: 14px; border-radius: 3px; background: #e2ddd1; border: 1px dashed #b8ae90; }
.lg-slot.filled { background: #d6e8d6; border: 1px solid #2d6d3d; }
.lg-tour-actions { display: flex; gap: 6px; margin-top: 8px; }
.lg-tour-actions button { flex: 1; min-height: 36px; font-size: 0.85rem; }
.lg-stop-badge { display: inline-block; padding: 1px 6px; background: #e8f0eb; border-radius: 3px; font-size: 0.7rem; color: #2d6d3d; margin-left: 4px; }
/* Kosten-Hinweise (Phase 8b — Didaktik: Kostenstruktur sichtbar machen) */
.lg-card-cost { color: #3d3a2f; margin-top: 2px; }
.lg-card-cost strong { color: #1f4b37; }
.lg-card-cost-detail { color: #888; font-size: 0.72rem; }
.lg-contract-cost { color: #6b6135; font-size: 0.74rem; margin-top: 2px; }
.lg-tour-preview { margin-top: 8px; padding: 8px 10px; background: #eef6f0; border: 1px solid #8cbf9b; border-radius: 5px; font-size: 0.80rem; }
.lg-tour-preview-row { display: flex; justify-content: space-between; margin: 2px 0; }
.lg-tour-preview-row.sum { border-top: 1px solid #b6d4c0; margin-top: 6px; padding-top: 6px; font-weight: 600; }
.lg-tour-preview-row.sum.positive { color: #1f6b3a; }
.lg-tour-preview-row.sum.negative { color: #a83232; }
/* Intermodal Leg-Pills (Phase 6b) */
.lg-legs { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 3px; }
.lg-leg { display: inline-block; padding: 1px 5px; background: #f0ece3; border-radius: 3px; font-size: 0.72rem; }
.lg-leg.done { background: #d6e8d6; color: #2d6d3d; text-decoration: line-through; }
.lg-leg.open { background: #e8f0eb; color: #1f4b37; font-weight: 600; box-shadow: 0 0 0 1px #1f4b37; }
.lg-leg.wait { background: #f0ece3; color: #888; }
/* Hilfestufen-Highlights (Phase 6) */
.lg-country-highlight { stroke: #d6b94e; stroke-width: 3; fill: rgba(214,185,78,0.12); }
.lg-region-highlight { stroke: #1e4f7a; stroke-width: 2; fill: rgba(30,79,122,0.08); }
/* Glossar-Wörter (Pattern aus DS, hier nur visueller Hint) */
[data-glossar] { border-bottom: 1px dotted var(--ggs-text-muted, #888); cursor: help; }
/* Referenzbilder im Minigame (Phase 8-UX) */
.lg-minigame-ref { display: flex; gap: 8px; justify-content: center; margin: 6px 0 10px; flex-wrap: wrap; }
.lg-ref-item { text-align: center; }
.lg-ref-item canvas { display: block; background: #f7f5ef; border: 1px solid #e2ddd1; border-radius: 4px; }
.lg-ref-label { font-size: 0.72rem; margin-top: 2px; font-weight: 600; }
.lg-ref-label.good { color: #2d6d3d; }
.lg-ref-label.bad { color: #b32d2d; }
/* Minigame Modal (Phase 6c → 7-UX) — Top-Down-Einparken mit Bicycle-Lenkung */
.lg-minigame-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); display: none; align-items: center; justify-content: center; z-index: 2000; }
.lg-minigame-overlay.open { display: flex; }
.lg-minigame-modal { background: #fff; border-radius: 8px; padding: 16px 20px; max-width: 580px; width: 94%; box-shadow: 0 8px 24px rgba(0,0,0,.3); }
.lg-minigame-modal h3 { margin: 0 0 8px; font-size: 1.05rem; color: var(--ggs-text); }
.lg-minigame-modal canvas { display: block; width: 100%; aspect-ratio: 5/3; background: #f0ece3; border: 1px solid #e2ddd1; border-radius: 4px; margin: 8px 0; touch-action: none; }
.lg-minigame-pad { display: grid; grid-template-columns: repeat(3, 58px); grid-template-rows: 48px 48px; gap: 4px; justify-content: center; margin: 8px auto; }
.lg-minigame-pad button { font-size: 1.3rem; border-radius: 6px; }
.lg-minigame-pad #mgUp { grid-column: 2; grid-row: 1; }
.lg-minigame-pad #mgLeft { grid-column: 1; grid-row: 2; }
.lg-minigame-pad #mgDown { grid-column: 2; grid-row: 2; }
.lg-minigame-pad #mgRight { grid-column: 3; grid-row: 2; }
.lg-minigame-pad button.pressed { background: #1f4b37; color: #fff; }
.lg-minigame-actions { display: flex; gap: 10px; justify-content: center; margin: 10px 0 4px; }
.lg-mg-action { min-width: 150px; min-height: 46px; font-size: 0.95rem; border-radius: 8px; border: 0; cursor: pointer; font-weight: 600; display: inline-flex; align-items: center; justify-content: center; gap: 6px; transition: transform 0.1s, box-shadow 0.1s; }
.lg-mg-action:hover { transform: translateY(-1px); box-shadow: 0 3px 8px rgba(0,0,0,.15); }
.lg-mg-action:active { transform: translateY(0); box-shadow: 0 1px 3px rgba(0,0,0,.2); }
.lg-mg-reset { background: #fbe6c2; color: #5e5320; border: 1px solid #d6b94e; }
.lg-mg-reset:hover { background: #f7dca5; }
.lg-mg-reset span { font-size: 1.2rem; }
.lg-mg-close { background: #1f4b37; color: #fff; }
.lg-mg-close:hover { background: #2d6d4d; }
.lg-minigame-timer-big { font-size: 1.6rem; font-weight: 700; font-variant-numeric: tabular-nums; color: #1f4b37; text-align: center; margin: 4px 0; letter-spacing: 1px; }
.lg-minigame-timer-big.warn { color: #8a5a1e; }
.lg-minigame-timer-big.danger { color: #b32d2d; }
.lg-minigame-result { padding: 8px 12px; margin-top: 8px; background: #f0ece3; border-radius: 4px; font-size: 0.88rem; }
.lg-minigame-result.success { background: #d6e8d6; color: #2d6d3d; }
.lg-minigame-result.fail { background: #f3e3e3; color: #b32d2d; }
.lg-minigame-hint { font-size: 0.78rem; color: #666; margin: 4px 0; }
.lg-minigame-meta { font-size: 0.78rem; color: #666; display: flex; gap: 16px; justify-content: center; font-variant-numeric: tabular-nums; }
/* Shunting-Minigame (Phase 8c) — reuse overlay, slight modal tweak */
.lg-shunting-modal { max-width: 1040px; }
.lg-shunting-modal canvas { aspect-ratio: 14/5; cursor: pointer; }
</style>
</head>
<body>
<!-- Injection-Point für logistik.php-Wrapper (BASE_PATH, Seeds, Levels, Sim-Name) -->
<script>
/*__LOGISTIK_INJECTION__*/
// Fallback für direkten HTML-Aufruf (Dev-Modus ohne PHP-Wrapper)
if (!window.LOGISTIK_BASE) {
window.LOGISTIK_BASE = '';
window.LOGISTIK_SIM_NAME = 'Logistik Europa (Dev)';
window.LOGISTIK_SEEDS = { locations: [], vehicleTypes: [], cargoTypes: [], railnet: { nodes: [], edges: [] }, contractTemplates: [] };
window.LOGISTIK_LEVELS = [];
}
</script>
<div class="lg-layout">
<!-- ============= Header (nach App/sims/template.html) ============= -->
<header class="ggs-header">
<a href="../../" class="ggs-header-logo">
<img src="../../assets/img/bildLogo.png" alt="" class="ggs-header-logo-icon">
<img src="../../assets/img/textlogo_geograsim.svg" alt="GeoGraSim" class="ggs-header-logo-text">
</a>
<div class="ggs-header-separator"></div>
<span class="ggs-header-module" id="simName">Logistik Europa</span>
<span class="ggs-header-badge" id="levelBadge">Level 1</span>
<div class="ggs-header-spacer"></div>
<div class="ggs-header-actions">
<div class="ggs-speed" id="speedCtrl" role="group" aria-label="Zeitskala">
<button class="ggs-speed-btn" data-speed="0" title="Pause"></button>
<button class="ggs-speed-btn active" data-speed="1" title="1× Sim-Zeit">1×</button>
<button class="ggs-speed-btn" data-speed="2" title="2× Sim-Zeit">2×</button>
<button class="ggs-speed-btn" data-speed="4" title="4× Sim-Zeit">4×</button>
<button class="ggs-speed-btn" data-speed="8" title="8× Sim-Zeit">8×</button>
</div>
<!-- Music-Player (Standard-Widget aus Design-System, via ggsMusicSetup befüllt) -->
<div class="ggs-music" id="ggs-music" hidden>
<select class="ggs-music-sel" id="ggs-music-sel" title="Hintergrundmusik wählen"></select>
<button class="ggs-music-btn" id="ggs-music-toggle" title="Play / Pause"></button>
<input class="ggs-music-vol" id="ggs-music-vol" type="range" min="0" max="100" value="22" title="Lautstärke">
</div>
<button class="ggs-btn-ghost" id="btnAnalytics" title="Session-Analyse öffnen">📊</button>
<button class="ggs-btn-ghost" id="btnMinigame" title="Mini-Übung „An-die-Rampe-Einparken"" hidden>🎮</button>
<button class="ggs-btn-ghost" id="btnShunting" title="Mini-Übung „Rangieren am Bahnhof"" hidden>🚆</button>
<a class="ggs-btn-ghost" id="lehrplanLink" href="#" target="_blank" rel="noopener" title="Lehrplan-Bezug">📚</a>
<button class="ggs-btn-ghost" id="btnSave" title="Durchgang speichern">💾</button>
<button class="ggs-btn-ghost" id="btnLoad" title="Durchgang laden">📂</button>
<a class="ggs-btn-ghost" href="../../sim" title="Zurück zum Cockpit">🏠</a>
</div>
</header>
<!-- ============= Hauptbereich ============= -->
<main class="lg-main">
<aside class="lg-panel" id="contractsPanel">
<h3>Aufträge</h3>
<div id="contractsList"></div>
</aside>
<section class="lg-map">
<div id="map" role="region" aria-label="Europa-Karte mit Logistik-Standorten"></div>
<div id="toast" class="lg-toast" role="status" aria-live="polite"></div>
<div class="lg-analytics-panel" id="analyticsPanel" hidden>
<h4>Session-Kennzahlen</h4>
<dl id="analyticsDl"></dl>
</div>
</section>
<aside class="lg-panel" id="vehiclesPanel">
<h3>Fahrzeuge</h3>
<div id="vehiclesList"></div>
</aside>
</main>
<!-- ============= Status-Leiste ============= -->
<div class="lg-status">
<span>Sim-Zeit: <b id="statusTime"></b></span>
<span>Konto: <b id="statusBalance"></b></span>
<span>Bilanz: <b id="statusScore"></b></span>
<span>Aufträge: <b id="statusContracts">0/0</b></span>
<span style="flex: 1"></span>
<span id="statusState" class="ggs-badge">INIT</span>
</div>
<!-- ============= Didaktikfenster + Event-Panel ============= -->
<div class="lg-didaktik" id="didaktik">
<div id="didaktikText"><em>Willkommen in der <span data-glossar="logistik">Simulation</span> „Logistik Europa". Du arbeitest als Bearbeiter:in eines europäischen Logistikunternehmens. Klick rechts auf ein <span data-glossar="fahrzeug">Fahrzeug</span>, dann links auf die passenden <span data-glossar="auftrag">Aufträge</span> zum Laden. Die Kostenzeile <strong>„€/100 km"</strong> hilft bei der Auswahl: grosses Fahrzeug lohnt nur bei vollen Ladungen.</em></div>
<div id="eventPanel" class="lg-event-panel" hidden></div>
</div>
<!-- ============= End-Screen (Phase 7) ============= -->
<div class="lg-endscreen-overlay" id="endscreenOverlay" role="dialog" aria-modal="true" aria-labelledby="endscreenTitle">
<div class="lg-endscreen-modal" id="endscreenModal">
<h2 id="endscreenTitle">Durchgang beendet</h2>
<p id="endscreenText"></p>
<div class="lg-endscreen-summary">
<dl id="endscreenStats"></dl>
</div>
<div class="lg-endscreen-actions">
<button class="ggs-btn-secondary" id="esClose">Schließen</button>
<button class="ggs-btn-primary" id="esRestart">Neuer Durchgang</button>
</div>
</div>
</div>
<!-- ============= Minigame-Modal (Phase 7-UX) ============= -->
<div class="lg-minigame-overlay" id="minigameOverlay" role="dialog" aria-modal="true" aria-labelledby="minigameTitle">
<div class="lg-minigame-modal">
<h3 id="minigameTitle">An-die-Rampe-Einparken</h3>
<p class="lg-minigame-hint">
<strong>Ziel:</strong> <strong>Rückwärts</strong> in die <span style="color:#2d6d3d;font-weight:600">grüne Zone</span>
parken — das <span style="color:#b32d2d">rote Heck</span> muss zur Rampen-Rückwand zeigen.
<strong>Automatisches Andocken</strong>, sobald du richtig stehst.<br>
<strong>3 Minuten Zeit</strong> — je schneller, desto kürzer die Ladezeit im Spiel.
<strong>Crash</strong> (Wand / anderes Fahrzeug) oder Zeitablauf → ein Kollege übernimmt (längste Ladezeit).<br>
<em>Steuerung:</em> ⬆ vor · ⬇ zurück · ⬅➡ lenken. Rückwärts dreht in Gegenrichtung.
</p>
<div class="lg-minigame-ref">
<div class="lg-ref-item">
<canvas id="mgRef1" width="130" height="70"></canvas>
<div class="lg-ref-label good">✅ Rückwärts</div>
</div>
<div class="lg-ref-item">
<canvas id="mgRef2" width="130" height="70"></canvas>
<div class="lg-ref-label bad">❌ Vorwärts</div>
</div>
<div class="lg-ref-item">
<canvas id="mgRef3" width="130" height="70"></canvas>
<div class="lg-ref-label bad">❌ Schief</div>
</div>
</div>
<canvas id="minigameCanvas" width="500" height="300"></canvas>
<div class="lg-minigame-timer-big" id="mgTimerBig">⏱ 3:00</div>
<div class="lg-minigame-meta">
<span>Tempo: <b id="mgSpeedLabel">0</b> km/h</span>
<span>Winkel: <b id="mgAngleLabel"></b></span>
<span id="mgDockStatus" style="font-weight:600;"></span>
</div>
<div class="lg-minigame-pad">
<button class="ggs-btn-ghost" id="mgUp" title="Vorwärts (⬆)"></button>
<button class="ggs-btn-ghost" id="mgLeft" title="Lenkung links (⬅)"></button>
<button class="ggs-btn-ghost" id="mgDown" title="Rückwärts (⬇)"></button>
<button class="ggs-btn-ghost" id="mgRight" title="Lenkung rechts (➡)"></button>
</div>
<div class="lg-minigame-actions">
<button class="lg-mg-action lg-mg-reset" id="mgReset" title="Position und Timer zurücksetzen"><span></span> Neu starten</button>
<button class="lg-mg-action lg-mg-close" id="mgClose" title="Mini-Übung schließen">Fertig · Schließen</button>
</div>
<div id="minigameResult" class="lg-minigame-result" hidden></div>
</div>
</div>
<!-- ============= Shunting-Modal (Phase 8c — Rangierbahnhof) ============= -->
<div class="lg-minigame-overlay" id="shuntingOverlay" role="dialog" aria-modal="true" aria-labelledby="shuntingTitle">
<div class="lg-minigame-modal lg-shunting-modal">
<h3 id="shuntingTitle">🚆 Rangierbahnhof — Hauptgleis + Sammelgleis</h3>
<p class="lg-minigame-hint">
<strong>Ziel:</strong> Reihenfolge <strong>🎡W · 🥨M · ⚓H · 🗼P</strong>
(Lok vorn) und mit der Lok rechts auf dem Hauptgleis ankommen.<br>
<strong>⬅ / ➡</strong> Lok fahren (vor / zurück, jeweils relativ zur
Lok-Front — am Scheinwerfer erkennst du, wohin sie schaut) ·
<strong>1 2 3</strong> obere Weichen, <strong>4 5 6</strong> untere Weichen
(oder Klick auf den Weichen-Kreis) · <strong></strong> klick zum Entkuppeln
(nur im Stillstand) · <strong>🔗</strong> automatisch beim Berühren am
Wagenende.
</p>
<canvas id="shuntingCanvas" width="1120" height="400"></canvas>
<div class="lg-minigame-meta">
<span>Rangiervorgänge: <b id="sgMoves">0</b> · Weichen: <b id="sgWeichenCount">0</b> · ⏱ <b id="sgTime">0.0 s</b></span>
<span id="sgStatus" style="font-weight:600;"></span>
<span hidden><b id="sgMin"></b></span>
</div>
<div class="lg-minigame-actions">
<button class="lg-mg-action lg-mg-reset" id="sgReset" title="Puzzle zurücksetzen"><span></span> Neu starten</button>
<button class="lg-mg-action lg-mg-close" id="sgClose" title="Mini-Übung schließen">Fertig · Schließen</button>
</div>
<div id="shuntingResult" class="lg-minigame-result" hidden></div>
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="engine.js"></script>
<script src="headless-runner.js"></script>
<script>
/* =================================================================
Logistik Europa — UI (Phasen 1-6)
----------------------------------------------------------------- */
/* GLOSSAR-Fallback (bis Atlas/Glossar die Inhalte über DB-API liefert) */
window.LOGISTIK_GLOSSAR = window.LOGISTIK_GLOSSAR || {
container: { title: 'Container', text: 'Standardisierte Transportbox für See- und Landverkehr. Macht das Umladen zwischen Schiff, Bahn und LKW schnell, ohne die Waren einzeln anzufassen.' },
teu: { title: 'TEU (20-Fuß-Container)', text: 'TEU = „Twenty-foot Equivalent Unit" — der Standardcontainer. 20 Fuß ≈ 6,06 m lang, 8 Fuß ≈ 2,44 m breit, 8,5 Fuß ≈ 2,59 m hoch. Fasst ca. 33 m³ Ladung oder bis 28 Tonnen. Ein 40-Fuß-Container (FEU) = 2 TEU. In unserer Simulation zählt 1 Container = 1 TEU.' },
intermodal: { title: 'Intermodaler Verkehr', text: 'Transport mit mindestens zwei verschiedenen Verkehrsträgern in einer Kette (z.B. Schiff → Bahn → LKW). Container werden umgeladen, bleiben aber dasselbe Stück.' },
umschlag: { title: 'Umschlag', text: 'Das Umladen einer Ware von einem Transportmittel auf ein anderes. Findet an Häfen, Bahnterminals und Logistikzentren statt.' },
luftlinie: { title: 'Luftlinie', text: 'Direkte gerade Linie zwischen zwei Punkten. Reale Straßen sind 2040 % länger, weil sie Berge umgehen und Städte verbinden.' },
disposition: { title: 'Disposition', text: 'Die Planung: Welcher Auftrag bekommt welches Fahrzeug? In welcher Reihenfolge wird gefahren? Ziel: keine Leerfahrten, alle Fristen halten.' },
frist: { title: 'Frist', text: 'Spätester Zeitpunkt für die Ablieferung. Bei Überschreitung gibt es Strafkosten (in unserer Simulation 1030 % vom Auftragswert pro Stunde).' },
leerfahrt: { title: 'Leerfahrt', text: 'Fahrt ohne Ladung — z.B. wenn ein LKW zur nächsten Abholstelle fährt. Verursacht Kosten, bringt aber keinen Erlös. Gute Disposition vermeidet sie.' },
standkosten: { title: 'Standkosten', text: 'Kosten, die anfallen, während Fahrzeug oder Container nicht bewegt werden — z.B. Container am Hafen, der auf Abholung wartet.' },
bahnterminal: { title: 'Bahnterminal', text: 'Umschlagplatz für Container zwischen Bahn und LKW. Liegt an wichtigen Knotenpunkten (Wien, München, Hamburg).' },
hafen: { title: 'Hafen', text: 'Knotenpunkt für See- und Landtransport. Container werden vom Schiff entladen und mit LKW oder Bahn ins Hinterland gebracht.' },
routing: { title: 'Routing', text: 'Berechnung der besten Strecke zwischen zwei Orten — entlang Straßen oder Bahnlinien, nicht in Luftlinie.' },
spedition: { title: 'Spedition', text: 'Unternehmen, das Transporte organisiert (Disposition, Routenplanung, Verträge mit Auftraggebern). Du übernimmst diese Rolle.' },
logistikkette:{ title: 'Logistikkette', text: 'Alle Schritte vom Hersteller bis zum Empfänger — Lager, Transport, Umschlag, Lieferung. Kette ist nur so stark wie ihr schwächstes Glied.' },
auftrag: { title: 'Auftrag', text: 'Ein Transport-Job mit Start, Ziel, Ware, Frist und Erlös. Du weist ihn einem Fahrzeug zu.' },
fahrzeug: { title: 'Fahrzeug', text: 'LKW (klein/groß) oder Güterzug. Jeder Typ hat andere Geschwindigkeit, Kapazität und Kosten.' },
logistik: { title: 'Logistik', text: 'Die Lehre und Praxis vom Transport — wie Waren möglichst schnell, billig und zuverlässig vom Absender zum Empfänger gelangen.' },
};
/* pickText: Leichte-Sprache-Helper.
Schaltet auf easy-Variante, wenn LOGISTIK_EASY=true (URL-Param ?easy=1
oder localStorage). Fallback auf standard. */
window.LOGISTIK_EASY = window.LOGISTIK_EASY === true
|| /[?&]easy=1\b/.test(location.search)
|| (function(){ try { return localStorage.getItem('logistik:easy') === '1'; } catch(_){ return false; } })();
window.pickText = function(opts) {
if (typeof opts === 'string') return opts;
if (window.LOGISTIK_EASY && opts.easy) return opts.easy;
return opts.standard || opts.easy || '';
};
/* Glossar-Click-Handler (Pattern aus Fluss-Modul) */
(function () {
var _popup = null;
document.addEventListener('click', function(e) {
if (_popup && !_popup.contains(e.target)) { _popup.remove(); _popup = null; }
var el = e.target.closest('[data-glossar]');
if (!el) return;
var key = el.dataset.glossar;
var entry = (window.LOGISTIK_GLOSSAR || {})[key];
if (!entry) return;
if (_popup) { _popup.remove(); _popup = null; }
var p = document.createElement('div');
p.className = 'ggs-glossar-popup';
p.innerHTML =
'<div class="ggs-glossar-title">' + entry.title + '</div>' +
'<div class="ggs-glossar-text">' + entry.text + '</div>';
document.body.appendChild(p);
_popup = p;
var rect = el.getBoundingClientRect();
var pw = p.offsetWidth;
var left = Math.max(8, Math.min(rect.left, window.innerWidth - pw - 8));
p.style.left = left + 'px';
p.style.top = (rect.bottom + 8) + 'px';
});
})();
(function () {
'use strict';
document.getElementById('simName').textContent = window.LOGISTIK_SIM_NAME || 'Logistik Europa';
/* ---------- Engine-Initialisierung (Lifecycle) ---------- */
const params = new URLSearchParams(location.search);
const levelNum = parseInt(params.get('level') || '1', 10);
const game = LogistikEngine.createGame(levelNum);
/* ---------- Server-API-Helper (Phase 7b) ----------
Alle Calls sind best-effort: bei Netzwerk-/Auth-Fehler wird leise
auf localStorage zurueckgefallen. Kein Aufruf blockiert das Spiel. */
const LG_API = (function () {
const base = (window.LOGISTIK_BASE || '').replace(/\/$/, '');
let disabled = false; // wird gesetzt, sobald ein 401 kommt -> Rest der Session offline
let activeAttemptId = null;
async function request(path, opts) {
if (disabled) return null;
try {
const res = await fetch(base + path, Object.assign({
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
}, opts || {}));
if (res.status === 401) { disabled = true; return null; }
if (!res.ok) return null;
return await res.json();
} catch (e) {
return null;
}
}
async function startAttempt() {
const j = await request('/api/logistik-sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ action: 'start', levelId: levelNum }),
});
if (j && j.attemptId) activeAttemptId = j.attemptId;
return activeAttemptId;
}
async function endAttempt(success, stats) {
if (!activeAttemptId) return null;
const body = {
action: 'end',
attemptId: activeAttemptId,
finalBalance: Math.round(game.balance || 0),
success: !!success,
stats: stats || {},
};
const j = await request('/api/logistik-sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(body),
});
activeAttemptId = null;
return j;
}
async function saveSlot(slot, data) {
return request('/api/logistik-saves', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ slot: slot, data: data, version: 1 }),
});
}
async function loadSlot(slot) {
return request('/api/logistik-saves?slot=' + encodeURIComponent(slot));
}
async function postAnalytics(entry) {
return request('/api/logistik-analytics', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(entry),
});
}
return {
startAttempt, endAttempt, saveSlot, loadSlot, postAnalytics,
isDisabled: () => disabled,
};
})();
// Autosave-Hook: local-first, Plattform-7b-Server-Save wird bei
// LEVEL_SUCCESS/LEVEL_FAILED vom Server-Closer ausgeloest.
LogistikEngine.onStateChange(game, function (evt) {
try {
localStorage.setItem(
'logistik:autosave:' + game.sessionId,
JSON.stringify(LogistikEngine.serialize(game))
);
} catch (e) {
console.warn('[Logistik] Autosave fehlgeschlagen:', e.message);
}
console.info('[Logistik] state:', evt.from, '→', evt.to, '(', evt.reason, ')');
});
// Server-seitigen Attempt anlegen (fire-and-forget).
LG_API.startAttempt().then(id => {
if (id) console.info('[Logistik] Server-Attempt:', id);
});
LogistikEngine.loadContent(game); // INIT → LOADING_CONTENT → READY
LogistikEngine.seedInitialVehicles(game); // Phase 2: Fahrzeuge anlegen
LogistikEngine.seedInitialContracts(game); // Phase 2: erste Aufträge anlegen
// Demo-Hook für Intermodal-Auftrag (Phase 6b — manueller Test)
if (/[?&]demo-intermodal=1\b/.test(location.search) &&
game._config && game._config.intermodalEnabled) {
try {
const demo = LogistikEngine.makeIntermodalContract(game, 'IM-DEMO', [
{ mode: 'TRUCK_SMALL', originId: 'rotterdam_hafen', targetId: 'hamburg' },
{ mode: 'TRAIN', originId: 'hamburg', targetId: 'muenchen' },
], 2, false);
game.activeContracts.push(demo);
} catch (e) {
console.warn('[Logistik] Demo-Intermodal fehlgeschlagen:', e.message);
}
}
/* ---------- Karte: Carto Positron + Marker + Layer ---------- */
const map = L.map('map', {
center: [50.5, 10.5],
zoom: 4,
minZoom: 4,
maxZoom: 10,
zoomControl: true,
tap: true, // Touch (iPad)
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors, © CARTO',
subdomains: 'abcd',
maxZoom: 19,
}).addTo(map);
// Layer-Gruppen pro Location-Typ
const layers = {
capitals: L.layerGroup(),
cities: L.layerGroup(),
ports: L.layerGroup(),
terminals: L.layerGroup(),
railnet: L.layerGroup(),
};
// Marker-Stile pro Typ
const markerStyle = {
CAPITAL: { radius: 8, color: '#1f4b37', fillColor: '#2d6d4d', fillOpacity: 0.85, weight: 2 },
CITY: { radius: 6, color: '#4a4a4a', fillColor: '#7d7d7d', fillOpacity: 0.75, weight: 1.5 },
PORT: { radius: 7, color: '#1e4f7a', fillColor: '#3a82c4', fillOpacity: 0.85, weight: 2 },
TERMINAL: { radius: 6, color: '#7a4a1e', fillColor: '#c4823a', fillOpacity: 0.85, weight: 2 },
};
// Locations rendern, gefiltert nach visibleLocationLevel
const visibleLevel = (game._config && game._config.visibleLocationLevel) || levelNum;
const locations = game.worldState.locations || [];
const locById = {};
locations.forEach(loc => {
locById[loc.id] = loc;
if ((loc.visibleFromLevel || 1) > visibleLevel) return;
const style = markerStyle[loc.type] || markerStyle.CITY;
const marker = L.circleMarker([loc.lat, loc.lon], style);
marker.bindPopup(
'<div class="lg-popup">' +
'<h4>' + escapeHtml(loc.name) + '</h4>' +
'<div class="lg-popup-meta">' + escapeHtml(loc.countryId) + ' · ' + escapeHtml(loc.regionId || '—') + '</div>' +
'<div class="lg-popup-type">' + escapeHtml(loc.type) + '</div>' +
'</div>'
);
if (loc.type === 'CAPITAL') marker.addTo(layers.capitals);
else if (loc.type === 'PORT') marker.addTo(layers.ports);
else if (loc.type === 'TERMINAL') marker.addTo(layers.terminals);
else marker.addTo(layers.cities);
});
// Bahnlinien (nur wenn railEnabled für dieses Level)
const railnet = game.worldState.railnet || { nodes: [], edges: [] };
const railEnabled = !!(game._config && game._config.railEnabled);
const nodeLocId = {};
railnet.nodes.forEach(n => { nodeLocId[n.id] = n.locationId; });
railnet.edges.forEach(edge => {
const a = locById[nodeLocId[edge.from]];
const b = locById[nodeLocId[edge.to]];
if (!a || !b) return;
const line = L.polyline([[a.lat, a.lon], [b.lat, b.lon]], {
color: '#7a4a1e', weight: 2, opacity: 0.6, dashArray: '6,6',
});
line.bindTooltip(a.name + ' ↔ ' + b.name + ' · ' + edge.distanceKm + ' km');
line.addTo(layers.railnet);
});
// Default-Layer aktivieren (Hauptstädte + Städte immer; Häfen+Bahn nur wenn Level passt)
layers.capitals.addTo(map);
layers.cities.addTo(map);
if (game._config && game._config.portsEnabled) layers.ports.addTo(map);
if (railEnabled) layers.railnet.addTo(map);
// Layer-Control rechts oben
L.control.layers(null, {
'Hauptstädte': layers.capitals,
'Städte': layers.cities,
'Häfen': layers.ports,
'Terminals': layers.terminals,
'Bahnnetz': layers.railnet,
}, { collapsed: false }).addTo(map);
// Skalenleiste (Phase 7-UX) — metrisch, unten-links
L.control.scale({
imperial: false,
metric: true,
maxWidth: 200, // ~ 100 km bei Europa-Zoom
position: 'bottomleft',
}).addTo(map);
/* ---------- Geld-Formatter (immer ganze Euro) ---------- */
function fmtEuro(v) {
return Math.round(v || 0).toLocaleString('de-DE') + ' €';
}
function fmtEuroSigned(v) {
const r = Math.round(v || 0);
return (r >= 0 ? '+' : '') + r.toLocaleString('de-DE') + ' €';
}
/* ---------- Status-Anzeige ---------- */
function updateStatusUI() {
document.getElementById('statusState').textContent = game.state;
document.getElementById('statusBalance').textContent = fmtEuro(game.balance);
document.getElementById('statusScore').textContent = game.score;
document.getElementById('statusContracts').textContent =
game.completedContracts.length + '/' + (game.activeContracts.length + game.completedContracts.length);
const t = new Date(game.simulationTime);
document.getElementById('statusTime').textContent = t.toLocaleString('de-DE');
}
/* ---------- Karten-Layer für aktive Routen + Fahrzeug-Marker ---------- */
const routeLayer = L.layerGroup().addTo(map);
const vehicleLayer = L.layerGroup().addTo(map);
const blinkLayer = L.layerGroup().addTo(map); // Hilfestufe BLINK_EXACT
const eventLayer = L.layerGroup().addTo(map); // (Phase 7-UX) Event-Symbole
const routeLineByRouteId = {};
const vehicleMarkerById = {};
const eventMarkerById = {};
let blinkInterval = null;
function eventEmoji(type) {
if (type === 'TRAFFIC_ACCIDENT') return '🚧';
if (type === 'SNOW') return '❄️';
if (type === 'STORM') return '🌪';
if (type === 'PORT_DELAY') return '⚓';
if (type === 'RAIL_STRIKE') return '🚆';
return '⚠';
}
function syncEventMarkers() {
const activeIds = new Set();
(game.activeEvents || []).forEach(e => {
if (typeof e.lat !== 'number' || typeof e.lon !== 'number') return;
activeIds.add(e.id);
if (!eventMarkerById[e.id]) {
const icon = L.divIcon({
className: 'lg-event-marker',
html: '<div style="font-size:26px; filter:drop-shadow(0 1px 3px rgba(0,0,0,.4));">' + eventEmoji(e.type) + '</div>',
iconSize: [34, 34],
iconAnchor: [17, 17],
});
const m = L.marker([e.lat, e.lon], { icon, interactive: true });
m.bindTooltip(e.messageShort, { direction: 'top', offset: [0, -12] });
m.addTo(eventLayer);
eventMarkerById[e.id] = m;
}
});
Object.keys(eventMarkerById).forEach(id => {
if (!activeIds.has(id)) {
eventLayer.removeLayer(eventMarkerById[id]);
delete eventMarkerById[id];
}
});
}
function vehicleEmoji(mode) {
if (mode === 'TRUCK_SMALL' || mode === 'TRUCK_LARGE') return '🚚';
if (mode === 'TRAIN') return '🚆';
return '📦';
}
function ensureVehicleMarker(v) {
if (vehicleMarkerById[v.id]) return vehicleMarkerById[v.id];
const color = v.color || '#1f4b37';
const icon = L.divIcon({
className: 'lg-vehicle-marker',
html: '<div style="width:30px;height:30px;border-radius:50%;background:#fff;border:3px solid ' + color + ';display:flex;align-items:center;justify-content:center;font-size:16px;box-shadow:0 1px 3px rgba(0,0,0,.3);">' + vehicleEmoji(v.mode) + '</div>',
iconSize: [32, 32],
iconAnchor: [16, 16],
});
const m = L.marker([v.lat || 0, v.lon || 0], { icon, interactive: true });
m.on('click', () => {
selectedVehicleId = (selectedVehicleId === v.id) ? null : v.id;
renderPanels();
updateRouteHighlight();
});
m.addTo(vehicleLayer);
vehicleMarkerById[v.id] = m;
return m;
}
function vehicleTooltipHtml(v) {
let lines = ['<strong>' + escapeHtml(v.displayName) + '</strong>'];
if (v.tripPhase) {
const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId);
const phaseLabel = v.tripPhase === 'pickup_drive' ? 'Leerfahrt zum Abholen'
: v.tripPhase === 'loading' ? 'Lädt'
: v.tripPhase === 'delivery_drive' ? 'Unterwegs'
: v.tripPhase === 'unloading' ? 'Lädt ab'
: v.tripPhase;
lines.push(phaseLabel);
if (c) {
lines.push(escapeHtml(c.code) + ' · ' +
escapeHtml(_locName(c.originLocationId)) + ' → ' +
escapeHtml(_locName(c.targetLocationId)));
}
if (v.tripPhase.endsWith('_drive')) {
lines.push(Math.round((v.segmentProgress || 0) * 100) + ' % gefahren');
}
} else {
lines.push('IDLE · ' + escapeHtml(_locName(v.currentLocationId)));
}
return lines.join('<br>');
}
function updateVehicleMarkers() {
(game.vehicles || []).forEach(v => {
const m = ensureVehicleMarker(v);
if (typeof v.lat === 'number' && typeof v.lon === 'number') {
m.setLatLng([v.lat, v.lon]);
}
// Tooltip live aktualisieren
const html = vehicleTooltipHtml(v);
if (!m.getTooltip()) {
m.bindTooltip(html, { direction: 'top', offset: [0, -10], sticky: true });
} else {
m.setTooltipContent(html);
}
});
// Vehicle-Deselection wenn Trip vorbei
if (selectedVehicleId) {
const sv = (game.vehicles || []).find(x => x.id === selectedVehicleId);
if (!sv || !sv.tripPhase) {
selectedVehicleId = null;
updateRouteHighlight();
}
}
}
/**
* Prüft: ist die Route zu einem verspäteten Auftrag zugehörig?
* (dueTime überschritten während Vehicle noch unterwegs)
*/
function routeBelongsToLateContract(routeId) {
const v = (game.vehicles || []).find(x =>
x.pickupRouteId === routeId || x.deliveryRouteId === routeId);
if (!v || !v.assignedContractId) return false;
const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId);
if (!c || !c.dueTime) return false;
return new Date(c.dueTime).getTime() < new Date(game.simulationTime).getTime();
}
function updateRouteHighlight() {
const selV = selectedVehicleId
? (game.vehicles || []).find(x => x.id === selectedVehicleId)
: null;
const focusedRouteIds = selV
? [selV.pickupRouteId, selV.deliveryRouteId].filter(Boolean)
: [];
const hasFocus = focusedRouteIds.length > 0;
Object.keys(routeLineByRouteId).forEach(id => {
const line = routeLineByRouteId[id];
if (!line) return;
const route = (game.activeRoutes || []).find(r => r.id === id);
const vehColor = (route && route.vehicleColor) || '#1f4b37';
const isPickup = !!(route && route.isPickup);
const isLate = routeBelongsToLateContract(id);
const isFocused = hasFocus && focusedRouteIds.indexOf(id) !== -1;
if (hasFocus && !isFocused) {
// Nicht in Fokus → gedimmt (grau)
line.setStyle({ color: '#888', weight: 2, opacity: 0.3, dashArray: isPickup ? '6,4' : null });
} else if (isLate) {
// Verspätet → rot + dashed (Vehicle-Farbe wird hier überschrieben, weil Dringlichkeit überwiegt)
line.setStyle({ color: '#b32d2d', weight: isFocused ? 6 : 5, opacity: 0.95, dashArray: '10,6' });
} else if (isFocused) {
// Fokussiert → Vehicle-Farbe, kräftig-dick
line.setStyle({ color: vehColor, weight: 6, opacity: 1, dashArray: isPickup ? '8,5' : null });
} else {
// Standard → Vehicle-Farbe
line.setStyle({
color: vehColor,
weight: isPickup ? 2 : 4,
opacity: isPickup ? 0.55 : 0.85,
dashArray: isPickup ? '6,4' : null,
});
}
});
}
function ensureRouteLine(route) {
if (routeLineByRouteId[route.id]) return routeLineByRouteId[route.id];
const color = route.vehicleColor || '#1f4b37';
const line = L.polyline(route.geometry, {
color,
weight: route.isPickup ? 2 : 4,
opacity: route.isPickup ? 0.55 : 0.85,
dashArray: route.isPickup ? '6,4' : null, // Pickup-Leerfahrt = dünn gestrichelt
});
line.addTo(routeLayer);
routeLineByRouteId[route.id] = line;
return line;
}
function syncRouteLines() {
// Hinzufügen
(game.activeRoutes || []).forEach(r => ensureRouteLine(r));
// Entfernen, was nicht mehr aktiv ist
Object.keys(routeLineByRouteId).forEach(id => {
if (!(game.activeRoutes || []).some(r => r.id === id)) {
routeLayer.removeLayer(routeLineByRouteId[id]);
delete routeLineByRouteId[id];
}
});
updateRouteHighlight();
}
/* ---------- Hilfe-Stufen-Hint (Phase 6: alle 5 Stufen aus PH 10.3) ---------- */
function applyHintForContract(contract) {
blinkLayer.clearLayers();
if (blinkInterval) { clearInterval(blinkInterval); blinkInterval = null; }
if (!contract) return;
const HM = LogistikEngine.HINT_MODE;
const mode = contract.hintMode || (game._config && game._config.defaultHintMode) || HM.NONE;
const locs = game.worldState.locations || [];
const o = locs.find(l => l.id === contract.originLocationId);
const t = locs.find(l => l.id === contract.targetLocationId);
if (!o || !t) return;
if (mode === HM.BLINK_EXACT) {
// Volle Hilfe: Origin (gelb) + Target (rot) blinken
const ringO = L.circleMarker([o.lat, o.lon], { radius: 14, color: '#d6b94e', fill: false, weight: 3 }).addTo(blinkLayer);
const ringT = L.circleMarker([t.lat, t.lon], { radius: 14, color: '#b32d2d', fill: false, weight: 3 }).addTo(blinkLayer);
let on = true;
blinkInterval = setInterval(() => {
on = !on;
[ringO, ringT].forEach(r => r.setStyle({ opacity: on ? 1 : 0.2 }));
}, 500);
}
else if (mode === HM.SHOW_COUNTRY) {
// Mittlere Hilfe: alle Locations im selben Land wie Target leuchten
const sameCountry = locs.filter(l => l.countryId === t.countryId);
sameCountry.forEach(l => {
L.circleMarker([l.lat, l.lon], { radius: 12, color: '#d6b94e', fillColor: '#fff8d6', fillOpacity: 0.4, weight: 2 }).addTo(blinkLayer);
});
// Origin als kleiner gelber Punkt zur Orientierung
L.circleMarker([o.lat, o.lon], { radius: 6, color: '#1f4b37', fillColor: '#2d6d4d', fillOpacity: 1, weight: 2 }).addTo(blinkLayer);
}
else if (mode === HM.SHOW_REGION) {
// Schwächere Hilfe: alle Locations in derselben Region wie Target
const sameRegion = locs.filter(l => l.regionId === t.regionId);
sameRegion.forEach(l => {
L.circleMarker([l.lat, l.lon], { radius: 10, color: '#1e4f7a', fillColor: '#cfe0f0', fillOpacity: 0.3, weight: 1.5 }).addTo(blinkLayer);
});
L.circleMarker([o.lat, o.lon], { radius: 6, color: '#1f4b37', fillColor: '#2d6d4d', fillOpacity: 1, weight: 2 }).addTo(blinkLayer);
}
else if (mode === HM.DISTANCE_FEEDBACK) {
// Phase 7+: Bei Klick auf falschen Marker → Distanz-Anzeige.
// In aktueller UI nicht direkt anwendbar (kein Klick-zum-Wählen-Modus).
// Skelett: nur Origin als Anker zeigen.
L.circleMarker([o.lat, o.lon], { radius: 8, color: '#1f4b37', fillColor: '#2d6d4d', fillOpacity: 0.7, weight: 2 }).addTo(blinkLayer);
}
// HM.NONE: nichts darstellen
}
/* ---------- Panels: Aufträge + Fahrzeuge ---------- */
let selectedContractId = null; // für intermodale Auftragswahl (Auftrag-first)
let selectedVehicleId = null; // Trip-Fokus ODER (wenn IDLE) Lademodus
let loadingVehicleId = null; // (Phase 8) Vehicle im Lademodus — = selectedVehicleId wenn IDLE
/* Cargo-Typ-Map aus Seeds */
function cargoEmoji(cargoTypeId) {
const ct = (game.worldState.cargoTypes || []).find(x => x.id === cargoTypeId);
return ct && ct.emoji ? ct.emoji : '📦';
}
function cargoName(cargoTypeId) {
const ct = (game.worldState.cargoTypes || []).find(x => x.id === cargoTypeId);
return ct ? ct.name : 'Ware';
}
/** Laderaum-Visualisierung: Slots mit Cargo-Icons pro Contract */
function renderCargoHold(v) {
const cap = v.capacityUnits || 1;
const loaded = v.loadedContractIds || [];
// Slot-Sequenz aus allen geladenen Contracts (jeder Container ein Slot)
const slots = [];
loaded.forEach(cid => {
const c = (game.activeContracts || []).find(x => x.id === cid);
if (!c) return;
const emoji = cargoEmoji(c.cargoTypeId);
for (let i = 0; i < (c.quantityContainers || 1); i++) {
slots.push('<span class="lg-slot filled" title="' + escapeHtml(c.code + ' · ' + cargoName(c.cargoTypeId)) + '">' + emoji + '</span>');
}
});
// Leere Slots
for (let i = slots.length; i < cap; i++) {
slots.push('<span class="lg-slot"></span>');
}
return '<div class="lg-cargo-hold">'
+ '<div class="lg-cargo-hold-title">Laderaum · ' + slots.filter((_,i)=>i<loaded.reduce((s,cid)=>s+((game.activeContracts||[]).find(x=>x.id===cid)||{quantityContainers:0}).quantityContainers,0)).length
+ ' / ' + cap + ' belegt</div>'
+ '<div class="lg-cargo-slots">' + slots.join('') + '</div>'
+ '</div>';
}
function renderContracts() {
const root = document.getElementById('contractsList');
const list = (game.activeContracts || []).slice().concat((game.completedContracts || []).slice(-3));
if (!list.length) {
root.innerHTML = '<div class="lg-hint">Keine Aufträge offen.</div>';
return;
}
root.innerHTML = '';
if (selectedContractId && (game.activeContracts || []).find(c => c.id === selectedContractId && c.state === 'OPEN')) {
const sel = game.activeContracts.find(c => c.id === selectedContractId);
const hint = document.createElement('div');
hint.className = 'lg-hint';
hint.innerHTML = pickText({
standard: 'Auftrag „' + escapeHtml(sel.code || sel.id) + '" gewählt — wähle nun ein <span data-glossar="fahrzeug">Fahrzeug</span>.',
easy: 'Du hast den Auftrag gewählt. Wähle jetzt ein Fahrzeug rechts.',
});
root.appendChild(hint);
}
// (Phase 7-UX) Gekoppeltes Highlight: wenn ein Vehicle selected ist, wird
// die zugehörige Contract-Card hervorgehoben (matched-pair)
let pairedContractId = null;
if (selectedVehicleId) {
const v = (game.vehicles || []).find(x => x.id === selectedVehicleId);
if (v && v.assignedContractId) pairedContractId = v.assignedContractId;
}
list.forEach(c => {
const card = document.createElement('div');
const cardSelected = (c.id === selectedContractId);
const cardPaired = (c.id === pairedContractId);
const isOpenForUser = c.intermodal
? (c.legs && c.legs[c.currentLegNum] && c.legs[c.currentLegNum].state === 'OPEN')
: (c.state === 'OPEN');
const tone = contractStateTone(c.state);
card.className = 'lg-card'
+ (cardSelected ? ' selected' : '')
+ (cardPaired ? ' linked-pair' : '')
+ (!isOpenForUser ? ' busy' : '')
+ (tone === 'open' ? ' state-open' : '')
+ (tone === 'busy' ? ' state-busy-soft' : '');
const due = new Date(c.dueTime);
const distance = _routeDistance(c.originLocationId, c.targetLocationId);
let legsHtml = '';
if (c.intermodal && c.legs) {
legsHtml = '<div class="lg-card-meta lg-legs"><span data-glossar="intermodal">Intermodal</span>: '
+ c.legs.map((l, i) => {
const cls = (l.state === 'DELIVERED') ? 'done'
: (l.state === 'OPEN') ? 'open'
: 'wait';
return '<span class="lg-leg ' + cls + '">'
+ (i + 1) + '. ' + escapeHtml(_locName(l.originLocationId))
+ '→' + escapeHtml(_locName(l.targetLocationId))
+ ' (' + (l.mode === 'TRAIN' ? '🚆' : '🚚') + ')</span>';
}).join(' ')
+ '</div>';
}
// Kostenschaetzung fuer das aktuell im Lademodus aktive Fahrzeug
// (falls vorhanden): origin → target mit dessen Modus. Erlaubt vor
// dem Laden eine Wirtschaftlichkeits-Einordnung.
let costHintHtml = '';
if (loadingVehicleId && isOpenForUser && !c.intermodal) {
const lv = (game.vehicles || []).find(x => x.id === loadingVehicleId);
if (lv) {
const estCost = estimateRouteCost(c.originLocationId, c.targetLocationId, lv.mode);
const margin = (c.rewardBase || 0) - estCost;
const sign = margin >= 0 ? '+' : '';
const col = margin >= 0 ? '#1f6b3a' : '#a83232';
costHintHtml = '<div class="lg-contract-cost">'
+ '≈ Fahrkosten mit ' + vehicleEmoji(lv.mode) + ' ' + escapeHtml(lv.displayName) + ': '
+ '<strong>' + fmtEuro(estCost) + '</strong>'
+ ' · Marge <span style="color:' + col + ';font-weight:600">' + sign + fmtEuro(Math.abs(margin)) + '</span>'
+ '</div>';
}
}
card.innerHTML =
'<div class="lg-card-title">' + escapeHtml(c.code || c.id) + (c.intermodal ? ' 🔗' : '') + '</div>' +
'<div class="lg-card-meta">' + escapeHtml(_locName(c.originLocationId)) + ' → ' + escapeHtml(_locName(c.targetLocationId)) +
' · ' + Math.round(distance) + ' km · ' + containerSymbols(c.quantityContainers) + '</div>' +
legsHtml +
'<div class="lg-card-meta">Erlös: ' + fmtEuro(c.rewardBase) + ' · <span data-glossar="frist">Frist</span>: ' + due.toLocaleString('de-DE') + '</div>' +
costHintHtml +
'<span class="lg-card-state ' + c.state + '">' + escapeHtml(contractStateLabel(c.state)) + '</span>';
card.addEventListener('click', () => {
if (!isOpenForUser) return;
// Phase 8: Wenn ein Vehicle im Lademodus ist und Contract NICHT intermodal,
// direkt in den LKW laden (statt nur Contract zu selecten).
if (loadingVehicleId && !c.intermodal) {
try {
const r = LogistikEngine.loadContract(game, loadingVehicleId, c.id);
showToast(pickText({
standard: '📦 ' + (c.code || c.id) + ' geladen — ' + r.freeSlotsAfter + ' Plätze frei.',
easy: 'Auftrag ist im LKW.',
}));
} catch (err) {
showToast(pickText({ standard: 'Laden geht nicht: ' + err.message, easy: 'Geht nicht: ' + err.message }));
}
renderPanels();
return;
}
// Sonst: alter Auftrag-first-Pfad (für Intermodal)
selectedContractId = (selectedContractId === c.id) ? null : c.id;
applyHintForContract(selectedContractId ? c : null);
renderPanels();
});
root.appendChild(card);
});
}
function renderVehicles() {
const root = document.getElementById('vehiclesList');
const list = game.vehicles || [];
if (!list.length) {
root.innerHTML = '<div class="lg-hint">Keine Fahrzeuge verfügbar.</div>';
return;
}
root.innerHTML = '';
// Falls intermodal-Auftrag selected: erforderlicher Mode für aktuellen Leg
let neededMode = null;
if (selectedContractId) {
const sel = (game.activeContracts || []).find(c => c.id === selectedContractId);
if (sel && sel.intermodal && sel.legs && sel.legs[sel.currentLegNum]) {
neededMode = sel.legs[sel.currentLegNum].mode;
}
}
list.forEach(v => {
const isFree = v.state === 'IDLE';
const modeFits = !neededMode || v.mode === neededMode;
const isLoading = (v.id === loadingVehicleId); // Phase 8: Lademodus
const isSelected = (v.id === selectedVehicleId);
const card = document.createElement('div');
card.className = 'lg-card'
+ (isSelected ? ' linked' : '')
+ (!isFree ? ' busy' : '');
const modeNote = (neededMode && !modeFits && selectedContractId)
? '<div class="lg-card-meta" style="color:#b32d2d">benötigt: ' + (neededMode === 'TRAIN' ? '🚆 Zug' : '🚚 LKW') + '</div>'
: '';
// (Phase 7-UX) Trip-Info bei MOVING/LOADING/UNLOADING
let tripHtml = '';
if (v.tripPhase) {
const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId);
const phaseLabel = v.tripPhase === 'pickup_drive' ? '🟡 Leerfahrt zum Abholen'
: v.tripPhase === 'loading' ? '📦 Lädt'
: v.tripPhase === 'delivery_drive' ? '🟢 Unterwegs'
: v.tripPhase === 'unloading' ? '📥 Lädt ab'
: v.tripPhase;
const phaseClass = v.tripPhase === 'pickup_drive' ? 'pickup'
: v.tripPhase === 'loading' || v.tripPhase === 'unloading' ? 'loading'
: '';
const progress = Math.round((v.segmentProgress || 0) * 100);
let etaLine = '';
const route = (game.activeRoutes || []).find(r => r.id === v.activeRouteId);
if (route && v.tripPhase.endsWith('_drive')) {
const remainingMin = Math.round(route.totalDurationMinutes * (1 - (v.segmentProgress || 0)));
const etaDate = new Date(new Date(game.simulationTime).getTime() + remainingMin * 60000);
etaLine = ' · ETA ' + etaDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
} else if ((v.tripPhase === 'loading' || v.tripPhase === 'unloading') && v.phaseRemainingMinutes > 0) {
etaLine = ' · noch ' + Math.ceil(v.phaseRemainingMinutes) + ' min';
}
const contractInfo = c
? escapeHtml(c.code) + ' · ' + escapeHtml(_locName(c.originLocationId)) + ' → ' + escapeHtml(_locName(c.targetLocationId))
: '—';
tripHtml = '<div class="lg-trip-info ' + phaseClass + '">'
+ '<div class="lg-trip-phase">' + phaseLabel + etaLine + '</div>'
+ '<div>' + contractInfo + '</div>'
+ (v.tripPhase.endsWith('_drive')
? '<div class="lg-progress-bar"><div class="lg-progress-fill ' + (v.tripPhase === 'pickup_drive' ? 'pickup' : '') + '" style="width:' + progress + '%"></div></div>'
: '')
+ '</div>';
}
const colorDot = '<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + (v.color || '#1f4b37') + ';margin-right:6px;vertical-align:middle;"></span>';
// Phase 8: Lademodus-Banner + Cargo-Hold + Tour-Actions
let loadModeHtml = '';
if (isLoading) {
loadModeHtml += '<div class="lg-loadmode-banner">📥 <strong>Lademodus aktiv</strong> — klick auf Aufträge links, um sie zu laden.</div>';
loadModeHtml += renderCargoHold(v);
// Tour-Kostenvorschau: didaktischer Hinweis fuer Anfaenger, auf Profi-Level (HintMode=NONE)
// ausgeblendet, damit Kopfrechnen explizit verlangt wird.
const hintMode = (game._config && game._config.defaultHintMode) || 'SHOW_REGION';
const showPreview = (hintMode === 'BLINK_EXACT' || hintMode === 'SHOW_COUNTRY' || hintMode === 'SHOW_REGION');
if (showPreview && (v.loadedContractIds || []).length) {
const est = estimateTourCost(v);
const rewardSum = (v.loadedContractIds || []).reduce((s, cid) => {
const cc = (game.activeContracts || []).find(x => x.id === cid);
return s + (cc ? (cc.rewardBase || 0) : 0);
}, 0);
const preMargin = rewardSum - est.cost;
const sign = preMargin >= 0 ? '+' : '';
const sumClass = preMargin >= 0 ? 'positive' : 'negative';
loadModeHtml += '<div class="lg-tour-preview">'
+ '<div class="lg-tour-preview-row"><span>Strecke (geschätzt)</span><span>' + Math.round(est.km) + ' km</span></div>'
+ '<div class="lg-tour-preview-row"><span>Fahrkosten (geschätzt)</span><span>' + fmtEuro(est.cost) + '</span></div>'
+ '<div class="lg-tour-preview-row"><span>Erlöse (ohne Boni/Strafen)</span><span>+' + fmtEuro(rewardSum) + '</span></div>'
+ '<div class="lg-tour-preview-row sum ' + sumClass + '"><span>Vor-Marge</span><span>' + sign + fmtEuro(Math.abs(preMargin)) + '</span></div>'
+ '</div>';
}
const loaded = (v.loadedContractIds || []).length;
loadModeHtml += '<div class="lg-tour-actions">'
+ '<button class="ggs-btn-primary" id="btnStartTour-' + v.id + '"' + (loaded ? '' : ' disabled') + '>🚀 Fahrt starten</button>'
+ '<button class="ggs-btn-secondary" id="btnCancelLoad-' + v.id + '">↺ Abbrechen</button>'
+ '</div>';
}
const per100 = vehicleCostPer100Km(v.mode);
const vehDef = (LogistikEngine.VEHICLE_DEFAULTS || {})[v.mode] || {};
const costLine = per100 != null
? '<div class="lg-card-meta lg-card-cost">'
+ '<strong>≈ ' + fmtEuro(per100) + ' / 100 km</strong>'
+ ' <span class="lg-card-cost-detail">('
+ (vehDef.costPerKm != null ? vehDef.costPerKm.toFixed(2).replace('.', ',') + ' €/km' : '')
+ (vehDef.costPerHour != null ? ' + ' + vehDef.costPerHour + ' €/h' : '')
+ ')</span>'
+ '</div>'
: '';
card.innerHTML =
'<div class="lg-card-title">' + colorDot + escapeHtml(v.displayName) + ' ' + vehicleEmoji(v.mode) + '</div>' +
'<div class="lg-card-meta">' + v.speedKmh + ' km/h · ' + containerSymbols(v.capacityUnits) + ' · ' + escapeHtml(_locName(v.currentLocationId)) + '</div>' +
costLine +
modeNote +
tripHtml +
loadModeHtml +
'<span class="lg-card-state ' + v.state + '">' + escapeHtml(vehicleStateLabel(v.state)) + '</span>';
card.addEventListener('click', (e) => {
// Klick auf Tour-Action-Buttons separat behandeln
if (e.target && e.target.id && e.target.id.indexOf('btnStartTour-') === 0) {
e.stopPropagation();
try {
LogistikEngine.startTour(game, v.id);
loadingVehicleId = null;
if (game.state === LogistikEngine.STATE.PLANNING) {
LogistikEngine.startSimulation(game);
}
showToast(pickText({ standard: '🚀 Tour gestartet — ' + (v.tourStops || []).length + ' Stops.', easy: 'Das Fahrzeug fährt jetzt los.' }));
} catch (err) {
showToast(pickText({ standard: 'Tour fehlgeschlagen: ' + err.message, easy: 'Das geht nicht: ' + err.message }));
}
renderPanels();
return;
}
if (e.target && e.target.id && e.target.id.indexOf('btnCancelLoad-') === 0) {
e.stopPropagation();
LogistikEngine.cancelLoading(game, v.id);
loadingVehicleId = null;
renderPanels();
return;
}
// Fall 1: Vehicle hat einen Trip → Fokus-Toggle
if (v.tripPhase) {
selectedVehicleId = (selectedVehicleId === v.id) ? null : v.id;
if (selectedVehicleId && typeof v.lat === 'number') map.panTo([v.lat, v.lon]);
renderPanels();
updateRouteHighlight();
return;
}
// Fall 2: Vehicle IDLE — Lademodus toggle
if (!isFree) return;
if (loadingVehicleId === v.id) {
// Schon im Lademodus → abbrechen (cancel = unload alles)
LogistikEngine.cancelLoading(game, v.id);
loadingVehicleId = null;
} else {
// Anderes Vehicle wechseln: erst altes abbrechen
if (loadingVehicleId) LogistikEngine.cancelLoading(game, loadingVehicleId);
loadingVehicleId = v.id;
// selectedContractId resetten (sonst alte Intermodal-Wahl-Reste)
selectedContractId = null;
applyHintForContract(null);
}
renderPanels();
});
root.appendChild(card);
});
}
function renderPanels() { renderContracts(); renderVehicles(); }
function _locName(id) {
const l = (game.worldState.locations || []).find(x => x.id === id);
return l ? l.name : id;
}
/* ---------- Container-Symbole (Phase 7-UX) ----------
Bis 6 Container: einzeln als Symbol. Ab 7: „N×📦" als Kürzel. */
function containerSymbols(n) {
n = Math.max(0, n | 0);
const sym = '<span data-glossar="teu" title="TEU (20-Fuß-Container)">📦</span>';
if (n === 0) return '<span style="color:#888">—</span>';
if (n <= 6) return '<span class="lg-containers">' + sym.repeat(n) + '</span>';
return '<span class="lg-containers">' + n + '× ' + sym + '</span>';
}
/* ---------- State-Labels (Phase 7-UX): Deutsch + Leichte Sprache ---------- */
const CONTRACT_STATE_LABELS = {
OPEN: { standard: 'offen', easy: 'frei' },
RESERVED: { standard: 'reserviert', easy: 'reserviert' },
ASSIGNED: { standard: 'zugewiesen', easy: 'zugeteilt' },
PICKUP_PENDING: { standard: 'Abholung läuft', easy: 'wird abgeholt' },
LOADING: { standard: 'wird beladen', easy: 'wird geladen' },
IN_TRANSIT: { standard: 'unterwegs', easy: 'auf dem Weg' },
AT_TRANSFER: { standard: 'am Umschlag', easy: 'wird umgeladen' },
UNLOADING: { standard: 'wird entladen', easy: 'wird ausgeladen' },
DELIVERED: { standard: 'geliefert', easy: 'fertig' },
LATE: { standard: 'verspätet', easy: 'zu spät' },
FAILED: { standard: 'fehlgeschlagen', easy: 'nicht geklappt' },
CANCELLED: { standard: 'abgebrochen', easy: 'abgesagt' },
WAITING: { standard: 'wartet', easy: 'wartet' },
};
const VEHICLE_STATE_LABELS = {
IDLE: { standard: 'bereit', easy: 'frei' },
RESERVED: { standard: 'reserviert', easy: 'reserviert' },
MOVING: { standard: 'fährt', easy: 'fährt' },
LOADING: { standard: 'lädt', easy: 'wird geladen' },
UNLOADING: { standard: 'lädt ab', easy: 'wird ausgeladen' },
WAITING: { standard: 'wartet', easy: 'wartet' },
BLOCKED: { standard: 'blockiert', easy: 'steht fest' },
BROKEN: { standard: 'defekt', easy: 'kaputt' },
};
function contractStateLabel(s) {
return pickText(CONTRACT_STATE_LABELS[s] || { standard: s });
}
function vehicleStateLabel(s) {
return pickText(VEHICLE_STATE_LABELS[s] || { standard: s });
}
function contractStateTone(s) {
// 'open' | 'busy' | 'done' | 'late' | 'fail' | 'wait'
if (s === 'OPEN') return 'open';
if (['PICKUP_PENDING','LOADING','IN_TRANSIT','AT_TRANSFER','UNLOADING'].indexOf(s) !== -1) return 'busy';
if (s === 'DELIVERED') return 'done';
if (s === 'LATE') return 'late';
if (['FAILED','CANCELLED'].indexOf(s) !== -1) return 'fail';
return 'wait';
}
/* ---------- Kosten-Helper (Phase 8b) ----------
Effektive Kosten pro 100 km bei Reisegeschwindigkeit:
cost_per_100km = 100 × €/km + (100 / speedKmh) × €/h
Wertebereich bei Defaults:
TRUCK_SMALL ≈ 156 € · TRUCK_LARGE ≈ 325 € · TRAIN ≈ 967 €
Per-100-km ist Kopfrechen-Anker für 1315-jaehrige: Distanz/100 × Wert. */
function vehicleCostPer100Km(mode) {
const veh = (LogistikEngine.VEHICLE_DEFAULTS || {})[mode];
if (!veh) return null;
return 100 * veh.costPerKm + (100 / veh.speedKmh) * veh.costPerHour;
}
/** Geschaetzte Fahrkosten fuer eine Einzelstrecke (pickup+delivery ignoriert). */
function estimateRouteCost(originId, targetId, mode) {
const veh = (LogistikEngine.VEHICLE_DEFAULTS || {})[mode];
if (!veh) return 0;
const km = _routeDistance(originId, targetId);
const hours = km / veh.speedKmh;
return km * veh.costPerKm + hours * veh.costPerHour;
}
/** Geschaetzte Kosten einer Tour aus v.loadedContractIds (Pickup-dann-Dropoff-Reihenfolge wie startTour). */
function estimateTourCost(v) {
const veh = (LogistikEngine.VEHICLE_DEFAULTS || {})[v.mode];
if (!veh) return { km: 0, cost: 0 };
const loaded = (v.loadedContractIds || []).map(cid =>
(game.activeContracts || []).find(x => x.id === cid)).filter(Boolean);
if (!loaded.length) return { km: 0, cost: 0 };
const stops = [];
loaded.forEach(c => stops.push(c.originLocationId));
loaded.forEach(c => stops.push(c.targetLocationId));
let prev = v.currentLocationId, km = 0;
stops.forEach(loc => {
if (loc === prev) return;
km += _routeDistance(prev, loc);
prev = loc;
});
const hours = km / veh.speedKmh;
return { km: km, cost: km * veh.costPerKm + hours * veh.costPerHour };
}
function _routeDistance(originId, targetId) {
const locs = game.worldState.locations || [];
const o = locs.find(l => l.id === originId);
const t = locs.find(l => l.id === targetId);
if (!o || !t) return 0;
// Haversine × 1.3 (gleiche Logik wie engine.calculateRoute Phase 2)
const R = 6371, dLat = (t.lat - o.lat) * Math.PI/180, dLon = (t.lon - o.lon) * Math.PI/180;
const s = Math.sin(dLat/2)**2 + Math.cos(o.lat*Math.PI/180)*Math.cos(t.lat*Math.PI/180)*Math.sin(dLon/2)**2;
return 2 * R * Math.atan2(Math.sqrt(s), Math.sqrt(1-s)) * 1.3;
}
function showToast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('visible');
setTimeout(() => el.classList.remove('visible'), 2500);
}
/* ---------- Notifications aus dem Engine in Toasts überführen ---------- */
function drainNotifications() {
if (!game.pendingNotifications || !game.pendingNotifications.length) return;
const next = game.pendingNotifications.shift();
if (next.type === 'CONTRACT_DELIVERED') {
const sign = next.net >= 0 ? '+' : '';
showToast(pickText({
standard: next.messageShort + ' (' + fmtEuroSigned(next.net) + ')',
easy: 'Auftrag fertig. Du hast ' + Math.abs(Math.round(next.net)) + ' Euro ' + (next.net >= 0 ? 'verdient.' : 'verloren.'),
}));
} else if (next.type === 'EVENT_STARTED') {
showToast(next.messageShort);
} else if (next.type === 'SHIP_ARRIVED') {
showToast(next.messageShort);
} else if (next.type === 'LEG_TRANSFERRED') {
showToast(next.messageShort);
}
}
/* ---------- Event-Panel im Didaktikfenster (Phase 6) ---------- */
function renderEventPanel() {
const panel = document.getElementById('eventPanel');
const events = game.activeEvents || [];
if (!events.length) { panel.hidden = true; panel.innerHTML = ''; return; }
panel.hidden = false;
panel.innerHTML = events.map(e => {
const remainingMs = new Date(e.endTime).getTime() - new Date(game.simulationTime).getTime();
const remainingMin = Math.max(0, Math.round(remainingMs / 60000));
const severe = e.impact && e.impact.speedMult <= 0.5;
return '<span class="lg-event-pill' + (severe ? ' severe' : '') + '">'
+ escapeHtml(e.messageShort) + ' · noch ' + remainingMin + ' min</span>';
}).join('');
}
/* ---------- Time-Controls (Speed inkl. Pause als 0×) ----------
Konvention aus Template: Pause ist data-speed="0" (timeScale=0).
Der 1×-Button startet die Sim, falls noch in PLANNING. */
const speedBtns = document.querySelectorAll('#speedCtrl .ggs-speed-btn');
function setActiveSpeedBtn(btn) {
speedBtns.forEach(b => b.classList.toggle('active', b === btn));
}
speedBtns.forEach(btn => {
btn.addEventListener('click', () => {
const scale = parseInt(btn.getAttribute('data-speed'), 10);
// Beim ersten Tempo > 0 die Sim starten, falls noch nicht RUNNING
if (scale > 0 && (game.state === LogistikEngine.STATE.PLANNING ||
game.state === LogistikEngine.STATE.READY)) {
if (game.state === LogistikEngine.STATE.READY) LogistikEngine.startPlanning(game);
LogistikEngine.startSimulation(game);
}
LogistikEngine.setTimeScale(game, scale);
setActiveSpeedBtn(btn);
updateStatusUI();
});
});
/* ---------- Lehrplan-Link (Modul-Detailseite, Anker lehrplan-bezug) ---------- */
const lehrplanLink = document.getElementById('lehrplanLink');
if (lehrplanLink) {
const base = window.LOGISTIK_BASE || '';
lehrplanLink.href = base + 'pages/modul-logistik.php#lehrplan-bezug';
}
/* ---------- Level-Badge ---------- */
const levelBadge = document.getElementById('levelBadge');
if (levelBadge) levelBadge.textContent = 'Level ' + levelNum;
/* ---------- Save/Load (Phase 7b: Server-API + localStorage-Fallback) ---------- */
document.getElementById('btnSave').addEventListener('click', async () => {
try {
const raw = JSON.stringify(LogistikEngine.serialize(game));
localStorage.setItem('logistik:save:manual', raw);
const res = await LG_API.saveSlot(1, raw);
if (res && res.saved) {
showToast(pickText({ standard: '✓ Gespeichert (Server + lokal).', easy: 'Gespeichert.' }));
} else {
showToast(pickText({ standard: '✓ Lokal gespeichert (Server nicht erreichbar).', easy: 'Gespeichert.' }));
}
console.info('[Logistik] Durchgang gespeichert.');
} catch (e) { alert('Speichern fehlgeschlagen: ' + e.message); }
});
document.getElementById('btnLoad').addEventListener('click', async () => {
const serverResp = await LG_API.loadSlot(1);
const serverData = serverResp && serverResp.data;
const raw = serverData || localStorage.getItem('logistik:save:manual');
if (!raw) return alert('Kein gespeicherter Durchgang gefunden.');
if (!confirm(pickText({
standard: 'Gespeicherten Durchgang laden? Aktueller Fortschritt geht verloren.',
easy: 'Alten Stand wieder holen? Der jetzige Fortschritt ist dann weg.',
}))) return;
try {
const restored = LogistikEngine.deserialize(typeof raw === 'string' ? raw : JSON.stringify(raw));
// Listener-Array erhalten (serialize() strippt sie) -- Closures im UI
// bleiben sonst ohne State-Hook.
const keepListeners = game._stateChangeListeners || [];
Object.keys(game).forEach(k => { if (!(k in restored) && k !== '_stateChangeListeners') delete game[k]; });
Object.assign(game, restored);
game._stateChangeListeners = keepListeners;
showToast(pickText({ standard: '✓ Durchgang geladen.', easy: 'Alter Stand ist wieder da.' }));
renderPanels();
updateStatusUI();
updateVehicleMarkers();
} catch (e) {
alert('Laden fehlgeschlagen: ' + e.message);
}
});
/* ---------- Analytics-Drain: game.pendingAnalytics -> Server ----------
Pro Tick maximal 1 Eintrag senden. Fehlgeschlagene Eintraege werden
entfernt (Server hat sie entweder oder sie sind verloren — Analytics
ist nicht geschaeftskritisch). */
let analyticsSending = false;
async function drainAnalyticsQueue() {
if (analyticsSending) return;
if (LG_API.isDisabled()) {
if (game.pendingAnalytics) game.pendingAnalytics.length = 0;
return;
}
if (!game.pendingAnalytics || !game.pendingAnalytics.length) return;
analyticsSending = true;
const entry = game.pendingAnalytics.shift();
try { await LG_API.postAnalytics(entry); } finally { analyticsSending = false; }
}
/* ---------- RAF-Loop ---------- */
let lastTime = performance.now();
let lastVehicleStateSig = '';
let lastAnalyticsRender = 0; // (Phase 7-UX) Throttling auf 1s
let lastStatusRender = 0; // Status-Leiste auf 250ms
let lastAnalyticsDrain = 0; // POST an Server auf 2s
function loop(now) {
const dt = now - lastTime;
lastTime = now;
LogistikEngine.tick(game, dt);
// Status-Leiste nur alle 250ms (flackert sonst bei hoher Simulationsrate)
if (now - lastStatusRender > 250) {
updateStatusUI();
lastStatusRender = now;
}
syncRouteLines();
updateVehicleMarkers();
drainNotifications();
if (now - lastAnalyticsDrain > 2000) {
drainAnalyticsQueue();
lastAnalyticsDrain = now;
}
// Panels nur neu rendern, wenn sich Vehicle/Contract-States geändert haben
const sig = (game.vehicles || []).map(v => v.id + ':' + v.state).join('|') + '#' +
(game.activeContracts || []).map(c => c.id + ':' + c.state).join('|') + '#' +
(game.completedContracts || []).length;
if (sig !== lastVehicleStateSig) {
lastVehicleStateSig = sig;
renderPanels();
}
renderEventPanel();
syncEventMarkers();
const analyticsEl = document.getElementById('analyticsPanel');
if (analyticsEl && !analyticsEl.hidden && typeof renderAnalyticsPanel === 'function'
&& now - lastAnalyticsRender > 1000) {
renderAnalyticsPanel();
lastAnalyticsRender = now;
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
// Initial-Update + Auto-Bereit auf PLANNING
LogistikEngine.startPlanning(game); // READY → PLANNING
updateStatusUI();
renderPanels();
updateVehicleMarkers();
/* ---------- Helpers ---------- */
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
console.info(
'[Logistik Phase 1] Engine bereit. ' +
locations.length + ' Locations geladen, ' +
railnet.edges.length + ' Bahnkanten. State: ' + game.state
);
/* =================================================================
Minigame: An-die-Rampe-Einparken (Phase 7-UX — Top-Down mit Bicycle-Lenkung)
----------------------------------------------------------------
Physik: Bicycle-Model mit Lenkwinkel, Vor- und Rückwärtsfahrt,
Rückwärts-Lenkinversion (wie in echt). Ziel: LKW-Heck in die
Andock-Bucht am rechten Rand.
================================================================= */
const cfgMG = game._config && game._config.minigamesEnabled;
const btnMinigame = document.getElementById('btnMinigame');
if (cfgMG) btnMinigame.hidden = false;
const overlay = document.getElementById('minigameOverlay');
const canvas = document.getElementById('minigameCanvas');
const ctx2d = canvas.getContext('2d');
const mgResult = document.getElementById('minigameResult');
const mgSpeedLabel = document.getElementById('mgSpeedLabel');
const mgAngleLabel = document.getElementById('mgAngleLabel');
const mgTimerBig = document.getElementById('mgTimerBig');
const mgDockStatus = document.getElementById('mgDockStatus');
const MG = {
W: 500, H: 300,
truckW: 60, truckH: 22, wheelbase: 40,
// Physik — etwas fixer für direkteres Feedback
maxSpeedFwd: 120, maxSpeedRev: 80,
accel: 200, brake: 260, friction: 80,
maxSteer: Math.PI / 5, // ~36°
steerRate: Math.PI * 1.5,
steerReturn: Math.PI * 2.0,
// Bucht: mittig-rechts, groß genug für den LKW längs rein
rampX: 360, rampY: 120, rampW: 120, rampH: 60,
// Grüne Zielzone — so groß wie der LKW + 3 px Toleranz in jede Richtung
// = 66 × 28, zentriert in der Bucht (Mittelpunkt 420/150)
greenZone: { x: 387, y: 136, w: 66, h: 28 },
// Hindernisse außerhalb des direkten Wegs (y=150 ist frei zwischen 100..350)
obstacles: [
{ x: 60, y: 40, w: 62, h: 22, color: '#7a7a7a' },
{ x: 250, y: 30, w: 62, h: 22, color: '#9a6a3a' },
{ x: 60, y: 240, w: 62, h: 22, color: '#3a6a8a' },
{ x: 250, y: 240, w: 62, h: 22, color: '#6a4a7a' },
],
// Active Mover (ein Fahrzeug mit AI-Steuerung; nur L3 aktiv)
activeMover: null, // wird beim Modal-Open gesetzt wenn L3
moverMaxTurn: Math.PI * 0.8, // rad/sec
// Countdown-Gesamtzeit
countdownMaxSec: 180,
};
let mgState = null;
let mgRaf = null;
let mgLastT = 0;
const mgKeys = { up: false, down: false, left: false, right: false };
/* ==========================================================
Web-Audio: Engine-Brummen + Bremsen + Crash (synthesized)
========================================================== */
let mgAudioCtx = null;
let mgEngineOsc = null;
let mgEngineGain = null;
let mgLastVForBrake = 0;
let mgCrashHandled = false;
function mgAudioInit() {
if (mgAudioCtx) return;
try {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
mgAudioCtx = new AC();
// Engine-Brummen: tiefer Sägezahn + Gain-Node
mgEngineOsc = mgAudioCtx.createOscillator();
mgEngineOsc.type = 'sawtooth';
mgEngineOsc.frequency.value = 60;
mgEngineGain = mgAudioCtx.createGain();
mgEngineGain.gain.value = 0; // startet stumm
mgEngineOsc.connect(mgEngineGain).connect(mgAudioCtx.destination);
mgEngineOsc.start();
} catch (e) { console.warn('[Logistik] Audio-Init:', e.message); }
}
function mgAudioStop() {
if (!mgAudioCtx) return;
try {
if (mgEngineGain) mgEngineGain.gain.value = 0;
if (mgAudioCtx.state === 'running') mgAudioCtx.suspend();
} catch (e) {}
}
function mgAudioResume() {
if (!mgAudioCtx) return;
try { if (mgAudioCtx.state !== 'running') mgAudioCtx.resume(); } catch (e) {}
}
function mgAudioEngineUpdate(v) {
if (!mgEngineOsc || !mgEngineGain || !mgAudioCtx) return;
const speed = Math.abs(v);
// Frequenz steigt mit Geschwindigkeit, Volume auch
const freq = 55 + Math.min(speed, 120) * 1.2;
const gain = Math.min(0.08, speed / 120 * 0.08); // gedeckelt, nicht zu laut
mgEngineOsc.frequency.setTargetAtTime(freq, mgAudioCtx.currentTime, 0.05);
mgEngineGain.gain.setTargetAtTime(gain, mgAudioCtx.currentTime, 0.05);
}
function mgAudioBrakeScreech() {
if (!mgAudioCtx) return;
try {
const dur = 0.25, now = mgAudioCtx.currentTime;
const buf = mgAudioCtx.createBuffer(1, Math.ceil(mgAudioCtx.sampleRate * dur), mgAudioCtx.sampleRate);
const ch = buf.getChannelData(0);
for (let i = 0; i < ch.length; i++) ch[i] = (Math.random() * 2 - 1) * (1 - i / ch.length);
const src = mgAudioCtx.createBufferSource();
src.buffer = buf;
const filt = mgAudioCtx.createBiquadFilter();
filt.type = 'bandpass';
filt.frequency.value = 2500;
filt.Q.value = 4;
const g = mgAudioCtx.createGain();
g.gain.value = 0.12;
src.connect(filt).connect(g).connect(mgAudioCtx.destination);
src.start(now);
src.stop(now + dur);
} catch (e) {}
}
function mgAudioCrash() {
if (!mgAudioCtx) return;
try {
const dur = 0.45, now = mgAudioCtx.currentTime;
const buf = mgAudioCtx.createBuffer(1, Math.ceil(mgAudioCtx.sampleRate * dur), mgAudioCtx.sampleRate);
const ch = buf.getChannelData(0);
for (let i = 0; i < ch.length; i++) ch[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / ch.length, 2);
const src = mgAudioCtx.createBufferSource();
src.buffer = buf;
const filt = mgAudioCtx.createBiquadFilter();
filt.type = 'lowpass';
filt.frequency.value = 600;
const g = mgAudioCtx.createGain();
g.gain.value = 0.25;
src.connect(filt).connect(g).connect(mgAudioCtx.destination);
src.start(now);
src.stop(now + dur);
// Zusätzlich ein tiefer Sinus-Impuls (Rumms)
const boom = mgAudioCtx.createOscillator();
boom.type = 'sine';
boom.frequency.setValueAtTime(90, now);
boom.frequency.exponentialRampToValueAtTime(30, now + 0.35);
const bg = mgAudioCtx.createGain();
bg.gain.setValueAtTime(0.3, now);
bg.gain.exponentialRampToValueAtTime(0.01, now + 0.4);
boom.connect(bg).connect(mgAudioCtx.destination);
boom.start(now);
boom.stop(now + 0.4);
} catch (e) {}
}
function mgMusicEnsurePlaying() {
const btn = document.getElementById('ggs-music-toggle');
if (!btn) return;
// Startet die Hauptmusik wenn sie nicht schon läuft.
// .playing-Klasse wird vom ggsMusicSetup gesetzt.
if (!btn.classList.contains('playing')) {
try { btn.click(); } catch (e) {}
}
}
function mgInit() {
mgState = {
x: 100, y: 150,
angle: 0,
v: 0,
steer: 0,
crashed: false,
docked: false,
result: null,
// Timer (Phase 8-UX): Stopwatch für Ladezeit-Konzept
timerStarted: false,
startMs: 0,
elapsedSec: 0,
stableInZoneSec: 0,
};
mgResult.hidden = true;
mgResult.className = 'lg-minigame-result';
mgResult.textContent = '';
mgDraw();
}
function mgReset() { mgInit(); }
function mgStep(dt) {
const s = mgState;
if (!s || s.docked) return;
// Gas / Bremse
if (mgKeys.up && !mgKeys.down) {
// Beschleunigen vorwärts (oder Bremsen wenn v < 0)
s.v += (s.v < 0 ? MG.brake : MG.accel) * dt;
} else if (mgKeys.down && !mgKeys.up) {
s.v -= (s.v > 0 ? MG.brake : MG.accel) * dt;
} else {
// Rollreibung
if (Math.abs(s.v) < MG.friction * dt) s.v = 0;
else s.v -= Math.sign(s.v) * MG.friction * dt;
}
// Geschwindigkeits-Limits
if (s.v > MG.maxSpeedFwd) s.v = MG.maxSpeedFwd;
if (s.v < -MG.maxSpeedRev) s.v = -MG.maxSpeedRev;
// Lenkwinkel
if (mgKeys.left && !mgKeys.right) {
s.steer -= MG.steerRate * dt;
} else if (mgKeys.right && !mgKeys.left) {
s.steer += MG.steerRate * dt;
} else {
// Lenkung geht zur Mitte zurück
if (Math.abs(s.steer) < MG.steerReturn * dt) s.steer = 0;
else s.steer -= Math.sign(s.steer) * MG.steerReturn * dt;
}
if (s.steer > MG.maxSteer) s.steer = MG.maxSteer;
if (s.steer < -MG.maxSteer) s.steer = -MG.maxSteer;
// Bicycle-Model: dθ = v/L * tan(δ). Bei negativem v dreht das Rad
// in die Gegenrichtung der Lenkung — genau das klassische
// Rückwärts-Lenkproblem.
s.angle += (s.v / MG.wheelbase) * Math.tan(s.steer) * dt;
s.x += s.v * Math.cos(s.angle) * dt;
s.y += s.v * Math.sin(s.angle) * dt;
// Wände
const margin = 12;
if (s.x < margin) { s.x = margin; s.v = 0; }
if (s.x > MG.W - margin) { s.x = MG.W - margin; s.v = 0; }
if (s.y < margin) { s.y = margin; s.v = 0; }
if (s.y > MG.H - margin) { s.y = MG.H - margin; s.v = 0; }
// Kollision mit Bucht-Wänden (Rahmen oben/unten/rechts, aber Öffnung links frei)
const r = { x: MG.rampX, y: MG.rampY, w: MG.rampW, h: MG.rampH };
// Truck-AABB approximativ (Rotation ignoriert — reicht didaktisch)
const hx = MG.truckW / 2, hy = MG.truckH / 2;
let tL = s.x - hx, tR = s.x + hx, tT = s.y - hy, tB = s.y + hy;
// Kollisionen mit geparkten LKWs → Crash = Game Over („Kollege übernimmt")
for (const o of MG.obstacles) {
const oL = o.x, oR = o.x + o.w, oT = o.y, oB = o.y + o.h;
if (tR > oL && tL < oR && tB > oT && tT < oB) {
mgGameOver('crash_vehicle');
return;
}
}
// Kollision mit Active Mover → Crash
if (MG.activeMover) {
const m = MG.activeMover;
const mhx = m.w / 2, mhy = m.h / 2;
const mL = m.x - mhx, mR = m.x + mhx, mT = m.y - mhy, mB = m.y + mhy;
if (tR > mL && tL < mR && tB > mT && tT < mB) {
mgGameOver('crash_vehicle');
return;
}
}
// Bucht-Wand-Kollision: Crash nur bei hoher Geschwindigkeit (>55) UND
// tiefem Eindringen (>6 px in die Wand). Sonst nur Bremse — damit
// sanftes Einfahren und leichtes Schrammen nie als Crash enden.
const deepTop = s.y < r.y - 4 && tB > r.y + 6 && tR > r.x + 4 && tL < r.x + r.w - 4;
const deepBottom = s.y > r.y + r.h + 4 && tT < r.y + r.h - 6 && tR > r.x + 4 && tL < r.x + r.w - 4;
const deepRight = s.x > r.x + r.w + 4 && tL < r.x + r.w - 6 && tB > r.y + 4 && tT < r.y + r.h - 4;
if ((deepTop || deepBottom || deepRight) && Math.abs(s.v) > 55) {
mgGameOver('crash_bay');
return;
}
// Sanfte Bremse + Reposition bei normalem Kontakt
if (tB > r.y && tT < r.y && tR > r.x && tL < r.x + r.w) {
s.y = r.y - hy - 1; s.v *= 0.5;
} else if (tT < r.y + r.h && tB > r.y + r.h && tR > r.x && tL < r.x + r.w) {
s.y = r.y + r.h + hy + 1; s.v *= 0.5;
} else if (tL < r.x + r.w && tR > r.x + r.w && tB > r.y && tT < r.y + r.h) {
s.x = r.x + r.w - hx - 1; s.v *= 0.4;
}
// Active Mover: sanft lenken Richtung nächstem Waypoint (L3)
if (MG.activeMover) {
const m = MG.activeMover;
const tgt = m.path[m.pathIdx];
const dx = tgt.x - m.x, dy = tgt.y - m.y;
const dist = Math.hypot(dx, dy);
if (dist < 18) {
m.pathIdx = (m.pathIdx + 1) % m.path.length;
}
const targetAngle = Math.atan2(dy, dx);
let d = ((targetAngle - m.angle + Math.PI * 3) % (2 * Math.PI)) - Math.PI;
const step = Math.sign(d) * Math.min(Math.abs(d), MG.moverMaxTurn * dt);
m.angle += step;
m.x += m.v * Math.cos(m.angle) * dt;
m.y += m.v * Math.sin(m.angle) * dt;
// Wraparound am Spielfeldrand (verschwindet, taucht anders wieder auf)
if (m.x < -40) m.x = MG.W + 40;
if (m.x > MG.W + 40) m.x = -40;
if (m.y < -40) m.y = MG.H + 40;
if (m.y > MG.H + 40) m.y = -40;
}
// Countdown: startet bei erster spürbarer Bewegung
if (!s.timerStarted && Math.abs(s.v) > 2) {
s.timerStarted = true;
s.startMs = performance.now();
}
if (s.timerStarted) {
s.elapsedSec = (performance.now() - s.startMs) / 1000;
const remaining = MG.countdownMaxSec - s.elapsedSec;
if (remaining <= 0) {
mgGameOver('timeout');
return;
}
}
// Audio: Engine-Sound je nach Geschwindigkeit
mgAudioEngineUpdate(s.v);
// Bremsen: abrupter Geschwindigkeitsabfall → Quietschen
const dv = Math.abs(s.v) - Math.abs(mgLastVForBrake);
if (dv < -8 && Math.abs(mgLastVForBrake) > 50) mgAudioBrakeScreech();
mgLastVForBrake = s.v;
// Auto-Detection „perfekt angedockt":
// - Mitte in grüner Zone
// - RÜCKSEITIG: Heck (roter Balken) muss zur Rampen-Rückwand zeigen.
// Bei angle=0° zeigt Front nach rechts, Heck nach links → FALSCH.
// Bei angle≈180° zeigt Heck nach rechts (zur Rampen-Wand) → RICHTIG.
// - langsam + 0.3s stabil
if (s.timerStarted) {
const g = MG.greenZone;
const inGreen = s.x >= g.x && s.x <= g.x + g.w && s.y >= g.y && s.y <= g.y + g.h;
const deg = ((s.angle * 180 / Math.PI) % 360 + 540) % 360 - 180;
// rückseitig = Winkel nahe ±180°. angleDev misst Abweichung vom 180°-Soll.
const angleDev = Math.abs(180 - Math.abs(deg));
const stable = Math.abs(s.v) < 20;
const rearCorrect = angleDev < 18;
s._dockStatus = { inGreen, rearCorrect, stable, angleDev };
if (inGreen && rearCorrect && stable) {
s.stableInZoneSec = (s.stableInZoneSec || 0) + dt;
if (s.stableInZoneSec > 0.3) {
mgFinishByTimer();
}
} else {
s.stableInZoneSec = 0;
}
}
}
/** Zeichnet einen LKW mit Kabine + Auflieger + Rädern + Fenstern.
* Context muss bereits auf den Truck-Mittelpunkt translated + rotated sein.
* Truck schaut in +X-Richtung (Front nach rechts, Heck nach links).
* @param {CanvasRenderingContext2D} ctx
* @param {{cabColor, trailerColor, steer}} opts
*/
function drawTruckDetailed(ctx, opts) {
opts = opts || {};
const W = MG.truckW, H = MG.truckH;
const cabW = 18, trailerW = 38, gap = 2;
const leftX = -W / 2;
const cabColor = opts.cabColor || '#1f4b37';
const trailerColor = opts.trailerColor || '#e6e2d6';
const steer = typeof opts.steer === 'number' ? opts.steer : 0;
// Schatten unter dem LKW
ctx.fillStyle = 'rgba(0,0,0,0.15)';
ctx.fillRect(leftX + 1, -H / 2 + 2, W, H);
// Auflieger (Trailer, hinterer Teil)
ctx.fillStyle = trailerColor;
ctx.fillRect(leftX, -H / 2, trailerW, H);
ctx.strokeStyle = '#7a7a7a';
ctx.lineWidth = 1;
ctx.strokeRect(leftX + 0.5, -H / 2 + 0.5, trailerW - 1, H - 1);
// Trailer-Streifen (Längs-Dekor)
ctx.strokeStyle = 'rgba(0,0,0,0.12)';
ctx.beginPath();
ctx.moveTo(leftX + 3, -H / 2 + H * 0.35); ctx.lineTo(leftX + trailerW - 3, -H / 2 + H * 0.35);
ctx.moveTo(leftX + 3, -H / 2 + H * 0.65); ctx.lineTo(leftX + trailerW - 3, -H / 2 + H * 0.65);
ctx.stroke();
// Kabine (vorn, farbig)
ctx.fillStyle = cabColor;
ctx.fillRect(leftX + trailerW + gap, -H / 2, cabW, H);
// Frontscheibe
ctx.fillStyle = 'rgba(168,208,232,0.85)';
ctx.fillRect(leftX + trailerW + gap + 4, -H / 2 + 3, cabW - 6, H - 6);
// Dachlüfter (kleine Box hinter der Windschutzscheibe)
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.fillRect(leftX + trailerW + gap + 1, -H / 2 + H * 0.4, 2, H * 0.2);
// Scheinwerfer
ctx.fillStyle = '#ffec99';
ctx.fillRect(leftX + W - 2, -H / 2 + 2, 2, 3);
ctx.fillRect(leftX + W - 2, H / 2 - 5, 2, 3);
// Heck-Balken (rot, Andock-Seite)
ctx.fillStyle = '#b32d2d';
ctx.fillRect(leftX, -H / 2, 3, H);
ctx.fillStyle = '#ffcccc';
ctx.fillRect(leftX - 0.5, -H / 2 + 2, 1, 2);
ctx.fillRect(leftX - 0.5, H / 2 - 4, 1, 2);
// Räder
ctx.fillStyle = '#1a1a1a';
const wheelW = 7, wheelH = 3;
// Trailer-Räder (2 Paare)
ctx.fillRect(leftX + 6, -H / 2 - 2.5, wheelW, wheelH);
ctx.fillRect(leftX + 6, H / 2 - 0.5, wheelW, wheelH);
ctx.fillRect(leftX + 20, -H / 2 - 2.5, wheelW, wheelH);
ctx.fillRect(leftX + 20, H / 2 - 0.5, wheelW, wheelH);
// Kabine-Vorderräder (gelenkt)
ctx.save();
ctx.translate(leftX + W - 8, -H / 2 - 1);
ctx.rotate(steer);
ctx.fillRect(-3.5, -1.5, 7, 3);
ctx.restore();
ctx.save();
ctx.translate(leftX + W - 8, H / 2 + 1);
ctx.rotate(steer);
ctx.fillRect(-3.5, -1.5, 7, 3);
ctx.restore();
}
/** Zeichnet ein statisch geparktes Fahrzeug (Hindernis). */
function drawObstacle(ctx, o) {
ctx.save();
ctx.translate(o.x + o.w / 2, o.y + o.h / 2);
drawTruckDetailed(ctx, { cabColor: o.color || '#7a7a7a', trailerColor: '#d4d0c3' });
ctx.restore();
}
/** Zeichnet die Andock-Bucht (wiederverwendbar für Mini-Referenzen). */
function drawBay(ctx, x, y, w, h, opts) {
opts = opts || {};
const lineScale = opts.lineScale || 1;
ctx.fillStyle = '#fff4c9';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = '#d6b94e';
ctx.lineWidth = 1;
for (let i = 0; i < Math.floor(w / 10) + 2; i++) {
ctx.beginPath();
ctx.moveTo(x + i * 10, y);
ctx.lineTo(x + i * 10 - Math.min(20, h), y + h);
ctx.stroke();
}
ctx.strokeStyle = '#5e5320';
ctx.lineWidth = 4 * lineScale;
ctx.beginPath();
ctx.moveTo(x, y); ctx.lineTo(x + w, y);
ctx.moveTo(x + w, y); ctx.lineTo(x + w, y + h);
ctx.moveTo(x + w, y + h); ctx.lineTo(x, y + h);
ctx.stroke();
ctx.strokeStyle = '#b45900';
ctx.lineWidth = 3 * lineScale;
ctx.beginPath();
ctx.moveTo(x + w - 3 * lineScale, y + 8 * lineScale);
ctx.lineTo(x + w - 3 * lineScale, y + h - 8 * lineScale);
ctx.stroke();
}
function mgDraw() {
const s = mgState; if (!s) return;
ctx2d.clearRect(0, 0, canvas.width, canvas.height);
// Asphalt
ctx2d.fillStyle = '#dcd7c9';
ctx2d.fillRect(0, 0, MG.W, MG.H);
// Fahrbahn-Linien (Dekor)
ctx2d.strokeStyle = 'rgba(255,255,255,0.4)';
ctx2d.setLineDash([10, 10]);
ctx2d.lineWidth = 1;
ctx2d.beginPath();
ctx2d.moveTo(0, 150); ctx2d.lineTo(MG.rampX - 10, 150);
ctx2d.stroke();
ctx2d.setLineDash([]);
// Hindernisse (geparkte LKWs)
MG.obstacles.forEach(o => drawObstacle(ctx2d, o));
// Active Mover (fahrendes Fahrzeug bei L3)
if (MG.activeMover) {
const m = MG.activeMover;
ctx2d.save();
ctx2d.translate(m.x, m.y);
ctx2d.rotate(m.angle);
drawTruckDetailed(ctx2d, { cabColor: m.color, trailerColor: '#d4d0c3' });
ctx2d.restore();
}
// Zielbucht
drawBay(ctx2d, MG.rampX, MG.rampY, MG.rampW, MG.rampH);
ctx2d.fillStyle = '#5e5320';
ctx2d.font = 'bold 11px sans-serif';
ctx2d.fillText('Bucht', MG.rampX + 10, MG.rampY - 4);
// Grüne Zielzone (Sweet Spot) — klar erkennbar
const gz = MG.greenZone;
ctx2d.fillStyle = 'rgba(45,109,61,0.35)';
ctx2d.fillRect(gz.x, gz.y, gz.w, gz.h);
ctx2d.strokeStyle = '#2d6d3d';
ctx2d.lineWidth = 2;
ctx2d.setLineDash([6, 4]);
ctx2d.strokeRect(gz.x, gz.y, gz.w, gz.h);
ctx2d.setLineDash([]);
ctx2d.fillStyle = '#2d6d3d';
ctx2d.font = 'bold 10px sans-serif';
ctx2d.fillText('🎯 Hier parken', gz.x + 2, gz.y - 2);
// Start-Marker
ctx2d.strokeStyle = '#888';
ctx2d.setLineDash([4, 4]);
ctx2d.lineWidth = 1;
ctx2d.strokeRect(100 - MG.truckW / 2, 150 - MG.truckH / 2, MG.truckW, MG.truckH);
ctx2d.setLineDash([]);
// LKW (mit Rotation) — detailliertes Design
ctx2d.save();
ctx2d.translate(s.x, s.y);
ctx2d.rotate(s.angle);
drawTruckDetailed(ctx2d, { cabColor: '#1f4b37', trailerColor: '#f0ece3', steer: s.steer });
ctx2d.restore();
// Heck-Position als kleiner Kreis (Ziel-Anker-Visualisierung)
const rear = mgTruckRear();
ctx2d.fillStyle = 'rgba(179,45,45,0.5)';
ctx2d.beginPath();
ctx2d.arc(rear.x, rear.y, 3, 0, Math.PI * 2);
ctx2d.fill();
// Meta-Anzeige aktualisieren
const kmh = Math.abs(s.v * 0.5) | 0; // px/s → willkürliche km/h-Skala
mgSpeedLabel.textContent = (s.v < 0 ? '-' : '') + kmh;
const deg = ((s.angle * 180 / Math.PI) % 360 + 360) % 360;
mgAngleLabel.textContent = (deg > 180 ? deg - 360 : deg).toFixed(0) + '°';
// Live-Dock-Status: zeigt dem Spieler welche Bedingung noch offen ist
if (mgDockStatus) {
const d = s._dockStatus;
if (!s.timerStarted || !d) {
mgDockStatus.textContent = '';
} else if (d.inGreen && d.rearCorrect && d.stable) {
mgDockStatus.textContent = '✓ halten…';
mgDockStatus.style.color = '#2d6d3d';
} else {
const parts = [];
parts.push(d.inGreen ? '✓ Zone' : '✗ Zone');
parts.push(d.rearCorrect ? '✓ Heck hin.' : '✗ Heck zur Rampe (' + (d.angleDev|0) + '°)');
parts.push(d.stable ? '✓ still' : '✗ bremsen');
mgDockStatus.textContent = parts.join(' · ');
mgDockStatus.style.color = '#8a5a1e';
}
}
// Countdown (MM:SS rückwärts) — prominent über Meta-Zeile
if (mgTimerBig) {
const remaining = Math.max(0, MG.countdownMaxSec - (s.elapsedSec || 0));
const m = Math.floor(remaining / 60);
const sc = Math.floor(remaining - m * 60);
const txt = m + ':' + (sc < 10 ? '0' : '') + sc;
mgTimerBig.textContent = s.timerStarted ? '⏱ ' + txt : '⏱ 3:00 · starte los!';
mgTimerBig.className = 'lg-minigame-timer-big'
+ (remaining < 30 ? ' danger' : remaining < 60 ? ' warn' : '');
}
}
function mgTruckRear() {
const s = mgState;
return {
x: s.x - (MG.truckW / 2) * Math.cos(s.angle),
y: s.y - (MG.truckW / 2) * Math.sin(s.angle),
};
}
function mgLoop(now) {
if (!mgState) return;
if (!mgLastT) mgLastT = now;
const dt = Math.min(0.05, (now - mgLastT) / 1000);
mgLastT = now;
mgStep(dt);
mgDraw();
mgRaf = requestAnimationFrame(mgLoop);
}
/** Auto-Finish durch Timer: verbrauchte Zeit bestimmt den Faktor. */
function mgFinishByTimer() {
const s = mgState; if (!s || s.docked) return;
s.docked = true;
// Engine stumm nach Andocken
if (mgEngineGain && mgAudioCtx) {
mgEngineGain.gain.setTargetAtTime(0, mgAudioCtx.currentTime, 0.1);
}
const t = s.elapsedSec;
let factor, quality, msgStd, msgEasy;
if (t < 15) {
factor = 0.7; quality = 'perfect';
msgStd = '🏆 Meisterlich angedockt in ' + t.toFixed(1) + ' s — Ladezeit nur 70 %.';
msgEasy = 'Super! Ganz schnell geschafft.';
} else if (t < 30) {
factor = 1.0; quality = 'ok';
msgStd = '👍 Sauber in ' + t.toFixed(1) + ' s — normale Ladezeit.';
msgEasy = 'Gut gemacht. Normales Laden.';
} else if (t < 60) {
factor = 1.2; quality = 'slow';
msgStd = '⏳ ' + t.toFixed(1) + ' s — Ladezeit +20 %.';
msgEasy = 'Etwas langsam. Laden dauert länger.';
} else {
factor = 1.3; quality = 'slow';
msgStd = '🐢 ' + t.toFixed(1) + ' s — Ladezeit +30 %.';
msgEasy = 'Langsam. Laden dauert länger.';
}
mgResult.className = 'lg-minigame-result' + (quality === 'perfect' ? ' success' : '');
mgResult.textContent = pickText({ standard: msgStd, easy: msgEasy });
mgResult.hidden = false;
mgApplyResult({ quality, factor, minigameTimeSec: t, success: true });
}
/** Game Over: Crash oder Zeitablauf → Maximale Ladezeit (Kollege übernimmt). */
function mgGameOver(reason) {
const s = mgState; if (!s || s.docked) return;
s.docked = true;
// Audio-Effekte: Crash-Sound bei Kollisionen (nicht bei Timeout)
if (reason === 'crash_vehicle' || reason === 'crash_bay') {
mgAudioCrash();
}
// Engine stumm
if (mgEngineGain && mgAudioCtx) {
mgEngineGain.gain.setTargetAtTime(0, mgAudioCtx.currentTime, 0.1);
}
const factor = 1.5;
let msgStd, msgEasy;
if (reason === 'timeout') {
msgStd = '⏰ Zeit abgelaufen! Ein Kollege übernimmt — maximale Ladezeit (150 %).';
msgEasy = 'Zeit vorbei. Jemand anderes macht das. Laden dauert lange.';
} else if (reason === 'crash_bay') {
msgStd = '💥 Gegen die Rampen-Wand! Ein Kollege übernimmt — maximale Ladezeit (150 %).';
msgEasy = 'Au! Gegen die Wand gefahren. Laden dauert lange.';
} else {
msgStd = '💥 Anderes Fahrzeug gerammt! Ein Kollege übernimmt — maximale Ladezeit (150 %).';
msgEasy = 'Au! Anderen LKW getroffen. Laden dauert lange.';
}
mgResult.className = 'lg-minigame-result fail';
mgResult.textContent = pickText({ standard: msgStd, easy: msgEasy });
mgResult.hidden = false;
mgApplyResult({ quality: 'miss', factor, reason, success: false });
}
function mgApplyResult(info) {
const result = {
success: !!info.success,
scoreModifier: info.quality === 'perfect' ? 1 : 0,
timeModifierMinutes: 0, costModifier: 0,
metadata: {
quality: info.quality,
loadingTimeFactor: info.factor,
minigameTimeSec: info.minigameTimeSec,
reason: info.reason,
},
};
if (mgState) mgState.result = result;
try { LogistikEngine.applyMinigameResult(game, result); }
catch (e) { console.warn('[Logistik] applyMinigameResult:', e.message); }
}
/** (Legacy-Funktion — wird nicht mehr vom UI aufgerufen, aber bleibt
* für Rückwärts-Kompatibilität.) */
/* Input-Handling: Tastatur + Touch */
function mgSetKey(name, pressed) {
mgKeys[name] = pressed;
const btnId = { up: 'mgUp', down: 'mgDown', left: 'mgLeft', right: 'mgRight' }[name];
const btn = document.getElementById(btnId);
if (btn) btn.classList.toggle('pressed', pressed);
}
const KEYMAP = {
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
w: 'up', s: 'down', a: 'left', d: 'right',
};
function mgKeyHandler(ev, pressed) {
if (!overlay.classList.contains('open')) return;
const k = KEYMAP[ev.key];
if (!k) return;
ev.preventDefault();
mgSetKey(k, pressed);
}
window.addEventListener('keydown', e => mgKeyHandler(e, true));
window.addEventListener('keyup', e => mgKeyHandler(e, false));
function mgBindButton(id, name) {
const el = document.getElementById(id);
const press = (e) => { e.preventDefault(); mgSetKey(name, true); };
const release = (e) => { e.preventDefault(); mgSetKey(name, false); };
el.addEventListener('mousedown', press);
el.addEventListener('touchstart', press, { passive: false });
['mouseup','mouseleave','touchend','touchcancel'].forEach(ev => el.addEventListener(ev, release));
}
mgBindButton('mgUp', 'up');
mgBindButton('mgDown', 'down');
mgBindButton('mgLeft', 'left');
mgBindButton('mgRight', 'right');
/** Mini-Renderer: kleinere LKW-Darstellung, passt in 130×70-Canvasse */
function drawMiniTruck(ctx, w, h, opts) {
opts = opts || {};
const cabColor = opts.cabColor || '#2d6d3d';
const trailerColor = opts.trailerColor || '#f0ece3';
const cabW = w * 0.32, trW = w * 0.62, gap = w * 0.04;
// Trailer
ctx.fillStyle = trailerColor;
ctx.fillRect(-w / 2, -h / 2, trW, h);
ctx.strokeStyle = '#888';
ctx.lineWidth = 0.8;
ctx.strokeRect(-w / 2 + 0.5, -h / 2 + 0.5, trW - 1, h - 1);
// Kabine
ctx.fillStyle = cabColor;
ctx.fillRect(-w / 2 + trW + gap, -h / 2, cabW, h);
// Scheibe
ctx.fillStyle = '#a8d0e8';
ctx.fillRect(-w / 2 + trW + gap + 1.5, -h / 2 + 1.5, cabW - 3, h - 3);
// Heck (rot)
ctx.fillStyle = '#b32d2d';
ctx.fillRect(-w / 2, -h / 2, 1.5, h);
// Räder
ctx.fillStyle = '#222';
ctx.fillRect(-w / 2 + 3, -h / 2 - 1, 4, 1.5);
ctx.fillRect(-w / 2 + 3, h / 2 - 0.5, 4, 1.5);
ctx.fillRect(-w / 2 + w - 7, -h / 2 - 1, 4, 1.5);
ctx.fillRect(-w / 2 + w - 7, h / 2 - 0.5, 4, 1.5);
}
/** Drei Referenz-Skizzen beim Modal-Öffnen einmalig zeichnen */
function mgDrawReferenceMiniatures() {
// Bucht: rechts, 50×30
const bay = { x: 64, y: 18, w: 58, h: 36 };
// Truck: 40×12
const trkW = 40, trkH = 12;
const variants = [
// Richtig: rückwärts, Heck (rot) zur Rampen-Rückwand (rechts)
{ id: 'mgRef1', label: 'good', tx: 92, ty: 36, ta: Math.PI, sub: 'rückwärts' },
// Falsch: vorwärts in der Bucht — Heck zeigt nach links
{ id: 'mgRef2', label: 'bad', tx: 92, ty: 36, ta: 0, sub: 'verkehrt' },
// Falsch: schief (rückwärts, aber schief gedreht)
{ id: 'mgRef3', label: 'bad', tx: 92, ty: 36, ta: Math.PI + 0.55, sub: 'schief' },
];
variants.forEach(v => {
const c = document.getElementById(v.id);
if (!c) return;
const cx = c.getContext('2d');
cx.clearRect(0, 0, c.width, c.height);
// Hintergrund
cx.fillStyle = '#dcd7c9';
cx.fillRect(0, 0, c.width, c.height);
// Bucht
cx.fillStyle = '#fff4c9';
cx.fillRect(bay.x, bay.y, bay.w, bay.h);
cx.strokeStyle = '#5e5320';
cx.lineWidth = 1.5;
cx.beginPath();
cx.moveTo(bay.x, bay.y); cx.lineTo(bay.x + bay.w, bay.y);
cx.moveTo(bay.x + bay.w, bay.y); cx.lineTo(bay.x + bay.w, bay.y + bay.h);
cx.moveTo(bay.x + bay.w, bay.y + bay.h); cx.lineTo(bay.x, bay.y + bay.h);
cx.stroke();
// Andocklinie hinten
cx.strokeStyle = '#b45900';
cx.lineWidth = 1.5;
cx.beginPath();
cx.moveTo(bay.x + bay.w - 2, bay.y + 4);
cx.lineTo(bay.x + bay.w - 2, bay.y + bay.h - 4);
cx.stroke();
// Truck
cx.save();
cx.translate(v.tx, v.ty);
cx.rotate(v.ta);
drawMiniTruck(cx, trkW, trkH, {
cabColor: v.label === 'good' ? '#2d6d3d' : '#5e5320',
trailerColor: '#f0ece3',
});
cx.restore();
// Icon links oben
cx.font = 'bold 14px sans-serif';
cx.fillStyle = v.label === 'good' ? '#2d6d3d' : '#b32d2d';
cx.fillText(v.label === 'good' ? '✓' : '✗', 5, 15);
});
}
function mgResetKeys() {
['up','down','left','right'].forEach(k => mgSetKey(k, false));
}
btnMinigame.addEventListener('click', () => {
overlay.classList.add('open');
mgLastT = 0;
mgResetKeys();
// Audio: Kontext initialisieren (benötigt User-Gesture — der Button-Click ist eine)
mgAudioInit();
mgAudioResume();
// Trucker-Musik starten (falls Hauptmusik noch nicht läuft)
mgMusicEnsurePlaying();
// Ab L2: ein fahrendes Fahrzeug mit Lenklogik, sichtbar im Spielfeld
const cfg = game._config || {};
const lvl = (cfg.maxActiveContracts || 0) >= 6 ? 3
: (cfg.maxActiveContracts || 0) >= 3 ? 2 : 1;
MG.activeMover = null;
if (lvl >= 2) {
MG.activeMover = {
x: 180, y: 85, angle: 0, v: lvl >= 3 ? 38 : 28, // L3 etwas schneller
w: 58, h: 22, color: '#9a3a3a',
path: [
{ x: 330, y: 85 },
{ x: 330, y: 215 },
{ x: 180, y: 215 },
{ x: 180, y: 85 },
],
pathIdx: 0,
};
}
mgInit();
mgDrawReferenceMiniatures();
cancelAnimationFrame(mgRaf);
mgRaf = requestAnimationFrame(mgLoop);
});
document.getElementById('mgClose').addEventListener('click', () => {
overlay.classList.remove('open');
cancelAnimationFrame(mgRaf); mgRaf = null;
mgResetKeys();
mgAudioStop();
mgState = null;
});
document.getElementById('mgReset').addEventListener('click', () => {
mgResetKeys();
mgReset();
});
/* =================================================================
Shunting-Minigame (Phase 8c — Neustart)
-----------------------------------------------------------------
PHASE 1: eine horizontale Schiene, 1 Lok + 4 Waggons gekoppelt,
Pfeiltasten ⬅/➡ fahren die ganze Komposition. Weichen/Sortierung
kommen erst drauf, wenn die Fahrgrundlage sich gut anfuehlt.
================================================================= */
const btnShunting = document.getElementById('btnShunting');
btnShunting.hidden = false;
/* === Gleisnetz Phase 4: Hauptgleis oben (4 Teile, W1-W3) +
3 Sidings (gleis_1/2/3) + Sammelgleis unten (4 Teile, W4-W6).
17 Segmente, 6 Weichen. Endpunkt-basierte Verbindungen erlauben
Loops (z.B. main → gleis 1 → samm → gleis 2 → main). === */
const SG = {
canvas: null, ctx: null,
seg: {
main_L: { e0: [40, 80], e1: [280, 80] },
main_M1: { e0: [280, 80], e1: [520, 80] },
main_M2: { e0: [520, 80], e1: [760, 80] },
main_R: { e0: [760, 80], e1: [1080, 80] },
branch_t_1: { e0: [280, 80], e1: [340, 200] },
gleis_1: { e0: [340, 200], e1: [540, 200] },
branch_b_1: { e0: [540, 200], e1: [600, 320] },
branch_t_2: { e0: [520, 80], e1: [580, 200] },
gleis_2: { e0: [580, 200], e1: [780, 200] },
branch_b_2: { e0: [780, 200], e1: [840, 320] },
branch_t_3: { e0: [760, 80], e1: [820, 200] },
gleis_3: { e0: [820, 200], e1: [1020, 200] },
branch_b_3: { e0: [1020,200], e1: [1080, 320] },
samm_L: { e0: [40, 320], e1: [600, 320] },
samm_M1: { e0: [600, 320], e1: [840, 320] },
samm_M2: { e0: [840, 320], e1: [1080, 320] },
samm_R: { e0: [1080,320], e1: [1100, 320] }, // 20px Stub fuer W6
},
locW: 58, locH: 28, wagonW: 48, wagonH: 24, coupling: 8,
speedPxPerSec: 130,
weichen: { W1: 'gerade', W2: 'gerade', W3: 'gerade', W4: 'gerade', W5: 'gerade', W6: 'gerade' },
weichenPos: { W1: [280, 80], W2: [520, 80], W3: [760, 80], W4: [600, 320], W5: [840, 320], W6: [1080, 320] },
WAGONS: [
{ id: 'W', color: '#9a3d4d', emoji: '🎡', label: 'Wien' },
{ id: 'M', color: '#b88a45', emoji: '🥨', label: 'München' },
{ id: 'H', color: '#4f7a9a', emoji: '⚓', label: 'Hamburg' },
{ id: 'P', color: '#6a7a3d', emoji: '🗼', label: 'Paris' },
],
TARGET: ['W', 'M', 'H', 'P'],
WAGON_BY_ID: {},
consists: [], // [{ id, items, head:{segId,s}, isActive }]
nextConsistId: 1,
activeId: null,
keys: { left: false, right: false },
vel: 0,
lastDirSign: 0,
rangierZuege: 0,
weichenstellungen: 0,
startTime: 0,
solved: false,
lastT: 0, raf: null,
};
SG.WAGONS.forEach(w => SG.WAGON_BY_ID[w.id] = w);
Object.values(SG.seg).forEach(s => {
s.len = Math.hypot(s.e1[0] - s.e0[0], s.e1[1] - s.e0[1]);
});
/* ---- Verbindungstabellen (endpunkt-basiert) ----
SG_LINKS: feste Verbindungen ohne Weiche (Branch <-> Siding).
SG_SWITCHES: Weichen, jede mit 3 Endpunkten und 2 Stellungen,
jede Stellung verbindet 2 der 3 Endpunkte (3. ist gesperrt). */
const SG_LINKS = {
'branch_t_1:e1': 'gleis_1:e0', 'gleis_1:e0': 'branch_t_1:e1',
'gleis_1:e1': 'branch_b_1:e0', 'branch_b_1:e0': 'gleis_1:e1',
'branch_t_2:e1': 'gleis_2:e0', 'gleis_2:e0': 'branch_t_2:e1',
'gleis_2:e1': 'branch_b_2:e0', 'branch_b_2:e0': 'gleis_2:e1',
'branch_t_3:e1': 'gleis_3:e0', 'gleis_3:e0': 'branch_t_3:e1',
'gleis_3:e1': 'branch_b_3:e0', 'branch_b_3:e0': 'gleis_3:e1',
};
const SG_SWITCHES = {
// Top: abbiegen verbindet main_LEFT mit Branch
W1: { gerade: ['main_L:e1', 'main_M1:e0'], abbiegen: ['main_L:e1', 'branch_t_1:e0'] },
W2: { gerade: ['main_M1:e1', 'main_M2:e0'], abbiegen: ['main_M1:e1', 'branch_t_2:e0'] },
W3: { gerade: ['main_M2:e1', 'main_R:e0'], abbiegen: ['main_M2:e1', 'branch_t_3:e0'] },
// Bottom: abbiegen verbindet samm_RIGHT mit Branch (Branch trifft samm von oben-LINKS,
// d.h. die natuerliche Fortsetzung beim Runterkommen ist samm_RIGHT)
W4: { gerade: ['samm_L:e1', 'samm_M1:e0'], abbiegen: ['samm_M1:e0', 'branch_b_1:e1'] },
W5: { gerade: ['samm_M1:e1', 'samm_M2:e0'], abbiegen: ['samm_M2:e0', 'branch_b_2:e1'] },
W6: { gerade: ['samm_M2:e1', 'samm_R:e0'], abbiegen: ['samm_R:e0', 'branch_b_3:e1'] },
};
/** Liefert das verbundene Endpunkt {seg, endpoint} oder null (Sackgasse / blockiert). */
function sgConnected(segId, endpoint) {
const key = segId + ':' + endpoint;
if (SG_LINKS[key]) {
const [s, e] = SG_LINKS[key].split(':');
return { seg: s, endpoint: e };
}
for (const name of Object.keys(SG_SWITCHES)) {
const sw = SG_SWITCHES[name];
const state = SG.weichen[name];
const pair = sw[state];
if (pair[0] === key) { const [s, e] = pair[1].split(':'); return { seg: s, endpoint: e }; }
if (pair[1] === key) { const [s, e] = pair[0].split(':'); return { seg: s, endpoint: e }; }
// Endpunkt ist an dieser Weiche, aber nicht in der aktiven Stellung -> blockiert
const allEndpoints = [].concat(sw.gerade, sw.abbiegen);
if (allEndpoints.includes(key)) return null;
}
return null;
}
/* ---- Geometrie ---- */
/** Position + Tangentenwinkel an Parameter s (0..len) auf einem Segment.
* dir = +1 → Lok schaut Richtung e1, -1 → Richtung e0. Der Winkel wird
* entsprechend gespiegelt, damit Lok und Waggons in Fahrtrichtung zeigen. */
function sgSegPos(segId, s, dir) {
const seg = SG.seg[segId];
const t = Math.max(0, Math.min(1, s / seg.len));
const baseAngle = Math.atan2(seg.e1[1] - seg.e0[1], seg.e1[0] - seg.e0[0]);
return {
x: seg.e0[0] + (seg.e1[0] - seg.e0[0]) * t,
y: seg.e0[1] + (seg.e1[1] - seg.e0[1]) * t,
angle: dir > 0 ? baseAngle : baseAngle + Math.PI,
};
}
/** Bewege Zustand {segId, s, dir} um signedDs Pixel.
* signedDs > 0: vorwaerts (in Heading)
* signedDs < 0: rueckwaerts
* Beruecksichtigt Segmentwechsel an Endpunkten. */
function sgTraverse(state, signedDs) {
let { segId, s, dir } = state;
let remaining = signedDs;
for (let g = 0; g < 16; g++) {
const seg = SG.seg[segId];
const ds = dir * remaining; // Aenderung des s-Wertes
let newS = s + ds;
if (newS > seg.len + 1e-9) {
// Endpunkt e1 ueberschritten
const overshoot = newS - seg.len;
const next = sgConnected(segId, 'e1');
if (!next) { s = seg.len; break; }
segId = next.seg;
const nseg = SG.seg[segId];
if (next.endpoint === 'e0') { s = 0; dir = +1; }
else { s = nseg.len; dir = -1; }
// Verbleibende Strecke in der neuen Heading-Richtung weiterfahren
remaining = (signedDs >= 0 ? 1 : -1) * Math.abs(overshoot);
} else if (newS < -1e-9) {
// Endpunkt e0 unterschritten
const undershoot = -newS;
const prev = sgConnected(segId, 'e0');
if (!prev) { s = 0; break; }
segId = prev.seg;
const nseg = SG.seg[segId];
if (prev.endpoint === 'e1') { s = nseg.len; dir = -1; }
else { s = 0; dir = +1; }
remaining = (signedDs >= 0 ? 1 : -1) * Math.abs(undershoot);
} else {
s = newS;
break;
}
}
return { segId, s, dir };
}
/** Zustand `distance` Pixel hinter Lok (gegen Heading-Richtung). */
function sgWalkBack(state, distance) { return sgTraverse(state, -distance); }
/** Zustand `distance` Pixel vor Lok (in Heading-Richtung). */
function sgWalkFwd (state, distance) { return sgTraverse(state, +distance); }
/* ---- Consist-Helper ---- */
function itemLen(it) { return it.type === 'lok' ? SG.locW : SG.wagonW; }
function itemCenterOffsets(items) {
// Offsets vom Item-0-Center (= consist.head)
const out = [];
let cum = 0;
items.forEach((it, i) => {
if (i === 0) out.push(0);
else {
cum += itemLen(items[i-1])/2 + SG.coupling + itemLen(it)/2;
out.push(cum);
}
});
return out;
}
function consistFrontEdge(c) {
const half = itemLen(c.items[0])/2;
const p = sgWalkFwd(c.head, half);
return { ...sgSegPos(p.segId, p.s, p.dir), segPos: p };
}
function consistBackEdge(c) {
const offs = itemCenterOffsets(c.items);
const lastHalf = itemLen(c.items[c.items.length-1])/2;
const back = offs[offs.length-1] + lastHalf;
const p = sgWalkBack(c.head, back);
return { ...sgSegPos(p.segId, p.s, p.dir), segPos: p };
}
function activeConsist() { return SG.consists.find(c => c.isActive); }
/* ---- Init ---- */
function sgInit() {
// Gemischte, NICHT vorsortierte Reihenfolge
let order;
do {
order = SG.WAGONS.map(w => w.id).sort(() => Math.random() - 0.5);
} while (order.join(',') === SG.TARGET.join(','));
const items = [{ type: 'lok' }];
order.forEach(id => items.push({ type: 'wagon', wagon: id }));
SG.consists = [{
id: 1,
items: items,
head: { segId: 'main_R', s: 200, dir: +1 }, // Lok schaut nach rechts
isActive: true,
}];
SG.nextConsistId = 2;
SG.activeId = 1;
SG.weichen = { W1: 'gerade', W2: 'gerade', W3: 'gerade', W4: 'gerade', W5: 'gerade', W6: 'gerade' };
SG.keys.left = false; SG.keys.right = false;
SG.vel = 0; SG.lastDirSign = 0;
SG.rangierZuege = 0;
SG.weichenstellungen = 0;
SG.startTime = performance.now();
SG.solved = false;
SG.lastT = 0;
document.getElementById('sgMoves').textContent = '0';
document.getElementById('sgWeichenCount').textContent = '0';
document.getElementById('sgTime').textContent = '0.0 s';
document.getElementById('sgStatus').textContent = '';
document.getElementById('shuntingResult').hidden = true;
}
/* ---- Schritt + Kollision/Kupplung ---- */
function sgStep(dt) {
if (SG.solved) return;
const active = activeConsist();
if (!active) return;
let v = 0;
if (SG.keys.left) v -= SG.speedPxPerSec;
if (SG.keys.right) v += SG.speedPxPerSec;
SG.vel = v;
const sign = Math.sign(v);
if (sign !== 0 && SG.lastDirSign !== 0 && sign !== SG.lastDirSign) {
SG.rangierZuege++;
document.getElementById('sgMoves').textContent = String(SG.rangierZuege);
}
if (sign !== 0) SG.lastDirSign = sign;
// Zeit-Anzeige
const elapsed = ((performance.now() - SG.startTime) / 1000);
document.getElementById('sgTime').textContent = elapsed.toFixed(1) + ' s';
if (v === 0) return;
let ds = v * dt;
const threshold = 5;
// Vorwaerts: Nasen-Kollision pruefen (KEINE Kupplung an der Lok-Front)
if (v > 0) {
const nose = consistFrontEdge(active);
for (const o of SG.consists) {
if (o.isActive) continue;
const oF = consistFrontEdge(o);
const oB = consistBackEdge(o);
const dF = Math.hypot(nose.x - oF.x, nose.y - oF.y);
const dB = Math.hypot(nose.x - oB.x, nose.y - oB.y);
if (Math.min(dF, dB) < threshold + Math.abs(ds)) {
ds = 0;
flashStatus('🚫 Lok-Front blockiert — Waggons koppeln nur am hinteren Ende.');
break;
}
}
}
if (ds !== 0) active.head = sgTraverse(active.head, ds);
// Rueckwaerts: Tail-Kupplung pruefen
if (v < 0) {
const tail = consistBackEdge(active);
for (const o of SG.consists) {
if (o.isActive) continue;
const oF = consistFrontEdge(o);
const oB = consistBackEdge(o);
const dF = Math.hypot(tail.x - oF.x, tail.y - oF.y);
const dB = Math.hypot(tail.x - oB.x, tail.y - oB.y);
if (dF < threshold + Math.abs(ds) || dB < threshold + Math.abs(ds)) {
sgCoupleTail(active, o, dF < dB ? 'front' : 'back');
flashStatus('🔗 Angekuppelt');
break;
}
}
}
sgCheckGoal();
}
function sgCoupleTail(active, other, otherEnd) {
const append = otherEnd === 'front' ? other.items.slice() : other.items.slice().reverse();
active.items = active.items.concat(append);
SG.consists = SG.consists.filter(c => c.id !== other.id);
}
function sgCheckGoal() {
if (SG.consists.length !== 1) return;
const a = activeConsist();
if (!a || a.items.length !== 5) return;
if (a.items[0].type !== 'lok') return;
const ids = a.items.slice(1).map(i => i.wagon);
if (ids.join(',') !== SG.TARGET.join(',')) return;
if (a.head.segId !== 'main_R' || a.head.dir !== +1) return;
const front = consistFrontEdge(a);
if (front.x < 1040) return;
sgMarkSolved();
}
function sgMarkSolved() {
SG.solved = true;
const elapsed = ((performance.now() - SG.startTime) / 1000).toFixed(1);
const stars = (SG.rangierZuege <= 8) ? 3 : (SG.rangierZuege <= 14) ? 2 : 1;
const resEl = document.getElementById('shuntingResult');
resEl.hidden = false;
resEl.className = 'lg-minigame-result success';
resEl.innerHTML = '🎉 Geschafft! ' + SG.rangierZuege + ' Rangiervorgänge · '
+ SG.weichenstellungen + ' Weichenstellungen · ' + elapsed + ' s — '
+ '★'.repeat(stars) + '☆'.repeat(3 - stars);
try {
LogistikEngine.applyMinigameResult && LogistikEngine.applyMinigameResult(game, {
type: 'shunting', stars, rangierZuege: SG.rangierZuege, weichenstellungen: SG.weichenstellungen, sec: parseFloat(elapsed),
});
} catch (e) { /* ignoriert wenn engine den Typ noch nicht kennt */ }
}
/* ---- Decouple ---- */
function sgDecouple(itemIndex) {
const a = activeConsist();
if (!a || itemIndex < 0 || itemIndex >= a.items.length - 1) return;
if (SG.vel !== 0) { flashStatus('⚠ Erst Zug anhalten zum Entkuppeln.'); return; }
const front = a.items.slice(0, itemIndex + 1);
const back = a.items.slice(itemIndex + 1);
const lokInFront = front.some(i => i.type === 'lok');
// Position des "back"-Teils: dessen Item-0 = old item[itemIndex+1]
const offs = itemCenterOffsets(a.items);
const backHeadOff = offs[itemIndex + 1];
const backHead = sgWalkBack(a.head, backHeadOff);
if (lokInFront) {
// Front bleibt aktiv, back wird geparkt
a.items = front;
SG.consists.push({
id: SG.nextConsistId++,
items: back,
head: backHead,
isActive: false,
});
} else {
// back enthaelt die Lok → back wird aktiv
SG.consists.push({
id: SG.nextConsistId++,
items: front,
head: { segId: a.head.segId, s: a.head.s, dir: a.head.dir },
isActive: false,
});
a.items = back;
a.head = backHead;
}
flashStatus('✂ Entkuppelt');
}
/* ---- Weichen ---- */
/** Welche Segmente betroffen sind, wenn Weiche `name` umgeschaltet wird.
* Ein Wechsel ist blockiert, wenn ein Wagen oder die Lok auf einem
* dieser Segmente steht oder zu nah an der Weiche selbst. */
const SG_WEICHE_SEG = {
W1: { branch: 'branch_t_1', mains: ['main_L', 'main_M1'] },
W2: { branch: 'branch_t_2', mains: ['main_M1', 'main_M2'] },
W3: { branch: 'branch_t_3', mains: ['main_M2', 'main_R' ] },
W4: { branch: 'branch_b_1', mains: ['samm_L', 'samm_M1'] },
W5: { branch: 'branch_b_2', mains: ['samm_M1', 'samm_M2'] },
W6: { branch: 'branch_b_3', mains: ['samm_M2', 'samm_R' ] },
};
function sgWeicheBlockiert(name) {
const cfg = SG_WEICHE_SEG[name];
const branch = cfg.branch;
const [before, after] = cfg.mains;
for (const c of SG.consists) {
const offs = itemCenterOffsets(c.items);
for (let i = 0; i < c.items.length; i++) {
const half = itemLen(c.items[i]) / 2;
const p = sgWalkBack(c.head, offs[i]);
if (p.segId === branch) return true;
if (p.segId === before && p.s > SG.seg[before].len - half - 4) return true;
if (p.segId === after && p.s < half + 4) return true;
}
}
return false;
}
function sgToggleWeiche(name) {
if (sgWeicheBlockiert(name)) {
flashStatus('⚠ Weiche ' + name + ' blockiert (Zug steht drauf).');
return;
}
SG.weichen[name] = SG.weichen[name] === 'gerade' ? 'abbiegen' : 'gerade';
SG.weichenstellungen++;
document.getElementById('sgWeichenCount').textContent = String(SG.weichenstellungen);
flashStatus('Weiche ' + name + ': ' + SG.weichen[name]);
}
function flashStatus(msg) {
const el = document.getElementById('sgStatus');
el.textContent = msg;
clearTimeout(flashStatus._t);
flashStatus._t = setTimeout(() => { el.textContent = ''; }, 2200);
}
/* ---- Rendering ---- */
function sgDraw() {
const ctx = SG.ctx;
if (!ctx) return;
ctx.fillStyle = '#f5f3ea';
ctx.fillRect(0, 0, SG.canvas.width, SG.canvas.height);
// Alle Segmente in Vollfarbe (Weichenstand wird nur ueber die Marker angezeigt)
Object.keys(SG.seg).forEach(id => sgDrawTrackSeg(ctx, SG.seg[id], true));
// Ziel-Markierung am rechten Ende von main_R
const goal = SG.seg.main_R;
ctx.strokeStyle = '#1f6b3a';
ctx.lineWidth = 3;
ctx.setLineDash([5, 4]);
ctx.beginPath();
ctx.moveTo(goal.e1[0], goal.e1[1] - 22);
ctx.lineTo(goal.e1[0], goal.e1[1] + 22);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#1f6b3a';
ctx.font = 'bold 11px system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.fillText('Ziel: Lok mit W·M·H·P sortiert', goal.e1[0] - 4, goal.e1[1] + 38);
// Gleisschilder
ctx.fillStyle = '#666';
ctx.font = 'bold 10px system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Hauptgleis', 50, 66);
ctx.fillText('Sammelgleis', 50, 340);
['Gleis 1', 'Gleis 2', 'Gleis 3'].forEach((name, i) => {
const s = SG.seg['gleis_' + (i + 1)];
ctx.fillText(name, s.e0[0] + 4, 222);
});
// Weichen-Marker
['W1','W2','W3','W4','W5','W6'].forEach(name => {
const pos = SG.weichenPos[name];
const sw = SG.weichen[name];
ctx.fillStyle = sw === 'gerade' ? '#1f6b3a' : '#b88419';
ctx.strokeStyle = '#111';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(pos[0], pos[1], 11, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = '#fff';
ctx.font = 'bold 13px system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(sw === 'gerade' ? '→' : '↘', pos[0], pos[1]);
ctx.fillStyle = '#333';
ctx.font = 'bold 10px system-ui, sans-serif';
ctx.fillText(name, pos[0], pos[1] - 22);
});
// Consists zeichnen — erst geparkte, dann active obenauf
SG.consists.filter(c => !c.isActive).forEach(sgDrawConsist);
const a = activeConsist();
if (a) sgDrawConsist(a);
// Decouple-Schere-Icons (nur active, nur wenn steht und mehr als 1 Item)
if (a && SG.vel === 0 && a.items.length > 1) {
sgDrawScissors(a);
}
}
function sgDrawConsist(c) {
const ctx = SG.ctx;
const offs = itemCenterOffsets(c.items);
// Hintere zuerst zeichnen → Lok obenauf
for (let i = c.items.length - 1; i >= 0; i--) {
const p = sgWalkBack(c.head, offs[i]);
const pos = sgSegPos(p.segId, p.s, p.dir);
const it = c.items[i];
if (it.type === 'lok') sgDrawLokTop(ctx, pos.x, pos.y, pos.angle);
else sgDrawWagonTop(ctx, pos.x, pos.y, pos.angle, SG.WAGON_BY_ID[it.wagon]);
}
}
function sgDrawScissors(c) {
const ctx = SG.ctx;
const offs = itemCenterOffsets(c.items);
SG._scissors = [];
for (let i = 0; i < c.items.length - 1; i++) {
const aHalf = itemLen(c.items[i]) / 2;
const offMid = offs[i] + aHalf + SG.coupling/2;
const p = sgWalkBack(c.head, offMid);
const pos = sgSegPos(p.segId, p.s, p.dir);
// Scheren-Icon SENKRECHT vom Gleis offsetten (oberhalb Hauptgleis,
// bzw. seitlich auf der schraegen Branch — perpendicular).
const seg = SG.seg[p.segId];
const dx = seg.e1[0] - seg.e0[0], dy = seg.e1[1] - seg.e0[1];
const L = Math.hypot(dx, dy);
const px = dy / L, py = -dx / L; // perpendicular: zeigt fuer y=const-Segmente nach oben
const off = 22;
const cx = pos.x + px * off, cy = pos.y + py * off;
// Kleines Verbindungsstrich
ctx.strokeStyle = '#b32d2d';
ctx.lineWidth = 1;
ctx.setLineDash([2, 2]);
ctx.beginPath();
ctx.moveTo(pos.x, pos.y); ctx.lineTo(cx, cy);
ctx.stroke();
ctx.setLineDash([]);
// Scherenkreis
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#b32d2d';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(cx, cy, 9, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = '#b32d2d';
ctx.font = 'bold 12px system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('✂', cx, cy + 1);
SG._scissors.push({ index: i, x: cx, y: cy });
}
}
function sgDrawTrackSeg(ctx, seg, active) {
const dx = seg.e1[0] - seg.e0[0], dy = seg.e1[1] - seg.e0[1];
const L = Math.hypot(dx, dy);
const nx = -dy / L, ny = dx / L;
const halfG = 10;
ctx.strokeStyle = active ? '#8a7f63' : '#dcd5bf';
ctx.lineWidth = 2;
const tieCount = Math.max(6, Math.floor(L / 16));
for (let i = 0; i < tieCount; i++) {
const t = (i + 0.5) / tieCount;
const cx = seg.e0[0] + dx * t, cy = seg.e0[1] + dy * t;
ctx.beginPath();
ctx.moveTo(cx + nx * (halfG + 3), cy + ny * (halfG + 3));
ctx.lineTo(cx - nx * (halfG + 3), cy - ny * (halfG + 3));
ctx.stroke();
}
ctx.strokeStyle = active ? '#333' : '#bdb8a4';
ctx.lineWidth = active ? 2.5 : 1.5;
for (const sgn of [1, -1]) {
ctx.beginPath();
ctx.moveTo(seg.e0[0] + nx * halfG * sgn, seg.e0[1] + ny * halfG * sgn);
ctx.lineTo(seg.e1[0] + nx * halfG * sgn, seg.e1[1] + ny * halfG * sgn);
ctx.stroke();
}
}
function sgDrawWagonTop(ctx, cx, cy, angle, wagon) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
const w = SG.wagonW, h = SG.wagonH;
ctx.fillStyle = wagon.color;
ctx.strokeStyle = '#222';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.roundRect ? ctx.roundRect(-w/2, -h/2, w, h, 3) : ctx.rect(-w/2, -h/2, w, h);
ctx.fill(); ctx.stroke();
ctx.fillStyle = 'rgba(0,0,0,0.22)';
ctx.fillRect(-w/2 + 4, -h/2 + 3, w - 8, h - 6);
ctx.fillStyle = '#fff';
ctx.font = 'bold 11px system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(wagon.emoji + ' ' + wagon.id, 0, 0);
ctx.fillStyle = '#111';
ctx.fillRect(w/2, -2, 3, 4);
ctx.fillRect(-w/2 - 3, -2, 3, 4);
ctx.restore();
}
function sgDrawLokTop(ctx, cx, cy, angle) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
const w = SG.locW, h = SG.locH;
ctx.fillStyle = '#2d3d4e';
ctx.strokeStyle = '#111';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.roundRect ? ctx.roundRect(-w/2, -h/2, w, h, 4) : ctx.rect(-w/2, -h/2, w, h);
ctx.fill(); ctx.stroke();
ctx.fillStyle = '#1a2430';
ctx.fillRect(-w/2 + 4, -h/2 + 3, w - 8, h - 6);
ctx.fillStyle = '#b8d9ea';
for (let i = 0; i < 3; i++) {
ctx.fillRect(-w/2 + 6 + i*7, -h/2 + 4, 5, 4);
ctx.fillRect(-w/2 + 6 + i*7, h/2 - 8, 5, 4);
}
ctx.fillStyle = '#ffe88a';
ctx.beginPath();
ctx.arc(w/2 - 4, -h/4, 2.5, 0, Math.PI * 2);
ctx.arc(w/2 - 4, h/4, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000';
ctx.fillRect(w/2 - 1, -h/2 + 2, 2, h - 4);
ctx.fillStyle = '#fff';
ctx.font = 'bold 10px system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('LOK', 8, 0);
ctx.restore();
}
/* ---- Loop + Eingabe ---- */
function sgLoop(t) {
const now = t || performance.now();
const dt = SG.lastT ? Math.min(0.05, (now - SG.lastT) / 1000) : 0;
SG.lastT = now;
sgStep(dt);
sgDraw();
if (document.getElementById('shuntingOverlay').classList.contains('open')) {
SG.raf = requestAnimationFrame(sgLoop);
}
}
function sgKeyDown(ev) {
if (ev.key === 'ArrowLeft') { SG.keys.left = true; ev.preventDefault(); }
if (ev.key === 'ArrowRight') { SG.keys.right = true; ev.preventDefault(); }
if (ev.key === '1') { sgToggleWeiche('W1'); ev.preventDefault(); }
if (ev.key === '2') { sgToggleWeiche('W2'); ev.preventDefault(); }
if (ev.key === '3') { sgToggleWeiche('W3'); ev.preventDefault(); }
if (ev.key === '4') { sgToggleWeiche('W4'); ev.preventDefault(); }
if (ev.key === '5') { sgToggleWeiche('W5'); ev.preventDefault(); }
if (ev.key === '6') { sgToggleWeiche('W6'); ev.preventDefault(); }
}
function sgKeyUp(ev) {
if (ev.key === 'ArrowLeft') SG.keys.left = false;
if (ev.key === 'ArrowRight') SG.keys.right = false;
}
function sgCanvasClick(ev) {
const rect = SG.canvas.getBoundingClientRect();
const cx = (ev.clientX - rect.left) * SG.canvas.width / rect.width;
const cy = (ev.clientY - rect.top) * SG.canvas.height / rect.height;
// Weichen
for (const name of ['W1','W2','W3','W4','W5','W6']) {
const p = SG.weichenPos[name];
if (Math.hypot(cx - p[0], cy - p[1]) < 18) { sgToggleWeiche(name); return; }
}
// Schere
for (const sc of (SG._scissors || [])) {
if (Math.hypot(cx - sc.x, cy - sc.y) < 14) { sgDecouple(sc.index); return; }
}
}
btnShunting.addEventListener('click', () => {
document.getElementById('shuntingOverlay').classList.add('open');
SG.canvas = document.getElementById('shuntingCanvas');
SG.ctx = SG.canvas.getContext('2d');
if (!SG.canvas._sgBound) {
SG.canvas.addEventListener('click', sgCanvasClick);
SG.canvas._sgBound = true;
}
sgInit();
window.addEventListener('keydown', sgKeyDown);
window.addEventListener('keyup', sgKeyUp);
cancelAnimationFrame(SG.raf);
SG.raf = requestAnimationFrame(sgLoop);
});
document.getElementById('sgClose').addEventListener('click', () => {
document.getElementById('shuntingOverlay').classList.remove('open');
cancelAnimationFrame(SG.raf); SG.raf = null;
window.removeEventListener('keydown', sgKeyDown);
window.removeEventListener('keyup', sgKeyUp);
SG.keys.left = false; SG.keys.right = false;
});
document.getElementById('sgReset').addEventListener('click', () => { sgInit(); });
/* ---------- Analytics-Panel + End-Screen (Phase 7) ---------- */
function computeAnalytics() {
const a = game.analytics || {};
const completed = game.completedContracts || [];
const startBudget = (game._config && game._config.startBudget) || 0;
const pct = (num, den) => (den ? (num / den * 100).toFixed(1) + ' %' : '—');
return {
completed: completed.length,
active: (game.activeContracts || []).length,
totalKm: Math.round(a.totalKm || 0),
emptyKm: Math.round(a.emptyKm || 0),
emptyRatio: pct(a.emptyKm || 0, a.totalKm || 0),
lateRatio: pct(a.lateDeliveries || 0, completed.length),
avgNet: completed.length
? Math.round(completed.reduce((s, c) => s + (c.finalBalance || 0), 0) / completed.length)
: 0,
totalRewards: Math.round(a.totalRewardEarned || 0),
totalPenalty: Math.round(a.totalPenaltyPaid || 0),
totalFares: Math.round(a.totalFareCostPaid || 0),
runningCosts: Math.round(a.runningCostsTotal || 0),
profit: game.balance - startBudget,
minigameRate: a.minigamesPlayed
? Math.round(((a.minigamesSuccessful || 0) / a.minigamesPlayed) * 100) + ' %'
: '—',
};
}
function renderAnalyticsPanel() {
const a = computeAnalytics();
const rows = [
['Aufträge erledigt', a.completed],
['Aufträge offen', a.active],
['Gefahren gesamt', a.totalKm + ' km'],
['davon Leerfahrt', a.emptyKm + ' km · ' + a.emptyRatio],
['Verspätungsquote', a.lateRatio],
['Ø Netto/Auftrag', fmtEuro(a.avgNet)],
['Erlöse gesamt', fmtEuro(a.totalRewards)],
['Fahrkosten', fmtEuro(a.totalFares)],
['Strafen', fmtEuro(a.totalPenalty)],
['Standkosten+Miete', fmtEuro(a.runningCosts)],
['Mini-Übung Erfolg', a.minigameRate],
['Bilanz (Profit)', fmtEuroSigned(a.profit)],
];
document.getElementById('analyticsDl').innerHTML =
rows.map(([k, v]) => '<dt>' + escapeHtml(k) + '</dt><dd>' + escapeHtml(String(v)) + '</dd>').join('');
}
const panelEl = document.getElementById('analyticsPanel');
document.getElementById('btnAnalytics').addEventListener('click', () => {
panelEl.hidden = !panelEl.hidden;
if (!panelEl.hidden) renderAnalyticsPanel();
});
/* End-Screen bei LEVEL_SUCCESS / LEVEL_FAILED */
const esOverlay = document.getElementById('endscreenOverlay');
const esModal = document.getElementById('endscreenModal');
function showEndScreen(finalState) {
const success = finalState === LogistikEngine.STATE.LEVEL_SUCCESS;
esModal.className = 'lg-endscreen-modal ' + (success ? 'success' : 'fail');
document.getElementById('endscreenTitle').textContent = success
? pickText({ standard: '✅ Durchgang erfolgreich', easy: 'Geschafft!' })
: pickText({ standard: '⚠ Durchgang beendet — Ziel verfehlt', easy: 'Leider nicht geschafft.' });
const cfg = game._config || {};
const a = computeAnalytics();
const target = Math.round(cfg.minTargetEarnings || 0).toLocaleString('de-DE');
document.getElementById('endscreenText').textContent = pickText({
standard: success
? 'Du hast das Min-Ziel von ' + target + ' € überschritten und den Durchgang positiv abgeschlossen.'
: 'Das Min-Ziel von ' + target + ' € wurde nicht erreicht. Versuche es nochmal!',
easy: success
? 'Super! Du hast das Ziel von ' + target + ' Euro erreicht.'
: 'Das Ziel hast du noch nicht erreicht. Probier es nochmal!',
});
const stats = [
['Aufträge erledigt', a.completed],
['Verspätungsquote', a.lateRatio],
['Leerfahrten-Anteil', a.emptyRatio],
['Ø Netto/Auftrag', fmtEuro(a.avgNet)],
['Endbilanz', fmtEuroSigned(a.profit)],
];
document.getElementById('endscreenStats').innerHTML =
stats.map(([k, v]) => '<dt>' + escapeHtml(k) + '</dt><dd>' + escapeHtml(String(v)) + '</dd>').join('');
esOverlay.classList.add('open');
}
document.getElementById('esClose').addEventListener('click', () => {
esOverlay.classList.remove('open');
});
document.getElementById('esRestart').addEventListener('click', () => {
location.reload();
});
LogistikEngine.onStateChange(game, (evt) => {
if (evt.to === LogistikEngine.STATE.LEVEL_SUCCESS || evt.to === LogistikEngine.STATE.LEVEL_FAILED) {
showEndScreen(evt.to);
// Server-Durchgang abschliessen (best-effort) + letzte Analytics-Eintraege leeren
const a = computeAnalytics();
LG_API.endAttempt(evt.to === LogistikEngine.STATE.LEVEL_SUCCESS, {
completed: a.completed,
lateRatio: a.lateRatio,
emptyRatio: a.emptyRatio,
avgNet: a.avgNet,
profit: a.profit,
levelNum: levelNum,
});
drainAnalyticsQueue();
}
});
// Für Browser-Konsole/Tests sichtbar
window.__logistikGame = game;
})();
/* =================================================================
Music-Player (Standard aus Design-System) — Pattern wie Klima/Fluss
Modul ruft einmalig ggsMusicSetup([{key, label, file}, ...]) auf.
Atlas liefert die finalen MP3-Dateien; bis dahin Platzhalter-Pfade.
================================================================= */
window.ggsMusicSetup = function(tracks) {
var el = document.getElementById('ggs-music');
var sel = document.getElementById('ggs-music-sel');
var btn = document.getElementById('ggs-music-toggle');
var vol = document.getElementById('ggs-music-vol');
if (!el || !tracks || !tracks.length) return;
el.hidden = false;
var audio = new Audio();
audio.loop = true;
var lsKey = 'ggs-music-logistik';
var state = {};
try { state = JSON.parse(localStorage.getItem(lsKey) || '{}'); } catch (e) {}
sel.innerHTML = tracks.map(function(t) {
return '<option value="' + t.key + '" data-file="' + t.file + '">' + t.label + '</option>';
}).join('');
if (state.track) sel.value = state.track;
audio.src = sel.options[sel.selectedIndex].dataset.file;
var defaultVol = 22;
audio.volume = (state.volume != null ? state.volume : defaultVol) / 100;
vol.value = state.volume != null ? state.volume : defaultVol;
function save() {
try {
localStorage.setItem(lsKey, JSON.stringify({
track: sel.value, volume: +vol.value, playing: !audio.paused,
}));
} catch (e) {}
}
sel.addEventListener('change', function() {
var wasPlaying = !audio.paused;
audio.src = sel.options[sel.selectedIndex].dataset.file;
if (wasPlaying) audio.play().catch(function(){});
save();
});
btn.addEventListener('click', function() {
if (audio.paused) {
audio.play().then(function(){ btn.textContent = '⏸'; btn.classList.add('playing'); save(); })
.catch(function(){});
} else {
audio.pause(); btn.textContent = '▶'; btn.classList.remove('playing'); save();
}
});
vol.addEventListener('input', function() {
audio.volume = vol.value / 100;
save();
});
};
(function () {
// Music-Tracks — von Thomas via .humanInput bereitgestellt
// (Country-Instrumentals, didaktisch passend trotz Genre-Tag)
var BASE = window.LOGISTIK_BASE || '';
ggsMusicSetup([
{ key: 'metronome', label: '⚙ Metronom', file: BASE + 'sounds/music/saddlewood-metronome.mp3' },
{ key: 'gears', label: '🔩 Gears', file: BASE + 'sounds/music/gears-and-marbles.mp3' },
{ key: 'lullaby', label: '🌙 Lullaby', file: BASE + 'sounds/music/barbed-lullaby.mp3' },
{ key: 'instrumental', label: '🎼 Instrumental', file: BASE + 'sounds/music/untitled-instrumental.mp3' }
]);
})();</script>
</body>
</html>