Skip to main content

lunar_render/
screen_shake.rs

1//! screen shake via trauma²-mapped camera offset.
2//!
3//! insert a [`ScreenShake`] resource and add trauma to trigger shaking.
4//! the system decays trauma each frame and applies a noise-derived offset
5//! to the camera position. offset magnitude scales as trauma² so small
6//! trauma values produce subtle movement.
7//!
8//! # example
9//!
10//! ```ignore
11//! use lunar_render::ScreenShake;
12//! use lunar_math::Vec2;
13//!
14//! fn on_explosion(mut shake: ResMut<ScreenShake>) {
15//!     shake.add_trauma(0.6); // medium hit
16//! }
17//!
18//! // register once in your setup
19//! commands.insert_resource(ScreenShake::new(Vec2::new(20.0, 12.0), 1.5));
20//! ```
21
22use bevy_ecs::prelude::*;
23use lunar_math::Vec2;
24
25use crate::Camera;
26
27/// camera shake state. insert as a resource to enable shaking.
28#[derive(Resource)]
29pub struct ScreenShake {
30	/// current trauma in [0, 1]. add via [`ScreenShake::add_trauma`]
31	pub trauma: f32,
32	/// trauma decay per second (e.g. 1.5 clears full trauma in ~0.67s)
33	pub decay_rate: f32,
34	/// maximum pixel offset at trauma == 1.0
35	pub max_offset: Vec2,
36	/// accumulated time used to animate the noise function
37	elapsed: f32,
38}
39
40impl ScreenShake {
41	/// create with given max offset and decay rate. trauma starts at zero
42	#[must_use]
43	pub fn new(max_offset: Vec2, decay_rate: f32) -> Self {
44		Self {
45			trauma: 0.0,
46			decay_rate,
47			max_offset,
48			elapsed: 0.0,
49		}
50	}
51
52	/// add trauma, clamped to [0, 1]
53	pub fn add_trauma(&mut self, amount: f32) {
54		self.trauma = (self.trauma + amount).min(1.0);
55	}
56}
57
58pub(crate) fn screen_shake_system(
59	mut shake: Option<ResMut<ScreenShake>>,
60	mut camera: Option<ResMut<Camera>>,
61	time: Res<lunar_core::Time>,
62) {
63	let (Some(shake), Some(camera)) = (shake.as_mut(), camera.as_mut()) else {
64		return;
65	};
66	if shake.trauma <= 0.0 {
67		return;
68	}
69
70	let delta = time.delta_seconds();
71	shake.elapsed += delta;
72	shake.trauma = (shake.trauma - shake.decay_rate * delta).max(0.0);
73
74	// trauma² maps [0,1] -> [0,1] with a soft curve: small trauma = subtle shake
75	let intensity = shake.trauma * shake.trauma;
76	let t = shake.elapsed;
77
78	// two-axis noise from sin harmonics — no extra deps, deterministic
79	let noise_x = (t * 13.7).sin() * 0.6 + (t * 29.3).sin() * 0.3 + (t * 53.1).sin() * 0.1;
80	let noise_y = (t * 11.3).sin() * 0.6 + (t * 31.7).sin() * 0.3 + (t * 47.9).sin() * 0.1;
81
82	camera.position.x += noise_x * intensity * shake.max_offset.x;
83	camera.position.y += noise_y * intensity * shake.max_offset.y;
84}
85
86#[cfg(test)]
87mod tests {
88	use super::*;
89	use lunar_core::Time;
90
91	fn run_system(world: &mut World) {
92		let mut system = IntoSystem::into_system(screen_shake_system);
93		system.initialize(world);
94		let _ = system.run((), world);
95	}
96
97	#[test]
98	fn no_shake_when_trauma_zero() {
99		let mut world = World::new();
100		world.insert_resource(Camera::new());
101		world.insert_resource(Time::default());
102		world.insert_resource(ScreenShake::new(Vec2::new(20.0, 20.0), 1.0));
103
104		run_system(&mut world);
105
106		let camera = world.resource::<Camera>();
107		assert!((camera.position.x - 0.0).abs() < 0.001);
108		assert!((camera.position.y - 0.0).abs() < 0.001);
109	}
110
111	#[test]
112	fn trauma_decays_each_frame() {
113		let mut world = World::new();
114		world.insert_resource(Camera::new());
115
116		let mut time = Time::default();
117		time.set_delta_seconds(0.1);
118		world.insert_resource(time);
119
120		let mut shake = ScreenShake::new(Vec2::new(10.0, 10.0), 2.0);
121		shake.add_trauma(1.0);
122		world.insert_resource(shake);
123
124		run_system(&mut world);
125
126		let shake = world.resource::<ScreenShake>();
127		// 1.0 - 2.0 * 0.1 = 0.8
128		assert!((shake.trauma - 0.8).abs() < 0.01);
129	}
130
131	#[test]
132	fn trauma_clamps_to_one() {
133		let mut shake = ScreenShake::new(Vec2::ZERO, 1.0);
134		shake.add_trauma(0.7);
135		shake.add_trauma(0.7);
136		assert!((shake.trauma - 1.0).abs() < 0.001);
137	}
138
139	#[test]
140	fn trauma_does_not_go_negative() {
141		let mut world = World::new();
142		world.insert_resource(Camera::new());
143
144		let mut time = Time::default();
145		time.set_delta_seconds(10.0); // huge delta
146		world.insert_resource(time);
147
148		let mut shake = ScreenShake::new(Vec2::new(10.0, 10.0), 1.0);
149		shake.add_trauma(0.1);
150		world.insert_resource(shake);
151
152		run_system(&mut world);
153
154		let shake = world.resource::<ScreenShake>();
155		assert!(shake.trauma >= 0.0);
156	}
157}