41731254b1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2477 lines
103 KiB
HTML
2477 lines
103 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||
<title>🚌 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; right: 8px; z-index: 1001;
|
||
display: flex; gap: 8px; align-items: center;
|
||
font-size: 13px; flex-wrap: wrap;
|
||
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: 84px; 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: 64px; 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-spacer"></div>
|
||
<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="btn-reset" onclick="if(confirm('Fahrt neu starten? Dein bisheriger Fortschritt geht verloren.'))location.reload()" title="Neu starten" aria-label="Neu starten">🔄</button>
|
||
<button class="bf-toggle" id="black-map-toggle" onclick="toggleBlackMap()" title="Beschriftung ein/aus" aria-label="Beschriftung umschalten"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12.6 4H18a2 2 0 0 1 2 2v5.4a2 2 0 0 1-.6 1.4l-6.6 6.6a2 2 0 0 1-2.8 0l-4.4-4.4a2 2 0 0 1 0-2.8L11.2 4.6a2 2 0 0 1 1.4-.6z"/><circle cx="15.7" cy="8.3" r="1.4"/></svg></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',
|
||
alwaysShow: true, // Onboarding bei jedem Besuch zeigen
|
||
onStart: function () { startGame(); }, // Onboarding schliessen startet die Tour
|
||
title: '🚌 Busfahrt',
|
||
lead: 'Du fährst mit dem Reisebus Aufträge quer durch Europa. Wäge gut ab: Zeit, Distanz und Bezahlung entscheiden über deinen Gewinn.',
|
||
steps: [
|
||
'Wähle links einen Auftrag aus. Du siehst die Zielstadt, die Distanz und deine Marge.',
|
||
'Tippe auf der Karte das richtige Ziel-Land oder die richtige Stadt an. Je genauer du triffst, desto besser.',
|
||
'Beantworte die Hauptstadt-Frage richtig und sichere dir einen Bonus auf den Auftrag.',
|
||
'Halte dein Budget hoch, gewinne Aufträge und schalte nach und nach neue Stufen frei.',
|
||
],
|
||
goals: [
|
||
'Europäische Hauptstädte und Länder zuordnen',
|
||
'Distanzen auf der Europa-Karte einschätzen',
|
||
'Wirtschaftlich planen: Marge, Treibstoff und Nachtfahrt im Blick behalten',
|
||
],
|
||
page2: {
|
||
title: '🗺 Warum ist die Karte auf Englisch?',
|
||
html: ''
|
||
+ '<p style="margin:0 0 .8rem">Die Städtenamen auf der Karte stehen auf <strong>Englisch</strong>, und das mit Absicht: es ist ein echter Lernvorteil.</p>'
|
||
+ '<ul style="margin:0 0 1rem;padding-left:1.2rem;line-height:1.5">'
|
||
+ '<li style="margin:.3rem 0"><strong>Namen unterscheiden sich international:</strong> auf der Karte steht „Vienna", im Quiz „Wien". So lernst du nebenbei, wie deine Heimat weltweit heißt.</li>'
|
||
+ '<li style="margin:.3rem 0"><strong>Englisch ist die Sprache der Geographie:</strong> Reiseplanung, Tourismus und Karten-Apps nutzen es überall.</li>'
|
||
+ '<li style="margin:.3rem 0"><strong>Übersetzen im Kopf</strong> („Munich" wird zu „München") trainiert geographisches Denken besser als reines Ablesen.</li>'
|
||
+ '<li style="margin:.3rem 0"><strong>Endonym und Exonym:</strong> viele Orte haben mehrere Namen: einheimisch „Lisboa", englisch „Lisbon", deutsch „Lissabon".</li>'
|
||
+ '</ul>'
|
||
+ '<div style="background:#eef4ec;border:1px solid #cfe0cf;border-radius:8px;padding:.7rem .9rem;font-size:.94rem;line-height:1.5">🎧 <strong>Ohren auf:</strong> Die Hintergrundmusik wird in verschiedenen europäischen Sprachen gesungen. Errätst du, welche Sprache gerade läuft?</div>',
|
||
},
|
||
});
|
||
});
|
||
</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: '© <a href="https://carto.com/attributions">CARTO</a> © OpenStreetMap',
|
||
maxZoom: 18,
|
||
}).addTo(map);
|
||
const btn = document.getElementById('black-map-toggle');
|
||
if (btn) {
|
||
const tag = '<path d="M12.6 4H18a2 2 0 0 1 2 2v5.4a2 2 0 0 1-.6 1.4l-6.6 6.6a2 2 0 0 1-2.8 0l-4.4-4.4a2 2 0 0 1 0-2.8L11.2 4.6a2 2 0 0 1 1.4-.6z"/><circle cx="15.7" cy="8.3" r="1.4"/>';
|
||
const slash = useBlank ? '<path d="M4.5 4.5l15 15"/>' : '';
|
||
btn.innerHTML = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' + tag + slash + '</svg>';
|
||
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 Tallinn–Helsinki (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 Stockholm–Helsinki (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);
|
||
// Kein separates Welcome-Overlay mehr: das GgsQuickIntro-Onboarding ist das
|
||
// einzige Start-Gate — dessen Schliessen ruft startGame() (siehe onStart).
|
||
}
|
||
|
||
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() {
|
||
if (!game) { setTimeout(startGame, 60); return; } // auf boot() warten
|
||
const wo = document.getElementById('welcome-overlay');
|
||
if (wo) wo.classList.remove('show');
|
||
initAudioOnFirstInteraction();
|
||
if (!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 => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
}[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>
|