1use crate::components::{Camera3D, CameraController, FrameInput, Interactable, Transform};
10use crate::ecs::{Entity, PipelineContext, StepResult, System};
11use std::time::Instant;
12
13const INTERACT_REACH: f32 = 3.0;
15const INTERACT_MIN_DOT: f32 = 0.5;
17
18#[derive(Debug)]
22pub struct Camera3DSystem {
23 free_fly: bool,
24 move_speed: f32,
25 sprint_multiplier: f32,
26 mouse_sensitivity: f32,
27 gamepad_look_sensitivity: f32,
30 player_radius: f32,
31 bounds_min: [f32; 3],
32 bounds_max: [f32; 3],
33 last_step: Option<Instant>,
34 velocity: [f32; 3],
37 interactable_entities: Vec<Entity>,
40 controls_cursor: crate::ecs::EventCursor,
42}
43
44impl Camera3DSystem {
45 pub(crate) fn new(c: CameraController) -> Self {
47 Self {
48 free_fly: c.free_fly,
49 move_speed: c.move_speed,
50 sprint_multiplier: c.sprint_multiplier,
51 mouse_sensitivity: c.mouse_sensitivity,
52 gamepad_look_sensitivity: crate::gfx::settings::DEFAULT_GAMEPAD_LOOK_SENSITIVITY,
53 player_radius: c.player_radius,
54 bounds_min: c.bounds_min,
55 bounds_max: c.bounds_max,
56 last_step: None,
57 velocity: [0.0; 3],
58 interactable_entities: Vec::new(),
59 controls_cursor: crate::ecs::EventCursor::default(),
60 }
61 }
62
63 pub fn reset_velocity(&mut self) {
68 self.velocity = [0.0; 3];
69 }
70}
71
72impl System for Camera3DSystem {
73 fn access(&self) -> crate::ecs::Access {
74 crate::ecs::Access::new()
75 .reads_components(crate::component_mask![crate::components::FrameInput])
76 .writes_components(crate::component_mask![
77 crate::components::Camera3D,
78 crate::components::Transform,
79 ])
80 .reads_resources(crate::resource_mask![
81 crate::ecs::decompose::EntityByName,
82 crate::components::ControlsCommand,
83 ])
84 .writes_resources(crate::resource_mask![crate::components::InteractEvent])
85 }
86
87 fn init(&mut self, ctx: &mut PipelineContext) {
88 self.last_step = Some(Instant::now());
89
90 crate::gfx::look_controls::apply_persisted(
91 ctx,
92 crate::gfx::look_controls::Look {
93 mouse_sensitivity: &mut self.mouse_sensitivity,
94 gamepad_look_sensitivity: &mut self.gamepad_look_sensitivity,
95 },
96 );
97
98 self.interactable_entities = ctx
100 .query_with_entity::<Interactable>()
101 .map(|(entity, _)| entity)
102 .collect();
103
104 let registered = self.interactable_entities.len();
105 if registered > 0 {
106 tracing::debug!(
107 "Camera3DSystem: registered {} interactable prop(s)",
108 registered
109 );
110 }
111 }
112
113 fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
114 let pending_fov = crate::gfx::look_controls::drain_commands(
118 ctx,
119 &mut self.controls_cursor,
120 crate::gfx::look_controls::Look {
121 mouse_sensitivity: &mut self.mouse_sensitivity,
122 gamepad_look_sensitivity: &mut self.gamepad_look_sensitivity,
123 },
124 );
125
126 let input = match ctx.query::<FrameInput>().next().cloned() {
130 Some(i) => i,
131 None => return StepResult::Continue,
134 };
135
136 let now = Instant::now();
137 let dt = self
138 .last_step
139 .map(|t| now.duration_since(t).as_secs_f32().min(0.1))
140 .unwrap_or(0.0);
141 self.last_step = Some(now);
142
143 for camera in ctx.query_mut::<Camera3D>() {
145 if let Some(fov) = pending_fov {
148 camera.fov_y_degrees = fov;
149 }
150 let look_dx = input.mouse_dx * self.mouse_sensitivity
153 + input.look_axis[0] * self.gamepad_look_sensitivity * dt;
154 let look_dy = input.mouse_dy * self.mouse_sensitivity
155 + input.look_axis[1] * self.gamepad_look_sensitivity * dt;
156 camera.yaw -= look_dx;
157 camera.pitch = (camera.pitch - look_dy).clamp(
158 -std::f32::consts::FRAC_PI_2 + 0.01,
159 std::f32::consts::FRAC_PI_2 - 0.01,
160 );
161
162 let speed = if input.sprint {
163 self.move_speed * self.sprint_multiplier
164 } else {
165 self.move_speed
166 };
167
168 let (fwd, right) = if self.free_fly {
174 let cp = camera.pitch.cos();
175 (
176 [
177 -camera.yaw.sin() * cp,
178 camera.pitch.sin(),
179 -camera.yaw.cos() * cp,
180 ],
181 [camera.yaw.cos(), 0.0_f32, -camera.yaw.sin()],
182 )
183 } else {
184 (
185 [-camera.yaw.sin(), 0.0_f32, -camera.yaw.cos()],
186 [camera.yaw.cos(), 0.0_f32, -camera.yaw.sin()],
187 )
188 };
189
190 let mut target = [0.0_f32; 3];
192 if input.forward {
193 target[0] += fwd[0] * speed;
194 target[1] += fwd[1] * speed;
195 target[2] += fwd[2] * speed;
196 }
197 if input.backward {
198 target[0] -= fwd[0] * speed;
199 target[1] -= fwd[1] * speed;
200 target[2] -= fwd[2] * speed;
201 }
202 if input.right {
203 target[0] += right[0] * speed;
204 target[2] += right[2] * speed;
205 }
206 if input.left {
207 target[0] -= right[0] * speed;
208 target[2] -= right[2] * speed;
209 }
210 target[0] += (fwd[0] * input.move_axis[1] + right[0] * input.move_axis[0]) * speed;
213 target[1] += fwd[1] * input.move_axis[1] * speed;
214 target[2] += (fwd[2] * input.move_axis[1] + right[2] * input.move_axis[0]) * speed;
215 if self.free_fly && input.jump {
217 target[1] += speed;
218 }
219
220 let half_life = 0.08_f32; let decay = 1.0 - 2.0_f32.powf(-dt / half_life);
224 self.velocity[0] += (target[0] - self.velocity[0]) * decay;
225 self.velocity[1] += (target[1] - self.velocity[1]) * decay;
226 self.velocity[2] += (target[2] - self.velocity[2]) * decay;
227
228 if self.free_fly {
229 camera.position[0] += self.velocity[0] * dt;
231 camera.position[1] += self.velocity[1] * dt;
232 camera.position[2] += self.velocity[2] * dt;
233 camera.desired_move = [0.0; 3];
234 camera.jump_requested = false;
235 } else {
236 let r = self.player_radius;
240 camera.position[0] =
241 camera.position[0].clamp(self.bounds_min[0] + r, self.bounds_max[0] - r);
242 camera.position[2] =
243 camera.position[2].clamp(self.bounds_min[2] + r, self.bounds_max[2] - r);
244
245 camera.desired_move = self.velocity;
248 camera.jump_requested = input.jump;
249 }
250 camera.interact_requested = input.interact;
251
252 camera.view_matrix =
255 crate::gfx::camera::view_matrix(camera.position, camera.yaw, camera.pitch);
256 }
257
258 if input.interact && !self.interactable_entities.is_empty() {
262 let (cam_pos, cam_yaw) = ctx
263 .query::<Camera3D>()
264 .next()
265 .map(|c| (c.position, c.yaw))
266 .unwrap_or(([0.0; 3], 0.0));
267 let fwd = [-cam_yaw.sin(), 0.0_f32, -cam_yaw.cos()];
268
269 let mut best: Option<(f32, Entity)> = None;
271 for &entity in &self.interactable_entities {
272 if let Some(t) = ctx.get::<Transform>(entity) {
273 let dx = t.position[0] - cam_pos[0];
274 let dz = t.position[2] - cam_pos[2];
275 let dist = (dx * dx + dz * dz).sqrt();
276 if dist < INTERACT_REACH && dist > 0.0 {
277 let dot = (fwd[0] * dx + fwd[2] * dz) / dist;
278 if dot > INTERACT_MIN_DOT && best.is_none_or(|(d, _)| dist < d) {
279 best = Some((dist, entity));
280 }
281 }
282 }
283 }
284 if let Some((_, entity)) = best {
285 if let Some(t) = ctx.get_mut::<Transform>(entity) {
286 t.rotation_deg[1] = (t.rotation_deg[1] + 45.0) % 360.0;
287 tracing::info!(
288 "interacted with prop, yaw now {:.0}\u{00b0}",
289 t.rotation_deg[1]
290 );
291 }
292 let target = ctx
296 .resource::<crate::ecs::decompose::EntityByName>()
297 .and_then(|n| {
298 n.0.iter()
299 .find(|(_, e)| **e == entity)
300 .map(|(&name, _)| name)
301 });
302 if let Some(target) = target {
303 ctx.events_mut::<crate::components::InteractEvent>()
304 .send(crate::components::InteractEvent { target });
305 }
306 }
307 }
308
309 StepResult::Continue
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use crate::components::{Camera3D, CameraController};
316 use crate::ecs::SYSTEMS;
317 use crate::ecs::World;
318
319 fn camera(controller: Option<CameraController>) -> Camera3D {
320 Camera3D {
321 fov_y_degrees: 75.0,
322 near: 0.05,
323 far: 200.0,
324 view_matrix: [[0.0; 4]; 4],
325 position: [0.0; 3],
326 yaw: 0.0,
327 pitch: 0.0,
328 desired_move: [0.0; 3],
329 jump_requested: false,
330 interact_requested: false,
331 controller,
332 }
333 }
334
335 #[test]
337 fn controlled_camera_spawns_internal_system() {
338 let mut world = World::new();
339 world.add_component(camera(Some(CameraController::default())));
340 world.start(SYSTEMS).unwrap();
341
342 let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
343 assert_eq!(names, ["Camera3DSystem"]);
344 }
345
346 #[test]
348 fn uncontrolled_camera_has_no_system() {
349 let mut world = World::new();
350 world.add_component(camera(None));
351 world.start(SYSTEMS).unwrap();
352 assert!(world.systems().is_empty());
353 }
354
355 #[test]
359 fn controls_command_updates_sensitivity_live() {
360 use crate::components::{ControlsCommand, FrameInput};
361
362 let mut world = World::new();
363 let ctrl = CameraController {
365 free_fly: true,
366 mouse_sensitivity: 0.001,
367 ..CameraController::default()
368 };
369 world.add_component(camera(Some(ctrl)));
370 world.start(SYSTEMS).unwrap();
371
372 world.events_mut::<ControlsCommand>().send(ControlsCommand {
376 mouse_sensitivity: Some(0.005),
377 ..Default::default()
378 });
379 world.add_component(FrameInput {
380 mouse_dx: 10.0,
381 ..Default::default()
382 });
383 world.step();
384
385 let yaw = world.query::<Camera3D>().next().map(|c| c.yaw).unwrap();
386 assert!(
387 (yaw - (-10.0 * 0.005)).abs() < 1.0e-6,
388 "yaw {yaw} should reflect the live sensitivity 0.005"
389 );
390 }
391
392 #[test]
397 fn controls_command_updates_fov_live() {
398 use crate::components::{ControlsCommand, FrameInput};
399
400 let mut world = World::new();
401 let ctrl = CameraController {
402 free_fly: true,
403 ..CameraController::default()
404 };
405 world.add_component(camera(Some(ctrl)));
406 world.start(SYSTEMS).unwrap();
407
408 let fov0 = world
410 .query::<Camera3D>()
411 .next()
412 .map(|c| c.fov_y_degrees)
413 .unwrap();
414 assert!((fov0 - 75.0).abs() < 1.0e-6);
415
416 world.events_mut::<ControlsCommand>().send(ControlsCommand {
419 fov_y_degrees: Some(90.0),
420 ..Default::default()
421 });
422 world.add_component(FrameInput::default());
423 world.step();
424 let fov1 = world
425 .query::<Camera3D>()
426 .next()
427 .map(|c| c.fov_y_degrees)
428 .unwrap();
429 assert!(
430 (fov1 - 90.0).abs() < 1.0e-6,
431 "fov {fov1} should reflect the live FOV 90"
432 );
433
434 world.events_mut::<ControlsCommand>().send(ControlsCommand {
435 mouse_sensitivity: Some(0.004),
436 ..Default::default()
437 });
438 world.step();
439 let fov2 = world
440 .query::<Camera3D>()
441 .next()
442 .map(|c| c.fov_y_degrees)
443 .unwrap();
444 assert!(
445 (fov2 - 90.0).abs() < 1.0e-6,
446 "fov {fov2} should be unchanged by a sensitivity-only command"
447 );
448 }
449
450 #[test]
456 fn camera_and_ui_share_frame_input() {
457 use crate::components::{FrameInput, KeyBinding, Screen, ScreenCommand};
458 use crate::ecs::asset_id::AssetId;
459
460 let mut world = World::new();
461 world.add_component(camera(Some(CameraController::default())));
462 world.add_component(Screen {
463 asset_id: AssetId(50),
464 initial: false,
465 fade_in_secs: 0.0,
466 ..Default::default()
467 });
468 world.add_component(KeyBinding {
469 key: "Escape".to_string(),
470 action: "screen:toggle:50".to_string(),
471 ..Default::default()
472 });
473 world.start(SYSTEMS).unwrap();
474
475 let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
476 assert!(names.contains(&"Camera3DSystem"));
477 assert!(names.contains(&"UiInputSystem"));
478
479 world.add_component(FrameInput {
480 escape: true,
481 ..Default::default()
482 });
483 world.step();
484
485 let mut cursor = crate::ecs::EventCursor::default();
486 let cmd = world
487 .events::<ScreenCommand>()
488 .and_then(|e| e.read(&mut cursor).next().cloned());
489 assert!(
490 matches!(cmd, Some(ScreenCommand::Toggle(AssetId(50)))),
491 "UiInputSystem must still process Escape when a camera is present"
492 );
493 }
494
495 fn interact_world() -> World {
499 use crate::components::Prop;
500 use crate::ecs::asset_id::AssetId;
501
502 let mut world = World::new();
503 let ctrl = CameraController {
504 free_fly: true,
505 ..CameraController::default()
506 };
507 world.add_component(camera(Some(ctrl)));
508 world.add_component(Prop {
509 asset_id: AssetId(1),
510 position: [0.0, 0.0, -2.0],
511 interactable: true,
512 ..Default::default()
513 });
514 world
515 }
516
517 #[test]
521 fn interact_rotates_transform() {
522 use crate::components::{FrameInput, Interactable, Prop, Transform};
523
524 let mut world = interact_world();
525 world.start(SYSTEMS).unwrap();
526 world.add_component(FrameInput {
527 interact: true,
528 ..Default::default()
529 });
530 world.step();
531
532 let transform_yaw = world
533 .join2::<Interactable, Transform>()
534 .map(|(_, _, t)| t.rotation_deg[1])
535 .next()
536 .expect("interactable entity has a Transform");
537 assert_eq!(transform_yaw, 45.0, "interact rotates the Transform");
538 assert_eq!(
539 world.query::<Prop>().count(),
540 0,
541 "the Prop column is drained at load"
542 );
543 }
544
545 #[test]
549 fn fps_walker_clamps_bounds_and_hands_off_movement() {
550 use crate::components::FrameInput;
551 use crate::gfx::camera::view_matrix;
552
553 let mut world = World::new();
554 let ctrl = CameraController {
555 free_fly: false,
556 move_speed: 5.0,
557 player_radius: 0.5,
558 bounds_min: [-10.0, -10.0, -10.0],
559 bounds_max: [10.0, 10.0, 10.0],
560 ..CameraController::default()
561 };
562 let mut cam = camera(Some(ctrl));
563 cam.position = [100.0, 2.0, 0.0];
565 world.add_component(cam);
566 world.start(SYSTEMS).unwrap();
567
568 world.add_component(FrameInput {
569 forward: true,
570 jump: true,
571 ..Default::default()
572 });
573 world.step();
574
575 let c = world.query::<Camera3D>().next().unwrap();
576 assert!(
578 (c.position[0] - 9.5).abs() < 1e-4,
579 "x clamped: {}",
580 c.position[0]
581 );
582 assert!(c.jump_requested, "jump intent handed off");
584 assert_eq!(
586 c.position[1], 2.0,
587 "grounded walker leaves height to physics"
588 );
589 assert_eq!(c.view_matrix, view_matrix(c.position, c.yaw, c.pitch));
591 }
592
593 #[test]
596 fn reset_velocity_zeroes_smoothed_velocity() {
597 let mut sys = super::Camera3DSystem::new(CameraController::default());
598 sys.velocity = [1.0, -2.0, 3.0];
599 sys.reset_velocity();
600 assert_eq!(sys.velocity, [0.0; 3]);
601 }
602}