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

659 lines
30 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-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>