[]]; $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']; ?> Heli-Missionen — Inhaltliches Drehbuch (Review-Tool)

📖 Heli-Missionen — Inhaltliches Drehbuch

/ Missionen mit Briefing 🚁 Zur Simulation 🎙️ Audio-Drehbuch 📍 Übersichtskarte ← Cockpit

Was ist das?

Pro Mission der inhaltliche Briefing-Text, der Schüler*innen vor der Routenplanung 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.

Inhalte werden iterativ gepflegt. Quelle: App/sims/heli/data/briefings.json — leere Felder erscheinen als „noch nicht geschrieben". Auf Zuruf bei Thomas erweitern.

ℹ️ 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.

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'; ?> $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) : ''; ?>
Heli:
Modell:
Wegpunkte:
Start:
Ziel:
$wpKey) { $w = $WP[$wpKey] ?? null; $name = $w ? $w['name'] : $wpKey; $cls = $i === 0 ? 'start' : ($i === $n - 1 ? 'end' : ''); $wpHtml[] = '' . htmlspecialchars($name) . ''; } echo implode('', $wpHtml); ?>
🚨 Unfall
noch nicht geschrieben
🗺️ Geografische Lage
noch nicht geschrieben
📍 Warum diese Route?
noch nicht geschrieben
Wetter
Zentrum: N, E · Radius: m · Typ:
gutes Wetter — kein Wetter-Umweg vorgesehen

🛫 Strecken-Abschnitte · Gesamt km · Flugzeit · Cruise km/h ()

$s): ?>
#AbschnittDistanzFlugzeitAudios am Wegpunkt
km — kein Audio zugeordnet —
'#4a7c8a', 'Startflug' => '#c97a3f', 'Kartenflug' => '#5a8a5e', 'Bergung' => '#a25cc8', ]; ?>

🎙️ Vollständiges Audio-Drehbuch · Slots ( Hauptlinie + Pool) · fehlend

$phaseRows): ?>
· Slots
TypSprecherAudio-IDText · Notiz
Pool Haupt ⚠ FEHLT ·
Legende: Haupt = Audio läuft mit hoher Wahrscheinlichkeit in dieser Mission. Pool = zufällig (Wetter/Hindernis/Strike-abhängig). Phasen: Planung · Startflug · Kartenflug · Bergung.
📖 Missionen insgesamt · Review-Tool · Bei Bedarf Briefings + Audio-Vorschläge ergänzen — Thomas sagt Bescheid.