1use 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
50pub struct CinematicRon<S, P = ()>(PhantomData<fn() -> (S, P)>);
55
56impl<S, P> CinematicRon<S, P> {
57 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
100pub const MAX_SECONDS: f32 = 3600.0;
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct CinematicDef<S> {
106 pub id: String,
108 pub music: Option<String>,
110 pub length_ms: u32,
113 pub shots: Vec<ShotDef<S>>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct ShotDef<S> {
121 pub at_ms: u32,
123 pub shot: S,
125}
126
127#[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 pub id: String,
141 pub length: Length,
143 #[serde(default = "BTreeMap::new")]
145 pub actions: BTreeMap<String, P>,
146 pub shots: Vec<TimedShot<S>>,
148}
149
150#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
152pub enum Length {
153 Music(String),
155 Seconds(f32),
157}
158
159#[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 pub at: f32,
168 pub shot: S,
170}
171
172pub trait ShotCheck<S> {
175 type Shot;
177
178 fn check(&mut self, n: usize, shot: &S, problems: &mut Vec<String>) -> Self::Shot;
183
184 fn finish(&mut self, _problems: &mut Vec<String>) {}
187}
188
189pub 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
203pub 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
220pub 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
314pub 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)] (ms.is_finite() && (0.0..=f64::from(u32::MAX)).contains(&ms)).then_some(ms as u32)
320}
321
322pub fn seconds(ms: u32) -> f64 {
324 f64::from(ms) / 1000.0
325}
326
327#[cfg(test)]
328mod tests;