Skip to main content

blockworx/tutorial/
replay.rs

1//! `--replay` playback state for the main editor UI. Unlike the tutorial
2//! [`Player`](super::player::Player), which runs a separate `Session` inside
3//! a floating window, replay drives the *app's own* document and tool with the
4//! script's `SimFrame`s — the real canvas, toolbar, and overlays are the
5//! video. This struct owns only what the app lacks: the level, the streaming
6//! lowerer, the sim clock, and the synthetic pointer the script implies.
7//!
8//! Replay is an authoring tool: it plays the script once through, then the
9//! app drops into interactive author mode on the end state. There is no
10//! transport UI and no input blocking; a lowering error prints on the
11//! console and ends playback the same way.
12
13use std::time::Duration;
14
15use egui::{Rect, vec2};
16
17use blockworx_doc::document::{DocIndex, Document};
18
19use crate::grid::GRID_SIZE;
20use crate::schema::lower::SourceIds;
21use crate::script::driver::{SimClock, SimDriver, Speed};
22use crate::script::lowering::{Lowering, SimFrame};
23use crate::script::step::CueScope;
24
25use super::level::Level;
26
27pub struct Replay {
28    level: Level,
29    lowering: Lowering,
30    /// The level file's own `"b1"` spellings, resolved to what the bridge
31    /// minted when the app opened this level — the cues' only handle on
32    /// them.
33    ids: SourceIds,
34    clock: SimClock,
35    /// The synthetic pointer and queued editor commit the script implies; the
36    /// app applies the frames to its own document through it.
37    driver: SimDriver,
38    /// The script ran out of frames.
39    finished: bool,
40    /// A doc-anchored step named a missing object — a level bug, reported on
41    /// the console; playback ends there.
42    stopped: bool,
43}
44
45impl Replay {
46    pub fn new(level: Level, ids: SourceIds) -> Self {
47        let lowering = Lowering::new(&level.script);
48        Self {
49            level,
50            lowering,
51            ids,
52            clock: SimClock::default(),
53            driver: SimDriver::default(),
54            finished: false,
55            stopped: false,
56        }
57    }
58
59    pub fn level(&self) -> &Level {
60        &self.level
61    }
62
63    /// The cue/camera clock: sim frames fed so far, in script time.
64    pub fn clock(&self) -> Duration {
65        self.clock.elapsed()
66    }
67
68    pub(crate) fn driver(&mut self) -> &mut SimDriver {
69        &mut self.driver
70    }
71
72    /// What this level's cues resolve against: the app's own document, and
73    /// the level file's id spellings the app's load minted ids for.
74    pub fn cue_scope<'a>(&'a self, doc: &'a Document, index: &'a mut DocIndex) -> CueScope<'a> {
75        CueScope::new(index.view(doc), &self.ids)
76    }
77
78    /// Bank `dt` real time and return how many sim frames came due. 0 once
79    /// playback has ended.
80    pub fn begin_show(&mut self, dt: Duration) -> u32 {
81        if self.done() {
82            return 0;
83        }
84        self.clock.begin_show(dt, Speed::NORMAL)
85    }
86
87    /// The next due frame of synthetic input, resolving doc-anchored targets
88    /// against the app's current document. `None` when the script just
89    /// finished or a target failed to resolve (reported on the console).
90    pub fn next_frame(&mut self, doc: &Document, index: &mut DocIndex) -> Option<SimFrame> {
91        let Self {
92            lowering,
93            ids,
94            clock,
95            level,
96            finished,
97            stopped,
98            ..
99        } = self;
100        match lowering.next(&CueScope::new(index.view(doc), ids)) {
101            Ok(Some(frame)) => {
102                clock.tick();
103                Some(frame)
104            }
105            Ok(None) => {
106                *finished = true;
107                None
108            }
109            Err(e) => {
110                tracing::error!(
111                    "replay of `{}` stopped: demo step {} names a missing object ({:?})",
112                    level.id,
113                    e.step,
114                    e.target
115                );
116                *stopped = true;
117                None
118            }
119        }
120    }
121
122    /// Playback has ended — one full pass (or a console-reported error) —
123    /// and every queued editor commit has been delivered, so the app can
124    /// drop into interactive author mode.
125    pub fn done(&self) -> bool {
126        self.stopped || (self.finished && !self.driver.enter_pending())
127    }
128}
129
130/// The `instruct` text over the live canvas: centered on the viewport on a
131/// translucent card, in screen space (fixed size regardless of zoom).
132pub fn draw_instruction(
133    painter: &egui::Painter,
134    viewport: Rect,
135    text: &str,
136    visuals: &egui::Visuals,
137) {
138    if text.is_empty() {
139        return;
140    }
141    let galley = painter.layout(
142        text.to_owned(),
143        egui::FontId::proportional(18.0),
144        visuals.text_color(),
145        viewport.width() - 4.0 * GRID_SIZE,
146    );
147    let card = Rect::from_center_size(viewport.center(), galley.size() + vec2(24.0, 16.0));
148    painter.rect_filled(card, 8.0, visuals.window_fill.gamma_multiply(0.85));
149    painter.galley(card.min + vec2(12.0, 8.0), galley, visuals.text_color());
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn test_level() -> Level {
157        Level::parse(
158            r#"level "replay-test" title="T" {
159                instructions "i"
160                script {
161                    camera x=0 y=0 w=10 h=10
162                    pause secs=1.0
163                }
164            }"#,
165        )
166        .unwrap()
167    }
168
169    /// A document with nothing in it: the test level resolves no target.
170    fn empty() -> (Document, DocIndex) {
171        (Document::default(), DocIndex::default())
172    }
173
174    #[test]
175    fn finished_script_reports_done_and_stops_advancing() {
176        let mut replay = Replay::new(test_level(), SourceIds::default());
177        let (doc, mut index) = empty();
178        assert!(!replay.done());
179        // Drain the script (a 1 s pause = 60 frames).
180        while replay.next_frame(&doc, &mut index).is_some() {}
181        assert!(replay.done());
182        assert_eq!(replay.begin_show(Duration::from_secs(1)), 0);
183    }
184
185    #[test]
186    fn done_waits_for_a_queued_editor_commit() {
187        let mut replay = Replay::new(test_level(), SourceIds::default());
188        let (doc, mut index) = empty();
189        while replay.next_frame(&doc, &mut index).is_some() {}
190        replay.driver().queue_enter();
191        assert!(!replay.done(), "the queued Enter still owes a frame");
192        assert!(replay.driver().idle_interaction().enter_pressed);
193        assert!(replay.done());
194    }
195}