Skip to main content

dotzuki_engine/
trigger_manager.rs

1//! # trigger_manager
2//!
3//! Generic trigger system for JRPG maps.  Triggers bind a script function to a
4//! tile area and fire when the player steps on, enters, or interacts with that
5//! area.  Triggers can be sourced from Tiled custom properties, exported JS
6//! function-name conventions, or hand-authored config files.
7//!
8//! The [`TriggerManager`] tracks active triggers and provides position-based
9//! queries used by the game loop.
10
11use crate::metatile::TriggerType;
12
13// ---------------------------------------------------------------------------
14// Trigger
15// ---------------------------------------------------------------------------
16
17/// A trigger bound to a specific tile or rectangular area on a map.
18///
19/// When the player satisfies the [`trigger_type`](Trigger::trigger_type)
20/// condition (stepping on, entering, or interacting with the tile), the
21/// associated [`script_name`](Trigger::script_name) is returned so the game
22/// loop can call the corresponding JS function on the script engine.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Trigger {
25    /// Unique identifier for this trigger (e.g. `"start_town_prof"`).
26    pub id: String,
27    /// The map this trigger lives on (e.g. `"StartTown"`).
28    pub map_id: String,
29    /// When the trigger fires — every frame ([`OnStep`](TriggerType::OnStep)),
30    /// once on entry ([`OnEnter`](TriggerType::OnEnter)), or on A-press
31    /// ([`OnInteract`](TriggerType::OnInteract)).
32    pub trigger_type: TriggerType,
33    /// Top-left X coordinate of the trigger area (in tile units).
34    pub x: u32,
35    /// Top-left Y coordinate of the trigger area (in tile units).
36    pub y: u32,
37    /// Width of the trigger area in tiles.  `1` for a single-tile trigger;
38    /// larger values for rectangular area triggers.
39    pub width: u32,
40    /// Height of the trigger area in tiles.
41    pub height: u32,
42    /// Name of the exported JS async function to call when this trigger fires.
43    pub script_name: String,
44    /// If `true`, the trigger fires only once and then is permanently disabled.
45    pub one_shot: bool,
46    /// Whether this trigger has already been activated (relevant for one-shot
47    /// triggers).  Call [`TriggerManager::reset_fired_for_map`] to re-enable
48    /// triggers after re-entering a map.
49    pub fired: bool,
50}
51
52impl Trigger {
53    /// Creates a single-tile trigger.
54    pub fn single_tile(
55        id: impl Into<String>,
56        map_id: impl Into<String>,
57        trigger_type: TriggerType,
58        x: u32,
59        y: u32,
60        script_name: impl Into<String>,
61        one_shot: bool,
62    ) -> Self {
63        Self {
64            id: id.into(),
65            map_id: map_id.into(),
66            trigger_type,
67            x,
68            y,
69            width: 1,
70            height: 1,
71            script_name: script_name.into(),
72            one_shot,
73            fired: false,
74        }
75    }
76
77    /// Returns `true` when tile `(tile_x, tile_y)` falls inside this trigger's
78    /// axis-aligned bounding box.
79    #[inline]
80    pub fn contains(&self, tile_x: u32, tile_y: u32) -> bool {
81        tile_x >= self.x
82            && tile_x < self.x.saturating_add(self.width)
83            && tile_y >= self.y
84            && tile_y < self.y.saturating_add(self.height)
85    }
86}
87
88// ---------------------------------------------------------------------------
89// TriggerManager
90// ---------------------------------------------------------------------------
91
92/// Manages a collection of [`Trigger`]s and determines which ones should fire
93/// given the player's current (and previous) position.
94///
95/// # Usage
96///
97/// ```ignore
98/// let mut mgr = TriggerManager::new();
99/// mgr.add_trigger(Trigger::single_tile(
100///     "heal_npc", "StartTown", TriggerType::OnStep,
101///     3, 5, "healParty", true,
102/// ));
103///
104/// // Every frame
105/// let triggered: Vec<String> = mgr.check_triggers("StartTown", 3, 5);
106/// for name in &triggered {
107///     script_engine.call_function(name);
108/// }
109///
110/// // On A button press
111/// if let Some(name) = mgr.check_interact_mut("StartTown", facing_x, facing_y) {
112///     script_engine.call_function(&name);
113/// }
114/// ```
115#[derive(Debug, Clone, Default)]
116pub struct TriggerManager {
117    triggers: Vec<Trigger>,
118    /// Tracked so [`OnEnter`](TriggerType::OnEnter) triggers can detect when
119    /// the player *enters* an area rather than simply standing in it.
120    prev_player_map: Option<String>,
121    prev_player_x: u32,
122    prev_player_y: u32,
123}
124
125impl TriggerManager {
126    /// Creates an empty trigger manager.
127    pub fn new() -> Self {
128        Self {
129            triggers: Vec::new(),
130            prev_player_map: None,
131            prev_player_x: 0,
132            prev_player_y: 0,
133        }
134    }
135
136    // ------------------------------------------------------------------
137    // Registration
138    // ------------------------------------------------------------------
139
140    /// Registers a single trigger.
141    pub fn add_trigger(&mut self, trigger: Trigger) {
142        self.triggers.push(trigger);
143    }
144
145    /// Registers multiple triggers in bulk.
146    pub fn add_triggers(&mut self, triggers: impl IntoIterator<Item = Trigger>) {
147        self.triggers.extend(triggers);
148    }
149
150    /// Removes all triggers associated with `map_id`.
151    pub fn remove_triggers_for_map(&mut self, map_id: &str) {
152        self.triggers.retain(|t| t.map_id != map_id);
153    }
154
155    /// Resets the [`fired`](Trigger::fired) flag for every trigger on the
156    /// given map, allowing one-shot triggers to fire again (e.g. after the
157    /// player re-enters the map).
158    pub fn reset_fired_for_map(&mut self, map_id: &str) {
159        for trigger in &mut self.triggers {
160            if trigger.map_id == map_id {
161                trigger.fired = false;
162            }
163        }
164    }
165
166    // ------------------------------------------------------------------
167    // Queries
168    // ------------------------------------------------------------------
169
170    /// Returns an iterator over all registered triggers (regardless of map).
171    pub fn all_triggers(&self) -> impl Iterator<Item = &Trigger> {
172        self.triggers.iter()
173    }
174
175    /// Number of registered triggers.
176    pub fn len(&self) -> usize {
177        self.triggers.len()
178    }
179
180    /// Returns `true` when there are no registered triggers.
181    pub fn is_empty(&self) -> bool {
182        self.triggers.is_empty()
183    }
184
185    // ------------------------------------------------------------------
186    // Core trigger checking
187    // ------------------------------------------------------------------
188
189    /// Checks all triggers for the given map against the player's current
190    /// position.  Handles [`OnStep`](TriggerType::OnStep) and
191    /// [`OnEnter`](TriggerType::OnEnter) triggers.
192    ///
193    /// Returns the [`script_name`](Trigger::script_name) of every trigger
194    /// that should fire this frame.  Callers should iterate the returned
195    /// list and invoke each function on the script engine.
196    ///
197    /// One-shot triggers are automatically marked as `fired` so they won't
198    /// activate again until [`reset_fired_for_map`](Self::reset_fired_for_map)
199    /// is called.
200    ///
201    /// # OnEnter vs OnStep
202    ///
203    /// * **OnEnter** — fires when the player was *not* in the area last frame
204    ///   but *is* this frame (entry detection).
205    /// * **OnStep** — fires every frame the player stands inside the area.
206    pub fn check_triggers(&mut self, map_id: &str, player_x: u32, player_y: u32) -> Vec<String> {
207        let same_map = self.prev_player_map.as_deref() == Some(map_id);
208
209        // Helper: was the player inside this trigger's area last frame?
210        let was_inside = |t: &Trigger| -> bool {
211            same_map && t.contains(self.prev_player_x, self.prev_player_y)
212        };
213
214        // Helper: is the player inside this trigger's area now?
215        let is_inside = |t: &Trigger| -> bool { t.contains(player_x, player_y) };
216
217        let mut triggered = Vec::new();
218
219        for trigger in &mut self.triggers {
220            if trigger.map_id != map_id {
221                continue;
222            }
223
224            // One-shot triggers that already fired are skipped.
225            if trigger.one_shot && trigger.fired {
226                continue;
227            }
228
229            let should_fire = match trigger.trigger_type {
230                TriggerType::OnStep => is_inside(trigger),
231                TriggerType::OnEnter => is_inside(trigger) && !was_inside(trigger),
232                // OnInteract is checked via check_interact / check_interact_mut
233                TriggerType::OnInteract => false,
234            };
235
236            if should_fire {
237                trigger.fired = true;
238                triggered.push(trigger.script_name.clone());
239            }
240        }
241
242        // Record position for next frame's OnEnter detection.
243        self.prev_player_map = Some(map_id.to_string());
244        self.prev_player_x = player_x;
245        self.prev_player_y = player_y;
246
247        triggered
248    }
249
250    // ------------------------------------------------------------------
251    // OnInteract (A-button) queries
252    // ------------------------------------------------------------------
253
254    /// Checks for [`OnInteract`](TriggerType::OnInteract) triggers at the tile
255    /// the player is facing.  Returns the script name if one is found.
256    ///
257    /// This is a read-only query — it does **not** mark one-shot triggers
258    /// as fired.  Use [`check_interact_mut`](Self::check_interact_mut) when
259    /// you also want to disable the trigger after activation.
260    pub fn check_interact(
261        &self,
262        map_id: &str,
263        facing_x: u32,
264        facing_y: u32,
265    ) -> Option<&str> {
266        self.triggers
267            .iter()
268            .find(|t| {
269                t.map_id == map_id
270                    && t.trigger_type == TriggerType::OnInteract
271                    && (!t.one_shot || !t.fired)
272                    && t.contains(facing_x, facing_y)
273            })
274            .map(|t| t.script_name.as_str())
275    }
276
277    /// Checks for [`OnInteract`](TriggerType::OnInteract) triggers at the tile
278    /// the player is facing.  Returns the script name and marks the trigger as
279    /// `fired` (so one-shot triggers won't activate again).
280    pub fn check_interact_mut(
281        &mut self,
282        map_id: &str,
283        facing_x: u32,
284        facing_y: u32,
285    ) -> Option<String> {
286        for trigger in &mut self.triggers {
287            if trigger.map_id != map_id {
288                continue;
289            }
290            if trigger.trigger_type != TriggerType::OnInteract {
291                continue;
292            }
293            if trigger.one_shot && trigger.fired {
294                continue;
295            }
296            if trigger.contains(facing_x, facing_y) {
297                trigger.fired = true;
298                return Some(trigger.script_name.clone());
299            }
300        }
301        None
302    }
303}
304
305// ---------------------------------------------------------------------------
306// Tests
307// ---------------------------------------------------------------------------
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn make_trigger(
314        id: &str,
315        map_id: &str,
316        tt: TriggerType,
317        x: u32,
318        y: u32,
319        script: &str,
320        one_shot: bool,
321    ) -> Trigger {
322        Trigger::single_tile(id, map_id, tt, x, y, script, one_shot)
323    }
324
325    // -- OnStep ----------------------------------------------------------
326
327    #[test]
328    fn on_step_fires_when_standing() {
329        let mut mgr = TriggerManager::new();
330        mgr.add_trigger(make_trigger("a", "map1", TriggerType::OnStep, 2, 3, "stepFn", false));
331        // Need one call to establish previous position
332        mgr.check_triggers("map1", 0, 0);
333        let names = mgr.check_triggers("map1", 2, 3);
334        assert_eq!(names, vec!["stepFn"]);
335    }
336
337    #[test]
338    fn on_step_fires_every_frame() {
339        let mut mgr = TriggerManager::new();
340        mgr.add_trigger(make_trigger("a", "map1", TriggerType::OnStep, 2, 3, "stepFn", false));
341        mgr.check_triggers("map1", 0, 0); // establish prev
342        let first = mgr.check_triggers("map1", 2, 3);
343        let second = mgr.check_triggers("map1", 2, 3);
344        assert_eq!(first, vec!["stepFn"]);
345        assert_eq!(second, vec!["stepFn"]);
346    }
347
348    #[test]
349    fn on_step_does_not_fire_when_not_standing() {
350        let mut mgr = TriggerManager::new();
351        mgr.add_trigger(make_trigger("a", "map1", TriggerType::OnStep, 2, 3, "stepFn", false));
352        mgr.check_triggers("map1", 0, 0);
353        let names = mgr.check_triggers("map1", 5, 5);
354        assert!(names.is_empty());
355    }
356
357    // -- OnEnter ---------------------------------------------------------
358
359    #[test]
360    fn on_enter_fires_once_on_entry() {
361        let mut mgr = TriggerManager::new();
362        mgr.add_trigger(make_trigger("a", "map1", TriggerType::OnEnter, 2, 3, "enterFn", false));
363        // Player is outside area
364        mgr.check_triggers("map1", 0, 0);
365        // Player walks in
366        let names = mgr.check_triggers("map1", 2, 3);
367        assert_eq!(names, vec!["enterFn"]);
368    }
369
370    #[test]
371    fn on_enter_does_not_refire() {
372        let mut mgr = TriggerManager::new();
373        mgr.add_trigger(make_trigger("a", "map1", TriggerType::OnEnter, 2, 3, "enterFn", false));
374        mgr.check_triggers("map1", 0, 0); // outside
375        mgr.check_triggers("map1", 2, 3); // enter — fires
376        let second = mgr.check_triggers("map1", 2, 3); // still inside — no fire
377        assert!(second.is_empty());
378    }
379
380    #[test]
381    fn on_enter_fires_on_different_map_entry() {
382        let mut mgr = TriggerManager::new();
383        mgr.add_trigger(make_trigger("a", "map2", TriggerType::OnEnter, 0, 0, "enterFn", false));
384        mgr.check_triggers("map1", 5, 5); // different map
385        let names = mgr.check_triggers("map2", 0, 0); // first frame on map2
386        assert_eq!(names, vec!["enterFn"]);
387    }
388
389    // -- One-shot --------------------------------------------------------
390
391    #[test]
392    fn one_shot_trigger_fires_once() {
393        let mut mgr = TriggerManager::new();
394        mgr.add_trigger(make_trigger("a", "map1", TriggerType::OnStep, 2, 3, "onceFn", true));
395        mgr.check_triggers("map1", 0, 0);
396        let first = mgr.check_triggers("map1", 2, 3);
397        let second = mgr.check_triggers("map1", 2, 3);
398        assert_eq!(first, vec!["onceFn"]);
399        assert!(second.is_empty());
400    }
401
402    #[test]
403    fn one_shot_reset_allows_refire() {
404        let mut mgr = TriggerManager::new();
405        mgr.add_trigger(make_trigger("a", "map1", TriggerType::OnStep, 2, 3, "onceFn", true));
406        mgr.check_triggers("map1", 0, 0);
407        mgr.check_triggers("map1", 2, 3); // fires
408        mgr.reset_fired_for_map("map1");
409        mgr.check_triggers("map1", 0, 0);
410        let again = mgr.check_triggers("map1", 2, 3);
411        assert_eq!(again, vec!["onceFn"]);
412    }
413
414    // -- OnInteract ------------------------------------------------------
415
416    #[test]
417    fn on_interact_fires_on_facing_tile() {
418        let mut mgr = TriggerManager::new();
419        mgr.add_trigger(make_trigger("a", "map1", TriggerType::OnInteract, 5, 5, "talkFn", false));
420        let name = mgr.check_interact("map1", 5, 5);
421        assert_eq!(name, Some("talkFn"));
422    }
423
424    #[test]
425    fn on_interact_ignores_wrong_position() {
426        let mut mgr = TriggerManager::new();
427        mgr.add_trigger(make_trigger("a", "map1", TriggerType::OnInteract, 5, 5, "talkFn", false));
428        let name = mgr.check_interact("map1", 0, 0);
429        assert!(name.is_none());
430    }
431
432    #[test]
433    fn on_interact_mut_marks_fired() {
434        let mut mgr = TriggerManager::new();
435        mgr.add_trigger(make_trigger("a", "map1", TriggerType::OnInteract, 5, 5, "talkFn", true));
436        let first = mgr.check_interact_mut("map1", 5, 5);
437        assert_eq!(first, Some("talkFn".into()));
438        let second = mgr.check_interact("map1", 5, 5);
439        assert!(second.is_none());
440    }
441
442    // -- Area triggers ---------------------------------------------------
443
444    #[test]
445    fn area_trigger_2x2() {
446        let mut mgr = TriggerManager::new();
447        let t = Trigger {
448            id: "area".into(),
449            map_id: "map1".into(),
450            trigger_type: TriggerType::OnStep,
451            x: 4,
452            y: 4,
453            width: 2,
454            height: 2,
455            script_name: "areaFn".into(),
456            one_shot: false,
457            fired: false,
458        };
459        mgr.add_trigger(t);
460        mgr.check_triggers("map1", 0, 0);
461        // (4,4), (5,4), (4,5), (5,5) should all match
462        for (x, y) in [(4, 4), (5, 4), (4, 5), (5, 5)] {
463            assert!(mgr.check_triggers("map1", x, y).contains(&"areaFn".into()));
464        }
465        // Outside
466        mgr.check_triggers("map1", 0, 0);
467        assert!(!mgr.check_triggers("map1", 3, 4).contains(&"areaFn".into()));
468        assert!(!mgr.check_triggers("map1", 6, 4).contains(&"areaFn".into()));
469    }
470
471    // -- Removal ---------------------------------------------------------
472
473    #[test]
474    fn remove_triggers_for_map() {
475        let mut mgr = TriggerManager::new();
476        mgr.add_trigger(make_trigger("a", "map1", TriggerType::OnStep, 0, 0, "fn1", false));
477        mgr.add_trigger(make_trigger("b", "map2", TriggerType::OnStep, 0, 0, "fn2", false));
478        mgr.remove_triggers_for_map("map1");
479        assert_eq!(mgr.len(), 1);
480        assert_eq!(mgr.triggers[0].script_name, "fn2");
481    }
482
483    // -- Multiple trigger types on same tile ------------------------------
484
485    #[test]
486    fn on_step_and_on_enter_both_fire_on_entry() {
487        let mut mgr = TriggerManager::new();
488        mgr.add_trigger(make_trigger("s", "map1", TriggerType::OnStep, 2, 3, "stepFn", false));
489        mgr.add_trigger(make_trigger("e", "map1", TriggerType::OnEnter, 2, 3, "enterFn", false));
490        mgr.check_triggers("map1", 0, 0); // outside
491        let names = mgr.check_triggers("map1", 2, 3);
492        // Both should fire on entry frame
493        assert!(names.contains(&"stepFn".into()));
494        assert!(names.contains(&"enterFn".into()));
495    }
496}