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, serde::Serialize, serde::Deserialize)]
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, serde::Serialize, serde::Deserialize)]
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(&self, map_id: &str, facing_x: u32, facing_y: u32) -> Option<&str> {
261        self.triggers
262            .iter()
263            .find(|t| {
264                t.map_id == map_id
265                    && t.trigger_type == TriggerType::OnInteract
266                    && (!t.one_shot || !t.fired)
267                    && t.contains(facing_x, facing_y)
268            })
269            .map(|t| t.script_name.as_str())
270    }
271
272    /// Checks for [`OnInteract`](TriggerType::OnInteract) triggers at the tile
273    /// the player is facing.  Returns the script name and marks the trigger as
274    /// `fired` (so one-shot triggers won't activate again).
275    pub fn check_interact_mut(
276        &mut self,
277        map_id: &str,
278        facing_x: u32,
279        facing_y: u32,
280    ) -> Option<String> {
281        for trigger in &mut self.triggers {
282            if trigger.map_id != map_id {
283                continue;
284            }
285            if trigger.trigger_type != TriggerType::OnInteract {
286                continue;
287            }
288            if trigger.one_shot && trigger.fired {
289                continue;
290            }
291            if trigger.contains(facing_x, facing_y) {
292                trigger.fired = true;
293                return Some(trigger.script_name.clone());
294            }
295        }
296        None
297    }
298}
299
300// ---------------------------------------------------------------------------
301// Tests
302// ---------------------------------------------------------------------------
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    fn make_trigger(
309        id: &str,
310        map_id: &str,
311        tt: TriggerType,
312        x: u32,
313        y: u32,
314        script: &str,
315        one_shot: bool,
316    ) -> Trigger {
317        Trigger::single_tile(id, map_id, tt, x, y, script, one_shot)
318    }
319
320    // -- OnStep ----------------------------------------------------------
321
322    #[test]
323    fn on_step_fires_when_standing() {
324        let mut mgr = TriggerManager::new();
325        mgr.add_trigger(make_trigger(
326            "a",
327            "map1",
328            TriggerType::OnStep,
329            2,
330            3,
331            "stepFn",
332            false,
333        ));
334        // Need one call to establish previous position
335        mgr.check_triggers("map1", 0, 0);
336        let names = mgr.check_triggers("map1", 2, 3);
337        assert_eq!(names, vec!["stepFn"]);
338    }
339
340    #[test]
341    fn on_step_fires_every_frame() {
342        let mut mgr = TriggerManager::new();
343        mgr.add_trigger(make_trigger(
344            "a",
345            "map1",
346            TriggerType::OnStep,
347            2,
348            3,
349            "stepFn",
350            false,
351        ));
352        mgr.check_triggers("map1", 0, 0); // establish prev
353        let first = mgr.check_triggers("map1", 2, 3);
354        let second = mgr.check_triggers("map1", 2, 3);
355        assert_eq!(first, vec!["stepFn"]);
356        assert_eq!(second, vec!["stepFn"]);
357    }
358
359    #[test]
360    fn on_step_does_not_fire_when_not_standing() {
361        let mut mgr = TriggerManager::new();
362        mgr.add_trigger(make_trigger(
363            "a",
364            "map1",
365            TriggerType::OnStep,
366            2,
367            3,
368            "stepFn",
369            false,
370        ));
371        mgr.check_triggers("map1", 0, 0);
372        let names = mgr.check_triggers("map1", 5, 5);
373        assert!(names.is_empty());
374    }
375
376    // -- OnEnter ---------------------------------------------------------
377
378    #[test]
379    fn on_enter_fires_once_on_entry() {
380        let mut mgr = TriggerManager::new();
381        mgr.add_trigger(make_trigger(
382            "a",
383            "map1",
384            TriggerType::OnEnter,
385            2,
386            3,
387            "enterFn",
388            false,
389        ));
390        // Player is outside area
391        mgr.check_triggers("map1", 0, 0);
392        // Player walks in
393        let names = mgr.check_triggers("map1", 2, 3);
394        assert_eq!(names, vec!["enterFn"]);
395    }
396
397    #[test]
398    fn on_enter_does_not_refire() {
399        let mut mgr = TriggerManager::new();
400        mgr.add_trigger(make_trigger(
401            "a",
402            "map1",
403            TriggerType::OnEnter,
404            2,
405            3,
406            "enterFn",
407            false,
408        ));
409        mgr.check_triggers("map1", 0, 0); // outside
410        mgr.check_triggers("map1", 2, 3); // enter — fires
411        let second = mgr.check_triggers("map1", 2, 3); // still inside — no fire
412        assert!(second.is_empty());
413    }
414
415    #[test]
416    fn on_enter_fires_on_different_map_entry() {
417        let mut mgr = TriggerManager::new();
418        mgr.add_trigger(make_trigger(
419            "a",
420            "map2",
421            TriggerType::OnEnter,
422            0,
423            0,
424            "enterFn",
425            false,
426        ));
427        mgr.check_triggers("map1", 5, 5); // different map
428        let names = mgr.check_triggers("map2", 0, 0); // first frame on map2
429        assert_eq!(names, vec!["enterFn"]);
430    }
431
432    // -- One-shot --------------------------------------------------------
433
434    #[test]
435    fn one_shot_trigger_fires_once() {
436        let mut mgr = TriggerManager::new();
437        mgr.add_trigger(make_trigger(
438            "a",
439            "map1",
440            TriggerType::OnStep,
441            2,
442            3,
443            "onceFn",
444            true,
445        ));
446        mgr.check_triggers("map1", 0, 0);
447        let first = mgr.check_triggers("map1", 2, 3);
448        let second = mgr.check_triggers("map1", 2, 3);
449        assert_eq!(first, vec!["onceFn"]);
450        assert!(second.is_empty());
451    }
452
453    #[test]
454    fn one_shot_reset_allows_refire() {
455        let mut mgr = TriggerManager::new();
456        mgr.add_trigger(make_trigger(
457            "a",
458            "map1",
459            TriggerType::OnStep,
460            2,
461            3,
462            "onceFn",
463            true,
464        ));
465        mgr.check_triggers("map1", 0, 0);
466        mgr.check_triggers("map1", 2, 3); // fires
467        mgr.reset_fired_for_map("map1");
468        mgr.check_triggers("map1", 0, 0);
469        let again = mgr.check_triggers("map1", 2, 3);
470        assert_eq!(again, vec!["onceFn"]);
471    }
472
473    // -- OnInteract ------------------------------------------------------
474
475    #[test]
476    fn on_interact_fires_on_facing_tile() {
477        let mut mgr = TriggerManager::new();
478        mgr.add_trigger(make_trigger(
479            "a",
480            "map1",
481            TriggerType::OnInteract,
482            5,
483            5,
484            "talkFn",
485            false,
486        ));
487        let name = mgr.check_interact("map1", 5, 5);
488        assert_eq!(name, Some("talkFn"));
489    }
490
491    #[test]
492    fn on_interact_ignores_wrong_position() {
493        let mut mgr = TriggerManager::new();
494        mgr.add_trigger(make_trigger(
495            "a",
496            "map1",
497            TriggerType::OnInteract,
498            5,
499            5,
500            "talkFn",
501            false,
502        ));
503        let name = mgr.check_interact("map1", 0, 0);
504        assert!(name.is_none());
505    }
506
507    #[test]
508    fn on_interact_mut_marks_fired() {
509        let mut mgr = TriggerManager::new();
510        mgr.add_trigger(make_trigger(
511            "a",
512            "map1",
513            TriggerType::OnInteract,
514            5,
515            5,
516            "talkFn",
517            true,
518        ));
519        let first = mgr.check_interact_mut("map1", 5, 5);
520        assert_eq!(first, Some("talkFn".into()));
521        let second = mgr.check_interact("map1", 5, 5);
522        assert!(second.is_none());
523    }
524
525    // -- Area triggers ---------------------------------------------------
526
527    #[test]
528    fn area_trigger_2x2() {
529        let mut mgr = TriggerManager::new();
530        let t = Trigger {
531            id: "area".into(),
532            map_id: "map1".into(),
533            trigger_type: TriggerType::OnStep,
534            x: 4,
535            y: 4,
536            width: 2,
537            height: 2,
538            script_name: "areaFn".into(),
539            one_shot: false,
540            fired: false,
541        };
542        mgr.add_trigger(t);
543        mgr.check_triggers("map1", 0, 0);
544        // (4,4), (5,4), (4,5), (5,5) should all match
545        for (x, y) in [(4, 4), (5, 4), (4, 5), (5, 5)] {
546            assert!(mgr.check_triggers("map1", x, y).contains(&"areaFn".into()));
547        }
548        // Outside
549        mgr.check_triggers("map1", 0, 0);
550        assert!(!mgr.check_triggers("map1", 3, 4).contains(&"areaFn".into()));
551        assert!(!mgr.check_triggers("map1", 6, 4).contains(&"areaFn".into()));
552    }
553
554    // -- Removal ---------------------------------------------------------
555
556    #[test]
557    fn remove_triggers_for_map() {
558        let mut mgr = TriggerManager::new();
559        mgr.add_trigger(make_trigger(
560            "a",
561            "map1",
562            TriggerType::OnStep,
563            0,
564            0,
565            "fn1",
566            false,
567        ));
568        mgr.add_trigger(make_trigger(
569            "b",
570            "map2",
571            TriggerType::OnStep,
572            0,
573            0,
574            "fn2",
575            false,
576        ));
577        mgr.remove_triggers_for_map("map1");
578        assert_eq!(mgr.len(), 1);
579        assert_eq!(mgr.triggers[0].script_name, "fn2");
580    }
581
582    // -- Multiple trigger types on same tile ------------------------------
583
584    #[test]
585    fn on_step_and_on_enter_both_fire_on_entry() {
586        let mut mgr = TriggerManager::new();
587        mgr.add_trigger(make_trigger(
588            "s",
589            "map1",
590            TriggerType::OnStep,
591            2,
592            3,
593            "stepFn",
594            false,
595        ));
596        mgr.add_trigger(make_trigger(
597            "e",
598            "map1",
599            TriggerType::OnEnter,
600            2,
601            3,
602            "enterFn",
603            false,
604        ));
605        mgr.check_triggers("map1", 0, 0); // outside
606        let names = mgr.check_triggers("map1", 2, 3);
607        // Both should fire on entry frame
608        assert!(names.contains(&"stepFn".into()));
609        assert!(names.contains(&"enterFn".into()));
610    }
611}