Skip to main content

dotzuki_engine_script/
cutscene.rs

1//! Cutscene system built on top of the Boa JS engine's async/await.
2//!
3//! Cutscenes are expressed directly as JS async functions — no separate
4//! scripting language needed. A cutscene is simply an `export async function`
5//! that uses the existing `game.*` API (`showText`, `movePlayer`, `delay`,
6//! `fadeScreen`, `playSound`, etc.).
7//!
8//! # Architecture
9//!
10//! ```text
11//! Trigger / Map Enter
12//!     │
13//!     ▼
14//! CutsceneManager::start_cutscene("prof_lab_intro")
15//!     │  sets active=true, current_script=Some("prof_lab_intro")
16//!     │
17//!     ▼
18//! game loop: sees cutscene active → calls script_engine.call_function("prof_lab_intro")
19//!     │
20//!     ▼
21//! JS: export async function prof_lab_intro() {
22//!       await game.fadeScreen("out");
23//!       await game.showText("Prof: Hello!");
24//!       await game.movePlayer(x, y);
25//!       await game.delay(30);
26//!     }
27//!     │  each await → ScriptCommand → Rust handles → promise resolved → next await
28//!     │
29//!     ▼
30//! function returns → cutscene ends → player regains control
31//! ```
32//!
33//! While a cutscene is active, the game loop suspends normal player directional
34//! input. Dialog/choice interaction remains functional so that `await game.showText()`
35//! and `await game.showChoice()` can proceed.
36
37use std::collections::VecDeque;
38
39/// Manages cutscene execution state.
40///
41/// A cutscene is a named JS async function that runs to completion,
42/// suspending normal player input while active. Cutscenes can be
43/// queued — when one finishes, the next one starts automatically.
44#[derive(Debug, Clone)]
45pub struct CutsceneManager {
46    /// Whether a cutscene is currently executing.
47    pub active: bool,
48    /// The name of the currently running cutscene script (JS export name).
49    pub current_script: Option<String>,
50    /// When `true`, player directional/movement input is blocked.
51    /// When `false`, the cutscene runs in parallel with player movement
52    /// (useful for ambient NPC chatter or environmental effects).
53    pub blocking: bool,
54    /// Queue of pending cutscene script names. When the current cutscene
55    /// finishes, the next one is started automatically.
56    pub queue: VecDeque<String>,
57    /// Internal: whether the current cutscene's script function has been
58    /// called (via ScriptEngine::call_function). Reset to `false` when
59    /// a new cutscene starts or a queued one is promoted.
60    pub started: bool,
61}
62
63impl CutsceneManager {
64    /// Creates a new cutscene manager with no active cutscene.
65    pub fn new() -> Self {
66        Self {
67            active: false,
68            current_script: None,
69            blocking: true,
70            queue: VecDeque::new(),
71            started: false,
72        }
73    }
74
75    /// Starts a cutscene with the given script name.
76    ///
77    /// If a cutscene is already active, the new script is appended to
78    /// the queue instead of interrupting the current one.
79    ///
80    /// `blocking`: if `true`, player movement input is suspended.
81    pub fn start_cutscene(&mut self, script_name: &str, blocking: bool) {
82        if self.active {
83            self.queue.push_back(script_name.to_string());
84            return;
85        }
86        self.active = true;
87        self.current_script = Some(script_name.to_string());
88        self.blocking = blocking;
89        self.started = false;
90    }
91
92    /// Returns `true` if a cutscene is currently active.
93    pub fn is_active(&self) -> bool {
94        self.active
95    }
96
97    /// Returns `true` if the cutscene blocks player movement input.
98    pub fn is_blocking(&self) -> bool {
99        self.active && self.blocking
100    }
101
102    /// Returns the name of the currently running cutscene, if any.
103    pub fn current_script_name(&self) -> Option<&str> {
104        self.current_script.as_deref()
105    }
106
107    /// Ends the current cutscene and starts the next queued one if any.
108    ///
109    /// Returns `Some(script_name)` if a queued cutscene was started.
110    pub fn end_cutscene(&mut self) -> Option<String> {
111        self.active = false;
112        self.current_script = None;
113        self.started = false;
114
115        if let Some(next) = self.queue.pop_front() {
116            self.active = true;
117            self.current_script = Some(next.clone());
118            self.started = false;
119            return Some(next);
120        }
121        None
122    }
123
124    /// Adds a script to the end of the cutscene queue.
125    pub fn queue_script(&mut self, script_name: &str) {
126        self.queue.push_back(script_name.to_string());
127    }
128
129    /// Returns the number of scripts waiting in the queue.
130    pub fn queue_len(&self) -> usize {
131        self.queue.len()
132    }
133
134    /// Returns `true` if there are scripts waiting in the queue.
135    pub fn has_queued(&self) -> bool {
136        !self.queue.is_empty()
137    }
138
139    /// Clears all queued scripts.
140    pub fn clear_queue(&mut self) {
141        self.queue.clear();
142    }
143
144    /// Forcefully stops the current cutscene and clears the queue.
145    pub fn force_stop(&mut self) {
146        self.active = false;
147        self.current_script = None;
148        self.queue.clear();
149        self.started = false;
150    }
151
152    pub fn mark_started(&mut self) {
153        self.started = true;
154    }
155
156    pub fn needs_start(&self) -> bool {
157        self.active && !self.started
158    }
159}
160
161impl Default for CutsceneManager {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_new_cutscene_manager() {
173        let cm = CutsceneManager::new();
174        assert!(!cm.is_active());
175        assert!(cm.current_script.is_none());
176        assert!(cm.queue.is_empty());
177    }
178
179    #[test]
180    fn test_start_and_end_cutscene() {
181        let mut cm = CutsceneManager::new();
182        cm.start_cutscene("prof_lab_intro", true);
183        assert!(cm.is_active());
184        assert!(cm.is_blocking());
185        assert_eq!(cm.current_script_name(), Some("prof_lab_intro"));
186
187        cm.end_cutscene();
188        assert!(!cm.is_active());
189        assert!(cm.current_script.is_none());
190    }
191
192    #[test]
193    fn test_non_blocking_cutscene() {
194        let mut cm = CutsceneManager::new();
195        cm.start_cutscene("ambient_chatter", false);
196        assert!(cm.is_active());
197        assert!(!cm.is_blocking());
198    }
199
200    #[test]
201    fn test_queue_when_active() {
202        let mut cm = CutsceneManager::new();
203        cm.start_cutscene("first", true);
204        cm.start_cutscene("second", true);
205        cm.start_cutscene("third", true);
206
207        // Only "first" should be running; others are queued.
208        assert_eq!(cm.current_script_name(), Some("first"));
209        assert_eq!(cm.queue_len(), 2);
210
211        // End "first" → "second" starts.
212        let next = cm.end_cutscene();
213        assert_eq!(next, Some("second".to_string()));
214        assert_eq!(cm.current_script_name(), Some("second"));
215        assert_eq!(cm.queue_len(), 1);
216
217        // End "second" → "third" starts.
218        let next = cm.end_cutscene();
219        assert_eq!(next, Some("third".to_string()));
220        assert_eq!(cm.current_script_name(), Some("third"));
221        assert_eq!(cm.queue_len(), 0);
222
223        // End "third" → nothing queued, cutscene ends.
224        let next = cm.end_cutscene();
225        assert_eq!(next, None);
226        assert!(!cm.is_active());
227    }
228
229    #[test]
230    fn test_queue_script() {
231        let mut cm = CutsceneManager::new();
232        cm.queue_script("intro");
233        cm.queue_script("prof_arrives");
234        assert_eq!(cm.queue_len(), 2);
235        assert!(!cm.is_active()); // queueing alone doesn't start
236
237        // Start the first manually.
238        cm.start_cutscene("manual_start", true);
239        assert_eq!(cm.current_script_name(), Some("manual_start"));
240
241        // End it → first queued script starts.
242        let next = cm.end_cutscene();
243        assert_eq!(next, Some("intro".to_string()));
244
245        // End again → second queued script starts.
246        let next = cm.end_cutscene();
247        assert_eq!(next, Some("prof_arrives".to_string()));
248    }
249
250    #[test]
251    fn test_force_stop() {
252        let mut cm = CutsceneManager::new();
253        cm.start_cutscene("running", true);
254        cm.queue_script("next1");
255        cm.queue_script("next2");
256
257        cm.force_stop();
258        assert!(!cm.is_active());
259        assert!(cm.current_script.is_none());
260        assert!(cm.queue.is_empty());
261    }
262
263    #[test]
264    fn test_clear_queue() {
265        let mut cm = CutsceneManager::new();
266        cm.queue_script("a");
267        cm.queue_script("b");
268        cm.clear_queue();
269        assert_eq!(cm.queue_len(), 0);
270        assert!(cm.queue.is_empty());
271    }
272
273    #[test]
274    fn test_default() {
275        let cm: CutsceneManager = Default::default();
276        assert!(!cm.is_active());
277        assert!(cm.queue.is_empty());
278    }
279}