Files
Adminator 1e51ef7def Nachtrag: alle bisher untracked Ordner + hängende Änderungen mit-committen
- Konzept/, didaktik_geografie/, didaktik_simulation/, v2-modules/, v2-platform/
- 12 code-workspace-Files
- STATUS-*.md
- viele M/D/R-Änderungen an bereits getrackten Files
- .gitignore verstärkt: **/.humaninput/, **/secret_keys.txt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 02:27:02 +02:00

696 lines
40 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Heli-Missionen — inhaltliches Drehbuch aller 25 Missionen.
*
* Read-only Anzeige. Inhalte aus sims/heli/data/briefings.json (von Thomas
* iterativ befuellt). Schwester-Seite zu /heli-drehbuch (Audio-Kuration).
*
* Pro Mission: Header (ID, Titel, Schwierigkeit, Stuetzpunkt, Heli),
* Wegpunkt-Kette, 4 Briefing-Felder (Unfall, Geo, Routen-Grund, Wetter),
* Mini-Auftragsbild.
*/
require_once __DIR__ . '/../../php/config/app.php';
require_once __DIR__ . '/../../php/config/db.php';
$db = getDB();
// === Missionen aus zentralem JSON (single source of truth) ===
$missionsPath = __DIR__ . '/../../sims/heli/data/missions.json';
$missionsData = file_exists($missionsPath) ? json_decode(file_get_contents($missionsPath), true) : ['missions'=>[]];
$missions = $missionsData['missions'] ?? [];
// === Heli-Stationen aus zentraler JSON (rechtssicher ohne Markennamen) ===
$stationsPath = __DIR__ . '/../../sims/heli/data/heli-stations.json';
$stationsData = file_exists($stationsPath) ? json_decode(file_get_contents($stationsPath), true) : ['stations'=>[], 'pools'=>[]];
$STATIONS = $stationsData['stations'] ?? [];
$POOLS = $stationsData['pools'] ?? [];
$DISCLAIMER = $stationsData['_disclaimer_anzeige'] ?? '';
// Helper: Sample-Kennzeichen pro Station (rein als Beispiel, in der Sim wird real zufaellig gezogen)
function sampleCallsign(string $stationKey, array $STATIONS, array $POOLS): string {
$s = $STATIONS[$stationKey] ?? null;
if (!$s) return '';
$pool = $POOLS[$s['pool'] ?? ''] ?? null;
if (!$pool || empty($pool['callsigns'])) return '';
// Deterministisches Sample (basiert auf stationKey-Hash, damit pro Reload stabil)
$idx = crc32($stationKey) % count($pool['callsigns']);
return $pool['callsigns'][$idx];
}
function stationModel(string $stationKey, array $STATIONS, array $POOLS): string {
$s = $STATIONS[$stationKey] ?? null;
if (!$s) return '?';
$pool = $POOLS[$s['pool'] ?? ''] ?? null;
return $pool['model'] ?? '?';
}
// === Wegpunkte aus DB ===
$wpRows = $db->query("SELECT wp_key, name, lat, lon, region FROM geo_waypoints")->fetchAll(PDO::FETCH_ASSOC);
$WP = [];
foreach ($wpRows as $w) $WP[$w['wp_key']] = $w;
// === Briefings aus JSON ===
$briefingsPath = __DIR__ . '/../../sims/heli/data/briefings.json';
$briefingsData = file_exists($briefingsPath) ? json_decode(file_get_contents($briefingsPath), true) : ['missions'=>[]];
$BR = $briefingsData['missions'] ?? [];
// === Audio-Texte sammeln (alle Lines-JSONs + Map-File) ===
$AUDIO_TEXTS = [];
$scriptsDir = __DIR__ . '/../../sims/heli/scripts';
$mapFile = $scriptsDir . '/audio-texts.json';
if (file_exists($mapFile)) {
$map = json_decode(file_get_contents($mapFile), true);
if (is_array($map)) {
foreach ($map as $k => $v) {
if (is_string($v) && strpos($k, '_') !== 0) $AUDIO_TEXTS[$k] = $v;
}
}
}
foreach (glob($scriptsDir . '/*-lines.json') as $jf) {
$j = json_decode(file_get_contents($jf), true);
if (!is_array($j) || empty($j['lines'])) continue;
foreach ($j['lines'] as $l) {
if (!empty($l['id']) && !empty($l['text'])) {
$AUDIO_TEXTS[$l['id']] = $l['text'];
}
}
}
// === Hilfen: Distanz (Haversine) + Flugzeit ===
function haversineKm(float $lat1, float $lon1, float $lat2, float $lon2): float {
$R = 6371.0;
$dLat = deg2rad($lat2 - $lat1);
$dLon = deg2rad($lon2 - $lon1);
$a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon / 2) ** 2;
return $R * 2 * atan2(sqrt($a), sqrt(1 - $a));
}
// Cruise-Speed nach Heli-Modell (km/h). H135 ~ 254, H145 ~ 268, AW169 ~ 280.
function cruiseSpeed(string $model): int {
if (strpos($model, 'H145') !== false) return 268;
if (strpos($model, 'AW169') !== false) return 280;
if (strpos($model, 'AB212') !== false) return 240;
if (strpos($model, 'S-70') !== false) return 295;
return 254; // H135 default
}
function flugzeitSec(float $km, string $model): int {
return (int) round($km / cruiseSpeed($model) * 3600);
}
function fmtMmSs(int $sec): string {
return sprintf('%d:%02d', floor($sec / 60), $sec % 60);
}
// Geschaetzte TTS-Sprechdauer aus Text-Laenge.
// Annahme: ~12 Zeichen/Sekunde fuer deutsche Sprache + 1.5 s Padding.
// Min 3 s, Max 25 s — clampt extreme Texte.
function estimateAudioDuration(string $text): int {
if (!$text) return 3;
$sec = (int) round(mb_strlen($text) / 12.0 + 1.5);
return max(3, min(25, $sec));
}
// === Audio-Drehbuch pro Mission — VOLLSTAENDIG: alle Hauptlinien + Pool-Audios
// fuer alle 4 Phasen (Planung, Startflug, Kartenflug, Bergung).
// Pool-Audios sind solche die ZUFAELLIG kommen koennen (Wetter, Hindernisse,
// Strikes). Szenario-Filter analog zum heli-timeline.php Default-Generator.
function audioDrehbuchFor(array $m, array $WP, array $AUDIO_TEXTS, array $stations, array $pools): array {
$rows = [];
$heliKey = $m['heliKey'] ?? '';
$mid = $m['id'];
$scen = $m['scen'] ?? 'berg-fels';
// scen-Mapping: missions.json benutzt Bindestrich (berg-schnee), Caution-Audios benutzen Underscore (berg_schnee)
$scenU = str_replace('-', '_', $scen);
// Hilfsfunktion: Slot anhaengen. group = 'haupt' | 'pool'
$append = function(string $phase, string $id, string $sprecher, string $group, string $notiz = '') use (&$rows, $AUDIO_TEXTS) {
$exists = isset($AUDIO_TEXTS[$id]);
$rows[] = [
'phase' => $phase, 'id' => $id, 'sprecher' => $sprecher,
'group' => $group, 'notiz' => $notiz,
'text' => $exists ? $AUDIO_TEXTS[$id] : '',
'fehlt' => !$exists,
'dur' => $exists ? estimateAudioDuration($AUDIO_TEXTS[$id]) : 0,
];
};
// ============================================================
// PHASE 1: PLANUNG (game.html → V3 Engine)
// ============================================================
$append('Planung', 'r_mission_intro_' . $mid, 'Tower', 'haupt', 'Mission-Intro');
$wps = $m['waypoints'];
for ($i = 1; $i < count($wps); $i++) {
$wpKey = $wps[$i];
$isLast = $i === count($wps) - 1;
$append('Planung', 'r_wp_geo_' . $wpKey, 'Briefing', 'haupt',
($isLast ? 'ZIEL: ' : 'Wegpunkt ' . $i . ': ') . $wpKey);
}
$append('Planung', 'r_nav_plan_done', 'Pilotin', 'haupt', 'Plan fertig');
// ============================================================
// PHASE 2: STARTFLUG (start.html iframe)
// ============================================================
$append('Startflug', 'r_tower_start_' . $heliKey, 'Tower', 'haupt', 'Tower: Startplatz frei');
$append('Startflug', 'r_mission_start_' . $mid, 'Tower', 'haupt', 'Tower: Mission-Freigabe');
// Pool: Pilot-Wetter-Kommentare (immer, Szenario-unabhaengig)
foreach (['r_pilot_calm1','r_pilot_calm2','r_pilot_calm3'] as $aid) {
$append('Startflug', $aid, 'Pilotin', 'pool', 'Wetter-OK');
}
// Pool: Pilot-Strecken-Hinweise (szenario-spezifisch)
$startScenPool = [
'berg-schnee' => ['r_pilot_trees','r_pilot_flock','r_pilot_wind','r_pilot_narrow','r_pilot_fog1','r_pilot_fog2','r_pilot_fog3','r_warn_nebel'],
'berg-fels' => ['r_pilot_trees','r_pilot_flock','r_pilot_wind','r_pilot_narrow','r_pilot_fog1','r_pilot_fog2','r_warn_nebel'],
'schlucht' => ['r_pilot_narrow','r_pilot_gusts','r_pilot_wind','r_pilot_flock','r_pilot_trees'],
'wasser' => ['r_pilot_flock','r_pilot_gusts','r_pilot_wind','r_pilot_fog1','r_pilot_fog2','r_warn_nebel'],
'autobahn' => ['r_warn_kran','r_warn_antenne','r_warn_strom','r_warn_schornstein','r_warn_fabrik','r_pilot_gap','r_pilot_gusts','r_pilot_time'],
'tal-dorf' => ['r_warn_kran','r_warn_antenne','r_pilot_gap','r_pilot_trees','r_pilot_flock','r_pilot_wind','r_pilot_fog1'],
];
foreach (($startScenPool[$scen] ?? $startScenPool['berg-fels']) as $aid) {
$append('Startflug', $aid, 'Pilotin', 'pool', 'Strecke ' . $scen);
}
// Pool: Proximity-Warns (immer)
foreach (['r_pilot_bird_ahead','r_pilot_plane_ahead','r_pilot_sportplane_level','r_pilot_storm_detour','r_pilot_fog_thickens','r_pilot_wind_turn','r_warn_gewitter','r_warn_fuel'] as $aid) {
$append('Startflug', $aid, 'Pilotin', 'pool', 'Proximity');
}
// Pool: Crash-Reaktionen szenario-spezifisch
$crashScenPool = [
'berg-schnee' => ['r_warn_vogel','r_warn_baum','r_warn_blitz','r_warn_flugzeug','r_warn_sport'],
'berg-fels' => ['r_warn_vogel','r_warn_baum','r_warn_blitz','r_warn_flugzeug','r_warn_sport'],
'schlucht' => ['r_warn_vogel','r_warn_baum','r_warn_blitz','r_warn_flugzeug'],
'wasser' => ['r_warn_vogel','r_warn_flugzeug','r_warn_sport','r_warn_blitz'],
'autobahn' => ['r_warn_haus','r_warn_klima','r_warn_vogel','r_warn_flugzeug','r_warn_blitz'],
'tal-dorf' => ['r_warn_haus','r_warn_baum','r_warn_vogel','r_warn_klima','r_warn_flugzeug'],
];
foreach (($crashScenPool[$scen] ?? $crashScenPool['berg-fels']) as $aid) {
$append('Startflug', $aid, 'Pilotin', 'pool', 'Crash-Reaktion');
}
// Pool: Strikes (immer)
foreach (['r_pilot_strike_1','r_pilot_strike_2','r_pilot_strike_3','r_pilot_strike_4','r_pilot_strike_5'] as $aid) {
$append('Startflug', $aid, 'Pilotin', 'pool', 'Pilot-Schreck');
}
$append('Startflug', 'r_pilot_three_strikes', 'Pilotin', 'pool', '3 Strikes erreicht');
$append('Startflug', 'r_tower_strike_' . $heliKey, 'Tower', 'pool', 'Tower nach Strike');
$append('Startflug', 'r_tower_handover_' . $heliKey, 'Tower', 'pool', 'Tower: Chefpilotin uebernimmt');
foreach (['r_pilot_too_high','r_pilot_time_up'] as $aid) {
$append('Startflug', $aid, 'Pilotin', 'pool', 'Fehler-Hinweis');
}
// Pool: Tower-generic
foreach (['r_tower1','r_tower2','r_tower3','r_tower4'] as $aid) {
$append('Startflug', $aid, 'Tower', 'pool', 'Generic Tower');
}
$append('Startflug', 'r_start_erfolg', 'Pilotin', 'haupt', 'Tower: Start erfolgreich');
// ============================================================
// PHASE 3: KARTENFLUG (game.html flight-Phase)
// r_mission_geo entfaellt — Doppelung zu r_tower_geo in Bergung.
// ============================================================
$append('Kartenflug', 'r_nav_heli_intro_' . $heliKey, 'Pilotin', 'haupt', 'Crew-Vorstellung');
$append('Kartenflug', 'r_nav_fuel_half', 'Pilotin', 'haupt', 'Treibstoff-Halbzeit');
// r_nav_altitude_* ans ENDE verlegt (didaktisch beim Zielanflug sinnvoller)
$isMountain = in_array($scen, ['berg-schnee','berg-fels','schlucht']);
$append('Kartenflug', $isMountain ? 'r_nav_altitude_mountain' : 'r_nav_altitude_valley', 'Pilotin', 'haupt', 'Hoehen-Info am Zielanflug');
$hasWetter = !empty($m['hasWetter']);
if ($hasWetter) {
$append('Kartenflug', 'r_nav_weather_enter', 'Pilotin', 'haupt', 'Wetter-Eintritt');
}
// r_im_zielgebiet wird in landing.html beim hover_drop ortsbezogen gespielt,
// NICHT im Kartenflug (sonst kommt es bei langen Routen viel zu früh).
// ============================================================
// PHASE 4: BERGUNG (landing.html iframe)
// Drei Modi: HIGHWAY (autobahn) / WASSER (wasser) / BERG (default)
// ============================================================
$isHw = $scen === 'autobahn';
$isSea = $scen === 'wasser';
$modeLabel = $isHw ? 'HW' : ($isSea ? 'SEA' : 'BERG');
$append('Bergung', 'r_pilot_call_' . $heliKey, 'Pilotin', 'haupt', 'Pilot: Einsatzort erreicht');
$append('Bergung', 'r_tower_geo_' . $mid, 'Tower', 'haupt', 'Tower: Roger + Region-Info');
if ($isHw) {
$append('Bergung', 'r_tower_hw_approach_' . $heliKey, 'Tower', 'haupt', 'HW: Tower-Anflug');
// HW-Sequenz — kein Pool, alle laufen in Reihenfolge
$append('Bergung', 'r_pilot_hw_zone_check', 'Pilotin', 'haupt', '1. Landezone prüfen');
$append('Bergung', 'r_pilot_hw_zone_blocked', 'Pilotin', 'haupt', '2a. Falls Zone blockiert: warten');
$append('Bergung', 'r_pilot_hw_zone_clear', 'Pilotin', 'haupt', '2b. Falls Zone frei: absinken');
$append('Bergung', 'r_land_approach', 'Pilotin', 'haupt', '3. Anflug auf Fahrbahn');
$append('Bergung', 'r_pilot_hw_ground_hold', 'Pilotin', 'haupt', '4. Am Boden — Sanitäter bringt Patient');
$append('Bergung', 'r_patient_aboard', 'Sani', 'haupt', '5. Patient an Bord');
$append('Bergung', 'r_tower_hw_extract_' . $heliKey, 'Tower', 'haupt', '6. HW: Strecke frei → Klinik');
$append('Bergung', 'r_pilot_hw_clinic_lift', 'Pilotin', 'haupt', '7. Klinik-Lift (Wieder-Start)');
} elseif ($isSea) {
$append('Bergung', 'r_tower_sea_approach_' . $heliKey, 'Tower', 'haupt', 'SEA: Wasserlage');
$append('Bergung', 'r_caution_wasser', 'Pilotin', 'haupt', 'Sicherheit: Wasser');
// SEA-Sequenz — Schwebeflug + Winch
$append('Bergung', 'r_land_approach', 'Pilotin', 'haupt', '1. Anflug zum Patienten');
$append('Bergung', 'r_land_winch_down', 'Pilotin', 'haupt', '2. Winde fährt aus, Retter abgeseilt');
$append('Bergung', 'r_land_rescuer_down', 'Flugretter', 'haupt', '3. Retter ist im Wasser');
$append('Bergung', 'r_warteschleife', 'Pilotin', 'haupt', '4. Heli in Warteschleife');
$append('Bergung', 'r_land_rescuer_ready', 'Flugretter', 'haupt', '5. Retter signalisiert bereit');
$append('Bergung', 'r_land_hook_connected', 'Pilotin', 'haupt', '6. Haken eingeklinkt');
$append('Bergung', 'r_land_lift_success', 'Pilotin', 'haupt', '7. Patient gehoben');
$append('Bergung', 'r_patient_aboard', 'Flugretter', 'haupt', '8. Patient an Bord');
$append('Bergung', 'r_tower_sea_extract_' . $heliKey, 'Tower', 'haupt', '9. SEA: Klinik freigegeben');
} else {
$append('Bergung', 'r_tower_approach_' . $heliKey, 'Tower', 'haupt', 'BERG: Zielanflug freigegeben');
$append('Bergung', 'r_caution_' . $scenU, 'Pilotin', 'haupt', 'Sicherheit: ' . $scen);
// BERG-Sequenz — Schwebeflug + Winch + Flugretter abgeseilt
$append('Bergung', 'r_land_approach', 'Pilotin', 'haupt', '1. Schwebeflug zum Patient');
$append('Bergung', 'r_land_winch_down', 'Pilotin', 'haupt', '2. Winde fährt aus, Retter abgeseilt');
$append('Bergung', 'r_retter_intermediate', 'Flugretter', 'haupt', '3. Retter unten, Versorgung läuft');
$append('Bergung', 'r_warteschleife', 'Pilotin', 'haupt', '4. Heli kreist / landet zwischen');
$append('Bergung', 'r_land_rescuer_ready', 'Flugretter', 'haupt', '5. Retter signalisiert bereit');
$append('Bergung', 'r_land_hook_connected', 'Pilotin', 'haupt', '6. Haken eingeklinkt');
$append('Bergung', 'r_land_lift_success', 'Pilotin', 'haupt', '7. Patient gehoben');
$append('Bergung', 'r_patient_aboard', 'Flugretter', 'haupt', '8. Patient an Bord');
$append('Bergung', 'r_tower_extract_' . $heliKey, 'Tower', 'haupt', '9. BERG: Direktfreigabe Klinik');
}
// Klinik-Anflug + Ende (alle Modi)
$append('Bergung', 'r_kurs_klinik', 'Pilotin', 'haupt', 'Pilot: Kurs Klinik');
$append('Bergung', 'r_land_clinic_ahead', 'Pilotin', 'haupt', 'Klinik voraus');
$append('Bergung', 'r_land_disembark', 'Pilotin', 'haupt', 'Patient uebergeben');
$append('Bergung', 'r_mission_success', 'Tower', 'haupt', 'Mission erfolgreich');
$append('Bergung', 'r_mission_abort', 'Tower', 'pool', 'Abbruch-Alternative');
return $rows;
}
// === Statistik: wieviele Missionen haben mindestens ein Feld befuellt ===
$fertig = 0;
$gesamt = count($missions);
foreach ($missions as $m) {
$b = $BR[$m['id']] ?? null;
if ($b && ($b['unfall'] || $b['geo'] || $b['routenGrund'] || $b['wetter'])) $fertig++;
}
$base = BASE_PATH;
$diffLabel = ['easy'=>'Einfach', 'medium'=>'Mittel', 'hard'=>'Schwer'];
$diffColor = ['easy'=>'#3a6b3e', 'medium'=>'#8a7020', 'hard'=>'#8a3a2a'];
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Heli-Missionen — Inhaltliches Drehbuch (Review-Tool)</title>
<link rel="icon" type="image/png" href="<?= $base ?>/favicon-96x96.png">
<link rel="stylesheet" href="<?= $base ?>/assets/fonts/inter.css">
<link rel="stylesheet" href="<?= $base ?>/assets/vendor/leaflet/leaflet.css">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',system-ui,sans-serif;background:#f5efe0;color:#1f2a24;line-height:1.5}
.topbar{background:#1f4b37;color:#f5efe0;padding:.7rem 1.2rem;display:flex;align-items:center;gap:.8rem;flex-wrap:wrap;position:sticky;top:0;z-index:50}
.topbar h1{font-size:1.05rem;font-weight:800;flex:1}
.topbar a{color:#f5efe0;text-decoration:none;font-size:.82rem;font-weight:700;padding:.35rem .7rem;border-radius:6px;background:rgba(255,255,255,.12)}
.topbar a:hover{background:rgba(255,255,255,.22)}
.topbar .stat{font-size:.78rem;background:rgba(255,255,255,.1);padding:.35rem .8rem;border-radius:6px}
.topbar .stat b{font-size:1rem;color:#e8c547}
.wrap{max-width:1080px;margin:0 auto;padding:1.2rem .9rem 4rem}
.intro{background:#fff;border-radius:12px;padding:1rem 1.2rem;margin-bottom:1.4rem;border-left:4px solid #c97a3f;box-shadow:0 2px 8px rgba(0,0,0,.05)}
.intro h2{font-size:1rem;color:#2d5434;margin-bottom:.4rem}
.intro p{font-size:.85rem;color:#3d5165;margin-bottom:.3rem}
.intro code{background:#f5efe0;padding:1px 6px;border-radius:4px;font-size:.78rem;font-family:'JetBrains Mono',monospace;color:#8a3a2a}
.mission{background:#fff;border-radius:12px;margin-bottom:1.2rem;overflow:hidden;box-shadow:0 2px 10px rgba(0,0,0,.06)}
.m-head{padding:.8rem 1.1rem;display:flex;align-items:center;gap:.7rem;border-bottom:1.5px solid #f0eee5;flex-wrap:wrap}
.m-badge{font-family:'JetBrains Mono',monospace;font-weight:800;font-size:.78rem;background:#1f4b37;color:#f5efe0;padding:.22rem .55rem;border-radius:4px;letter-spacing:.04em}
.m-icon{font-size:1.4rem}
.m-title{font-size:1rem;font-weight:800;color:#1f4b37;flex:1}
.m-difficulty{font-size:.62rem;font-weight:700;padding:2px 7px;border-radius:4px;text-transform:uppercase;letter-spacing:.05em}
.m-region{font-size:.72rem;color:#5a6b5a;font-weight:600}
.m-body{display:grid;grid-template-columns:240px 1fr;gap:0;min-height:200px}
@media (max-width:780px){.m-body{grid-template-columns:1fr}}
.m-left{background:#f5efe0;padding:.9rem;border-right:1.5px solid #f0eee5}
@media (max-width:780px){.m-left{border-right:none;border-bottom:1.5px solid #f0eee5}}
.m-img{width:100%;height:130px;border-radius:8px;background:#e3eef2 center/cover no-repeat;margin-bottom:.6rem;display:flex;align-items:center;justify-content:center;font-size:2.2rem;opacity:.85;border:1px solid rgba(0,0,0,.05)}
.m-meta{font-size:.74rem;color:#3d5165;line-height:1.55}
.m-meta b{color:#1f4b37}
.m-route{font-size:.72rem;color:#5a5a5a;margin-top:.5rem;padding-top:.5rem;border-top:1px dashed #d4cdb8}
.m-route .wp{display:inline-block;background:#fff;padding:1px 5px;border-radius:3px;margin:1px;border:1px solid #d4cdb8;font-weight:600;color:#2d5434}
.m-route .wp.start{background:#c97a3f;color:#fff;border-color:#b06020}
.m-route .wp.end{background:#c85c4a;color:#fff;border-color:#a04030}
.m-route .arr{color:#a8a8a8;margin:0 1px}
.m-right{padding:.9rem 1.1rem}
.field{margin-bottom:.85rem}
.field:last-child{margin-bottom:0}
.f-label{font-size:.7rem;font-weight:700;color:#c97a3f;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.2rem;display:flex;align-items:center;gap:.4rem}
.f-label .ic{font-size:.95rem}
.f-text{font-size:.86rem;color:#1f2a24;line-height:1.55}
.f-empty{font-size:.78rem;color:#a8a8a8;font-style:italic;background:#faf8f1;border:1px dashed #d4cdb8;border-radius:6px;padding:.45rem .65rem}
.f-weather{background:#eef2f5;border-left:3px solid #4a7c8a;border-radius:6px;padding:.5rem .7rem}
.f-weather .w-label{font-weight:700;color:#1f4e5a;font-size:.82rem;margin-bottom:.2rem}
.f-weather .w-reason{font-size:.78rem;color:#3d5165;line-height:1.5}
.f-weather .w-coords{font-size:.65rem;color:#7a8a9a;margin-top:.3rem;font-family:'JetBrains Mono',monospace}
.progress-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#dceadd;margin-right:4px}
.progress-dot.done{background:#5a8a5e}
/* === Mission-Map (echte Karte pro Mission) === */
.mission-map{width:100%;height:280px;border-radius:8px;background:#e8e4d8;margin:0 0 .8rem;border:1px solid rgba(0,0,0,.08);overflow:hidden}
/* === Direkt-Start-Button === */
.start-btn{display:inline-flex;align-items:center;gap:.4rem;background:#c97a3f;color:#fff;text-decoration:none;font-weight:700;font-size:.85rem;padding:.5rem 1rem;border-radius:8px;box-shadow:0 2px 6px rgba(0,0,0,.15);transition:transform .15s,box-shadow .15s}
.start-btn:hover{background:#b06020;transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.2)}
/* === Strecken-Tabelle === */
.section{margin-top:1rem;padding-top:.9rem;border-top:1.5px dashed #d4cdb8}
.section h4{font-size:.78rem;font-weight:700;color:#c97a3f;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.5rem}
.seg-table{width:100%;border-collapse:collapse;font-size:.78rem}
.seg-table th{background:#f5efe0;text-align:left;padding:.4rem .55rem;color:#5a5a5a;font-weight:700;font-size:.7rem;text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid #d4cdb8}
.seg-table td{padding:.45rem .55rem;border-bottom:1px solid #f0eee5;vertical-align:top}
.seg-from-to{font-weight:700;color:#2d5434}
.seg-km{color:#3d5165}
.seg-time{color:#c97a3f;font-weight:700;font-family:'JetBrains Mono',monospace}
.seg-audios{font-size:.72rem;color:#5a5a5a}
.seg-audio-id{display:inline-block;background:#e3eef2;color:#1f4e5a;padding:1px 6px;border-radius:3px;font-family:'JetBrains Mono',monospace;font-size:.66rem;margin:1px 2px 1px 0}
/* === Audio-Drehbuch === */
.ad-table{width:100%;border-collapse:collapse;font-size:.78rem}
.ad-table th{background:#f5efe0;text-align:left;padding:.35rem .5rem;color:#5a5a5a;font-weight:700;font-size:.68rem;text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid #d4cdb8}
.ad-table td{padding:.4rem .5rem;border-bottom:1px solid #f0eee5;vertical-align:top}
.ad-time{font-family:'JetBrains Mono',monospace;font-weight:700;color:#c97a3f;white-space:nowrap}
.ad-phase{font-size:.65rem;color:#7a8a7a;text-transform:uppercase;letter-spacing:.04em;font-weight:600}
.ad-id{font-family:'JetBrains Mono',monospace;font-size:.68rem;color:#1f4e5a;background:#e3eef2;padding:1px 5px;border-radius:3px}
.ad-text{font-style:italic;color:#3d5165;line-height:1.5}
.ad-empty{font-size:.78rem;color:#a8a8a8;font-style:italic;background:#faf8f1;border:1px dashed #d4cdb8;border-radius:6px;padding:.45rem .65rem}
/* === Marker-Styles (Inline) === */
.heli-marker-start{background:#c97a3f;color:#fff;border:2px solid #fff;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;box-shadow:0 2px 4px rgba(0,0,0,.3)}
.heli-marker-mid{background:#fff;color:#2d5434;border:2px solid #2d5434;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:800;box-shadow:0 1px 3px rgba(0,0,0,.2)}
.heli-marker-end{background:#c85c4a;color:#fff;border:2px solid #fff;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;box-shadow:0 2px 4px rgba(0,0,0,.3)}
</style>
</head>
<body>
<div class="topbar">
<h1>📖 Heli-Missionen — Inhaltliches Drehbuch</h1>
<span class="stat"><b><?= $fertig ?></b> / <?= $gesamt ?> Missionen mit Briefing</span>
<a href="<?= $base ?>/heli-game">🚁 Zur Simulation</a>
<a href="<?= $base ?>/heli-drehbuch">🎙️ Audio-Drehbuch</a>
<a href="<?= $base ?>/heli-uebersicht">📍 Übersichtskarte</a>
<a href="<?= $base ?>/sim">← Cockpit</a>
</div>
<div class="wrap">
<div class="intro">
<h2>Was ist das?</h2>
<p>Pro Mission der inhaltliche Briefing-Text, der Schüler*innen <b>vor der Routenplanung</b> erklärt: Was ist passiert, wo, warum genau diese Route. Ohne diesen Briefing fliegt der Schüler blind und kann die Tower-Anweisungen nicht nachvollziehen.</p>
<p>Inhalte werden iterativ gepflegt. Quelle: <code>App/sims/heli/data/briefings.json</code> — leere Felder erscheinen als „noch nicht geschrieben". Auf Zuruf bei Thomas erweitern.</p>
<?php if ($DISCLAIMER): ?>
<p style="font-size:.78rem;color:#7a8a7a;margin-top:.4rem;border-top:1px dashed #d4cdb8;padding-top:.4rem">
️ <?= htmlspecialchars($DISCLAIMER) ?> Pro Mission wird hier ein deterministisches Sample-Kennzeichen aus dem Pool angezeigt — im Spiel zieht das System bei jeder Mission ein zufälliges aus dem Pool.
</p>
<?php endif; ?>
</div>
<?php foreach ($missions as $m): ?>
<?php
$b = $BR[$m['id']] ?? ['unfall'=>null,'geo'=>null,'routenGrund'=>null,'wetter'=>null];
$stationKey = $m['base'];
$stationCfg = $STATIONS[$stationKey] ?? null;
$sample = sampleCallsign($stationKey, $STATIONS, $POOLS);
$modelName = stationModel($stationKey, $STATIONS, $POOLS);
$orgLabel = $stationCfg['orgLabel'] ?? 'Rettungshubschrauber';
$stationName = $stationCfg['stationName'] ?? $stationKey;
$cfg = ['heliName' => ($stationName . ($sample ? ' (' . $sample . ')' : '')), 'heliModel' => $modelName, 'org' => $orgLabel];
$baseWp = $WP[$m['waypoints'][0]] ?? null;
$endWp = $WP[end($m['waypoints'])] ?? null;
$missionImg = $base . '/sims/heli/assets/missions/' . $m['id'] . '.png';
$diffL = $diffLabel[$m['difficulty']] ?? '?';
$diffC = $diffColor[$m['difficulty']] ?? '#888';
?>
<?php
// Strecken-Abschnitte berechnen + Audio-Drehbuch
$segs = [];
$totalKm = 0.0; $totalSec = 0;
for ($i = 1; $i < count($m['waypoints']); $i++) {
$a = $WP[$m['waypoints'][$i-1]] ?? null;
$bw = $WP[$m['waypoints'][$i]] ?? null;
if (!$a || !$bw) continue;
$km = haversineKm((float)$a['lat'], (float)$a['lon'], (float)$bw['lat'], (float)$bw['lon']);
$sec = flugzeitSec($km, $modelName);
$totalKm += $km;
$totalSec += $sec;
$segs[] = [
'from' => $a['name'], 'to' => $bw['name'],
'fromKey' => $m['waypoints'][$i-1], 'toKey' => $m['waypoints'][$i],
'km' => $km, 'sec' => $sec,
// Audios pro Wegpunkt-Ankunft
'audios' => array_values(array_filter([
isset($AUDIO_TEXTS['r_wp_geo_' . $m['waypoints'][$i]]) ? 'r_wp_geo_' . $m['waypoints'][$i] : null,
isset($AUDIO_TEXTS['r_nav_plan_correct']) ? 'r_nav_plan_correct' : null,
])),
];
}
$audioRows = audioDrehbuchFor($m, $WP, $AUDIO_TEXTS, $STATIONS, $POOLS);
// Map-Daten als JSON fuer JS
$mapData = [];
foreach ($m['waypoints'] as $idx => $wpKey) {
$w = $WP[$wpKey] ?? null;
if (!$w) continue;
$mapData[] = [
'key' => $wpKey, 'name' => $w['name'],
'lat' => (float)$w['lat'], 'lon' => (float)$w['lon'],
'idx' => $idx, 'total' => count($m['waypoints']),
];
}
$mapDataJson = htmlspecialchars(json_encode($mapData, JSON_UNESCAPED_UNICODE), ENT_QUOTES);
$wetterJson = (!empty($b['wetter']) && is_array($b['wetter']))
? htmlspecialchars(json_encode($b['wetter'], JSON_UNESCAPED_UNICODE), ENT_QUOTES)
: '';
?>
<div class="mission" id="<?= $m['id'] ?>">
<div class="m-head">
<span class="m-badge"><?= strtoupper($m['id']) ?></span>
<span class="m-icon"><?= $m['icon'] ?></span>
<span class="m-title"><?= htmlspecialchars($m['title']) ?></span>
<span class="m-difficulty" style="background:<?= $diffC ?>22;color:<?= $diffC ?>"><?= $diffL ?></span>
<span class="m-region">📍 <?= htmlspecialchars($m['region']) ?></span>
<a href="<?= $base ?>/heli-game?mission=<?= urlencode($m['id']) ?>" class="start-btn" title="Mission direkt starten" target="_blank" rel="noopener">▶ Mission starten</a>
</div>
<div class="m-body">
<div class="m-left">
<div class="m-img" style="background-image:url('<?= $missionImg ?>')"><?= file_exists(__DIR__ . '/../../sims/heli/assets/missions/' . $m['id'] . '.png') ? '' : $m['icon'] ?></div>
<div class="m-meta">
<div><b>Heli:</b> <?= htmlspecialchars($cfg['heliName']) ?></div>
<div><b>Modell:</b> <?= htmlspecialchars($cfg['heliModel']) ?></div>
<div><b>Wegpunkte:</b> <?= count($m['waypoints']) ?></div>
<?php
$startName = $baseWp ? $baseWp['name'] : $m['waypoints'][0];
$endName = $endWp ? $endWp['name'] : end($m['waypoints']);
?>
<div style="margin-top:.3rem"><b>Start:</b> <?= htmlspecialchars($startName) ?></div>
<div><b>Ziel:</b> <?= htmlspecialchars($endName) ?></div>
</div>
<div class="m-route">
<?php
$wpHtml = [];
$n = count($m['waypoints']);
foreach ($m['waypoints'] as $i => $wpKey) {
$w = $WP[$wpKey] ?? null;
$name = $w ? $w['name'] : $wpKey;
$cls = $i === 0 ? 'start' : ($i === $n - 1 ? 'end' : '');
$wpHtml[] = '<span class="wp ' . $cls . '">' . htmlspecialchars($name) . '</span>';
}
echo implode('<span class="arr">→</span>', $wpHtml);
?>
</div>
</div>
<div class="m-right">
<div class="field">
<div class="f-label"><span class="ic">🚨</span> Unfall</div>
<?php if ($b['unfall']): ?>
<div class="f-text"><?= nl2br(htmlspecialchars($b['unfall'])) ?></div>
<?php else: ?>
<div class="f-empty">noch nicht geschrieben</div>
<?php endif; ?>
</div>
<div class="field">
<div class="f-label"><span class="ic">🗺️</span> Geografische Lage</div>
<?php if ($b['geo']): ?>
<div class="f-text"><?= nl2br(htmlspecialchars($b['geo'])) ?></div>
<?php else: ?>
<div class="f-empty">noch nicht geschrieben</div>
<?php endif; ?>
</div>
<div class="field">
<div class="f-label"><span class="ic">📍</span> Warum diese Route?</div>
<?php if ($b['routenGrund']): ?>
<div class="f-text"><?= nl2br(htmlspecialchars($b['routenGrund'])) ?></div>
<?php else: ?>
<div class="f-empty">noch nicht geschrieben</div>
<?php endif; ?>
</div>
<div class="field">
<div class="f-label"><span class="ic">⛅</span> Wetter</div>
<?php if (!empty($b['wetter']) && is_array($b['wetter'])): ?>
<div class="f-weather">
<div class="w-label"><?= htmlspecialchars($b['wetter']['label'] ?? '') ?></div>
<div class="w-reason"><?= htmlspecialchars($b['wetter']['reason'] ?? '') ?></div>
<div class="w-coords">
Zentrum: <?= number_format((float)($b['wetter']['lat']??0), 4, '.', '') ?> N,
<?= number_format((float)($b['wetter']['lon']??0), 4, '.', '') ?> E
· Radius: <?= (int)($b['wetter']['radius']??0) ?> m
· Typ: <?= htmlspecialchars($b['wetter']['type'] ?? 'rain') ?>
</div>
</div>
<?php else: ?>
<div class="f-empty">gutes Wetter — kein Wetter-Umweg vorgesehen</div>
<?php endif; ?>
</div>
</div>
</div>
<!-- === KARTE === -->
<div style="padding:.9rem 1.1rem">
<div class="mission-map" data-mission="<?= $m['id'] ?>" data-waypoints="<?= $mapDataJson ?>" data-wetter="<?= $wetterJson ?>"></div>
<!-- === STRECKEN-TABELLE === -->
<div class="section">
<h4>🛫 Strecken-Abschnitte · Gesamt <?= number_format($totalKm, 1, ',', '.') ?> km · <?= fmtMmSs($totalSec) ?> Flugzeit · Cruise <?= cruiseSpeed($modelName) ?> km/h (<?= htmlspecialchars($modelName) ?>)</h4>
<table class="seg-table">
<thead><tr><th>#</th><th>Abschnitt</th><th>Distanz</th><th>Flugzeit</th><th>Audios am Wegpunkt</th></tr></thead>
<tbody>
<?php foreach ($segs as $sidx => $s): ?>
<tr>
<td style="color:#8a8a8a"><?= $sidx + 1 ?></td>
<td class="seg-from-to"><?= htmlspecialchars($s['from']) ?> → <?= htmlspecialchars($s['to']) ?></td>
<td class="seg-km"><?= number_format($s['km'], 1, ',', '.') ?> km</td>
<td class="seg-time"><?= fmtMmSs($s['sec']) ?></td>
<td class="seg-audios">
<?php if ($s['audios']): foreach ($s['audios'] as $aid): ?>
<span class="seg-audio-id"><?= htmlspecialchars($aid) ?></span>
<?php endforeach; else: ?>
<span style="color:#a8a8a8;font-style:italic">— kein Audio zugeordnet —</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- === AUDIO-DREHBUCH — VOLLSTÄNDIG === -->
<div class="section">
<?php
$fehltCount = 0;
$hauptCount = 0; $poolCount = 0;
foreach ($audioRows as $ar) {
if (!empty($ar['fehlt'])) $fehltCount++;
if (($ar['group']??'')==='haupt') $hauptCount++; else $poolCount++;
}
// Gruppiere nach Phase
$byPhase = [];
foreach ($audioRows as $ar) $byPhase[$ar['phase']][] = $ar;
$phaseColors = [
'Planung' => '#4a7c8a',
'Startflug' => '#c97a3f',
'Kartenflug' => '#5a8a5e',
'Bergung' => '#a25cc8',
];
?>
<h4>🎙️ Vollständiges Audio-Drehbuch · <?= count($audioRows) ?> Slots (<?= $hauptCount ?> Hauptlinie + <?= $poolCount ?> Pool) · <?= $fehltCount ?> fehlend</h4>
<?php foreach ($byPhase as $phaseName => $phaseRows): ?>
<div style="margin-top:.8rem;border-left:4px solid <?= $phaseColors[$phaseName] ?? '#888' ?>;padding-left:.7rem">
<div style="font-size:.85rem;font-weight:800;color:<?= $phaseColors[$phaseName] ?? '#333' ?>;margin-bottom:.4rem">
<?= htmlspecialchars($phaseName) ?> · <?= count($phaseRows) ?> Slots
</div>
<table class="ad-table">
<thead><tr><th style="width:55px">Typ</th><th style="width:80px">Sprecher</th><th style="width:220px">Audio-ID</th><th>Text · Notiz</th></tr></thead>
<tbody>
<?php foreach ($phaseRows as $ar):
$rowStyle = '';
$isPool = ($ar['group']??'')==='pool';
if (!empty($ar['fehlt'])) $rowStyle = 'background:#fbeed5';
elseif ($isPool) $rowStyle = 'background:#fafafa';
?>
<tr style="<?= $rowStyle ?>">
<td>
<?php if ($isPool): ?>
<span style="background:#d4cdb8;color:#5a5a5a;padding:1px 6px;border-radius:3px;font-size:.6rem;font-weight:700;text-transform:uppercase">Pool</span>
<?php else: ?>
<span style="background:#2d5434;color:#fff;padding:1px 6px;border-radius:3px;font-size:.6rem;font-weight:700;text-transform:uppercase">Haupt</span>
<?php endif; ?>
</td>
<td style="font-size:.7rem;color:#7a8a7a;font-weight:600"><?= htmlspecialchars($ar['sprecher'] ?? '?') ?></td>
<td><span class="ad-id"><?= htmlspecialchars($ar['id']) ?></span></td>
<td class="ad-text">
<?php if (!empty($ar['fehlt'])): ?>
<span style="color:#a04030;font-weight:700">⚠ FEHLT</span>
<?php if (!empty($ar['notiz'])): ?> · <span style="color:#7a8a7a"><?= htmlspecialchars($ar['notiz']) ?></span><?php endif; ?>
<?php else: ?>
<span style="color:#1f2a24"><?= htmlspecialchars($ar['text']) ?></span>
<?php if (!empty($ar['notiz'])): ?><br><span style="color:#8a8a8a;font-size:.7rem">↳ <?= htmlspecialchars($ar['notiz']) ?></span><?php endif; ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endforeach; ?>
<div style="margin-top:.7rem;font-size:.72rem;color:#7a8a7a;line-height:1.5">
<b>Legende:</b> <span style="background:#2d5434;color:#fff;padding:1px 5px;border-radius:3px">Haupt</span> = Audio läuft mit hoher Wahrscheinlichkeit in dieser Mission.
<span style="background:#d4cdb8;color:#5a5a5a;padding:1px 5px;border-radius:3px">Pool</span> = zufällig (Wetter/Hindernis/Strike-abhängig).
Phasen: <b style="color:#4a7c8a">Planung</b> · <b style="color:#c97a3f">Startflug</b> · <b style="color:#5a8a5e">Kartenflug</b> · <b style="color:#a25cc8">Bergung</b>.
</div>
</div>
</div>
</div>
<?php endforeach; ?>
<div style="text-align:center;font-size:.8rem;color:#8a8a8a;margin-top:1.5rem">
📖 <?= $gesamt ?> Missionen insgesamt · Review-Tool · Bei Bedarf Briefings + Audio-Vorschläge ergänzen — Thomas sagt Bescheid.
</div>
</div>
<script src="<?= $base ?>/assets/vendor/leaflet/leaflet.js"></script>
<script>
// Leaflet-Karten pro Mission, lazy-initialisiert beim Scroll-in
(function(){
var BASE = <?= json_encode($base) ?>;
var tileUrl = BASE + '/php/tile-proxy.php?z={z}&x={x}&y={y}&p=osm';
var inited = new WeakSet();
function initMap(div) {
if (inited.has(div)) return;
inited.add(div);
var wps = JSON.parse(div.dataset.waypoints || '[]');
if (!wps.length) { div.innerHTML = '<div style="padding:1rem;text-align:center;color:#a8a8a8">Keine Wegpunkt-Koordinaten</div>'; return; }
var wetter = null;
try { wetter = div.dataset.wetter ? JSON.parse(div.dataset.wetter) : null; } catch(_){}
var map = L.map(div, { zoomControl: true, attributionControl: false, scrollWheelZoom: false }).setView([wps[0].lat, wps[0].lon], 9);
L.tileLayer(tileUrl, { maxZoom: 18 }).addTo(map);
// Route-Polyline
var coords = wps.map(function(w){ return [w.lat, w.lon]; });
L.polyline(coords, { color: '#c97a3f', weight: 4, opacity: .85, dashArray: '8 6' }).addTo(map);
// Wegpunkt-Marker
wps.forEach(function(w, i) {
var isStart = i === 0, isEnd = i === wps.length - 1;
var cls = isStart ? 'heli-marker-start' : (isEnd ? 'heli-marker-end' : 'heli-marker-mid');
var lbl = isStart ? 'S' : (isEnd ? 'Z' : i);
L.marker([w.lat, w.lon], {
icon: L.divIcon({ className: '', html: '<div class="' + cls + '">' + lbl + '</div>', iconSize: [24, 24], iconAnchor: [12, 12] }),
title: w.name
}).addTo(map).bindTooltip(w.name, { permanent: false, direction: 'top' });
});
// Wetter-Zone falls vorhanden
if (wetter && wetter.lat && wetter.lon && wetter.radius) {
L.circle([wetter.lat, wetter.lon], {
radius: wetter.radius, color: '#6b7d8f', fillColor: '#6b7d8f', fillOpacity: .18, weight: 1.5, dashArray: '6,4'
}).addTo(map).bindTooltip(wetter.label || '⛈️ Schlechtwetter');
}
// Bounds auf alle Punkte zoomen
var bounds = L.latLngBounds(coords);
if (wetter && wetter.lat) bounds.extend([wetter.lat, wetter.lon]);
map.fitBounds(bounds, { padding: [25, 25] });
}
if ('IntersectionObserver' in window) {
var io = new IntersectionObserver(function(entries){
entries.forEach(function(e){
if (e.isIntersecting) initMap(e.target);
});
}, { rootMargin: '200px 0px' });
document.querySelectorAll('.mission-map').forEach(function(el){ io.observe(el); });
} else {
document.querySelectorAll('.mission-map').forEach(initMap);
}
})();
</script>
</body>
</html>