Skip to main content

concinnity_engine/gfx/
camera_controller.rs

1// src/gfx/camera_controller.rs
2//
3// First-person / fly-through camera controller. An internal system (not a
4// declarable asset): `World::start` constructs one when the world has a
5// `Camera3D` whose `controller` is set, reading that controller's config. It
6// turns mouse/keyboard input into a `Camera3D` orientation and a movement
7// intent for the player's `RigidBody`.
8
9use crate::components::{Camera3D, CameraController, FrameInput, Interactable, Transform};
10use crate::ecs::{Entity, PipelineContext, StepResult, System};
11use std::time::Instant;
12
13// Reach distance for interacting with a Prop, in world units.
14const INTERACT_REACH: f32 = 3.0;
15// Minimum facing dot product (~60-degree cone) for an interaction.
16const INTERACT_MIN_DOT: f32 = 0.5;
17
18/// First-person / fly-through controller behavior. Constructed internally by
19/// `World::start` from the controlling `Camera3D`'s `CameraController`;
20/// never a world-declared asset.
21#[derive(Debug)]
22pub struct Camera3DSystem {
23    free_fly: bool,
24    move_speed: f32,
25    sprint_multiplier: f32,
26    mouse_sensitivity: f32,
27    // Gamepad look speed in radians per second at full stick deflection
28    // (rate-based, unlike the per-pixel mouse sensitivity).
29    gamepad_look_sensitivity: f32,
30    player_radius: f32,
31    bounds_min: [f32; 3],
32    bounds_max: [f32; 3],
33    last_step: Option<Instant>,
34    // smoothed horizontal velocity; lerped toward the target each tick so
35    // WASD movement accelerates and decelerates instead of snapping
36    velocity: [f32; 3],
37    // Interactable entities (those carrying the Interactable tag), collected at
38    // init so step() rotates only their Transforms on interact.
39    interactable_entities: Vec<Entity>,
40    // Cursor into the Events<ControlsCommand> queue (live settings changes).
41    controls_cursor: crate::ecs::EventCursor,
42}
43
44impl Camera3DSystem {
45    // Build a controller from a `Camera3D`'s controller settings.
46    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    /// Zero the smoothed movement velocity. Called when an external source (the
64    /// cn debug `camera-set` command) teleports the camera, so free-fly velocity
65    /// integration does not drift the new pose on the next step. Only reached
66    /// from the binary-only debug drive, hence dead in a `--lib` build.
67    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        // Collect interact targets: every entity carrying the Interactable tag.
99        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        // Live settings-menu changes sent this tick by GraphicsSystem, which runs
115        // first. FOV is written in the camera loop below, which holds the
116        // mutable Camera3D borrow.
117        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        // Read (not drain) the input snapshot deposited by GraphicsSystem this
127        // frame, so UiInputSystem can read the same snapshot (e.g. for a pause
128        // menu over this camera). GraphicsSystem clears it before the next push.
129        let input = match ctx.query::<FrameInput>().next().cloned() {
130            Some(i) => i,
131            // no input means GraphicsSystem hasn't run yet or there is no
132            // graphics backend -- nothing to do this tick
133            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        // update every Camera3D in the world (normally exactly one)
144        for camera in ctx.query_mut::<Camera3D>() {
145            // A live FOV change (settings-menu slider) applies to the camera's
146            // projection on the next rendered frame.
147            if let Some(fov) = pending_fov {
148                camera.fov_y_degrees = fov;
149            }
150            // Look: pixel-based mouse deltas plus the rate-based right stick
151            // (deflection x radians/second x dt, so it is frame-rate correct).
152            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            // Two movement modes share the same input/decay/screen-matrix
169            // outer loop; only the basis vectors and how velocity is
170            // committed differ. Free-fly drives the camera position
171            // directly and adds a vertical component; the FPS walker keeps
172            // motion horizontal and delegates to PhysicsSystem.
173            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            // build the target velocity from current key state
191            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            // The left stick rides the same bases: partial deflection walks
211            // proportionally slower (the axis magnitude is at most 1).
212            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            // Free-fly: jump is "rise"; no down key, descend by pitching down + W.
216            if self.free_fly && input.jump {
217                target[1] += speed;
218            }
219
220            // exponential decay toward target -- time-correct so frame rate does not
221            // affect the feel. half_life controls how quickly speed builds/drops.
222            let half_life = 0.08_f32; // seconds to reach ~50% of target speed
223            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                // Apply directly; no PhysicsSystem, no bounds, no gravity.
230                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                // soft containment: pull the camera back inside the bounds box.
237                // PhysicsSystem owns the position, so this is a one-frame-lagged
238                // correction applied before it runs.
239                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                // hand the movement intent to PhysicsSystem, which resolves it
246                // against the world and writes the final camera position back
247                camera.desired_move = self.velocity;
248                camera.jump_requested = input.jump;
249            }
250            camera.interact_requested = input.interact;
251
252            // write the view matrix as a fallback for worlds with no
253            // PhysicsSystem; PhysicsSystem overwrites it once it has moved.
254            camera.view_matrix =
255                crate::gfx::camera::view_matrix(camera.position, camera.yaw, camera.pitch);
256        }
257
258        // interactable props: press the interact key while facing one to rotate
259        // it 45 degrees. Pickup/drop is handled by PhysicsSystem. The target
260        // rotation lives on the entity's Transform.
261        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            // nearest interactable entity within reach the player faces
270            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                // Announce the press for declarative logic (Behavior interact
293                // sources); an unnamed entity has no addressable identity to
294                // announce.
295                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    // A Camera3D whose `controller` is set spawns the internal controller.
336    #[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    // `controller: null` opts out: a cutscene camera gets no controller.
347    #[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    // A ControlsCommand pushed mid-tick updates the live mouse sensitivity, so
356    // the same frame's mouse-look uses the new value (not the init-time one).
357    // This is the settings-menu sensitivity slider applying without a restart.
358    #[test]
359    fn controls_command_updates_sensitivity_live() {
360        use crate::components::{ControlsCommand, FrameInput};
361
362        let mut world = World::new();
363        // Free-fly avoids the PhysicsSystem path; start from a known sensitivity.
364        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        // GraphicsSystem would send this when the slider is dragged; the camera
373        // reads it this tick. A mouse delta in the same frame must rotate by the
374        // NEW sensitivity (0.005), not the controller's 0.001.
375        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    // A ControlsCommand carrying a new FOV updates the Camera3D's fov_y_degrees
393    // live (the projection is rebuilt from it each frame), and an event with
394    // fov_y_degrees: None leaves the FOV untouched. This is the settings-menu FOV
395    // slider applying without a restart.
396    #[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        // The camera starts at the authored 75 degrees.
409        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        // A FOV-only command applies this tick; a sensitivity-only command does
417        // not disturb the FOV.
418        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    // With both a controlled camera and a UiInputSystem (Screen + KeyBinding),
451    // both systems read the same per-frame FrameInput: Camera3DSystem runs
452    // first but no longer consumes it, so UiInputSystem still receives Escape
453    // and toggles the menu. (Regression: Camera3DSystem drained the input,
454    // starving the menu, so Escape did nothing over a captured camera.)
455    #[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    // Build a free-fly camera at the origin facing -Z, plus an interactable prop
496    // two units ahead (within reach and inside the facing cone), and a latched
497    // interact input. Shared by the interact decomposition tests.
498    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    // Pressing interact rotates the target entity's Transform 45 degrees; the
518    // Prop column was drained at load, so the rotation can only land on the
519    // Transform.
520    #[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    // A grounded (non-free-fly) camera clamps its position back inside the
546    // bounds box and hands the movement intent + jump to PhysicsSystem instead
547    // of moving itself, driving the FPS-walker basis and commit branch.
548    #[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        // Well outside the +X wall so the containment clamp is observable.
564        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        // Pulled back to the wall minus the player radius (10 - 0.5).
577        assert!(
578            (c.position[0] - 9.5).abs() < 1e-4,
579            "x clamped: {}",
580            c.position[0]
581        );
582        // Jump is handed to physics rather than applied here.
583        assert!(c.jump_requested, "jump intent handed off");
584        // The FPS walker keeps the camera grounded: no direct vertical move.
585        assert_eq!(
586            c.position[1], 2.0,
587            "grounded walker leaves height to physics"
588        );
589        // The view matrix was rebuilt from the (clamped) pose.
590        assert_eq!(c.view_matrix, view_matrix(c.position, c.yaw, c.pitch));
591    }
592
593    // reset_velocity zeroes the smoothed movement velocity so a teleport does
594    // not drift the next step.
595    #[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}