Files
geograsim/App/sims/busfahrt/game.html
T
Adminator aab9b35781 Busfahrt v2: Header kompakt-links, Budget/Auftrag nach links, Zoom unten
- Header kompakt links (nicht mehr Vollbreite) -> keine Ueberlappung mit
  der Auftrags-Karte oben rechts.
- Budget aus Header raus (widersprach dem Graph); Graph-Budget-Wert wird
  jetzt live in updateTopbar aktualisiert. updateTopbar null-sicher.
- Auftrag-Nr ins Stats-Panel (links bei den Graphen).
- Leaflet-Zoom nach unten links.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 16:14:09 +02:00

2459 lines
101 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>🚌 Busfahrt · GeoGraSim</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<link rel="stylesheet" href="../../assets/fonts/inter.css">
<link rel="stylesheet" href="../../assets/css/ggs-header.css">
<script src="../../assets/js/ggs-header.js"></script>
<style>
:root {
--bf-green: #1f4b37;
--bf-green-2: #4a7c4e;
--bf-yellow: #e8c547;
--bf-cream: #e8e4d8;
--bf-paper: #f5f3ea;
--bf-ink: #2a2a2a;
--bf-ink-2: #555;
--bf-good: #2e7d32;
--bf-ok: #ed6c02;
--bf-miss: #c62828;
--bf-shadow: 0 2px 12px rgba(0,0,0,0.15);
}
html, body {
margin: 0; padding: 0; height: 100dvh; overflow: hidden;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bf-paper); color: var(--bf-ink);
-webkit-tap-highlight-color: transparent;
}
#map { width: 100vw; height: 100dvh; background: #cfe1ed; }
/* ===== Topbar ===== */
.ggs-topbar {
position: absolute; top: 8px; left: 8px; z-index: 1001;
display: flex; gap: 8px; align-items: center;
font-size: 13px; flex-wrap: wrap;
max-width: calc(100vw - 16px);
pointer-events: none; /* leere Bereiche zur Karte durchlassen */
}
/* Auftrag-Nr im Stats-Panel (links, bei den Graphen) */
.bf-stats__order { font-weight: 800; font-size: 14px; color: var(--bf-green); margin-bottom: 8px; }
.ggs-topbar a {
text-decoration: none;
background: #f1ede2; color: var(--bf-ink);
border: 1px solid #d4cfc0; border-radius: 6px;
padding: 4px 10px; font-size: 13px;
min-height: 36px; display: inline-flex; align-items: center; gap: 4px;
}
.ggs-topbar h1 {
margin: 0; padding: 0 6px;
font-size: 14px; font-weight: 700;
color: var(--bf-green);
}
.ggs-topbar .bf-budget {
background: var(--bf-yellow);
color: var(--bf-ink);
font-weight: 700;
padding: 4px 10px; border-radius: 6px;
font-size: 13px;
min-height: 36px; display: inline-flex; align-items: center; gap: 4px;
}
.ggs-topbar .bf-counter {
color: var(--bf-ink-2); font-size: 12px;
padding: 4px 6px;
background: var(--bf-cream); border-radius: 6px;
min-height: 36px; display: inline-flex; align-items: center;
}
.ggs-topbar .bf-toggle {
background: #f1ede2; color: var(--bf-ink);
border: 1px solid #d4cfc0; border-radius: 6px;
padding: 4px 10px; font-size: 14px;
min-width: 36px; min-height: 36px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
}
.ggs-topbar .bf-toggle:active { background: #e0d9c5; }
.ggs-topbar .bf-toggle.active { background: var(--bf-green); color: #fff; border-color: var(--bf-green); }
.ggs-topbar .bf-night-pill {
background: #1a1a2e; color: var(--bf-yellow);
padding: 4px 8px; border-radius: 6px;
font-size: 12px; font-weight: 700;
min-height: 36px; display: none; align-items: center; gap: 4px;
}
.ggs-topbar .bf-night-pill.show { display: inline-flex; }
/* ===== Stats-Panel (links, unter der Topbar) ===== */
.bf-stats {
position: absolute; left: 8px; top: 110px; z-index: 1000;
background: rgba(255, 255, 255, 0.95);
border-radius: 12px; padding: 10px 12px;
box-shadow: var(--bf-shadow);
width: 200px;
}
.bf-stats__chart { margin-bottom: 10px; }
.bf-stats__chart:last-child { margin-bottom: 0; }
.bf-stats__head {
display: flex; justify-content: space-between; align-items: baseline;
font-size: 11px; color: var(--bf-ink-2);
margin-bottom: 2px;
}
.bf-stats__title { font-weight: 600; }
.bf-stats__value { font-weight: 700; color: var(--bf-ink); }
.bf-stats svg {
width: 100%; height: 50px; display: block;
background: var(--bf-paper); border-radius: 4px;
}
.bf-stats__hint {
font-size: 10px; color: var(--bf-ink-2);
margin-top: 2px;
}
/* ===== Auftrags-Card (oben rechts) ===== */
.bf-order {
position: absolute; top: 8px; right: 8px; z-index: 1000;
background: #fff; border-radius: 12px;
box-shadow: var(--bf-shadow);
padding: 12px 14px;
width: min(360px, calc(100vw - 16px));
border-top: 4px solid var(--bf-green);
}
.bf-order.hidden { display: none; }
.bf-order__head {
display: flex; justify-content: space-between; align-items: center;
font-size: 12px; color: var(--bf-ink-2);
margin-bottom: 4px;
}
.bf-order__title {
font-weight: 700; color: var(--bf-green);
font-size: 15px;
}
.bf-order__prompt {
font-size: 14px; line-height: 1.4;
margin: 6px 0 10px;
}
.bf-order.is-night {
border-top-color: #1a1a2e;
background: #1a1a2e;
color: #f1ede2;
}
.bf-order.is-night .bf-order__title { color: var(--bf-yellow); }
.bf-order.is-night .bf-order__head,
.bf-order.is-night .bf-attempt-label,
.bf-order.is-night .bf-order__phase { color: #ccc; }
.bf-attempts {
display: flex; gap: 4px; align-items: center;
margin-bottom: 6px;
}
.bf-attempt-dot {
width: 14px; height: 14px; border-radius: 50%;
background: #d4cfc0; border: 2px solid #b5b0a1;
transition: opacity 0.2s;
}
.bf-attempt-dot.miss { background: var(--bf-miss); border-color: #8b0000; }
.bf-attempt-dot.ok { background: var(--bf-ok); border-color: #a04500; }
.bf-attempt-dot.good { background: var(--bf-good); border-color: #1b5e20; }
/* Bei Auflösung: ungenutzte Versuche-Dots verblassen */
.bf-order.resolved .bf-attempt-dot:not(.miss):not(.ok):not(.good) { opacity: 0.25; }
.bf-order.resolved .bf-attempt-label { color: var(--bf-good); font-weight: 600; }
.bf-attempt-label {
font-size: 12px; color: var(--bf-ink-2);
margin-left: 4px;
}
.bf-hint {
background: #fff7e0; border-left: 3px solid var(--bf-yellow);
padding: 6px 10px; border-radius: 4px;
font-size: 13px; line-height: 1.4;
margin-top: 8px;
color: var(--bf-ink);
}
.bf-hint.hidden { display: none; }
.bf-night-hint {
background: #2a2a3e;
color: var(--bf-yellow);
border-left: 3px solid var(--bf-yellow);
padding: 8px 10px; border-radius: 4px;
font-size: 12px; line-height: 1.4;
margin-top: 8px;
}
.bf-night-hint strong { color: #fff; }
.bf-night-hint.hidden { display: none; }
.bf-timer {
display: inline-flex; align-items: center; gap: 4px;
background: var(--bf-cream);
color: var(--bf-ink);
padding: 2px 8px; border-radius: 10px;
font-size: 12px; font-weight: 700;
font-variant-numeric: tabular-nums;
min-width: 60px; justify-content: center;
}
.bf-timer.hot { background: #fff7e0; color: #6d4c00; }
.bf-timer.cold { background: #f1ede2; color: var(--bf-ink-2); }
.bf-order.is-night .bf-timer {
background: #3a3a4e; color: var(--bf-yellow);
}
.bf-order.is-night .bf-timer.hot {
background: #4a3a2e; color: #ffd770;
}
.bf-order__phase {
border-top: 1px solid #e8e4d8;
margin-top: 10px; padding-top: 8px;
font-size: 12px; color: var(--bf-ink-2);
text-align: center;
}
.bf-order.is-night .bf-order__phase { border-color: #555; }
/* ===== Modal-Pattern ===== */
.ggs-modal-overlay {
position: fixed; inset: 0;
background: rgba(20, 20, 20, 0.6);
display: none; align-items: center; justify-content: center;
z-index: 2000; padding: 12px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
}
.ggs-modal-overlay.show { display: flex; }
.ggs-modal {
background: #fff; border-radius: 12px;
max-height: 92dvh; width: min(640px, 96vw);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 20px 18px;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
position: relative;
}
.ggs-modal h2 {
margin: 0 0 12px;
color: var(--bf-green);
padding-right: 36px;
font-size: 20px;
}
.ggs-modal-close {
position: absolute; top: 8px; right: 8px;
width: 36px; height: 36px;
background: #f1ede2;
border: 1px solid #d4cfc0; border-radius: 50%;
cursor: pointer; font-size: 16px;
display: flex; align-items: center; justify-content: center;
}
.ggs-modal-close:active { background: #e0d9c5; }
.ggs-modal-actions {
position: sticky; bottom: 0;
background: linear-gradient(to bottom, rgba(255,255,255,0), #fff 30%);
padding-top: 12px; margin-top: 12px;
display: flex; gap: 8px; justify-content: flex-end;
flex-wrap: wrap;
}
.ggs-modal-actions button {
min-height: 44px; padding: 10px 16px;
border-radius: 6px; cursor: pointer;
font-size: 14px; font-weight: 600;
background: var(--bf-green); color: #fff; border: none;
}
.ggs-modal-actions button:active { background: #143628; }
.ggs-modal-actions button.secondary {
background: #f1ede2; color: var(--bf-ink);
border: 1px solid #d4cfc0;
}
.bf-welcome-list {
margin: 8px 0 0; padding-left: 20px;
font-size: 14px; line-height: 1.6;
}
/* ===== Single-Choice ===== */
/* ----- Crossing-Info (Fähren / Tunnel / Sondermaut) ----- */
.bf-crossing {
margin: 10px 0;
padding: 8px 12px;
background: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 8px;
}
.bf-crossing summary { font-weight: 700; cursor: pointer; color: #0d47a1; padding: 2px 0; }
.bf-crossing p { margin: 6px 0; }
.bf-crossing a { color: #1565c0; }
/* ----- Ereignis-Modal ----- */
.bf-event-modal { max-width: 560px; }
.bf-event-icon {
width: 88px; height: 88px;
margin: 0 auto 10px;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #1f4b37, #4a7c4e);
border-radius: 18px;
box-shadow: 0 4px 12px rgba(31, 75, 55, 0.35);
}
.bf-event-icon svg { width: 56px; height: 56px; }
.bf-event-intro { font-size: 15px; line-height: 1.5; margin: 6px 0 14px; }
.bf-event-quizq { font-weight: 700; margin: 8px 0 6px; }
.bf-event-quiz-choices { display: grid; gap: 8px; margin-bottom: 10px; }
.bf-event-quiz-feedback {
font-size: 14px; padding: 8px 10px; border-radius: 6px; margin-bottom: 10px;
}
.bf-event-quiz-feedback.right { background: #e8f5e9; color: #1b5e20; }
.bf-event-quiz-feedback.wrong { background: #ffebee; color: #b71c1c; }
.bf-event-choices { display: grid; gap: 8px; margin: 10px 0; }
.bf-event-feedback {
font-size: 14px; line-height: 1.5;
margin: 10px 0; padding: 10px 12px;
background: #fffceb; border: 1px solid #e8c547;
border-radius: 8px;
}
.bf-event-sachinfo {
margin-top: 12px;
background: #f5f4ec; border: 1px solid #d4cfc0;
border-radius: 8px; padding: 8px 12px;
}
.bf-event-sachinfo summary {
font-weight: 700; cursor: pointer; padding: 4px 0;
color: #1f4b37;
}
.bf-event-sachinfo p { font-size: 13px; line-height: 1.5; margin: 6px 0; }
.bf-event-lehrplan, .bf-event-glossary { font-size: 12px; color: #4a5c4e; }
.bf-event-lehrplan b, .bf-event-glossary b { color: #1f4b37; }
.bf-choices {
display: grid; gap: 10px;
margin: 12px 0;
}
.bf-choice {
background: #f1ede2;
border: 2px solid #d4cfc0;
border-radius: 10px;
padding: 14px 16px;
font-size: 16px; font-weight: 600;
cursor: pointer; text-align: left;
min-height: 52px;
color: var(--bf-ink);
transition: background 0.1s;
}
.bf-choice:active { background: #e0d9c5; }
.bf-choice.right { background: #c8e6c9; border-color: var(--bf-good); }
.bf-choice.wrong { background: #ffcdd2; border-color: var(--bf-miss); }
.bf-choice[disabled] { opacity: 0.6; cursor: default; }
.bf-choice-feedback {
font-size: 14px; line-height: 1.4;
margin-top: 8px; padding: 8px 10px;
border-radius: 6px;
}
.bf-choice-feedback.right { background: #e8f5e9; color: #1b5e20; }
.bf-choice-feedback.wrong { background: #ffebee; color: #b71c1c; }
.bf-choice-feedback.hidden { display: none; }
/* ===== InfoCard mit Marge-Erklärung ===== */
.bf-info {
position: absolute; top: 8px; right: 8px; z-index: 1000;
background: #fff; border-radius: 12px;
box-shadow: 0 4px 18px rgba(0,0,0,0.22);
padding: 14px 14px 12px;
width: min(380px, calc(100vw - 16px));
max-height: calc(100dvh - 16px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
border-top: 4px solid var(--bf-green);
}
.bf-info.hidden { display: none; }
.bf-info:not(.hidden) {
animation: bf-slide-in-right 0.35s cubic-bezier(0.2, 0.7, 0.4, 1.05) both;
}
@keyframes bf-slide-in-right {
from { transform: translateX(110%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.bf-info h3 {
margin: 0 0 8px;
font-size: 17px; color: var(--bf-green);
padding-right: 36px; line-height: 1.3;
}
.bf-info-close {
position: absolute; top: 8px; right: 8px;
width: 32px; height: 32px;
background: #f1ede2; border: 1px solid #d4cfc0; border-radius: 50%;
cursor: pointer; font-size: 14px;
display: flex; align-items: center; justify-content: center;
}
.bf-info-close:active { background: #e0d9c5; }
.bf-info-img {
width: 100%; height: 140px; object-fit: cover;
border-radius: 8px;
background: var(--bf-cream);
display: block;
margin-bottom: 10px;
}
.bf-info-meta {
font-size: 12px; color: var(--bf-ink-2);
margin-bottom: 8px;
display: flex; gap: 6px; flex-wrap: wrap;
}
.bf-info-meta .pill {
background: var(--bf-cream); padding: 2px 8px; border-radius: 10px;
}
.bf-info-distance {
font-size: 13px; font-weight: 600;
margin: 6px 0; padding: 6px 8px;
background: #f1ede2; border-radius: 6px;
line-height: 1.3;
}
.bf-info-distance.good { background: #e8f5e9; color: #1b5e20; }
.bf-info-distance.ok { background: #fff7e0; color: #6d4c00; }
.bf-info-distance.miss { background: #ffebee; color: #b71c1c; }
/* didaktischer Marge-Block — Ergebnis prominent oben, Details unten */
.bf-margin {
margin-top: 8px;
border-radius: 8px;
overflow: hidden;
background: var(--bf-cream);
font-size: 12px; line-height: 1.5;
}
.bf-margin.won { background: #e8f5e9; }
.bf-margin.lost { background: #ffebee; }
.bf-margin__headline {
padding: 10px 12px;
display: flex; justify-content: space-between; align-items: baseline;
gap: 12px;
}
.bf-margin__headline-label {
font-size: 13px; font-weight: 600; color: var(--bf-ink);
}
.bf-margin__headline-value {
font-size: 22px; font-weight: 800; line-height: 1;
}
.bf-margin.won .bf-margin__headline-value { color: var(--bf-good); }
.bf-margin.lost .bf-margin__headline-value { color: var(--bf-miss); }
.bf-margin__lesson {
padding: 0 12px 8px;
font-size: 12px; line-height: 1.4;
color: var(--bf-ink-2);
}
.bf-margin__details {
border-top: 1px solid rgba(0,0,0,0.10);
background: rgba(255,255,255,0.5);
padding: 8px 12px 10px;
font-size: 11px; line-height: 1.5;
color: var(--bf-ink-2);
}
.bf-margin__details-title {
font-weight: 600; color: var(--bf-ink);
margin-bottom: 4px;
}
.bf-margin__row {
display: flex; justify-content: space-between;
margin: 1px 0;
}
.bf-margin .pos { color: var(--bf-good); }
.bf-margin .neg { color: var(--bf-miss); }
/* Buchhaltungs-Tabelle (Monospace, sauber untereinander) */
.bf-calc {
display: grid;
grid-template-columns: 1fr auto;
gap: 0 12px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.6;
font-variant-numeric: tabular-nums;
color: var(--bf-ink);
}
.bf-calc > div { padding: 1px 0; white-space: nowrap; }
.bf-calc .v { text-align: right; }
.bf-calc__sep {
grid-column: 1 / -1;
border-top: 1px solid rgba(0,0,0,0.18);
margin: 4px 0 2px; height: 0;
padding: 0;
}
.bf-calc .total { font-weight: 700; font-size: 13px; }
.bf-calc .pos { color: var(--bf-good); }
.bf-calc .neg { color: var(--bf-miss); }
.bf-calc .detour { color: var(--bf-miss); font-weight: 600; }
.bf-calc .detour.zero { color: var(--bf-good); }
.bf-info-text {
font-size: 13px; line-height: 1.5;
margin: 8px 0;
}
.bf-info-next {
display: block; width: 100%;
margin-top: 10px;
min-height: 44px; padding: 10px 16px;
border-radius: 6px; cursor: pointer;
font-size: 14px; font-weight: 600;
background: var(--bf-green); color: #fff; border: none;
}
.bf-info-next:active { background: #143628; }
/* ===== EndScreen ===== */
.bf-stars {
text-align: center; font-size: 48px;
margin: 12px 0; letter-spacing: 4px;
}
.bf-end-totals {
margin: 12px 0;
background: var(--bf-cream); border-radius: 8px;
padding: 12px; font-size: 14px;
}
.bf-end-row {
display: flex; justify-content: space-between;
margin: 2px 0;
}
.bf-end-row.final {
border-top: 1px solid rgba(0,0,0,0.15);
margin-top: 6px; padding-top: 6px;
font-weight: 700; font-size: 16px;
}
.bf-verdict {
text-align: center; margin-top: 10px;
font-size: 14px; line-height: 1.4;
}
.bf-logbook {
margin: 12px 0 0; width: 100%;
border-collapse: collapse; font-size: 12px;
}
.bf-logbook th {
text-align: left; color: var(--bf-ink-2);
padding: 4px 6px;
border-bottom: 1px solid #d4cfc0; font-weight: 600;
}
.bf-logbook td {
padding: 6px 6px;
border-bottom: 1px solid #f1ede2;
vertical-align: top;
}
.bf-logbook td.score-cell { text-align: center; font-size: 14px; }
.bf-logbook td.margin-cell { text-align: right; font-weight: 600; white-space: nowrap; }
.bf-logbook td.margin-cell.pos { color: var(--bf-good); }
.bf-logbook td.margin-cell.neg { color: var(--bf-miss); }
.bf-logbook tr.lost-row td { opacity: 0.7; }
.bf-logbook tr.night-row td:first-child::before {
content: '🌙 '; font-size: 11px;
}
.bf-logbook small { color: var(--bf-ink-2); }
/* ===== Leaflet-Marker ===== */
.bf-tap-pin, .bf-target-pin {
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
transform-origin: 50% 50%;
}
.bf-tap-pin {
background: var(--bf-yellow);
width: 22px; height: 22px;
border: 3px solid var(--bf-ink);
box-shadow: 0 3px 8px rgba(0,0,0,0.35);
animation: bf-pin-drop 0.45s cubic-bezier(0.18, 0.89, 0.32, 1.28) both;
}
.bf-target-pin {
background: var(--bf-green);
width: 28px; height: 28px;
border: 3px solid #fff;
box-shadow: 0 4px 10px rgba(0,0,0,0.45);
animation: bf-pin-pop 0.55s cubic-bezier(0.18, 0.89, 0.32, 1.28) both;
}
@keyframes bf-pin-drop {
0% { transform: rotate(-45deg) translateY(-50px) scale(0.4); opacity: 0; }
60% { transform: rotate(-45deg) translateY(2px) scale(1.05); opacity: 1; }
100% { transform: rotate(-45deg) translateY(0) scale(1); opacity: 1; }
}
@keyframes bf-pin-pop {
0% { transform: rotate(-45deg) scale(0.2); opacity: 0; }
65% { transform: rotate(-45deg) scale(1.18); opacity: 1; }
100% { transform: rotate(-45deg) scale(1); opacity: 1; }
}
.leaflet-tooltip.bf-target-tip {
background: var(--bf-green); color: #fff;
border: none; border-radius: 4px;
padding: 3px 8px; font-weight: 600; font-size: 13px;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
}
.leaflet-tooltip.bf-target-tip::before { display: none; }
.bf-distance-line {
animation: bf-line-fade 0.7s ease-out 0.15s both;
}
@keyframes bf-line-fade {
from { stroke-opacity: 0; }
to { stroke-opacity: 0.85; }
}
/* aktiver (Auftrag-)Bus oder Schiff während Animation */
.bf-active-bus, .bf-active-ship {
font-size: 26px;
pointer-events: none;
text-shadow: 0 1px 3px rgba(255,255,255,0.8), 0 0 8px rgba(0,0,0,0.4);
transform-origin: 50% 50%;
animation: bf-bus-pop 0.4s ease-out;
}
@keyframes bf-bus-pop {
from { transform: scale(0.4); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
/* Schiff-Linie: Wellenmuster, breiter, mehr Wasser-Feeling */
.bf-ship-line {
animation: bf-line-fade 0.7s ease-out 0.15s both;
}
@media (max-width: 540px) {
.bf-order, .bf-info {
width: calc(100vw - 16px); right: 8px; left: 8px;
}
.ggs-modal h2 { font-size: 18px; }
.bf-info-img { height: 120px; }
.ggs-topbar { font-size: 12px; gap: 4px; }
.ggs-topbar h1 { font-size: 13px; }
.bf-stats { width: 160px; }
}
</style>
</head>
<body>
<!-- Topbar -->
<div class="ggs-topbar ggs-header" data-ggs-sim="busfahrt" data-ggs-home="../../schueler.html">
<h1 class="ggs-header-module">Busfahrt</h1>
<span class="bf-night-pill" id="topbar-night">🌙 Nachtfahrt</span>
<div class="ggs-header-actions">
<a href="../../schueler.html" title="Zurück zur Schülerseite">🏠</a>
<div class="ggs-music" id="ggs-music">
<select class="ggs-music-sel" id="song-select" onchange="onSongSelect(this.value)" title="Hintergrundmusik wählen">
<option value="__random__">🎲 Random</option>
</select>
<button class="ggs-music-btn" id="music-toggle" onclick="toggleMusicPlay()" title="Musik Pause/Wiedergabe" aria-label="Musik Pause"></button>
<button class="ggs-music-btn" id="next-song" onclick="nextSong()" title="Nächster Song" aria-label="Nächster Song">⏭️</button>
</div>
<button class="bf-toggle active" id="audio-toggle" onclick="toggleAudio()" title="Ton (SFX) ein/aus" aria-label="Ton umschalten">🔊</button>
<button class="bf-toggle" id="btn-info" onclick="if(window.GGS_TUTORIAL&&typeof window.GGS_TUTORIAL.replay==='function')window.GGS_TUTORIAL.replay();" title="Anleitung nochmal zeigen" aria-label="Anleitung"></button>
<button class="bf-toggle" id="black-map-toggle" onclick="toggleBlackMap()" title="Beschriftung ein/aus" aria-label="Beschriftung umschalten">🏷️</button>
<button class="bf-toggle" id="map-en-info" onclick="showMapEnglishInfo()" title="Warum ist die Karte auf Englisch?" aria-label="Karte auf Englisch — Erklärung" style="width:auto;padding:0 10px">️ EN</button>
</div>
</div>
<!-- Stats-Panel (links) -->
<div class="bf-stats" id="stats-panel">
<div class="bf-stats__order"><span id="topbar-counter"></span></div>
<div class="bf-stats__chart">
<div class="bf-stats__head">
<span class="bf-stats__title">Budget</span>
<span class="bf-stats__value" id="stats-budget-val">5.000 €</span>
</div>
<svg id="chart-budget" viewBox="0 0 200 50" preserveAspectRatio="none"></svg>
<div class="bf-stats__hint" id="stats-budget-hint">Start: 5.000 €</div>
</div>
<div class="bf-stats__chart">
<div class="bf-stats__head">
<span class="bf-stats__title">Genauigkeit</span>
<span class="bf-stats__value" id="stats-acc-val">— km</span>
</div>
<svg id="chart-accuracy" viewBox="0 0 200 50" preserveAspectRatio="none"></svg>
<div class="bf-stats__hint" id="stats-acc-hint">km daneben pro Auftrag</div>
</div>
</div>
<div id="map"></div>
<!-- Auftrags-Card -->
<div class="bf-order hidden" id="order-card">
<div class="bf-order__head">
<span id="order-counter">Auftrag 1 / 8</span>
<span class="bf-timer" id="order-timer">⏱ 0:00</span>
</div>
<div class="bf-order__title" id="order-title"></div>
<div class="bf-order__prompt" id="order-prompt"></div>
<div class="bf-attempts">
<span class="bf-attempt-dot" id="dot-1"></span>
<span class="bf-attempt-dot" id="dot-2"></span>
<span class="bf-attempt-dot" id="dot-3"></span>
<span class="bf-attempt-label" id="attempt-label">3 Versuche</span>
</div>
<div class="bf-hint hidden" id="order-hint"></div>
<div class="bf-night-hint hidden" id="night-hint">
🌙 <strong>Nachtfahrt</strong> — die Karte ist jetzt ohne Beschriftung.
Du musst die Stadt nur an der Topografie erkennen.
</div>
<div class="bf-order__phase" id="phase-label">Tippe auf die Karte</div>
</div>
<!-- Info-Card (Stadt + Marge-Erklärung) -->
<div class="bf-info hidden" id="info-card">
<button class="bf-info-close" onclick="closeInfo()" aria-label="Schließen"></button>
<h3 id="info-title"></h3>
<img class="bf-info-img" id="info-img" alt="" onerror="this.style.display='none'">
<div class="bf-info-meta">
<span class="pill" id="info-country"></span>
<span class="pill" id="info-pop"></span>
<span class="pill" id="info-capital"></span>
</div>
<div class="bf-info-distance" id="info-distance"></div>
<details class="bf-crossing" id="info-crossing" style="display:none">
<summary id="info-crossing-label"></summary>
<p id="info-crossing-text" style="font-size:13px;line-height:1.5;margin:6px 0"></p>
<p id="info-crossing-glossar" style="font-size:12px"></p>
</details>
<div class="bf-margin" id="info-margin">
<div class="bf-margin__headline">
<span class="bf-margin__headline-label" id="margin-headline-label">Marge</span>
<span class="bf-margin__headline-value" id="margin-headline-value">— €</span>
</div>
<div class="bf-margin__lesson" id="margin-lesson"></div>
<div class="bf-margin__details">
<div class="bf-margin__details-title">So setzt sich das zusammen:</div>
<div id="margin-rows"></div>
</div>
</div>
<p class="bf-info-text" id="info-text"></p>
<button class="bf-info-next" onclick="nextOrder()" id="info-next-btn">Nächster Auftrag →</button>
</div>
<!-- Welcome-Modal -->
<div class="ggs-modal-overlay" id="welcome-overlay">
<div class="ggs-modal">
<h2>🚌 Willkommen bei der Busfahrt</h2>
<p>Du arbeitest für ein Busunternehmen. Aufträge kommen rein:
„Fahre nach Paris" oder „Bringe die Reisegruppe in die Hauptstadt
von Spanien".</p>
<p><strong>So geht es:</strong></p>
<ul class="bf-welcome-list">
<li>Lies den Auftrag oben rechts.</li>
<li>Tippe die Stadt auf der Karte an. Du hast <strong>3 Versuche</strong>.</li>
<li>Tippst du daneben, fährt der Bus den Umweg — die zusätzlichen
Kilometer ziehen wir von deiner Marge ab.</li>
<li>Ziel: <strong>am Tour-Ende mehr Geld in der Kasse</strong> als zu Beginn.</li>
</ul>
<p style="font-size: 13px; color: var(--bf-ink-2); margin-top: 12px;">
<strong>Startbudget 5.000 €</strong> — Tour-Ziel: <strong id="welcome-target">— €</strong>.
Beim 6. Auftrag startet die Nachtfahrt 🌙 — keine Beschriftung auf der Karte.</p>
<div class="ggs-modal-actions">
<button onclick="startGame()">Los arbeiten</button>
</div>
</div>
</div>
<!-- Ereignis-Modal (Zwischen-Auftrags-Events: Pannen, Grenze, Maut, …) -->
<div class="ggs-modal-overlay" id="event-overlay">
<div class="ggs-modal bf-event-modal">
<div class="bf-event-icon" id="event-icon" aria-hidden="true"></div>
<h2 id="event-title"></h2>
<p id="event-intro" class="bf-event-intro"></p>
<div id="event-quiz" style="display:none">
<p id="event-quiz-q" class="bf-event-quizq"></p>
<div id="event-quiz-choices" class="bf-event-quiz-choices"></div>
<div id="event-quiz-feedback" class="bf-event-quiz-feedback" style="display:none"></div>
</div>
<div id="event-choices" class="bf-event-choices"></div>
<div id="event-feedback" class="bf-event-feedback" style="display:none"></div>
<details class="bf-event-sachinfo" id="event-sachinfo" style="display:none">
<summary>📚 Sachinformation + Lehrplan-Bezug</summary>
<p id="event-sachinfo-text"></p>
<p class="bf-event-lehrplan" id="event-lehrplan"></p>
<p class="bf-event-glossary" id="event-glossary"></p>
</details>
<div class="ggs-modal-actions">
<button id="event-continue" onclick="closeEvent()" style="display:none">Weiter →</button>
</div>
</div>
</div>
<!-- Single-Choice-Modal (Typ B) -->
<div class="ggs-modal-overlay" id="choice-overlay">
<div class="ggs-modal">
<h2 id="choice-title">Welche Stadt?</h2>
<p id="choice-prompt"></p>
<div class="bf-choices" id="choices-container"></div>
<div class="bf-choice-feedback hidden" id="choice-feedback"></div>
<div class="ggs-modal-actions">
<button id="choice-continue" onclick="afterChoice()" style="display:none;">Auf Karte tippen</button>
</div>
</div>
</div>
<!-- Bonus-Phasen-Overlay: Sprache, Währung, Vignette — wird flexibel befüllt -->
<div class="ggs-modal-overlay" id="extra-overlay">
<div class="ggs-modal" style="max-width:520px">
<div id="extra-badge" style="display:inline-block;font-size:.7rem;letter-spacing:.06em;text-transform:uppercase;font-weight:800;color:#5a8a5e;margin-bottom:.4rem">Bonus</div>
<h2 id="extra-title"></h2>
<p id="extra-prompt" style="font-size:.92rem;color:#3a3a3a;margin-bottom:.7rem"></p>
<div id="extra-quote" style="display:none;font-size:1.25rem;font-weight:700;font-style:italic;background:#f7f6f3;border-left:4px solid #4a7c4e;padding:.7rem .9rem;border-radius:5px;margin-bottom:.9rem;color:#1f4b37"></div>
<div class="bf-choices" id="extra-choices"></div>
<div class="bf-choice-feedback hidden" id="extra-feedback"></div>
<div class="ggs-modal-actions">
<button id="extra-continue" onclick="afterExtra()" style="display:none;">Weiter</button>
</div>
</div>
</div>
<!-- Info: Warum ist die Karte auf Englisch? -->
<div class="ggs-modal-overlay" id="map-en-overlay">
<div class="ggs-modal" style="max-width:600px">
<div style="display:inline-block;font-size:.7rem;letter-spacing:.06em;text-transform:uppercase;font-weight:800;color:#4a7c4e;margin-bottom:.4rem">Geographie · Hintergrund</div>
<h2 style="margin:0 0 .8rem">🗺 Warum ist die Karte auf Englisch?</h2>
<p style="font-size:.92rem;color:#3a3a3a;margin-bottom:1rem">Die Antwort: <em>weil das ein echter Lernvorteil für dich ist.</em> Hier die Gründe — was du dabei lernst, ist viel wertvoller als bloße Wiedererkennung.</p>
<ol style="font-size:.86rem;color:#2a2a2a;line-height:1.55;padding-left:1.3rem">
<li style="margin-bottom:.7rem"><strong>🌍 Wie heißt deine Heimat international?</strong><br>
Auf der Karte steht „<em>Vienna</em>", im Quiz „<em>Wien</em>". Du lernst nebenbei: in englischsprachigen Ländern, in Apps, in Forschung heißen die Städte anders.</li>
<li style="margin-bottom:.7rem"><strong>🛣 Englisch ist die Verkehrssprache der Geographie</strong><br>
Reiseplanung, Tourismus, internationale Apps, Karten — überall Englisch. Wer Reisebus fährt, kommt damit täglich in Kontakt.</li>
<li style="margin-bottom:.7rem"><strong>🧠 Transfer-Denken statt Auswendiglernen</strong><br>
Du musst „Munich" als „München" erkennen — dieses Übersetzen im Kopf trainiert geographisches Denken besser als reines Ablesen.</li>
<li style="margin-bottom:.7rem"><strong>🌐 Endonym vs. Exonym</strong><br>
Real existieren mehrere Namen pro Ort: der einheimische („Lisboa" in Portugal), der englische („Lisbon"), der deutsche („Lissabon"). Das ist ein eigenes geographisches Konzept — und du übst es ständig.</li>
<li style="margin-bottom:.2rem"><strong>⚖ Englisch als fairer Kompromiss</strong><br>
Lokalnamen wären für dich teils nicht erkennbar (vergleiche „Athína" mit „Athen"), reine Eindeutschung wäre nicht international. Englisch ist die anerkannte Mitte.</li>
</ol>
<p style="font-size:.78rem;color:#6a6a6a;margin-top:1rem;font-style:italic">PS: Technisch nutzen wir OpenStreetMap, deren Standard-Beschriftung englisch ist. Dass wir das beibehalten haben, ist eine bewusste didaktische Entscheidung.</p>
<div class="ggs-modal-actions">
<button onclick="hideMapEnglishInfo()">Verstanden — weiterfahren</button>
</div>
</div>
</div>
<!-- EndScreen -->
<div class="ggs-modal-overlay" id="end-overlay">
<div class="ggs-modal">
<h2>🚌 Tour beendet</h2>
<div class="bf-stars" id="end-stars">⭐⭐⭐</div>
<div class="bf-end-totals">
<div class="bf-end-row"><span>Topografie-Punkte:</span><span id="end-topo-points"></span></div>
<div class="bf-end-row"><span>Aufträge gewonnen:</span><span id="end-won-count"></span></div>
<div class="bf-end-row"><span>Quiz richtig auf 1. Versuch:</span><span id="end-quiz-firsttry"></span></div>
<div class="bf-end-row"><span>Startbudget:</span><span>5.000 €</span></div>
<div class="bf-end-row"><span>Verdienst aus Fahrten:</span><span id="end-earnings"></span></div>
<div class="bf-end-row" id="end-quiz-row" style="display:none"><span>Quiz-Bonus / -Strafe:</span><span id="end-quiz-delta"></span></div>
<div class="bf-end-row final"><span>Endbudget:</span><span id="end-budget">— €</span></div>
<div class="bf-verdict" id="end-verdict"></div>
</div>
<table class="bf-logbook" id="end-logbook"></table>
<div class="ggs-modal-actions">
<button class="secondary" onclick="location.reload()">Nochmal arbeiten</button>
<button onclick="window.location.href='../../schueler.html'">Zur Übersicht</button>
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script src="engine.js"></script>
<script src="../../assets/js/ggs-quick-intro.js" defer></script>
<script>
// Plattform-Mini-Onboarding (siehe assets/js/ggs-quick-intro.js).
// Registriert window.GGS_TUTORIAL.replay() für den -Header-Button und
// öffnet das Modal beim 1. Besuch automatisch.
window.addEventListener('DOMContentLoaded', function () {
if (window.GgsQuickIntro) GgsQuickIntro.init({
simId: 'busfahrt',
title: '🚌 Busfahrt',
lead: 'Du fährst Aufträge durch Europa. Wähl gut — Zeit, Distanz und Bezahlung wollen abgewogen werden.',
steps: [
'Auftrag auswählen — du siehst Stadt, Distanz und Marge.',
'Auf der Karte das richtige Ziel-Land/-Stadt anklicken. Genauigkeit zählt.',
'Hauptstadt-Quiz beantworten — Bonus auf den Auftrag.',
'Budget hochhalten, Aufträge gewinnen, Stufen freischalten.',
],
goals: [
'Europäische Hauptstädte und Länder zuordnen',
'Distanzen auf der Europa-Karte einschätzen',
'Wirtschaftlich planen — Marge, Treibstoff, Nachtfahrt',
],
});
});
</script>
<script>
/*__BUSFAHRT_INJECTION__*/
const EASY = !!window.STUDENT_EASY;
const BLACK_MAP_INITIAL = !!window.BUSFAHRT_BLACK_MAP;
// Stoppuhr-Bonus: default an. Lehrkraft kann via BUSFAHRT_TIME_BONUS=false abschalten.
const TIME_BONUS_ENABLED = window.BUSFAHRT_TIME_BONUS !== false;
const TIME_BONUS_WINDOW = window.BUSFAHRT_TIME_BONUS_SEC || 50; // Sekunden
const TIME_BONUS_MAX_EUR = 50; // €
// ===== Tile-Layer (Standard + label-frei für Nachtfahrten) =====
const TILE_VARIANTS = {
light: window.BUSFAHRT_TILE_PROXY
? window.BUSFAHRT_TILE_PROXY + '?p=carto&z={z}&x={x}&y={y}'
: 'https://cartodb-basemaps-a.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
blank: 'https://cartodb-basemaps-a.global.ssl.fastly.net/light_nolabels/{z}/{x}/{y}.png',
};
const map = L.map('map', {
center: [40, 20], zoom: 3, minZoom: 2, maxZoom: 8,
worldCopyJump: true,
});
map.zoomControl.setPosition('bottomleft'); // oben links ist die Marken-Pille
L.control.scale({ position: 'bottomleft', imperial: false, metric: true, maxWidth: 160 }).addTo(map);
let tileLayer = null;
let blackMapManual = BLACK_MAP_INITIAL; // vom User per Toggle gesetzt
let blackMapNight = false; // automatisch durch Night-Order gesetzt
function applyTileVariant() {
const useBlank = blackMapManual || blackMapNight;
if (tileLayer) map.removeLayer(tileLayer);
tileLayer = L.tileLayer(useBlank ? TILE_VARIANTS.blank : TILE_VARIANTS.light, {
attribution: '&copy; <a href="https://carto.com/attributions">CARTO</a> &copy; OpenStreetMap',
maxZoom: 18,
}).addTo(map);
const btn = document.getElementById('black-map-toggle');
if (btn) {
btn.textContent = useBlank ? '🚫🏷️' : '🏷️';
btn.classList.toggle('active', useBlank);
btn.title = useBlank ? 'Beschriftung wieder einblenden' : 'Beschriftung ausblenden';
}
}
applyTileVariant();
function toggleBlackMap() {
blackMapManual = !blackMapManual;
applyTileVariant();
}
// ===== Game State =====
let game = null;
let totalOrders = 0;
let mapInteractive = false;
let currentTapMarker = null;
let currentTargetMarker = null;
let currentLine = null;
let busMarker = null; // bleibt persistent zwischen Aufträgen
// Stoppuhr pro Auftrag
let timerStartT = 0;
let timerHandle = null;
let timerStopped = false;
let timerElapsedSec = 0;
const tapIcon = L.divIcon({
className: '', html: '<div class="bf-tap-pin"></div>',
iconSize: [22, 22], iconAnchor: [11, 22],
});
const targetIcon = L.divIcon({
className: '', html: '<div class="bf-target-pin"></div>',
iconSize: [28, 28], iconAnchor: [14, 28],
});
const busIcon = L.divIcon({
className: '', html: '<div class="bf-active-bus">🚌</div>',
iconSize: [32, 32], iconAnchor: [16, 16],
});
const shipIcon = L.divIcon({
className: '', html: '<div class="bf-active-ship">⛴️</div>',
iconSize: [32, 32], iconAnchor: [16, 16],
});
// ===== Audio =====
const SFX_NAMES = ['tap-map','pin-drop','hit-perfect','hit-ok','hit-miss','attempts-empty',
'choice-right','choice-wrong','order-resolved','bid-win','bid-lose','tour-end'];
const SFX = {};
const SONGS = [
'Bus Across Europe.mp3', 'Bus Across Europe(1).mp3',
'Bus Across Europe(2).mp3', 'Bus Across Europe(3).mp3',
'Bus from Amsterdam.mp3', 'Bus from Amsterdam(1).mp3',
'Bus Through Europe.mp3', 'Bus Through Europe(1).mp3',
];
let bgAudio = null;
let audioMuted = false; // SFX-Mute
let audioInitialized = false;
let musicMode = 'random'; // 'random' | '<dateiname>'
let musicPaused = false;
function preloadSfx() {
const base = (window.BUSFAHRT_BASE || './') + 'assets/sounds/';
SFX_NAMES.forEach(n => {
const a = new Audio(base + n + '.mp3');
a.preload = 'auto';
a.volume = 0.4;
SFX[n] = a;
});
}
function playSfx(name) {
if (audioMuted) return;
const src = SFX[name];
if (!src) return;
try {
const c = src.cloneNode(true);
c.volume = src.volume;
c.play().catch(() => {});
} catch (e) {}
}
function startBgMusic() {
if (musicPaused) return;
if (bgAudio) { bgAudio.pause(); bgAudio = null; }
// Random-Modus: zufälligen Track wählen. Festgelegter Modus: diesen Track.
let song;
if (musicMode === 'random') {
song = SONGS[Math.floor(Math.random() * SONGS.length)];
} else {
song = SONGS.includes(musicMode) ? musicMode : SONGS[0];
}
bgAudio = new Audio((window.BUSFAHRT_BASE || './') + 'assets/audio/' + encodeURI(song));
bgAudio.volume = 0.16;
// Random-Modus: nach Ende den nächsten Track. Festgelegter Modus: Track loopen.
bgAudio.onended = () => {
if (musicMode === 'random') setTimeout(startBgMusic, 1500);
else setTimeout(() => { if (!musicPaused) startBgMusic(); }, 800);
};
bgAudio.play().catch(() => {});
}
function stopBgMusic() {
if (bgAudio) { bgAudio.pause(); bgAudio = null; }
}
function nextSong() {
if (!audioInitialized) initAudioOnFirstInteraction();
else if (!musicPaused) startBgMusic();
}
function onSongSelect(value) {
musicMode = value || 'random';
if (!audioInitialized) initAudioOnFirstInteraction();
if (musicPaused) { musicPaused = false; _updateMusicBtn(); }
startBgMusic();
}
function toggleMusicPlay() {
if (!audioInitialized) {
initAudioOnFirstInteraction();
musicPaused = false;
_updateMusicBtn();
return;
}
musicPaused = !musicPaused;
if (musicPaused) stopBgMusic();
else startBgMusic();
_updateMusicBtn();
}
function _updateMusicBtn() {
const btn = document.getElementById('music-toggle');
if (!btn) return;
btn.textContent = musicPaused ? '▶' : '⏸';
btn.classList.toggle('active', !musicPaused);
}
// Track-Liste in Dropdown füllen (einmalig beim Boot)
function _initSongDropdown() {
const sel = document.getElementById('song-select');
if (!sel) return;
// Reset (außer Random-Option)
while (sel.options.length > 1) sel.remove(1);
SONGS.forEach((s, i) => {
const opt = document.createElement('option');
opt.value = s;
// Datei-Endung + (n) raus, " (Variante)" ergänzen falls Numerierung
let label = s.replace(/\.mp3$/i, '');
const m = label.match(/^(.*?)\((\d+)\)$/);
if (m) label = m[1].trim() + ' · Variante ' + m[2];
opt.textContent = '🎵 ' + label;
sel.appendChild(opt);
});
}
function toggleAudio() {
audioMuted = !audioMuted;
const btn = document.getElementById('audio-toggle');
btn.textContent = audioMuted ? '🔇' : '🔊';
btn.classList.toggle('active', !audioMuted);
if (audioMuted) stopBgMusic();
else if (audioInitialized) startBgMusic();
}
function initAudioOnFirstInteraction() {
if (audioInitialized) return;
audioInitialized = true;
startBgMusic();
}
// ===== Helpers =====
function pickText(t) {
if (!t) return '';
if (typeof t === 'string') return t;
if (EASY && t.easy) return t.easy;
return t.de || '';
}
function formatEur(n) {
if (n == null || isNaN(n)) return '— €';
return Math.round(n).toLocaleString('de-DE') + ' €';
}
function formatKm(n) {
return Math.round(n).toLocaleString('de-DE') + ' km';
}
function formatPop(m) {
if (m == null) return '?';
if (m < 1) return Math.round(m * 1000) + ' Tsd.';
return m.toFixed(1).replace('.', ',') + ' Mio.';
}
function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const toRad = d => d * Math.PI / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat/2)**2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon/2)**2;
return 2 * R * Math.asin(Math.sqrt(a));
}
// Bekannte Übergänge mit fixen geographischen Crossing-Punkten. Ohne diese
// Stützpunkte gibt OSRM teils gar keine Route zurück (UK ↔ Festland) oder
// die Route wird zur Luftlinie querfeldein über die Nordsee. Wir injizieren
// für Festland↔Insel und über-Meer-Tunnel die echten Verkehrsknoten.
const _UK_NAMES = new Set(['Großbritannien', 'Vereinigtes Königreich']);
function _crossingWaypoints(fromCity, toCity) {
if (!fromCity || !toCity || fromCity.country === toCity.country) return [];
const fromUk = _UK_NAMES.has(fromCity.country);
const toUk = _UK_NAMES.has(toCity.country);
// UK ↔ Festland (FR/BE/NL): Eurotunnel Calais ↔ Folkestone bzw. Fähre Dover ↔ Calais.
// Wir nehmen die kürzeste Querung: Calais (50.948,1.853) ↔ Dover (51.128,1.317).
if (fromUk !== toUk) {
return fromUk ? [[51.128, 1.317], [50.948, 1.853]] // UK → Festland
: [[50.948, 1.853], [51.128, 1.317]]; // Festland → UK
}
// Irland ↔ UK: Dublin Port (53.347, -6.226) ↔ Holyhead (53.310, -4.630)
const fromIE = fromCity.country === 'Irland';
const toIE = toCity.country === 'Irland';
if (fromIE !== toIE) {
if (_UK_NAMES.has(fromCity.country) || _UK_NAMES.has(toCity.country)) {
return fromIE ? [[53.347, -6.226], [53.310, -4.630]]
: [[53.310, -4.630], [53.347, -6.226]];
}
// Irland ↔ Festland (Frankreich): Direktfähre Dublin → Cherbourg
return fromIE ? [[53.347, -6.226], [49.640, -1.617]]
: [[49.640, -1.617], [53.347, -6.226]];
}
// Dänemark ↔ Schweden: Öresundbrücke nahe Saltholm
if ((fromCity.country === 'Dänemark' && toCity.country === 'Schweden') ||
(fromCity.country === 'Schweden' && toCity.country === 'Dänemark')) {
// Brückenmitte zwischen Kopenhagen (55.617, 12.658) und Malmö (55.611, 12.998)
return [[55.614, 12.828]];
}
// Finnland ↔ Estland: Fähre TallinnHelsinki (80 km, 2 h)
// Hafen Tallinn (Vanasadam): 59.444, 24.755 ↔ Hafen Helsinki (Länsisatama): 60.155, 24.948
if ((fromCity.country === 'Finnland' && toCity.country === 'Estland') ||
(fromCity.country === 'Estland' && toCity.country === 'Finnland')) {
return fromCity.country === 'Finnland'
? [[60.155, 24.948], [59.444, 24.755]]
: [[59.444, 24.755], [60.155, 24.948]];
}
// Finnland ↔ Schweden: Nachtfähre StockholmHelsinki (Viking Line, Tallink Silja)
// Hafen Stockholm (Värtahamnen): 59.353, 18.108 ↔ Helsinki: 60.155, 24.948
// Route via Åland: Mittelpunkt grob 60.0, 20.0 als Marker für die Inselgruppe.
if ((fromCity.country === 'Finnland' && toCity.country === 'Schweden') ||
(fromCity.country === 'Schweden' && toCity.country === 'Finnland')) {
return fromCity.country === 'Finnland'
? [[60.155, 24.948], [60.0, 20.0], [59.353, 18.108]]
: [[59.353, 18.108], [60.0, 20.0], [60.155, 24.948]];
}
return [];
}
// Inselländer ohne Auto-Verbindung zum Festland — Bus muss aufs Schiff.
// Großbritannien/Irland NICHT in der Liste: Eurotunnel + Fähre Calais-Dover
// sind für Reisebusse normal; OSRM kennt die Auto-Route i.d.R. korrekt.
const FERRY_REQUIRED_COUNTRIES = new Set([
'Island', 'Malta', 'Zypern', 'Sardinien', 'Sizilien', 'Korsika',
]);
function _busRequiresFerry(fromCity, toCity) {
if (!fromCity || !toCity) return false;
if (FERRY_REQUIRED_COUNTRIES.has(fromCity.country) && fromCity.country !== toCity.country) return true;
if (FERRY_REQUIRED_COUNTRIES.has(toCity.country) && fromCity.country !== toCity.country) return true;
return false;
}
// ===== OSRM-Route (Best-Effort, Fallback Luftlinie) =====
async function fetchRoute(latlngList) {
const path = latlngList.map(p => p[1] + ',' + p[0]).join(';');
const url = `https://router.project-osrm.org/route/v1/driving/${path}?overview=full&geometries=geojson`;
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 5000);
const resp = await fetch(url, { signal: ctrl.signal });
clearTimeout(t);
if (!resp.ok) return null;
const data = await resp.json();
if (!data.routes || !data.routes[0]) return null;
return {
coords: data.routes[0].geometry.coordinates.map(c => [c[1], c[0]]), // → [lat, lon]
distanceKm: data.routes[0].distance / 1000,
};
} catch (e) {
return null;
}
}
function fallbackRoute(latlngList) {
// Direkte Luftlinien-Punkte zwischen den Stops, mit Subdivision für glatte Animation.
const out = [];
for (let i = 0; i < latlngList.length - 1; i++) {
const a = latlngList[i], b = latlngList[i + 1];
const segments = 30;
for (let s = 0; s < segments; s++) {
const t = s / segments;
out.push([a[0] + (b[0]-a[0])*t, a[1] + (b[1]-a[1])*t]);
}
}
out.push(latlngList[latlngList.length - 1]);
let total = 0;
for (let i = 0; i < latlngList.length - 1; i++) {
total += haversineKm(latlngList[i][0], latlngList[i][1], latlngList[i+1][0], latlngList[i+1][1]);
}
return { coords: out, distanceKm: total };
}
// ===== Bus-/Schiff-Animation entlang Route =====
// opts.tProgressStart, opts.tProgressEnd: Anteil 0..1 — für Animations-Teilstücke
// (so können wir die Fahrt unterbrechen, ein Modal zeigen, dann weiterfahren).
async function animateBusAlongRoute(route, durationMs, isShip, opts) {
if (!route || !route.coords || route.coords.length < 2) return;
durationMs = durationMs || 2500;
opts = opts || {};
const tStart = (typeof opts.tProgressStart === 'number') ? opts.tProgressStart : 0;
const tEnd = (typeof opts.tProgressEnd === 'number') ? opts.tProgressEnd : 1;
// Polyline + Marker nur beim ersten Teilstück (tStart === 0) neu aufbauen
if (tStart === 0) {
if (currentLine) map.removeLayer(currentLine);
currentLine = L.polyline(route.coords, {
color: isShip ? '#2c5e7e' : '#1f4b37',
weight: isShip ? 4 : 3,
dashArray: isShip ? '2, 6' : '6, 8',
className: isShip ? 'bf-ship-line' : 'bf-distance-line',
opacity: 0.9,
}).addTo(map);
if (!busMarker) {
busMarker = L.marker(route.coords[0], { icon: isShip ? shipIcon : busIcon, interactive: false }).addTo(map);
} else {
busMarker.setLatLng(route.coords[0]);
busMarker.setIcon(isShip ? shipIcon : busIcon);
}
}
// Kumulative Distanzen pro Segment für Geschwindigkeits-konsistente Animation
const cumDist = [0];
for (let i = 1; i < route.coords.length; i++) {
cumDist.push(cumDist[i-1] + haversineKm(
route.coords[i-1][0], route.coords[i-1][1],
route.coords[i][0], route.coords[i][1]
));
}
const totalLen = cumDist[cumDist.length - 1] || 1;
// Marker auf Start-Position des Teilstücks setzen
function posAtT(progress) {
const target = totalLen * progress;
let lo = 0, hi = cumDist.length - 1;
while (lo + 1 < hi) {
const mid = (lo + hi) >> 1;
if (cumDist[mid] <= target) lo = mid;
else hi = mid;
}
const seg = cumDist[hi] - cumDist[lo] || 1;
const frac = (target - cumDist[lo]) / seg;
const a = route.coords[lo], b = route.coords[hi];
return [a[0] + (b[0]-a[0])*frac, a[1] + (b[1]-a[1])*frac];
}
if (busMarker) busMarker.setLatLng(posAtT(tStart));
const segDuration = durationMs * (tEnd - tStart);
if (segDuration <= 0) return;
const startT = performance.now();
return new Promise(resolve => {
function tick(now) {
const localT = Math.min((now - startT) / segDuration, 1);
const progress = tStart + (tEnd - tStart) * localT;
busMarker.setLatLng(posAtT(progress));
if (localT < 1) requestAnimationFrame(tick);
else resolve();
}
requestAnimationFrame(tick);
});
}
// Spielt eine Bonus-Phase als Modal-Sequenz ab (alle queued Extras).
// Wird mitten auf der Fahrt aufgerufen und blockt bis alle Modals durch sind.
function _playExtrasModal() {
return new Promise(resolve => {
function next() {
const ex = window.BusfahrtEngine.nextExtra && window.BusfahrtEngine.nextExtra(game);
if (!ex) { _afterExtraDone = null; return resolve(); }
_afterExtraDone = next; // wird von afterExtra() aufgerufen
showExtraModal(ex);
}
next();
});
}
let _afterExtraDone = null;
// ===== Boot =====
async function boot() {
await window.BusfahrtEngine.loadData();
const lvl = window.BUSFAHRT_FORCED_LEVEL || 'l1';
game = window.BusfahrtEngine.init(lvl, Date.now());
totalOrders = game.tourOrders.length;
if (lvl === 'l1') map.setView([54, 15], 4);
else if (lvl === 'l2') map.setView([35, 70], 3);
else map.setView([20, 0], 2);
preloadSfx();
_initSongDropdown();
// Bus an Heimat-Garage platzieren — bleibt persistent zwischen Aufträgen.
const home = window.BusfahrtEngine.getCity(window.BusfahrtEngine.ECON.homeCity);
if (home) {
busMarker = L.marker([home.lat, home.lon], { icon: busIcon, interactive: false }).addTo(map);
}
updateTopbar();
drawCharts();
const ECON = window.BusfahrtEngine.ECON;
const target = (typeof ECON.targetBudget === 'object')
? (ECON.targetBudget[lvl] || 7500)
: ECON.targetBudget;
document.getElementById('welcome-target').textContent = formatEur(target);
document.getElementById('welcome-overlay').classList.add('show');
}
function updateTopbar() {
if (!game) return;
// Budget nur noch im Stats-Panel/Graph (live) — kein Widerspruch mehr
const sb = document.getElementById('stats-budget-val');
if (sb) sb.textContent = formatEur(game.budget);
const idx = Math.min(game.orderIndex + 1, totalOrders);
const c = document.getElementById('topbar-counter');
if (c) c.textContent = `Auftrag ${idx} / ${totalOrders}`;
// Night-Pill
const order = game.currentOrder || (game.tourOrders[game.orderIndex] || null);
const n = document.getElementById('topbar-night');
if (n) n.classList.toggle('show', !!(order && order.isNight));
}
function startGame() {
document.getElementById('welcome-overlay').classList.remove('show');
initAudioOnFirstInteraction();
if (game && !game._startedAt) game._startedAt = Date.now();
loadNextOrder();
}
// ===== Auftrag laden =====
function loadNextOrder() {
clearMapMarkers();
document.getElementById('order-hint').classList.add('hidden');
document.getElementById('info-card').classList.add('hidden');
resetAttemptDots();
const order = window.BusfahrtEngine.nextOrder(game);
if (!order) {
showEndScreen();
return;
}
// Tile auf "Nacht" wenn Night-Order
blackMapNight = !!order.isNight;
applyTileVariant();
renderOrderCard(order);
updateTopbar();
// Zwischenfrage bei JEDEM Auftrag: solange game.currentQuestion existiert,
// ist die Karte gesperrt. Bei Typ B steht die Frage im Auftrag (Hauptstadt),
// bei A/C/D liefert die Engine die generierte Land-Frage aus city.question.
if (game.currentQuestion) {
showChoiceModal(order);
mapInteractive = false;
} else {
document.getElementById('order-card').classList.remove('hidden');
// Auch wenn kein Quiz: erst Bonus-Phasen (Sprache/Währung/Vignette) laufen
mapInteractive = false;
runNextExtraOrStartMap();
}
}
function renderOrderCard(order) {
const card = document.getElementById('order-card');
card.classList.remove('hidden');
card.classList.remove('resolved');
card.classList.toggle('is-night', !!order.isNight);
document.getElementById('order-counter').textContent =
`Auftrag ${game.orderIndex + 1} / ${totalOrders}` + (order.isNight ? ' · 🌙 Nachtfahrt' : '');
const cityForA = ['A', 'C'].includes(order.type)
? window.BusfahrtEngine.getCity(order.cityId)
: null;
document.getElementById('order-title').textContent = cityForA
? cityForA.title
: (order.type === 'B' ? 'Hauptstadt-Frage' : 'Welche Stadt?');
document.getElementById('order-prompt').textContent = pickText(order.prompt);
document.getElementById('attempt-label').textContent = '3 Versuche';
document.getElementById('phase-label').textContent =
game.currentQuestion ? 'Erst Quiz beantworten' : 'Tippe auf die Karte';
// Nachtfahrt-Hint
document.getElementById('night-hint').classList.toggle('hidden', !order.isNight);
// Stoppuhr starten erst NACH der Zwischenfrage (sonst läuft sie unfair
// während des Quiz). Bei Aufträgen ohne Question läuft sie sofort.
if (game.currentQuestion) stopTimer(true);
else startTimer();
}
// ===== Stoppuhr =====
function startTimer() {
stopTimer(true);
if (!TIME_BONUS_ENABLED) return;
timerStartT = performance.now();
timerStopped = false;
timerElapsedSec = 0;
updateTimerDisplay();
timerHandle = setInterval(updateTimerDisplay, 250);
}
function stopTimer(reset) {
if (timerHandle) { clearInterval(timerHandle); timerHandle = null; }
if (!reset) {
timerStopped = true;
timerElapsedSec = (performance.now() - timerStartT) / 1000;
} else {
timerStopped = true;
timerElapsedSec = 0;
const el = document.getElementById('order-timer');
if (el) { el.textContent = '⏱ 0:00'; el.className = 'bf-timer'; }
}
}
function updateTimerDisplay() {
const el = document.getElementById('order-timer');
if (!el) return;
if (timerStopped) return;
const sec = (performance.now() - timerStartT) / 1000;
const mm = Math.floor(sec / 60);
const ss = Math.floor(sec % 60);
el.textContent = `⏱ ${mm}:${String(ss).padStart(2,'0')}`;
el.classList.toggle('hot', sec < TIME_BONUS_WINDOW * 0.5);
el.classList.toggle('cold', sec >= TIME_BONUS_WINDOW);
}
function computeTimeBonus(sec) {
if (!TIME_BONUS_ENABLED) return 0;
return Math.max(0, Math.round(TIME_BONUS_MAX_EUR * (1 - sec / TIME_BONUS_WINDOW)));
}
function resetAttemptDots() {
for (let i = 1; i <= 3; i++) {
document.getElementById('dot-' + i).classList.remove('miss', 'ok', 'good');
}
}
// ===== Zwischenfrage (Quiz vor jedem Auftrag) =====
// Engine liefert game.currentQuestion mit {kind, prompt, choices, choiceLabels,
// correctIndex, attempts}. Die Reihenfolge der Choices kommt aus der Engine —
// dort schon stabil/deterministisch festgelegt (siehe generate-city-questions.py
// für 'country'-Fragen, observations.json für 'capital'-Fragen).
function showChoiceModal(order) {
const q = game.currentQuestion;
if (!q) return;
const ov = document.getElementById('choice-overlay');
document.getElementById('choice-prompt').textContent = pickText(q.prompt);
const cont = document.getElementById('choices-container');
cont.innerHTML = '';
const fb = document.getElementById('choice-feedback');
fb.classList.add('hidden');
fb.className = 'bf-choice-feedback hidden';
document.getElementById('choice-continue').style.display = 'none';
q.choiceLabels.forEach((label, idx) => {
const btn = document.createElement('button');
btn.className = 'bf-choice';
btn.textContent = label;
btn.dataset.idx = String(idx);
btn.onclick = () => onChoicePick(btn, idx, cont);
cont.appendChild(btn);
});
ov.classList.add('show');
}
function onChoicePick(btn, pickedIdx, container) {
const result = window.BusfahrtEngine.evaluateQuestion(game, pickedIdx);
if (!result) return;
const fb = document.getElementById('choice-feedback');
// Geld-Effekt sofort sichtbar machen (Topbar + Float-Hint)
updateTopbar();
if (result.correct) {
playSfx('choice-right');
btn.classList.add('right');
Array.from(container.children).forEach(b => b.disabled = true);
const q = game.currentQuestion;
const correctLabel = q.choiceLabels[q.correctIndex];
let msg;
if (result.bonusEur > 0) {
msg = `Richtig! +${result.bonusEur} € Bonus für die erste richtige Antwort.`;
} else if (result.penaltyEur > 0) {
msg = `Richtig — aber zu spät. ${result.attempts.length - 1} Fehlversuch(e) haben ${result.penaltyEur} € gekostet.`;
} else {
msg = `Richtig: ${correctLabel}.`;
}
// Didaktischer Zusatz: Großbritannien ↔ Vereinigtes Königreich klären.
if (correctLabel === 'Großbritannien') {
msg += '\n\n💡 Das Land heißt offiziell auch „Vereinigtes Königreich" (UK = United Kingdom). „Großbritannien" ist eigentlich nur die Insel mit England, Schottland und Wales — Nordirland gehört auch zum UK, ist aber auf der anderen Insel.';
}
fb.style.whiteSpace = 'pre-line';
fb.textContent = msg;
fb.className = 'bf-choice-feedback right';
fb.classList.remove('hidden');
document.getElementById('choice-continue').style.display = 'inline-flex';
} else {
playSfx('choice-wrong');
btn.classList.add('wrong');
btn.disabled = true;
// Penalty pro Level: ECON.quizPenaltyPerWrongEur ist jetzt {l1,l2,l3}.
const penaltyTbl = window.BusfahrtEngine.ECON.quizPenaltyPerWrongEur;
const penalty = (typeof penaltyTbl === 'object')
? (penaltyTbl[game.levelId] || penaltyTbl.l1 || 50)
: penaltyTbl;
fb.textContent = `Nicht ganz. ${penalty} € Strafe. Versuch noch eine Antwort.`;
fb.className = 'bf-choice-feedback wrong';
fb.classList.remove('hidden');
}
}
function afterChoice() {
document.getElementById('choice-overlay').classList.remove('show');
// Direkt zur Karte — Bonus-Phasen kommen erst MITTEN auf der Fahrt
// (nach dem Tap), nicht vor dem Tap. So bleibt die Story logisch:
// Quiz → Tap → Fahrt mit Halt an der Grenze → Ankunft.
mapInteractive = true;
document.getElementById('phase-label').textContent = 'Tippe auf die Karte';
startTimer();
}
function showExtraModal(extra) {
const ov = document.getElementById('extra-overlay');
const badge = document.getElementById('extra-badge');
const title = document.getElementById('extra-title');
const prompt = document.getElementById('extra-prompt');
const quote = document.getElementById('extra-quote');
const cont = document.getElementById('extra-choices');
const fb = document.getElementById('extra-feedback');
const btn = document.getElementById('extra-continue');
cont.innerHTML = '';
fb.classList.add('hidden');
fb.className = 'bf-choice-feedback hidden';
btn.style.display = 'none';
quote.style.display = 'none';
quote.textContent = '';
if (extra.kind === 'language') {
badge.textContent = '🗣 Sprache · ' + (extra.country || '');
badge.style.color = '#4a7c4e';
title.textContent = 'Was bedeutet das?';
// Prompt-Varianten — rotieren, damit es nicht jedes Mal gleich klingt.
var L = _langName(extra.lang);
var promptVariants = [
'🚏 An der Haltestelle steigt jemand ein und sagt auf '+L+':',
'☕ Im Café an der Raststätte hörst du auf '+L+':',
'🗺 Ein Fahrgast wendet sich auf '+L+' an dich:',
'📻 Aus dem Bus-Radio kommt eine Ansage auf '+L+':',
'🪧 Auf einem Schild am Straßenrand steht auf '+L+':',
'🛎 An der Hotel-Rezeption hörst du auf '+L+':',
'🧳 Beim Aussteigen sagt eine Reisende auf '+L+':',
'🍽 Die Bedienung im Restaurant fragt auf '+L+':'
];
prompt.textContent = promptVariants[Math.floor(Math.random() * promptVariants.length)];
quote.style.display = 'block';
quote.textContent = '„' + extra.sentence + '"';
extra.choices.forEach((label, idx) => {
const b = document.createElement('button');
b.className = 'bf-choice';
b.textContent = label;
b.onclick = () => _onExtraPick(b, idx, cont, extra);
cont.appendChild(b);
});
} else if (extra.kind === 'currency') {
badge.textContent = '💱 Währungswechsel · ' + (extra.country || '');
badge.style.color = '#c9913a';
title.textContent = 'Geld wechseln?';
var changeAmount = 200;
var feePct = extra.feePct || 3;
var feeEur = Math.round(changeAmount * (feePct / 100));
prompt.textContent = 'Im Zielland zahlt man in ' + extra.currency
+ ' (1 € ≈ ' + extra.rate + ' ' + extra.currency
+ '). Für Unterwegs-Kosten wechseln wir ' + changeAmount + ' € — die Bank nimmt etwa '
+ feePct + ' % Gebühr (= ' + feeEur + ' €).';
const buyBtn = document.createElement('button');
buyBtn.className = 'bf-choice';
buyBtn.innerHTML = '✅ Wechseln (' + feeEur + ' € Gebühr)';
buyBtn.onclick = () => _onExtraSimple({ accept: true });
cont.appendChild(buyBtn);
} else if (extra.kind === 'vignette') {
badge.textContent = '🛣 Maut · ' + (extra.country || '');
badge.style.color = '#c07a6b';
title.textContent = 'Reisebus-Maut kaufen?';
prompt.textContent = 'Für die Fahrt auf den Autobahnen in ' + extra.country
+ ' zahlt ein Reisebus eine Tagespauschale: ' + extra.priceLocal + ' '
+ extra.currency + (extra.currency !== 'EUR' ? ' (≈ ' + extra.priceEur + ' €)' : '')
+ '. Ohne Bezahlung riskierst du eine Strafe der Verkehrspolizei.';
const yesBtn = document.createElement('button');
yesBtn.className = 'bf-choice';
yesBtn.innerHTML = '✅ Tagespauschale bezahlen (' + extra.priceEur + ' €)';
yesBtn.onclick = () => _onExtraSimple({ buy: true });
cont.appendChild(yesBtn);
const noBtn = document.createElement('button');
noBtn.className = 'bf-choice';
noBtn.innerHTML = '⚠ Ohne Maut fahren (Strafrisiko)';
noBtn.onclick = () => _onExtraSimple({ buy: false });
cont.appendChild(noBtn);
}
ov.classList.add('show');
}
function _onExtraPick(btn, pickedIdx, container, extra) {
const res = window.BusfahrtEngine.resolveExtra(game, { pickedIndex: pickedIdx });
if (!res) return;
updateTopbar();
const fb = document.getElementById('extra-feedback');
const st = res.state;
if (pickedIdx === extra.correctIndex) {
playSfx('choice-right');
btn.classList.add('right');
Array.from(container.children).forEach(b => b.disabled = true);
let msg = 'Richtig! ';
if (st.bonusEur > 0) msg += '+' + st.bonusEur + ' € für die Sprach-Trefferquote.';
else if (st.penaltyEur > 0) msg += 'Beim ' + st.attempts.length + '. Versuch geklappt. ' + st.penaltyEur + ' € auf dem Weg.';
else msg += 'Ohne Bonus — aber sauber gemacht.';
fb.textContent = msg;
fb.className = 'bf-choice-feedback right';
fb.classList.remove('hidden');
document.getElementById('extra-continue').style.display = 'inline-flex';
} else {
playSfx('choice-wrong');
btn.classList.add('wrong');
btn.disabled = true;
if (res.resolved) {
fb.textContent = 'Knapp daneben. Die richtige Übersetzung war: „' + extra.choices[extra.correctIndex] + '". Weiter geht\'s.';
fb.className = 'bf-choice-feedback wrong';
fb.classList.remove('hidden');
document.getElementById('extra-continue').style.display = 'inline-flex';
} else {
fb.textContent = 'Nicht ganz. 10 € — noch ein Versuch.';
fb.className = 'bf-choice-feedback wrong';
fb.classList.remove('hidden');
}
}
}
function _onExtraSimple(payload) {
const res = window.BusfahrtEngine.resolveExtra(game, payload);
if (!res) return;
updateTopbar();
const fb = document.getElementById('extra-feedback');
const st = res.state;
let msg = '';
if (st.kind === 'currency') {
msg = 'Du hast ' + (st.changedEur || 200) + ' € gewechselt. Wechselgebühr: ' + (st.feeEur || 0) + ' €.';
} else if (st.kind === 'vignette') {
if (st.outcome === 'bought') msg = 'Tagespauschale bezahlt. Du fährst legal weiter.';
else msg = 'Erwischt! 100 € Strafe für nicht bezahlte Bus-Maut.';
}
fb.textContent = msg;
fb.className = 'bf-choice-feedback ' + (st.outcome === 'fined' ? 'wrong' : 'right');
fb.classList.remove('hidden');
document.getElementById('extra-continue').style.display = 'inline-flex';
}
function afterExtra() {
document.getElementById('extra-overlay').classList.remove('show');
// Wenn die Modal-Sequenz läuft (mid-Fahrt), gibt _afterExtraDone den
// nächsten Schritt zurück. Sonst (sehr alter Pfad, eigentlich nicht mehr
// genutzt) gehen wir zurück zur Karte.
if (typeof _afterExtraDone === 'function') {
const fn = _afterExtraDone;
_afterExtraDone = null;
fn();
}
}
function showMapEnglishInfo() {
document.getElementById('map-en-overlay').classList.add('show');
}
function hideMapEnglishInfo() {
document.getElementById('map-en-overlay').classList.remove('show');
}
function _langName(iso) {
return ({
it:'Italienisch', fr:'Französisch', es:'Spanisch', pt:'Portugiesisch',
en:'Englisch', nl:'Niederländisch', pl:'Polnisch', cs:'Tschechisch',
sk:'Slowakisch', hu:'Ungarisch', hr:'Kroatisch', sl:'Slowenisch',
sr:'Serbisch', ro:'Rumänisch', bg:'Bulgarisch', el:'Griechisch',
tr:'Türkisch', da:'Dänisch', sv:'Schwedisch', no:'Norwegisch',
fi:'Finnisch', is:'Isländisch', ru:'Russisch', uk:'Ukrainisch',
et:'Estnisch', lv:'Lettisch', lt:'Litauisch', mk:'Mazedonisch',
sq:'Albanisch'
})[iso] || iso;
}
// ===== Karten-Tap =====
map.on('click', async (e) => {
if (!mapInteractive || !game || !game.currentOrder) return;
playSfx('tap-map');
const tap = { lat: e.latlng.lat, lon: e.latlng.lng };
const result = window.BusfahrtEngine.checkTap(game, tap);
if (!result) return;
paintAttempt(tap);
updateAttemptDot(game.attempts.length, result.score);
if (result.resolved) {
mapInteractive = false;
if (result.score === 3) playSfx('hit-perfect');
else if (result.score === 1) playSfx('hit-ok');
else playSfx('attempts-empty');
// Order-Card sofort umschalten — Tour ist erledigt, kein weiterer Tap.
const orderCard = document.getElementById('order-card');
orderCard.classList.add('resolved');
document.getElementById('attempt-label').textContent =
result.score === 3 ? '✓ Treffer!' : (result.score === 1 ? 'Ungenau' : 'Versuche aufgebraucht');
document.getElementById('order-hint').classList.add('hidden');
document.getElementById('phase-label').textContent = '🚌 Bus fährt zum Ziel …';
await runResolution(tap);
} else {
playSfx('hit-miss');
showHint(result);
document.getElementById('attempt-label').textContent =
`Versuch ${game.attempts.length + 1} / 3`;
setTimeout(() => clearTapMarker(), 1500);
}
});
function paintAttempt(tap) {
if (currentTapMarker) map.removeLayer(currentTapMarker);
currentTapMarker = L.marker([tap.lat, tap.lon], { icon: tapIcon }).addTo(map);
}
function updateAttemptDot(attemptNum, score) {
const d = document.getElementById('dot-' + attemptNum);
if (!d) return;
if (score === 3) d.classList.add('good');
else if (score === 1) d.classList.add('ok');
else d.classList.add('miss');
}
function showHint(result) {
const hintEl = document.getElementById('order-hint');
hintEl.textContent = result.hint;
hintEl.classList.remove('hidden');
}
function clearTapMarker() {
if (currentTapMarker) { map.removeLayer(currentTapMarker); currentTapMarker = null; }
}
function clearMapMarkers() {
clearTapMarker();
if (currentTargetMarker) { map.removeLayer(currentTargetMarker); currentTargetMarker = null; }
if (currentLine) { map.removeLayer(currentLine); currentLine = null; }
// busMarker bleibt persistent — Bus steht da wo er zuletzt angehalten hat.
}
// ===== Auftrag auflösen: Map fly + Route fetch + Bus animieren + settle + InfoCard =====
async function runResolution(tap) {
const order = game.currentOrder;
const target = window.BusfahrtEngine.getCity(order.cityId);
const fromCity = window.BusfahrtEngine.getCity(game.lastCity);
if (!target || !fromCity) return;
// Map auf den ganzen Streckenbereich (Start, Tap, Ziel) fliegen
const all = [
[fromCity.lat, fromCity.lon],
[tap.lat, tap.lon],
[target.lat, target.lon],
];
const bounds = L.latLngBounds(all).pad(0.3);
map.flyToBounds(bounds, { maxZoom: 7, duration: 0.85, easeLinearity: 0.25 });
// Ziel-Marker (kommt erst nach Map-Flug)
setTimeout(() => {
if (currentTargetMarker) map.removeLayer(currentTargetMarker);
currentTargetMarker = L.marker([target.lat, target.lon], { icon: targetIcon })
.addTo(map)
.bindTooltip(target.title, {
permanent: true, direction: 'top', offset: [0, -26],
className: 'bf-target-tip',
});
}, 400);
// Stoppuhr stoppen — Zeit zwischen Auftragsstart und Tap-Auflösung
stopTimer(false);
const timeSec = timerElapsedSec;
const timeBonusEur = computeTimeBonus(timeSec);
// BEIDE Routen parallel: direkt + via Tap (für konsistenten Umweg-Vergleich).
// Für bekannte Übergänge (Kanaltunnel, Öresund, Irische See) Pseudo-Waypoints
// einfügen, damit die Route NICHT diagonal übers Meer "fliegt".
const crossing = _crossingWaypoints(fromCity, target);
const directWp = [
[fromCity.lat, fromCity.lon],
...crossing,
[target.lat, target.lon],
];
const viaWp = [
[fromCity.lat, fromCity.lon],
[tap.lat, tap.lon],
...crossing,
[target.lat, target.lon],
];
let [directRoute, viaRoute] = await Promise.all([fetchRoute(directWp), fetchRoute(viaWp)]);
// Schiff-Modus: wenn (a) OSRM keine Auto-Route findet, (b) Start oder Ziel
// in einem Inselland ohne Auto-Verbindung liegt, oder (c) die Auto-Route
// unplausibel viel länger ist als die Luftlinie (= OSRM hat sich um
// halben Kontinent geschlängelt). Alle drei Fälle → Bus auf Fähre.
let isShip = false;
if (!directRoute || !viaRoute) {
isShip = true;
} else {
const airKm = haversineKm(fromCity.lat, fromCity.lon, target.lat, target.lon);
if (directRoute.distanceKm > airKm * 1.8 && airKm > 300) isShip = true;
if (!isShip && _busRequiresFerry(fromCity, target)) isShip = true;
// Strecken mit bekannter Meeresquerung — auch wenn OSRM eine Auto-Route
// anbietet, ist es didaktisch Eurotunnel/Fähre/Öresund → Schiff-Anzeige.
if (!isShip && crossing.length) isShip = true;
}
if (isShip) {
directRoute = fallbackRoute(directWp);
viaRoute = (game.attempts[game.attempts.length-1].distanceKm < 10)
? directRoute
: fallbackRoute(viaWp);
document.getElementById('phase-label').textContent = '⛴️ Fähre fährt zum Ziel …';
}
// Animationsdauer der Distanz anpassen — fixe 2.5 s wirken bei 1 900 km
// wie ein Beamen. Skalieren: 2 s + 5 ms/km, gedeckelt 8 s.
const totalKm = (viaRoute && viaRoute.distanceKm) || directRoute.distanceKm || 0;
const animMs = Math.max(2500, Math.min(8000, 2000 + totalKm * 5));
// Bonus-Phasen mitten auf der Fahrt — Story: erst Grenze/Vignette (~40 %),
// dann ein Stück weiter, dann Sprache/Café (~75 %), dann Ankunft.
// Wir spielen die Extras-Queue an EINEM Punkt (~55 %) hintereinander ab.
const hasExtras = !!(game.currentExtras && game.currentExtras.queue.length);
if (hasExtras) {
document.getElementById('phase-label').textContent = '🚏 Halt auf der Strecke …';
await animateBusAlongRoute(viaRoute, animMs, isShip, { tProgressStart: 0, tProgressEnd: 0.55 });
await _playExtrasModal();
document.getElementById('phase-label').textContent = isShip ? '⛴️ Fähre fährt zum Ziel …' : '🚌 Bus fährt zum Ziel …';
await animateBusAlongRoute(viaRoute, animMs, isShip, { tProgressStart: 0.55, tProgressEnd: 1 });
} else {
await animateBusAlongRoute(viaRoute, animMs, isShip);
}
// Nach der Animation: Marker zurück auf Bus (wir steigen am Hafen aus)
if (isShip && busMarker) busMarker.setIcon(busIcon);
// Settle: beide Distanzen aus derselben Quelle, plus Schiff-Flag und Zeit-Bonus
const report = window.BusfahrtEngine.settleOrder(game, {
directDistanceKm: directRoute.distanceKm,
actualDistanceKm: viaRoute.distanceKm,
isShip: isShip,
timeBonusEur: timeBonusEur,
timeSec: timeSec,
});
if (!report) return;
playSfx(report.won ? 'bid-win' : 'bid-lose');
showInfoCard(report);
drawCharts();
updateTopbar();
}
// ===== InfoCard mit didaktischem Marge-Block =====
function showInfoCard(report) {
const city = window.BusfahrtEngine.getCity(report.cityId);
if (!city) return;
document.getElementById('info-title').textContent = city.title;
const img = document.getElementById('info-img');
img.style.display = '';
if (city.imagePath) {
img.src = (window.BUSFAHRT_BASE || './') + city.imagePath;
img.alt = city.title;
} else {
img.style.display = 'none';
}
document.getElementById('info-country').textContent = '🌍 ' + city.country;
document.getElementById('info-pop').textContent = '👥 ' + formatPop(city.population_million);
const cap = document.getElementById('info-capital');
if (city.isCapital) {
cap.textContent = '⭐ Hauptstadt';
cap.style.display = '';
} else {
cap.style.display = 'none';
}
// Topo-Score
const distEl = document.getElementById('info-distance');
const km = Math.round(report.tapDistanceKm);
let scoreLabel, klass;
if (report.finalScore === 3) { scoreLabel = `🟢 Treffer! Nur ${km} km daneben — 3 Punkte`; klass = 'good'; }
else if (report.finalScore === 1) { scoreLabel = `🟠 Ungenau — ${km} km daneben — 1 Punkt`; klass = 'ok'; }
else { scoreLabel = `🔴 Versuche aufgebraucht — ${km} km daneben`; klass = 'miss'; }
distEl.textContent = scoreLabel;
distEl.className = 'bf-info-distance ' + klass;
// Strecken-spezifischer Übergang (Fähre / Tunnel / Sondermaut) — eigene
// Sachinfo-Box mit ausklappbarer Erklärung + Glossar-Link.
const crossWrap = document.getElementById('info-crossing');
if (report.crossingFee && report.crossingFee > 0) {
document.getElementById('info-crossing-label').innerHTML =
esc(report.crossingLabel) + ' <span style="color:#0d47a1;font-weight:700">(' + formatEur(report.crossingFee) + ')</span>';
document.getElementById('info-crossing-text').textContent = report.crossingSachinfo || '';
document.getElementById('info-crossing-glossar').innerHTML = report.crossingGlossar
? '<b>Glossar:</b> <a href="' + glossarHref(report.crossingGlossar) + '" target="_blank">' + esc(report.crossingGlossar) + '</a>'
: '';
crossWrap.style.display = '';
crossWrap.open = true; // beim ersten Mal aufgeklappt
} else {
crossWrap.style.display = 'none';
}
// Marge-Block: Endergebnis groß oben, Details darunter
const margBlock = document.getElementById('info-margin');
margBlock.classList.toggle('won', report.margin >= 0);
margBlock.classList.toggle('lost', report.margin < 0);
const detourKm = Math.round(report.detourKm);
const sign = report.margin >= 0 ? '+' : '';
document.getElementById('margin-headline-label').textContent =
report.margin >= 0 ? 'Marge' : 'Verlust';
document.getElementById('margin-headline-value').textContent =
sign + formatEur(report.margin);
let lesson;
if (detourKm <= 5) {
lesson = '✓ Punktlandung — du holst die volle Marge raus.';
} else if (detourKm < 100) {
lesson = `Kleiner Umweg von ${detourKm} km — kostet ${formatEur(detourKm * 1.00)}, frisst aber noch nicht die ganze Marge.`;
} else if (report.margin >= 0) {
lesson = `Umweg von ${detourKm} km. Du hast trotzdem Plus gemacht — knapp.`;
} else {
lesson = `Umweg von ${detourKm} km. Der zog ${formatEur(detourKm * 1.00)} aus der Kasse — diese Tour ging ins Minus.`;
}
document.getElementById('margin-lesson').textContent = lesson;
// Buchhaltungs-Tabelle (Monospace, Zwischenschritte)
const rows = document.getElementById('margin-rows');
const fmtSigned = n => (n >= 0 ? '+' : '') + formatEur(Math.abs(n));
const subtotal = (report.baseMargin != null) ? report.baseMargin : (report.playerPrice - report.busCost);
const subClass = subtotal >= 0 ? 'pos' : 'neg';
const margClass = report.margin >= 0 ? 'pos' : 'neg';
const detourTxt = detourKm > 0 ? '+ ' + formatKm(detourKm) : '0 km';
const vehicleLabel = report.isShip ? 'Schiff' : 'Bus';
// Quiz-Bilanz: in evaluateQuestion bereits live aufs Budget gebucht.
// Hier transparent ausweisen + in die Tour-Marge der Buchhaltung mit reinrechnen.
const quizBonus = report.quizBonus || 0;
const quizPenalty = report.quizPenalty || 0;
const quizDelta = quizBonus - quizPenalty;
const tourMargin = report.margin + quizDelta;
const tourMargClass = tourMargin >= 0 ? 'pos' : 'neg';
let html = '<div class="bf-calc">';
html += `<div>Direkte Strecke</div><div class="v">${formatKm(report.realDistanceKm)}</div>`;
html += `<div>Tatsächlich gefahren</div><div class="v">${formatKm(report.actualDistanceKm)}${report.isShip ? ' ⛴️' : ''}</div>`;
html += `<div class="detour ${detourKm <= 5 ? 'zero' : ''}">davon Umweg</div>`;
html += `<div class="v detour ${detourKm <= 5 ? 'zero' : ''}">${detourTxt}</div>`;
html += '<div class="bf-calc__sep"></div>';
html += `<div>Einnahmen (Verkauf)</div><div class="v pos">+${formatEur(report.playerPrice)}</div>`;
html += `<div>Ausgaben (${vehicleLabel}-Kosten)</div><div class="v neg">${formatEur(report.busCost)}</div>`;
// Strecken-spezifische Zusatzkosten (Maut, Tunnel, Fähre)
if (report.crossingFee && report.crossingFee > 0) {
html += `<div>${esc(report.crossingLabel || 'Zusatz-Maut')}</div>`;
html += `<div class="v neg">${formatEur(report.crossingFee)}</div>`;
}
html += '<div class="bf-calc__sep"></div>';
const showSubtotal = report.timeBonus > 0 || quizBonus > 0 || quizPenalty > 0;
if (showSubtotal) {
html += `<div class="total">Zwischensumme</div><div class="v total ${subClass}">${fmtSigned(subtotal)}</div>`;
if (report.timeBonus > 0) {
html += `<div>Zeit-Bonus (${Math.round(report.timeSec)} s)</div><div class="v pos">+${formatEur(report.timeBonus)}</div>`;
}
if (quizBonus > 0) {
html += `<div>Quiz-Bonus (1. Versuch)</div><div class="v pos">+${formatEur(quizBonus)}</div>`;
}
if (quizPenalty > 0) {
html += `<div>Quiz-Strafe (Fehlversuche)</div><div class="v neg">${formatEur(quizPenalty)}</div>`;
}
html += '<div class="bf-calc__sep"></div>';
}
html += `<div class="total">Marge</div><div class="v total ${tourMargClass}">${fmtSigned(tourMargin)}</div>`;
html += '</div>';
rows.innerHTML = html;
// Headline-Marge inklusive Quiz, damit die Karte nicht zwei verschiedene
// „Marge"-Zahlen zeigt (Tabelle vs. Headline).
document.getElementById('margin-headline-value').textContent =
(tourMargin >= 0 ? '+' : '') + formatEur(Math.abs(tourMargin));
document.getElementById('margin-headline-label').textContent =
tourMargin >= 0 ? 'Marge' : 'Verlust';
document.getElementById('info-margin').className = 'bf-margin ' + (tourMargin >= 0 ? 'won' : 'lost');
// Lesson erweitern wenn Schiff
if (report.isShip) {
const txt = document.getElementById('margin-lesson').textContent;
document.getElementById('margin-lesson').textContent =
'⛴️ Schiffstour übers Meer — die Strecke wurde aufs Schiff verladen. ' + txt;
}
document.getElementById('info-text').textContent = pickText(city.infoText);
const isLast = game.orderIndex >= totalOrders;
document.getElementById('info-next-btn').textContent = isLast ? 'Auswertung →' : 'Nächster Auftrag →';
const card = document.getElementById('info-card');
card.classList.remove('hidden');
card.style.animation = 'none'; void card.offsetWidth; card.style.animation = '';
card.scrollTop = 0;
playSfx('order-resolved');
}
function closeInfo() {
document.getElementById('info-card').classList.add('hidden');
// Vor dem nächsten Auftrag prüfen: gibt es ein passendes Zwischen-Ereignis?
// (Realismus-Filter: Schengen-Außengrenze, Alpen-Pass, Winter, …) — siehe
// Engine.pickEventForOrder. Wenn ja, Event-Modal zeigen; sonst direkt weiter.
const ev = window.BusfahrtEngine.pickEventForOrder(game);
if (ev) showEvent(ev);
else loadNextOrder();
}
function nextOrder() { closeInfo(); }
// ===== Ereignis-System (Zwischen-Auftrags-Events) =====
// SVG-Icons im offiziellen Bildstil (Forest-Green-Gradient-Hintergrund + weiße
// Linien-Icons). Bewusst piktogrammatisch — DALL-E-3 ist aktuell blockiert
// (Account-Limit), Inline-SVGs halten die App trotzdem visuell stimmig.
const BF_EVENT_ICONS = {
'passenger-late':
'<svg viewBox="0 0 64 64" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">'+
'<circle cx="32" cy="18" r="7"/><path d="M18 54v-6c0-7 6-13 14-13s14 6 14 13v6"/>'+
'<circle cx="48" cy="48" r="10" fill="#e8c547" stroke="#fff"/>'+
'<path d="M48 42v6l4 3" stroke="#1f4b37" stroke-width="2.5"/></svg>',
'border-control':
'<svg viewBox="0 0 64 64" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">'+
'<rect x="6" y="22" width="52" height="22" rx="3"/><path d="M14 22V10h36v12"/>'+
'<path d="M22 30h6M22 36h20M22 22v22M42 22v22"/>'+
'<circle cx="50" cy="14" r="3" fill="#e8c547" stroke="none"/></svg>',
'tire-flat':
'<svg viewBox="0 0 64 64" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">'+
'<ellipse cx="32" cy="40" rx="22" ry="14"/><ellipse cx="32" cy="40" rx="10" ry="6" fill="#1f4b37" stroke="#fff"/>'+
'<path d="M10 40c0-3 1-6 3-8M54 40c0-3-1-6-3-8" stroke-dasharray="4 3"/>'+
'<path d="M18 24l-6-6M46 24l6-6" stroke="#e8c547"/></svg>',
'brakes-hot':
'<svg viewBox="0 0 64 64" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">'+
'<circle cx="32" cy="38" r="16"/><circle cx="32" cy="38" r="6" fill="#fff" stroke="none"/>'+
'<path d="M22 16c0 4 4 4 4 8s-4 4-4 8M32 12c0 4 4 4 4 8s-4 4-4 8M42 16c0 4 4 4 4 8s-4 4-4 8" stroke="#e8c547"/></svg>',
'tachograph':
'<svg viewBox="0 0 64 64" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">'+
'<circle cx="32" cy="32" r="22"/><path d="M32 14v6M50 32h-6M32 50v-6M14 32h6"/>'+
'<path d="M32 32l10-12" stroke="#e8c547" stroke-width="3.5"/>'+
'<circle cx="32" cy="32" r="2.5" fill="#e8c547" stroke="none"/></svg>',
'tunnel-traffic':
'<svg viewBox="0 0 64 64" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">'+
'<path d="M8 50V32a24 24 0 0148 0v18"/><path d="M8 50h48"/>'+
'<rect x="20" y="38" width="10" height="10" rx="1" fill="#e8c547" stroke="none"/>'+
'<rect x="34" y="38" width="10" height="10" rx="1" fill="#e8c547" stroke="none"/></svg>',
'vignette':
'<svg viewBox="0 0 64 64" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">'+
'<rect x="12" y="14" width="40" height="36" rx="4"/><path d="M20 24h24M20 32h24M20 40h14"/>'+
'<circle cx="46" cy="44" r="6" fill="#e8c547" stroke="none"/>'+
'<path d="M44 44l2 2 4-4" stroke="#1f4b37" stroke-width="2.5"/></svg>',
'snow-chains':
'<svg viewBox="0 0 64 64" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">'+
'<circle cx="32" cy="32" r="18"/><circle cx="32" cy="32" r="6" fill="#1f4b37" stroke="#fff"/>'+
'<path d="M18 18l4 4M46 18l-4 4M18 46l4-4M46 46l-4-4"/>'+
'<path d="M14 32h4M50 32h-4M32 14v4M32 50v-4" stroke="#e8c547"/></svg>',
'theft':
'<svg viewBox="0 0 64 64" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">'+
'<rect x="14" y="26" width="36" height="26" rx="2"/><path d="M14 38h36M22 26v-6a10 10 0 0120 0v6"/>'+
'<circle cx="42" cy="20" r="6" fill="#e8c547" stroke="none"/>'+
'<path d="M40 20l2 2 4-4" stroke="#1f4b37" stroke-width="2.5"/></svg>',
'medical-emergency':
'<svg viewBox="0 0 64 64" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">'+
'<rect x="10" y="14" width="44" height="36" rx="4"/><path d="M32 22v20M22 32h20" stroke="#e8c547" stroke-width="5"/></svg>',
};
function eventIconHtml(key) {
return BF_EVENT_ICONS[key] || BF_EVENT_ICONS['medical-emergency'];
}
let currentEvent = null;
function showEvent(ev) {
currentEvent = ev;
document.getElementById('event-icon').innerHTML = eventIconHtml(ev.icon);
document.getElementById('event-title').textContent = pickText(ev.title);
document.getElementById('event-intro').textContent = pickText(ev.intro);
// Sachinfo-Block aufbauen (zugeklappt, schülerfreundlich)
const sachWrap = document.getElementById('event-sachinfo');
const di = ev.didactic || {};
if (di.sachinfo) {
document.getElementById('event-sachinfo-text').textContent = di.sachinfo;
const lp = Array.isArray(di.lehrplan) ? di.lehrplan : [];
document.getElementById('event-lehrplan').innerHTML = lp.length
? '<b>Lehrplan:</b> ' + lp.map(s => esc(s)).join(' · ') : '';
const gl = Array.isArray(di.glossary) ? di.glossary : [];
document.getElementById('event-glossary').innerHTML = gl.length
? '<b>Glossar:</b> ' + gl.map(s => `<a href="${glossarHref(s)}" target="_blank">${esc(s)}</a>`).join(' · ') : '';
sachWrap.style.display = '';
} else { sachWrap.style.display = 'none'; }
// Quiz oder direkte Wahl?
const fb = document.getElementById('event-feedback'); fb.style.display = 'none';
const cont = document.getElementById('event-continue'); cont.style.display = 'none';
document.getElementById('event-choices').innerHTML = '';
if (ev.quiz) {
// Quiz-Modus: erst Quiz-Antwort, dann automatisch Choice 0 (richtig) / 1 (falsch)
document.getElementById('event-quiz').style.display = '';
document.getElementById('event-quiz-q').textContent = pickText(ev.quiz.question);
const qcont = document.getElementById('event-quiz-choices');
qcont.innerHTML = '';
document.getElementById('event-quiz-feedback').style.display = 'none';
ev.quiz.choices.forEach((c, idx) => {
const b = document.createElement('button');
b.className = 'bf-choice';
b.textContent = c;
b.onclick = () => onEventQuizPick(idx);
qcont.appendChild(b);
});
} else {
document.getElementById('event-quiz').style.display = 'none';
renderEventChoices();
}
document.getElementById('event-overlay').classList.add('show');
}
function onEventQuizPick(pickedIdx) {
const ev = currentEvent;
if (!ev || !ev.quiz) return;
const correct = pickedIdx === ev.quiz.correctIndex;
const fb = document.getElementById('event-quiz-feedback');
fb.textContent = (correct ? 'Richtig! ' : 'Leider falsch. ') + (ev.quiz.explanation || '');
fb.className = 'bf-event-quiz-feedback ' + (correct ? 'right' : 'wrong');
fb.style.display = '';
// alle Quiz-Buttons sperren, markieren
const qcont = document.getElementById('event-quiz-choices');
Array.from(qcont.children).forEach((b, i) => {
b.disabled = true;
if (i === ev.quiz.correctIndex) b.classList.add('right');
if (i === pickedIdx && i !== ev.quiz.correctIndex) b.classList.add('wrong');
});
// Event-Choice automatisch: choices[0] = richtig, choices[1] = falsch
setTimeout(() => applyEventChoiceByIndex(correct ? 0 : 1), 1200);
}
function renderEventChoices() {
const cont = document.getElementById('event-choices');
cont.innerHTML = '';
currentEvent.choices.forEach((c, idx) => {
const b = document.createElement('button');
b.className = 'bf-choice';
const sign = (c.deltaEur || 0) >= 0 ? '+' : '';
const eur = formatEur(Math.abs(c.deltaEur || 0));
const min = c.deltaMin || 0;
b.innerHTML = `<div style="font-weight:700">${esc(pickText(c.label))}</div>`
+ `<div style="font-size:12px;color:#5b6358;margin-top:4px">${sign}${eur}${min ? ' · +' + min + ' Min' : ''}</div>`;
b.onclick = () => applyEventChoiceByIndex(idx);
cont.appendChild(b);
});
}
function applyEventChoiceByIndex(idx) {
const ev = currentEvent;
if (!ev) return;
const result = window.BusfahrtEngine.applyEventChoice(game, ev.id, idx);
if (!result) return;
updateTopbar();
const fb = document.getElementById('event-feedback');
let msg = result.feedback || '';
if (result.deltaEur || result.deltaMin) {
const sign = result.deltaEur >= 0 ? '+' : '';
const eur = formatEur(Math.abs(result.deltaEur));
msg += `\n\n💰 ${sign}${eur}${result.deltaMin ? ' · +' + result.deltaMin + ' Min' : ''}`;
}
if (result.penaltyHint) msg += '\n\n⚠ ' + result.penaltyHint;
fb.style.whiteSpace = 'pre-line';
fb.textContent = msg;
fb.style.display = '';
// Choices sperren, Continue zeigen
Array.from(document.getElementById('event-choices').children).forEach((b, i) => {
b.disabled = true;
if (i === idx) b.classList.add('right');
});
document.getElementById('event-continue').style.display = 'inline-flex';
}
function closeEvent() {
document.getElementById('event-overlay').classList.remove('show');
currentEvent = null;
loadNextOrder();
}
// Glossar-Link bauen — Apache-Routing leitet /glossar.html → /glossar.php.
// Zwei Ebenen aus BUSFAHRT_BASE rausgehen, damit der Pfad sowohl lokal
// (/geograsim/App/sims/busfahrt/ → /geograsim/App/) als auch produktiv
// (/sims/busfahrt/ → /) auf die richtige App-Wurzel zeigt.
function glossarHref(slug) {
// glossar.php erwartet ?term=<slug>. Extension-less URL — Apache-Rewrite
// leitet /glossar → pages/glossar.php. Pfad relativ zur App-Wurzel:
// lokal /geograsim/App/glossar, produktiv /glossar.
var base = window.BUSFAHRT_BASE || '/';
return base.replace(/sims\/busfahrt\/?$/, '') + 'glossar?term=' + encodeURIComponent(slug);
}
// Mini-Escape-Helper (für Sachinfo + Glossar-Links)
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
// ===== Stats-Charts (SVG) =====
function drawCharts() {
if (!game) return;
// Budget-Verlauf
const budgetSeries = [game.reports.length ? 5000 : 5000];
let b = 5000;
game.reports.forEach(r => {
b += r.margin;
budgetSeries.push(b);
});
drawLineChart('chart-budget', budgetSeries, 5000, '#1f4b37');
document.getElementById('stats-budget-val').textContent = formatEur(game.budget);
const lastDelta = game.reports.length ? game.reports[game.reports.length - 1].margin : 0;
document.getElementById('stats-budget-hint').textContent = game.reports.length
? `letzte Marge: ${lastDelta >= 0 ? '+' : ''}${formatEur(lastDelta)}`
: 'Start: 5.000 €';
// Genauigkeit (km daneben)
const accSeries = game.reports.map(r => Math.round(r.tapDistanceKm));
drawBarChart('chart-accuracy', accSeries);
if (accSeries.length) {
const last = accSeries[accSeries.length - 1];
document.getElementById('stats-acc-val').textContent = last + ' km';
const avg = Math.round(accSeries.reduce((a,b)=>a+b,0) / accSeries.length);
document.getElementById('stats-acc-hint').textContent = `Schnitt: ${avg} km daneben`;
} else {
document.getElementById('stats-acc-val').textContent = '—';
document.getElementById('stats-acc-hint').textContent = 'km daneben pro Auftrag';
}
}
function drawLineChart(elId, values, refLine, color) {
const svg = document.getElementById(elId);
if (!svg) return;
const W = 200, H = 50, pad = 4;
if (!values.length) { svg.innerHTML = ''; return; }
const minV = Math.min(...values, refLine);
const maxV = Math.max(...values, refLine);
const range = (maxV - minV) || 1000;
const yFor = v => H - pad - ((v - minV) / range) * (H - 2*pad);
const xFor = i => pad + (values.length === 1 ? (W - 2*pad)/2 : (i / (values.length - 1)) * (W - 2*pad));
const refY = yFor(refLine);
const points = values.map((v,i) => `${xFor(i).toFixed(1)},${yFor(v).toFixed(1)}`).join(' ');
const dots = values.map((v,i) =>
`<circle cx="${xFor(i).toFixed(1)}" cy="${yFor(v).toFixed(1)}" r="2" fill="${color}"/>`
).join('');
// Bereich unter der Linie subtil füllen
const areaPts = `${pad},${H-pad} ${points} ${(W-pad).toFixed(1)},${H-pad}`;
svg.innerHTML = `
<line x1="0" y1="${refY.toFixed(1)}" x2="${W}" y2="${refY.toFixed(1)}"
stroke="#999" stroke-dasharray="2,2" stroke-width="0.6"/>
<polygon points="${points} ${xFor(values.length-1).toFixed(1)},${(H-pad).toFixed(1)} ${pad},${(H-pad).toFixed(1)}"
fill="${color}" fill-opacity="0.15"/>
<polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.6"/>
${dots}
`;
}
function drawBarChart(elId, values) {
const svg = document.getElementById(elId);
if (!svg) return;
if (!values.length) { svg.innerHTML = ''; return; }
const W = 200, H = 50, pad = 4;
const maxV = Math.max(...values, 200);
const slot = (W - 2*pad) / Math.max(values.length, 8);
const barW = slot * 0.7;
const offset = (slot - barW) / 2;
const bars = values.map((v, i) => {
const x = pad + slot * i + offset;
const h = Math.max(1, (v / maxV) * (H - 2*pad));
const y = H - pad - h;
let fill = '#2e7d32';
if (v >= 200) fill = '#c62828';
else if (v >= 50) fill = '#ed6c02';
return `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${h.toFixed(1)}" fill="${fill}" rx="1"/>`;
}).join('');
svg.innerHTML = `
<line x1="0" y1="${(H - pad - (50 / maxV) * (H - 2*pad)).toFixed(1)}"
x2="${W}" y2="${(H - pad - (50 / maxV) * (H - 2*pad)).toFixed(1)}"
stroke="#1b5e20" stroke-dasharray="1,2" stroke-width="0.5"/>
${bars}
`;
}
// ===== EndScreen + Fahrtenbuch =====
function showEndScreen() {
const ECON = window.BusfahrtEngine.ECON;
const maxTopo = totalOrders * 3;
const ratio = game.score / maxTopo;
let stars;
if (ratio >= 0.85) stars = '⭐⭐⭐';
else if (ratio >= 0.55) stars = '⭐⭐';
else if (ratio >= 0.30) stars = '⭐';
else stars = '—';
document.getElementById('end-stars').textContent = stars;
const wonCount = game.reports.filter(r => r.won).length;
const quizFirstTry = game.reports.filter(r => r.quizFirstTry).length;
const quizBonusSum = game.reports.reduce((s, r) => s + (r.quizBonus || 0), 0);
const quizPenaltySum = game.reports.reduce((s, r) => s + (r.quizPenalty || 0), 0);
const quizDelta = quizBonusSum - quizPenaltySum;
document.getElementById('end-topo-points').textContent = `${game.score} / ${maxTopo}`;
document.getElementById('end-won-count').textContent = `${wonCount} / ${totalOrders}`;
document.getElementById('end-quiz-firsttry').textContent = `${quizFirstTry} / ${totalOrders}`;
document.getElementById('end-earnings').textContent = (game.earnings >= 0 ? '+' : '') + formatEur(game.earnings);
if (quizBonusSum > 0 || quizPenaltySum > 0) {
document.getElementById('end-quiz-row').style.display = '';
document.getElementById('end-quiz-delta').textContent =
(quizDelta >= 0 ? '+' : '') + formatEur(Math.abs(quizDelta));
}
document.getElementById('end-budget').textContent = formatEur(game.budget);
const target = (typeof ECON.targetBudget === 'object')
? (ECON.targetBudget[game.levelId] || 7500)
: ECON.targetBudget;
const verdEl = document.getElementById('end-verdict');
if (game.budget >= target) {
verdEl.innerHTML = `🎉 Super! Du hast das Tour-Ziel von <strong>${formatEur(target)}</strong> erreicht.`;
} else if (game.budget >= ECON.startBudget) {
verdEl.innerHTML = `Solide Tour. Ziel war ${formatEur(target)} — Marge zu klein, zu viele Umwege.`;
} else {
verdEl.innerHTML = `🚧 Verlust. Tipp: genauer auf die Karte tippen — Umwege fressen die Marge auf.`;
}
const tbl = document.getElementById('end-logbook');
tbl.innerHTML = '<tr><th>#</th><th>Tour</th><th>Topo</th><th>Marge</th></tr>';
game.reports.forEach((r, i) => {
const fromCity = window.BusfahrtEngine.getCity(r.fromCityId);
const toCity = window.BusfahrtEngine.getCity(r.cityId);
const fromName = fromCity ? fromCity.title : r.fromCityId;
const toName = toCity ? toCity.title : r.cityId;
const scoreEmoji = r.finalScore === 3 ? '🟢' : (r.finalScore === 1 ? '🟠' : '🔴');
// Tour-Marge in der Tabelle inkludiert Quiz-Effekt, damit die Summe stimmt.
const quizDeltaRow = (r.quizBonus || 0) - (r.quizPenalty || 0);
const tourMargin = r.margin + quizDeltaRow;
const margClass = tourMargin > 0 ? 'pos' : (tourMargin < 0 ? 'neg' : '');
const margTxt = (tourMargin >= 0 ? '+' : '') + formatEur(tourMargin);
let quizBadge = '';
if (r.quizFirstTry) quizBadge = ' · 🧠 ✓';
else if (r.quizAttemptsCount > 1) quizBadge = ` · 🧠 ${r.quizAttemptsCount} Versuche`;
else if (r.quizAttemptsCount === 1) quizBadge = ' · 🧠 ✓';
const tr = document.createElement('tr');
if (tourMargin < 0) tr.className = 'lost-row';
if (r.isNight) tr.classList.add('night-row');
tr.innerHTML =
`<td>${i + 1}</td>` +
`<td>${fromName} → <strong>${toName}</strong><br><small>${Math.round(r.realDistanceKm)} km direkt · ${Math.round(r.actualDistanceKm)} km gefahren${r.detourKm > 5 ? ' · +'+Math.round(r.detourKm)+' km Umweg' : ''}${quizBadge}</small></td>` +
`<td class="score-cell">${scoreEmoji}</td>` +
`<td class="margin-cell ${margClass}">${margTxt}</td>`;
tbl.appendChild(tr);
});
document.getElementById('order-card').classList.add('hidden');
document.getElementById('info-card').classList.add('hidden');
clearMapMarkers();
document.getElementById('end-overlay').classList.add('show');
playSfx('tour-end');
// Submit ans Lehrer-Cockpit (war früher der berüchtigte Atlas-Bug —
// Schüler-Ergebnisse verschwanden ins Nichts. Seit 2026-05-30 gefixt.)
submitBusfahrtTour();
}
// ===== Live-View für Lehrkräfte (Plattform-Hook) =====
// Wird vom live-client.js alle ~4 s an /api/live gesendet.
// Klein halten (< 1 KB), stabile Schlüssel, didaktisch sinnvoll.
window.GGS_LIVE_STATE = function () {
if (!game) return null;
const reports = game.reports || [];
const wonCount = reports.filter(r => r.won).length;
const lostCount = reports.length - wonCount;
const lastR = reports[reports.length - 1] || null;
const lastCity = window.BusfahrtEngine.getCity(game.lastCity);
const avgKm = reports.length
? Math.round(reports.reduce((s, r) => s + (r.tapDistanceKm || 0), 0) / reports.length)
: null;
const isNight = !!(game.currentOrder && game.currentOrder.isNight);
const quizFirstTry = reports.filter(r => r.quizFirstTry).length;
return {
levelId: game.levelId,
orderIndex: game.orderIndex,
totalOrders: totalOrders,
budget: Math.round(game.budget),
topoPoints: game.score,
topoMax: totalOrders * 3,
leistung_pct: (totalOrders*3)===0 ? null : Math.max(0,Math.min(100,Math.round(game.score/(totalOrders*3)*100))),
wonOrders: wonCount,
lostOrders: lostCount,
lastCityTitle: lastCity ? lastCity.title : null,
lastCityId: game.lastCity,
lastMargin: lastR ? lastR.margin : null,
avgTapKm: avgKm,
isNight: isNight,
isShipLast: lastR ? !!lastR.isShip : false,
quizFirstTry: quizFirstTry,
};
};
// ===== Submit-Hook =====
// Am Tour-Ende einen Eintrag in `assessments` schreiben, damit das
// Lehrer-Cockpit Schüler-Ergebnisse sieht (analog Klima-/Sonnensystem-Submit).
// Fix für Atlas-Bug-Bericht vom 2026-05-05 (Submit hat komplett gefehlt).
function submitBusfahrtTour() {
if (!window.BUSFAHRT_SESSION_ID) return;
const api = (window.BUSFAHRT_API_BASE || '/geograsim/App/php/api') + '/progress.php';
const total = game.tourOrders.length || 1;
const wonCount = game.reports.filter(r => r.won).length;
const quizFirstTry = game.reports.filter(r => r.quizFirstTry).length;
const ratio = game.score / (total * 3);
const stars = ratio >= 0.85 ? 3 : (ratio >= 0.55 ? 2 : (ratio >= 0.30 ? 1 : 0));
try {
fetch(api, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
sim_id: 'busfahrt',
action: 'submit_assessment',
data: {
level: game.levelId, stars,
score: Math.round(game.score),
duration_ms: Date.now() - (game._startedAt || Date.now()),
completed: true,
results: {
budget: game.budget,
earnings: game.earnings,
wonOrders: wonCount,
totalOrders: total,
quizFirstTry: quizFirstTry,
eventCount: (game.eventLog || []).length,
leistung_pct: (total*3)===0 ? null : Math.max(0,Math.min(100,Math.round(game.score/(total*3)*100))),
}
}
})
}).catch(()=>{});
} catch (_) {}
}
// ===== Boot =====
boot().catch(err => {
console.error('[busfahrt] boot failed', err);
});
console.log('[busfahrt] V1.1 (Umweg-Marge + Nachtfahrt + Charts).',
'mode:', window.BUSFAHRT_SESSION_MODE,
'level:', window.BUSFAHRT_FORCED_LEVEL || 'l1',
'easy:', window.STUDENT_EASY,
'blackMap:', BLACK_MAP_INITIAL);
</script>
</body>
</html>