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}