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>
659 lines
30 KiB
PHP
659 lines
30 KiB
PHP
<?php
|
||
/**
|
||
* Heli-Drehbuch — Kurations-Tool fuer Audio-Auswahl pro Mission.
|
||
* Spielt eine Mission als Karten-Animation in Echtzeit ab, zeigt alle
|
||
* Audio-Trigger als anklickbare Karten und speichert die Aktivierung
|
||
* pro Mission als JSON.
|
||
*/
|
||
require_once __DIR__ . '/../../php/config/app.php';
|
||
require_once __DIR__ . '/../../php/config/db.php';
|
||
|
||
$db = getDB();
|
||
|
||
// === Wegpunkte aus DB ===
|
||
$rows = $db->query("SELECT wp_key, name, lat, lon FROM geo_waypoints")->fetchAll(PDO::FETCH_ASSOC);
|
||
$WP = [];
|
||
foreach ($rows as $r) {
|
||
$WP[$r['wp_key']] = ['name' => $r['name'], 'lat' => (float)$r['lat'], 'lon' => (float)$r['lon']];
|
||
}
|
||
|
||
// === Missionen (Spiegel zu ALL_MISSIONS in game.html) ===
|
||
$missions = [
|
||
['id'=>'m1', 'title'=>'Skiunfall Damüls', 'base'=>'nenzing', 'heliKey'=>'c8', 'scen'=>'berg-schnee', 'waypoints'=>['nenzing','thueringen','zwischenwasser','damuels','bergrettung_muels']],
|
||
['id'=>'m2', 'title'=>'Verkehrsunfall A14', 'base'=>'nenzing', 'heliKey'=>'c8', 'scen'=>'autobahn', 'waypoints'=>['nenzing','feldkirch','rankweil','verkehr_a14']],
|
||
['id'=>'m3', 'title'=>'Bergrettung Brand', 'base'=>'nenzing', 'heliKey'=>'c8', 'scen'=>'berg-fels', 'waypoints'=>['nenzing','buers','brand']],
|
||
['id'=>'m4', 'title'=>'Skiunfall Sölden', 'base'=>'innsbruck', 'heliKey'=>'c1', 'scen'=>'berg-schnee', 'waypoints'=>['innsbruck','telfs','oetz','soelden','skiunfall_soelden']],
|
||
['id'=>'m5', 'title'=>'Seerettung Bregenz', 'base'=>'nenzing', 'heliKey'=>'c8', 'scen'=>'wasser', 'waypoints'=>['nenzing','dornbirn','bregenz']],
|
||
['id'=>'m6', 'title'=>'Canyoning Mellau', 'base'=>'nenzing', 'heliKey'=>'c8', 'scen'=>'schlucht', 'waypoints'=>['nenzing','dornbirn','egg','bezau','mellau']],
|
||
['id'=>'m7', 'title'=>'Wanderunfall Kufstein', 'base'=>'innsbruck', 'heliKey'=>'c1', 'scen'=>'berg-fels', 'waypoints'=>['innsbruck','hall','schwaz','jenbach','woergl','kufstein']],
|
||
['id'=>'m8', 'title'=>'Notfall Gaschurn', 'base'=>'nenzing', 'heliKey'=>'c8', 'scen'=>'tal-dorf', 'waypoints'=>['nenzing','bludenz','schruns','stgallenk','gaschurn']],
|
||
['id'=>'m9', 'title'=>'Lawineneinsatz Mayrhofen', 'base'=>'innsbruck', 'heliKey'=>'c1', 'scen'=>'berg-schnee', 'waypoints'=>['innsbruck','hall','schwaz','jenbach','zell_ziller','mayrhofen']],
|
||
['id'=>'m10', 'title'=>'Verkehrsunfall A2 Baden', 'base'=>'wien_c9', 'heliKey'=>'c9', 'scen'=>'autobahn', 'waypoints'=>['wien_c9','wien','moedling','baden','verkehr_a2_baden']],
|
||
['id'=>'m11', 'title'=>'Sog in der Neuen Donau', 'base'=>'wien_c9', 'heliKey'=>'c9', 'scen'=>'wasser', 'waypoints'=>['wien_c9','wien_donau','see_donau_wien']],
|
||
['id'=>'m12', 'title'=>'Kletterunfall Schneeberg', 'base'=>'wr_neustadt_c3', 'heliKey'=>'c3', 'scen'=>'berg-fels', 'waypoints'=>['wr_neustadt_c3','wr_neustadt','puchberg','schneeberg','berg_schneeberg']],
|
||
['id'=>'m13', 'title'=>'Wanderunfall Rax', 'base'=>'wr_neustadt_c3', 'heliKey'=>'c3', 'scen'=>'berg-fels', 'waypoints'=>['wr_neustadt_c3','wr_neustadt','puchberg','rax','berg_rax']],
|
||
['id'=>'m14', 'title'=>'Surfer Neusiedler See', 'base'=>'oberwart_c16', 'heliKey'=>'c16', 'scen'=>'wasser', 'waypoints'=>['oberwart_c16','mattersburg','eisenstadt','podersdorf','see_neusiedl']],
|
||
['id'=>'m15', 'title'=>'Verkehrsunfall A1 Sattledt', 'base'=>'linz_c10', 'heliKey'=>'c10', 'scen'=>'autobahn', 'waypoints'=>['linz_c10','wels','sattledt','verkehr_a1_sattledt']],
|
||
['id'=>'m16', 'title'=>'Boot in Not Traunsee', 'base'=>'linz_c10', 'heliKey'=>'c10', 'scen'=>'wasser', 'waypoints'=>['linz_c10','wels','gmunden','traunsee','see_traun']],
|
||
['id'=>'m17', 'title'=>'LKW-Unfall A10 Tauern', 'base'=>'salzburg_c6', 'heliKey'=>'c6', 'scen'=>'autobahn', 'waypoints'=>['salzburg_c6','hallein','golling','flachau','verkehr_a10_tauern']],
|
||
['id'=>'m18', 'title'=>'Seerettung Wolfgangsee', 'base'=>'salzburg_c6', 'heliKey'=>'c6', 'scen'=>'wasser', 'waypoints'=>['salzburg_c6','st_wolfgang','see_wolfgang']],
|
||
['id'=>'m19', 'title'=>'Absturz Großglockner', 'base'=>'salzburg_c6', 'heliKey'=>'c6', 'scen'=>'berg-schnee', 'waypoints'=>['salzburg_c6','zell_see','kaprun','heiligenblut','grossglockner','berg_glockner']],
|
||
['id'=>'m20', 'title'=>'Dachstein Südwand', 'base'=>'niederoeblarn_c14', 'heliKey'=>'c14', 'scen'=>'berg-schnee', 'waypoints'=>['niederoeblarn_c14','bad_aussee','dachstein','berg_dachstein']],
|
||
['id'=>'m21', 'title'=>'Alpinunfall Hochschwab', 'base'=>'graz_c12', 'heliKey'=>'c12', 'scen'=>'berg-fels', 'waypoints'=>['graz_c12','bruck_mur','hochschwab','berg_hochschwab']],
|
||
['id'=>'m22', 'title'=>'Unfall A9 Bosruck', 'base'=>'graz_c12', 'heliKey'=>'c12', 'scen'=>'autobahn', 'waypoints'=>['graz_c12','bruck_mur','leoben','verkehr_a9_pyhrn']],
|
||
['id'=>'m23', 'title'=>'Badegast Wörthersee', 'base'=>'klagenfurt_c11', 'heliKey'=>'c11', 'scen'=>'wasser', 'waypoints'=>['klagenfurt_c11','klagenfurt','poertschach','see_woerth']],
|
||
['id'=>'m24', 'title'=>'Glockner Südseite', 'base'=>'klagenfurt_c11', 'heliKey'=>'c11', 'scen'=>'berg-schnee', 'waypoints'=>['klagenfurt_c11','villach','spittal','heiligenblut','berg_glockner']],
|
||
['id'=>'m25', 'title'=>'Unfall A2 Klagenfurt', 'base'=>'klagenfurt_c11', 'heliKey'=>'c11', 'scen'=>'autobahn', 'waypoints'=>['klagenfurt_c11','klagenfurt','verkehr_a2_klagenfurt']],
|
||
];
|
||
|
||
// === Texte aus den TTS-JSON-Skripten zusammensammeln ===
|
||
$scriptsDir = __DIR__ . '/../../sims/heli/scripts';
|
||
$texts = [];
|
||
foreach (glob($scriptsDir . '/*.json') as $jf) {
|
||
$j = json_decode(file_get_contents($jf), true);
|
||
if (!$j || empty($j['lines'])) continue;
|
||
foreach ($j['lines'] as $l) {
|
||
if (!empty($l['id']) && !empty($l['text'])) $texts[$l['id']] = $l['text'];
|
||
}
|
||
}
|
||
|
||
$WPjs = json_encode($WP, JSON_UNESCAPED_UNICODE);
|
||
$missionsJs = json_encode($missions, JSON_UNESCAPED_UNICODE);
|
||
$textsJs = json_encode($texts, JSON_UNESCAPED_UNICODE);
|
||
$base = BASE_PATH;
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||
<title>Heli-Drehbuch — Audio-Kuration</title>
|
||
<link rel="stylesheet" href="<?= $base ?>/assets/vendor/leaflet/leaflet.css">
|
||
<link rel="stylesheet" href="<?= $base ?>/assets/fonts/inter.css">
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{font-family:'Inter',system-ui,sans-serif;background:#e8e4d8;height:100dvh;display:flex;flex-direction:column;overflow:hidden;color:#1f4b37}
|
||
header{background:#1f4b37;color:#f5efe0;padding:.5rem .8rem;display:flex;gap:.6rem;align-items:center;flex-wrap:wrap;flex-shrink:0;font-size:.85rem}
|
||
header h1{font-size:1rem;font-weight:800;margin-right:.6rem}
|
||
header select,header button{font-family:inherit;font-size:.82rem;padding:.32rem .6rem;border-radius:6px;border:none;cursor:pointer;font-weight:700}
|
||
header select{background:#f5efe0;color:#1f4b37;min-width:230px}
|
||
header button{background:#c97a3f;color:#fff}
|
||
header button.recording{background:#c85c4a}
|
||
header button.speed.on{background:#f5efe0;color:#1f4b37;outline:2px solid #f5efe0}
|
||
header button:disabled{opacity:.4;cursor:not-allowed}
|
||
header .info{margin-left:auto;font-size:.78rem;opacity:.85}
|
||
main{flex:1;display:flex;min-height:0}
|
||
#mapWrap{flex:1.2;background:#fff;position:relative;border-right:2px solid #c97a3f}
|
||
#map{width:100%;height:100%}
|
||
#sidebar{flex:1;display:flex;flex-direction:column;background:#f5efe0;min-width:380px;max-width:520px}
|
||
.sb-head{padding:.6rem .8rem;background:#fff;border-bottom:2px solid #c97a3f;font-size:.78rem;color:#5a5a5a;display:flex;gap:.6rem;align-items:center;flex-wrap:wrap}
|
||
.sb-head .legend{display:flex;gap:.5rem;font-size:.72rem}
|
||
.sb-head .legend span{display:inline-flex;align-items:center;gap:.25rem}
|
||
.sb-head .legend i{display:inline-block;width:10px;height:10px;border-radius:2px}
|
||
#cards{flex:1;overflow-y:auto;padding:.6rem;display:flex;flex-direction:column;gap:.4rem}
|
||
.card{background:#fff;border:2px solid #d4cdb8;border-radius:8px;padding:.5rem .65rem;font-size:.8rem;line-height:1.35;cursor:default;transition:all .12s;position:relative;display:flex;gap:.55rem;align-items:flex-start}
|
||
.card-img{width:48px;height:48px;border-radius:50%;background:#e8e4d8;border:2px solid #c97a3f;flex-shrink:0;background-size:cover;background-position:center}
|
||
.card-body{flex:1;min-width:0}
|
||
.card.clickable{cursor:pointer}
|
||
.card.clickable:hover{border-color:#c97a3f}
|
||
.card.active-now{background:#fff2e6;border-color:#c85c4a;box-shadow:0 0 0 2px rgba(200,92,74,.4)}
|
||
.card.kept{background:#e8f0e3;border-color:#4a7c4e}
|
||
.card.kept::before{content:'✓ behalten';position:absolute;top:-8px;right:6px;background:#4a7c4e;color:#fff;font-size:.62rem;padding:1px 6px;border-radius:4px;font-weight:700}
|
||
.card-meta{display:flex;justify-content:space-between;font-size:.68rem;color:#7a6a5a;margin-bottom:.18rem;font-weight:700}
|
||
.card-id{font-family:'Courier New',monospace;font-size:.72rem;color:#3d5165;margin-bottom:.15rem}
|
||
.card-text{color:#1f2a1f}
|
||
.card-phase{display:inline-block;padding:1px 6px;border-radius:3px;font-size:.62rem;font-weight:800;color:#fff;letter-spacing:.04em}
|
||
.card-phase.plan{background:#3d5165}
|
||
.card-phase.briefing{background:#7a6a5a}
|
||
.card-phase.start{background:#c97a3f}
|
||
.card-phase.flight{background:#4a7c8a}
|
||
.card-phase.landing{background:#c85c4a}
|
||
.card-phase.extract{background:#4a7c4e}
|
||
.heli-marker{width:30px;height:30px;display:flex;align-items:center;justify-content:center;font-size:24px;filter:drop-shadow(0 2px 4px rgba(0,0,0,.4))}
|
||
.wp-marker{width:24px;height:24px;background:#fff;border:2.5px solid #1f4e5a;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;color:#1f4e5a;box-shadow:0 2px 5px rgba(0,0,0,.25)}
|
||
.wp-marker.target{border-color:#c85c4a;color:#c85c4a;background:#fff5f0}
|
||
.wp-marker.base{border-color:#d4a017;color:#9a7800;background:#fffde0}
|
||
#timeIndicator{position:absolute;top:.6rem;left:.6rem;background:rgba(31,75,55,.95);color:#f5efe0;padding:.4rem .7rem;border-radius:6px;font-size:.85rem;font-weight:800;font-family:'Courier New',monospace;z-index:1500;box-shadow:0 2px 6px rgba(0,0,0,.18)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<h1>🎬 Heli-Drehbuch</h1>
|
||
<select id="missionSel"></select>
|
||
<button id="btnPlay">▶ Start</button>
|
||
<button id="btnPause" disabled>⏸ Pause</button>
|
||
<button id="btnReset">⟲ Reset</button>
|
||
<span style="opacity:.6;font-size:.7rem">Springe zu</span>
|
||
<button id="btnJumpFlight" style="background:#4a7c8a">→ Flug</button>
|
||
<button id="btnJumpLanding" style="background:#c85c4a">→ Landung</button>
|
||
<span style="opacity:.6;font-size:.7rem;margin-left:.4rem">Tempo</span>
|
||
<button class="speed" data-speed="1" style="background:#5a6e80">1×</button>
|
||
<button class="speed" data-speed="2" style="background:#5a6e80">2×</button>
|
||
<button class="speed" data-speed="5" style="background:#5a6e80">5×</button>
|
||
<button class="speed" data-speed="10" style="background:#5a6e80">10×</button>
|
||
<button id="btnRec">🔴 Aufnahme</button>
|
||
<button id="btnSave">💾 Speichern</button>
|
||
<span class="info" id="info">—</span>
|
||
</header>
|
||
|
||
<main>
|
||
<div id="mapWrap">
|
||
<div id="map"></div>
|
||
<div id="timeIndicator">00:00 / 00:00 · — </div>
|
||
</div>
|
||
|
||
<aside id="sidebar">
|
||
<div class="sb-head">
|
||
<div>Drehbuch</div>
|
||
<div class="legend">
|
||
<span><i style="background:#fff2e6;border:1px solid #c85c4a"></i>spielt gerade</span>
|
||
<span><i style="background:#e8f0e3;border:1px solid #4a7c4e"></i>behalten</span>
|
||
<span style="opacity:.7">(im Aufnahme-Modus klickbar)</span>
|
||
</div>
|
||
</div>
|
||
<div id="cards"></div>
|
||
</aside>
|
||
</main>
|
||
|
||
<script src="<?= $base ?>/assets/vendor/leaflet/leaflet.js"></script>
|
||
<script src="<?= $base ?>/sims/heli/scripts/funk-pille.js"></script>
|
||
<script>
|
||
const WP = <?= $WPjs ?>;
|
||
const MISSIONS = <?= $missionsJs ?>;
|
||
const TEXTS = <?= $textsJs ?>;
|
||
const BASE = '<?= $base ?>';
|
||
|
||
// HeliFunk init — laedt speakers.json, ermoeglicht Sprecher-Resolver in renderCards
|
||
if (window.HeliFunk) {
|
||
HeliFunk.init(BASE + '/sims/heli/scripts', BASE + '/sims/heli/img').then(function(){
|
||
// Nach dem Init nochmal rendern, damit die Sprecher-Bilder erscheinen
|
||
if (typeof renderCards === 'function') renderCards();
|
||
}).catch(function(){});
|
||
}
|
||
|
||
// Haversine — Distanz in km zwischen zwei lat/lon-Punkten.
|
||
function calcDistance(lat1, lon1, lat2, lon2) {
|
||
const R = 6371;
|
||
const toRad = d => d * Math.PI / 180;
|
||
const dLat = toRad(lat2 - lat1), dLon = toRad(lon2 - lon1);
|
||
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)**2;
|
||
return 2 * R * Math.asin(Math.sqrt(a));
|
||
}
|
||
// Bearing in Grad (0=N, 90=O, 180=S, 270=W) — analog game.html.
|
||
function calcBearing(lat1, lon1, lat2, lon2) {
|
||
const toRad = d => d * Math.PI / 180;
|
||
const φ1 = toRad(lat1), φ2 = toRad(lat2);
|
||
const Δλ = toRad(lon2 - lon1);
|
||
const y = Math.sin(Δλ) * Math.cos(φ2);
|
||
const x = Math.cos(φ1)*Math.sin(φ2) - Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ);
|
||
return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
|
||
}
|
||
// 8 Himmelsrichtungen → r_nav_<key>
|
||
function bearingToAudio8(bearing) {
|
||
const dirs = ['n','no','o','so','s','sw','w','nw'];
|
||
const idx = Math.round(bearing / 45) % 8;
|
||
return 'r_nav_' + dirs[idx];
|
||
}
|
||
// Spiel-Tempo: max(5s, dist/67.5*60s) pro Segment — exakt wie game.html flyToNext.
|
||
function segmentSeconds(distKm) {
|
||
return Math.max(5, (distKm / 67.5) * 60);
|
||
}
|
||
|
||
// ============================================================
|
||
// Drehbuch-Generierung pro Mission — zeitsynchron mit dem echten Spiel
|
||
// ============================================================
|
||
function buildScript(mission) {
|
||
const events = [];
|
||
const segments = []; // [{startT, endT, fromKey, toKey, distKm}]
|
||
const wps = mission.waypoints;
|
||
const heliKey = mission.heliKey;
|
||
const scen = mission.scen;
|
||
const isHighway = scen === 'autobahn';
|
||
const isWater = scen === 'wasser';
|
||
let t = 0;
|
||
|
||
// V1-Pilot-Geschlecht je Heli (passend zu speakers.json)
|
||
const PILOT_GENDER = {
|
||
c1:'m', c5:'m', c9:'m', c12:'m',
|
||
c2:'f', c6:'f', c11:'f', c16:'f',
|
||
c3:'m', c7:'m', c10:'m', c14:'m',
|
||
c4:'f', c8:'f', ara:'f'
|
||
};
|
||
const pg = PILOT_GENDER[heliKey] || 'f';
|
||
const sfx = pg === 'm' ? '_m' : '';
|
||
|
||
// === PLAN: 1× wertvolles Mission-Geo-Audio am Anfang ===
|
||
events.push({
|
||
phase: 'plan', t,
|
||
audio: 'r_mission_geo_' + mission.id,
|
||
label: 'Plan-Phase: Geographie zur Mission (Briefing-Offizierin, einmalig)',
|
||
duration: 14
|
||
});
|
||
t += 18;
|
||
// Plan-Klick-Phase ohne weitere Audios — Bearbeiter sucht Wegpunkte (~ Anzahl × 6 s)
|
||
t += Math.max(20, (wps.length - 1) * 6);
|
||
const tPlanEnd = t;
|
||
|
||
// === BRIEFING: kurz, kein Audio ===
|
||
events.push({ phase:'briefing', t, audio:null, label:'Briefing-Screen — Audio bereits leer, Start freigeben', duration: 3 });
|
||
t += 4;
|
||
|
||
// === START: 1× Mission-Start (Tower mit Heli + Auftrag) ===
|
||
events.push({
|
||
phase:'start', t,
|
||
audio:'r_mission_start_' + mission.id,
|
||
label:'Start: Tower-Funk — Heli identifiziert, Auftrag und Freigabe',
|
||
duration: 12
|
||
});
|
||
t += 16;
|
||
const tFlightStart = t;
|
||
|
||
// === FLIGHT: keine Sprach-Audios, nur Rotor-Loop ===
|
||
// Heli-Animation in den Segmenten, akustisch laeuft heliFly.mp3.
|
||
for (let i = 1; i < wps.length; i++) {
|
||
const a = WP[wps[i-1]], b = WP[wps[i]];
|
||
if (!a || !b) continue;
|
||
const dist = calcDistance(a.lat, a.lon, b.lat, b.lon);
|
||
const segDur = segmentSeconds(dist);
|
||
segments.push({ startT: t, endT: t + segDur, fromKey: wps[i-1], toKey: wps[i], distKm: dist });
|
||
t += segDur;
|
||
}
|
||
events.push({
|
||
phase:'flight', t: tFlightStart + 1, audio: null,
|
||
label: 'Kartenflug — kein Sprach-Audio, nur Hubschrauber-Rotor-Loop',
|
||
duration: t - tFlightStart - 1
|
||
});
|
||
const tFlightEnd = t;
|
||
|
||
// === LANDING: 1× Caution-Audio (szenario-spezifisch, Pilot m/f) ===
|
||
let cautionScen = isHighway ? 'autobahn'
|
||
: isWater ? 'wasser'
|
||
: (scen === 'schlucht') ? 'schlucht'
|
||
: (scen === 'tal-dorf') ? 'tal_dorf'
|
||
: (scen === 'berg-schnee') ? 'berg_schnee'
|
||
: 'berg_fels';
|
||
events.push({
|
||
phase:'landing', t,
|
||
audio: 'r_caution_' + cautionScen + sfx,
|
||
label:'Anflug Einsatzort: Achtung-Hinweis (Pilot*in, szenario-spezifisch)',
|
||
duration: 10
|
||
});
|
||
t += 14;
|
||
|
||
// === Bergung: Patient an Bord ===
|
||
// Highway: Sani (female), non-Highway: Bergretter (male)
|
||
events.push({
|
||
phase:'landing', t,
|
||
audio: isHighway ? 'r_patient_aboard' : 'r_patient_aboard_m',
|
||
label: 'Patient an Bord (' + (isHighway ? 'Sanitäterin' : 'Flugretter') + ')',
|
||
duration: 5
|
||
});
|
||
t += 8;
|
||
|
||
// === Klinikflug ===
|
||
events.push({
|
||
phase:'extract', t,
|
||
audio: 'r_kurs_klinik' + sfx,
|
||
label:'Kurs Klinik (Pilot*in, sanft fliegen)',
|
||
duration: 8
|
||
});
|
||
t += 12;
|
||
|
||
// === Erfolg ===
|
||
events.push({
|
||
phase:'extract', t,
|
||
audio: 'r_mission_success',
|
||
label: 'Patient sicher abgeliefert (Tower)',
|
||
duration: 6
|
||
});
|
||
t += 8;
|
||
|
||
return { events, segments, tPlanEnd, tFlightStart, tFlightEnd, totalDuration: t };
|
||
}
|
||
|
||
// ============================================================
|
||
// State + UI
|
||
// ============================================================
|
||
let map = null, heliMarker = null, polyline = null, wpMarkers = [];
|
||
let currentMission = MISSIONS[0];
|
||
let scriptData = { events: [], segments: [], tPlanEnd: 0, tFlightStart: 0, tFlightEnd: 0, totalDuration: 0 };
|
||
let kept = new Set(); // audio-IDs als 'behalten' markiert
|
||
let isPlaying = false, isRecording = false;
|
||
let playStartMs = 0, pausedAtMs = 0;
|
||
let playOffsetSec = 0; // Skip-zu-Phase setzt diesen Offset, Animation startet dort
|
||
let speedFactor = 1;
|
||
let currentEventIdx = -1;
|
||
let audioCtx = null;
|
||
|
||
// Audio-Caching + Funk-Filter
|
||
const audioCache = {};
|
||
function ensureAudioCtx() {
|
||
if (!audioCtx) {
|
||
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e) {}
|
||
}
|
||
}
|
||
async function playFunkAudio(name) {
|
||
if (!name) return;
|
||
ensureAudioCtx();
|
||
if (!audioCtx) return;
|
||
if (audioCtx.state === 'suspended') await audioCtx.resume();
|
||
const path = BASE + '/sims/heli/sounds/radio/' + name + '.mp3';
|
||
let buf = audioCache[path];
|
||
if (!buf) {
|
||
try {
|
||
const r = await fetch(path);
|
||
if (!r.ok) return;
|
||
const ab = await r.arrayBuffer();
|
||
buf = await audioCtx.decodeAudioData(ab);
|
||
audioCache[path] = buf;
|
||
} catch(e) { return; }
|
||
}
|
||
const src = audioCtx.createBufferSource();
|
||
src.buffer = buf;
|
||
const bp = audioCtx.createBiquadFilter();
|
||
bp.type = 'bandpass'; bp.frequency.value = 1800; bp.Q.value = 2;
|
||
const gain = audioCtx.createGain(); gain.gain.value = 2.5;
|
||
src.connect(bp); bp.connect(gain); gain.connect(audioCtx.destination);
|
||
src.start(0);
|
||
}
|
||
|
||
// ============================================================
|
||
// Mission-Setup
|
||
// ============================================================
|
||
function selectMission(missionId) {
|
||
resetPlayback();
|
||
currentMission = MISSIONS.find(m => m.id === missionId) || MISSIONS[0];
|
||
scriptData = buildScript(currentMission);
|
||
// Persistierte Auswahl laden
|
||
loadKeptFromStorage();
|
||
renderMap();
|
||
renderCards();
|
||
updateInfo();
|
||
updateTimeIndicator(0);
|
||
}
|
||
|
||
function renderMap() {
|
||
if (!map) {
|
||
map = L.map('map', { zoomControl:true }).setView([47.5, 13.5], 7);
|
||
L.tileLayer('<?= $base ?>/php/tile-proxy.php?z={z}&x={x}&y={y}&p=osm', {
|
||
attribution:'© OpenStreetMap', maxZoom:14
|
||
}).addTo(map);
|
||
}
|
||
// Layers reset
|
||
if (polyline) { map.removeLayer(polyline); polyline = null; }
|
||
wpMarkers.forEach(m => map.removeLayer(m));
|
||
wpMarkers = [];
|
||
if (heliMarker) { map.removeLayer(heliMarker); heliMarker = null; }
|
||
// Wegpunkte
|
||
const wpKeys = currentMission.waypoints;
|
||
const coords = wpKeys.map(k => WP[k]).filter(Boolean).map(w => [w.lat, w.lon]);
|
||
if (!coords.length) return;
|
||
polyline = L.polyline(coords, { color:'#c85c4a', weight:3, dashArray:'8,6' }).addTo(map);
|
||
wpKeys.forEach((k, i) => {
|
||
const w = WP[k]; if (!w) return;
|
||
const isBase = i === 0;
|
||
const isTarget = i === wpKeys.length - 1;
|
||
const cls = isBase ? 'wp-marker base' : isTarget ? 'wp-marker target' : 'wp-marker';
|
||
const html = '<div class="'+cls+'">'+(isBase?'🚁':isTarget?'🎯':i)+'</div>';
|
||
const m = L.marker([w.lat, w.lon], {
|
||
icon: L.divIcon({className:'', html, iconSize:[24,24], iconAnchor:[12,12]})
|
||
}).addTo(map).bindTooltip(w.name);
|
||
wpMarkers.push(m);
|
||
});
|
||
// Heli-Marker auf Base
|
||
const start = coords[0];
|
||
heliMarker = L.marker(start, {
|
||
icon: L.divIcon({className:'', html:'<div class="heli-marker">🚁</div>', iconSize:[30,30], iconAnchor:[15,15]}),
|
||
zIndexOffset: 2000
|
||
}).addTo(map);
|
||
// Karte zoomt auf Tour
|
||
map.fitBounds(L.latLngBounds(coords).pad(0.2));
|
||
}
|
||
|
||
function renderCards() {
|
||
const wrap = document.getElementById('cards');
|
||
wrap.innerHTML = '';
|
||
const heliKey = currentMission && currentMission.heliKey || '';
|
||
scriptData.events.forEach((ev, i) => {
|
||
const c = document.createElement('div');
|
||
c.className = 'card';
|
||
if (isRecording) c.classList.add('clickable');
|
||
if (ev.audio && kept.has(ev.audio)) c.classList.add('kept');
|
||
c.dataset.idx = i;
|
||
const txt = ev.audio ? (TEXTS[ev.audio] || '(Text fehlt — Audio existiert evtl. nicht)') : '';
|
||
// Sprecher via HeliFunk-Resolver (laeuft erst wenn HeliFunk init durch ist)
|
||
let speakerLabel = '', speakerImg = '';
|
||
if (window.HeliFunk && ev.audio) {
|
||
const sp = HeliFunk.resolveSpeaker(ev.audio, heliKey);
|
||
if (sp) { speakerLabel = sp.label; speakerImg = sp.image; }
|
||
}
|
||
c.innerHTML = ''
|
||
+ (speakerImg ? '<div class="card-img" style="background-image:url(\''+speakerImg+'\')"></div>' : '<div class="card-img" style="opacity:.3"></div>')
|
||
+ '<div class="card-body">'
|
||
+ '<div class="card-meta"><span class="card-phase '+ev.phase+'">'+ev.phase.toUpperCase()+'</span><span>'+(speakerLabel||'—')+' · '+formatTime(ev.t)+'</span></div>'
|
||
+ '<div class="card-id">'+(ev.audio || '—')+'</div>'
|
||
+ '<div class="card-text">'+(txt || '<i style="opacity:.6">'+ev.label+'</i>')+'</div>'
|
||
+ '</div>';
|
||
c.addEventListener('click', () => onCardClick(i));
|
||
wrap.appendChild(c);
|
||
});
|
||
}
|
||
|
||
function onCardClick(idx) {
|
||
if (!isRecording) return;
|
||
const ev = scriptData.events[idx];
|
||
if (!ev || !ev.audio) return;
|
||
if (kept.has(ev.audio)) kept.delete(ev.audio);
|
||
else kept.add(ev.audio);
|
||
renderCards();
|
||
updateInfo();
|
||
}
|
||
|
||
// ============================================================
|
||
// Playback (Echtzeit)
|
||
// ============================================================
|
||
function startPlayback() {
|
||
if (!scriptData.events.length) return;
|
||
ensureAudioCtx();
|
||
isPlaying = true;
|
||
// playStartMs so setzen, dass elapsed = pausedAtMs/1000 + playOffsetSec ist.
|
||
playStartMs = performance.now() - pausedAtMs - playOffsetSec * 1000;
|
||
pausedAtMs = 0;
|
||
document.getElementById('btnPlay').disabled = true;
|
||
document.getElementById('btnPause').disabled = false;
|
||
tickPlayback();
|
||
}
|
||
function pausePlayback() {
|
||
if (!isPlaying) return;
|
||
isPlaying = false;
|
||
pausedAtMs = performance.now() - playStartMs;
|
||
document.getElementById('btnPlay').disabled = false;
|
||
document.getElementById('btnPause').disabled = true;
|
||
}
|
||
function resetPlayback() {
|
||
isPlaying = false;
|
||
pausedAtMs = 0;
|
||
playOffsetSec = 0;
|
||
currentEventIdx = -1;
|
||
document.querySelectorAll('.card.active-now').forEach(c => c.classList.remove('active-now'));
|
||
document.getElementById('btnPlay').disabled = false;
|
||
document.getElementById('btnPause').disabled = true;
|
||
if (heliMarker && currentMission) {
|
||
const start = WP[currentMission.waypoints[0]];
|
||
if (start) heliMarker.setLatLng([start.lat, start.lon]);
|
||
}
|
||
updateTimeIndicator(0);
|
||
}
|
||
// Spult zu einem bestimmten Zeitpunkt (Sekunden) im Drehbuch und startet
|
||
// die Wiedergabe von dort. Heli-Position + Karten werden automatisch
|
||
// per tickPlayback aktualisiert.
|
||
function jumpTo(targetSec) {
|
||
pausePlayback();
|
||
pausedAtMs = 0;
|
||
playOffsetSec = Math.max(0, targetSec);
|
||
currentEventIdx = -1;
|
||
document.querySelectorAll('.card.active-now').forEach(c => c.classList.remove('active-now'));
|
||
startPlayback();
|
||
}
|
||
function tickPlayback() {
|
||
if (!isPlaying) return;
|
||
// Speed-Faktor: realer Zeit-Offset wird mit speedFactor multipliziert.
|
||
const realDt = (performance.now() - playStartMs) / 1000;
|
||
const elapsed = realDt * speedFactor;
|
||
// Heli-Position SEGMENTWEISE — exakt wie das Spiel:
|
||
// - vor tFlightStart: Heli auf Base
|
||
// - in einem Segment: linear interpoliert ueber dessen reale Spielzeit
|
||
// - nach tFlightEnd: Heli am Zielpunkt
|
||
if (heliMarker && currentMission) {
|
||
const wps = currentMission.waypoints;
|
||
let lat, lon;
|
||
if (elapsed < scriptData.tFlightStart) {
|
||
// Plan / Briefing / Start — Heli steht auf der Base
|
||
const a = WP[wps[0]];
|
||
if (a) { lat = a.lat; lon = a.lon; }
|
||
} else if (elapsed >= scriptData.tFlightEnd) {
|
||
// Landing / Extract — am letzten Wegpunkt
|
||
const b = WP[wps[wps.length - 1]];
|
||
if (b) { lat = b.lat; lon = b.lon; }
|
||
} else {
|
||
// Im Flug: zustaendiges Segment finden + linear interpolieren
|
||
let seg = null;
|
||
for (const s of scriptData.segments) {
|
||
if (elapsed >= s.startT && elapsed < s.endT) { seg = s; break; }
|
||
}
|
||
if (seg) {
|
||
const a = WP[seg.fromKey], b = WP[seg.toKey];
|
||
if (a && b) {
|
||
const f = (elapsed - seg.startT) / Math.max(0.001, seg.endT - seg.startT);
|
||
lat = a.lat + (b.lat - a.lat) * f;
|
||
lon = a.lon + (b.lon - a.lon) * f;
|
||
}
|
||
}
|
||
}
|
||
if (lat !== undefined && lon !== undefined) heliMarker.setLatLng([lat, lon]);
|
||
}
|
||
// Aktiver Event-Eintrag
|
||
let idx = -1;
|
||
for (let i = 0; i < scriptData.events.length; i++) {
|
||
const ev = scriptData.events[i];
|
||
const evEnd = ev.t + (ev.duration || 4);
|
||
if (elapsed >= ev.t && elapsed < evEnd) { idx = i; break; }
|
||
}
|
||
if (idx !== currentEventIdx) {
|
||
document.querySelectorAll('.card.active-now').forEach(c => c.classList.remove('active-now'));
|
||
if (idx >= 0) {
|
||
const card = document.querySelector('.card[data-idx="'+idx+'"]');
|
||
if (card) {
|
||
card.classList.add('active-now');
|
||
card.scrollIntoView({ behavior:'smooth', block:'center' });
|
||
}
|
||
const ev = scriptData.events[idx];
|
||
if (ev.audio) playFunkAudio(ev.audio);
|
||
}
|
||
currentEventIdx = idx;
|
||
}
|
||
updateTimeIndicator(elapsed);
|
||
if (elapsed >= scriptData.totalDuration + 1) {
|
||
pausePlayback();
|
||
return;
|
||
}
|
||
requestAnimationFrame(tickPlayback);
|
||
}
|
||
|
||
function updateTimeIndicator(elapsed) {
|
||
// Phase aus Zeit ableiten (vor tFlightStart = PLAN/BRIEFING/START,
|
||
// dann FLIGHT, dann LANDING/EXTRACT)
|
||
let phase = 'PLAN';
|
||
if (elapsed >= scriptData.tFlightEnd) phase = 'LANDING';
|
||
else if (elapsed >= scriptData.tFlightStart) phase = 'FLIGHT';
|
||
else if (elapsed >= scriptData.tPlanEnd) phase = 'START';
|
||
document.getElementById('timeIndicator').textContent =
|
||
formatTime(elapsed) + ' / ' + formatTime(scriptData.totalDuration)
|
||
+ ' · ' + phase + ' · ' + speedFactor + '×';
|
||
}
|
||
|
||
function formatTime(s) {
|
||
const m = Math.floor(s / 60);
|
||
const r = Math.floor(s % 60);
|
||
return String(m).padStart(2,'0') + ':' + String(r).padStart(2,'0');
|
||
}
|
||
|
||
// ============================================================
|
||
// Aufnahme + Speichern (localStorage)
|
||
// ============================================================
|
||
function toggleRecording() {
|
||
isRecording = !isRecording;
|
||
const btn = document.getElementById('btnRec');
|
||
btn.classList.toggle('recording', isRecording);
|
||
btn.textContent = isRecording ? '⏺ Aufnahme aktiv (klicke Karten)' : '🔴 Aufnahme';
|
||
renderCards();
|
||
}
|
||
function saveKept() {
|
||
const data = {
|
||
missionId: currentMission.id,
|
||
savedAt: new Date().toISOString(),
|
||
keptAudios: Array.from(kept),
|
||
};
|
||
localStorage.setItem('heli-drehbuch-' + currentMission.id, JSON.stringify(data));
|
||
alert('Gespeichert: ' + kept.size + ' Audios als "behalten" markiert für ' + currentMission.id.toUpperCase()
|
||
+ '\n\nGespeichert in localStorage. Sobald du mehrere Missionen kuratiert hast, koennen wir die Liste an den Server schicken.');
|
||
updateInfo();
|
||
}
|
||
function loadKeptFromStorage() {
|
||
kept = new Set();
|
||
const raw = localStorage.getItem('heli-drehbuch-' + currentMission.id);
|
||
if (!raw) return;
|
||
try {
|
||
const data = JSON.parse(raw);
|
||
(data.keptAudios || []).forEach(a => kept.add(a));
|
||
} catch(e) {}
|
||
}
|
||
function updateInfo() {
|
||
const audioCount = scriptData.events.filter(e => e.audio).length;
|
||
document.getElementById('info').textContent =
|
||
currentMission.id.toUpperCase() + ' · ' + audioCount + ' Audios · ' +
|
||
kept.size + ' behalten · Total ' + formatTime(scriptData.totalDuration);
|
||
}
|
||
|
||
// ============================================================
|
||
// Init
|
||
// ============================================================
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
const sel = document.getElementById('missionSel');
|
||
MISSIONS.forEach(m => {
|
||
const o = document.createElement('option');
|
||
o.value = m.id;
|
||
o.textContent = m.id.toUpperCase() + ' — ' + m.title + ' (' + m.scen + ')';
|
||
sel.appendChild(o);
|
||
});
|
||
sel.addEventListener('change', () => selectMission(sel.value));
|
||
document.getElementById('btnPlay').addEventListener('click', startPlayback);
|
||
document.getElementById('btnPause').addEventListener('click', pausePlayback);
|
||
document.getElementById('btnReset').addEventListener('click', resetPlayback);
|
||
document.getElementById('btnRec').addEventListener('click', toggleRecording);
|
||
document.getElementById('btnSave').addEventListener('click', saveKept);
|
||
document.getElementById('btnJumpFlight').addEventListener('click', () => jumpTo(scriptData.tFlightStart));
|
||
document.getElementById('btnJumpLanding').addEventListener('click', () => jumpTo(scriptData.tFlightEnd));
|
||
// Speed-Buttons — beim Wechsel bleibt die aktuelle Position erhalten
|
||
document.querySelectorAll('button.speed').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const newSpeed = parseFloat(btn.dataset.speed) || 1;
|
||
if (isPlaying) {
|
||
const currentElapsed = ((performance.now() - playStartMs) / 1000) * speedFactor;
|
||
speedFactor = newSpeed;
|
||
playStartMs = performance.now() - (currentElapsed / newSpeed) * 1000;
|
||
} else {
|
||
speedFactor = newSpeed;
|
||
}
|
||
document.querySelectorAll('button.speed').forEach(b => b.classList.toggle('on', b === btn));
|
||
});
|
||
});
|
||
// Default 1× hervorheben
|
||
document.querySelector('button.speed[data-speed="1"]').classList.add('on');
|
||
selectMission(MISSIONS[0].id);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|