use bevy::math::Vec2;
use bevy::prelude::*;
use crate::CarnageSettings;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[repr(u32)]
pub enum GoreTier {
Stylised = 0,
Blood = 1,
BloodAndGore = 2,
GrossViolence = 3,
}
#[derive(Resource, Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct GorePolicy {
pub tier: GoreTier,
pub blood_decals: bool,
pub dismemberment: bool,
pub viscera: bool,
pub screen_blood: bool,
pub ragdolls: bool,
pub persistence_scale: f32,
pub shake_scale: f32,
pub intensity: f32,
pub reference_class: f32,
pub ejection_profile: f32,
pub wetness: f32,
pub aim_exclusion_deg: f32,
pub max_decals: u32,
pub max_flashes_per_second: u32,
}
pub mod shipped {
use super::GoreTier;
pub(super) fn tier() -> GoreTier {
GoreTier::BloodAndGore
}
pub(super) fn on() -> bool {
true
}
pub(super) fn persistence_scale() -> f32 {
1.0
}
pub(super) fn shake_scale() -> f32 {
1.0
}
pub(super) fn intensity() -> f32 {
0.6
}
pub(super) fn reference_class() -> f32 {
1.0
}
pub(super) fn ejection_profile() -> f32 {
0.5
}
pub(super) fn wetness() -> f32 {
1.0
}
pub(super) fn aim_exclusion_deg() -> f32 {
10.0
}
pub(super) fn max_decals() -> u32 {
256
}
pub(super) fn max_flashes_per_second() -> u32 {
3
}
}
impl Default for GorePolicy {
fn default() -> Self {
GorePolicy {
tier: shipped::tier(),
blood_decals: shipped::on(),
dismemberment: shipped::on(),
viscera: shipped::on(),
screen_blood: shipped::on(),
ragdolls: shipped::on(),
persistence_scale: shipped::persistence_scale(),
shake_scale: shipped::shake_scale(),
intensity: shipped::intensity(),
reference_class: shipped::reference_class(),
ejection_profile: shipped::ejection_profile(),
wetness: shipped::wetness(),
aim_exclusion_deg: shipped::aim_exclusion_deg(),
max_decals: shipped::max_decals(),
max_flashes_per_second: shipped::max_flashes_per_second(),
}
}
}
impl GorePolicy {
pub fn for_tier(tier: GoreTier) -> Self {
let base = GorePolicy { tier, ..Default::default() };
match tier {
GoreTier::Stylised => GorePolicy {
dismemberment: false,
viscera: false,
screen_blood: false,
persistence_scale: 0.25,
intensity: 0.4,
reference_class: 0.0,
wetness: 0.0,
..base
},
GoreTier::Blood => {
GorePolicy { dismemberment: false, viscera: false, persistence_scale: 0.6, ..base }
}
GoreTier::BloodAndGore => base,
GoreTier::GrossViolence => {
GorePolicy { persistence_scale: 1.5, intensity: 0.85, ..base }
}
}
}
pub fn draws_blood(&self) -> bool {
self.tier > GoreTier::Stylised
}
pub fn validate(&self) -> Result<(), String> {
for (name, v) in [
("persistence_scale", self.persistence_scale),
("shake_scale", self.shake_scale),
("intensity", self.intensity),
("reference_class", self.reference_class),
("ejection_profile", self.ejection_profile),
("wetness", self.wetness),
] {
if !v.is_finite() || v < 0.0 {
return Err(format!(
"carnage: {name} is {v} — every policy scalar must be finite and non-negative."
));
}
}
if !(0.0..=90.0).contains(&self.aim_exclusion_deg) {
return Err(format!(
"carnage: aim_exclusion_deg is {} — it is a half-angle about the aim point, so it \
must be in [0, 90]. Ten is the documented value; thirty is folklore.",
self.aim_exclusion_deg
));
}
if self.max_decals == 0 {
return Err("carnage: max_decals is 0 — that turns off world blood through a ceiling \
rather than through `blood_decals`, which is the honest switch."
.to_string());
}
Ok(())
}
}
#[derive(Resource, Clone, Copy, Debug, Default)]
pub struct FlashGate {
ticks: [Option<u32>; FLASH_RING],
cursor: usize,
}
const FLASH_RING: usize = 4;
pub const WCAG_FLASHES_PER_SECOND: u32 = 3;
impl FlashGate {
pub fn admit(&mut self, tick: u32, hz: u32, policy: &GorePolicy) -> bool {
let hz = hz.max(1);
let allowed = policy.max_flashes_per_second.min(WCAG_FLASHES_PER_SECOND).max(1) as usize;
let recent = self
.ticks
.iter()
.filter(|slot| slot.is_some_and(|t| tick.wrapping_sub(t) < hz))
.count();
if recent >= allowed {
return false;
}
self.ticks[self.cursor % FLASH_RING] = Some(tick);
self.cursor = self.cursor.wrapping_add(1);
true
}
pub fn recent(&self, tick: u32, hz: u32) -> u32 {
let hz = hz.max(1);
self.ticks.iter().filter(|s| s.is_some_and(|t| tick.wrapping_sub(t) < hz)).count() as u32
}
}
pub fn occludes_aim(ndc: Vec2, radius_ndc: f32, policy: &GorePolicy) -> bool {
if !ndc.is_finite() || !radius_ndc.is_finite() {
return true;
}
const HALF_FIELD_DEG: f32 = 45.0;
let exclusion = (policy.aim_exclusion_deg / HALF_FIELD_DEG).clamp(0.0, 1.0);
ndc.length() - radius_ndc.max(0.0) < exclusion
}
#[derive(Resource, Clone, Debug, Default)]
pub struct DecalBudget {
live: Vec<(u32, u64)>,
}
impl DecalBudget {
pub fn len(&self) -> usize {
self.live.len()
}
pub fn is_empty(&self) -> bool {
self.live.is_empty()
}
pub fn admit(&mut self, tick: u32, id: u64, policy: &GorePolicy) -> Option<Vec<u64>> {
if !policy.blood_decals {
return None;
}
self.live.push((tick, id));
let cap = policy.max_decals.max(1) as usize;
let mut evicted = Vec::new();
while self.live.len() > cap {
let Some(oldest) = self
.live
.iter()
.enumerate()
.min_by_key(|(_, (t, i))| (*t, *i))
.map(|(index, _)| index)
else {
break;
};
evicted.push(self.live.remove(oldest).1);
}
Some(evicted)
}
pub fn release(&mut self, id: u64) {
self.live.retain(|(_, i)| *i != id);
}
}
pub fn coalesce_hitstop(
pending: &mut Vec<u32>,
hz: u32,
s: &CarnageSettings,
) -> u32 {
let one = pending.iter().copied().max().unwrap_or(0);
pending.clear();
let hz = hz.max(1);
let budget = (s.hitstop_budget_per_second.clamp(0.0, 1.0) * hz as f32).round() as u32;
one.min(budget)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_flash_gate_refuses_the_fourth_flash_in_a_second() {
let policy = GorePolicy::default();
let mut gate = FlashGate::default();
assert!(gate.admit(0, 60, &policy), "the first flash is admitted");
assert!(gate.admit(10, 60, &policy), "the second");
assert!(gate.admit(20, 60, &policy), "the third");
assert!(!gate.admit(30, 60, &policy), "the fourth inside one second must be refused");
assert_eq!(gate.recent(30, 60), 3, "and the meter must say why");
assert!(gate.admit(61, 60, &policy), "past the window the gate opens again");
}
#[test]
fn a_policy_cannot_raise_the_flash_ceiling() {
let greedy = GorePolicy { max_flashes_per_second: 60, ..Default::default() };
let mut gate = FlashGate::default();
let admitted = (0..10u32).filter(|t| gate.admit(*t, 60, &greedy)).count();
assert_eq!(
admitted, WCAG_FLASHES_PER_SECOND as usize,
"the WCAG ceiling must hold whatever the policy asks for"
);
}
#[test]
fn a_policy_can_lower_the_flash_ceiling() {
let cautious = GorePolicy { max_flashes_per_second: 1, ..Default::default() };
let mut gate = FlashGate::default();
assert!(gate.admit(0, 60, &cautious));
assert!(!gate.admit(1, 60, &cautious), "one per second means one");
}
#[test]
fn the_aim_cone_excludes_the_reticle_and_not_the_screen() {
let p = GorePolicy::default();
assert!(occludes_aim(Vec2::ZERO, 0.0, &p), "dead centre must be refused");
assert!(
!occludes_aim(Vec2::new(0.5, 0.0), 0.0, &p),
"half way to the edge must be allowed, or blood has nowhere to land"
);
assert!(occludes_aim(Vec2::new(0.15, 0.0), 0.0, &p), "just inside the cone is refused");
assert!(!occludes_aim(Vec2::new(0.30, 0.0), 0.0, &p), "just outside it is admitted");
assert!(
occludes_aim(Vec2::new(0.4, 0.0), 0.3, &p),
"an effect that REACHES the reticle must be refused, not only one centred on it"
);
assert!(occludes_aim(Vec2::new(f32::NAN, 0.0), 0.0, &p), "a non-place must be refused");
let off = GorePolicy { aim_exclusion_deg: 0.0, ..Default::default() };
assert!(!occludes_aim(Vec2::new(0.001, 0.0), 0.0, &off), "zero degrees excludes nothing");
}
#[test]
fn the_decal_budget_evicts_oldest_first_and_holds_its_cap() {
let p = GorePolicy { max_decals: 4, ..Default::default() };
let mut budget = DecalBudget::default();
for id in 0..4u64 {
let evicted = budget.admit(id as u32, id, &p).expect("world blood is on");
assert!(evicted.is_empty(), "nothing is evicted below the cap");
}
let evicted = budget.admit(100, 99, &p).expect("admitted");
assert_eq!(evicted, vec![0], "the oldest decal must go first");
assert_eq!(budget.len(), 4, "and the cap must hold");
let mut a = DecalBudget::default();
let mut b = DecalBudget::default();
let run = |d: &mut DecalBudget| -> Vec<u64> {
(0..40u64).flat_map(|id| d.admit((id / 2) as u32, id, &p).unwrap_or_default()).collect()
};
assert_eq!(run(&mut a), run(&mut b), "eviction must be a function of the record");
let off = GorePolicy { blood_decals: false, ..Default::default() };
assert!(
DecalBudget::default().admit(0, 1, &off).is_none(),
"with world blood off the decal is refused outright rather than admitted and evicted"
);
}
#[test]
fn hitstop_coalesces_to_one_stop_and_stays_inside_its_budget() {
let s = CarnageSettings::default();
let mut pending = vec![3u32, 3, 3, 3, 3];
let one = coalesce_hitstop(&mut pending, 60, &s);
assert_eq!(one, 3, "five wounds asking for three ticks each is three ticks, not fifteen");
assert!(pending.is_empty(), "the queue must be drained, or the next tick double-counts");
let mut greedy = vec![600u32];
let capped = coalesce_hitstop(&mut greedy, 60, &s);
let budget = (s.hitstop_budget_per_second * 60.0).round() as u32;
assert_eq!(capped, budget, "a single absurd request must be capped to the per-second budget");
let mut none: Vec<u32> = Vec::new();
assert_eq!(coalesce_hitstop(&mut none, 60, &s), 0, "no wounds is no freeze");
}
#[test]
fn every_tier_keeps_the_hit_confirmation() {
for tier in
[GoreTier::Stylised, GoreTier::Blood, GoreTier::BloodAndGore, GoreTier::GrossViolence]
{
let p = GorePolicy::for_tier(tier);
assert!(
p.blood_decals,
"{tier:?} switched the decal emitter off — reduction is substitution, never deletion"
);
assert!(p.validate().is_ok(), "{tier:?} must be a valid policy");
assert_eq!(p.tier, tier, "the tier must record itself");
}
assert!(!GorePolicy::for_tier(GoreTier::Stylised).draws_blood());
assert!(!GorePolicy::for_tier(GoreTier::Blood).dismemberment);
assert!(GorePolicy::for_tier(GoreTier::BloodAndGore).dismemberment);
assert!(
GorePolicy::for_tier(GoreTier::GrossViolence).persistence_scale
> GorePolicy::for_tier(GoreTier::Blood).persistence_scale,
"PEGI's criterion is persistence, so the top tier must persist longer"
);
}
#[test]
fn the_shipped_intensity_is_not_maximal() {
let p = GorePolicy::default();
assert!(
p.intensity > 0.3 && p.intensity < 0.8,
"intensity {} is outside the band the juiciness literature supports",
p.intensity
);
assert_eq!(p.aim_exclusion_deg, 10.0, "ten degrees is the documented value");
assert_eq!(p.max_flashes_per_second, WCAG_FLASHES_PER_SECOND);
assert!(p.validate().is_ok());
}
#[test]
fn the_policy_door_refuses_what_cannot_mean_anything() {
let bad = |f: fn(&mut GorePolicy)| {
let mut p = GorePolicy::default();
f(&mut p);
p.validate().expect_err("this policy must be refused")
};
assert!(bad(|p| p.intensity = -1.0).contains("intensity"));
assert!(bad(|p| p.shake_scale = f32::NAN).contains("shake_scale"));
assert!(bad(|p| p.aim_exclusion_deg = 120.0).contains("aim_exclusion_deg"));
assert!(bad(|p| p.max_decals = 0).contains("max_decals"));
}
}