use bevy::prelude::*;
use crate::flame::{Flame, FlameAssets, FlameLook, FlameMaterial, FlameSite, Flamed, flame_parts};
use crate::model::{MAX_TONGUES, flame_tongue};
#[derive(Component, Clone, Copy, Debug, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(default))]
#[reflect(Component, Default)]
pub struct FlameTongues {
pub count: u8,
pub spread: f32,
}
impl Default for FlameTongues {
fn default() -> Self {
Self { count: 5, spread: 0.5 }
}
}
impl FlameTongues {
pub fn problems(&self) -> Vec<crate::Problem> {
let mut out = Vec::new();
crate::model::problem::within(&mut out, "count", f32::from(self.count), 1.0, f32::from(MAX_TONGUES));
crate::model::problem::positive(&mut out, "spread", self.spread);
out
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Reflect)]
#[reflect(Component)]
pub struct FlameTongue {
pub index: u8,
pub lean: Vec2,
}
#[derive(Component, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Tongued {
count: u8,
spread: u32,
}
type TonguedSite =
(Entity, &'static FlameSite, &'static FlameTongues, Option<&'static Tongued>, Option<&'static Children>);
#[allow(clippy::too_many_arguments)]
pub(crate) fn dress_tongues(
mut commands: Commands,
sites: Query<TonguedSite, With<Flamed>>,
mut removed: RemovedComponents<FlameTongues>,
children: Query<&Children>,
tongues: Query<(), With<FlameTongue>>,
middles: Query<&Flame, Without<FlameTongue>>,
mut assets: ResMut<FlameAssets>,
mut materials: ResMut<Assets<FlameMaterial>>,
look: Res<FlameLook>,
time: Res<Time>,
) {
for site in removed.read() {
let Ok(kids) = children.get(site) else { continue };
for kid in kids.iter().filter(|kid| tongues.contains(*kid)) {
commands.entity(kid).try_despawn();
}
commands.entity(site).try_remove::<Tongued>();
}
if !assets.built() {
return;
}
let t = time.elapsed_secs();
for (entity, site, wanted, built, kids) in &sites {
let count = if wanted.spread.is_finite() && wanted.spread > 0.0 {
wanted.count.clamp(1, MAX_TONGUES)
} else {
1
};
let now = Tongued { count, spread: wanted.spread.to_bits() };
if built == Some(&now) {
continue;
}
let kids: &[Entity] = kids.map_or(&[], |kids| &kids[..]);
for kid in kids.iter().filter(|kid| tongues.contains(**kid)) {
commands.entity(*kid).try_despawn();
}
let kindled =
kids.iter().find_map(|kid| middles.get(*kid).ok()).map_or(f32::MAX, |middle| middle.kindled);
let mut extras = Vec::with_capacity(usize::from(count));
for index in 1..count {
let tongue = flame_tongue(index, count, wanted.spread, site.height, site.phase);
let offset = Vec3::new(tongue.foot[0], 0.0, tongue.foot[1]);
let parts = flame_parts(
site,
tongue.height,
site.phase + tongue.phase,
kindled,
offset,
&mut assets,
&mut materials,
&look,
t,
);
extras.push((
Name::new("Flame tongue"),
FlameTongue { index, lean: Vec2::from(tongue.lean) },
parts,
));
}
commands.entity(entity).queue_silenced(move |mut site: EntityWorldMut| {
site.insert(now).with_children(|parent| {
for extra in extras {
parent.spawn(extra);
}
});
});
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use crate::flame::{build_flame_assets, dress_flames, update_flames};
use crate::model::{FLICKER_DEPTH, MERGE_RATIO, unpack_flame_tag};
use bevy::mesh::MeshTag;
fn tongue_app() -> App {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::transform::TransformPlugin))
.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, dress_tongues, update_flames).chain());
app.update();
app
}
fn site(height: f32, phase: f32) -> FlameSite {
FlameSite {
color: None,
height,
phase,
kindle: 0.0,
diameter: Some(0.8),
depth: FLICKER_DEPTH,
depth_fade: false,
fire: None,
}
}
fn flames_of(app: &mut App, site: Entity) -> Vec<(Entity, bool, Transform, f32)> {
let max_height = app.world().resource::<FlameAssets>().max_height();
let mut found: Vec<_> = app
.world_mut()
.query_filtered::<(Entity, &ChildOf, &Transform, &MeshTag, Has<FlameTongue>), With<Flame>>()
.iter(app.world())
.filter(|(_, parent, ..)| parent.parent() == site)
.map(|(entity, _, at, tag, tongue)| {
(entity, tongue, *at, unpack_flame_tag(tag.value, max_height).1)
})
.collect();
found.sort_by_key(|(_, tongue, ..)| *tongue);
found
}
#[test]
fn a_site_with_tongues_has_that_many_flames_and_the_middle_one_is_its_own() {
let mut app = tongue_app();
let at = app
.world_mut()
.spawn((site(0.7, 0.4), FlameTongues { count: 5, spread: 0.5 }, Transform::default()))
.id();
app.update();
app.update();
let flames = flames_of(&mut app, at);
assert_eq!(flames.len(), 5, "five flames");
assert!(!flames[0].1, "the first is the site's own, not a tongue");
assert_eq!(flames[0].2.translation, Vec3::ZERO);
assert!((flames[0].3 - 0.7).abs() < 1e-3);
assert!(flames[1..].iter().all(|(_, tongue, ..)| *tongue));
for (_, _, at, height) in &flames[1..] {
let distance = at.translation.x.hypot(at.translation.z);
assert!(distance <= 0.25 + 1e-5 && distance > 0.0, "a foot on the bed: {distance}");
assert_eq!(at.translation.y, 0.0, "a foot on the bed's plane");
assert!(*height < 0.7 && *height <= MERGE_RATIO * distance + 1e-3, "{height} at {distance}");
}
}
#[test]
fn a_second_frame_does_not_twin_the_tongues_and_a_site_without_the_component_has_one_flame() {
let mut app = tongue_app();
let wide = app.world_mut().spawn((site(0.7, 0.4), FlameTongues { count: 3, spread: 0.5 })).id();
let plain = app.world_mut().spawn(site(0.35, 1.0)).id();
for _ in 0..4 {
app.update();
}
assert_eq!(flames_of(&mut app, wide).len(), 3);
assert_eq!(flames_of(&mut app, plain).len(), 1);
}
#[test]
fn changing_the_component_rebuilds_the_tongues_and_removing_it_takes_them_away() {
let mut app = tongue_app();
let at = app.world_mut().spawn((site(0.7, 0.4), FlameTongues { count: 5, spread: 0.5 })).id();
app.update();
app.update();
assert_eq!(flames_of(&mut app, at).len(), 5);
let before: Vec<_> = flames_of(&mut app, at).iter().map(|f| f.2.translation).collect();
app.world_mut().entity_mut(at).get_mut::<FlameTongues>().unwrap().count = 3;
app.update();
app.update();
assert_eq!(flames_of(&mut app, at).len(), 3, "two tongues fewer");
app.world_mut().entity_mut(at).get_mut::<FlameTongues>().unwrap().spread = 1.0;
app.update();
app.update();
let wider = flames_of(&mut app, at);
assert_eq!(wider.len(), 3, "a wider bed keeps the count");
assert!(
wider[1..].iter().any(|f| f.2.translation.x.hypot(f.2.translation.z) > 0.25),
"and puts a foot beyond the old bed"
);
assert_ne!(before[1], wider[1].2.translation);
app.world_mut().entity_mut(at).remove::<FlameTongues>();
app.update();
app.update();
let one = flames_of(&mut app, at);
assert_eq!(one.len(), 1, "back to the site's own flame");
assert!(!one[0].1);
}
#[test]
fn an_unchanged_fire_does_not_rebuild_its_tongues() {
let mut app = tongue_app();
let at = app.world_mut().spawn((site(0.7, 0.4), FlameTongues::default())).id();
app.update();
app.update();
let first: Vec<Entity> = flames_of(&mut app, at).iter().map(|f| f.0).collect();
app.world_mut().entity_mut(at).get_mut::<FlameTongues>().unwrap().spread = 0.5;
app.update();
app.update();
assert_eq!(
flames_of(&mut app, at).iter().map(|f| f.0).collect::<Vec<_>>(),
first,
"the same entities"
);
}
#[test]
fn a_spread_that_is_not_a_number_is_the_middle_flame_alone_and_is_not_rebuilt_every_frame() {
let mut app = tongue_app();
let at = app.world_mut().spawn((site(0.7, 0.4), FlameTongues { count: 5, spread: f32::NAN })).id();
for _ in 0..3 {
app.update();
}
let flames = flames_of(&mut app, at);
assert_eq!(flames.len(), 1, "no bed, no tongues");
app.world_mut().entity_mut(at).get_mut::<FlameTongues>().unwrap().spread = 0.5;
app.update();
app.update();
let grown: Vec<Entity> = flames_of(&mut app, at).iter().map(|f| f.0).collect();
assert_eq!(grown.len(), 5);
app.update();
assert_eq!(flames_of(&mut app, at).iter().map(|f| f.0).collect::<Vec<_>>(), grown);
}
#[test]
fn two_fires_do_not_lay_their_tongues_alike() {
let mut app = tongue_app();
let one = app.world_mut().spawn((site(0.7, 0.4), FlameTongues::default())).id();
let other = app.world_mut().spawn((site(0.7, 2.2), FlameTongues::default())).id();
app.update();
app.update();
let feet = |app: &mut App, site| -> Vec<Vec3> {
flames_of(app, site).iter().map(|f| f.2.translation).collect()
};
assert_ne!(feet(&mut app, one), feet(&mut app, other));
}
#[test]
fn the_tongues_are_all_one_fires_catch() {
let mut app = tongue_app();
let at = app
.world_mut()
.spawn((FlameSite { kindle: 0.6, ..site(0.7, 0.4) }, FlameTongues { count: 4, spread: 0.5 }))
.id();
app.update();
app.update();
let flames = flames_of(&mut app, at);
assert_eq!(flames.len(), 4);
let scale = flames[0].2.scale.x;
assert!(scale < 1.0, "the fire is still catching: {scale}");
for flame in &flames[1..] {
assert!((flame.2.scale.x - scale).abs() < 1e-6, "every tongue catches with the fire: {flame:?}");
}
}
fn lean_of(at: &Transform) -> Vec2 {
Vec2::new(at.scale.y - 1.0, at.scale.z - 1.0)
}
#[test]
fn the_outer_tongues_lean_in_towards_the_middle_and_the_middle_one_stands_upright() {
let mut app = tongue_app();
let at = app.world_mut().spawn((site(0.7, 0.4), FlameTongues { count: 6, spread: 0.6 })).id();
for _ in 0..3 {
app.update();
}
let flames = flames_of(&mut app, at);
assert_eq!(flames.len(), 6);
assert_eq!(lean_of(&flames[0].2), Vec2::ZERO, "the site's own flame is upright in still air");
for (_, _, at, _) in &flames[1..] {
let foot = Vec2::new(at.translation.x, at.translation.z);
let lean = lean_of(at);
assert!(
lean.length() > 0.0 && lean.length() <= crate::model::tongue::INWARD_LEAN + 1e-5,
"{lean}"
);
assert!(lean.dot(foot) < 0.0, "leans in: foot {foot}, lean {lean}");
assert!(lean.perp_dot(foot).abs() < 1e-5, "straight at the middle: foot {foot}, lean {lean}");
}
}
#[test]
fn a_site_turned_about_the_vertical_leans_its_tongues_at_its_own_middle() {
let mut app = tongue_app();
let at = app
.world_mut()
.spawn((
site(0.7, 0.4),
FlameTongues { count: 6, spread: 0.6 },
Transform::from_xyz(3.0, 0.0, -2.0).with_rotation(Quat::from_rotation_y(2.0)),
))
.id();
for _ in 0..3 {
app.update();
}
let turn = Quat::from_rotation_y(2.0);
for (_, _, local, _) in &flames_of(&mut app, at)[1..] {
let world_foot = turn * local.translation;
let foot = Vec2::new(world_foot.x, world_foot.z);
let lean = lean_of(local);
assert!(lean.dot(foot) < 0.0, "leans in, in the world's axes: foot {foot}, lean {lean}");
assert!(lean.perp_dot(foot).abs() < 1e-4, "straight at the middle: foot {foot}, lean {lean}");
}
}
#[test]
fn a_gale_and_the_inward_lean_together_stay_inside_the_tilt_a_wind_alone_is_capped_at() {
let mut app = tongue_app();
app.insert_resource(crate::FlameWind { velocity: Vec2::new(40.0, 0.0) });
let at = app.world_mut().spawn((site(0.7, 0.4), FlameTongues { count: 6, spread: 0.6 })).id();
for _ in 0..3 {
app.update();
}
let cap = crate::model::FLAME_TILT_LIMIT.sin() + 1e-5;
let flames = flames_of(&mut app, at);
assert!(lean_of(&flames[0].2).length() > 0.9, "the gale lays the middle flame over");
for (_, _, at, _) in &flames {
assert!(lean_of(at).length() <= cap, "{}", lean_of(at));
}
}
#[test]
fn a_bad_count_or_spread_is_reported_and_a_good_one_is_not() {
assert!(FlameTongues::default().problems().is_empty());
assert!(FlameTongues { count: 1, spread: 0.1 }.problems().is_empty());
let fields = |t: FlameTongues| t.problems().iter().map(|p| p.field).collect::<Vec<_>>();
assert_eq!(fields(FlameTongues { count: 0, spread: 0.5 }), ["count"]);
assert_eq!(fields(FlameTongues { count: MAX_TONGUES + 1, spread: 0.5 }), ["count"]);
assert_eq!(fields(FlameTongues { count: 3, spread: 0.0 }), ["spread"]);
assert_eq!(fields(FlameTongues { count: 3, spread: f32::NAN }), ["spread"]);
}
}