Make export own its playback and centralize session exits (#89)

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
2026-11-02 21:00:22 +03:00
parent f28e68db62
commit 814f8c7e83
4 changed files with 239 additions and 63 deletions
+93 -61
View File
@@ -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<u32>,
/// The raid the wave menu orders.
wave: wave::WaveConfig,
/// A replay playing (it takes the screen and the mouse wheel).
replay: Option<replay::Player>,
/// An MP4 export in progress (`/airdef replay export`).
export: Option<Export>,
/// Playback lifecycle. Export owns the exact replay player it is capturing.
presentation: presentation::Session<replay::Player, Export>,
/// 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<sim::lights::Beam> = 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<render::SmokeSegment>) {
/// The recorded scene is read directly; live simulation state is never replaced.
fn shown_smoke(&self) -> (f64, Vec<render::SmokeSegment>) {
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<DVec3> {
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<ParticleSpawn> = 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<usize>) -> 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<Result<(), String>> {
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, &sections);
}
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));
+1 -1
View File
@@ -573,7 +573,7 @@ impl AirDef {
self.map_close();
return;
}
if self.replay.is_some() {
if self.presentation.playback().is_some() {
say("Карта: сначала закройте повтор");
return;
}
+144
View File
@@ -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<P, E> {
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<P, E> {
state: State<P, E>,
}
impl<P, E> Default for Session<P, E> {
fn default() -> Self {
Self { state: State::Live }
}
}
impl<P, E> Session<P, E> {
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<P, E> {
std::mem::replace(&mut self.state, State::Live)
}
pub fn restore(&mut self, state: State<P, E>) {
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::<u8, &str>::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::<u8, ()>::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());
}
}
+1 -1
View File
@@ -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,