88e3c09e5c
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>
5782 lines
268 KiB
HTML
5782 lines
268 KiB
HTML
<!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">0°</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 Logistikunternehmens.',
|
||
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 Tagesmiete 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
console.info(
|
||
'[Logistik Phase 1] Engine bereit. ' +
|
||
locations.length + ' Locations geladen, ' +
|
||
railnet.edges.length + ' Bahnkanten. State: ' + game.state
|
||
);
|
||
|
||
/* =================================================================
|
||
Minigame: An-die-Rampe-Einparken (Phase 7-UX — Top-Down mit Bicycle-Lenkung)
|
||
----------------------------------------------------------------
|
||
Physik: Bicycle-Model mit Lenkwinkel, Vor- und Rückwärtsfahrt,
|
||
Rückwärts-Lenkinversion (wie in echt). Ziel: LKW-Heck in die
|
||
Andock-Bucht am rechten Rand.
|
||
================================================================= */
|
||
const 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>
|