use serde::{Deserialize, Serialize};
use crate::entity::WorldEntity;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TriggerDef {
pub on: TriggerEvent,
pub action: TriggerActionDef,
#[serde(default, skip_serializing_if = "is_false")]
pub once: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cooldown: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requires_item: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum TriggerEvent {
Start,
Click {
#[serde(default = "default_click_distance")]
max_distance: f32,
#[serde(default, skip_serializing_if = "Option::is_none")]
prompt: Option<String>,
},
Proximity {
#[serde(default = "default_radius")]
radius: f32,
},
AreaEnter {
#[serde(default, skip_serializing_if = "Option::is_none")]
volume: Option<TriggerVolume>,
},
AreaExit {
#[serde(default, skip_serializing_if = "Option::is_none")]
volume: Option<TriggerVolume>,
},
Collision {
#[serde(default = "default_collision_radius")]
radius: f32,
},
Timer { interval: f32 },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(tag = "shape", rename_all = "snake_case")]
pub enum TriggerVolume {
Box { half_extents: [f32; 3] },
Sphere { radius: f32 },
}
impl TriggerVolume {
pub fn contains_local(&self, p: [f32; 3]) -> bool {
match self {
TriggerVolume::Box { half_extents: h } => {
p[0].abs() <= h[0] && p[1].abs() <= h[1] && p[2].abs() <= h[2]
}
TriggerVolume::Sphere { radius } => {
p[0] * p[0] + p[1] * p[1] + p[2] * p[2] <= radius * radius
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum TriggerActionDef {
ShowText {
text: String,
#[serde(default = "default_text_seconds")]
seconds: f32,
},
Show,
Hide,
Toggle,
Remove,
Animate {
#[serde(default = "default_property")]
property: String,
#[serde(default)]
to: Vec<f32>,
#[serde(default = "default_duration")]
duration: f32,
},
Teleport { destination: [f32; 3] },
Host {
name: String,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
args: serde_json::Map<String, serde_json::Value>,
},
}
impl TriggerDef {
pub fn area(&self, entity: &WorldEntity) -> Option<TriggerVolume> {
let volume = match &self.on {
TriggerEvent::AreaEnter { volume } | TriggerEvent::AreaExit { volume } => volume,
_ => return None,
};
Some(volume.clone().unwrap_or_else(|| match &entity.shape {
Some(shape) => TriggerVolume::Box {
half_extents: shape.local_aabb_half(),
},
None => TriggerVolume::Sphere {
radius: DEFAULT_AREA_RADIUS,
},
}))
}
pub fn cooldown_secs(&self) -> f32 {
self.cooldown.unwrap_or(match self.on {
TriggerEvent::Proximity { .. } => 1.0,
_ => 0.0,
})
}
}
pub const DEFAULT_AREA_RADIUS: f32 = 3.0;
fn is_false(v: &bool) -> bool {
!*v
}
fn default_click_distance() -> f32 {
5.0
}
fn default_radius() -> f32 {
5.0
}
fn default_collision_radius() -> f32 {
3.0
}
fn default_property() -> String {
"position".to_string()
}
fn default_duration() -> f32 {
1.0
}
fn default_text_seconds() -> f32 {
4.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shape::Shape;
#[test]
fn json_is_flat_and_tagged() {
let t = TriggerDef {
on: TriggerEvent::Click {
max_distance: 4.0,
prompt: Some("Open".into()),
},
action: TriggerActionDef::ShowText {
text: "It creaks open.".into(),
seconds: 4.0,
},
once: true,
cooldown: None,
requires_item: None,
};
let json = serde_json::to_value(&t).unwrap();
assert_eq!(json["on"]["event"], "click");
assert_eq!(json["action"]["action"], "show_text");
assert_eq!(json["once"], true);
let back: TriggerDef = serde_json::from_value(json).unwrap();
assert_eq!(back, t);
}
#[test]
fn defaults_fill_missing_numbers() {
let t: TriggerDef =
serde_json::from_str(r#"{"on":{"event":"proximity"},"action":{"action":"hide"}}"#)
.unwrap();
assert_eq!(t.on, TriggerEvent::Proximity { radius: 5.0 });
assert!(!t.once);
assert_eq!(t.cooldown_secs(), 1.0);
}
#[test]
fn host_actions_carry_their_arguments() {
let t: TriggerDef = serde_json::from_str(
r#"{"on":{"event":"click"},"action":{"action":"host","name":"add_score","args":{"amount":10}}}"#,
)
.unwrap();
let TriggerActionDef::Host { name, args } = &t.action else {
panic!("{t:?}");
};
assert_eq!(name, "add_score");
assert_eq!(args["amount"], 10);
}
#[test]
fn areas_default_to_the_shape_box_or_a_sphere() {
let area = |volume| TriggerDef {
on: TriggerEvent::AreaEnter { volume },
action: TriggerActionDef::Show,
once: false,
cooldown: None,
requires_item: None,
};
let crate_ = WorldEntity::new(1, "crate").with_shape(Shape::Cuboid {
x: 2.0,
y: 1.0,
z: 4.0,
});
let empty = WorldEntity::new(2, "spot");
assert_eq!(
area(None).area(&crate_),
Some(TriggerVolume::Box {
half_extents: [1.0, 0.5, 2.0]
})
);
assert_eq!(
area(None).area(&empty),
Some(TriggerVolume::Sphere { radius: 3.0 })
);
let sphere = TriggerVolume::Sphere { radius: 2.0 };
assert_eq!(area(Some(sphere.clone())).area(&crate_), Some(sphere));
let b = TriggerVolume::Box {
half_extents: [1.0, 0.5, 2.0],
};
assert!(b.contains_local([0.9, -0.4, 1.9]));
assert!(!b.contains_local([0.9, 0.6, 0.0]));
assert!(TriggerVolume::Sphere { radius: 1.0 }.contains_local([0.6, 0.6, 0.0]));
assert!(!TriggerVolume::Sphere { radius: 1.0 }.contains_local([0.8, 0.8, 0.0]));
}
}