use bevy::prelude::*;
use crate::FlameSite;
use crate::model::{MOTION_RESPONSE, smooth_velocity};
#[derive(Resource, Clone, Copy, Debug, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(default))]
#[reflect(Resource, Default)]
pub struct FlameMotion {
pub gain: f32,
pub response: f32,
}
impl Default for FlameMotion {
fn default() -> Self {
Self { gain: 1.0, response: MOTION_RESPONSE }
}
}
impl FlameMotion {
pub fn problems(&self) -> Vec<crate::Problem> {
use crate::model::problem::{non_negative, positive};
let mut out = Vec::new();
non_negative(&mut out, "gain", self.gain);
positive(&mut out, "response", self.response);
out
}
}
#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Reflect)]
#[reflect(Component, Default)]
pub struct FlameVelocity {
pub ground: Vec2,
last: Option<Vec3>,
seen: u8,
}
#[derive(Resource, Debug, Default)]
pub(crate) struct MovingSites(pub(crate) usize);
pub(crate) fn track_flame_velocity(
time: Res<Time>,
motion: Option<Res<FlameMotion>>,
mut moving: ResMut<MovingSites>,
mut sites: Query<(&GlobalTransform, &mut FlameVelocity), With<FlameSite>>,
) {
let dt = time.delta_secs();
if dt <= 0.0 {
return;
}
let response = motion.as_deref().map_or(MOTION_RESPONSE, |m| m.response);
let mut count = 0;
for (global, mut velocity) in &mut sites {
let here = global.translation();
if velocity.seen >= 2 && velocity.ground == Vec2::ZERO && velocity.last == Some(here) {
continue;
}
if velocity.seen < 2 {
velocity.seen += 1;
velocity.last = Some(here);
continue;
}
let step = velocity.last.map_or([0.0, 0.0], |last| [here.x - last.x, here.z - last.z]);
let previous = velocity.ground.to_array();
let next = Vec2::from(smooth_velocity(previous, step, dt, response));
if next != velocity.ground {
velocity.ground = next;
}
if velocity.last != Some(here) {
velocity.last = Some(here);
}
if next != Vec2::ZERO {
count += 1;
}
}
if moving.0 != count {
moving.0 = count;
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use bevy::time::TimeUpdateStrategy;
use std::time::Duration;
const STEP: f32 = 1.0 / 60.0;
fn site() -> FlameSite {
FlameSite {
height: 0.35,
phase: 0.0,
kindle: 0.0,
diameter: Some(0.1),
depth: crate::FLICKER_DEPTH,
depth_fade: false,
fire: None,
color: None,
}
}
fn tracking_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(bevy::transform::TransformPlugin)
.init_resource::<FlameMotion>()
.init_resource::<MovingSites>()
.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(STEP)))
.add_systems(Update, track_flame_velocity);
app.update();
app
}
fn velocity(app: &App, site: Entity) -> Vec2 {
app.world().get::<FlameVelocity>(site).map(|v| v.ground).unwrap_or(Vec2::NAN)
}
fn walk(app: &mut App, site: Entity, speed: Vec3, frames: usize) {
for _ in 0..frames {
if let Some(mut at) = app.world_mut().get_mut::<Transform>(site) {
at.translation += speed * STEP;
}
app.update();
}
}
#[test]
fn a_site_walked_at_a_pace_feels_that_pace_and_stops_when_it_stops() {
let mut app = tracking_app();
let site = app.world_mut().spawn((site(), Transform::from_xyz(10.0, 0.0, 4.0))).id();
walk(&mut app, site, Vec3::new(1.4, 0.0, -0.7), 60);
let v = velocity(&app, site);
assert!((v - Vec2::new(1.4, -0.7)).length() < 0.02, "{v:?}");
assert_eq!(app.world().resource::<MovingSites>().0, 1);
walk(&mut app, site, Vec3::ZERO, 120);
assert_eq!(velocity(&app, site), Vec2::ZERO, "a stopped fire is a still fire");
assert_eq!(app.world().resource::<MovingSites>().0, 0);
}
#[test]
fn a_site_spawned_away_from_the_origin_is_not_a_site_that_arrived_from_it() {
let mut app = tracking_app();
let site = app.world_mut().spawn((site(), Transform::from_xyz(0.4, 0.0, 0.0))).id();
for _ in 0..30 {
app.update();
assert_eq!(velocity(&app, site), Vec2::ZERO);
}
}
#[test]
fn a_site_carried_by_a_moving_parent_moves_with_it_and_its_own_swing_adds() {
let mut app = tracking_app();
let site = app.world_mut().spawn((site(), Transform::from_xyz(0.0, 1.0, 0.0))).id();
let bearer = app.world_mut().spawn(Transform::from_xyz(3.0, 0.0, 3.0)).add_child(site).id();
walk(&mut app, bearer, Vec3::new(0.0, 0.0, 1.0), 90);
let v = velocity(&app, site);
assert!((v - Vec2::new(0.0, 1.0)).length() < 0.02, "{v:?}");
}
#[test]
fn a_teleport_is_not_a_velocity() {
let mut app = tracking_app();
let site = app.world_mut().spawn((site(), Transform::from_xyz(1.0, 0.0, 1.0))).id();
walk(&mut app, site, Vec3::new(1.0, 0.0, 0.0), 60);
let before = velocity(&app, site);
app.world_mut().get_mut::<Transform>(site).unwrap().translation += Vec3::new(200.0, 0.0, 0.0);
app.update();
app.update();
assert!(
(velocity(&app, site) - before).length() < 0.02,
"a 200 m step in a frame is a respawn: {before:?}"
);
walk(&mut app, site, Vec3::new(1.0, 0.0, 0.0), 30);
assert!((velocity(&app, site).x - 1.0).abs() < 0.05, "and the walk goes on from where it landed");
}
#[test]
fn a_paused_world_holds_the_velocity_it_had() {
let mut app = tracking_app();
let site = app.world_mut().spawn((site(), Transform::default())).id();
walk(&mut app, site, Vec3::X, 60);
let moving = velocity(&app, site);
app.world_mut().resource_mut::<Time<Virtual>>().pause();
for _ in 0..30 {
app.update();
}
assert_eq!(velocity(&app, site), moving);
}
#[test]
fn motion_settings_are_checked_like_the_crates_other_settings() {
assert!(FlameMotion::default().problems().is_empty());
let bad = FlameMotion { gain: -1.0, response: 0.0 };
let fields: Vec<_> = bad.problems().iter().map(|p| p.field).collect();
assert_eq!(fields, ["gain", "response"]);
}
}