Skip to main content

shuyi_assets/
cinematic.rs

1//! Cinematic files: a list of shots and when each starts, over a length
2//! of time (ticket 1113; game ADR-0060). The engine reads the file's
3//! frame and checks its times; what a shot shows is the game's own (`S`),
4//! checked by the game ([`ShotCheck`]) and played by
5//! `shuyi_ui::cinematic`.
6//!
7//! ```ron
8//! (
9//!     id: "title",
10//!     length: Music("title"),     // or Seconds(10.0)
11//!     shots: [
12//!         (at: 0.0, shot: Pan(from: (4, 9), to: (12, 3.5))),
13//!         (at: 6.0, shot: Logo),
14//!     ],
15//! )
16//! ```
17//!
18//! - `at` is seconds from the start. A shot lasts until the next one's
19//!   `at`; the last until the end. Shots cut: there is no fade.
20//! - `length` is `Music(cue)`, the cue's `length_ms` in the audio
21//!   manifest (so the file never repeats a number the manifest owns), or
22//!   `Seconds(n)` for a cinematic on no track.
23//! - `actions` (optional) are lists by name that shots may share: what a
24//!   game's actors act out, say. The game says what a list is (`P`) and
25//!   checks them with its shots.
26//! - Unknown fields are refused. Times are kept in whole milliseconds, so
27//!   a checked cinematic holds no floats and can be compared with `Eq`.
28//!
29//! As a format (ADR-0007), [`CinematicRon`] reads a file as written
30//! ([`CinematicFile`]); [`check`] then checks it against the game's music
31//! and shots.
32use crate::prelude::*;
33
34use alloc::collections::BTreeMap;
35use core::fmt;
36use core::marker::PhantomData;
37
38use serde::de::DeserializeOwned;
39use serde::{Deserialize, Serialize};
40
41use crate::audio::AudioManifest;
42use crate::error::ContentError;
43use crate::format::{AssetKind, AssetPath, DecodeCx, Format, TEXT_MAX_BYTES, text};
44use crate::ron::parse_ron;
45
46impl<S: 'static, P: 'static> AssetKind for CinematicFile<S, P> {
47    const NAME: &'static str = "cinematic";
48}
49
50/// The cinematic file (`cinematic`, `.ron`) of a game whose shots are `S`
51/// and shared lists `P`, read as [`parse`] says: as written, before any
52/// check. [`check`] checks it, with the cinematic's file stem
53/// ([`AssetPath::stem`]), the music and the game's shots.
54pub struct CinematicRon<S, P = ()>(PhantomData<fn() -> (S, P)>);
55
56impl<S, P> CinematicRon<S, P> {
57    /// The format, for a game's shots `S` and lists `P`.
58    pub const fn new() -> Self {
59        Self(PhantomData)
60    }
61}
62
63impl<S, P> Default for CinematicRon<S, P> {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69impl<S, P> fmt::Debug for CinematicRon<S, P> {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.write_str("CinematicRon")
72    }
73}
74
75impl<S: DeserializeOwned + 'static, P: DeserializeOwned + 'static> Format for CinematicRon<S, P> {
76    type Asset = CinematicFile<S, P>;
77
78    fn id(&self) -> &'static str {
79        "cinematic"
80    }
81
82    fn extensions(&self) -> &'static [&'static str] {
83        &["ron"]
84    }
85
86    fn max_bytes(&self) -> usize {
87        TEXT_MAX_BYTES
88    }
89
90    fn decode(
91        &self,
92        file: &AssetPath,
93        bytes: &[u8],
94        _cx: &DecodeCx<'_>,
95    ) -> Result<CinematicFile<S, P>, Vec<ContentError>> {
96        parse(file.shown(), text(file, bytes)?).map_err(|e| vec![e])
97    }
98}
99
100/// The longest a cinematic that states its own length may be, in seconds.
101pub const MAX_SECONDS: f32 = 3600.0;
102
103/// One cinematic: its shots, in order, over a length of time.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct CinematicDef<S> {
106    /// The cinematic's id (its file stem).
107    pub id: String,
108    /// The music cue whose length is the cinematic's, if it follows one.
109    pub music: Option<String>,
110    /// How long it is, in milliseconds (never 0): the music cue's
111    /// `length_ms`, or the length the file states.
112    pub length_ms: u32,
113    /// The shots, by start time; never empty, the first at 0. A shot lasts
114    /// until the next one starts, the last until the end.
115    pub shots: Vec<ShotDef<S>>,
116}
117
118/// A shot and when it starts.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct ShotDef<S> {
121    /// Milliseconds from the cinematic's start.
122    pub at_ms: u32,
123    /// What is shown: the game's shot.
124    pub shot: S,
125}
126
127/// A cinematic file as written, before any check: `S` is a shot and `P`
128/// a list of `actions` as the game writes them (`()` for a game whose
129/// shots share none).
130#[derive(Debug, PartialEq, Serialize, Deserialize)]
131#[serde(
132    deny_unknown_fields,
133    bound(
134        serialize = "S: Serialize, P: Serialize",
135        deserialize = "S: Deserialize<'de>, P: Deserialize<'de>"
136    )
137)]
138pub struct CinematicFile<S, P = ()> {
139    /// The cinematic's id; it must be the file's name.
140    pub id: String,
141    /// How long it is.
142    pub length: Length,
143    /// Lists by name, for shots to share.
144    #[serde(default = "BTreeMap::new")]
145    pub actions: BTreeMap<String, P>,
146    /// The shots and when each starts.
147    pub shots: Vec<TimedShot<S>>,
148}
149
150/// How long a cinematic is, as its file says.
151#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
152pub enum Length {
153    /// As long as this music cue.
154    Music(String),
155    /// This many seconds.
156    Seconds(f32),
157}
158
159/// A shot as written, and when it starts.
160#[derive(Debug, PartialEq, Serialize, Deserialize)]
161#[serde(
162    deny_unknown_fields,
163    bound(serialize = "S: Serialize", deserialize = "S: Deserialize<'de>")
164)]
165pub struct TimedShot<S> {
166    /// Seconds from the start.
167    pub at: f32,
168    /// The game's shot.
169    pub shot: S,
170}
171
172/// How a game checks its shots `S`, one file at a time; it may keep what
173/// it learns from one shot for the next (a shared list it checked).
174pub trait ShotCheck<S> {
175    /// The shot as the game keeps it.
176    type Shot;
177
178    /// Checks shot `n` (counted from 1), after the frame's checks of its
179    /// time, adding each problem to `problems` as a whole message: one of
180    /// the shot alone starts `shot {n}: `. Returns the shot as kept, used
181    /// only if the file has no problem at all.
182    fn check(&mut self, n: usize, shot: &S, problems: &mut Vec<String>) -> Self::Shot;
183
184    /// Adds the problems found once every shot is checked (say, a shared
185    /// list that no shot uses). None by default.
186    fn finish(&mut self, _problems: &mut Vec<String>) {}
187}
188
189/// Reads cinematic `source` (errors attributed to `file`) without
190/// checking it: a game whose shots share `actions` makes its
191/// [`ShotCheck`] from them, then calls [`check`].
192///
193/// # Errors
194///
195/// When `source` isn't such a file, with the place.
196pub fn parse<S: DeserializeOwned, P: DeserializeOwned>(
197    file: &str,
198    source: &str,
199) -> Result<CinematicFile<S, P>, ContentError> {
200    parse_ron(file, source)
201}
202
203/// Reads and checks cinematic `source` ([`parse`], then [`check`]), for a
204/// game whose shots share no `actions`.
205///
206/// # Errors
207///
208/// Every problem of the file, as [`check`] reports them.
209pub fn from_source<S: DeserializeOwned, C: ShotCheck<S>>(
210    file: &str,
211    stem: &str,
212    source: &str,
213    audio: &AudioManifest,
214    shots: C,
215) -> Result<CinematicDef<C::Shot>, Vec<ContentError>> {
216    let raw: CinematicFile<S> = parse(file, source).map_err(|e| vec![e])?;
217    check(file, stem, &raw, audio, shots)
218}
219
220/// Checks cinematic `raw`, read from `file`, whose name without its
221/// extension is `stem`, with the music cues of `audio` and the game's
222/// checks of its shots. Reports every problem, in order:
223///
224/// - the id isn't the file name;
225/// - the length: a music cue that doesn't exist, or seconds that aren't
226///   above 0 and at most [`MAX_SECONDS`];
227/// - no shots;
228/// - for each shot, a time that isn't one, a first shot not at 0, a shot
229///   that doesn't start after the one before it, or not before the end;
230///   then the shot's own problems ([`ShotCheck::check`]);
231/// - and last, [`ShotCheck::finish`]'s.
232///
233/// # Errors
234///
235/// Every problem, each attributed to `file`.
236pub fn check<S, P, C: ShotCheck<S>>(
237    file: &str,
238    stem: &str,
239    raw: &CinematicFile<S, P>,
240    audio: &AudioManifest,
241    mut shots: C,
242) -> Result<CinematicDef<C::Shot>, Vec<ContentError>> {
243    let mut problems = Vec::new();
244    if raw.id != stem {
245        problems.push(format!(
246            "id \"{}\" must match the file name \"{stem}\"",
247            raw.id
248        ));
249    }
250    let (music, length_ms) = match &raw.length {
251        Length::Music(cue) => {
252            let length = audio.music.get(cue).map(|m| m.length_ms);
253            if length.is_none() {
254                problems.push(format!("length: no music cue \"{cue}\""));
255            }
256            (Some(cue.clone()), length)
257        }
258        Length::Seconds(seconds) => {
259            let length = millis(*seconds).filter(|&ms| ms > 0 && *seconds <= MAX_SECONDS);
260            if length.is_none() {
261                problems.push(format!(
262                    "length: {seconds} seconds; it must be above 0 and at most {MAX_SECONDS}"
263                ));
264            }
265            (None, length)
266        }
267    };
268    if raw.shots.is_empty() {
269        problems.push("shots: a cinematic needs at least one shot".to_owned());
270    }
271    let mut kept: Vec<ShotDef<C::Shot>> = Vec::new();
272    for (i, TimedShot { at, shot }) in raw.shots.iter().enumerate() {
273        let n = i + 1;
274        let at_ms = millis(*at);
275        match (at_ms, kept.last().map(|s| s.at_ms)) {
276            (None, _) => problems.push(format!("shot {n}: at {at} is not a time")),
277            (Some(ms), None) if ms != 0 => {
278                problems.push(format!("shot {n}: the first shot must be at 0.0, not {at}"));
279            }
280            (Some(ms), Some(before)) if ms <= before => problems.push(format!(
281                "shot {n}: at {at} must be after the shot before it; shots are in order of time"
282            )),
283            _ => {}
284        }
285        if let (Some(ms), Some(length)) = (at_ms, length_ms)
286            && ms >= length
287        {
288            problems.push(format!(
289                "shot {n}: at {at} is not before the end ({} seconds)",
290                seconds(length)
291            ));
292        }
293        let shot = shots.check(n, shot, &mut problems);
294        kept.push(ShotDef {
295            at_ms: at_ms.unwrap_or(0),
296            shot,
297        });
298    }
299    shots.finish(&mut problems);
300    match length_ms {
301        Some(length_ms) if problems.is_empty() => Ok(CinematicDef {
302            id: raw.id.clone(),
303            music,
304            length_ms,
305            shots: kept,
306        }),
307        _ => Err(problems
308            .into_iter()
309            .map(|m| ContentError::new(file, m))
310            .collect()),
311    }
312}
313
314/// `seconds` in whole milliseconds, or `None` if it isn't a time: not a
315/// number, below 0, or past what a `u32` holds.
316pub fn millis(seconds: f32) -> Option<u32> {
317    let ms = libm::round(f64::from(seconds) * 1000.0);
318    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // checked
319    (ms.is_finite() && (0.0..=f64::from(u32::MAX)).contains(&ms)).then_some(ms as u32)
320}
321
322/// `ms` in seconds, for a message.
323pub fn seconds(ms: u32) -> f64 {
324    f64::from(ms) / 1000.0
325}
326
327#[cfg(test)]
328mod tests;