use bevy::prelude::*;
use bevy_hanabi::{
AccelModifier, Attribute, ColorOverLifetimeModifier, EffectAsset, EffectSpawner, ExprWriter,
Gradient, HanabiPlugin, KillAabbModifier, LinearDragModifier, MotionIntegration, OrientMode,
OrientModifier, ParticleEffect, ScalarType, SetAttributeModifier, SetPositionCone3dModifier,
SetVelocitySphereModifier, ShapeDimension, SimulationCondition, SimulationSpace,
SizeOverLifetimeModifier, SpawnerSettings,
};
use crate::spatter::{BACK_SPATTER_SPEED, FORWARD_SPATTER_SPEED, wound_seed};
use crate::wound::{Wound, WoundKind};
use crate::{CarnageSettings, Wounded};
#[derive(Resource, Debug, Clone)]
pub struct CarnageEffects {
pub spatter: Handle<EffectAsset>,
pub mist: Handle<EffectAsset>,
pub spurt: Handle<EffectAsset>,
pub seep: Handle<EffectAsset>,
pub ribbon: Handle<EffectAsset>,
}
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct EffectTtl(pub u32);
fn blood_gradient() -> Gradient<Vec4> {
let mut g = Gradient::new();
g.add_key(0.0, Vec4::new(0.62, 0.05, 0.05, 1.0));
g.add_key(0.55, Vec4::new(0.40, 0.02, 0.02, 0.95));
g.add_key(1.0, Vec4::new(0.18, 0.01, 0.01, 0.0));
g
}
struct BloodEffect {
name: &'static str,
base_radius: f32,
height: f32,
speed: [f32; 2],
lifetime: [f32; 2],
drag_scale: f32,
size: f32,
space: SimulationSpace,
condition: SimulationCondition,
spawner: SpawnerSettings,
}
impl BloodEffect {
fn build(self, s: &CarnageSettings) -> EffectAsset {
let writer = ExprWriter::new();
let init_pos = SetPositionCone3dModifier {
height: writer.lit(self.height).expr(),
base_radius: writer.lit(self.base_radius).expr(),
top_radius: writer.lit(self.base_radius * 0.15).expr(),
dimension: ShapeDimension::Volume,
};
let t = writer.rand(ScalarType::Float);
let init_vel = SetVelocitySphereModifier {
center: writer.lit(Vec3::new(0.0, -self.height, 0.0)).expr(),
speed: (writer.lit(self.speed[1])
+ t.clone() * writer.lit(self.speed[0] - self.speed[1]))
.expr(),
};
let init_size = SetAttributeModifier::new(
Attribute::F32_0,
(writer.lit(self.size * 0.45) + t * writer.lit(self.size * 1.1)).expr(),
);
let init_age = SetAttributeModifier::new(Attribute::AGE, writer.lit(0.0).expr());
let init_lifetime = SetAttributeModifier::new(
Attribute::LIFETIME,
(writer.lit(self.lifetime[0])
+ writer.rand(ScalarType::Float) * writer.lit(self.lifetime[1] - self.lifetime[0]))
.expr(),
);
let update_size = SetAttributeModifier::new(
Attribute::SIZE,
writer
.attr(Attribute::F32_0)
.mul(
writer
.lit(1.0)
.sub(writer.attr(Attribute::AGE).div(writer.attr(Attribute::LIFETIME)))
.max(writer.lit(0.0)),
)
.expr(),
);
let reach = (self.speed[1] * self.lifetime[1]).max(4.0);
let kill_center = writer.lit(Vec3::ZERO).expr();
let kill_half = writer.lit(Vec3::splat(reach)).expr();
let mut module = writer.finish();
let gravity = AccelModifier::constant(&mut module, Vec3::NEG_Y * s.gravity);
let drag = LinearDragModifier::constant(&mut module, s.drag * self.drag_scale);
let kill = KillAabbModifier::new(kill_center, kill_half);
EffectAsset::new(s.effect_capacity, self.spawner, module)
.with_name(self.name)
.with_simulation_space(self.space)
.with_simulation_condition(self.condition)
.init(init_pos)
.init(init_vel)
.init(init_size)
.init(init_age)
.init(init_lifetime)
.update(gravity)
.update(drag)
.update(update_size)
.update(kill)
.render(ColorOverLifetimeModifier::new(blood_gradient()))
.render(OrientModifier::new(OrientMode::AlongVelocity))
}
}
pub fn spatter_burst(s: &CarnageSettings) -> EffectAsset {
BloodEffect {
name: "carnage:spatter",
base_radius: 0.03,
height: 0.06,
speed: [BACK_SPATTER_SPEED, FORWARD_SPATTER_SPEED],
lifetime: [0.35, 0.85],
drag_scale: 1.0,
size: 0.022,
space: SimulationSpace::Global,
condition: SimulationCondition::WhenVisible,
spawner: SpawnerSettings::once(1.0.into()),
}
.build(s)
}
pub fn mist_puff(s: &CarnageSettings) -> EffectAsset {
BloodEffect {
name: "carnage:mist",
base_radius: 0.05,
height: 0.04,
speed: [FORWARD_SPATTER_SPEED, FORWARD_SPATTER_SPEED],
lifetime: [0.10, 0.22],
drag_scale: 6.0,
size: 0.010,
space: SimulationSpace::Global,
condition: SimulationCondition::WhenVisible,
spawner: SpawnerSettings::once(1.0.into()),
}
.build(s)
}
pub fn arterial_spurt(s: &CarnageSettings) -> EffectAsset {
let period = if s.spurt_bpm > 0.0 { 60.0 / s.spurt_bpm } else { 1.0 };
BloodEffect {
name: "carnage:spurt",
base_radius: 0.015,
height: 0.05,
speed: [FORWARD_SPATTER_SPEED * 0.35, FORWARD_SPATTER_SPEED * 0.6],
lifetime: [0.45, 0.95],
drag_scale: 0.8,
size: 0.026,
space: SimulationSpace::Global,
condition: SimulationCondition::Always,
spawner: SpawnerSettings::burst(24.0.into(), period.into()),
}
.build(s)
}
pub fn wound_seep(s: &CarnageSettings) -> EffectAsset {
BloodEffect {
name: "carnage:seep",
base_radius: 0.012,
height: 0.02,
speed: [0.15, 0.6],
lifetime: [0.5, 1.1],
drag_scale: 2.0,
size: 0.014,
space: SimulationSpace::Local,
condition: SimulationCondition::Always,
spawner: SpawnerSettings::rate(26.0.into()),
}
.build(s)
}
const RIBBON_LIFETIME: f32 = 0.9;
const RIBBON_RATE: f32 = 60.0;
const RIBBON_CAPACITY: u32 = 64;
pub fn gib_ribbon() -> EffectAsset {
let writer = ExprWriter::new();
let init_pos =
SetAttributeModifier::new(Attribute::POSITION, writer.lit(Vec3::ZERO).expr());
let init_age = SetAttributeModifier::new(Attribute::AGE, writer.lit(0.0).expr());
let init_lifetime =
SetAttributeModifier::new(Attribute::LIFETIME, writer.lit(RIBBON_LIFETIME).expr());
let init_size = SetAttributeModifier::new(Attribute::SIZE, writer.lit(0.045).expr());
let init_ribbon_id = SetAttributeModifier::new(Attribute::RIBBON_ID, writer.lit(0u32).expr());
EffectAsset::new(RIBBON_CAPACITY, SpawnerSettings::rate(RIBBON_RATE.into()), writer.finish())
.with_name("carnage:ribbon")
.with_motion_integration(MotionIntegration::None)
.with_simulation_space(SimulationSpace::Global)
.with_simulation_condition(SimulationCondition::Always)
.init(init_pos)
.init(init_age)
.init(init_lifetime)
.init(init_size)
.init(init_ribbon_id)
.render(SizeOverLifetimeModifier {
gradient: Gradient::linear(Vec3::ONE, Vec3::ZERO),
..default()
})
.render(ColorOverLifetimeModifier::new(Gradient::linear(
Vec4::new(0.42, 0.02, 0.02, 1.0),
Vec4::new(0.12, 0.01, 0.01, 0.0),
)))
}
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct BleedingChunk;
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct RibbonInstance;
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct EffectFade(pub u32);
const RIBBON_FADE_TICKS: u32 = 60;
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct CarnageVfxSystems;
pub struct CarnageVfxPlugin;
impl Plugin for CarnageVfxPlugin {
fn build(&self, app: &mut App) {
if !app.is_plugin_added::<HanabiPlugin>() {
app.add_plugins(HanabiPlugin);
}
app.init_resource::<CarnageSettings>()
.add_systems(Startup, (build_effects, crate::decal::build_splats))
.add_systems(
Update,
(
spawn_wound_effects,
despawn_finished_effects,
attach_ribbons,
fade_landed_ribbons,
fade_effects,
)
.in_set(CarnageVfxSystems),
);
}
}
fn build_effects(
mut commands: Commands,
mut assets: ResMut<Assets<EffectAsset>>,
settings: Res<CarnageSettings>,
) {
let s = &*settings;
commands.insert_resource(CarnageEffects {
spatter: assets.add(spatter_burst(s)),
mist: assets.add(mist_puff(s)),
spurt: assets.add(arterial_spurt(s)),
seep: assets.add(wound_seep(s)),
ribbon: assets.add(gib_ribbon()),
});
}
fn spawn_wound_effects(
mut commands: Commands,
mut wounded: MessageReader<Wounded>,
effects: Option<Res<CarnageEffects>>,
settings: Res<CarnageSettings>,
) {
let Some(effects) = effects else {
return;
};
for w in wounded.read() {
let wound = Wound {
at: w.at,
normal: w.normal,
area: w.area,
severity: w.severity,
kind: w.kind,
};
let count = crate::spatter::droplet_count(&wound, &settings);
if count == 0 {
continue;
}
let seed = wound_seed(&wound);
let rotation = Quat::from_rotation_arc(Vec3::Y, w.normal.normalize_or_zero());
let transform = Transform { translation: w.at, rotation, scale: Vec3::ONE };
let handles: &[(&Handle<EffectAsset>, u32)] = match w.kind {
WoundKind::Severance => &[(&effects.spatter, count)],
WoundKind::Channel => &[(&effects.spatter, count), (&effects.mist, count / 2)],
};
for (handle, n) in handles.iter().copied() {
if n == 0 {
continue;
}
commands.spawn((
ParticleEffect { handle: handle.clone(), prng_seed: Some(seed) },
EffectSpawner::new(&SpawnerSettings::once((n as f32).into())),
transform,
EffectTtl(TTL_TICKS),
));
}
}
}
const TTL_TICKS: u32 = 180;
fn despawn_finished_effects(
mut commands: Commands,
mut q: Query<(Entity, &EffectSpawner, &mut EffectTtl)>,
) {
for (entity, spawner, mut ttl) in &mut q {
ttl.0 = ttl.0.saturating_sub(1);
if spawner.has_completed() && ttl.0 == 0 {
commands.entity(entity).despawn();
}
}
}
fn attach_ribbons(
mut commands: Commands,
effects: Option<Res<CarnageEffects>>,
settings: Res<CarnageSettings>,
chunks: Query<(Entity, &GlobalTransform, Option<&Children>), With<BleedingChunk>>,
ribbons: Query<(), With<RibbonInstance>>,
) {
let Some(effects) = effects else { return }; let mut live = ribbons.iter().count() as u32;
for (entity, at, children) in &chunks {
if live >= settings.max_ribbons {
return;
}
let already = children
.is_some_and(|c| c.iter().any(|child| ribbons.get(child).is_ok()));
if already {
continue;
}
let p = at.translation();
let q = |x: f32| (x / crate::soup::WELD).round() as i64 as u32;
let seed = q(p.x)
^ q(p.y).wrapping_mul(0x9E37_79B9)
^ q(p.z).wrapping_mul(2_654_435_761);
commands.entity(entity).with_child((
ParticleEffect { handle: effects.ribbon.clone(), prng_seed: Some(seed) },
RibbonInstance,
Transform::default(),
Visibility::default(),
));
live += 1;
}
}
fn fade_landed_ribbons(
mut commands: Commands,
ribbons: Query<(Entity, &ChildOf), (With<RibbonInstance>, Without<EffectFade>)>,
bleeding: Query<(), With<BleedingChunk>>,
) {
for (entity, parent) in &ribbons {
if bleeding.get(parent.parent()).is_err() {
commands.entity(entity).insert(EffectFade(RIBBON_FADE_TICKS));
}
}
}
fn fade_effects(
mut commands: Commands,
mut fading: Query<(Entity, &mut EffectFade, Option<&mut EffectSpawner>)>,
) {
for (entity, mut fade, spawner) in &mut fading {
if let Some(mut spawner) = spawner {
spawner.active = false;
}
fade.0 = fade.0.saturating_sub(1);
if fade.0 == 0 {
commands.entity(entity).despawn();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_droplet_effects_build_at_the_authored_capacity() {
let s = CarnageSettings::default();
let built = [
("spatter", spatter_burst(&s)),
("mist", mist_puff(&s)),
("spurt", arterial_spurt(&s)),
("seep", wound_seep(&s)),
];
for (what, asset) in &built {
assert_eq!(
asset.capacity(),
s.effect_capacity,
"{what} was built at a capacity other than the dial"
);
assert!(asset.name.contains("carnage:"), "{what} is missing its namespaced name");
}
let ribbon = gib_ribbon();
let mut names: Vec<&str> =
built.iter().map(|(_, a)| a.name.as_str()).chain([ribbon.name.as_str()]).collect();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), 5, "two effects share a name, so they are not five effects");
}
#[test]
fn the_effects_differ_where_they_must() {
let s = CarnageSettings::default();
assert_eq!(
wound_seep(&s).simulation_space,
SimulationSpace::Local,
"the seep must ride the fragment it is on"
);
assert_eq!(
arterial_spurt(&s).simulation_condition,
SimulationCondition::Always,
"an off-screen body must keep bleeding"
);
assert_eq!(
wound_seep(&s).simulation_condition,
SimulationCondition::Always,
"and so must keep seeping"
);
}
#[test]
fn the_ribbon_is_a_detached_uncapacitied_strand() {
let asset = gib_ribbon();
assert_eq!(
asset.simulation_space,
SimulationSpace::Global,
"a ribbon is global space — that is what leaves the strand where it was emitted"
);
assert_eq!(
asset.motion_integration,
bevy_hanabi::MotionIntegration::None,
"a ribbon particle must not integrate velocity; the emitter's motion draws the line"
);
assert_eq!(
asset.capacity(),
RIBBON_CAPACITY,
"the ribbon must not be sized off `effect_capacity` — see the constant's docs"
);
assert!(
RIBBON_CAPACITY as f32 >= RIBBON_RATE * RIBBON_LIFETIME,
"capacity {RIBBON_CAPACITY} cannot hold {RIBBON_RATE} particles/s alive for \
{RIBBON_LIFETIME}s, so the strand would be truncated"
);
assert!(
RIBBON_FADE_TICKS as f32 >= RIBBON_LIFETIME * 60.0,
"the fade must outlast one particle's life, or a landed chunk's strand is cut off"
);
}
#[test]
fn the_spurt_period_matches_the_bleed_schedule() {
let s = CarnageSettings::default();
let asset_period = 60.0 / s.spurt_bpm;
let schedule_period = crate::bleed::pulse_period(60, &s) as f32 / 60.0;
assert!(
(asset_period - schedule_period).abs() < 0.02,
"the spurt asset pulses every {asset_period:.4}s but the schedule every \
{schedule_period:.4}s — the blood and the model would disagree"
);
}
}