Skip to main content

shuyi_ui/
cinematic.rs

1//! Plays a cinematic (`shuyi_assets::cinematic`, ticket 1113; the game's
2//! game ADR-0060): a [`Timeline`] says which shot a time falls in and how far
3//! through it, and a [`CinematicPlayer`] has the shot draw itself there.
4//! What a shot shows is the game's: its shot kinds implement
5//! [`ShotPainter`].
6//!
7//! **Every frame is drawn from the time alone**: nothing is carried from
8//! one frame to the next, and no shot may keep state that an update
9//! advances; whatever moves in a shot is worked out from the time. So
10//! whoever owns the clock can follow the music, jump or loop, and a test
11//! can draw any moment. The player has no clock of its own.
12//!
13//! The engine also gives what most shots move by: an [`ease_in_out`] (and
14//! every curve of `shuyi_core::ease`, through
15//! [`ease_f32`](crate::ease::ease_f32)), and
16//! a [`Pan`] over a picture shown as the frame's backdrop (game ADR-0048,
17//! [`cinema::view`]), its frame ([`PanFrame`]) and its paint
18//! ([`paint_pan`]).
19
20use crate::prelude::*;
21use alloc::rc::Rc;
22
23use shuyi_assets::cinematic::CinematicDef;
24
25use crate::ctx::{Ctx, GameData};
26use crate::frame::cinema;
27use crate::frame::color::{EngineColor, Palette};
28use crate::frame::console::ConsoleSpec;
29use crate::frame::glyph_buffer::{Cell, GlyphBuffer, Rect};
30
31/// Where a time falls in a [`Timeline`].
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct ShotAt {
34    /// Which shot, counted from 0.
35    pub index: usize,
36    /// How far through the shot: 0 at its start, up to (never reaching) 1
37    /// at the next shot's start.
38    pub progress: f32,
39}
40
41/// Everything a shot is told about a moment of its cinematic.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct Moment {
44    /// Which shot, counted from 0.
45    pub index: usize,
46    /// How far through the shot, as [`ShotAt::progress`].
47    pub progress: f32,
48    /// Milliseconds since the shot started.
49    pub into_ms: u32,
50    /// How long the shot lasts, in milliseconds ([`Timeline::shot_ms`]).
51    pub shot_ms: u32,
52    /// Milliseconds since the cinematic started, wrapped at its length.
53    pub t_ms: u32,
54}
55
56/// When each shot of a cinematic starts, and when the cinematic ends.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Timeline {
59    /// Each shot's start, in milliseconds, in order.
60    starts_ms: Vec<u32>,
61    /// The length, in milliseconds; at least 1.
62    length_ms: u32,
63}
64
65impl Timeline {
66    /// A timeline of shots starting at `starts_ms` (in order, the first at
67    /// 0, as a loaded cinematic has them) over `length_ms` (0 counts as 1).
68    pub fn new(starts_ms: Vec<u32>, length_ms: u32) -> Self {
69        Self {
70            starts_ms,
71            length_ms: length_ms.max(1),
72        }
73    }
74
75    /// The timeline of `def`'s shots.
76    pub fn of<S>(def: &CinematicDef<S>) -> Self {
77        Self::new(def.shots.iter().map(|s| s.at_ms).collect(), def.length_ms)
78    }
79
80    /// The length, in milliseconds.
81    pub fn length_ms(&self) -> u32 {
82        self.length_ms
83    }
84
85    /// When the last shot starts, in milliseconds (0 with no shots).
86    pub fn last_start_ms(&self) -> u32 {
87        self.starts_ms.last().copied().unwrap_or(0)
88    }
89
90    /// Whether `t_ms` (wrapped at the length) falls in the last shot.
91    pub fn in_last_shot(&self, t_ms: u64) -> bool {
92        self.wrapped_ms(t_ms) >= self.last_start_ms()
93    }
94
95    /// How long shot `index` lasts, in milliseconds: until the next one
96    /// starts, the last until the end. 0 for a shot there isn't.
97    pub fn shot_ms(&self, index: usize) -> u32 {
98        let Some(&start) = self.starts_ms.get(index) else {
99            return 0;
100        };
101        let end = self.starts_ms.get(index + 1).copied();
102        end.unwrap_or(self.length_ms).saturating_sub(start)
103    }
104
105    /// `t_ms` wrapped at the length: the cinematic loops.
106    pub fn wrapped_ms(&self, t_ms: u64) -> u32 {
107        u32::try_from(t_ms % u64::from(self.length_ms)).unwrap_or(0)
108    }
109
110    /// The shot `t_ms` milliseconds after the start falls in, wrapped at
111    /// the length. A shot lasts until the next one starts, the last until
112    /// the end; time before the first shot's start is the first shot's.
113    pub fn at_ms(&self, t_ms: u64) -> ShotAt {
114        let (index, into) = self.into_shot(t_ms);
115        let start = self.wrapped_ms(t_ms) - into;
116        let end = self.starts_ms.get(index + 1).map_or(self.length_ms, |&s| s);
117        let span = end.saturating_sub(start).max(1);
118        #[allow(clippy::cast_precision_loss)] // a shot is far below 2^24 ms
119        let progress = (into as f32 / span as f32).clamp(0.0, 1.0);
120        ShotAt { index, progress }
121    }
122
123    /// The shot `t_ms` falls in ([`at_ms`](Self::at_ms)), counted from 0,
124    /// and how many milliseconds into it that is.
125    pub fn into_shot(&self, t_ms: u64) -> (usize, u32) {
126        let t = self.wrapped_ms(t_ms);
127        let index = self.starts_ms.iter().rposition(|&s| s <= t).unwrap_or(0);
128        let start = self.starts_ms.get(index).map_or(0, |&s| s.min(t));
129        (index, t - start)
130    }
131
132    /// [`at_ms`](Self::at_ms) for `t` seconds ([`millis`]).
133    pub fn at(&self, t: f32) -> ShotAt {
134        self.at_ms(millis(t))
135    }
136
137    /// Everything about the moment `t_ms` milliseconds after the start
138    /// (wrapped at the length) that a shot is told.
139    pub fn moment(&self, t_ms: u64) -> Moment {
140        let ShotAt { index, progress } = self.at_ms(t_ms);
141        Moment {
142            index,
143            progress,
144            into_ms: self.into_shot(t_ms).1,
145            shot_ms: self.shot_ms(index),
146            t_ms: self.wrapped_ms(t_ms),
147        }
148    }
149}
150
151/// `t` seconds in whole milliseconds. A time below 0, or one that isn't a
152/// number, counts as 0.
153pub fn millis(t: f32) -> u64 {
154    let ms = (f64::from(t) * 1000.0).round();
155    // The cast stops at 0 and at the biggest number by itself.
156    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
157    if ms.is_finite() { ms as u64 } else { 0 }
158}
159
160/// `p` (0 to 1; clamped, not a number counts as 0) eased in and out: slow
161/// at both ends, fastest in the middle. It is smoothstep, the curve of
162/// [`Ease::Smooth`](shuyi_core::Ease::Smooth), in `f32`; through
163/// [`ease_f32`](crate::ease::ease_f32) a skin has every other curve too.
164pub fn ease_in_out(p: f32) -> f32 {
165    let p = if p.is_finite() {
166        p.clamp(0.0, 1.0)
167    } else {
168        0.0
169    };
170    p * p * (3.0 - 2.0 * p)
171}
172
173/// A pan over a picture shown as the frame's backdrop (game ADR-0048): the
174/// picture's pixel `from` in the middle of the screen when the shot
175/// starts, `to` when it ends, eased in and out ([`ease_in_out`]), at one
176/// whole zoom for the whole shot.
177#[derive(Debug, Clone, Copy, PartialEq)]
178pub struct Pan {
179    /// The picture's pixel in the middle of the screen at the start.
180    pub from: (f32, f32),
181    /// The picture's pixel in the middle of the screen at the end.
182    pub to: (f32, f32),
183    /// Screen pixels per picture pixel.
184    pub zoom: u8,
185}
186
187impl Pan {
188    /// The picture's pixel in the middle of the screen `progress` (0 to 1)
189    /// through the shot.
190    pub fn centre(&self, progress: f32) -> (f32, f32) {
191        let eased = ease_in_out(progress);
192        let (from, to) = (self.from, self.to);
193        (
194            from.0 + (to.0 - from.0) * eased,
195            from.1 + (to.1 - from.1) * eased,
196        )
197    }
198
199    /// The picture's pixel at the top-left of a screen of `screen`'s cells
200    /// of `console` ([`cinema::view`]), `progress` through the shot, over a
201    /// picture `picture_px` big: the backdrop's `origin_px`.
202    pub fn origin_px(
203        &self,
204        console: ConsoleSpec,
205        picture_px: (u32, u32),
206        screen: Rect,
207        progress: f32,
208    ) -> (f32, f32) {
209        let px = |cells: i32, cell: u16| u32::try_from(cells).unwrap_or(0) * u32::from(cell);
210        let clip = (px(screen.w, console.cell_w), px(screen.h, console.cell_h));
211        cinema::view(picture_px, clip, self.centre(progress), self.zoom)
212    }
213
214    /// What the pan shows `progress` through the shot on a screen of
215    /// `screen`'s cells of `console`, over a picture `picture_px` big
216    /// ([`origin_px`](Self::origin_px)).
217    pub fn frame(
218        &self,
219        console: ConsoleSpec,
220        picture_px: (u32, u32),
221        screen: Rect,
222        progress: f32,
223    ) -> PanFrame {
224        PanFrame {
225            origin_px: self.origin_px(console, picture_px, screen, progress),
226            zoom: self.zoom,
227        }
228    }
229}
230
231/// A frame of a [`Pan`]: where its picture is behind the screen, as plain
232/// data.
233#[derive(Debug, Clone, Copy, PartialEq)]
234pub struct PanFrame {
235    /// The picture's pixel at the screen's top-left ([`Pan::origin_px`]).
236    pub origin_px: (f32, f32),
237    /// Screen pixels per picture pixel.
238    pub zoom: u8,
239}
240
241/// Paints `frame` over the whole of `buf`: `picture` (a whole map, say:
242/// [`MapPicture`](crate::grid::MapPicture)) as its backdrop, behind cells
243/// that are all holes, in `palette`'s text color.
244pub fn paint_pan(
245    palette: &Palette,
246    picture: Rc<GlyphBuffer>,
247    frame: PanFrame,
248    buf: &mut GlyphBuffer,
249) {
250    let hole = Cell::see_through(' ', palette.engine(EngineColor::Text));
251    buf.fill_rect(buf.bounds(), hole);
252    buf.set_backdrop(picture, buf.bounds(), frame.origin_px, frame.zoom);
253}
254
255/// A game's shot, built once to be drawn at any moment: one of its shot
256/// kinds with whatever it needs ready (a picture painted, a text laid
257/// out). Never changed by drawing.
258pub trait ShotPainter {
259    /// The game it is a shot of.
260    type Game: GameData;
261    /// What every shot of a player is drawn with, from whoever plays it
262    /// (`()` for nothing): the backdrop a title picked, say.
263    type Shared;
264    /// What a frame of the shot shows, as plain data: for tests of what
265    /// plays, and for [`paint`](Self::paint).
266    type Frame;
267
268    /// What the shot shows at `moment`, on a screen of `screen` cells.
269    fn frame(
270        &self,
271        ctx: &Ctx<Self::Game>,
272        shared: &Self::Shared,
273        moment: Moment,
274        screen: Rect,
275    ) -> Self::Frame;
276
277    /// Paints `frame` (the shot's frame at `moment`) over the whole of
278    /// `buf`, which is cleared to the background.
279    fn paint(
280        &self,
281        ctx: &Ctx<Self::Game>,
282        shared: &Self::Shared,
283        moment: Moment,
284        frame: Self::Frame,
285        buf: &mut GlyphBuffer,
286    );
287}
288
289/// Draws a cinematic of shots `S` at any time.
290#[derive(Debug, Clone, PartialEq)]
291pub struct CinematicPlayer<S> {
292    timeline: Timeline,
293    shots: Vec<S>,
294}
295
296impl<S> CinematicPlayer<S> {
297    /// A player of `shots` (one for each start of `timeline`).
298    pub fn new(timeline: Timeline, shots: Vec<S>) -> Self {
299        Self { timeline, shots }
300    }
301
302    /// A player of the cinematic `def`, each shot built once by `build`,
303    /// in order.
304    pub fn of<'d, D>(def: &'d CinematicDef<D>, build: impl FnMut(&'d D) -> S) -> Self {
305        let shots = def.shots.iter().map(|s| &s.shot).map(build).collect();
306        Self::new(Timeline::of(def), shots)
307    }
308
309    /// When its shots start and how long it is.
310    pub fn timeline(&self) -> &Timeline {
311        &self.timeline
312    }
313
314    /// Shot `index`, counted from 0.
315    pub fn shot(&self, index: usize) -> Option<&S> {
316        self.shots.get(index)
317    }
318
319    /// The moment `t` seconds after the start (wrapped at the length).
320    pub fn moment(&self, t: f32) -> Moment {
321        self.timeline.moment(millis(t))
322    }
323
324    /// The shot shown `t` seconds after the start (wrapped at the length),
325    /// and the moment of it; `None` with no shots.
326    pub fn at(&self, t: f32) -> Option<(&S, Moment)> {
327        let moment = self.moment(t);
328        self.shots.get(moment.index).map(|shot| (shot, moment))
329    }
330}
331
332impl<S: ShotPainter> CinematicPlayer<S> {
333    /// What is shown `t` seconds after the start (wrapped at the length),
334    /// on a screen of `screen` cells; `None` with no shots.
335    pub fn frame(
336        &self,
337        ctx: &Ctx<S::Game>,
338        shared: &S::Shared,
339        t: f32,
340        screen: Rect,
341    ) -> Option<S::Frame> {
342        let (shot, moment) = self.at(t)?;
343        Some(shot.frame(ctx, shared, moment, screen))
344    }
345
346    /// Draws the cinematic as it is `t` seconds after its start (wrapped
347    /// at the length) over the whole of `buf`, at `brightness` (1 = as it
348    /// is, 0 = black: [`GlyphBuffer::fade`]). The same `t` always draws
349    /// the same frame.
350    pub fn draw(
351        &self,
352        ctx: &Ctx<S::Game>,
353        shared: &S::Shared,
354        t: f32,
355        brightness: f32,
356        buf: &mut GlyphBuffer,
357    ) {
358        let c = |e| ctx.palette.engine(e);
359        let blank = Cell::new(' ', c(EngineColor::Text), c(EngineColor::Background));
360        buf.fill_rect(buf.bounds(), blank);
361        if let Some((shot, moment)) = self.at(t) {
362            let frame = shot.frame(ctx, shared, moment, buf.bounds());
363            shot.paint(ctx, shared, moment, frame, buf);
364        }
365        buf.fade(brightness);
366    }
367}
368
369#[cfg(test)]
370mod tests;