1e51ef7def
- 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>
696 lines
40 KiB
PHP
696 lines
40 KiB
PHP
<?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>
|