use bevy::ecs::component::Component;
use crate::CarnageSettings;
use crate::wound::Wound;
#[derive(Component, Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Bleed {
pub opened_at: u32,
pub area: f32,
}
impl Bleed {
pub fn new(opened_at: u32, area: f32) -> Self {
Bleed { opened_at, area }
}
fn age(&self, tick: u32) -> u32 {
tick.wrapping_sub(self.opened_at)
}
}
pub fn pulse_period(hz: u32, s: &CarnageSettings) -> u32 {
if !(s.spurt_bpm > 0.0) || !s.spurt_bpm.is_finite() {
return 1;
}
let ticks = (hz as f32 * 60.0 / s.spurt_bpm).round();
if !ticks.is_finite() || ticks < 1.0 {
return 1;
}
(ticks as u32).max(1)
}
pub fn pulses_on(b: &Bleed, tick: u32, hz: u32, s: &CarnageSettings) -> bool {
b.age(tick) % pulse_period(hz, s) == 0
}
pub fn flow(b: &Bleed, tick: u32, hz: u32, s: &CarnageSettings) -> f32 {
let _ = hz;
let age = b.age(tick);
if age >= s.clot_ticks {
return 0.0;
}
if age < s.spurt_ticks {
return 1.0;
}
let taper = s.clot_ticks.saturating_sub(s.spurt_ticks);
if taper == 0 {
return 0.0;
}
let through = (age - s.spurt_ticks) as f32 / taper as f32;
(1.0 - through).clamp(0.0, 1.0)
}
pub fn clotted(b: &Bleed, tick: u32, hz: u32, s: &CarnageSettings) -> bool {
let _ = hz;
b.age(tick) >= s.clot_ticks
}
pub fn pulse_wound(
b: &Bleed,
w: &Wound,
tick: u32,
hz: u32,
s: &CarnageSettings,
) -> Option<Wound> {
if !pulses_on(b, tick, hz, s) {
return None;
}
let f = flow(b, tick, hz, s);
if f <= 0.0 {
return None;
}
Some(Wound { severity: (w.severity * f).clamp(0.0, 1.0), ..*w })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wound::WoundKind;
use bevy::math::Vec3;
const HZ: u32 = 60;
fn wound() -> Wound {
Wound {
at: Vec3::new(0.1, 0.9, -0.2),
normal: Vec3::X,
area: 0.004,
severity: 1.0,
kind: WoundKind::Severance,
}
}
#[test]
fn the_pulse_train_never_drifts() {
let s = CarnageSettings::default();
let b = Bleed::new(0, 0.004);
let period = pulse_period(HZ, &s);
assert_eq!(period, 38, "96 bpm at 60 Hz is a pulse every 38 ticks");
let beats: Vec<u32> = (0..600u32).filter(|t| pulses_on(&b, *t, HZ, &s)).collect();
assert!(beats.len() > 10, "600 ticks must hold more than ten beats");
assert_eq!(beats[0], 0, "the tick a wound opens on is itself a beat");
for w in beats.windows(2) {
assert_eq!(w[1] - w[0], period, "a gap of {} broke the train", w[1] - w[0]);
}
}
#[test]
fn the_train_is_anchored_to_the_wound_that_opened() {
let s = CarnageSettings::default();
let early = Bleed::new(0, 0.004);
let late = Bleed::new(7, 0.004);
let period = pulse_period(HZ, &s);
assert!(pulses_on(&late, 7, HZ, &s), "it beats when it opens");
assert!(pulses_on(&late, 7 + period, HZ, &s), "and a period later");
assert!(!pulses_on(&late, 7 + 1, HZ, &s), "and not in between");
assert_ne!(
pulses_on(&early, 7, HZ, &s),
pulses_on(&late, 7, HZ, &s),
"two wounds opened at different ticks must not share a heartbeat"
);
}
#[test]
fn flow_falls_monotonically_to_exactly_zero() {
let s = CarnageSettings::default();
let b = Bleed::new(0, 0.004);
let mut last = f32::INFINITY;
for t in 0..(s.clot_ticks + 240) {
let f = flow(&b, t, HZ, &s);
assert!(f <= last + 1.0e-7, "flow rose from {last} to {f} at tick {t}");
assert!((0.0..=1.0).contains(&f), "flow {f} at tick {t} is outside [0, 1]");
last = f;
}
assert_eq!(flow(&b, s.spurt_ticks - 1, HZ, &s), 1.0, "full flow right up to the taper");
assert_eq!(flow(&b, s.clot_ticks, HZ, &s), 0.0, "exactly zero at the clot tick");
assert_eq!(flow(&b, s.clot_ticks + 10_000, HZ, &s), 0.0, "and it stays there");
}
#[test]
fn clotting_never_reverses() {
let s = CarnageSettings::default();
let b = Bleed::new(0, 0.004);
assert!(!clotted(&b, 0, HZ, &s), "it does not open already clotted");
assert!(!clotted(&b, s.clot_ticks - 1, HZ, &s));
for t in s.clot_ticks..(s.clot_ticks + 5_000) {
assert!(clotted(&b, t, HZ, &s), "unclotted again at tick {t}");
}
}
#[test]
fn a_wound_opened_before_the_tick_wrap_does_not_panic() {
let s = CarnageSettings::default();
let b = Bleed::new(u32::MAX - 3, 0.004);
for t in [u32::MAX - 3, u32::MAX - 1, u32::MAX, 0, 1, 2, 40] {
let f = flow(&b, t, HZ, &s);
assert!((0.0..=1.0).contains(&f), "flow {f} at wrapped tick {t}");
let _ = pulses_on(&b, t, HZ, &s);
let _ = clotted(&b, t, HZ, &s);
let _ = pulse_wound(&b, &wound(), t, HZ, &s);
}
assert_eq!(
flow(&b, u32::MAX - 3, HZ, &s),
1.0,
"the tick it opened on is full flow, wrap or not"
);
}
#[test]
fn a_pulse_is_the_same_wound_at_a_lower_severity() {
let s = CarnageSettings::default();
let b = Bleed::new(0, 0.004);
let w = wound();
let first = pulse_wound(&b, &w, 0, HZ, &s).expect("it pulses the tick it opens");
assert_eq!(first.severity, 1.0, "the first jet is full severity");
assert_eq!(first.at, w.at, "and it is the same wound");
assert_eq!(first.normal, w.normal);
assert_eq!(first.area, w.area);
assert_eq!(first.kind, w.kind);
assert!(pulse_wound(&b, &w, 1, HZ, &s).is_none(), "nothing between beats");
let period = pulse_period(HZ, &s);
let late = (s.clot_ticks - 1) / period * period;
let seep = pulse_wound(&b, &w, late, HZ, &s).expect("the last beat before the clot bleeds");
assert!(
seep.severity < first.severity && seep.severity > 0.0,
"the last seep was {} against a first jet of {}",
seep.severity,
first.severity
);
for t in s.clot_ticks..(s.clot_ticks + period * 4) {
assert!(pulse_wound(&b, &w, t, HZ, &s).is_none(), "a clotted wound pulsed at {t}");
}
}
#[test]
fn a_degenerate_rate_floors_instead_of_dividing_by_zero() {
let mut s = CarnageSettings::default();
assert_eq!(pulse_period(0, &s), 1, "zero hz floors to every tick");
s.spurt_bpm = 0.0;
assert_eq!(pulse_period(HZ, &s), 1, "zero bpm floors rather than dividing by zero");
assert!(s.validate().is_err(), "and such a block is refused at the door anyway");
}
#[test]
fn the_period_scales_with_the_callers_rate() {
let s = CarnageSettings::default();
assert_eq!(pulse_period(30, &s), 19, "half the rate is half the ticks per beat");
assert_eq!(pulse_period(120, &s), 75, "double the rate is double");
}
}