Skip to main content

lunar_render/
camera_follow.rs

1//! camera follow system for 2d games.
2//!
3//! insert a [`CameraFollow2d`] resource to make the camera track a target
4//! entity. the system runs in PostUpdate, after transforms propagate, so it
5//! always reads the target's position for the current frame.
6//!
7//! # example
8//!
9//! ```ignore
10//! use lunar_render::{Camera, CameraFollow2d};
11//! use lunar_math::{Vec2, Rect};
12//!
13//! // follow entity `player`, no lead, small deadzone, bounded to the level
14//! commands.insert_resource(CameraFollow2d {
15//!     target: player,
16//!     lead: Vec2::ZERO,
17//!     deadzone: Vec2::new(32.0, 24.0),
18//!     bounds: Some(Rect::new(0.0, 0.0, 3200.0, 1800.0)),
19//!     lerp_speed: 8.0,
20//! });
21//! ```
22
23use bevy_ecs::prelude::*;
24use lunar_math::{Rect, Vec2, WorldTransform};
25
26use crate::Camera;
27
28/// drives the camera to track a target entity each frame.
29///
30/// all fields are public so game code can tweak them at runtime (e.g. widen
31/// deadzone during a cutscene or remove bounds when zooming out to a world map).
32#[derive(Resource)]
33pub struct CameraFollow2d {
34	/// entity to follow — must have a [`WorldTransform`] component
35	pub target: Entity,
36	/// world-space offset added to the target position before tracking.
37	/// positive X leads right, positive Y leads down (matches screen Y)
38	pub lead: Vec2,
39	/// half-extents of the dead zone in world space.
40	/// the camera does not move while the target stays within this box.
41	/// set to `Vec2::ZERO` to always track exactly
42	pub deadzone: Vec2,
43	/// world-space rectangle the camera position is clamped to after tracking.
44	/// `None` means unbounded
45	pub bounds: Option<Rect>,
46	/// lerp speed in units per second (0.0 = snap immediately, higher = smoother)
47	pub lerp_speed: f32,
48}
49
50pub(crate) fn camera_follow_system(
51	follow: Option<Res<CameraFollow2d>>,
52	mut camera: Option<ResMut<Camera>>,
53	transforms: Query<&WorldTransform>,
54	time: Res<lunar_core::Time>,
55) {
56	let (Some(follow), Some(camera)) = (follow, camera.as_mut()) else {
57		return;
58	};
59	let Ok(target_transform) = transforms.get(follow.target) else {
60		return;
61	};
62
63	let desired = target_transform.translation + follow.lead;
64	let delta = desired - camera.position;
65
66	// skip update if target is inside the deadzone
67	if delta.x.abs() <= follow.deadzone.x && delta.y.abs() <= follow.deadzone.y {
68		return;
69	}
70
71	let new_position = if follow.lerp_speed <= 0.0 {
72		desired
73	} else {
74		let t = (follow.lerp_speed * time.delta_seconds()).min(1.0);
75		camera.position + delta * t
76	};
77
78	camera.position = match follow.bounds {
79		None => new_position,
80		Some(bounds) => Vec2::new(
81			new_position.x.clamp(bounds.x, bounds.x + bounds.w),
82			new_position.y.clamp(bounds.y, bounds.y + bounds.h),
83		),
84	};
85}
86
87#[cfg(test)]
88mod tests {
89	use super::*;
90	use lunar_core::Time;
91
92	fn make_world_with_target(position: Vec2) -> (World, Entity) {
93		let mut world = World::new();
94		let entity = world
95			.spawn(WorldTransform::from_xy(position.x, position.y))
96			.id();
97		world.insert_resource(Time::default());
98		(world, entity)
99	}
100
101	#[test]
102	fn snaps_to_target_with_zero_lerp() {
103		let (mut world, target) = make_world_with_target(Vec2::new(200.0, 100.0));
104		world.insert_resource(Camera::new());
105		world.insert_resource(CameraFollow2d {
106			target,
107			lead: Vec2::ZERO,
108			deadzone: Vec2::ZERO,
109			bounds: None,
110			lerp_speed: 0.0,
111		});
112
113		let mut system = IntoSystem::into_system(camera_follow_system);
114		system.initialize(&mut world);
115		let _ = system.run((), &mut world);
116
117		let camera = world.resource::<Camera>();
118		assert!((camera.position.x - 200.0).abs() < 0.001);
119		assert!((camera.position.y - 100.0).abs() < 0.001);
120	}
121
122	#[test]
123	fn deadzone_prevents_movement() {
124		let (mut world, target) = make_world_with_target(Vec2::new(10.0, 5.0));
125		world.insert_resource(Camera::new()); // camera at origin
126		world.insert_resource(CameraFollow2d {
127			target,
128			lead: Vec2::ZERO,
129			deadzone: Vec2::new(50.0, 50.0),
130			bounds: None,
131			lerp_speed: 0.0,
132		});
133
134		let mut system = IntoSystem::into_system(camera_follow_system);
135		system.initialize(&mut world);
136		let _ = system.run((), &mut world);
137
138		let camera = world.resource::<Camera>();
139		// target (10, 5) is inside deadzone (50, 50) — camera should not move
140		assert!((camera.position.x - 0.0).abs() < 0.001);
141		assert!((camera.position.y - 0.0).abs() < 0.001);
142	}
143
144	#[test]
145	fn bounds_clamp_camera_position() {
146		let (mut world, target) = make_world_with_target(Vec2::new(9999.0, 9999.0));
147		world.insert_resource(Camera::new());
148		world.insert_resource(CameraFollow2d {
149			target,
150			lead: Vec2::ZERO,
151			deadzone: Vec2::ZERO,
152			bounds: Some(Rect::new(0.0, 0.0, 800.0, 600.0)),
153			lerp_speed: 0.0,
154		});
155
156		let mut system = IntoSystem::into_system(camera_follow_system);
157		system.initialize(&mut world);
158		let _ = system.run((), &mut world);
159
160		let camera = world.resource::<Camera>();
161		assert!((camera.position.x - 800.0).abs() < 0.001);
162		assert!((camera.position.y - 600.0).abs() < 0.001);
163	}
164
165	#[test]
166	fn lead_offset_applied() {
167		let (mut world, target) = make_world_with_target(Vec2::new(100.0, 100.0));
168		world.insert_resource(Camera::new());
169		world.insert_resource(CameraFollow2d {
170			target,
171			lead: Vec2::new(50.0, 0.0),
172			deadzone: Vec2::ZERO,
173			bounds: None,
174			lerp_speed: 0.0,
175		});
176
177		let mut system = IntoSystem::into_system(camera_follow_system);
178		system.initialize(&mut world);
179		let _ = system.run((), &mut world);
180
181		let camera = world.resource::<Camera>();
182		// desired = (100 + 50, 100 + 0) = (150, 100)
183		assert!((camera.position.x - 150.0).abs() < 0.001);
184		assert!((camera.position.y - 100.0).abs() < 0.001);
185	}
186}