fc8996f064
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>
3216 lines
142 KiB
HTML
3216 lines
142 KiB
HTML
<!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">0°</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 20–40 % 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 10–30 % 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 13–15-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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
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>
|