diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index d802d6a..b35e100 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -20,6 +20,7 @@ mod map; mod film; mod monitor; mod panel; +mod presentation; mod radar; mod replay; mod smoke; @@ -48,10 +49,8 @@ struct AirDef { grid_ids: Vec, /// The raid the wave menu orders. wave: wave::WaveConfig, - /// A replay playing (it takes the screen and the mouse wheel). - replay: Option, - /// An MP4 export in progress (`/airdef replay export`). - export: Option, + /// Playback lifecycle. Export owns the exact replay player it is capturing. + presentation: presentation::Session, /// The effects preset a film replaced: (shader, hd). film_prev: Option<(bool, bool)>, /// Counts the replay/film sessions started; an export belongs to the one it began with. @@ -465,7 +464,7 @@ impl AirDef { let shown = self.shown(); // A replay lights with the recording's searchlights, where and as they pointed. let beams: Vec = if shown.replay { - match self.replay.as_ref().and_then(|r| self.sim.replay(r.rec).map(|rec| rec.beams(f64::from(shown.now)))) { + match self.presentation.playback().and_then(|r| self.sim.replay(r.rec).map(|rec| rec.beams(f64::from(shown.now)))) { Some(b) => b, None => Vec::new(), } @@ -575,15 +574,14 @@ impl AirDef { /// The smoke volumes the renderer draws and the clock they are aged by: the live ones, or (in a /// replay) the blasts' billows at the replay's moment, never the live raid's trails and fires. - /// The billows are the live ones' own function (`Smoke::segments`), run on the recording's - /// blasts and time with the live state set aside for the call. - fn shown_smoke(&mut self) -> (f64, Vec) { + /// The recorded scene is read directly; live simulation state is never replaced. + fn shown_smoke(&self) -> (f64, Vec) { let shown = self.shown(); if !shown.replay { return (self.sim.tick as f64, self.smoke.segments(&self.sim)); } // The recording alone, as a function of its time: the live Sim is not touched. - let segments = match self.sim.replay(self.replay.as_ref().map_or(0, |r| r.rec)) { + let segments = match self.sim.replay(self.presentation.playback().map_or(0, |r| r.rec)) { Some(rec) => smoke::replay_smoke(rec, f64::from(shown.now)), None => Vec::new(), }; @@ -592,7 +590,7 @@ impl AirDef { /// Where the picture is seen from: the replay's camera, or the player's eyes. fn viewer(&self, shown: &Shown) -> Option { - match &self.replay { + match self.presentation.playback() { Some(r) if shown.replay => Some(r.eye()), _ => player().map(|pl| eye(&pl)), } @@ -601,7 +599,7 @@ impl AirDef { /// The blasts the effects show and the clock they run on: the live ones, or (in a replay) the /// recording's at the replay's moment, never the live world's. fn shown(&self) -> Shown { - if let Some(r) = &self.replay + if let Some(r) = self.presentation.playback() && r.filming() && let Some(rec) = self.sim.replay(r.rec) { @@ -645,7 +643,7 @@ impl AirDef { /// The engines the simulation or the replay declared become persistent sound sources (#95): /// started, followed, stopped. The one-shots below stay as they were. fn step_engines(&mut self, out: &mut Out) { - let replay = self.replay.as_ref().is_some_and(|r| r.filming()); + let replay = self.presentation.playback().is_some_and(|r| r.filming()); let cut = std::mem::take(&mut out.engine_cut) || replay != self.engines_replay; self.engines_replay = replay; let generation = render::sound_source_generation(); @@ -719,7 +717,7 @@ impl AirDef { self.send_models(&out.models); } if !out.particles.is_empty() { - let hd_smoke = self.smoke.hd && !self.replay.as_ref().is_some_and(|r| r.filming()); + let hd_smoke = self.smoke.hd && !self.presentation.playback().is_some_and(|r| r.filming()); let spawns: Vec = out .particles .into_iter() @@ -799,7 +797,7 @@ impl AirDef { fn draw_hud(&mut self, player: DVec3, yaw: f32) { let pic = self.sim.air_picture(); // The command post's grid and replays cover the screen: the other panels wait under them. - if self.grid || self.replay.is_some() || (pic.tracks.is_empty() && self.sim.sams.is_empty() && self.sim.missiles.is_empty()) { + if self.grid || self.presentation.playback().is_some() || (pic.tracks.is_empty() && self.sim.sams.is_empty() && self.sim.missiles.is_empty()) { if self.hud_shown { hud::clear(); render::clear_corner(); @@ -1077,9 +1075,7 @@ impl AirDef { if self.world.is_some() && self.dirty { self.save(); } - if self.replay.is_some() { - self.stop_replay(); - } + self.end_session(presentation::ExitCause::WorldChange); let shader = self.sim.shader; let seed = game::get_clock().map_or(0x5eed, |c| c.game_time.wrapping_mul(2654435761) ^ 0x9e37); self.sim = Sim::new(seed); @@ -1141,7 +1137,7 @@ impl AirDef { /// Plays the last recorded raid: "map", "full", "reel" or (with `event`) a clip. fn start_replay(&mut self, what: &str, event: Option) -> Result<(), String> { - switch_allowed(self.export.is_some())?; + switch_allowed(self.presentation.is_exporting())?; let n = self.sim.replays.len(); if n == 0 { return Err("Повторов пока нет: запишется первый же налёт (когда небо очистится)".into()); @@ -1162,30 +1158,34 @@ impl AirDef { self.say_block_log(rec); let player = replay::Player::new(rec.number, mode, &self.sim); // A film (its quality and effects) or another replay ends properly before this one. - self.end_session(); + self.end_session(presentation::ExitCause::Replaced); self.session += 1; - self.replay = Some(player); + self.presentation.replay(player); self.grid = false; input::claim_wheel(true); Ok(()) } fn stop_replay(&mut self) { - if self.export.take().is_some() { - render::capture_stop(true); + let was_export = self.presentation.is_exporting(); + self.end_session(presentation::ExitCause::UserStop); + if was_export { say("Экспорт отменён"); } - self.end_session(); } /// Ends whatever replay or film shows, on every path: the film quality, the effects, the /// clean HUD, the views and the time and camera overrides go back to the live game's. - fn end_session(&mut self) { + fn end_session(&mut self, cause: presentation::ExitCause) { + let cleanup = self.presentation.end(cause); + if cleanup.cancel_capture { + render::capture_stop(true); + } + self.pending.clear(); render::set_film_quality(false); if let Some((shader, hd)) = self.film_prev.take() { self.set_effects(shader, hd); } - self.replay = None; self.show_world_at(0, 0.0, false); render::set_time_override(None); render::set_main_view(None); @@ -1198,7 +1198,7 @@ impl AirDef { /// Plays the director's film of the last raid at the best quality: HD effects on (restored after). fn start_film(&mut self, which: &str, export: Option<&ExportOpts>) -> Result<(), String> { if export.is_none() { - switch_allowed(self.export.is_some())?; + switch_allowed(self.presentation.is_exporting())?; } let n = self.sim.replays.len(); if n == 0 { @@ -1219,9 +1219,9 @@ impl AirDef { player.limit = o.seconds; } // What was shown ends first (its effects are remembered below, not lost). - self.end_session(); + self.end_session(presentation::ExitCause::Replaced); self.session += 1; - self.replay = Some(player); + self.presentation.replay(player); self.grid = false; input::claim_wheel(true); // The best picture: HD on, the renderer's film quality (full-size world, more cloud and fog steps). @@ -1254,7 +1254,7 @@ impl AirDef { o.name = a.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')).collect(); } } - if self.export.is_some() { + if self.presentation.is_exporting() { return Err("Экспорт уже идёт (/airdef replay stop — отмена)".into()); } if o.name.is_empty() { @@ -1263,20 +1263,26 @@ impl AirDef { } let which = o.which.clone(); self.start_film(&which, Some(&o))?; + let state = self.presentation.take(); + let playback = match state { + presentation::State::Replay(player) => player, + other => { + self.presentation.restore(other); + self.end_session(presentation::ExitCause::Failed); + return Err("Не удалось передать фильм в захват".into()); + } + }; say(&format!("Экспорт «{}»: {} fps, {}x{}; планирую кадры…", o.name, o.fps, o.size.0, o.size.1)); - self.export = Some(Export { session: self.session, opts: o, stage: Stage::Planning, ticks: 0 }); + self.presentation.export(playback, Export { session: self.session, opts: o, stage: Stage::Planning, ticks: 0 }); Ok(()) } /// Drives an export: plan, start the recorder, wait for its first frame, report, stop at the end. - fn export_step(&mut self, r: &mut replay::Player) { - let Some(ex) = &mut self.export else { return }; + fn export_step(&mut self, r: &mut replay::Player, ex: &mut Export) -> Option> { if ex.session != self.session { // The film this export recorded was replaced: the recorder stops, nothing else drives it. - render::capture_stop(true); - self.export = None; say("Экспорт отменён: показ заменён другим"); - return; + return Some(Err("показ заменён другим".into())); } ex.ticks += 1; let st = render::capture_status(); @@ -1334,13 +1340,14 @@ impl AirDef { _ => {} } if let Some(res) = finish { - self.export = None; - match res { + match &res { Ok(m) => say(&format!("Экспорт готов: {m}")), Err(e) => say(&format!("Экспорт не удался: {e}")), } r.done = true; + return Some(res.map(|_| ())); } + None } /// What a raid strikes: the marked objects, else where the player stands. @@ -1653,10 +1660,9 @@ impl Mod for AirDef { grid: false, grid_ids: Vec::new(), wave: wave::WaveConfig::default(), - replay: None, + presentation: presentation::Session::default(), block_log: None, time_view: false, - export: None, film_prev: None, session: 0, smoke: Default::default(), @@ -1704,23 +1710,23 @@ impl Mod for AirDef { fn tick(&mut self, tick: u64) { self.sim.engine_tick = tick; - let Some(p) = player() else { return }; // Each world keeps its own systems, objects and settings: loaded when it opens. let world = world_identity(); if self.world.as_ref() != Some(&world) { self.open_world(world); } + let Some(p) = player() else { return }; for event in input::poll() { match event { Event::UseStart(u) => self.use_item(&u.item.id), Event::KeyDown(k) if k == "map" => self.map_toggle(), Event::KeyDown(k) if k == "menu" => { - if self.replay.is_some() { + if self.presentation.playback().is_some() { self.stop_replay(); } self.open_menu(Page::Main) } - Event::KeyDown(k) if k == "rp_exit" && self.replay.is_some() => self.stop_replay(), + Event::KeyDown(k) if k == "rp_exit" && self.presentation.playback().is_some() => self.stop_replay(), Event::KeyDown(k) if k.starts_with("rp_") => { let c = match k.as_str() { "rp_back" => replay::Control::Back, @@ -1729,7 +1735,7 @@ impl Mod for AirDef { "rp_slower" => replay::Control::Slower, _ => replay::Control::Pause, }; - if let Some(r) = &mut self.replay + if let Some(r) = self.presentation.playback_mut() && let Some(rec) = self.sim.replay(r.rec) { r.control(c, rec); @@ -1739,14 +1745,14 @@ impl Mod for AirDef { Event::KeyDown(k) if k == "grid" => self.grid = !self.grid, Event::KeyDown(k) if k == "ew" => self.toggle_ew(), Event::KeyDown(k) if k == "replay" => { - if self.replay.is_some() { + if self.presentation.playback().is_some() { self.stop_replay(); } else if let Err(e) = self.start_replay("map", None) { say(&e); } } Event::Wheel(w) => { - if let Some(r) = &mut self.replay + if let Some(r) = self.presentation.playback_mut() && let Some(rec) = self.sim.replay(r.rec) { r.wheel(w, rec); @@ -1819,22 +1825,48 @@ impl Mod for AirDef { if !sections.is_empty() { render::query_sections(render::Policy::Collision, §ions); } - if let Some(mut r) = self.replay.take() { - // A replay shows the recording alone: not the live raid's models, particles, sounds or jolts. - if r.filming() { - out.models.clear(); - out.particles.clear(); - out.sounds.clear(); - out.engines.clear(); - out.shake = None; + match self.presentation.take() { + presentation::State::Live => {} + presentation::State::Replay(mut playback) => { + if playback.filming() { + out.models.clear(); + out.particles.clear(); + out.sounds.clear(); + out.engines.clear(); + out.shake = None; + } + playback.tick(&self.sim, &mut self.ground, self.listener, &mut out); + if playback.done { + self.presentation.restore(presentation::State::Replay(playback)); + self.end_session(presentation::ExitCause::Finished); + } else { + self.presentation.restore(presentation::State::Replay(playback)); + } } - r.tick(&self.sim, &mut self.ground, self.listener, &mut out); - self.export_step(&mut r); - if r.done { - self.replay = Some(r); - self.stop_replay(); - } else { - self.replay = Some(r); + presentation::State::Export { mut playback, mut export } => { + let progress = if export.session != self.session { + Some(Err("показ заменён другим".into())) + } else { + if playback.filming() { + out.models.clear(); + out.particles.clear(); + out.sounds.clear(); + out.engines.clear(); + out.shake = None; + } + playback.tick(&self.sim, &mut self.ground, self.listener, &mut out); + self.export_step(&mut playback, &mut export) + }; + let cause = match progress { + Some(Ok(())) => Some(presentation::ExitCause::Finished), + Some(Err(_)) => Some(presentation::ExitCause::Failed), + None if playback.done => Some(presentation::ExitCause::Failed), + None => None, + }; + self.presentation.restore(presentation::State::Export { playback, export }); + if let Some(cause) = cause { + self.end_session(cause); + } } } self.emit(out); @@ -1843,7 +1875,7 @@ impl Mod for AirDef { self.map_frame(); self.draw_hud(DVec3::new(p.x, p.y, p.z), p.yaw); // The replay takes the screen; else the monitors and the cameras they show. - if let Some(r) = &self.replay { + if let Some(r) = self.presentation.playback() { let size = render::gui_size(); render::set_hud(&r.hud(&self.sim, size)); render::set_views(&r.views(size)); diff --git a/airdef-mod/src/map.rs b/airdef-mod/src/map.rs index 405bfa5..f7e5322 100644 --- a/airdef-mod/src/map.rs +++ b/airdef-mod/src/map.rs @@ -573,7 +573,7 @@ impl AirDef { self.map_close(); return; } - if self.replay.is_some() { + if self.presentation.playback().is_some() { say("Карта: сначала закройте повтор"); return; } diff --git a/airdef-mod/src/presentation.rs b/airdef-mod/src/presentation.rs new file mode 100644 index 0000000..4f74da1 --- /dev/null +++ b/airdef-mod/src/presentation.rs @@ -0,0 +1,144 @@ +//! Presentation playback ownership and lifecycle transitions. + +/// The one active presentation mode. Export owns its playback so it cannot outlive its film. +pub enum State { + Live, + Replay(P), + Export { playback: P, export: E }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExitCause { + UserStop, + WorldChange, + Replaced, + Finished, + Failed, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Cleanup { + pub was_export: bool, + pub cancel_capture: bool, +} + +pub struct Session { + state: State, +} + +impl Default for Session { + fn default() -> Self { + Self { state: State::Live } + } +} + +impl Session { + pub fn is_live(&self) -> bool { + matches!(self.state, State::Live) + } + + pub fn is_exporting(&self) -> bool { + matches!(self.state, State::Export { .. }) + } + + pub fn playback(&self) -> Option<&P> { + match &self.state { + State::Live => None, + State::Replay(player) + | State::Export { + playback: player, .. + } => Some(player), + } + } + + pub fn playback_mut(&mut self) -> Option<&mut P> { + match &mut self.state { + State::Live => None, + State::Replay(player) + | State::Export { + playback: player, .. + } => Some(player), + } + } + + pub fn replay(&mut self, playback: P) { + assert!( + self.is_live(), + "end the current presentation before replacing it" + ); + self.state = State::Replay(playback); + } + + pub fn export(&mut self, playback: P, export: E) { + assert!( + self.is_live(), + "end the current presentation before starting export" + ); + self.state = State::Export { playback, export }; + } + + /// Temporarily move the active state out while a tick mutates the player and export together. + pub fn take(&mut self) -> State { + std::mem::replace(&mut self.state, State::Live) + } + + pub fn restore(&mut self, state: State) { + assert!(self.is_live(), "restore only into a detached live session"); + self.state = state; + } + + /// Every exit converges here; a completed capture is already drained and must not be cancelled. + pub fn end(&mut self, cause: ExitCause) -> Cleanup { + let was_export = self.is_exporting(); + self.state = State::Live; + Cleanup { + was_export, + cancel_capture: was_export && cause != ExitCause::Finished, + } + } +} + +#[cfg(test)] +mod tests { + use super::{ExitCause, Session, State}; + + #[test] + fn export_always_owns_its_playback_and_user_stop_cleans_capture() { + let mut session = Session::::default(); + session.export(7, "capture"); + assert!(matches!( + session.take(), + State::Export { + playback: 7, + export: "capture" + } + )); + session.export(7, "capture"); + assert_eq!(session.end(ExitCause::UserStop).cancel_capture, true); + assert!(session.is_live()); + } + + #[test] + fn world_change_and_failure_share_cleanup_while_success_drains() { + for cause in [ExitCause::WorldChange, ExitCause::Failed] { + let mut session = Session::<(), ()>::default(); + session.export((), ()); + assert_eq!(session.end(cause).cancel_capture, true); + assert!(session.is_live()); + } + let mut session = Session::<(), ()>::default(); + session.export((), ()); + assert_eq!(session.end(ExitCause::Finished).cancel_capture, false); + assert!(session.is_live()); + } + + #[test] + fn replay_replacement_is_not_an_export_and_stop_is_idempotent() { + let mut session = Session::::default(); + session.replay(1); + assert!(session.playback().is_some()); + assert_eq!(session.end(ExitCause::Replaced).was_export, false); + assert_eq!(session.end(ExitCause::UserStop).was_export, false); + assert!(session.is_live()); + } +} diff --git a/airdef-mod/src/scenario.rs b/airdef-mod/src/scenario.rs index 34b6f7a..85fa4f6 100644 --- a/airdef-mod/src/scenario.rs +++ b/airdef-mod/src/scenario.rs @@ -61,7 +61,7 @@ impl AirDef { } // The whole body is read and checked before anything is touched (#86). let layout = layout::Layout::parse(text).map_err(|e| format!("Сценарий не загружен, сцена осталась прежней: {e}"))?; - if self.replay.is_some() { + if self.presentation.playback().is_some() { self.stop_replay(); } // Everything of the last layout goes: its systems with their jobs and targets, the sky,