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}