Files
geograsim/App/sims/logistik/game.html
T
Adminator 88e3c09e5c Per-Sim leistung_pct: alle 14 Sims senden das eine Leistungs-% (0-100)
Jede Sim liefert jetzt selbst leistung_pct in GGS_LIVE_STATE UND im Submit
(Fundament fuer das einheitliche %-System) — kein Rueckrechnen aus Sternen
mehr, keine 0-3-Sonderfaelle. Formeln je Sim wie vereinbart:
- klima/fluss/kofferdetektiv/heli: = vorhandener 0-100-score
- weltkueche: richtige/(richtige+falsche)*100 (Fehler in %)
- busfahrt/fluggesellschaft: topoPunkte/(Auftraege*3)*100
- farmer: gewichteter totalScore; energiemanager: ausgegl./gesamt -15/Blackout
- eu-werkstatt: firstTry/Schritte -5/Muelleimer; sonnensystem: Erstversuch-Quote
- tourismustal: Ziele+Zufriedenheit+Natur gewichtet; tourismusregion: gewichteter score
- logistik: Kontostand->% (sendet erstmals eine Kennzahl, auch an /api/assessment)
Teacher _leistungPct + live.php extractScore bevorzugen bereits diesen Wert.
Additive Aenderungen, bestehende Logik/Sterne unveraendert; alle 15 Dateien
syntaktisch geprueft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 12:35:56 +02:00

5782 lines
268 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Logistik Europa — Simulation</title>
<link rel="icon" type="image/svg+xml" href="../../favicon.svg">
<link rel="icon" type="image/png" href="../../favicon-96x96.png" sizes="96x96">
<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 auto; height: 100dvh; }
.lg-main { display: grid; grid-template-columns: 240px 220px 1fr; 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-status-item { cursor: help; padding: 2px 4px; border-radius: 4px;
transition: background 0.15s; }
.lg-status-item:hover { background: var(--ggs-bg-dark, #f5f3ea); }
/* Blink-Highlight (Phase 8f++): Buttons pulsieren waehrend Voice-Hint */
.lg-blink { animation: lg-btn-blink 0.7s ease-in-out infinite alternate;
box-shadow: 0 0 0 2px rgba(216,153,0,0); }
@keyframes lg-btn-blink {
0% { background: var(--ggs-bg-dark, #f0ece3);
box-shadow: 0 0 0 2px rgba(216,153,0,0); transform: scale(1); }
100% { background: #ffd766;
box-shadow: 0 0 0 4px rgba(216,153,0,.45); transform: scale(1.08); }
}
.lg-status-realtime { font-size: 0.78rem; color: #6a6a6a; font-variant-numeric: tabular-nums;
padding: 2px 8px; background: #f5f3ea; border-radius: 10px; border: 1px solid #d6d1c4; }
.lg-status-explain { position: fixed; background: #fff; border: 1px solid #c8c4b3;
border-radius: 8px; padding: 12px 16px; box-shadow: 0 8px 24px rgba(0,0,0,.18);
max-width: 320px; font-size: 0.85rem; z-index: 4000; line-height: 1.4; }
.lg-status-explain h4 { margin: 0 0 6px; font-size: 0.95rem; color: #1f4b37; }
.lg-status-explain p { margin: 0 0 4px; color: #444; }
.lg-status-explain .lg-formula { font-family: monospace; background: #f5f3ea;
padding: 2px 6px; border-radius: 3px; display: inline-block; margin-top: 4px; }
.lg-trip-history { display: flex; gap: 6px; align-items: center; padding: 6px 12px; background: #f5f3ea;
border-top: 1px solid var(--ggs-border); overflow-x: auto; white-space: nowrap;
font-size: 0.82rem; min-height: 36px; }
.lg-trip-history .lg-th-label { color: #666; font-weight: 600; margin-right: 4px; flex-shrink: 0; }
.lg-trip-pill { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 12px;
font-variant-numeric: tabular-nums; cursor: pointer; border: 1px solid transparent;
font-weight: 600; font-size: 0.78rem; flex-shrink: 0; }
.lg-trip-pill.positive { background: #d6e8d6; color: #2d6d3d; border-color: #8cbf9b; }
.lg-trip-pill.negative { background: #f3e3e3; color: #b32d2d; border-color: #d8a0a0; }
.lg-trip-pill.rent { background: #fff3d6; color: #7a5a1a; border-color: #d6b94e;
font-style: italic; cursor: default; opacity: 0.92; }
.lg-trip-pill:hover { box-shadow: 0 2px 6px rgba(0,0,0,.15); }
.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 200px 1fr; }
}
@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; }
/* Frist-Balken in Kontrakt-Cards (Phase 8f++) — Restzeit visuell:
graue Strecke mit gelber/roter Gefahrenzone am Ende, grüner Fill von links */
.lg-deadline { margin-top: 5px; }
.lg-deadline-bar { position: relative; height: 8px;
background: linear-gradient(to right,
#e8e4d8 0%, #e8e4d8 70%,
#fbe6c2 70%, #fbe6c2 88%,
#f3d0d0 88%, #f3d0d0 100%);
border-radius: 4px; overflow: hidden; }
.lg-deadline-fill { position: absolute; top: 0; right: 0; height: 100%;
background: #2d6d4d; transition: width 0.6s linear, background-color 0.3s; }
.lg-deadline-fill.warn { background: #c88a19; }
.lg-deadline-fill.danger { background: #b32d2d; }
.lg-deadline-label { font-size: 0.7rem; color: #555; margin-top: 2px;
font-variant-numeric: tabular-nums; display: flex; justify-content: space-between; }
.lg-deadline-label .lg-dl-warn { color: #b32d2d; font-weight: 700; }
.lg-trip-remaining { font-size: 0.7rem; color: #6a5e3e; margin-top: 2px; font-variant-numeric: tabular-nums; }
.lg-trip-penalty { font-size: 0.72rem; margin-top: 4px; padding: 2px 6px; border-radius: 3px;
background: #f3d0d0; color: #b32d2d; font-weight: 600; }
.lg-trip-penalty.warn { background: #fbe6c2; color: #7a3d00; }
/* 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) */
/* CTA-Button-Variante (Phase 8f++) — fuer prominente Splash/End-Aktionen.
Laenger, runder, mit klarem Focus-Ring. Ueberschreibt ggs-btn-primary
mit mehr Gewicht ohne das Design-System zu aendern. */
.lg-cta-btn { padding: 12px 28px !important; font-size: 15px !important;
border-radius: 12px !important; letter-spacing: 0.02em;
box-shadow: 0 4px 14px rgba(47,109,77,.28);
transition: transform .15s ease, box-shadow .15s ease; }
.lg-cta-btn:hover { transform: translateY(-2px) !important;
box-shadow: 0 6px 18px rgba(47,109,77,.4) !important; }
.lg-cta-btn:active { transform: translateY(0) !important; }
.lg-cta-btn.secondary { color: var(--ggs-fjord); background: #fff !important;
border: 1.5px solid var(--ggs-fjord); box-shadow: 0 2px 6px rgba(47,109,77,.15); }
/* Level-Splash / Intro-Screen (Phase 8f++) */
.lg-splash-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.78);
display: none; align-items: center; justify-content: center; z-index: 3000; }
.lg-splash-overlay.open { display: flex; }
.lg-splash-modal { background: #fff; border-radius: 14px; max-width: 720px;
width: 94%; overflow: hidden; box-shadow: 0 16px 48px rgba(0,0,0,.55);
max-height: 95dvh; display: flex; flex-direction: column; }
.lg-splash-img { width: 100%; display: block; max-height: 300px; object-fit: cover;
flex-shrink: 0; }
.lg-splash-body { padding: 20px 28px 24px; overflow-y: auto; }
.lg-splash-body h2 { margin: 0 0 4px; color: #1f4b37; font-size: 1.45rem; }
.lg-splash-subtitle { color: #666; font-size: 0.9rem; margin-bottom: 14px; }
.lg-splash-sections { display: flex; flex-direction: column; gap: 12px; margin-bottom: 18px; }
.lg-splash-section { display: flex; gap: 12px; align-items: flex-start; }
.lg-splash-section-icon { font-size: 1.6rem; line-height: 1.2; flex-shrink: 0;
width: 32px; text-align: center; }
.lg-splash-section-body h3 { margin: 0 0 3px; font-size: 0.98rem; color: #1f4b37;
font-weight: 700; }
.lg-splash-section-body p { margin: 0; font-size: 0.9rem; color: #444; line-height: 1.45; }
.lg-splash-actions { text-align: right; margin-top: 6px; }
.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; }
/* Sterne + Leerfahrten-Display (Phase 8f++) */
.lg-endscreen-stars { text-align: center; margin: 10px 0 6px; }
.lg-stars { font-size: 2.2rem; letter-spacing: 0.1em; line-height: 1; }
.lg-stars .lg-star-on { color: #e8c547; text-shadow: 0 1px 2px rgba(0,0,0,.15); }
.lg-stars .lg-star-off { color: #d6d1c4; }
.lg-stars-label { font-size: 0.95rem; color: #555; margin-top: 4px; font-weight: 600; }
.lg-endscreen-empty { margin: 10px 0; padding: 10px 14px; background: #f7f5ef; border-radius: 6px; }
.lg-empty-label { font-size: 0.88rem; color: #555; display: flex; justify-content: space-between; }
.lg-empty-label span { font-weight: 700; color: #1f4b37; font-variant-numeric: tabular-nums; }
.lg-empty-bar { height: 10px; background: #e2ddd1; border-radius: 5px; overflow: hidden; margin-top: 4px; }
.lg-empty-fill { height: 100%; background: linear-gradient(90deg, #2d6d4d 0%, #e8c547 60%, #b32d2d 100%); transition: width .4s; }
.lg-empty-sub { font-size: 0.78rem; color: #666; margin-top: 4px; }
/* 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: 6px 8px; margin-top: 6px; font-size: 0.72rem; color: #5e5320;
line-height: 1.3; }
.lg-loadmode-banner strong { font-size: 0.82rem; }
.lg-loadmode-sub { color: #8a7a4f; font-size: 0.68rem; }
.lg-cargo-hold { margin-top: 6px; padding: 6px 8px; background: #f7f5ef;
border: 1px solid #c0b8a3; border-radius: 5px; font-size: 0.7rem; }
.lg-cargo-hold-title { font-size: 0.66rem; color: #666; margin-bottom: 4px;
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; flex-direction: column; gap: 6px; margin-top: 8px; }
.lg-tour-actions button { width: 100%; min-height: 32px; padding: 6px 14px;
font-size: 0.85rem; border-radius: 16px; }
.lg-stop-badge { display: inline-block; padding: 1px 6px; background: #e8f0eb; border-radius: 3px; font-size: 0.7rem; color: #2d6d3d; margin-left: 4px; }
/* Away-Bonus-Badge (Phase 8d): kennzeichnet Auftraege, die Leerfahrt zum Pickup brauchen */
.lg-away-badge { display: inline-block; margin-left: 4px; padding: 1px 6px; background: #fff4c9; color: #7a5a1a;
border: 1px solid #d6b94e; border-radius: 3px; font-size: 0.68rem; font-weight: 600; vertical-align: middle; }
/* Announce-Modal (Phase 8f): kurze Gratulation/Meldung fuer Meilensteine */
.lg-announce-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.35); display: none;
align-items: center; justify-content: center; z-index: 2500; }
.lg-announce-overlay.open { display: flex; }
.lg-announce-modal { background: #fff; border-radius: 12px; padding: 22px 28px; text-align: center;
max-width: 440px; width: 90%; box-shadow: 0 12px 32px rgba(0,0,0,.3);
animation: lg-announce-in .35s cubic-bezier(.16,1,.3,1) both; }
.lg-announce-icon { font-size: 3rem; margin-bottom: 4px; }
.lg-announce-modal h3 { margin: 6px 0 4px; color: #1f4b37; font-size: 1.15rem; }
.lg-announce-modal p { margin: 4px 0; color: #333; font-size: 0.95rem; }
.lg-announce-sub { color: #666 !important; font-size: 0.82rem !important; font-style: italic; }
@keyframes lg-announce-in {
0% { transform: scale(.6); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
/* Auto-Boost-Badge (Phase 8e++): erscheint wenn Sim automatisch auf 8x ist */
.lg-autoboost-badge { display: inline-flex; align-items: center; margin-left: 6px; padding: 2px 8px;
background: #ffecb3; color: #8a5a1a; border: 1px solid #e0a030; border-radius: 10px;
font-size: 0.72rem; font-weight: 600; animation: lg-autoboost-pulse 1.5s ease-in-out infinite; }
@keyframes lg-autoboost-pulse {
0%, 100% { background: #ffecb3; }
50% { background: #ffd766; }
}
/* Bonus-Pills (Phase 8e++): Ladebonus-Stack oben rechts auf der Karte.
Position wo vorher das Layer-Control-Widget war. */
.lg-bonus-pills { position: absolute; top: 12px; right: 12px; z-index: 480;
display: flex; flex-direction: column; gap: 4px; align-items: flex-end; }
.lg-bonus-pill { background: #ffecb3; color: #8a5a1a; border: 1px solid #e0a030;
border-radius: 12px; padding: 3px 10px; font-size: 0.78rem; font-weight: 600;
box-shadow: 0 2px 4px rgba(0,0,0,.15); cursor: pointer;
font-variant-numeric: tabular-nums; white-space: nowrap;
transition: transform .2s, box-shadow .2s; }
.lg-bonus-pill:hover { transform: scale(1.05); box-shadow: 0 3px 8px rgba(0,0,0,.2); }
.lg-bonus-popover { position: absolute; top: 40px; right: 0; background: #fff;
border: 1px solid #d6b94e; border-radius: 6px; padding: 8px 12px;
font-size: 0.82rem; color: #333; max-width: 240px; z-index: 490;
box-shadow: 0 4px 12px rgba(0,0,0,.2); }
/* Flug-Animation: Pill fliegt zum Fahrzeug-Marker und fadet aus */
.lg-bonus-pill-fly { position: fixed; z-index: 9999; pointer-events: none;
transition: transform .8s cubic-bezier(.4,0,.2,1), opacity .8s, scale .8s; }
/* Autobahn-Game-Overlay (Phase 9) — füllt den Viewport, Modul rendert
komplett innerhalb von #lgAutobahnContainer. */
.lg-autobahn-overlay { position: fixed; inset: 0; background: rgba(20,30,25,0.6);
display: flex; align-items: center; justify-content: center;
z-index: 9000; padding: 16px; }
.lg-autobahn-overlay[hidden] { display: none; }
.lg-autobahn-container { width: 100%; max-width: 1100px; max-height: 92dvh;
background: var(--ggs-bg, #f5f3ea); border-radius: 14px; overflow: hidden;
box-shadow: 0 24px 48px rgba(0,0,0,.4); }
/* Park-Celebration (Phase 8f++): grosses Glueckwunsch-Fenster zentriert,
fliegt nach 1,2 s zur Bonus-Pill oben rechts und verschwindet.
Wrapper macht das Zentrieren, innere Card kann unbeeinflusst ihren
eigenen Transform fuer Pop-in und Flight bekommen. */
.lg-park-celebrate-wrap { position: fixed; inset: 0; display: flex;
align-items: center; justify-content: center; z-index: 10000;
pointer-events: none; }
.lg-park-celebrate { background: #fff; color: #1f4b37; padding: 22px 36px;
border: 2px solid #2d6d4d; border-radius: 14px;
box-shadow: 0 14px 40px rgba(0,0,0,.35);
text-align: center; min-width: 260px; transform: scale(1);
animation: lg-park-pop 0.35s cubic-bezier(.3,1.4,.5,1) both; }
.lg-park-celebrate-icon { font-size: 2.8rem; line-height: 1; margin-bottom: 4px; }
.lg-park-celebrate-title { font-size: 1.25rem; font-weight: 700; color: #2d6d3d; }
.lg-park-celebrate-sub { font-size: 0.92rem; color: #555; margin-top: 4px; }
@keyframes lg-park-pop {
0% { transform: scale(0.4); opacity: 0; }
60% { transform: scale(1.08); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
/* Pflicht-Pause-Banner in der Fahrzeug-Card (Phase 8f++) */
.lg-break-banner { background: #fff3d6; color: #7a5a1a; border: 1px solid #d6b94e;
border-radius: 4px; padding: 4px 8px; font-size: 0.78rem; font-weight: 600;
margin-bottom: 4px; }
/* Verfahren-Banner (Phase 8e+) */
.lg-verfahren-banner { position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
z-index: 500; background: #fbe6c2; color: #7a3d00; border: 2px solid #c88a19; border-radius: 6px;
padding: 10px 16px; font-size: 0.95rem; font-weight: 500;
box-shadow: 0 4px 12px rgba(0,0,0,.2); max-width: 85%; text-align: center; }
/* Uhr-Widget (Phase 8f++): Stunden-/Minutenzeiger + Tag/Nacht-Gradient.
Liegt unten-rechts auf der Karte (oben-links ist Leaflet-Zoom,
unten-links ist Leaflet-Scale). 72 × 72 px, nicht-interaktiv. */
.lg-clock-widget { position: absolute; bottom: 16px; right: 12px; z-index: 450;
width: 72px; height: 72px; pointer-events: none;
filter: drop-shadow(0 2px 4px rgba(0,0,0,.25)); }
.lg-clock-widget .lg-clock-day-label { font-size: 9px; font-weight: 700;
fill: #fff; text-anchor: middle; paint-order: stroke; stroke: #333;
stroke-width: 2px; }
/* Info-Cards (Phase 8f++): pro Zielort bei LOADING-Start, Slide-in-from-right.
Atlas-Pattern (Mail 2026-04-24 22:00): Toast++ mit Bild, nicht-blockierend. */
.lg-infocard-container { position: fixed; top: 180px; right: 16px; z-index: 1200;
display: flex; flex-direction: column; gap: 12px; max-width: 300px;
pointer-events: none; }
.lg-infocard { background: #fff; border-radius: 12px; overflow: hidden;
box-shadow: 0 6px 24px rgba(0,0,0,.18); pointer-events: auto;
animation: lg-slide-in 0.4s cubic-bezier(.2,.8,.2,1) both,
lg-slide-out 0.4s 10s cubic-bezier(.5,0,.8,.3) both; }
.lg-infocard-img { width: 100%; height: 110px; display: flex; align-items: center;
justify-content: center; object-fit: cover;
background: linear-gradient(135deg, #e8e4d8 0%, #d6cfb8 100%);
background-size: cover; background-position: center;
color: #6a5a3a; font-size: 0.95rem; font-weight: 600; text-align: center;
letter-spacing: 0.02em; padding: 0 8px; box-sizing: border-box; }
.lg-infocard-body { padding: 10px 12px; }
.lg-infocard-body h3 { font-size: 0.88rem; font-weight: 700; margin: 0 0 3px;
color: #1f4b37; }
.lg-infocard-body p { font-size: 0.72rem; color: #555; margin: 0; line-height: 1.35; }
.lg-infocard-stats { display: flex; gap: 10px; font-size: 0.68rem; margin-top: 6px;
color: #888; font-variant-numeric: tabular-nums; }
.lg-infocard-stats b { color: #555; font-weight: 600; }
@keyframes lg-slide-in { from { transform: translateX(120%); opacity: 0 }
to { transform: translateX(0); opacity: 1 } }
@keyframes lg-slide-out { to { transform: translateX(120%); opacity: 0 } }
/* Negative-Marge-Hinweis (Phase 8f++): wenn alle OPEN-Aufträge unprofitabel */
.lg-all-negative-hint { margin-bottom: 8px; padding: 8px 10px;
background: #fff3d6; color: #7a5a1a; border: 1px solid #d6b94e;
border-radius: 6px; font-size: 0.82rem; line-height: 1.35;
display: flex; gap: 6px; align-items: flex-start; }
.lg-all-negative-hint .lg-anh-icon { font-size: 1.1rem; flex-shrink: 0; }
/* Card-Dismiss-X (Phase 8f++): Auftrag ohne Annahme verwerfen */
.lg-card { position: relative; }
.lg-card-dismiss { position: absolute; top: 4px; right: 4px;
width: 22px; height: 22px; border-radius: 11px; border: none;
background: rgba(255,255,255,.7); color: #888;
font-size: 14px; line-height: 1; cursor: pointer; padding: 0;
transition: background 0.15s, color 0.15s; }
.lg-card-dismiss:hover { background: #f3d0d0; color: #b32d2d; }
/* Train-Eligible-Badge: Auftrag passt für Güterzug */
.lg-train-badge { display: inline-block; margin-left: 4px; padding: 1px 8px;
border-radius: 3px; font-size: 0.7rem; font-weight: 700;
background: #cfe0f0; color: #1e4f7a; border: 1px solid #5f85b8;
vertical-align: middle; }
/* Bonus-Variant-Badge (Phase 8f++): Zwilling mit +50 % Reward */
.lg-bonus-variant-badge { display: inline-block; margin-left: 4px; padding: 1px 8px;
border-radius: 3px; font-size: 0.7rem; font-weight: 700;
background: #fff3d6; color: #7a5a1a; border: 1px solid #e0c060;
vertical-align: middle; animation: lg-bonus-pulse 2.2s ease-in-out infinite; }
@keyframes lg-bonus-pulse {
0%, 100% { background: #fff3d6; box-shadow: 0 0 0 rgba(224,192,96,0); }
50% { background: #ffe9a8; box-shadow: 0 0 8px rgba(224,192,96,.6); }
}
/* Profile-Badges (Phase 8e simple): tight / far / easy */
.lg-profile-badge { display: inline-block; margin-left: 4px; padding: 1px 6px; border-radius: 3px;
font-size: 0.68rem; font-weight: 600; vertical-align: middle; border: 1px solid; }
.lg-profile-tight { background: #fde1d1; color: #8a3a1a; border-color: #d8844e; }
.lg-profile-far { background: #e0eaf6; color: #1f4b7a; border-color: #5f85b8; }
.lg-profile-easy { background: #e8f0e8; color: #2d6b3d; border-color: #7aa87a; }
/* Phase 8e: OFFERED-Auftrags-Karten mit Accept/Decline */
.lg-card.state-offered { background: #fff9ea; border-left: 4px solid #c88a19; }
.lg-express-badge { display: inline-block; margin-left: 4px; padding: 1px 6px; background: #ffecb3; color: #8a5a1a;
border: 1px solid #e0a030; border-radius: 3px; font-size: 0.68rem; font-weight: 600; vertical-align: middle; }
.lg-multi-badge { display: inline-block; margin-left: 4px; padding: 1px 6px; background: #e0eaf6; color: #1f4b7a;
border: 1px solid #5f85b8; border-radius: 3px; font-size: 0.68rem; font-weight: 600; vertical-align: middle; }
.lg-offer-countdown { font-size: 0.74rem; color: #8a5a1a; margin-top: 4px; font-variant-numeric: tabular-nums; }
.lg-offer-actions { display: flex; gap: 6px; margin-top: 6px; }
.lg-offer-actions button { flex: 1; min-height: 32px; font-size: 0.82rem; border-radius: 4px; border: 0; cursor: pointer;
font-weight: 600; }
.lg-offer-accept { background: #1f6b3a; color: #fff; }
.lg-offer-accept:hover { background: #2d7d48; }
.lg-offer-decline { background: #f5f3ea; color: #7a2d2d; border: 1px solid #d6b0b0; }
.lg-offer-decline:hover { background: #f3e3e3; }
/* Fahrzeugkauf-UI (Phase 8d) */
.lg-buy-vehicle-btn { width: 100%; margin-bottom: 8px; min-height: 36px; }
.lg-buy-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); display: none; align-items: center;
justify-content: center; z-index: 2100; }
.lg-buy-overlay.open { display: flex; }
.lg-buy-modal { background: #fff; border-radius: 8px; padding: 20px 22px; padding-bottom: max(20px, env(safe-area-inset-bottom)); max-width: 560px; width: 94%; max-height: calc(100dvh - 24px); overflow-y: auto; box-shadow: 0 8px 24px rgba(0,0,0,.3); position: relative; }
.lg-buy-modal h3 { margin: 0 0 10px; font-size: 1.05rem; color: var(--ggs-text); }
.lg-buy-modal .lg-buy-hint { font-size: 0.8rem; color: #666; margin: 4px 0 12px; }
.lg-buy-item { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center;
padding: 10px; border: 1px solid #e2ddd1; border-radius: 5px; margin-bottom: 8px; }
.lg-buy-item.disabled { opacity: 0.5; background: #f5f3ea; }
.lg-buy-item-icon { font-size: 1.6rem; }
.lg-buy-item-body b { display: block; font-size: 0.92rem; color: #1f4b37; }
.lg-buy-item-body span { font-size: 0.78rem; color: #555; }
.lg-buy-item-price { font-weight: 700; color: #1f4b37; font-size: 1.0rem; white-space: nowrap; }
.lg-buy-item-price.unaffordable { color: #b32d2d; }
.lg-buy-item button { min-width: 80px; min-height: 34px; }
.lg-buy-actions { display: flex; justify-content: flex-end; margin-top: 8px; }
/* 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: 6px; padding: 6px 8px; background: #eef6f0;
border: 1px solid #8cbf9b; border-radius: 5px; font-size: 0.72rem; }
.lg-tour-preview-row { display: flex; justify-content: space-between;
align-items: baseline; gap: 8px; margin: 1px 0; }
.lg-tour-preview-row.sum { border-top: 1px solid #b6d4c0; margin-top: 4px;
padding-top: 4px; font-weight: 600; }
.lg-tour-preview-row.sum.positive { color: #1f6b3a; }
.lg-tour-preview-row.sum.negative { color: #a83232; }
.lg-tp-num { font-variant-numeric: tabular-nums; white-space: nowrap;
text-align: right; min-width: 70px; }
.lg-tp-num small { color: #777; font-weight: 400; margin-left: 2px; }
.lg-info-btn { display: inline-flex; align-items: center; justify-content: center;
width: 14px; height: 14px; border-radius: 50%; border: 1px solid #888;
background: #fff; color: #555; font-size: 0.62rem; font-weight: 700;
cursor: pointer; padding: 0; line-height: 1; vertical-align: middle;
margin-left: 4px; font-style: italic; }
.lg-info-btn:hover { background: #555; color: #fff; }
/* 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: 14px 18px; padding-bottom: max(14px, env(safe-area-inset-bottom)); max-width: 580px; width: 94%; max-height: 94dvh; overflow-y: auto; box-shadow: 0 8px 24px rgba(0,0,0,.3); position: relative; }
.lg-mg-x { position: absolute; top: 8px; right: 8px; width: 36px; height: 36px; min-width: 36px; min-height: 36px; border-radius: 50%; border: 0; background: rgba(0,0,0,.06); color: #1f4b37; font-size: 1.3rem; line-height: 1; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; z-index: 10; padding: 0; }
.lg-mg-x:hover { background: rgba(0,0,0,.12); }
.lg-mg-x:active { background: rgba(0,0,0,.18); }
.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; }
/* Parken-Minispiel: Landscape-Layout (iPad Landscape 1180×820 + Desktop) */
.lg-mg-park-layout { display: block; }
.lg-mg-park-canvas-wrap { width: 100%; }
.lg-mg-park-side { display: flex; flex-direction: column; gap: 8px; align-items: center; }
@media (min-width: 880px) and (orientation: landscape) {
.lg-minigame-modal.lg-mg-park-modal { max-width: 1100px; max-height: 92dvh; padding: 12px 16px; padding-bottom: max(12px, env(safe-area-inset-bottom)); }
.lg-mg-park-modal .lg-minigame-ref { display: none; } /* Referenz-Bilder im Landscape ausblenden, Platz fuer Canvas */
.lg-mg-park-modal .lg-minigame-hint { font-size: 0.78rem; line-height: 1.35; margin: 4px 0 6px; }
.lg-mg-park-modal .lg-mg-park-layout { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 16px; align-items: start; }
.lg-mg-park-modal canvas#minigameCanvas { aspect-ratio: 16/9; margin: 0; }
.lg-mg-park-modal .lg-mg-park-side { padding-top: 4px; }
}
/* D-Pad (klassisch — Fallback fuer Maus) */
.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; }
/* Virtueller Joystick — Touch-tauglich, ein Finger fuer alle 4 Achsen */
.lg-mg-joystick { position: relative; width: 200px; height: 200px; border-radius: 50%; background: radial-gradient(circle at 50% 50%, #f4eedc 0%, #e2ddc7 65%, #cdc6ac 100%); border: 2px solid #b3a87a; touch-action: none; user-select: none; flex-shrink: 0; box-shadow: inset 0 2px 8px rgba(0,0,0,0.15); }
.lg-mg-joystick::before, .lg-mg-joystick::after { content: ''; position: absolute; background: rgba(31,75,55,0.18); }
.lg-mg-joystick::before { left: 50%; top: 8px; bottom: 8px; width: 2px; transform: translateX(-1px); }
.lg-mg-joystick::after { top: 50%; left: 8px; right: 8px; height: 2px; transform: translateY(-1px); }
.lg-mg-joy-knob { position: absolute; left: 50%; top: 50%; width: 80px; height: 80px; margin-left: -40px; margin-top: -40px; border-radius: 50%; background: linear-gradient(180deg, #2d6d4d 0%, #1f4b37 100%); border: 2px solid #1a3d2c; box-shadow: 0 3px 10px rgba(0,0,0,0.3); transition: transform 0.08s ease-out; pointer-events: none; }
.lg-mg-joy-arrows { position: absolute; inset: 0; pointer-events: none; color: rgba(31,75,55,0.6); font-size: 1.4rem; font-weight: 700; }
.lg-mg-joy-arrows span { position: absolute; }
.lg-mg-joy-arrows .up { top: 6px; left: 50%; transform: translateX(-50%); }
.lg-mg-joy-arrows .down { bottom: 6px; left: 50%; transform: translateX(-50%); }
.lg-mg-joy-arrows .left { left: 8px; top: 50%; transform: translateY(-50%); }
.lg-mg-joy-arrows .right { right: 8px; top: 50%; transform: translateY(-50%); }
/* iPad/Touch: Joystick zeigen, D-Pad ausblenden */
@media (hover: none) and (pointer: coarse) {
.lg-mg-park-modal .lg-minigame-pad { display: none; }
}
/* Desktop/Maus: D-Pad bleibt sichtbar, Joystick als zusaetzliche Option */
@media (hover: hover) and (pointer: fine) {
.lg-mg-park-modal .lg-mg-joystick { display: none; }
}
.lg-minigame-actions { display: flex; gap: 10px; justify-content: center; margin: 10px 0 4px; position: sticky; bottom: 0; background: linear-gradient(180deg, rgba(255,255,255,0) 0%, #fff 30%, #fff 100%); padding: 8px 4px 4px; z-index: 5; }
.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="/schueler" 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-mode="pause" title="Pause — alles steht"></button>
<button class="ggs-speed-btn active" data-mode="auto" title="Auto — Sim passt Tempo selbst an (1× bei Entscheidung, 8× bei reiner Fahrt)"><span id="autoModeLabel">Auto 1×</span></button>
<button class="ggs-speed-btn" data-mode="fixed8" title="Fix 8× — bleibt auch bei stehendem Fahrzeug auf 8× (nicht-Auto)">8×</button>
<button class="ggs-speed-btn" data-mode="turbo" title="Dev-Turbo: 16× — kommt später raus" style="opacity:.75;">🚀 16×</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="btnVoiceToggle" title="Sprachausgabe an/aus">🔊</button>
<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" href="../../schueler" 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>
<aside class="lg-panel" id="vehiclesPanel">
<h3>Fahrzeuge</h3>
<div id="vehiclesList"></div>
</aside>
<section class="lg-map" style="position:relative;">
<div id="map" role="region" aria-label="Europa-Karte mit Logistik-Standorten"></div>
<div id="verfahrenBanner" class="lg-verfahren-banner" hidden></div>
<div id="bonusPills" class="lg-bonus-pills"></div>
<svg class="lg-clock-widget" id="lgClock" viewBox="-50 -50 100 100"
role="img" aria-label="Simulations-Uhr">
<defs>
<radialGradient id="lgClockFace" cx="50%" cy="50%" r="50%">
<stop id="lgClockStop0" offset="0%" stop-color="#ffe9a8"/>
<stop id="lgClockStop1" offset="100%" stop-color="#7ab5e0"/>
</radialGradient>
</defs>
<circle cx="0" cy="0" r="47" fill="url(#lgClockFace)" stroke="#2a2a2a" stroke-width="2.5"/>
<g stroke="#2a2a2a" stroke-width="1.5" stroke-linecap="round">
<line x1="0" y1="-42" x2="0" y2="-46"/>
<line x1="40.5" y1="-23.4" x2="39.8" y2="-25.3" transform="rotate(30)"/>
<line x1="0" y1="-42" x2="0" y2="-46" transform="rotate(60)"/>
<line x1="0" y1="-42" x2="0" y2="-46" transform="rotate(90)"/>
<line x1="0" y1="-42" x2="0" y2="-46" transform="rotate(120)"/>
<line x1="0" y1="-42" x2="0" y2="-46" transform="rotate(150)"/>
<line x1="0" y1="-42" x2="0" y2="-46" transform="rotate(180)"/>
<line x1="0" y1="-42" x2="0" y2="-46" transform="rotate(210)"/>
<line x1="0" y1="-42" x2="0" y2="-46" transform="rotate(240)"/>
<line x1="0" y1="-42" x2="0" y2="-46" transform="rotate(270)"/>
<line x1="0" y1="-42" x2="0" y2="-46" transform="rotate(300)"/>
<line x1="0" y1="-42" x2="0" y2="-46" transform="rotate(330)"/>
</g>
<line id="lgClockHour" x1="0" y1="4" x2="0" y2="-24" stroke="#1a1a1a" stroke-width="3.5" stroke-linecap="round"/>
<line id="lgClockMinute" x1="0" y1="5" x2="0" y2="-36" stroke="#1a1a1a" stroke-width="2" stroke-linecap="round"/>
<circle cx="0" cy="0" r="2.5" fill="#1a1a1a"/>
<text id="lgClockDay" class="lg-clock-day-label" x="0" y="30">Tag 1</text>
</svg>
<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>
</main>
<!-- ============= Status-Leiste ============= -->
<div class="lg-status">
<span class="lg-status-item" data-explain="time">Sim-Zeit: <b id="statusTime"></b></span>
<span class="lg-status-item" data-explain="balance">Konto: <b id="statusBalance"></b></span>
<span class="lg-status-item" data-explain="revenue">Umsatz: <b id="statusRevenue"></b></span>
<span class="lg-status-item" data-explain="profit">Gewinn: <b id="statusProfit"></b></span>
<span class="lg-status-item" id="statusRentWrap" data-explain="rent" hidden>Miete: <b id="statusRent"></b>/Tag</span>
<span class="lg-status-item" data-explain="contracts">Aufträge: <b id="statusContracts">0/0</b></span>
<span style="flex: 1"></span>
<span id="statusRealtime" class="lg-status-realtime" title="Echte Spielzeit dieser Session">⏱ —</span>
<span id="statusState" class="ggs-badge">INIT</span>
</div>
<!-- ============= Announce-Modal (Phase 8f) ============= -->
<div id="announceOverlay" class="lg-announce-overlay">
<div class="lg-announce-modal">
<div class="lg-announce-icon" id="announceIcon">🎉</div>
<h3 id="announceTitle">Titel</h3>
<p id="announceText">Text</p>
<p class="lg-announce-sub" id="announceSub" hidden></p>
</div>
</div>
<!-- ============= Info-Cards (Phase 8f++) ============= -->
<div id="lgInfocards" class="lg-infocard-container" aria-live="polite"></div>
<!-- ============= Autobahn-Game Overlay (Phase 9) ============= -->
<div id="lgAutobahnOverlay" class="lg-autobahn-overlay" hidden>
<div id="lgAutobahnContainer" class="lg-autobahn-container"></div>
</div>
<!-- ============= Fahrten-Historie (Phase 8e) ============= -->
<div class="lg-trip-history" id="tripHistoryStrip" hidden>
<span class="lg-th-label">📜 Historie:</span>
<div id="tripHistoryPills" style="display:flex; gap:6px; flex-wrap:nowrap;"></div>
</div>
<!-- Trip-Detail-Modal -->
<div class="lg-buy-overlay" id="tripDetailOverlay" role="dialog" aria-modal="true" aria-labelledby="tripDetailTitle">
<div class="lg-buy-modal">
<h3 id="tripDetailTitle">Fahrt-Details</h3>
<dl id="tripDetailBody" style="display:grid; grid-template-columns:auto 1fr; gap:4px 16px; font-size:0.88rem;"></dl>
<div class="lg-buy-actions">
<button class="ggs-btn-ghost" id="tripDetailClose">Schließen</button>
</div>
</div>
</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>
<!-- ============= Level-Splash (Phase 8f++) ============= -->
<div class="lg-splash-overlay" id="levelSplashOverlay" role="dialog" aria-modal="true" aria-labelledby="lgSplashTitle">
<div class="lg-splash-modal">
<img class="lg-splash-img" id="lgSplashImg" src="" alt="">
<div class="lg-splash-body">
<h2 id="lgSplashTitle"></h2>
<div class="lg-splash-subtitle" id="lgSplashSubtitle"></div>
<div class="lg-splash-sections" id="lgSplashSections"></div>
<div class="lg-splash-actions">
<button class="ggs-btn-primary lg-cta-btn" id="lgSplashStart">Los geht's →</button>
</div>
</div>
</div>
</div>
<!-- ============= End-Screen (Phase 7 / 8f++) ============= -->
<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-stars" id="endscreenStars" hidden>
<div class="lg-stars" id="lgStars"></div>
<div class="lg-stars-label" id="lgStarsLabel"></div>
</div>
<div class="lg-endscreen-empty" id="endscreenEmpty" hidden>
<div class="lg-empty-label">Leerfahrten-Anteil <span id="lgEmptyPct"></span></div>
<div class="lg-empty-bar"><div class="lg-empty-fill" id="lgEmptyFill"></div></div>
<div class="lg-empty-sub" id="lgEmptySub"></div>
</div>
<div class="lg-endscreen-summary">
<dl id="endscreenStats"></dl>
</div>
<div class="lg-endscreen-actions">
<button class="ggs-btn-secondary lg-cta-btn secondary" id="esClose">Schließen</button>
<button class="ggs-btn-primary lg-cta-btn" 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 lg-mg-park-modal">
<button class="lg-mg-x" id="mgX" aria-label="Mini-Übung schließen" title="Mini-Übung schließen"></button>
<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.
⏱ 3 Minuten · Crash oder Timeout → Kollege übernimmt.
</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>
<div class="lg-mg-park-layout">
<div class="lg-mg-park-canvas-wrap">
<canvas id="minigameCanvas" width="500" height="300"></canvas>
</div>
<div class="lg-mg-park-side">
<div class="lg-minigame-timer-big" id="mgTimerBig">⏱ 3:00</div>
<div class="lg-minigame-meta">
<span>Tempo: <b id="mgSpeedLabel">0</b> km/h</span>
<span>Winkel: <b id="mgAngleLabel"></b></span>
<span id="mgDockStatus" style="font-weight:600;"></span>
</div>
<!-- Virtueller Joystick fuer iPad/Touch (ein Finger, alle 4 Achsen) -->
<div class="lg-mg-joystick" id="mgJoystick" aria-label="Joystick: ziehen zum Steuern">
<div class="lg-mg-joy-arrows">
<span class="up"></span>
<span class="down"></span>
<span class="left"></span>
<span class="right"></span>
</div>
<div class="lg-mg-joy-knob" id="mgJoyKnob"></div>
</div>
<!-- D-Pad (Maus/Tastatur-Fallback) -->
<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>
</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">
<button class="lg-mg-x" id="sgX" aria-label="Mini-Übung schließen" title="Mini-Übung schließen"></button>
<h3 id="shuntingTitle">🚆 Rangierbahnhof — Hauptgleis + Sammelgleis</h3>
<p class="lg-minigame-hint">
<strong>Ziel:</strong> Waggons in Reihenfolge <strong>#1 · #2 · #3 · #4</strong>
sortieren (Lok vorn) und mit der Lok rechts auf dem Hauptgleis ankommen.
<strong>Unter 4 Minuten</strong> = Bonus auf das nächste Be-/Entladen.<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>
<!-- ============= Fahrzeugkauf-Dialog (Phase 8d) ============= -->
<div class="lg-buy-overlay" id="buyVehicleOverlay" role="dialog" aria-modal="true" aria-labelledby="buyVehicleTitle">
<div class="lg-buy-modal">
<h3 id="buyVehicleTitle">🛒 Neues Fahrzeug kaufen</h3>
<p class="lg-buy-hint" id="buyVehicleHint"></p>
<div id="buyVehicleList"></div>
<div class="lg-buy-actions">
<button class="ggs-btn-ghost" id="buyVehicleClose">Abbrechen</button>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="engine.js"></script>
<script src="assets/vendor/three/three.min.js"></script>
<script src="assets/vendor/autobahn-game/autobahn-game.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);
let levelNum = parseInt(params.get('level') || '1', 10);
// Integrations-Vertrag: teacher_started erzwingt forcedLevel
if (window.LOGISTIK_SESSION_MODE === 'teacher_started' && window.LOGISTIK_FORCED_LEVEL) {
levelNum = parseInt(window.LOGISTIK_FORCED_LEVEL, 10) || levelNum;
}
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),
leistung_pct: Math.max(0, Math.min(100, Math.round((game.balance || 0) / 16000 * 100))),
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),
});
}
/** Assessment-Call (Integrations-Vertrag §6.3/§6.4).
* phase: 'started' | 'running' | 'completed'
* payload: { kennzahlen?, results?, reflections?, durationMs?, completedPhases? } */
async function postAssessment(phase, payload) {
payload = payload || {};
return request('/api/assessment', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(Object.assign({
simId: 'logistik',
phase: phase,
duration: payload.durationMs || 0,
completedPhases: payload.completedPhases || [phase],
processLog: payload.kennzahlen ? [{ at: Date.now(), kennzahlen: payload.kennzahlen }] : [],
results: payload.results || {},
reflections: payload.reflections || {},
}, {})),
});
}
return {
startAttempt, endAttempt, saveSlot, loadSlot, postAnalytics, postAssessment,
isDisabled: () => disabled,
};
})();
// Autosave entfernt (Thomas 2026-04-25). Auf der ToDo-Liste:
// konzeptionelles Auto-Save mit Resume bei naechstem Start.
LogistikEngine.onStateChange(game, function (evt) {
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);
});
// Assessment: Level-Start
const sessionStartMs = performance.now();
LG_API.postAssessment('started', {
durationMs: 0,
completedPhases: [],
kennzahlen: { level: levelNum, mode: window.LOGISTIK_SESSION_MODE || 'free' },
});
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 — alle immer sichtbar (Thomas-Wunsch: keine Stufen-Filterung)
const locations = game.worldState.locations || [];
const locById = {};
locations.forEach(loc => {
locById[loc.id] = loc;
const style = markerStyle[loc.type] || markerStyle.CITY;
let marker;
if (loc.type === 'PORT') {
// Hafen als Schiff-Icon (Anker als Symbol fuer Seehafen)
marker = L.marker([loc.lat, loc.lon], {
icon: L.divIcon({
className: 'lg-port-icon-wrap',
html: '<div class="lg-port-icon">⚓</div>',
iconSize: [24, 24],
iconAnchor: [12, 12],
}),
});
} else if (loc.type === 'TERMINAL') {
// Terminal/Binnenhafen als Container-Icon
marker = L.marker([loc.lat, loc.lon], {
icon: L.divIcon({
className: 'lg-terminal-icon-wrap',
html: '<div class="lg-terminal-icon">📦</div>',
iconSize: [22, 22],
iconAnchor: [11, 11],
}),
});
} else {
marker = L.circleMarker([loc.lat, loc.lon], style);
}
const typeLabel = {
CAPITAL: 'Hauptstadt',
CITY: 'Stadt',
PORT: 'Seehafen',
TERMINAL: 'Logistik-Terminal',
INDUSTRY: 'Industrie',
CUSTOMER: 'Kunde',
HUB: 'Knoten',
}[loc.type] || loc.type;
marker.bindPopup(
'<div class="lg-popup">' +
'<h4>' + escapeHtml(loc.name) + '</h4>' +
'<div class="lg-popup-meta">' + escapeHtml(loc.countryId) + ' · ' + escapeHtml(typeLabel) + '</div>' +
(loc.didacticInfo ? '<div class="lg-popup-didactic">' + escapeHtml(loc.didacticInfo) + '</div>' : '') +
'</div>'
);
// Klick auf Stadt:
// 1) Wenn Verfahren-Event aktiv: als Ziel-Tipp werten
// 2) Sonst: didaktische Info-Card einblenden
marker.on('click', () => {
const lost = (game.vehicles || []).find(v => v.verfahrenPending);
if (!lost) { showCityCard(loc); return; }
const result = LogistikEngine.resolveDriverLost(game, lost.id, loc.id, loc.type);
if (result.ok) {
const lostH = result.totalLostHours || 0;
showToast(pickText({
standard: lostH > 0
? '✓ Richtig! Durch falsche Tipps gingen ' + lostH.toFixed(1) + ' h verloren.'
: '✓ Richtig! ' + (lost.verfahrenDriverLabel || 'Fahrer*in') + ' fährt weiter.',
easy: lostH > 0
? 'Richtig. ' + lostH.toFixed(1) + ' Stunden verloren.'
: 'Richtig! Fahrt geht weiter.',
}));
} else if (result.wrong) {
const wasted = result.wastedHours || 1.5;
showToast(pickText({
standard: '✗ Dein Tipp war falsch — ' + wasted + ' h verloren. Versuch nochmal.',
easy: 'Falsch. ' + wasted + ' Stunden verloren. Probier nochmal.',
}));
}
renderVerfahrenBanner();
});
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);
});
// Alle Layer immer sichtbar (kein Layer-Control mehr — Thomas-Wunsch)
layers.capitals.addTo(map);
layers.cities.addTo(map);
layers.ports.addTo(map);
layers.terminals.addTo(map);
if (railEnabled) layers.railnet.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') + ' €';
}
/* ---------- Uhr-Widget (Phase 8f++): Zeiger + Tag/Nacht-Gradient ----------
Sonnen-Faktor in [0,1], 1=Mittag, 0=Mitternacht. Je nach Wert werden
zwei Farbstops im radialGradient interpoliert (innen=Sonne/Mond,
aussen=Himmel). Tag-Label unterm Zeiger. */
function updateClockWidget(date, dayNum) {
const clock = document.getElementById('lgClock');
if (!clock) return;
const h = date.getHours();
const m = date.getMinutes();
const minuteAngle = m * 6;
const hourAngle = (h % 12) * 30 + m * 0.5;
const hourHand = document.getElementById('lgClockHour');
const minuteHand = document.getElementById('lgClockMinute');
if (hourHand) hourHand.setAttribute('transform', 'rotate(' + hourAngle + ')');
if (minuteHand) minuteHand.setAttribute('transform', 'rotate(' + minuteAngle + ')');
// Sonnen-Faktor: sin-Kurve, Maximum um 13 Uhr (kleine Verschiebung wirkt natuerlicher)
const tHours = h + m / 60;
const sun = Math.max(0, Math.sin((tHours - 7) / 12 * Math.PI));
// Farben interpolieren: Mitternacht (dunkles Indigo) → Mittag (Hellblau/Gelb)
const mix = (a, b, t) => [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
const hex = ([r, g, b]) => '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('');
const centerNight = [60, 70, 120]; // Mond
const centerDay = [255, 233, 168]; // Sonne
const outerNight = [20, 38, 74]; // Nachthimmel
const outerDay = [122, 181, 224]; // Mittag-Blau
const center = hex(mix(centerNight, centerDay, sun));
const outer = hex(mix(outerNight, outerDay, sun));
const stop0 = document.getElementById('lgClockStop0');
const stop1 = document.getElementById('lgClockStop1');
if (stop0) stop0.setAttribute('stop-color', center);
if (stop1) stop1.setAttribute('stop-color', outer);
// Tag-Label-Farbe je nach Hintergrund-Helligkeit
const label = document.getElementById('lgClockDay');
if (label) label.textContent = 'Tag ' + dayNum;
}
/* ---------- Status-Click-Explainer (Phase 8f++) ----------
Jedes Status-Feld zeigt bei Klick eine kurze Card mit Bedeutung
und aktueller Berechnung. Klick ausserhalb / nochmal schliesst sie. */
let _statusExplainEl = null;
function _statusExplain(key, anchor) {
if (_statusExplainEl) { _statusExplainEl.remove(); _statusExplainEl = null; }
const a = (game.analytics || {});
const cfg = game._config || {};
const startBud = cfg.startBudget || 0;
const balance = Math.round(game.balance || 0);
const umsatz = Math.round(a.totalRewardEarned || 0);
const fahrk = Math.round(a.totalFareCostPaid || 0);
const miete = Math.round(a.runningCostsRental || 0);
const strafen = Math.round(a.totalPenaltyPaid || 0);
const gewinn = balance - startBud;
const nVeh = (game.vehicles || []).length;
const rentDay = (cfg.rentalCostPerDay || 0) * nVeh;
const limitH = cfg.timeLimitHours || 0;
const elapsedH = game.simulationStartTime
? (new Date(game.simulationTime).getTime() - new Date(game.simulationStartTime).getTime()) / 3600000
: 0;
const remainH = Math.max(0, limitH - elapsedH);
const completed = (game.completedContracts || []).length;
const totalC = completed + (game.activeContracts || []).length;
const E = {
time: {
title: 'Sim-Zeit',
desc: 'Aktuelle Zeit in der Simulation. 1 Tag = 24 Sim-Stunden.',
line: 'Verstrichen: ' + elapsedH.toFixed(1) + ' h · Verbleibend: ' + remainH.toFixed(1) + ' h von ' + limitH + ' h',
},
balance: {
title: 'Konto',
desc: 'Dein aktuelles Guthaben in Euro. Wenn das Konto auf 0 € oder darunter fällt, ist die Simulation verloren.',
line: 'Aktuell: ' + fmtEuro(balance),
},
revenue: {
title: 'Umsatz',
desc: 'Summe aller eingenommenen Auftragserlöse seit Simulationsstart. Vor Abzug von Fahrkosten und Miete.',
line: 'Umsatz bisher: ' + fmtEuro(umsatz),
},
profit: {
title: 'Gewinn',
desc: 'Was du seit Start verdient (oder verloren) hast. Konto minus Startbudget.',
formula: 'Konto ' + fmtEuro(balance) + ' Startbudget ' + fmtEuro(startBud) + ' = ' + (gewinn >= 0 ? '+' : '') + fmtEuro(Math.abs(gewinn)),
},
rent: {
title: 'Tagesmiete',
desc: 'Fixkosten pro Sim-Tag für deine ' + nVeh + ' Fahrzeug' + (nVeh !== 1 ? 'e' : '') + ' à ' + fmtEuro(cfg.rentalCostPerDay || 0) + '. Läuft auch im Stand.',
formula: nVeh + ' × ' + fmtEuro(cfg.rentalCostPerDay || 0) + ' = ' + fmtEuro(rentDay) + ' / Tag',
line: 'Bisher gezahlte Miete: ' + fmtEuro(miete),
},
contracts: {
title: 'Aufträge',
desc: 'Erledigte Aufträge / aktuelle Gesamtmenge (offen + erledigt).',
line: 'Erledigt: ' + completed + ' · Offen oder unterwegs: ' + (totalC - completed),
},
};
const info = E[key];
if (!info) return;
const div = document.createElement('div');
div.className = 'lg-status-explain';
div.innerHTML =
'<h4>' + escapeHtml(info.title) + '</h4>' +
'<p>' + escapeHtml(info.desc) + '</p>' +
(info.formula ? '<div class="lg-formula">' + escapeHtml(info.formula) + '</div>' : '') +
(info.line ? '<p>' + escapeHtml(info.line) + '</p>' : '');
document.body.appendChild(div);
// Position oberhalb des Anker-Elements
const r = anchor.getBoundingClientRect();
const dr = div.getBoundingClientRect();
const left = Math.max(8, Math.min(window.innerWidth - dr.width - 8, r.left + r.width / 2 - dr.width / 2));
const top = Math.max(8, r.top - dr.height - 8);
div.style.left = left + 'px';
div.style.top = top + 'px';
_statusExplainEl = div;
// Schliessen bei naechstem Klick irgendwo
setTimeout(() => {
const closer = (e) => {
if (!_statusExplainEl) return;
if (_statusExplainEl.contains(e.target)) return;
_statusExplainEl.remove();
_statusExplainEl = null;
document.removeEventListener('click', closer, true);
};
document.addEventListener('click', closer, true);
}, 0);
}
// Click-Handler an alle Status-Items binden
document.querySelectorAll('.lg-status-item').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const k = el.getAttribute('data-explain');
_statusExplain(k, el);
});
});
/* ---------- Frist-Balken Live-Update (Phase 8f++) ----------
Aktualisiert die Width-Werte aller .lg-deadline-Elemente, ohne die
Card neu zu zeichnen. So tickt der Balken sichtbar runter. */
function updateDeadlineBars() {
const nowMs = new Date(game.simulationTime).getTime();
document.querySelectorAll('.lg-deadline').forEach(el => {
const cid = el.getAttribute('data-contract-id');
const c = (game.activeContracts || []).find(x => x.id === cid);
if (!c || !c.dueTime || !c.issueTime) return;
const issueMs = new Date(c.issueTime).getTime();
const dueMs = new Date(c.dueTime).getTime();
const totalH = (dueMs - issueMs) / 3600000;
const remainH = (dueMs - nowMs) / 3600000;
if (totalH <= 0) return;
const pct = Math.max(0, Math.min(100, (remainH / totalH) * 100));
const fill = el.querySelector('.lg-deadline-fill');
if (fill) {
fill.style.width = pct.toFixed(1) + '%';
fill.classList.toggle('warn', pct < 30 && pct >= 12);
fill.classList.toggle('danger', pct < 12);
}
const remainEl = el.querySelector('.lg-dl-remain');
if (remainEl) {
const isL1Bar = el.getAttribute('data-l1') === '1';
remainEl.textContent = remainH > 0
? (isL1Bar ? 'verschwindet in ' : 'noch ') + remainH.toFixed(1).replace('.', ',') + ' h'
: (isL1Bar ? 'läuft gleich aus' : 'Frist abgelaufen');
remainEl.classList.toggle('lg-dl-warn', pct < 30);
}
});
}
/* ---------- Status-Anzeige ---------- */
function updateStatusUI() {
document.getElementById('statusState').textContent = game.state;
// Echte Session-Laufzeit in Minuten:Sekunden
const realSec = Math.max(0, (performance.now() - sessionStartMs) / 1000);
const m = Math.floor(realSec / 60);
const s = String(Math.floor(realSec % 60)).padStart(2, '0');
const realEl = document.getElementById('statusRealtime');
if (realEl) realEl.textContent = '⏱ ' + m + ':' + s + ' min';
document.getElementById('statusBalance').textContent = fmtEuro(game.balance);
const a = (game.analytics || {});
const startBud = (game._config && game._config.startBudget) || 0;
const umsatz = Math.round(a.totalRewardEarned || 0);
const gewinn = Math.round((game.balance || 0) - startBud);
const sign = gewinn >= 0 ? '+' : '';
document.getElementById('statusRevenue').textContent = fmtEuro(umsatz);
const profitEl = document.getElementById('statusProfit');
profitEl.textContent = sign + fmtEuro(Math.abs(gewinn));
profitEl.style.color = gewinn >= 0 ? '#1f6b3a' : '#a83232';
document.getElementById('statusContracts').textContent =
game.completedContracts.length + '/' + (game.activeContracts.length + game.completedContracts.length);
const t = new Date(game.simulationTime);
// Kompaktes Format ohne Sekunden: "Tag X, HH:MM"
const start = game.simulationStartTime
? new Date(game.simulationStartTime).getTime() : t.getTime();
const dayNum = Math.floor((t.getTime() - start) / 86400000) + 1;
const hh = String(t.getHours()).padStart(2, '0');
const mm = String(t.getMinutes()).padStart(2, '0');
document.getElementById('statusTime').textContent = 'Tag ' + dayNum + ', ' + hh + ':' + mm;
// Miete pro Tag (= rentalCostPerDay × Fahrzeuganzahl). Nur anzeigen,
// wenn das Level ueberhaupt Miete hat.
const rentPerDay = (game._config && game._config.rentalCostPerDay) || 0;
const rentWrap = document.getElementById('statusRentWrap');
const rentEl = document.getElementById('statusRent');
if (rentPerDay > 0 && rentEl && rentWrap) {
// Per-Vehicle Multiplikator (Train teurer): Summe aus allen Mietsaetzen
const VD = (LogistikEngine && LogistikEngine.VEHICLE_DEFAULTS) || {};
const totalRent = (game.vehicles || []).reduce((sum, v) => {
const mult = (VD[v.mode] && typeof VD[v.mode].rentMult === 'number') ? VD[v.mode].rentMult : 1.0;
return sum + rentPerDay * mult;
}, 0);
rentEl.textContent = fmtEuro(Math.round(totalRent));
rentWrap.hidden = false;
} else if (rentWrap) {
rentWrap.hidden = true;
}
// Uhr-Widget parallel aktualisieren
updateClockWidget(t, dayNum);
// Level-Badge mit L1-Stufen-Progression (Phase 8e):
// "Level 1 · Stufe 3 Zeitdruck · Runde 5"
const lb = document.getElementById('levelBadge');
if (lb) {
const cfg = game._config || {};
const isL1 = (cfg.maxActiveContracts || 1) === 1;
if (isL1 && game.progression) {
const round = game.progression.round || 1;
const L1_STUFEN_UI = [
null,
{ n: 1, name: 'Erster Auftrag', minRound: 1 },
{ n: 2, name: 'Mehrere zur Wahl', minRound: 2 },
{ n: 3, name: 'Weitere Strecken', minRound: 4 },
{ n: 4, name: 'Mehr Fahrzeuge', minRound: 7 },
{ n: 5, name: 'Soft-Ramp → L2', minRound: 11 },
];
let stufe = L1_STUFEN_UI[1];
for (let i = 1; i < L1_STUFEN_UI.length; i++) {
if (L1_STUFEN_UI[i] && round >= L1_STUFEN_UI[i].minRound) stufe = L1_STUFEN_UI[i];
}
lb.textContent = 'Level ' + levelNum + ' · Stufe ' + stufe.n + ' ' + stufe.name + ' · R' + round;
} else {
lb.textContent = 'Level ' + levelNum;
}
}
}
/* ---------- 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') return '🚚';
if (mode === 'TRUCK_MID') return '🚛';
if (mode === 'TRUCK_LARGE') return '🚛';
if (mode === 'TRAIN_SMALL' || 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') {
// IDLE-Fahrzeug leicht versetzen (Offset nach Fahrzeug-Index), damit
// der Stadtmarker darunter klickbar bleibt (Verfahren-Ziel-Tipp).
// Fahrende Fahrzeuge folgen der Route ohne Offset.
let displayLat = v.lat, displayLon = v.lon;
if (v.state === 'IDLE' && !v.tripPhase) {
const idx = parseInt((v.id || 'veh_1').replace(/\D/g, ''), 10) || 1;
// Ring von 4 Richtungen (NO, NW, SW, SO) + ~6 km Radius
const angles = [45, 135, 225, 315];
const a = angles[(idx - 1) % 4] * Math.PI / 180;
const km = 6;
const dLat = Math.cos(a) * km / 111;
const dLon = Math.sin(a) * km / (111 * Math.cos(v.lat * Math.PI / 180));
displayLat = v.lat + dLat;
displayLon = v.lon + dLon;
}
m.setLatLng([displayLat, displayLon]);
}
// 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');
// Reihenfolge (Thomas 2026-04-26): primär nach Container-Anzahl
// aufsteigend (zuerst die kleinen), sekundaer nach Frist aufsteigend.
// Sichtbar: OPEN + RESERVED (geladen, noch nicht losgefahren). Sobald
// ein Tour startet (LOADING/IN_TRANSIT/...): aus der Liste raus, ist in
// der Fahrzeug-Card. Abgelaufene Frist wird ausgeblendet.
const nowMs = new Date(game.simulationTime).getTime();
const visibleStates = new Set(['OPEN', 'RESERVED']);
const list = (game.activeContracts || [])
.filter(c => {
if (!(visibleStates.has(c.state) || c.intermodal)) return false;
if (c.dueTime && new Date(c.dueTime).getTime() < nowMs) return false;
return true;
})
.slice()
.sort((a, b) => {
const ca = a.quantityContainers || 1;
const cb = b.quantityContainers || 1;
if (ca !== cb) return ca - cb;
const da = a.dueTime ? new Date(a.dueTime).getTime() : Infinity;
const db = b.dueTime ? new Date(b.dueTime).getTime() : Infinity;
return da - db;
});
if (!list.length) {
root.innerHTML = '<div class="lg-hint">Keine Aufträge offen.</div>';
return;
}
root.innerHTML = '';
// Negative-Marge-Hinweis: wenn ein Fahrzeug zur Verfuegung steht und ALLE
// OPEN-Auftraege fuer dieses Fahrzeug Verlustgeschaefte waeren, blende
// einen Tipp ein. Voice-Hint wird einmal pro "Welle" gesprochen.
const lvForCheck = (game.vehicles || []).find(v => v.id === loadingVehicleId
&& v.state === 'IDLE' && !v.tripPhase);
const openOnly = (game.activeContracts || []).filter(c => c.state === 'OPEN' && !c.intermodal);
if (lvForCheck && openOnly.length > 0) {
const allNegative = openOnly.every(c => {
const cost = estimateRouteCost(c.originLocationId, c.targetLocationId, lvForCheck.mode);
return ((c.rewardBase || 0) - cost) < 0;
});
if (allNegative) {
const banner = document.createElement('div');
banner.className = 'lg-all-negative-hint';
banner.innerHTML = '<span class="lg-anh-icon">💡</span><span>'
+ escapeHtml(pickText({
standard: 'Alle aktuellen Aufträge wären für ' + escapeHtml(lvForCheck.driverName || lvForCheck.displayName)
+ ' Verlustgeschäfte. Vielleicht warten — neue Aufträge kommen regelmäßig nach.',
easy: 'Diese Aufträge bringen Verlust. Warte. Bessere kommen.',
}))
+ '</span>';
root.appendChild(banner);
// Voice-Tipp einmal pro Welle (pro Spawn-Zyklus). Wir setzen einen
// Marker auf der Vehicle-Instanz, damit nicht auf jeden Re-Render
// erneut gesprochen wird.
if (!lvForCheck._allNegHintSpoken) {
lvForCheck._allNegHintSpoken = true;
// Voice nur ab L2+ — auf L1 ist immer nur 1 Auftrag offen,
// der UI-Banner reicht (Audio-Text "alle aktuellen Aufträge"
// klingt im Plural sonst falsch).
const cfgNeg = game._config || {};
if ((cfgNeg.maxActiveContracts || 1) > 1) speak('all_negative_hint');
}
} else {
// Sobald wieder profitable Aufträge da sind: Marker zuruecksetzen,
// damit beim naechsten Engpass wieder ein Hinweis kommt.
if (lvForCheck._allNegHintSpoken) lvForCheck._allNegHintSpoken = false;
}
}
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;
}
// Capacity-Filter (nur L2/L3, nicht L1): wenn ein Fahrzeug ausgewaehlt
// ist, blende die Aufträge aus, die wegen Container-Anzahl nicht in
// den Restladeraum passen. Aufträge die kleiner sind als der Laderaum
// gehen natuerlich. L1 bleibt unangetastet.
const isNonL1 = (game._config || {}).maxActiveContracts > 1;
let lvForCap = null, freeCap = Infinity;
if (isNonL1 && loadingVehicleId) {
lvForCap = (game.vehicles || []).find(v => v.id === loadingVehicleId);
if (lvForCap) {
const used = (lvForCap.loadedContractIds || []).reduce((sum, cid) => {
const lc = (game.activeContracts || []).find(x => x.id === cid);
return sum + (lc ? (lc.quantityContainers || 0) : 0);
}, 0);
freeCap = (lvForCap.capacityUnits || 1) - used;
}
}
list.forEach(c => {
// Filter: nicht-passende Auftraege ueberspringen (nur L2/L3 + Vehicle gewaehlt)
if (lvForCap && c.state === 'OPEN' && !c.intermodal
&& (c.quantityContainers || 1) > freeCap) {
return;
}
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');
// Auftrag im aktuell selektierten Fahrzeug geladen?
const lvSel = (game.vehicles || []).find(x => x.id === loadingVehicleId);
const inSelected = !!(lvSel && (lvSel.loadedContractIds || []).includes(c.id));
// Auftrag in einem anderen Fahrzeug geladen?
const inOtherVehicle = !inSelected && (game.vehicles || [])
.some(v => (v.loadedContractIds || []).includes(c.id));
const tone = contractStateTone(c.state);
card.className = 'lg-card'
+ (cardSelected ? ' selected' : '')
+ (cardPaired ? ' linked-pair' : '')
+ (inSelected ? ' linked' : '')
+ ((inOtherVehicle && !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 + Zeit-Reserve fuer das aktuell im Lademodus aktive Fahrzeug
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';
// Zeit-Reserve: Fahrtdauer vs. Frist
const vehDef = (LogistikEngine.VEHICLE_DEFAULTS || {})[lv.mode] || {};
const distanceKm = _routeDistance(c.originLocationId, c.targetLocationId);
const travelH = vehDef.speedKmh ? distanceKm / vehDef.speedKmh : 0;
const nowMs = new Date(game.simulationTime).getTime();
const dueMs = new Date(c.dueTime).getTime();
const slackH = (dueMs - nowMs) / 3600000 - travelH;
let slackLbl, slackCol;
if (slackH >= 3) { slackLbl = slackH.toFixed(1) + 'h Reserve'; slackCol = '#2d6d3d'; }
else if (slackH >= 1) { slackLbl = slackH.toFixed(1) + 'h Reserve — knapp'; slackCol = '#8a5a1a'; }
else if (slackH >= 0) { slackLbl = slackH.toFixed(1) + 'h Reserve — kritisch'; slackCol = '#b32d2d'; }
else { slackLbl = 'Frist NICHT zu schaffen'; slackCol = '#b32d2d'; }
const isL1ForCost = (game._config || {}).maxActiveContracts === 1;
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>'
+ (isL1ForCost ? '' : '<br>⏰ <span style="color:' + slackCol + ';font-weight:600">' + slackLbl + '</span>')
+ '</div>';
}
}
// Profile-Badge (aus L1-Generator): tight/far/easy
const profileBadges = {
tight: '<span class="lg-profile-badge lg-profile-tight" title="Mehr Geld, aber Frist knapp">⚡ knappe Frist</span>',
far: '<span class="lg-profile-badge lg-profile-far" title="Weite Strecke — Fahrzeug landet weit vom Hub">🌍 weite Fahrt</span>',
easy: '<span class="lg-profile-badge lg-profile-easy" title="Kurze Strecke, weniger Geld, schnell fertig">🌿 kurz</span>',
};
const profileBadge = c.profile && profileBadges[c.profile] ? ' ' + profileBadges[c.profile] : '';
// L1 hat keine Fristen (Thomas 2026-04-25): Frist- und Slack-Hinweise
// komplett ausblenden. Profile "tight" (knappe Frist) waere auf L1
// ebenfalls irrefuehrend → Badge unterdruecken.
const isL1 = (game._config || {}).maxActiveContracts === 1;
const profileBadgeFinal = isL1 && c.profile === 'tight' ? '' : profileBadge;
const bonusBadge = c.bonusVariant
? ' <span class="lg-bonus-variant-badge" title="+50 % Reward gegenueber dem Zwilling — Glueckstreffer!">💎 Bonus +50 %</span>'
: '';
const trainBadge = c.trainEligible
? ' <span class="lg-train-badge" title="Großauftrag — ideal für den Güterzug!">🚂 Zug-tauglich</span>'
: '';
const deadlineLine = isL1
? '' // keine Frist-Zeile auf L1
: ' · <span data-glossar="frist">Frist</span>: ' + due.toLocaleString('de-DE');
// Zeit-Balken — zeigt verbleibende Zeit als Anteil der Gesamtfrist.
// Gruener Fill von links, gelbe und rote Gefahrenzone am rechten Ende.
// L1: nur fuer OPEN-Auftraege, Label "verschwindet in X h" (Cards
// kommen und gehen), keine Strafanzeige.
// L2/L3: in allen aktiven Zustaenden, Label "noch X h" als Lieferfrist.
let deadlineBarHtml = '';
const deadlineDoneStates = new Set(['DELIVERED', 'FAILED', 'CANCELLED', 'EXPIRED']);
const showDeadline = c.dueTime && !deadlineDoneStates.has(c.state)
&& (isL1 ? c.state === 'OPEN' : true);
if (showDeadline) {
const issueRaw = c.issueTime || game.simulationStartTime || game.simulationTime;
const issueMs = new Date(issueRaw).getTime();
const dueMs = new Date(c.dueTime).getTime();
const nowMs = new Date(game.simulationTime).getTime();
const totalH = (dueMs - issueMs) / 3600000;
const remainH = (dueMs - nowMs) / 3600000;
if (totalH > 0) {
const pct = Math.max(0, Math.min(100, (remainH / totalH) * 100));
const fillCls = pct < 12 ? 'danger' : pct < 30 ? 'warn' : '';
const remainTxt = remainH > 0
? (isL1 ? 'verschwindet in ' : 'noch ') + remainH.toFixed(1).replace('.', ',') + ' h'
: (isL1 ? 'läuft gleich aus' : 'Frist abgelaufen');
const totalTxt = isL1 ? 'Angebot' : 'Frist: ' + totalH.toFixed(0) + ' h';
deadlineBarHtml = '<div class="lg-deadline" data-contract-id="' + escapeHtml(c.id) + '" data-l1="' + (isL1 ? '1' : '0') + '">'
+ '<div class="lg-deadline-bar">'
+ '<div class="lg-deadline-fill ' + fillCls + '" style="width:' + pct.toFixed(1) + '%"></div>'
+ '</div>'
+ '<div class="lg-deadline-label">'
+ '<span>' + totalTxt + '</span>'
+ '<span class="lg-dl-remain' + (pct < 30 ? ' lg-dl-warn' : '') + '">' + remainTxt + '</span>'
+ '</div>'
+ '</div>';
}
}
// X-Button oben rechts: Auftrag verwerfen (nur fuer OPEN-Auftraege)
const dismissBtn = (c.state === 'OPEN' && !c.intermodal)
? '<button class="lg-card-dismiss" title="Auftrag verwerfen" data-dismiss-id="' + escapeHtml(c.id) + '">×</button>'
: '';
card.innerHTML =
dismissBtn +
'<div class="lg-card-title">' + escapeHtml(c.code || c.id)
+ (c.intermodal ? ' 🔗' : '')
+ profileBadgeFinal
+ bonusBadge
+ trainBadge
+ (c.awayBonus ? ' <span class="lg-away-badge" title="Leerfahrt zum Pickup noetig, dafuer hoeherer Reward">↗ woanders</span>' : '')
+ '</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) + deadlineLine + '</div>' +
deadlineBarHtml +
costHintHtml +
'<span class="lg-card-state ' + c.state + '">' + escapeHtml(contractStateLabel(c.state)) + '</span>';
card.addEventListener('click', (ev) => {
// X-Button: Auftrag verwerfen (silent remove aus activeContracts)
if (ev.target && ev.target.classList && ev.target.classList.contains('lg-card-dismiss')) {
ev.stopPropagation();
const dismissId = ev.target.getAttribute('data-dismiss-id');
const idx = (game.activeContracts || []).findIndex(x => x.id === dismissId && x.state === 'OPEN');
if (idx >= 0) {
game.activeContracts.splice(idx, 1);
renderPanels();
}
return;
}
// RESERVED in selektiertem Vehicle → Toggle: ausladen
if (!isOpenForUser && inSelected && !c.intermodal) {
try { LogistikEngine.unloadContract(game, loadingVehicleId, c.id); } catch (e) {}
renderPanels();
return;
}
// RESERVED in anderem Vehicle → kurzer Hinweis
if (!isOpenForUser && inOtherVehicle && !c.intermodal) {
const otherV = (game.vehicles || [])
.find(v => (v.loadedContractIds || []).includes(c.id));
showToast(pickText({
standard: 'Bereits in ' + (otherV ? otherV.displayName : 'anderem Fahrzeug') + ' geladen.',
easy: 'Schon im anderen LKW.',
}));
return;
}
if (!isOpenForUser) return;
// Robuste Auswahl (Thomas 2026-04-25): vor dem Laden immer pruefen,
// ob das aktuell gewaehlte Fahrzeug wirklich laden kann. Sonst
// das naechste IDLE-Fahrzeug nehmen. Verhindert stale
// loadingVehicleId nach Tour-Starts.
if (!c.intermodal) {
let lvCheck = (game.vehicles || []).find(v => v.id === loadingVehicleId);
if (!lvCheck || lvCheck.state !== 'IDLE' || lvCheck.tripPhase) {
const nextIdle = (game.vehicles || []).find(v => v.state === 'IDLE' && !v.tripPhase);
loadingVehicleId = nextIdle ? nextIdle.id : null;
}
if (!loadingVehicleId) {
showToast(pickText({
standard: 'Kein freies Fahrzeug verfügbar.',
easy: 'Kein LKW frei.',
}));
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) {
// Prüfen ob dieser Auftrag bereits geladen ist → Toggle: ausladen
const lv = (game.vehicles || []).find(x => x.id === loadingVehicleId);
if (lv && (lv.loadedContractIds || []).includes(c.id)) {
try { LogistikEngine.unloadContract(game, loadingVehicleId, c.id); } catch (e) {}
renderPanels();
return;
}
// Auto-Swap: wenn kein Platz, ältesten geladenen Auftrag entladen und neu versuchen
let r, swapped = null;
try {
r = LogistikEngine.loadContract(game, loadingVehicleId, c.id);
} catch (err) {
const loaded = (lv && lv.loadedContractIds) || [];
if (loaded.length) {
swapped = loaded[0];
try {
LogistikEngine.unloadContract(game, loadingVehicleId, swapped);
r = LogistikEngine.loadContract(game, loadingVehicleId, c.id);
} catch (err2) {
showToast(pickText({ standard: 'Laden geht nicht: ' + err2.message, easy: 'Geht nicht.' }));
renderPanels();
return;
}
} else {
showToast(pickText({ standard: 'Laden geht nicht: ' + err.message, easy: 'Geht nicht: ' + err.message }));
renderPanels();
return;
}
}
const msg = swapped
? '🔄 ' + (c.code || c.id) + ' geladen (' + swapped.replace('contract_','') + ' ausgetauscht)'
: '📦 ' + (c.code || c.id) + ' geladen — ' + r.freeSlotsAfter + ' Plätze frei.';
showToast(pickText({ standard: msg, easy: 'Auftrag ist im LKW.' }));
// Voice: Auftrag nach Zielstadt
speak('order_to_' + c.targetLocationId);
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');
// Leere/freie Fahrzeuge zuerst (IDLE ohne tripPhase). Stable-Sort
// erhält Original-Reihenfolge innerhalb der Gruppen.
const list = (game.vehicles || []).slice().sort((a, b) => {
const aFree = (a.state === 'IDLE' && !a.tripPhase) ? 0 : 1;
const bFree = (b.state === 'IDLE' && !b.tripPhase) ? 0 : 1;
return aFree - bFree;
});
// Auto-Select: oberstes IDLE-Fahrzeug automatisch in Lademodus, wenn noch keins aktiv
if (!loadingVehicleId) {
const firstIdle = list.find(v => v.state === 'IDLE' && !v.tripPhase);
if (firstIdle) loadingVehicleId = firstIdle.id;
} else {
// Wenn das aktuell gewählte Fahrzeug nicht mehr IDLE ist (rollt), auf nächstes IDLE umschalten
const current = list.find(v => v.id === loadingVehicleId);
if (!current || current.state !== 'IDLE' || current.tripPhase) {
const firstIdle = list.find(v => v.state === 'IDLE' && !v.tripPhase);
loadingVehicleId = firstIdle ? firstIdle.id : null;
}
}
root.innerHTML = '';
// Phase 8d: Fahrzeugkauf-Button ab L2 (maxActiveContracts > 1).
// L1 nutzt Auto-Buy bei Balance ≥ 15k, kein manueller Kauf.
const cfg = game._config || {};
if ((cfg.maxActiveContracts || 1) > 1) {
const btn = document.createElement('button');
btn.className = 'ggs-btn-secondary lg-buy-vehicle-btn';
btn.innerHTML = '🛒 Neues Fahrzeug kaufen';
btn.addEventListener('click', openBuyVehicleDialog);
root.appendChild(btn);
}
if (!list.length) {
const hint = document.createElement('div');
hint.className = 'lg-hint';
hint.textContent = pickText({
standard: 'Keine Fahrzeuge verfügbar — kauf eins oder warte auf die nächste Runde.',
easy: 'Keine Fahrzeuge da. Kauf eines.',
});
root.appendChild(hint);
return;
}
// 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 8f++) Pflicht-Pause-Banner (ab L2 aktiv)
let breakHtml = '';
if (v.onMandatoryBreak) {
const rem = Math.ceil(v.breakRemainingMin || 0);
breakHtml = '<div class="lg-break-banner">☕ Lenkzeit-Pause · noch '
+ rem + ' min</div>';
}
// (Phase 7-UX) Trip-Info bei MOVING/LOADING/UNLOADING + Multi-Tour
let tripHtml = breakHtml;
if (v.tripPhase) {
const c = (game.activeContracts || []).find(x => x.id === v.assignedContractId);
const isDriving = v.tripPhase.endsWith('_drive') || v.tripPhase === 'tour_driving';
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 === 'tour_driving' ? '🟢 Tour-Fahrt'
: v.tripPhase === 'tour_handling' ? '📦 Tour-Stop'
: v.tripPhase;
const phaseClass = v.tripPhase === 'pickup_drive' ? 'pickup'
: v.tripPhase === 'loading' || v.tripPhase === 'unloading' || v.tripPhase === 'tour_handling' ? 'loading'
: '';
const progress = Math.round((v.segmentProgress || 0) * 100);
const route = (game.activeRoutes || []).find(r => r.id === v.activeRouteId);
let remainingMin = 0;
let etaLine = '';
if (route && isDriving) {
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.tripPhase === 'tour_handling')
&& v.phaseRemainingMinutes > 0) {
etaLine = ' · noch ' + Math.ceil(v.phaseRemainingMinutes) + ' min';
}
// Origin/Target-Info: bei Tour-Driving aus tourStops, sonst aus assignedContract
let contractInfo = '—';
if (v.tripPhase === 'tour_driving' || v.tripPhase === 'tour_handling') {
const stops = v.tourStops || [];
const idx = v.currentStopIndex || 0;
const stop = stops[idx];
const fromLoc = idx === 0 ? null : stops[idx - 1];
if (stop) {
const fromName = fromLoc ? _locName(fromLoc.locationId) : _locName(v.currentLocationId);
contractInfo = 'Stop ' + (idx + 1) + '/' + stops.length
+ ' · ' + escapeHtml(fromName) + ' → ' + escapeHtml(_locName(stop.locationId));
}
} else if (c) {
contractInfo = escapeHtml(c.code) + ' · '
+ escapeHtml(_locName(c.originLocationId)) + ' → ' + escapeHtml(_locName(c.targetLocationId));
}
// Verbleibende Zeit als kleine Stunden-Zahl (nur bei Fahrt-Phasen)
const remainHrsLine = (isDriving && remainingMin > 0)
? '<div class="lg-trip-remaining">noch ' + (remainingMin / 60).toFixed(1).replace('.', ',') + ' h</div>'
: '';
// Strafen-Hinweis: Auftrag laeuft, dueTime ueberschritten oder kritisch
let strafeHtml = '';
const cForPenalty = c || ((game.activeContracts || [])
.find(x => v.tourStops && v.tourStops.some(s => (s.contractIds || []).includes(x.id))));
const cfgPen = (game._config || {}).latePenaltyOverride;
if (cForPenalty && cForPenalty.dueTime && cfgPen > 0) {
const nowMs = new Date(game.simulationTime).getTime();
const dueMs = new Date(cForPenalty.dueTime).getTime();
const lateH = (nowMs - dueMs) / 3600000;
if (lateH > 0) {
const est = Math.round((cForPenalty.rewardBase || 0) * cfgPen * lateH);
strafeHtml = '<div class="lg-trip-penalty">⚠ Strafe wächst — bisher ' + fmtEuro(est) + '</div>';
} else if (lateH > -2 && isDriving && remainingMin / 60 > -lateH) {
strafeHtml = '<div class="lg-trip-penalty warn">⚠ Frist droht — Restzeit reicht nicht</div>';
}
}
tripHtml += '<div class="lg-trip-info ' + phaseClass + '">'
+ '<div class="lg-trip-phase">' + phaseLabel + etaLine + '</div>'
+ '<div>' + contractInfo + '</div>'
+ (isDriving
? '<div class="lg-progress-bar"><div class="lg-progress-fill ' + (v.tripPhase === 'pickup_drive' ? 'pickup' : '') + '" style="width:' + progress + '%"></div></div>'
: '')
+ remainHrsLine
+ strafeHtml
+ '</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</strong><br><span class="lg-loadmode-sub">Klicke auf Aufträge</span></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 ca.</span><span class="lg-tp-num">' + Math.round(est.km) + '<small> km</small></span></div>'
+ '<div class="lg-tour-preview-row"><span>Fahrkosten ca.</span><span class="lg-tp-num">' + fmtEuro(est.cost).replace(' €', '') + '<small> €</small></span></div>'
+ '<div class="lg-tour-preview-row"><span>Erlöse <button class="lg-info-btn" data-info="erloese" title="Was sind Erlöse?">i</button></span><span class="lg-tp-num">+' + fmtEuro(rewardSum).replace(' €', '') + '<small> €</small></span></div>'
+ '<div class="lg-tour-preview-row sum ' + sumClass + '"><span>Gewinn ca.</span><span class="lg-tp-num">' + sign + fmtEuro(Math.abs(preMargin)).replace(' €', '') + '<small> €</small></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') + '>Los geht\'s →</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) => {
// Info-Button (i): zeigt Tooltip-artige Erklaerung an
if (e.target && e.target.classList && e.target.classList.contains('lg-info-btn')) {
e.stopPropagation();
const key = e.target.getAttribute('data-info');
const explanations = {
erloese: 'Erlöse = Summe der Auftragsbeträge ohne Bonus-Pillen und ohne Verspätungsstrafen. Tatsächlicher Eingang weicht ggf. ab.',
};
showToast(explanations[key] || '');
return;
}
// 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.' }));
maybeMinigameHint();
} 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(); renderTripHistory(); renderVerfahrenBanner(); renderBonusPills(); }
/* ---------- Bonus-Pills (Phase 8e++) ---------- */
function renderBonusPills() {
const wrap = document.getElementById('bonusPills');
if (!wrap) return;
const bonuses = game.pendingBonuses || [];
wrap.innerHTML = '';
if (!bonuses.length) return;
bonuses.forEach(b => {
const pill = document.createElement('div');
pill.className = 'lg-bonus-pill';
pill.dataset.bonusId = b.id;
pill.textContent = '⚡ Ladebonus +' + b.minutes + ' min';
pill.title = 'Klick: Info. Wird automatisch beim naechsten Laden/Entladen verwendet.';
pill.addEventListener('click', (e) => {
e.stopPropagation();
showBonusPopover(b, pill);
});
wrap.appendChild(pill);
});
}
function showBonusPopover(bonus, anchor) {
// Entferne alte Popover
const existing = document.querySelector('.lg-bonus-popover');
if (existing) existing.remove();
const pop = document.createElement('div');
pop.className = 'lg-bonus-popover';
pop.innerHTML = pickText({
standard: '<strong>Ladebonus +' + bonus.minutes + ' min</strong><br>'
+ 'Aus dem Einpark-Minispiel (' + bonus.source + ').<br>'
+ '<em>Wird automatisch beim nächsten Laden oder Entladen genutzt — die Phase-Zeit verringert sich entsprechend.</em>',
easy: '<strong>+' + bonus.minutes + ' Minuten Bonus.</strong><br>'
+ 'Wird automatisch beim Laden/Entladen benutzt.',
});
document.getElementById('bonusPills').appendChild(pop);
setTimeout(() => {
document.addEventListener('click', function h(ev) {
if (!pop.contains(ev.target)) { pop.remove(); document.removeEventListener('click', h); }
});
}, 10);
setTimeout(() => { if (pop.parentNode) pop.remove(); }, 5000);
}
/** Flug-Animation einer Bonus-Pill zum Fahrzeug-Marker. */
function flyBonusToVehicle(minutesSaved, vehicleId) {
const v = (game.vehicles || []).find(x => x.id === vehicleId);
if (!v || typeof v.lat !== 'number') return;
const fromEl = document.querySelector('.lg-bonus-pill');
const mapEl = document.getElementById('map');
if (!mapEl) return;
const fromRect = fromEl ? fromEl.getBoundingClientRect()
: { left: mapEl.getBoundingClientRect().right - 140, top: mapEl.getBoundingClientRect().top + 20, width: 120, height: 26 };
const point = map.latLngToContainerPoint([v.lat, v.lon]);
const mapRect = mapEl.getBoundingClientRect();
const toX = mapRect.left + point.x;
const toY = mapRect.top + point.y;
const clone = document.createElement('div');
clone.className = 'lg-bonus-pill lg-bonus-pill-fly';
clone.textContent = '⚡ ' + minutesSaved + ' min';
clone.style.left = fromRect.left + 'px';
clone.style.top = fromRect.top + 'px';
document.body.appendChild(clone);
requestAnimationFrame(() => {
clone.style.transform = 'translate(' + (toX - fromRect.left) + 'px, ' + (toY - fromRect.top) + 'px) scale(0.5)';
clone.style.opacity = '0.2';
});
setTimeout(() => clone.remove(), 900);
}
/* ---------- Verfahren-Ereignis-Banner (Phase 8e+) ---------- */
function renderVerfahrenBanner() {
const banner = document.getElementById('verfahrenBanner');
if (!banner) return;
const pending = (game.vehicles || []).find(v => v.verfahrenPending);
if (!pending) { banner.hidden = true; return; }
const target = (game.worldState.locations || []).find(l => l.id === pending.verfahrenTargetLocationId);
const targetName = target ? target.name : pending.verfahrenTargetLocationId;
banner.hidden = false;
banner.innerHTML = pickText({
standard: '🤷 <strong>' + escapeHtml(pending.verfahrenDriverLabel || 'Fahrer*in')
+ '</strong> hat sich verfahren! Bring sie nach <strong>' + escapeHtml(targetName)
+ '</strong> — klick den Ort auf der Karte.',
easy: '🤷 <strong>' + escapeHtml(pending.verfahrenDriverLabel || 'Fahrer*in')
+ '</strong> ist falsch gefahren. Ziel: <strong>' + escapeHtml(targetName)
+ '</strong>. Klick die Stadt auf der Karte.',
});
}
/* ---------- Trip-Historie als Pills (Phase 8e) ---------- */
// Miete-Marker pro Sim-Tag (clientseitig akkumuliert, sobald die Sim
// einen Tagesübergang ueberschreitet). Visualisiert die Fixkosten.
const _rentMarkers = [];
let _lastRentTickDay = 0;
function checkDailyRentMarkers() {
if (!game.simulationStartTime) return;
const cfg = game._config || {};
const perVeh = cfg.rentalCostPerDay || 0;
if (perVeh <= 0) return;
const elapsedH = (new Date(game.simulationTime).getTime() -
new Date(game.simulationStartTime).getTime()) / 3600000;
const currentDayBoundary = Math.floor(elapsedH / 24);
while (_lastRentTickDay < currentDayBoundary) {
_lastRentTickDay++;
const nVeh = (game.vehicles || []).length || 1;
const total = perVeh * nVeh;
_rentMarkers.push({
day: _lastRentTickDay,
amount: total,
vehicles: nVeh,
// Zeitstempel = Tagesgrenze (Start + n*24h)
simTime: new Date(new Date(game.simulationStartTime).getTime() + _lastRentTickDay * 86400000).toISOString(),
});
}
}
function renderTripHistory() {
const strip = document.getElementById('tripHistoryStrip');
const pills = document.getElementById('tripHistoryPills');
const done = game.completedContracts || [];
if (!done.length && !_rentMarkers.length) { strip.hidden = true; return; }
strip.hidden = false;
pills.innerHTML = '';
// Trip-Items + Miete-Items zeitlich mischen
const items = [];
done.forEach((c, i) => items.push({
kind: 'trip', simTime: c.deliveryTime || c.dueTime || game.simulationTime,
data: c, index: i + 1,
}));
_rentMarkers.forEach(m => items.push({ kind: 'rent', simTime: m.simTime, data: m }));
items.sort((a, b) => new Date(a.simTime).getTime() - new Date(b.simTime).getTime());
items.forEach(it => {
if (it.kind === 'trip') {
const c = it.data;
const net = Math.round(c.finalBalance || 0);
const cls = net >= 0 ? 'positive' : 'negative';
const sign = net >= 0 ? '+' : '';
const pill = document.createElement('span');
pill.className = 'lg-trip-pill ' + cls;
pill.textContent = 'Fahrt ' + it.index + ' · ' + sign + fmtEuro(Math.abs(net));
pill.title = (c.code || c.id) + ': ' + _locName(c.originLocationId) + ' → ' + _locName(c.targetLocationId);
pill.addEventListener('click', () => openTripDetail(c, it.index));
pills.appendChild(pill);
} else {
const m = it.data;
const pill = document.createElement('span');
pill.className = 'lg-trip-pill rent';
pill.textContent = 'Miete Tag ' + m.day + ' · ' + fmtEuro(m.amount);
pill.title = 'Tagesmiete für ' + m.vehicles + ' Fahrzeug' + (m.vehicles > 1 ? 'e' : '')
+ ' à ' + fmtEuro((game._config || {}).rentalCostPerDay || 0);
pills.appendChild(pill);
}
});
}
function openTripDetail(c, num) {
const body = document.getElementById('tripDetailBody');
const net = Math.round(c.finalBalance || 0);
const lateH = c.lateHours || 0;
const durH = c.durationHours || 0;
const rows = [
['Fahrt', '#' + num + ' · ' + escapeHtml(c.code || c.id)],
['Strecke', escapeHtml(_locName(c.originLocationId)) + ' → ' + escapeHtml(_locName(c.targetLocationId))],
['Container', c.quantityContainers || 1],
['Status', escapeHtml(c.state || '?')],
['Erlös (Basis)', fmtEuro(c.rewardBase || 0)],
['Netto', (net >= 0 ? '+' : '') + fmtEuro(Math.abs(net))],
['Dauer', durH.toFixed(1) + ' h'],
['Verspätung', lateH > 0 ? lateH.toFixed(1) + ' h' : '—'],
];
body.innerHTML = rows.map(([k, v]) => '<dt>' + escapeHtml(k) + '</dt><dd>' + v + '</dd>').join('');
document.getElementById('tripDetailTitle').textContent = 'Fahrt ' + num + ' · ' + (c.code || c.id);
document.getElementById('tripDetailOverlay').classList.add('open');
}
document.getElementById('tripDetailClose').addEventListener('click', () => {
document.getElementById('tripDetailOverlay').classList.remove('open');
});
document.getElementById('tripDetailOverlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
});
/* ---------- Fahrzeugkauf-Dialog (Phase 8d, ab L2) ---------- */
function openBuyVehicleDialog() {
const overlay = document.getElementById('buyVehicleOverlay');
const listEl = document.getElementById('buyVehicleList');
const hintEl = document.getElementById('buyVehicleHint');
const cfg = game._config || {};
const available = cfg.availableVehicleTypes || ['TRUCK_SMALL'];
const prices = LogistikEngine.VEHICLE_PRICES || {};
const defaults = LogistikEngine.VEHICLE_DEFAULTS || {};
hintEl.textContent = pickText({
standard: 'Kontostand: ' + fmtEuro(game.balance) + '. Wähle ein Fahrzeug — die Kosten werden sofort abgezogen.',
easy: 'Du hast ' + fmtEuro(game.balance) + ' auf dem Konto. Wähle ein Fahrzeug.',
});
// Train ist erst ab Tag 5 (Sim-Stunde 96) mietbar
const elapsedH = game.simulationStartTime
? (new Date(game.simulationTime).getTime() - new Date(game.simulationStartTime).getTime()) / 3600000
: 0;
const trainUnlocked = elapsedH >= 96;
const rows = [
{ mode: 'TRUCK_SMALL', icon: '🚚', name: 'Kleiner LKW', hint: '1 Container · 70 km/h · günstig pro km' },
{ mode: 'TRUCK_MID', icon: '🚛', name: 'Mittlerer LKW', hint: '2 Container · 65 km/h · gute Bündelung' },
{ mode: 'TRUCK_LARGE', icon: '🚛', name: 'Großer LKW', hint: '3 Container · 60 km/h · lohnt bei voller Ladung · Tagesmiete +40 %' },
{ mode: 'TRAIN_SMALL', icon: '🚂', name: 'Güterzug (klein)',
hint: '6 Container · 80 km/h · Spawn in Hamburg · Tagesmiete 3× — nur sinnvoll bei großen Aufträgen!'
+ (trainUnlocked ? '' : ' · 🔒 ab Tag 5 verfügbar') },
{ mode: 'TRAIN', icon: '🚆', name: 'Güterzug', hint: '20 Container · 90 km/h · für L3' },
].filter(r => available.includes(r.mode));
listEl.innerHTML = rows.map(r => {
const price = prices[r.mode] || 0;
const isTrainLocked = (r.mode === 'TRAIN_SMALL' || r.mode === 'TRAIN') && !trainUnlocked;
const canAfford = (game.balance || 0) >= price;
const enabled = canAfford && !isTrainLocked;
const d = defaults[r.mode] || {};
const priceTxt = fmtEuro(price);
const isTrain = (r.mode === 'TRAIN_SMALL' || r.mode === 'TRAIN');
const buyLabel = isTrain ? 'Mieten' : 'Kaufen';
return '<div class="lg-buy-item' + (enabled ? '' : ' disabled') + '">'
+ '<div class="lg-buy-item-icon">' + r.icon + '</div>'
+ '<div class="lg-buy-item-body">'
+ '<b>' + escapeHtml(r.name) + '</b>'
+ '<span>' + escapeHtml(r.hint) + ' · ' + d.costPerKm + '€/km + ' + d.costPerHour + '€/h</span>'
+ '</div>'
+ '<div class="lg-buy-item-price' + (canAfford ? '' : ' unaffordable') + '">' + priceTxt + '</div>'
+ '<button class="ggs-btn-primary" data-mode="' + r.mode + '"' + (enabled ? '' : ' disabled') + '>' + buyLabel + '</button>'
+ '</div>';
}).join('');
// Click-Handler nur auf enabled Buttons
listEl.querySelectorAll('button[data-mode]').forEach(b => {
if (b.disabled) return;
b.addEventListener('click', () => {
const mode = b.getAttribute('data-mode');
const r = LogistikEngine.buyVehicle(game, mode);
if (r.success) {
showToast(pickText({
standard: '🛒 ' + r.vehicle.displayName + ' gekauft.',
easy: 'Neues Fahrzeug gekauft.',
}));
overlay.classList.remove('open');
renderPanels();
updateStatusUI();
} else {
showToast('⚠ ' + r.reason);
}
});
});
overlay.classList.add('open');
}
document.getElementById('buyVehicleClose').addEventListener('click', () => {
document.getElementById('buyVehicleOverlay').classList.remove('open');
});
// Klick auf Overlay-Hintergrund schliesst Dialog
document.getElementById('buyVehicleOverlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) e.currentTarget.classList.remove('open');
});
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 = {
OFFERED: { standard: 'Angebot', easy: 'Angebot' },
EXPIRED: { standard: 'verfallen', easy: 'abgelaufen' },
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) {
// Bevorzugt Roadnet-Dijkstra (entspricht der echten Fahrt). Fallback
// Luftlinie × 1.3, wenn Knoten nicht im Roadnet sind.
const road = (game.worldState && game.worldState.roadnet) || { nodes: [], edges: [] };
const nodeOf = (locId) => (road.nodes || []).find(n => n.locationId === locId);
if (LogistikEngine && LogistikEngine.railShortestPath && road.nodes && road.nodes.length) {
const a = nodeOf(originId), b = nodeOf(targetId);
if (a && b) {
try {
const dij = LogistikEngine.railShortestPath(road, a.id, b.id);
if (dij && isFinite(dij.distanceKm) && dij.distanceKm > 0) return dij.distanceKm;
} catch (e) { /* fallback unten */ }
}
}
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;
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 ---------- */
/** Announce-Modal — 3.5s sichtbar, klickbar zum sofortigen Schließen.
* Für Meilensteine (Auto-Buy, Stufen-Wechsel, Level-Win). Auto-Boost
* pausiert sich nicht; Modal blockiert Eingabe optisch, Sim läuft weiter. */
function announce(icon, title, text, sub) {
const o = document.getElementById('announceOverlay');
if (!o) return;
document.getElementById('announceIcon').textContent = icon || '🎉';
document.getElementById('announceTitle').textContent = title || '';
document.getElementById('announceText').textContent = text || '';
const subEl = document.getElementById('announceSub');
if (sub) { subEl.hidden = false; subEl.textContent = sub; } else { subEl.hidden = true; }
o.classList.add('open');
clearTimeout(announce._t);
announce._t = setTimeout(() => { o.classList.remove('open'); }, 3500);
}
document.getElementById('announceOverlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget || e.target.closest('.lg-announce-modal')) {
e.currentTarget.classList.remove('open');
}
});
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.'),
}));
// Voice: je nach Zielstadt die passende Line. Wenn das Level keine
// Strafen hat (latePenaltyOverride=0), wird "delivered_late" nicht
// gesprochen — sonst würde die Stimme auf L1 von einer Strafe reden,
// die gar nicht abgezogen wird.
const delivered = (game.completedContracts || []).slice(-1)[0];
if (delivered) {
const targetId = delivered.targetLocationId;
const cfgPenForVoice = (game._config || {}).latePenaltyOverride || 0;
const isLate = cfgPenForVoice > 0
&& (delivered.state === 'LATE' || (delivered.finalBalance < 0 && next.net < 0));
if (isLate) speak('delivered_late');
else speak('delivered_at_' + targetId);
}
} 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);
} else if (next.type === 'VEHICLE_PURCHASED') {
// Subtext nur sinnvoll bei L2+ (mehrere Aufträge gleichzeitig).
// Auf L1 (maxActiveContracts=1) wäre "Mehr Aufträge gleichzeitig" falsch.
const cfgPurch = game._config || {};
const isL1Purch = (cfgPurch.maxActiveContracts || 1) === 1;
const subPurch = isL1Purch
? pickText({ standard: 'Du kannst jetzt zwei Touren parallel fahren.', easy: 'Jetzt zwei LKWs.' })
: pickText({ standard: 'Mehr Aufträge gleichzeitig möglich.', easy: 'Du kannst mehr auf einmal machen.' });
announce('🎉', pickText({ standard: 'Neuer LKW!', easy: 'Neuer LKW!' }),
next.messageShort, subPurch);
renderPanels();
// Voice je nach Notification-ID:
// autorent_* = L1 Auto-Rent → second_truck_rented
// rent_train_* = L2 Train-Anmietung → train_rented
// buy_* = manueller Truck-Kauf L2 → kein Audio
if (next.id && next.id.indexOf('autorent_') === 0) speak('second_truck_rented');
else if (next.id && next.id.indexOf('rent_train_') === 0) speak('train_rented');
} else if (next.type === 'LOAD_BONUS_CONSUMED') {
flyBonusToVehicle(next.minutesSaved || 0, next.vehicleId);
showToast(next.messageShort);
} else if (next.type === 'CONTRACT_EXPIRED') {
showToast(next.messageShort);
} else if (next.type === 'REPAIR_COST') {
showToast(next.messageShort);
speak('minigame_touch');
} else if (next.type === 'BREAK_STARTED') {
showToast(next.messageShort);
renderPanels();
speak('mandatory_break_start');
} else if (next.type === 'BREAK_ENDED') {
showToast(next.messageShort);
renderPanels();
} else if (next.type === 'DRIVER_LOST') {
const vname = next.driverLabel || '';
speak(vname.toLowerCase().indexOf('anna') >= 0 ? 'verfahren_anna' : 'verfahren_tom');
} else if (next.type === 'DRIVER_FOUND') {
speak('verfahren_ok');
} else if (next.type === 'DRIVER_WRONG_TIP') {
speak('verfahren_wrong');
}
}
/* ---------- Info-Cards (Phase 8f++) ----------
Beim Uebergang eines Auftrags in CONTRACT_STATE.LOADING (= Fahrzeug
laedt am Pickup) blenden wir fuer 5 Sek eine Card mit Bild der
Zielstadt + Didaktik-Text ein. Atlas-Pattern (Mail 22:00). */
const _shownInfocardForContract = new Set();
function watchContractLoading() {
const contracts = game.activeContracts || [];
contracts.forEach(c => {
if (c.state !== 'LOADING') return;
if (_shownInfocardForContract.has(c.id)) return;
_shownInfocardForContract.add(c.id);
showInfoCard(c);
});
// Aufraeumen: IDs, die nicht mehr aktiv sind, aus dem Set raus
// (damit Wiederverwendung von IDs — unwahrscheinlich, aber sicher — OK ist)
const activeIds = new Set(contracts.map(c => c.id));
for (const id of Array.from(_shownInfocardForContract)) {
if (!activeIds.has(id)) _shownInfocardForContract.delete(id);
}
}
function showInfoCard(contract) {
const locs = (game.worldState && game.worldState.locations) || [];
const dest = locs.find(l => l.id === contract.targetLocationId);
if (!dest) return;
const origin = locs.find(l => l.id === contract.originLocationId);
_renderInfoCard(dest, origin, contract);
}
/** Info-Card beim Marker-Klick: reines Stadt-Portrait ohne Auftragskontext. */
function showCityCard(loc) {
if (!loc) return;
_renderInfoCard(loc, null, null);
}
function _renderInfoCard(dest, origin, contract) {
const container = document.getElementById('lgInfocards');
if (!container) return;
// Max 2 Cards gleichzeitig — aelteste weicht (FIFO).
const existing = container.querySelectorAll('.lg-infocard');
for (let i = 0; i <= existing.length - 2; i++) existing[i].remove();
const card = document.createElement('div');
card.className = 'lg-infocard';
// Bild-Bereich: immer als DIV starten (zeigt Stadtnamen). Wenn das Bild
// erfolgreich laedt, wird's als background-image drauflegt und der Name
// verschwindet. Wenn das Bild 404 ist, bleibt der Name + Farbverlauf.
const imgWrap = document.createElement('div');
imgWrap.className = 'lg-infocard-img';
imgWrap.textContent = dest.name;
const imgBase = (window.LOGISTIK_BASE || '') + 'assets/cities/';
const imgUrl = imgBase + encodeURIComponent(dest.id) + '.webp';
const probe = new Image();
probe.onload = function() {
imgWrap.style.backgroundImage = 'url("' + imgUrl + '")';
imgWrap.style.backgroundSize = 'cover';
imgWrap.style.backgroundPosition = 'center';
imgWrap.textContent = '';
};
probe.src = imgUrl;
const body = document.createElement('div');
body.className = 'lg-infocard-body';
const title = (contract && origin)
? escapeHtml(origin.name) + ' → ' + escapeHtml(dest.name)
: escapeHtml(dest.name);
let statsHtml = '';
if (contract) {
const reward = Math.round(contract.rewardBase || 0);
let dueH = 0;
if (contract.dueTime && game.simulationTime) {
dueH = Math.max(0,
(new Date(contract.dueTime).getTime() - new Date(game.simulationTime).getTime()) / 36e5);
}
statsHtml =
'<div class="lg-infocard-stats">' +
'<span>💰 <b>' + reward + ' €</b></span>' +
(dueH > 0 ? '<span>⏱ <b>' + dueH.toFixed(1) + ' h</b></span>' : '') +
'</div>';
} else {
const typeLabel = {
CAPITAL: 'Hauptstadt', CITY: 'Stadt', PORT: 'Hafen',
TERMINAL: 'Terminal', HUB: 'Knoten',
}[dest.type] || dest.type;
statsHtml = '<div class="lg-infocard-stats">' +
'<span><b>' + escapeHtml(typeLabel) + '</b></span>' +
(dest.countryId ? '<span>' + escapeHtml(dest.countryId) + '</span>' : '') +
'</div>';
}
body.innerHTML =
'<h3>' + title + '</h3>' +
(dest.didacticInfo ? '<p>' + escapeHtml(dest.didacticInfo) + '</p>' : '') +
statsHtml;
card.appendChild(imgWrap);
card.appendChild(body);
card.addEventListener('click', () => card.remove());
container.appendChild(card);
setTimeout(() => { if (card.parentNode) card.remove(); }, 10500);
}
/** Stufen-Wechsel-Announce: wenn sich L1-Stufe aendert, Gratulation. */
let lastAnnouncedStufe = null;
function checkStufeChange() {
const cfg = game._config || {};
if ((cfg.maxActiveContracts || 1) !== 1) return; // nur L1
if (!game.progression) return;
const round = game.progression.round || 1;
const stufen = [
null,
{ n: 1, name: 'Erster Auftrag', minRound: 1 },
{ n: 2, name: 'Mehrere zur Wahl', minRound: 2 },
{ n: 3, name: 'Weitere Strecken', minRound: 4 },
{ n: 4, name: 'Mehr Fahrzeuge', minRound: 7 },
{ n: 5, name: 'Soft-Ramp → L2', minRound: 11 },
];
let stufe = stufen[1];
for (let i = 1; i < stufen.length; i++) {
if (stufen[i] && round >= stufen[i].minRound) stufe = stufen[i];
}
if (lastAnnouncedStufe === null) { lastAnnouncedStufe = stufe.n; return; }
if (stufe.n > lastAnnouncedStufe) {
announce('🎯', pickText({ standard: 'Neue Stufe erreicht!', easy: 'Du bist weiter!' }),
'Stufe ' + stufe.n + ': ' + stufe.name,
pickText({
standard: 'Das Auftragsangebot wird größer und die Strecken länger.',
easy: 'Mehr Aufträge und weitere Orte.',
}));
lastAnnouncedStufe = stufe.n;
}
}
/* ---------- 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: 3 Modi (Pause / Auto / Turbo) ----------
- Pause: alles steht (timeScale 0)
- Auto: Sim-Tempo wird automatisch bestimmt — 1× bei Entscheidung /
Verfahren, 8× bei reiner Fahrt. Label zeigt aktuellen Wert.
- Turbo: 16× (Entwickler-Knopf, kommt spaeter raus) */
const speedBtns = document.querySelectorAll('#speedCtrl .ggs-speed-btn');
function setActiveSpeedBtn(btn) {
speedBtns.forEach(b => b.classList.toggle('active', b === btn));
}
let playerMode = 'auto'; // 'pause' | 'auto' | 'fixed8' | 'turbo'
speedBtns.forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.getAttribute('data-mode');
// Auto/Turbo startet Sim aus PLANNING/READY
if (mode !== 'pause' && (game.state === LogistikEngine.STATE.PLANNING ||
game.state === LogistikEngine.STATE.READY)) {
if (game.state === LogistikEngine.STATE.READY) LogistikEngine.startPlanning(game);
LogistikEngine.startSimulation(game);
}
playerMode = mode;
// Aktivzeit-Buchhaltung fürs Lehrer-Cockpit bei Pause einfrieren
// (sonst läuft die „Seit"-Zeit weiter, obwohl das Spiel steht).
try { if (window.__GGS_LIVE__) window.__GGS_LIVE__.setPaused(mode === 'pause'); } catch (_) {}
setActiveSpeedBtn(btn);
updateStatusUI();
});
});
/** Effektive TimeScale nach Modus:
* pause → 0
* turbo → 16 (Dev-Flag)
* auto → Verfahren: 1×, reine Fahrt (alle Fahrzeuge in tripPhase): 8×,
* sonst 1× (Entscheidungsfenster) */
function effectiveTimeScale() {
if (playerMode === 'pause') return 0;
if (playerMode === 'turbo') return 16;
if (playerMode === 'fixed8') return 8;
const vehicles = game.vehicles || [];
if (vehicles.some(v => v.verfahrenPending)) return 1;
if (vehicles.length && vehicles.every(v => v.tripPhase)) return 8;
return 1;
}
function updateAutoBoost() {
const autoLabel = document.getElementById('autoModeLabel');
if (game.state !== LogistikEngine.STATE.RUNNING) {
if (autoLabel) autoLabel.textContent = 'Auto';
return;
}
const target = effectiveTimeScale();
if (target !== game.timeScale) {
LogistikEngine.setTimeScale(game, target);
}
// Label des Auto-Buttons zeigt den aktuell wirksamen Faktor im Auto-Modus.
// In Pause/Turbo zeigt es nur „Auto" (der andere Button hat die Info).
if (autoLabel) {
autoLabel.textContent = (playerMode === 'auto') ? ('Auto ' + target + '×') : 'Auto';
}
}
/* Lehrplan-Link entfernt (Ziel-Seite pages/modul-logistik.php existiert nicht) */
/* ---------- Level-Badge ---------- */
const levelBadge = document.getElementById('levelBadge');
if (levelBadge) levelBadge.textContent = 'Level ' + levelNum;
/* ---------- Level-Splash (Phase 8f++) ----------
Einmaliger Intro-Screen pro Level: Bild + Was / Wo / Ziel. Die
Simulation startet nach Click auf „Los geht's". */
const SPLASH_CONTENT = {
2: {
title: 'Level 2 — Wachstum',
subtitle: 'Du hast einen zweiten Lieferwagen bekommen. Mehr Aufträge, mehr Auswahl, sechs Tage Zeit.',
img: 'level-2.png',
sections: [
{
icon: '🚚',
title: 'Was?',
text: 'Du startest mit zwei Fahrzeugen: einem kleinen Lieferwagen (1 Container) in Wien und einem mittleren (2 Container) in München. Manche Aufträge brauchen 2 Container — die kann nur der mittlere Lieferwagen alleine nehmen, oder du belädst ihn mit zwei kleinen Aufträgen vom selben Pickup-Ort.',
},
{
icon: '🗺️',
title: 'Wo?',
text: 'Wien, München, Innsbruck, Salzburg, Graz, Mailand, Berlin. Längere Strecken sind möglich — und können sich richtig lohnen.',
},
{
icon: '🏁',
title: 'Ziel',
text: '6 Tage durchhalten. Pleite gehen heißt verloren. Endbilanz bestimmt deine Sterne (1 bis 5). Tagesmiete: 400 € pro Fahrzeug. Weitere Fahrzeuge kannst du jederzeit über den 🛒-Button im Fahrzeug-Panel kaufen.',
},
{
icon: '🚂',
title: 'Tipp — Der Güterzug',
text: 'Ab dem 5. Tag kannst du einen kleinen Güterzug dazumieten (6 Container Kapazität). Vorsicht: er ist nur dann profitabel, wenn er auch volle Aufträge bekommt. Sonst frisst die Tagesmiete den Gewinn.',
},
],
},
1: {
title: 'Level 1 — Einstieg in die Spedition',
subtitle: 'Deine erste Woche als Disponent*in eines kleinen Logistik­unternehmens.',
img: 'level-1.png',
sections: [
{
icon: '📦',
title: 'Was?',
text: 'Du arbeitest mit einem gemieteten Lieferwagen, der in Wien bereitsteht. Klicke einen offenen Auftrag an — dein Fahrzeug wird automatisch beladen und fährt los. Jeder erledigte Auftrag bringt Geld aufs Konto. Ab 10 000 € Konto kommt automatisch ein zweiter Lieferwagen dazu — die Tagesmiete verdoppelt sich dann.',
},
{
icon: '🗺️',
title: 'Wo?',
text: 'Mitteleuropa: Wien, Salzburg, München, Innsbruck, Graz. In den späteren Runden kommen Mailand und Berlin dazu. Deine Fahrzeuge nutzen echte Autobahn-Strecken auf der Karte.',
},
{
icon: '🏁',
title: 'Ziel',
text: '4 Tage durchhalten. Wer pleite geht (Konto auf 0 € oder weniger), scheidet aus. Sonst entscheidet die Endbilanz: je höher, desto mehr Sterne (1 bis 5). Achtung: die Tages­miete von 500 € pro Fahrzeug läuft auch dann, wenn der Lieferwagen steht.',
},
{
icon: '🎮',
title: 'Tipp — Minispiel',
text: 'Über den 🎮-Button kannst du das Einpark-Minispiel starten. Wer schnell und sauber andockt, bekommt eine Bonus-Pill oben rechts: sie verkürzt die nächste Ladezeit um bis zu 45 min. Touchierst du dabei einen anderen LKW oder die Rampe, kostet das 1 000 € Reparatur und der Bonus entfällt.',
},
],
},
// Stufen 2 und 3: Bilder + Texte folgen
};
function showLevelSplash(lvl) {
const c = SPLASH_CONTENT[lvl];
const overlay = document.getElementById('levelSplashOverlay');
if (!c || !overlay) return;
const img = document.getElementById('lgSplashImg');
if (img) {
img.src = (window.LOGISTIK_BASE || '') + 'assets/splash/' + c.img;
img.alt = c.title;
}
document.getElementById('lgSplashTitle').textContent = c.title;
document.getElementById('lgSplashSubtitle').textContent = c.subtitle || '';
const sec = document.getElementById('lgSplashSections');
sec.innerHTML = '';
(c.sections || []).forEach(s => {
const el = document.createElement('div');
el.className = 'lg-splash-section';
el.innerHTML =
'<div class="lg-splash-section-icon">' + s.icon + '</div>' +
'<div class="lg-splash-section-body">' +
'<h3>' + escapeHtml(s.title) + '</h3>' +
'<p>' + escapeHtml(s.text) + '</p>' +
'</div>';
sec.appendChild(el);
});
overlay.classList.add('open');
}
document.getElementById('lgSplashStart').addEventListener('click', () => {
document.getElementById('levelSplashOverlay').classList.remove('open');
});
// Splash einmal beim Level-Start anzeigen (nur wenn Content vorhanden)
showLevelSplash(levelNum);
/* ---------- Voice-Pipeline (Phase 8f++) ----------
Sequentielle Audio-Wiedergabe via OpenAI-TTS-generierten MP3s.
Eine Queue — nie zwei gleichzeitig. Easy-Language nutzt andere Dateien.
Toggle 🔊/🔇 im Header, Default: an. */
const VOICE_BASE = (window.LOGISTIK_BASE || '') + 'assets/audio/voice/de/';
const voiceQueue = [];
let voicePlaying = false;
let voiceAudio = null;
let voiceEnabled = (typeof localStorage !== 'undefined')
? (localStorage.getItem('lg_voice_off') !== '1')
: true;
// Music-Auto-Start nach Welcome: gemerkter Key wird gespielt, sobald
// die Voice-Queue (zum ersten Mal) leerlaeuft.
let _pendingMusicStart = null;
function speak(lineId, opts) {
if (!voiceEnabled || !lineId) return;
const easy = !!window.STUDENT_EASY;
const file = VOICE_BASE + lineId + (easy ? '_easy' : '') + '.mp3';
const wasIdle = (voiceQueue.length === 0 && !voicePlaying);
voiceQueue.push({ file, onStart: opts && opts.onStart, onEnd: opts && opts.onEnd });
if (wasIdle && typeof window.lgAmbientDuck === 'function') {
window.lgAmbientDuck();
}
_voiceNext();
}
function _voiceNext() {
if (voicePlaying) return;
if (!voiceQueue.length) {
// Queue leer: Musik wieder hochfaden ODER (falls ein Auto-Start
// gemerkt wurde) erst Musik starten und sofort auf voller Lautstaerke.
if (_pendingMusicStart && typeof window.lgAmbientStart === 'function') {
window.lgAmbientStart(_pendingMusicStart);
_pendingMusicStart = null;
}
if (typeof window.lgAmbientUnduck === 'function') window.lgAmbientUnduck();
return;
}
const item = voiceQueue.shift();
voicePlaying = true;
voiceAudio = new Audio(item.file);
voiceAudio.volume = 0.9;
if (item.onStart) { try { item.onStart(); } catch (_) {} }
const done = () => {
voicePlaying = false; voiceAudio = null;
if (item.onEnd) { try { item.onEnd(); } catch (_) {} }
_voiceNext();
};
voiceAudio.addEventListener('ended', done);
voiceAudio.addEventListener('error', done);
voiceAudio.play().catch(done);
}
function voiceStop() {
voiceQueue.length = 0;
if (voiceAudio) { try { voiceAudio.pause(); } catch (_) {} voiceAudio = null; }
voicePlaying = false;
if (typeof window.lgAmbientUnduck === 'function') window.lgAmbientUnduck();
}
/* Voice-Toggle im Header */
const voiceBtn = document.getElementById('btnVoiceToggle');
function renderVoiceBtn() {
if (voiceBtn) voiceBtn.textContent = voiceEnabled ? '🔊' : '🔇';
}
if (voiceBtn) {
voiceBtn.addEventListener('click', () => {
voiceEnabled = !voiceEnabled;
if (!voiceEnabled) voiceStop();
try { localStorage.setItem('lg_voice_off', voiceEnabled ? '0' : '1'); } catch (_) {}
renderVoiceBtn();
});
renderVoiceBtn();
}
// Welcome-Line + Auto-Start Country-Playlist nach dem Splash-Klick.
// Nach Ende der Welcome-Voice (Queue leer) startet die gespeicherte
// Playlist via lgAmbientStart automatisch.
const splashBtnVoice = document.getElementById('lgSplashStart');
if (splashBtnVoice) {
splashBtnVoice.addEventListener('click', () => {
_pendingMusicStart = 'country';
speak('welcome');
});
}
/* Minispiel-Hinweis: bei der ersten Tour erstmals, danach alle 6 Touren
erneut. Während der Sprachausgabe blinkt der 🎮-Button im Header. */
let _toursStartedSoFar = 0;
function maybeMinigameHint() {
_toursStartedSoFar++;
const fire = (_toursStartedSoFar === 1) || (_toursStartedSoFar % 6 === 0);
if (!fire) return;
const btn = document.getElementById('btnMinigame');
speak('minigame_hint', {
onStart: () => { if (btn) btn.classList.add('lg-blink'); },
onEnd: () => { if (btn) btn.classList.remove('lg-blink'); },
});
}
/* Tag-Meilenstein-Watcher: pro Sim-Tag-Ende einmal feuern. Plus
"Halbzeit" (50% des Limits) und "letzter Tag" (Limit-24h). Generisch
fuer L1 (4 Tage) und L2 (6 Tage). */
const _spokenMilestones = {
day1: false, day2: false, day3: false, day4: false, day5: false,
halftime: false, lastDay: false, lastHour: false, balanceLow: false,
};
function checkTimeMilestones() {
if (!game.simulationStartTime || !game.simulationTime) return;
const elapsedH = (new Date(game.simulationTime).getTime() -
new Date(game.simulationStartTime).getTime()) / 3600000;
const limitH = (game._config || {}).timeLimitHours || 96;
const totalDays = Math.round(limitH / 24);
if (!_spokenMilestones.day1 && elapsedH >= 24) { _spokenMilestones.day1 = true; speak('day_1_end'); }
if (!_spokenMilestones.day2 && elapsedH >= 48) { _spokenMilestones.day2 = true; speak('day_2_end'); }
if (!_spokenMilestones.day3 && elapsedH >= 72) { _spokenMilestones.day3 = true; speak('day_3_end'); }
if (totalDays >= 5 && !_spokenMilestones.day4 && elapsedH >= 96) { _spokenMilestones.day4 = true; speak('day_4_end'); }
if (totalDays >= 6 && !_spokenMilestones.day5 && elapsedH >= 120) { _spokenMilestones.day5 = true; speak('day_5_end'); }
// Halbzeit: nur wenn vom Tagesende-Trigger nicht bereits abgedeckt
const halfH = limitH / 2;
if (totalDays !== 4 && !_spokenMilestones.halftime
&& elapsedH >= halfH && elapsedH < halfH + 6) {
_spokenMilestones.halftime = true; speak('halftime');
}
// Letzter Tag (24h vor Schluss). Spielt auf jedem Level — auf L1 (4 Tage)
// läuft "Tag drei ist um." + "Nur noch ein Tag." sequentiell ueber die
// Speak-Queue, das ist die deutlichere Warnung als nur day_3_end allein.
if (!_spokenMilestones.lastDay && elapsedH >= limitH - 24) {
_spokenMilestones.lastDay = true; speak('last_day');
}
if (!_spokenMilestones.lastHour && elapsedH >= limitH - 1) {
_spokenMilestones.lastHour = true; speak('last_hour');
}
// Konto-Knapp-Warner: einmalig, wenn Balance unter 25% des Startbudgets faellt.
// Bankrott (<= 0) ist eigene Game-Over-Voice; hier nur Vorwarnung.
const cfgBL = game._config || {};
const startBudget = cfgBL.startBudget || 8000;
if (!_spokenMilestones.balanceLow
&& (game.balance || 0) > 0
&& (game.balance || 0) < startBudget * 0.25) {
_spokenMilestones.balanceLow = true; speak('balance_low');
}
}
/* Save/Load entfernt (Thomas 2026-04-25). Auto-Save mit Resume steht
auf der ToDo-Liste — siehe project_logistik_state.md */
/* ---------- 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
let lastDeadlineRefresh = 0; // Frist-Balken alle 500ms tickend
let lastAssessmentBeat = 0; // Integrations-Vertrag §6.3: running-Heartbeat 30s
function loop(now) {
const dt = now - lastTime;
lastTime = now;
LogistikEngine.tick(game, dt);
// Auto-Boost bei reiner Fahrtzeit (8× wenn alle Fahrzeuge busy, kein Verfahren)
updateAutoBoost();
// Status-Leiste nur alle 250ms (flackert sonst bei hoher Simulationsrate)
if (now - lastStatusRender > 250) {
updateStatusUI();
lastStatusRender = now;
}
syncRouteLines();
updateVehicleMarkers();
drainNotifications();
checkStufeChange();
watchContractLoading();
checkTimeMilestones();
checkAutobahnGameTrigger();
if (now - lastDeadlineRefresh > 500) {
updateDeadlineBars();
lastDeadlineRefresh = now;
}
const prevRentDay = _lastRentTickDay;
checkDailyRentMarkers();
if (_lastRentTickDay !== prevRentDay) renderTripHistory();
if (now - lastAnalyticsDrain > 2000) {
drainAnalyticsQueue();
lastAnalyticsDrain = now;
}
// Assessment-Heartbeat alle 30s (Integrations-Vertrag §6.3)
if (now - lastAssessmentBeat > 30000 && !game.state.startsWith('LEVEL_')) {
const a = (typeof computeAnalytics === 'function') ? computeAnalytics() : {};
LG_API.postAssessment('running', {
durationMs: Math.round(now - sessionStartMs),
completedPhases: ['started', 'running'],
kennzahlen: {
level: levelNum,
balance: Math.round(game.balance || 0),
completed: a.completed || 0,
active: a.active || 0,
lateRatio: a.lateRatio || '',
emptyRatio: a.emptyRatio || '',
round: (game.progression && game.progression.round) || 1,
},
});
lastAssessmentBeat = now;
}
// Panels nur neu rendern, wenn sich Vehicle/Contract-States geändert haben
// (inkl. Verfahren-Pending).
const sig = (game.vehicles || []).map(v => v.id + ':' + v.state + ':' + (v.verfahrenPending ? 'lost' : 'ok')).join('|') + '#' +
(game.activeContracts || []).map(c => c.id + ':' + c.state).join('|') + '#' +
(game.completedContracts || []).length + '#' +
(game.pendingBonuses || []).length;
if (sig !== lastVehicleStateSig) {
lastVehicleStateSig = sig;
renderPanels();
}
renderEventPanel();
syncEventMarkers();
const analyticsEl = document.getElementById('analyticsPanel');
if (analyticsEl && !analyticsEl.hidden && typeof renderAnalyticsPanel === 'function'
&& now - lastAnalyticsRender > 1000) {
renderAnalyticsPanel();
lastAnalyticsRender = now;
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
// Initial-Update + Auto-Bereit auf PLANNING
LogistikEngine.startPlanning(game); // READY → PLANNING
updateStatusUI();
renderPanels();
updateVehicleMarkers();
/* ---------- Helpers ---------- */
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
console.info(
'[Logistik Phase 1] Engine bereit. ' +
locations.length + ' Locations geladen, ' +
railnet.edges.length + ' Bahnkanten. State: ' + game.state
);
/* =================================================================
Minigame: An-die-Rampe-Einparken (Phase 7-UX — Top-Down mit Bicycle-Lenkung)
----------------------------------------------------------------
Physik: Bicycle-Model mit Lenkwinkel, Vor- und Rückwärtsfahrt,
Rückwärts-Lenkinversion (wie in echt). Ziel: LKW-Heck in die
Andock-Bucht am rechten Rand.
================================================================= */
const btnMinigame = document.getElementById('btnMinigame');
// LKW-Einpark-Minigame ist auf allen Levels verfügbar (Rampe gehört zum Job)
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 (Thomas 2026-04-25): GANZER Truck muss drin liegen.
// Truck 60×22 → Zone 76×34 (≈ +8 px je Seite Toleranz). Truck-Mitte
// muss in [412..428] × [144..156] landen (16 × 12 px Sweet-Spot).
greenZone: { x: 382, y: 133, w: 76, h: 34 },
// 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,
_touched: false, // gesetzt bei erster Kollision (1000 €-Strafe, kein Park-Bonus)
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 / Active Mover / Bucht-Wand → Touchiert
// (1000 € Reparatur, Minispiel läuft weiter, kein Game Over). 1-s-Cooldown
// verhindert wiederholtes Abbuchen pro Frame bei anhaltendem Kontakt.
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) {
mgTouch('parked', s, o);
break;
}
}
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) {
mgTouch('mover', s, { x: m.x - mhx, y: m.y - mhy, w: m.w, h: m.h });
}
}
// Bucht-Wand-Kollision: bei hoher Geschwindigkeit UND tiefem Eindringen
// gilt es als Touchieren (1000 €). Sanftes Einfahren / leichtes Schrammen
// bleibt ohne Strafe.
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) {
mgTouch('bay', s, null);
}
// 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;
// Strenger Check (Thomas 2026-04-25): der GANZE Truck muss in der
// gruenen Zone liegen, nicht nur die Mitte. Bei korrekter Andock-
// Orientierung (~180°) ist die Truck-AABB axisaligned, daher reicht
// ein Eckenpruefer.
const hx = MG.truckW / 2, hy = MG.truckH / 2;
const tL = s.x - hx, tR = s.x + hx;
const tT = s.y - hy, tB = s.y + hy;
const inGreen = tL >= g.x && tR <= g.x + g.w
&& tT >= g.y && tB <= 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) < 10;
const rearCorrect = angleDev < 9;
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 });
setTimeout(() => { if (typeof mgCloseOverlay === 'function') mgCloseOverlay(); }, 3000);
}
/** Game Over: nur noch bei Zeitablauf (Kollisionen: siehe mgTouch). */
function mgGameOver(reason) {
const s = mgState; if (!s || s.docked) return;
s.docked = true;
if (mgEngineGain && mgAudioCtx) {
mgEngineGain.gain.setTargetAtTime(0, mgAudioCtx.currentTime, 0.1);
}
const factor = 1.5;
const msgStd = '⏰ Zeit abgelaufen! Ein Kollege übernimmt — maximale Ladezeit (150 %).';
const msgEasy = 'Zeit vorbei. Jemand anderes macht das. 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 });
setTimeout(() => { if (typeof mgCloseOverlay === 'function') mgCloseOverlay(); }, 3000);
}
/** Touchiert: Kollision mit geparktem LKW, Active-Mover oder Bucht-Wand.
* → Einmal pro Minispiel-Sitzung 1000 € Reparatur. Weitere Touches
* sind nur Rebound + Sound. Wer touchiert hat, bekommt keinen Park-Bonus
* (siehe mgApplyResult-Check auf s._touched). */
function mgTouch(kind, s, obstacle) {
if (!s || s.docked) return;
const alreadyTouched = !!s._touched;
// Truck abstoßen, damit er nicht im Obstacle stecken bleibt.
if (obstacle) mgReboundFrom(s, obstacle);
s.v *= -0.4;
mgAudioCrash();
if (alreadyTouched) return; // Cap: max 1× pro Einparken
s._touched = true;
// Strafe einmalig abbuchen + Notification ueber Engine
game.balance = (game.balance || 0) - 1000;
if (!game.pendingNotifications) game.pendingNotifications = [];
const label = kind === 'bay' ? 'Rampen-Wand'
: kind === 'mover' ? 'fahrendem Fahrzeug'
: 'geparktem LKW';
game.pendingNotifications.push({
id: 'touch_' + performance.now(),
type: 'REPAIR_COST',
messageShort: '💥 Touchiert (' + label + ') — 1000 € Reparatur, kein Park-Bonus',
simTime: game.simulationTime,
});
mgResult.className = 'lg-minigame-result fail';
mgResult.textContent = pickText({
standard: '💥 Touchiert — 1000 € Reparatur. Kein Park-Bonus. Weiter einparken!',
easy: 'Au! Berührt. Kostet 1000 Euro. Kein Bonus. Weiter.',
});
mgResult.hidden = false;
setTimeout(() => { if (mgState && !mgState.docked) mgResult.hidden = true; }, 2500);
if (typeof updateStatusUI === 'function') updateStatusUI();
}
/** Stoesst den Truck von einem Obstacle-AABB weg (kleinster Achsen-Overlap). */
function mgReboundFrom(s, o) {
const hx = MG.truckW / 2, hy = MG.truckH / 2;
const oCx = o.x + o.w / 2, oCy = o.y + o.h / 2;
const dx = s.x - oCx, dy = s.y - oCy;
const overlapX = (hx + o.w / 2) - Math.abs(dx);
const overlapY = (hy + o.h / 2) - Math.abs(dy);
if (overlapX < 0 || overlapY < 0) return;
if (overlapX < overlapY) s.x += Math.sign(dx || 1) * (overlapX + 1);
else s.y += Math.sign(dy || 1) * (overlapY + 1);
}
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); }
// Phase 8e++: Ladebonus-Pill als Stapel-Item. Zeit-basiert, je kuerzer desto mehr Bonus.
if (info.success) {
const t = info.minigameTimeSec || 999;
// Park-Bonus entfaellt, wenn die Fahrt jemanden touchiert hat
// (Thomas-Regel: Reparatur ist gezahlt, aber kein extra Bonus).
const touched = !!(mgState && mgState._touched);
let bonusMin = 0;
if (!touched && t < 15) bonusMin = 45;
else if (!touched && t < 30) bonusMin = 20;
else if (!touched && t < 60) bonusMin = 5;
if (bonusMin > 0) {
try {
LogistikEngine.pushLoadBonus(game, bonusMin, 'Einparken ' + t.toFixed(1) + 's');
renderBonusPills();
celebrateParkingSuccess(bonusMin, t);
speak('minigame_perfect');
} catch (e) { /* ignore */ }
}
} else if (info.reason === 'timeout') {
speak('minigame_timeout');
}
}
/** Park-Celebration (Phase 8f++): grosse Info-Karte zeigt Bonus, haelt
* kurz, fliegt dann nach rechts oben zur Bonus-Pill und schrumpft dort.
* Reines UI-Feedback — keine State-Aenderung. */
function celebrateParkingSuccess(bonusMin, timeSec) {
const pillContainer = document.getElementById('bonusPills');
if (!pillContainer) return;
const wrap = document.createElement('div');
wrap.className = 'lg-park-celebrate-wrap';
const card = document.createElement('div');
card.className = 'lg-park-celebrate';
card.innerHTML =
'<div class="lg-park-celebrate-icon">🏆</div>' +
'<div class="lg-park-celebrate-title">Perfekt eingeparkt!</div>' +
'<div class="lg-park-celebrate-sub">+' + bonusMin + ' min Ladezeit-Bonus · ' + timeSec.toFixed(1) + ' s</div>';
wrap.appendChild(card);
document.body.appendChild(wrap);
// Fluglogik: nach 1200 ms Richtung Pill-Container translatieren + schrumpfen
setTimeout(() => {
const pillRect = pillContainer.getBoundingClientRect();
const cardRect = card.getBoundingClientRect();
const dx = (pillRect.left + 30) - (cardRect.left + cardRect.width / 2);
const dy = (pillRect.top + 14) - (cardRect.top + cardRect.height / 2);
card.style.transition = 'transform .55s cubic-bezier(.4,0,.2,1), opacity .55s';
card.style.transform = 'translate(' + dx + 'px,' + dy + 'px) scale(0.18)';
card.style.opacity = '0';
}, 1200);
setTimeout(() => { if (wrap.parentNode) wrap.remove(); }, 1850);
}
/* ============================================================
Autobahn-Abfahrt-Game (Phase 9, ab L2)
Trigger: LKW im *_drive nahe einem der NODES → Modal öffnet,
Sim pausiert, Modul rennt, onEnd-Result mapped auf Bonus/Strafe/Crash.
============================================================ */
const autobahnTriggered = new Set(); // pro vehicleId+routeId nur 1x
let autobahnGameOpen = false;
let autobahnSavedMode = null;
let autobahnCurrentVehicleId = null;
let autobahnLastEndedAt = 0; // Echtzeit-ms; Cooldown 5 min zwischen Spielen
const AUTOBAHN_COOLDOWN_MS = 5 * 60 * 1000;
function _haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const s = Math.sin(dLat/2)**2 +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2)**2;
return 2 * R * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
}
function checkAutobahnGameTrigger() {
if (autobahnGameOpen) return;
if (!window.lgAutobahnGame || !window.lgAutobahnGame.NODES) return;
const cfg = game._config || {};
// Nur L2 (railEnabled, !intermodalEnabled). L1 nicht, L3 nicht (haette
// intermodal). Filter konsistent mit anderem L2-only Code.
if (!cfg.railEnabled || cfg.intermodalEnabled) return;
// Cooldown: max alle 5 Minuten Echtzeit
if (Date.now() - autobahnLastEndedAt < AUTOBAHN_COOLDOWN_MS) return;
const NODES = window.lgAutobahnGame.NODES;
for (const v of (game.vehicles || [])) {
if (!v.tripPhase) continue;
// Nur in Fahr-Phasen (nicht beim Laden/Entladen)
if (v.tripPhase !== 'pickup_drive' && v.tripPhase !== 'delivery_drive'
&& v.tripPhase !== 'tour_driving') continue;
if (typeof v.lat !== 'number' || typeof v.lon !== 'number') continue;
const triggerKey = v.id + ':' + (v.activeRouteId || 'none');
if (autobahnTriggered.has(triggerKey)) continue;
for (const scenarioKey in NODES) {
const node = NODES[scenarioKey];
const distKm = _haversineKm(v.lat, v.lon, node.lat, node.lon);
if (distKm < 8) {
autobahnTriggered.add(triggerKey);
openAutobahnGame(scenarioKey, v);
return;
}
}
}
}
function openAutobahnGame(scenarioKey, vehicle) {
autobahnGameOpen = true;
autobahnCurrentVehicleId = vehicle.id;
// Sim hart pausieren: paused-Flag friert tick() komplett ein
// (playerMode='pause' allein reicht nicht — siehe Bug in
// convertRealTimeToGameMinutes mit `timeScale || 1`).
autobahnSavedMode = playerMode;
playerMode = 'pause';
try { LogistikEngine.pause(game); } catch (e) {}
// Musik ducken, falls noch nicht gepausiert
if (typeof window.lgAmbientDuck === 'function') window.lgAmbientDuck();
const overlay = document.getElementById('lgAutobahnOverlay');
const container = document.getElementById('lgAutobahnContainer');
container.innerHTML = '';
overlay.hidden = false;
try {
window.lgAutobahnGame.start({
container: container,
scenario: scenarioKey,
driverName: vehicle.driverName || 'Fahrer*in',
truckLabel: vehicle.vehicleTypeLabel || vehicle.displayName,
easyMode: !!window.STUDENT_EASY,
onEnd: (result) => {
handleAutobahnGameResult(result, vehicle.id);
},
});
} catch (err) {
console.error('[Logistik] Autobahn-Game Fehler:', err);
// Bei Fehler: Spiel überspringen ohne Konsequenzen
handleAutobahnGameResult({ outcome: 'success', scenario: scenarioKey }, vehicle.id);
}
}
function handleAutobahnGameResult(result, vehicleId) {
const overlay = document.getElementById('lgAutobahnOverlay');
const container = document.getElementById('lgAutobahnContainer');
overlay.hidden = true;
container.innerHTML = '';
autobahnGameOpen = false;
autobahnLastEndedAt = Date.now();
// Sim wieder aktivieren mit altem Mode
if (autobahnSavedMode) {
playerMode = autobahnSavedMode;
autobahnSavedMode = null;
}
try { LogistikEngine.resume(game); } catch (e) {}
if (typeof window.lgAmbientUnduck === 'function') window.lgAmbientUnduck();
const v = (game.vehicles || []).find(x => x.id === vehicleId);
const vName = v ? (v.driverName || v.displayName) : 'Fahrer*in';
if (result.outcome === 'success') {
try { LogistikEngine.pushLoadBonus(game, 30, 'Autobahn-Abfahrt sauber'); } catch (e) {}
renderBonusPills();
showToast(pickText({
standard: '🛣 ' + vName + ' hat die Abfahrt sauber genommen — +30 min Bonus.',
easy: vName + ' richtig gefahren. Bonus!',
}));
speak('autobahn_success');
} else if (result.outcome === 'late') {
// Strafzeit: Restfahrt verlängern
if (v && v.activeRouteId) {
const route = (game.activeRoutes || []).find(r => r.id === v.activeRouteId);
if (route) route.totalDurationMinutes = (route.totalDurationMinutes || 0) + 30;
}
showToast(pickText({
standard: '⚠ ' + vName + ' hat die Abfahrt verpasst — +30 min Verzögerung.',
easy: vName + ' falsch gefahren. Dauert länger.',
}));
speak('autobahn_late');
} else if (result.outcome === 'crash') {
// Vehicle 24h ausgefallen + Reparatur
if (v) {
const repairUntilMs = new Date(game.simulationTime).getTime() + 24 * 3600000;
v.repairUntil = new Date(repairUntilMs).toISOString();
v.tripPhase = null;
v.state = 'IDLE';
v.activeRouteId = null;
v.segmentProgress = 0;
// Geladene Aufträge zurück auf OPEN
(v.loadedContractIds || []).forEach(cid => {
const c = (game.activeContracts || []).find(x => x.id === cid);
if (c) { c.state = 'OPEN'; c.reservedByVehicleId = null; }
});
v.loadedContractIds = [];
// Aktive Route entfernen
game.activeRoutes = (game.activeRoutes || [])
.filter(r => !(v.activeRouteId && r.id === v.activeRouteId));
}
game.balance = (game.balance || 0) - 2000;
showToast(pickText({
standard: '💥 Unfall! ' + vName + ' fährt heute nicht mehr — 2 000 € Reparatur.',
easy: 'Unfall! ' + vName + ' kaputt. 2 000 Euro Reparatur.',
}));
speak('autobahn_crash');
}
renderPanels();
updateStatusUI();
}
/* 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');
/* Virtueller Joystick (Thomas 2026-04-29): ein Finger steuert alle 4 Achsen
* gleichzeitig. iPad-Multitouch-Buttons hatten Probleme bei diagonaler
* Eingabe. Joystick mappt (dx, dy) vom Mittelpunkt auf mgKeys-Flags. */
(function setupJoystick() {
const stick = document.getElementById('mgJoystick');
const knob = document.getElementById('mgJoyKnob');
if (!stick || !knob) return;
let active = false;
let activePointerId = null;
const DEAD_ZONE_FRAC = 0.18; // 18% des Radius = neutrale Zone
const KNOB_MAX_OFFSET = 0.7; // Knopf bewegt sich max bis 70% des Radius
const AXIS_THRESHOLD = 0.32; // ab 32% Auslenkung gilt Achse als "gedrueckt"
function applyDelta(dx, dy) {
const rect = stick.getBoundingClientRect();
const radius = rect.width / 2;
const dist = Math.hypot(dx, dy);
const norm = Math.min(1, dist / radius);
// Knopf-Position visuell
let kx = dx, ky = dy;
if (norm > KNOB_MAX_OFFSET) {
const scale = (radius * KNOB_MAX_OFFSET) / dist;
kx = dx * scale; ky = dy * scale;
}
knob.style.transform = 'translate(' + kx + 'px,' + ky + 'px)';
// Achsen-Flags
const ax = (norm < DEAD_ZONE_FRAC) ? 0 : (dx / radius);
const ay = (norm < DEAD_ZONE_FRAC) ? 0 : (dy / radius);
mgSetKey('left', ax < -AXIS_THRESHOLD);
mgSetKey('right', ax > AXIS_THRESHOLD);
mgSetKey('up', ay < -AXIS_THRESHOLD);
mgSetKey('down', ay > AXIS_THRESHOLD);
}
function reset() {
knob.style.transform = 'translate(0,0)';
mgSetKey('up', false);
mgSetKey('down', false);
mgSetKey('left', false);
mgSetKey('right', false);
}
function pointerPos(ev) {
const rect = stick.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
return { dx: ev.clientX - cx, dy: ev.clientY - cy };
}
stick.addEventListener('pointerdown', (ev) => {
if (active) return;
active = true;
activePointerId = ev.pointerId;
stick.setPointerCapture(ev.pointerId);
const p = pointerPos(ev);
applyDelta(p.dx, p.dy);
ev.preventDefault();
});
stick.addEventListener('pointermove', (ev) => {
if (!active || ev.pointerId !== activePointerId) return;
const p = pointerPos(ev);
applyDelta(p.dx, p.dy);
ev.preventDefault();
});
const endHandler = (ev) => {
if (!active || ev.pointerId !== activePointerId) return;
active = false;
activePointerId = null;
try { stick.releasePointerCapture(ev.pointerId); } catch (e) {}
reset();
};
stick.addEventListener('pointerup', endHandler);
stick.addEventListener('pointercancel', endHandler);
stick.addEventListener('pointerleave', endHandler);
})();
/** 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();
// Aktiver Mover: immer aktiv (in allen Levels — gehört didaktisch zum
// Rangieren auf einem echten Hof). Geschwindigkeit wächst mit Level.
const cfg = game._config || {};
const lvl = (cfg.maxActiveContracts || 0) >= 6 ? 3
: (cfg.maxActiveContracts || 0) >= 3 ? 2 : 1;
MG.activeMover = {
x: 180, y: 85, angle: 0,
v: lvl >= 3 ? 38 : lvl >= 2 ? 28 : 20, // L1: langsam, L2: mittel, L3: schnell
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);
});
function mgCloseOverlay() {
if (!overlay.classList.contains('open')) return;
overlay.classList.remove('open');
cancelAnimationFrame(mgRaf); mgRaf = null;
mgResetKeys();
mgAudioStop();
mgState = null;
}
document.getElementById('mgClose').addEventListener('click', mgCloseOverlay);
document.getElementById('mgX') .addEventListener('click', mgCloseOverlay);
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');
// Rangier-Minigame nur wenn TRAIN im Fuhrpark (auf L1 kein Zug → Button bleibt hidden)
btnShunting.hidden = !(game.vehicles || []).some(v => v.mode === 'TRAIN');
/* === 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: '1', color: '#9a3d4d', label: 'Wagen 1' },
{ id: '2', color: '#b88a45', label: 'Wagen 2' },
{ id: '3', color: '#4f7a9a', label: 'Wagen 3' },
{ id: '4', color: '#6a7a3d', label: 'Wagen 4' },
],
TARGET: ['1', '2', '3', '4'],
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,
warnSwitch: null, // Name der Weiche, die gerade rot pulsiert (oder null)
warnTime: 0, // performance.now() des letzten Warn-Triggers
_audioCtx: null, // Web-Audio-Kontext, lazy
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
* An Segmentgrenzen wird die neue Orientierung per Dot-Produkt berechnet:
* Lok-Heading + Bewegungsrichtung werden geometrisch auf das neue
* Segment projiziert, sodass bei gerade Uebergaengen dir erhalten bleibt
* (kein Spiegeln, kein Zusammenfalten des Zugs). */
function sgTraverse(state, signedDs) {
let { segId, s, dir } = state;
let rSigned = signedDs;
let blockedAt = null; // {segId, endpoint} falls an einer Sackgasse/gesperrten Weiche gestoppt
for (let g = 0; g < 16; g++) {
const seg = SG.seg[segId];
const sCh = dir * rSigned;
const newS = s + sCh;
if (newS > seg.len + 1e-9) {
const over = newS - seg.len;
const nc = sgConnected(segId, 'e1');
if (!nc) { s = seg.len; blockedAt = { segId, endpoint: 'e1' }; break; }
const t = sgTransitionOut(seg, SG.seg[nc.seg], nc.endpoint, dir, sCh);
segId = nc.seg; s = t.s; dir = t.dir;
rSigned = t.sChSign * dir * Math.abs(over);
} else if (newS < -1e-9) {
const under = -newS;
const pc = sgConnected(segId, 'e0');
if (!pc) { s = 0; blockedAt = { segId, endpoint: 'e0' }; break; }
const t = sgTransitionOut(seg, SG.seg[pc.seg], pc.endpoint, dir, sCh);
segId = pc.seg; s = t.s; dir = t.dir;
rSigned = t.sChSign * dir * Math.abs(under);
} else {
s = newS;
break;
}
}
return { segId, s, dir, blockedAt };
}
/** Mapping Endpunkt → Weichen-Name (oder null). */
const SG_ENDPOINT_SWITCH = {};
Object.keys(SG_SWITCHES).forEach(name => {
const sw = SG_SWITCHES[name];
[].concat(sw.gerade, sw.abbiegen).forEach(k => { SG_ENDPOINT_SWITCH[k] = name; });
});
function sgEndpointSwitch(segId, endpoint) {
return SG_ENDPOINT_SWITCH[segId + ':' + endpoint] || null;
}
/** Berechnet neuen (s, dir, sChSign) beim Segmentwechsel.
* oldSeg, newSeg: Segment-Objekte
* newEntry: 'e0' | 'e1' (an welcher Seite tritt Zug ein)
* oldDir, oldSCh: Lok-Heading und s-Aenderung vor dem Uebergang
* Rueckgabe:
* s = 0 bzw. newSeg.len (Eintrittspunkt)
* dir = Sign(Dot(alte Lok-Heading, neue e0->e1-Richtung))
* sChSign = Sign(Dot(phys. Motion-Richtung, neue e0->e1))
* → gibt das Vorzeichen von sChange auf dem neuen Segment */
function sgTransitionOut(oldSeg, newSeg, newEntry, oldDir, oldSCh) {
const odx = oldSeg.e1[0] - oldSeg.e0[0], ody = oldSeg.e1[1] - oldSeg.e0[1];
const oln = Math.hypot(odx, ody);
const ofx = oldDir * odx / oln, ofy = oldDir * ody / oln; // alte Lok-Heading (world)
const pmSign = oldSCh >= 0 ? +1 : -1;
const pmx = pmSign * odx / oln, pmy = pmSign * ody / oln; // phys. Motion (world)
const ndx = newSeg.e1[0] - newSeg.e0[0], ndy = newSeg.e1[1] - newSeg.e0[1];
const nln = Math.hypot(ndx, ndy);
const nfx = ndx / nln, nfy = ndy / nln; // neue e0->e1-Richtung
const fd = ofx * nfx + ofy * nfy;
const pd = pmx * nfx + pmy * nfy;
return {
s: newEntry === 'e0' ? 0 : nln,
dir: fd >= 0 ? +1 : -1,
sChSign: pd >= 0 ? +1 : -1,
};
}
/** 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.');
sgPlayWarnTone();
break;
}
}
}
if (ds !== 0) {
const r = sgTraverse(active.head, ds);
active.head = { segId: r.segId, s: r.s, dir: r.dir };
// Wenn wir an einer Sackgasse/gesperrten Weiche gestoppt sind: Warnung
if (r.blockedAt) {
const sw = sgEndpointSwitch(r.blockedAt.segId, r.blockedAt.endpoint);
if (sw) sgTriggerSwitchWarn(sw);
}
}
// 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 elapsedMs = performance.now() - SG.startTime;
const elapsed = (elapsedMs / 1000).toFixed(1);
const stars = (SG.rangierZuege <= 8) ? 3 : (SG.rangierZuege <= 14) ? 2 : 1;
const timeBonus = elapsedMs <= 4 * 60 * 1000; // Unter 4 Minuten -> Bonus aufs naechste Be-/Entladen
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)
+ (timeBonus ? '<br><strong>⚡ Zeit-Bonus aktiv</strong> — nächste Lade-/Entlade-Zeit verkürzt.' : '');
try {
LogistikEngine.applyMinigameResult && LogistikEngine.applyMinigameResult(game, {
type: 'shunting',
stars,
rangierZuege: SG.rangierZuege,
weichenstellungen: SG.weichenstellungen,
sec: parseFloat(elapsed),
timeBonus, // Engine kann das fuer Lade-/Entlade-Speedup nutzen
});
} 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).');
sgTriggerSwitchWarn(name);
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]);
}
/* ---- Warnung (Switch rot pulsiert + Web-Audio Ton) ---- */
function sgTriggerSwitchWarn(name) {
SG.warnSwitch = name;
SG.warnTime = performance.now();
sgPlayWarnTone();
}
function sgPlayWarnTone() {
try {
if (!SG._audioCtx) SG._audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const ctx = SG._audioCtx;
const now = ctx.currentTime;
// Zwei kurze Pieptöne (wie Ampelwarnung), 880 Hz, square
for (let i = 0; i < 2; i++) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(880, now + i * 0.14);
gain.gain.setValueAtTime(0, now + i * 0.14);
gain.gain.linearRampToValueAtTime(0.12, now + i * 0.14 + 0.015);
gain.gain.linearRampToValueAtTime(0, now + i * 0.14 + 0.09);
osc.connect(gain).connect(ctx.destination);
osc.start(now + i * 0.14);
osc.stop(now + i * 0.14 + 0.10);
}
} catch (e) { /* Audio optional */ }
}
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 + #1·#2·#3·#4 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-Warn-Ring (pulsierend rot, 1500ms nach Trigger)
const warnElapsed = SG.warnSwitch ? (performance.now() - SG.warnTime) : 9999;
if (SG.warnSwitch && warnElapsed < 1500) {
const pos = SG.weichenPos[SG.warnSwitch];
const phase = (warnElapsed % 500) / 500; // Pulszyklus 500ms
const radius = 16 + 8 * Math.sin(phase * Math.PI * 2);
const alpha = 1 - warnElapsed / 1500;
ctx.strokeStyle = 'rgba(220, 50, 50, ' + (alpha * 0.85).toFixed(3) + ')';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(pos[0], pos[1], radius, 0, Math.PI * 2);
ctx.stroke();
} else if (SG.warnSwitch) {
SG.warnSwitch = null;
}
// Weichen-Marker
['W1','W2','W3','W4','W5','W6'].forEach(name => {
const pos = SG.weichenPos[name];
const sw = SG.weichen[name];
const isWarn = (SG.warnSwitch === name) && warnElapsed < 1500;
ctx.fillStyle = isWarn ? '#d23232' : (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.font = 'bold 16px system-ui, sans-serif';
ctx.fillText('#' + 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);
});
function sgCloseOverlay() {
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('sgClose').addEventListener('click', sgCloseOverlay);
document.getElementById('sgX') .addEventListener('click', sgCloseOverlay);
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),
rentalCosts: Math.round(a.runningCostsRental || 0),
idleCosts: Math.round(a.runningCostsIdle || 0),
portCosts: Math.round(a.runningCostsPort || 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');
/** Sterne-Bewertung aus Endbilanz (Thomas 2026-04-24).
* Referenzwerte aus Balance-Kalkulation L1 (4 Tage, 1-3 LKW via Auto-Rent):
* Pessimist ~8.700 EUR, Median ~11.600 EUR, Optimist ~15.700 EUR,
* Optimum ~20.300 EUR.
* Bankrott (balance <= 0) = 0 Sterne, Level verloren. */
function computeStars(balance) {
if (balance <= 0) return 0;
if (balance >= 16000) return 5;
if (balance >= 13000) return 4;
if (balance >= 10500) return 3;
if (balance >= 8500) return 2;
return 1;
}
const STAR_LABELS = {
5: 'Spitze! Optimale Ausnutzung.',
4: 'Sehr gut — so spielen Profis.',
3: 'Solide — Median-Bereich.',
2: 'Knapp, aber solvent.',
1: 'Gerade noch überlebt.',
0: 'Bankrott — Level verloren.',
};
function showEndScreen(finalState) {
const success = finalState === LogistikEngine.STATE.LEVEL_SUCCESS;
esModal.className = 'lg-endscreen-modal ' + (success ? 'success' : 'fail');
const cfg = game._config || {};
const a = computeAnalytics();
const finalBal = game.balance;
const stars = computeStars(finalBal);
const isBankrupt = finalBal <= 0;
document.getElementById('endscreenTitle').textContent = success
? pickText({ standard: '✅ 4 Tage durchgestanden', easy: 'Geschafft!' })
: pickText({
standard: isBankrupt ? '💸 Pleite — Durchgang verloren' : '⚠ Durchgang beendet',
easy: 'Leider nicht geschafft.'
});
document.getElementById('endscreenText').textContent = pickText({
standard: success
? 'Endbilanz ' + fmtEuro(finalBal) + '. ' + STAR_LABELS[stars]
: (isBankrupt
? 'Das Konto ist auf 0 € oder darunter gefallen — Spiel vorbei.'
: 'Der Durchgang ist beendet.'),
easy: success
? 'Fertig! Endstand: ' + fmtEuro(finalBal) + '.'
: 'Kein Geld mehr übrig. Noch mal probieren!',
});
// Sterne-Box
const starsBox = document.getElementById('endscreenStars');
if (starsBox) {
starsBox.hidden = false;
const starsEl = document.getElementById('lgStars');
starsEl.innerHTML = '';
for (let i = 1; i <= 5; i++) {
const s = document.createElement('span');
s.className = i <= stars ? 'lg-star-on' : 'lg-star-off';
s.textContent = '★';
starsEl.appendChild(s);
}
document.getElementById('lgStarsLabel').textContent = STAR_LABELS[stars] || '';
}
// Voice: Sterne oder Bankrott-Line, eine einzige Ausgabe mit leichtem Delay
if (isBankrupt) setTimeout(() => speak('bankrupt'), 400);
else setTimeout(() => speak('stars_' + stars), 400);
// Leerfahrten-Box
const emptyBox = document.getElementById('endscreenEmpty');
if (emptyBox && a.totalKm > 0) {
emptyBox.hidden = false;
const pct = a.totalKm > 0 ? (a.emptyKm / a.totalKm * 100) : 0;
document.getElementById('lgEmptyPct').textContent = pct.toFixed(1) + ' %';
document.getElementById('lgEmptyFill').style.width = Math.min(100, pct).toFixed(1) + '%';
let sub = '';
if (pct < 10) sub = 'Super — fast alle Kilometer mit Ladung.';
else if (pct < 20) sub = 'Gut — wenig Leerzeit.';
else if (pct < 35) sub = 'Ausbaufähig — versuch Rückfahrten mit Fracht zu füllen.';
else sub = 'Viele Kilometer ohne Ladung — hier liegt viel Potenzial.';
document.getElementById('lgEmptySub').textContent = sub +
' (' + Math.round(a.emptyKm).toLocaleString('de-DE') + ' km leer / '
+ Math.round(a.totalKm).toLocaleString('de-DE') + ' km gesamt)';
} else if (emptyBox) {
emptyBox.hidden = true;
}
// Transparente Konto-Rechnung
const startBud = cfg.startBudget || 0;
const stats = [
['Aufträge erledigt', a.completed],
['Verspätungsquote', a.lateRatio],
['Startbudget', fmtEuro(startBud)],
['Erlöse (Basis + Bonus)','+' + fmtEuro(a.totalRewards || 0)],
['Strafen', '' + fmtEuro(a.totalPenalty || 0)],
['Fahrkosten', '' + fmtEuro(a.totalFares || 0)],
];
// Laufende Kosten didaktisch aufgesplittet (nur zeigen was > 0 ist)
if (a.rentalCosts > 0) stats.push(['Fahrzeugmiete', '' + fmtEuro(a.rentalCosts)]);
if (a.idleCosts > 0) stats.push(['Standkosten', '' + fmtEuro(a.idleCosts)]);
if (a.portCosts > 0) stats.push(['Hafen-Standgeld', '' + fmtEuro(a.portCosts)]);
stats.push(
['Endbilanz', fmtEuro(finalBal)],
['Gewinn/Verlust', 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();
const success = evt.to === LogistikEngine.STATE.LEVEL_SUCCESS;
LG_API.endAttempt(success, {
completed: a.completed,
lateRatio: a.lateRatio,
emptyRatio: a.emptyRatio,
avgNet: a.avgNet,
profit: a.profit,
levelNum: levelNum,
});
// Assessment: Level-Abschluss (Integrations-Vertrag §6.4)
LG_API.postAssessment('completed', {
durationMs: Math.round(performance.now() - sessionStartMs),
completedPhases: ['started', 'running', 'completed'],
results: {
success: success,
finalBalance: Math.round(game.balance || 0),
leistung_pct: Math.max(0, Math.min(100, Math.round((game.balance || 0) / 16000 * 100))),
minTarget: (game._config && game._config.minTargetEarnings) || 0,
completed: a.completed,
lateRatio: a.lateRatio,
emptyRatio: a.emptyRatio,
avgNet: a.avgNet,
profit: a.profit,
levelNum: levelNum,
round: (game.progression && game.progression.round) || 1,
},
});
drainAnalyticsQueue();
}
});
// Für Browser-Konsole/Tests sichtbar
window.__logistikGame = game;
// ===== Live-State für Lehrer*innen-Cockpit =====
// Read-only-Snapshot. Defensive: jeder Field-Zugriff in try, kein Crash bei
// partiell initialisierter Engine. Lehrperson sieht Kontostand, Auftragslage
// und Spielfortschritt — ohne dass die Sim wissen muss, dass jemand zuschaut.
window.GGS_LIVE_STATE = function() {
try {
if (!game) return null;
var orders = Array.isArray(game.orders) ? game.orders : [];
var stateCnt = function(s) { return orders.filter(function(o){ return o && o.state === s; }).length; };
var delivered = stateCnt('DELIVERED');
var open = stateCnt('OPEN');
var inFlight = orders.filter(function(o){ return o && ['PICKUP_PENDING','LOADING','IN_TRANSIT','AT_TRANSFER','UNLOADING'].indexOf(o.state) >= 0; }).length;
var late = stateCnt('LATE') + stateCnt('FAILED');
var totalProgressable = delivered + late + inFlight; // bearbeitet
var pct = orders.length > 0 ? Math.round(totalProgressable / orders.length * 100) : 0;
return {
simId: 'logistik',
levelId: levelNum || 1,
progressPct: pct,
leistung_pct: Math.max(0, Math.min(100, Math.round((game.balance || 0) / 16000 * 100))),
phase: 'Runde ' + ((game.progression && game.progression.round) || 1),
balance: Math.round(game.balance || 0),
ordersDelivered: delivered,
ordersOpen: open,
ordersInFlight: inFlight,
ordersLate: late,
totalOrders: orders.length,
orderIndex: delivered, // für generische Vergleichs-Anzeige
round: (game.progression && game.progression.round) || 1,
timestamp: Date.now(),
};
} catch (_) { return null; }
};
})();
/* =================================================================
Music-Player mit Playlist-Support
ggsMusicSetup([{ key, label, files: [...] }, ...]) — Playlist-Modus
ggsMusicSetup([{ key, label, file: '...' }, ...]) — Single-Track-Loop (rückwärtskompatibel)
Playlist: Queue wird pro Auswahl geshuffled, bei track-Ende spielt der
nächste automatisch; am Ende der Queue wird neu geshuffled.
================================================================= */
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;
tracks.forEach(function(t) { if (!t.files && t.file) t.files = [t.file]; });
var audio = new Audio();
var lsKey = 'ggs-music-logistik';
var state = {};
try { state = JSON.parse(localStorage.getItem(lsKey) || '{}'); } catch (e) {}
var plQueue = [];
var plIndex = 0;
function shuffle(arr) {
var a = arr.slice();
for (var i = a.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = a[i]; a[i] = a[j]; a[j] = tmp;
}
return a;
}
function getTrack(key) {
for (var i = 0; i < tracks.length; i++) if (tracks[i].key === key) return tracks[i];
return tracks[0];
}
function loadPlaylist(key) {
var t = getTrack(key);
// Reihenfolge: wie in der Definition (Thomas 2026-04-25 — kein Shuffle).
plQueue = t.files.slice();
plIndex = 0;
audio.src = plQueue[0];
audio.loop = plQueue.length === 1;
}
function advance() {
plIndex++;
if (plIndex >= plQueue.length) plIndex = 0; // Loop von vorne
audio.src = plQueue[plIndex];
audio.play().catch(function(){});
}
audio.addEventListener('ended', function() { if (!audio.loop) advance(); });
sel.innerHTML = tracks.map(function(t) {
return '<option value="' + t.key + '">' + t.label + '</option>';
}).join('');
if (state.track && tracks.some(function(t){ return t.key === state.track; })) sel.value = state.track;
loadPlaylist(sel.value);
var defaultVol = 22;
var userVol = (state.volume != null ? state.volume : defaultVol) / 100;
audio.volume = userVol;
vol.value = state.volume != null ? state.volume : defaultVol;
// Ducking-API (Phase 8f++): waehrend Sprachausgabe Lautstaerke auf 25%
var duckActive = false;
var fadeRaf = null;
var DUCK_FACTOR = 0.25;
function fadeTo(targetVol, ms) {
if (fadeRaf) cancelAnimationFrame(fadeRaf);
var startVol = audio.volume;
var startT = performance.now();
function step(now) {
var k = Math.min(1, (now - startT) / ms);
audio.volume = startVol + (targetVol - startVol) * k;
if (k < 1) fadeRaf = requestAnimationFrame(step);
else fadeRaf = null;
}
fadeRaf = requestAnimationFrame(step);
}
window.lgAmbientDuck = function() {
if (duckActive) return;
duckActive = true;
fadeTo(userVol * DUCK_FACTOR, 250);
};
window.lgAmbientUnduck = function() {
if (!duckActive) return;
duckActive = false;
fadeTo(userVol, 350);
};
window.lgAmbientStart = function(key) {
if (key) {
var t = getTrack(key);
if (t) {
sel.value = key;
loadPlaylist(key);
}
}
audio.play().then(function(){
btn.textContent = '⏸'; btn.classList.add('playing'); save();
}).catch(function(){});
};
function save() {
try {
localStorage.setItem(lsKey, JSON.stringify({
track: sel.value, volume: Math.round(userVol * 100), playing: !audio.paused,
}));
} catch (e) {}
}
sel.addEventListener('change', function() {
var wasPlaying = !audio.paused;
loadPlaylist(sel.value);
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() {
userVol = vol.value / 100;
audio.volume = duckActive ? userVol * DUCK_FACTOR : userVol;
save();
});
};
(function () {
var BASE = window.LOGISTIK_BASE || '';
var M = BASE + 'sounds/music/';
ggsMusicSetup([
{ key: 'alpine', label: '🏔 Alpine', files: [
M+'alpine-freightlight-v1.mp3', M+'alpine-freightlight-v2.mp3',
M+'alpine-freight-light-v3.mp3', M+'alpine-freight-light-v4.mp3',
]},
{ key: 'country', label: '🎸 Country', files: [
M+'busted-wagon-wheel.mp3',
M+'dustlight-freight-v1.mp3', M+'dustlight-freight-v2.mp3',
M+'saddlewood-metronome.mp3', M+'saddlewood-metronome-v2.mp3',
M+'borderline-kilometers-v1.mp3', M+'borderline-kilometers-v2.mp3',
M+'diesel-and-dreams-v1.mp3', M+'diesel-and-dreams-v2.mp3',
M+'grenzen-auf-asphalt-v1.mp3', M+'grenzen-auf-asphalt-v2.mp3',
M+'grenzen-im-scheinwerfer-v1.mp3', M+'grenzen-im-scheinwerfer-v2.mp3',
M+'still-trucking-morgen-v1.mp3', M+'still-trucking-morgen-v2.mp3',
]},
{ key: 'mechanik', label: '⚙ Mechanik', files: [
M+'gears-and-marbles.mp3', M+'gears-and-marbles-v2.mp3',
M+'untitled-instrumental.mp3',
]},
{ key: 'bahn', label: '🚂 Bahn', files: [
M+'rail-gravel-v1.mp3', M+'rail-gravel-v2.mp3',
M+'station-glissando-v1.mp3', M+'station-glissando-v2.mp3', M+'station-glissando-v3.mp3',
]},
{ key: 'drama', label: '🔥 Spannung', files: [
M+'pressure-bloom-v1.mp3', M+'pressure-bloom-v2.mp3',
M+'barbed-lullaby.mp3',
]},
]);
})();</script>
<script src="../../assets/js/ggs-quick-intro.js" defer></script>
<script>
window.addEventListener('DOMContentLoaded', function () {
if (window.GgsQuickIntro) GgsQuickIntro.init({
simId: 'logistik',
title: '📦 Logistik Europa',
lead: 'Du planst Container-Routen quer durch Europa. Hafen, Bahn, LKW — was passt zum Auftrag? Was zahlt sich aus, was kostet Zeit?',
steps: [
'Auftrag wählen — Start-Hafen, Ziel-Stadt, Container-Anzahl, Bezahlung.',
'Route aus Schiff / Bahn / LKW zusammenstellen.',
'Auf Zeitfenster achten — Verspätung kostet.',
'Budget hochhalten, Routen optimieren, Stufen freischalten.',
],
goals: [
'Europäische Häfen und Wirtschaftsknoten kennen',
'Verkehrsmittel-Vor- und Nachteile abwägen',
'Lieferketten als wirtschaftliches System verstehen',
],
});
});
</script>
</body>
</html>