diff --git a/crates/benilla-app/src/cinematic.rs b/crates/benilla-app/src/cinematic.rs index 51018a1f4..cc4882495 100644 --- a/crates/benilla-app/src/cinematic.rs +++ b/crates/benilla-app/src/cinematic.rs @@ -9,7 +9,7 @@ //! - The path plays as an M2 animation (`0x7121a0`, sequence 0, rate 1.0), and a shot ends at its //! sequence band's end ([`CinematicPath::duration_ms`]); shipped fly-bys clamp and play once. //! - Every shot, the first included, is announced with `CMSG_NEXT_CINEMATIC_CAMERA` from the shot -//! arm (`0x48edf0`, sent at `0x48ef11`). A camera id of 0 ends the cinematic (`0x48efe0`). +//! arm (`0x48edf0`, sent at `0x48ef11`, after a far shot's load). A camera id of 0 ends the cinematic (`0x48efe0`). //! - ESC is no engine binding: `StopCinematic` has no native caller, and the only skip is //! `CinematicFrame.xml`'s `OnKeyDown`, whose Lua queues //! [`SessionRequest::StopCinematic`](benilla_ui::script::SessionRequest). @@ -19,6 +19,13 @@ //! - Every boundary (start, shot advance, end) cuts through black: a 0.25 s fade-out //! (`[0x804550]`, `0x4c0d10`) whose completion runs the step, then a fade-in (`0x4c1280`). There //! is no audio fade on this path. +//! - A shot that moves the camera more than 30 yards (`0x48eec1`; start and advance share the +//! arm `0x48edf0`) loads its destination in full before it fades in: `0x48eed3` calls +//! `0x66fc80`, which runs the synchronous map update `0x694920` at the black, and only then are +//! the clock restarted, the shot's sound played (`0x48ef2e`) and the shot announced. Here the +//! black is held instead, until the camera's spot is presentable or the teleport hold's +//! ceiling runs out ([`ShotHold`]). The end does the same for the body's spot when the camera +//! ends more than 30 yards from the followed object (`0x48f0fc` to `0x48f118`). //! //! Playback takes over, and releases, the camera pose, the streaming focus (which follows the //! camera, so the flight is over streamed terrain) and the UI's cinematic flag, which drives @@ -35,7 +42,7 @@ use bevy::prelude::*; use crate::client_state::ClientState; use crate::loading_screen::LoadingScreen; use crate::net::{CinematicTriggeredMessage, ClientCommand, NetCommands}; -use crate::player::PlayerControlSet; +use crate::player::{PlayerControlSet, SETTLE_TIMEOUT}; use crate::ui_script::SweepApp; /// Both cinematic DBCs, read once at startup. @@ -56,6 +63,26 @@ pub(crate) struct Cinematic { /// A skip not yet paid: `StopCinematic` runs EndCinematic as its fade-out's completion /// (`0x48f067`, `0x48f080`), so [`stop`] sets this and [`drive`] pays it at full black. skip: bool, + /// A far shot's black, held until the world at its first eye is in ([`ShotHold`]). + hold: Option, + /// How many far shots have asked for a reload so far: the stream's focus carries it, so its + /// load re-arms for each (a cinematic's focus is unpaced throughout, with no pacing edge). + reloads: u32, +} + +/// The black a shot that moved the camera past the reload gate holds before it fades in. +/// +/// The reference's arm (`0x48edf0`, and `0x48efe0` into it) loads that spot synchronously with the +/// screen at black and only then restarts the shot's clock and fades in (`0x48eed3` to `0x48ef43`). +/// The stream cannot block, so the black holds with the shot's clock stopped. It ends on the same +/// residency a teleport's hold does ([`crate::player::residency_reached`] and a presentable +/// world), or at [`SETTLE_TIMEOUT`] from the shot's arm, which nothing extends. +struct ShotHold { + /// `Time::elapsed_secs` at which the hold gives up. + ceiling: f32, + /// The arm's own frame: the stream has not yet seen the camera at the new spot, so the world + /// it reports is the last shot's. + fresh: bool, } /// One cinematic in flight. @@ -90,9 +117,23 @@ impl Cinematic { self.playing.is_some() } + /// The far reloads shots have asked for so far. + pub(crate) fn reloads(&self) -> u32 { + self.reloads + } + + /// A far shot asks the stream for a new load. + pub(crate) fn mark_reload(&mut self) { + self.reloads = self.reloads.wrapping_add(1); + } + /// The shot on screen: `(run, shot index, narration sound id)`, keyed on [`Playing::run`] so a - /// re-trigger counts as a new shot. + /// re-trigger counts as a new shot. None while a far shot's world loads (the black holds): its + /// sound plays after the load (`0x48ef2e`), and the shot left had its sound cut at the advance. pub(crate) fn playing_shot(&self) -> Option<(u64, usize, u32)> { + if self.hold.is_some() { + return None; + } let play = self.playing.as_ref()?; Some((play.run, play.index, play.shot().sound_id)) } @@ -122,8 +163,24 @@ impl Cinematic { }), settling: None, skip: false, + hold: None, + reloads: 0, } } + + /// A cinematic playing `shots`, for tests of the driver. + pub(crate) fn playing_shots_for_test(shots: Vec) -> Self { + let mut cine = Self::playing_for_test(); + if let Some(play) = cine.playing.as_mut() { + play.shots = shots; + } + cine + } + + /// Is a far shot's black being held for its world? + pub(crate) fn holds_black_for_shot(&self) -> bool { + self.hold.is_some() + } } /// One of the two letterbox bars, full-width black, top or bottom. @@ -330,6 +387,7 @@ fn take_trigger( fn relinquish(cine: &mut Cinematic, net: Option<&NetCommands>, why: &str) { if let Some(play) = cine.playing.take() { info!("cinematic: {} {why}", play.sequence_id); + cine.hold = None; ack(net); } if let Some(id) = cine.pending.take() { @@ -347,6 +405,8 @@ fn start_pending( net: Option>, state: Res>, mut fade: ResMut, + time: Res