diff --git a/crates/client/src/debug.rs b/crates/client/src/debug.rs new file mode 100644 index 0000000..78141f9 --- /dev/null +++ b/crates/client/src/debug.rs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Debug-only input handling, kept separate from the gameplay input path. +//! +//! Debug affordances are bound behind a modifier chord so they cannot collide with movement keys: [`DEBUG_MODIFIER`] (F1) is held, and a second key selects the affordance. The currently bound chords are: +//! +//! - **F1 + V**: toggles the [`RenderMode::Points`] debug rasterisation mode, which draws one point per mesh vertex. Pressing it again returns to [`RenderMode::Filled`]. + +use renderer::RenderMode; +use winit::keyboard::KeyCode; + +/// The key that must be held for a debug chord to be recognised. +const DEBUG_MODIFIER: KeyCode = KeyCode::F1; + +/// A debug operation requested by the input layer, applied by the caller. +/// +/// The layer deliberately returns an intent rather than acting directly, so it owns no renderer or window handles and stays a pure function of key events. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub(crate) enum DebugAction { + /// Applies the given rasterisation mode to the renderer. + SetRenderMode(RenderMode), +} + +/// Owns debug-only input state and translates key events into [`DebugAction`]s. +#[derive(Default)] +pub(crate) struct DebugControls { + /// Whether [`DEBUG_MODIFIER`] is currently held. Chords are recognised only while this is set. + modifier_held: bool, + /// The rasterisation mode most recently requested, used to make each chord a toggle back to [`RenderMode::Filled`]. + render_mode: RenderMode, +} + +impl DebugControls { + /// Translates one key event into a debug action, updating internal state. + /// + /// Returns [`None`] when the event is not part of a debug chord, which is the common case; the caller then handles the key normally. Actions fire on the press edge only, so one physical tap toggles once rather than once per press and once per release. + pub(crate) fn handle_key(&mut self, code: KeyCode, pressed: bool) -> Option { + if code == DEBUG_MODIFIER { + self.modifier_held = pressed; + return None; + } + + if !pressed || !self.modifier_held { + return None; + } + + let requested = render_mode_for_key(code)?; + + // Re-pressing the chord for the active mode returns to the normal path, so a single chord both enables and disables its mode. + self.render_mode = if self.render_mode == requested { + RenderMode::Filled + } else { + requested + }; + + Some(DebugAction::SetRenderMode(self.render_mode)) + } +} + +/// Maps a chord key to the render mode it selects, or [`None`] if the key is unbound. +/// +/// This is the single table a new rasterisation debug mode is added to. +const fn render_mode_for_key(code: KeyCode) -> Option { + match code { + KeyCode::KeyV => Some(RenderMode::Points), + _ => None, + } +} + +#[cfg(test)] +#[path = "tests/debug.rs"] +mod tests; diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 941fb3d..080cfe2 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -6,6 +6,7 @@ mod camera; mod chunks; +mod debug; mod mesh_pool; use std::time::Instant; @@ -56,6 +57,8 @@ struct App { camera: Camera, /// The current keyboard and mouse input state. input: InputState, + /// Debug-only key handling, kept separate from the gameplay input path. + debug: debug::DebugControls, /// Timestamp of the previous frame, used to derive delta-time. `None` before the first frame. last_frame: Option, /// Handles onto the background network connection: the handshake outcome, the chunk-subscription sender, and the chunk-delivery receiver. `None` before the connection is started. @@ -80,6 +83,7 @@ impl Default for App { -0.5, ), input: InputState::default(), + debug: debug::DebugControls::default(), last_frame: None, link: None, connected: false, @@ -89,6 +93,22 @@ impl Default for App { } } +impl App { + /// Applies a debug action produced by [`debug::DebugControls`]. + /// + /// Actions targeting the renderer are dropped while it is uninitialised, which is the window between application start and the first `resumed` call. + fn apply_debug_action(&mut self, action: debug::DebugAction) { + match action { + debug::DebugAction::SetRenderMode(mode) => { + if let Some(renderer) = self.renderer.as_mut() { + renderer.set_render_mode(mode); + info!(?mode, "render mode toggled"); + } + } + } + } +} + impl ApplicationHandler for App { fn resumed(&mut self, event_loop: &ActiveEventLoop) { let attributes = Window::default_attributes().with_title("Synvael"); @@ -194,6 +214,11 @@ impl ApplicationHandler for App { WindowEvent::KeyboardInput { event, .. } => { let pressed = event.state == ElementState::Pressed; if let PhysicalKey::Code(code) = event.physical_key { + // Debug chords are resolved first and on their own seam, so debug bindings can grow without entangling the gameplay bindings below. + if let Some(action) = self.debug.handle_key(code, pressed) { + self.apply_debug_action(action); + } + match code { KeyCode::KeyW => self.input.forward = pressed, KeyCode::KeyS => self.input.backward = pressed, diff --git a/crates/client/src/tests/debug.rs b/crates/client/src/tests/debug.rs new file mode 100644 index 0000000..eeee158 --- /dev/null +++ b/crates/client/src/tests/debug.rs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Unit tests for the debug chord handling in [`crate::debug`]. + +use super::*; + +/// Presses and releases a key, returning the action produced on the press edge. +fn tap(controls: &mut DebugControls, code: KeyCode) -> Option { + let action = controls.handle_key(code, true); + controls.handle_key(code, false); + action +} + +#[test] +fn chord_key_alone_does_nothing() { + let mut controls = DebugControls::default(); + assert_eq!(tap(&mut controls, KeyCode::KeyV), None); +} + +#[test] +fn modifier_alone_produces_no_action() { + let mut controls = DebugControls::default(); + assert_eq!(controls.handle_key(DEBUG_MODIFIER, true), None); + assert_eq!(controls.handle_key(DEBUG_MODIFIER, false), None); +} + +#[test] +fn held_modifier_plus_bound_key_selects_the_mode() { + let mut controls = DebugControls::default(); + controls.handle_key(DEBUG_MODIFIER, true); + assert_eq!( + tap(&mut controls, KeyCode::KeyV), + Some(DebugAction::SetRenderMode(RenderMode::Points)) + ); +} + +#[test] +fn repeating_the_chord_toggles_back_to_filled() { + let mut controls = DebugControls::default(); + controls.handle_key(DEBUG_MODIFIER, true); + tap(&mut controls, KeyCode::KeyV); + assert_eq!( + tap(&mut controls, KeyCode::KeyV), + Some(DebugAction::SetRenderMode(RenderMode::Filled)) + ); +} + +#[test] +fn action_fires_on_the_press_edge_only() { + let mut controls = DebugControls::default(); + controls.handle_key(DEBUG_MODIFIER, true); + assert!(controls.handle_key(KeyCode::KeyV, true).is_some()); + assert_eq!(controls.handle_key(KeyCode::KeyV, false), None); +} + +#[test] +fn releasing_the_modifier_disarms_the_chord() { + let mut controls = DebugControls::default(); + controls.handle_key(DEBUG_MODIFIER, true); + controls.handle_key(DEBUG_MODIFIER, false); + assert_eq!(tap(&mut controls, KeyCode::KeyV), None); +} + +#[test] +fn unbound_key_under_the_modifier_is_ignored() { + let mut controls = DebugControls::default(); + controls.handle_key(DEBUG_MODIFIER, true); + assert_eq!(tap(&mut controls, KeyCode::KeyW), None); +}