use bevy::asset::embedded_asset;
use bevy::camera::visibility::NoFrustumCulling;
use bevy::light::NotShadowCaster;
use bevy::mesh::{MeshTag, MeshVertexBufferLayoutRef};
use bevy::pbr::{Material, MaterialPipeline, MaterialPipelineKey, MaterialPlugin, MeshMaterial3d};
use bevy::prelude::*;
use bevy::render::render_resource::{AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError};
use bevy::shader::ShaderRef;
use bevy::time::Real;
use crate::LightsSystems;
use crate::chain_sets;
use crate::flicker::{Flicker, Kindle};
use crate::model::{
FLICKER_DEPTH, KINDLE_SECONDS, flicker_swing, kindle_gain, pack_flame_tag, set_flame_brightness,
};
use crate::pool::PoolCandidate;
const FLAME_SHADER_PATH: &str = "embedded://bevy_lights/flame.wesl";
pub struct FlamePlugin;
impl Plugin for FlamePlugin {
fn build(&self, app: &mut App) {
chain_sets(app);
embedded_asset!(app, "flame.wesl");
app.add_plugins(MaterialPlugin::<FlameMaterial>::default())
.init_resource::<FlameLook>()
.init_resource::<FlameAssets>()
.register_type::<FlameLook>()
.register_type::<FlameSite>()
.add_systems(Update, (dress_flames, update_flames).chain().in_set(LightsSystems::Flame))
.add_systems(Startup, build_flame_assets);
}
}
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct FlameMaterial {
#[uniform(0)]
base: LinearRgba,
#[uniform(0)]
params: Vec4,
}
impl Material for FlameMaterial {
fn vertex_shader() -> ShaderRef {
FLAME_SHADER_PATH.into()
}
fn fragment_shader() -> ShaderRef {
FLAME_SHADER_PATH.into()
}
fn alpha_mode(&self) -> AlphaMode {
AlphaMode::Add
}
fn enable_prepass() -> bool {
false
}
fn enable_shadows() -> bool {
false
}
fn specialize(
_pipeline: &MaterialPipeline,
descriptor: &mut RenderPipelineDescriptor,
_layout: &MeshVertexBufferLayoutRef,
_key: MaterialPipelineKey<Self>,
) -> Result<(), SpecializedMeshPipelineError> {
if let Some(depth) = descriptor.depth_stencil.as_mut() {
depth.depth_write_enabled = Some(false);
}
descriptor.primitive.cull_mode = None;
Ok(())
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[require(Visibility)]
#[reflect(Component)]
pub struct FlameSite {
pub height: f32,
pub phase: f32,
pub kindle: f32,
pub diameter: Option<f32>,
pub depth: f32,
pub depth_fade: bool,
pub fire: Option<Entity>,
}
#[derive(Component, Clone, Copy, Debug, Default, PartialEq)]
pub struct Flame {
pub kindled: f32,
pub source: Option<Entity>,
}
#[derive(Resource, Clone, Copy, Debug, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[reflect(Resource)]
pub struct FlameLook {
pub color: LinearRgba,
pub strength: f32,
pub max_height: f32,
}
impl Default for FlameLook {
fn default() -> Self {
Self { color: LinearRgba::rgb(1.0, 0.55, 0.18), strength: 1.0, max_height: 4.0 }
}
}
#[derive(Resource, Default)]
struct FlameAssets {
mesh: Handle<Mesh>,
material: Handle<FlameMaterial>,
faded_material: Handle<FlameMaterial>,
max_height: f32,
built: bool,
}
impl FlameAssets {
fn material_for(&self, depth_fade: bool) -> &Handle<FlameMaterial> {
if depth_fade { &self.faded_material } else { &self.material }
}
}
#[derive(Component)]
struct Flamed;
fn build_flame_assets(
mut assets: ResMut<FlameAssets>,
look: Res<FlameLook>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<FlameMaterial>>,
) {
assets.mesh = meshes.add(Rectangle::new(1.0, 1.0));
assets.material = materials.add(material_for(*look, false));
assets.faded_material = materials.add(material_for(*look, true));
assets.max_height = look.max_height;
assets.built = true;
}
fn material_for(look: FlameLook, depth_fade: bool) -> FlameMaterial {
FlameMaterial {
base: LinearRgba {
red: look.color.red,
green: look.color.green.min(1.0),
blue: look.color.blue,
alpha: look.strength,
},
params: Vec4::new(0.0, look.max_height, if depth_fade { 1.0 } else { 0.0 }, look.strength),
}
}
fn dress_flames(
mut commands: Commands,
sites: Query<(Entity, &FlameSite), Without<Flamed>>,
assets: Res<FlameAssets>,
time: Res<Time>,
) {
if !assets.built {
return;
}
let max_height = assets.max_height;
let t = time.elapsed_secs();
for (site, flame) in &sites {
let catch = flame.kindle;
let brightness = flicker_swing(flame.diameter, flame.depth, t, flame.phase);
let tag = MeshTag::new(pack_flame_tag(flame.phase, flame.height, brightness, max_height));
let mesh = assets.mesh.clone();
let material = assets.material_for(flame.depth_fade).clone();
commands.entity(site).queue_silenced(move |mut site: EntityWorldMut| {
site.insert(Flamed).with_children(|parent| {
parent.spawn((
Name::new("Flame"),
Flame { kindled: if catch > 0.0 { 0.0 } else { f32::MAX }, source: None },
tag,
Mesh3d(mesh),
MeshMaterial3d(material),
Transform::IDENTITY,
NotShadowCaster,
NoFrustumCulling,
));
});
});
}
}
fn update_flames(
real: Res<Time<Real>>,
time: Res<Time>,
look: Res<FlameLook>,
assets: Res<FlameAssets>,
mut materials: ResMut<Assets<FlameMaterial>>,
mut flames: Query<(
&mut Flame,
&mut Transform,
&mut MeshTag,
&mut MeshMaterial3d<FlameMaterial>,
&ChildOf,
)>,
sites: Sites,
) {
if !assets.built {
return;
}
let dt = real.delta_secs();
let t = time.elapsed_secs();
for (mut flame, mut transform, mut tag, mut material, parent) in &mut flames {
let (site, owner) = sites.sites.get(parent.parent()).map_or((None, None), |(s, o)| (Some(s), o));
let source = owner.map(ChildOf::parent).filter(|owner| !sites.sites.contains(*owner));
if flame.source != source {
flame.source = source;
}
if let Some(site) = site {
let wanted = assets.material_for(site.depth_fade);
if material.0 != *wanted {
material.0 = wanted.clone();
}
let (diameter, depth, phase) = sites.fire_of(site);
let brightness = flicker_swing(diameter, depth, t, phase);
let packed = set_flame_brightness(tag.value, brightness);
if packed != tag.value {
tag.value = packed;
}
}
if let Some(elapsed) =
site.and_then(|site| sites.kindle_of(site)).filter(|elapsed| *elapsed < KINDLE_SECONDS)
{
let scale = Vec3::splat(kindle_gain(elapsed).max(0.001));
if transform.scale != scale {
transform.scale = scale;
}
if flame.kindled < f32::MAX {
flame.kindled = f32::MAX;
}
continue;
}
if flame.kindled >= f32::MAX {
if transform.scale != Vec3::ONE {
transform.scale = Vec3::ONE;
}
continue;
}
flame.kindled = (flame.kindled + dt).min(f32::MAX);
let catch = site.map_or(0.0, |site| site.kindle);
let gain = if catch > 0.0 { kindle_gain(flame.kindled.min(catch)) } else { 1.0 };
transform.scale = Vec3::splat(gain.max(0.001));
if flame.kindled >= catch {
flame.kindled = f32::MAX;
transform.scale = Vec3::ONE;
}
}
let elapsed = real.elapsed_secs_wrapped();
for handle in [&assets.material, &assets.faded_material] {
let Some(mut made) = materials.get_mut(handle) else {
continue;
};
made.params.x = elapsed;
if look.is_changed() {
made.base = LinearRgba {
red: look.color.red,
green: look.color.green.min(1.0),
blue: look.color.blue,
alpha: look.strength,
};
made.params.y = look.max_height;
made.params.w = look.strength;
}
}
}
#[derive(bevy::ecs::system::SystemParam)]
struct Sites<'w, 's> {
sites: Query<'w, 's, (&'static FlameSite, Option<&'static ChildOf>)>,
lights: Query<'w, 's, (Option<&'static Flicker>, Option<&'static PoolCandidate>)>,
kindles: Query<'w, 's, &'static Kindle>,
}
impl Sites<'_, '_> {
fn kindle_of(&self, site: &FlameSite) -> Option<f32> {
self.kindles.get(site.fire?).ok().map(|kindle| kindle.elapsed)
}
fn fire_of(&self, site: &FlameSite) -> (Option<f32>, f32, f32) {
let own = (site.diameter, site.depth, site.phase);
let Some((flicker, candidate)) = site.fire.and_then(|light| self.lights.get(light).ok()) else {
return own;
};
match (flicker, candidate) {
(Some(flicker), _) => (flicker.diameter, flicker.depth, flicker.phase),
(None, Some(candidate)) => (
candidate.diameter,
candidate.depth.unwrap_or(FLICKER_DEPTH),
candidate.phase.unwrap_or(site.phase),
),
(None, None) => own,
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use crate::model::{FLICKER_DEPTH, unpack_flame_tag};
use core::f32::consts::TAU;
fn dressing_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(Assets::<Mesh>::default())
.insert_resource(Assets::<FlameMaterial>::default())
.init_resource::<FlameLook>()
.init_resource::<FlameAssets>()
.insert_resource(bevy::time::TimeUpdateStrategy::ManualDuration(
std::time::Duration::from_secs_f64(1.0 / 60.0),
));
app.add_systems(Startup, build_flame_assets)
.add_systems(Update, (dress_flames, update_flames).chain());
app.update();
app
}
fn flame_children(app: &mut App) -> Vec<(Entity, u32)> {
app.world_mut()
.query_filtered::<(&MeshTag, &ChildOf), With<Flame>>()
.iter(app.world())
.map(|(tag, child_of)| (child_of.parent(), tag.value))
.collect()
}
#[test]
fn every_site_gets_one_flame_and_a_second_frame_does_not_twin_it() {
let mut app = dressing_app();
let site = app
.world_mut()
.spawn((
FlameSite {
height: 0.9,
phase: 0.4,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
))
.id();
app.update();
let children = flame_children(&mut app);
assert_eq!(children.len(), 1, "one flame");
assert_eq!(children[0].0, site, "a child of the site");
app.update();
assert_eq!(flame_children(&mut app).len(), 1, "still one flame");
}
#[test]
fn an_unchanged_brightness_leaves_the_tag_unwritten() {
let mut app = dressing_app();
app.world_mut().spawn((
FlameSite {
height: 0.35,
phase: 2.5,
kindle: 0.0,
diameter: Some(0.34),
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
));
app.update();
let flame =
app.world_mut().query_filtered::<Entity, With<Flame>>().single(app.world()).expect("one flame");
let mut changed = 0;
let mut last = app.world().entity(flame).get_ref::<MeshTag>().expect("a tag").last_changed();
for _ in 0..30 {
app.update();
let tag = app.world().entity(flame).get_ref::<MeshTag>().expect("a tag");
let tick = tag.last_changed();
if tick != last {
changed += 1;
last = tick;
}
}
assert!(changed > 0, "the tag never changed, so the flame is not breathing at all");
assert!(changed < 30, "the tag changed on all 30 frames: an unchanged byte still re-extracts");
}
#[test]
fn a_flames_tag_carries_its_sites_phase_and_height() {
let mut app = dressing_app();
app.world_mut().spawn((
FlameSite {
height: 0.35,
phase: 2.5,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
));
app.update();
let max_height = app.world().resource::<FlameAssets>().max_height;
let children = flame_children(&mut app);
assert_eq!(children.len(), 1);
let (phase, height, _brightness) = unpack_flame_tag(children[0].1, max_height);
assert!((phase - 2.5).abs() < TAU / 256.0, "phase {phase}");
assert!((height - 0.35).abs() < 1e-3, "height {height}");
}
#[test]
fn a_flames_brightness_is_the_fires_own_swing() {
let mut app = dressing_app();
app.world_mut().spawn((
FlameSite {
height: 0.35,
phase: 2.5,
kindle: 0.0,
diameter: Some(0.34),
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
));
app.update();
let max_height = app.world().resource::<FlameAssets>().max_height;
let t = app.world().resource::<Time>().elapsed_secs();
let children = flame_children(&mut app);
assert_eq!(children.len(), 1);
let (_, _, brightness) = unpack_flame_tag(children[0].1, max_height);
let expected = flicker_swing(Some(0.34), FLICKER_DEPTH, t, 2.5);
assert!(
(brightness - expected).abs() < 1.0 / 255.0,
"brightness {brightness} against the light's own {expected}"
);
assert!((0.70..=1.0).contains(&brightness), "{brightness} outside the swing window");
}
#[test]
fn a_flame_breathes_by_the_light_its_site_names() {
let mut app = dressing_app();
let light =
app.world_mut().spawn(Flicker { phase: 1.3, diameter: Some(0.3), depth: 0.30, ..default() }).id();
let site = FlameSite {
height: 0.35,
phase: 2.5,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: Some(light),
};
app.world_mut().spawn((site, Transform::default()));
for _ in 0..40 {
app.update();
}
let max_height = app.world().resource::<FlameAssets>().max_height;
let brightness = |app: &mut App| {
let t = app.world().resource::<Time>().elapsed_secs();
(unpack_flame_tag(flame_children(app)[0].1, max_height).2, t)
};
let (got, t) = brightness(&mut app);
let theirs = flicker_swing(Some(0.3), 0.30, t, 1.3);
let own = flicker_swing(None, FLICKER_DEPTH, t, 2.5);
assert!((own - theirs).abs() > 2.0 / 255.0, "the fixture must tell the two apart: {own} vs {theirs}");
assert!((got - theirs).abs() < 1.0 / 255.0, "brightness {got} against the light's {theirs}");
app.world_mut().get_mut::<Flicker>(light).expect("the light").set_depth(0.05);
app.update();
let (got, t) = brightness(&mut app);
let calmer = flicker_swing(Some(0.3), 0.05, t, 1.3);
assert!((got - calmer).abs() < 1.0 / 255.0, "brightness {got} after set_depth, wanted {calmer}");
}
#[test]
fn a_flame_reads_a_pool_candidate_the_way_the_pool_does() {
let mut app = dressing_app();
let candidate = app.world_mut().spawn(PoolCandidate { depth: Some(0.30), ..default() }).id();
let bare = app.world_mut().spawn(PoolCandidate::default()).id();
let site = |fire| FlameSite {
height: 0.35,
phase: 2.5,
kindle: 0.0,
diameter: None,
depth: 0.0,
depth_fade: false,
fire: Some(fire),
};
let deep = app.world_mut().spawn((site(candidate), Transform::default())).id();
let plain = app.world_mut().spawn((site(bare), Transform::default())).id();
for _ in 0..40 {
app.update();
}
let max_height = app.world().resource::<FlameAssets>().max_height;
let t = app.world().resource::<Time>().elapsed_secs();
for (site, depth) in [(deep, 0.30), (plain, FLICKER_DEPTH)] {
let tag =
flame_children(&mut app).into_iter().find(|(parent, _)| *parent == site).expect("a flame").1;
let wanted = flicker_swing(None, depth, t, 2.5);
let got = unpack_flame_tag(tag, max_height).2;
assert!((got - wanted).abs() < 1.0 / 255.0, "depth {depth}: brightness {got}, wanted {wanted}");
}
}
#[test]
fn a_dangling_fire_link_falls_back_to_the_sites_own_values() {
let mut app = dressing_app();
let gone = app.world_mut().spawn_empty().id();
app.world_mut().despawn(gone);
let bare = app.world_mut().spawn_empty().id();
for fire in [gone, bare] {
app.world_mut().spawn((
FlameSite {
height: 0.35,
phase: 2.5,
kindle: 0.0,
diameter: Some(0.34),
depth: 0.25,
depth_fade: false,
fire: Some(fire),
},
Transform::default(),
));
}
for _ in 0..40 {
app.update();
}
let max_height = app.world().resource::<FlameAssets>().max_height;
let t = app.world().resource::<Time>().elapsed_secs();
let wanted = flicker_swing(Some(0.34), 0.25, t, 2.5);
let flames = flame_children(&mut app);
assert_eq!(flames.len(), 2);
for (_, tag) in flames {
let got = unpack_flame_tag(tag, max_height).2;
assert!((got - wanted).abs() < 1.0 / 255.0, "brightness {got}, wanted {wanted}");
}
}
#[test]
fn a_relit_fire_catches_its_flame_with_its_light() {
let mut app = dressing_app();
let light = app.world_mut().spawn(Kindle { elapsed: KINDLE_SECONDS }).id();
let site = |fire| {
(
FlameSite {
height: 0.35,
phase: 0.0,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire,
},
Transform::default(),
)
};
let linked = app.world_mut().spawn(site(Some(light))).id();
let free = app.world_mut().spawn(site(None)).id();
for _ in 0..5 {
app.update();
}
let scales = |app: &mut App| {
app.world_mut()
.query_filtered::<(&Transform, &ChildOf), With<Flame>>()
.iter(app.world())
.map(|(t, c)| (c.parent(), t.scale.x))
.collect::<Vec<_>>()
};
assert!(scales(&mut app).iter().all(|(_, s)| *s == 1.0), "a settled fire draws whole");
for step in [0.0, 0.15, 0.3, 0.45] {
app.world_mut().entity_mut(light).insert(Kindle { elapsed: step });
app.update();
assert_eq!(flame_children(&mut app).len(), 2);
let scales = scales(&mut app);
let scale_of = |site| scales.iter().find(|(parent, _)| *parent == site).map(|(_, s)| *s);
let wanted = kindle_gain(step).max(0.001);
let got = scale_of(linked).unwrap_or_default();
assert!((got - wanted).abs() < 1e-6, "linked flame {got} at elapsed {step}, wanted {wanted}");
assert_eq!(scale_of(free), Some(1.0), "the unlinked flame is untouched");
}
app.world_mut().entity_mut(light).insert(Kindle { elapsed: 0.2 });
app.update();
app.world_mut().entity_mut(light).remove::<Kindle>();
app.update();
app.update();
assert!(
scales(&mut app).iter().all(|(_, s)| *s == 1.0),
"a flame is whole once its light's kindle is gone"
);
}
#[test]
fn a_sized_flame_and_an_unsized_one_do_not_read_alike() {
fn brightness_of(diameter: Option<f32>) -> f32 {
let mut app = dressing_app();
app.world_mut().spawn((
FlameSite {
height: 0.35,
phase: 2.5,
kindle: 0.0,
diameter,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
));
for _ in 0..18 {
app.update();
}
let max_height = app.world().resource::<FlameAssets>().max_height;
unpack_flame_tag(flame_children(&mut app)[0].1, max_height).2
}
assert_ne!(brightness_of(Some(0.34)), brightness_of(None));
}
#[test]
fn a_changed_look_rewrites_the_shared_material() {
let mut app = dressing_app();
let handle = app.world().resource::<FlameAssets>().material.clone();
{
let mut look = app.world_mut().resource_mut::<FlameLook>();
look.color = LinearRgba::rgb(0.2, 0.9, 0.4);
look.strength = 0.5;
}
app.update();
let material = app
.world()
.resource::<Assets<FlameMaterial>>()
.get(&handle)
.cloned()
.unwrap_or_else(|| panic!("the shared flame material must exist"));
assert!((material.base.red - 0.2).abs() < 1e-6, "{:?}", material.base);
assert!((material.base.green - 0.9).abs() < 1e-6, "{:?}", material.base);
assert!((material.base.alpha - 0.5).abs() < 1e-6, "{:?}", material.base);
assert!((material.params.w - 0.5).abs() < 1e-6, "{:?}", material.params);
}
#[test]
fn a_site_that_asks_for_the_depth_fade_is_drawn_with_the_material_that_has_it() {
fn fade_of(app: &mut App, site: Entity) -> f32 {
let flame = app
.world()
.get::<Children>(site)
.and_then(|c| c.first().copied())
.expect("a dressed site has a flame");
let handle = app
.world()
.get::<MeshMaterial3d<FlameMaterial>>(flame)
.expect("a flame has a material")
.0
.clone();
app.world()
.resource::<Assets<FlameMaterial>>()
.get(&handle)
.expect("the material exists")
.params
.z
}
let mut app = dressing_app();
let site = |depth_fade| FlameSite {
height: 0.9,
phase: 0.0,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade,
fire: None,
};
let plain = app.world_mut().spawn(site(false)).id();
let faded = app.world_mut().spawn(site(true)).id();
app.update();
assert_eq!(fade_of(&mut app, plain), 0.0);
assert_eq!(fade_of(&mut app, faded), 1.0);
app.world_mut().get_mut::<FlameSite>(plain).expect("the site").depth_fade = true;
app.world_mut().get_mut::<FlameSite>(faded).expect("the site").depth_fade = false;
app.update();
assert_eq!(fade_of(&mut app, plain), 1.0);
assert_eq!(fade_of(&mut app, faded), 0.0);
app.world_mut().resource_mut::<FlameLook>().strength = 0.5;
app.update();
for site in [plain, faded] {
let flame = app.world().get::<Children>(site).and_then(|c| c.first().copied()).expect("a flame");
let handle =
app.world().get::<MeshMaterial3d<FlameMaterial>>(flame).expect("a material").0.clone();
let material =
app.world().resource::<Assets<FlameMaterial>>().get(&handle).expect("exists").clone();
assert!((material.params.w - 0.5).abs() < 1e-6, "{:?}", material.params);
}
}
#[test]
fn a_site_on_a_prop_keeps_its_flame_at_the_site_and_leaves_with_the_prop() {
let mut app = dressing_app();
let prop = app.world_mut().spawn(Transform::from_xyz(4.0, 0.0, 0.0)).id();
let site = app
.world_mut()
.spawn((
FlameSite {
height: 0.35,
phase: 0.7,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::from_xyz(0.0, 1.5, 0.0),
ChildOf(prop),
))
.id();
app.update();
let flames = flame_children(&mut app);
assert_eq!(flames.len(), 1, "one flame: {flames:?}");
assert_eq!(flames[0].0, site, "the flame is the site's child, wherever the site stands");
let local = app
.world_mut()
.query_filtered::<&Transform, With<Flame>>()
.single(app.world())
.copied()
.unwrap_or_default();
assert_eq!(local.translation, Vec3::ZERO, "the flame's foot is the site's origin");
app.world_mut().despawn(prop);
app.update();
assert!(flame_children(&mut app).is_empty(), "a prop that goes takes its flame with it");
}
#[test]
fn a_flame_names_the_prop_its_site_hangs_on_and_follows_a_reparent() {
let site_of = || FlameSite {
height: 0.35,
phase: 0.0,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
};
let mut app = dressing_app();
let torch = app.world_mut().spawn(Transform::default()).id();
let other = app.world_mut().spawn(Transform::default()).id();
let on_torch = app.world_mut().spawn((site_of(), ChildOf(torch))).id();
let on_root = app.world_mut().spawn(site_of()).id();
let nested = app.world_mut().spawn((site_of(), ChildOf(on_root))).id();
app.update();
let source_of = |app: &mut App, site: Entity| -> Option<Option<Entity>> {
app.world_mut()
.query::<(&Flame, &ChildOf)>()
.iter(app.world())
.find(|(_, parent)| parent.parent() == site)
.map(|(flame, _)| flame.source)
};
assert_eq!(source_of(&mut app, on_torch), Some(Some(torch)));
assert_eq!(source_of(&mut app, on_root), Some(None), "a root site belongs to no prop");
assert_eq!(source_of(&mut app, nested), Some(None), "a site under a site belongs to the outer site");
app.world_mut().entity_mut(on_torch).insert(ChildOf(other));
app.update();
assert_eq!(source_of(&mut app, on_torch), Some(Some(other)), "a reparented site is followed");
app.world_mut().entity_mut(on_torch).remove::<ChildOf>();
app.update();
assert_eq!(source_of(&mut app, on_torch), Some(None), "an unparented site has no source");
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod kindle_tests {
use super::*;
use crate::KINDLE_SECONDS;
use crate::model::FLICKER_DEPTH;
use bevy::time::TimeUpdateStrategy;
fn app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(Assets::<Mesh>::default())
.insert_resource(Assets::<FlameMaterial>::default())
.init_resource::<FlameLook>()
.init_resource::<FlameAssets>()
.insert_resource(TimeUpdateStrategy::ManualDuration(std::time::Duration::from_secs_f64(
1.0 / 60.0,
)));
app.add_systems(Startup, build_flame_assets)
.add_systems(Update, (dress_flames, update_flames).chain());
app.update();
app
}
fn scale_of(app: &mut App) -> f32 {
app.world_mut()
.query_filtered::<&Transform, With<Flame>>()
.iter(app.world())
.next()
.map(|transform| transform.scale.x)
.unwrap_or(f32::NAN)
}
#[test]
fn a_flame_lit_with_its_fire_grows_from_nothing_and_stops_at_full() {
let mut app = app();
let site = FlameSite {
height: 0.9,
phase: 0.0,
kindle: KINDLE_SECONDS,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
};
app.world_mut().spawn((site, Transform::default()));
app.update();
app.update();
let early = scale_of(&mut app);
assert!(early < 0.2, "a flame that has just caught is tiny: {early}");
for _ in 0..((KINDLE_SECONDS / 2.0 * 60.0) as usize) {
app.update();
}
let middle = scale_of(&mut app);
assert!(middle > early && middle < 1.05, "the flame is still growing at half time: {middle}");
for _ in 0..((KINDLE_SECONDS * 60.0) as usize) {
app.update();
}
let settled = scale_of(&mut app);
assert!((settled - 1.0).abs() < 1e-5, "a caught flame is full size and stays there: {settled}");
}
#[test]
fn a_flame_that_has_been_burning_all_along_is_full_size_at_once() {
let mut app = app();
app.world_mut().spawn((
FlameSite {
height: 0.9,
phase: 0.0,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
));
app.update();
app.update();
assert_eq!(scale_of(&mut app), 1.0);
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod catch_colour_tests {
use super::*;
use crate::KINDLE_SECONDS;
use crate::model::FLICKER_DEPTH;
use bevy::time::TimeUpdateStrategy;
fn app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(Assets::<Mesh>::default())
.insert_resource(Assets::<FlameMaterial>::default())
.init_resource::<FlameLook>()
.init_resource::<FlameAssets>()
.insert_resource(TimeUpdateStrategy::ManualDuration(std::time::Duration::from_secs_f64(
1.0 / 60.0,
)));
app.add_systems(Startup, build_flame_assets)
.add_systems(Update, (dress_flames, update_flames).chain());
app.update();
app
}
fn scales(app: &mut App) -> Vec<f32> {
let mut found: Vec<f32> = app
.world_mut()
.query_filtered::<&Transform, With<Flame>>()
.iter(app.world())
.map(|transform| transform.scale.x)
.collect();
found.sort_by(|a, b| a.total_cmp(b));
found
}
#[test]
fn a_catching_flame_carries_its_own_progress_for_the_colour() {
let mut app = app();
app.world_mut().spawn((
FlameSite {
height: 0.9,
phase: 0.0,
kindle: KINDLE_SECONDS,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
));
app.update();
app.update();
let early = scales(&mut app)[0];
assert!(early < 0.2, "a catching flame reports a small catch: {early}");
for _ in 0..((KINDLE_SECONDS * 60.0) as usize + 2) {
app.update();
}
let settled = scales(&mut app)[0];
assert!((settled - 1.0).abs() < 1e-5, "and exactly 1 once it has taken: {settled}");
}
#[test]
fn one_fire_catching_does_not_tint_another() {
let mut app = app();
app.world_mut().spawn((
FlameSite {
height: 0.9,
phase: 0.0,
kindle: KINDLE_SECONDS,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::default(),
));
app.world_mut().spawn((
FlameSite {
height: 0.9,
phase: 1.3,
kindle: 0.0,
diameter: None,
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
},
Transform::from_xyz(5.0, 0.0, 0.0),
));
app.update();
app.update();
let catching = scales(&mut app);
assert_eq!(catching.len(), 2, "two flames: {catching:?}");
assert!(catching[0] < 0.2, "the catching one is small: {catching:?}");
assert!((catching[1] - 1.0).abs() < 1e-5, "the steady one is untouched: {catching:?}");
}
}