feat(client): add debug controls for toggling render modes

This commit is contained in:
Serkyo 2026-07-27 21:14:22 +02:00
parent 5f4b30a4dc
commit 5c10aefee9
3 changed files with 166 additions and 0 deletions

View file

@ -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<DebugAction> {
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<RenderMode> {
match code {
KeyCode::KeyV => Some(RenderMode::Points),
_ => None,
}
}
#[cfg(test)]
#[path = "tests/debug.rs"]
mod tests;

View file

@ -6,6 +6,7 @@
mod camera; mod camera;
mod chunks; mod chunks;
mod debug;
mod mesh_pool; mod mesh_pool;
use std::time::Instant; use std::time::Instant;
@ -56,6 +57,8 @@ struct App {
camera: Camera, camera: Camera,
/// The current keyboard and mouse input state. /// The current keyboard and mouse input state.
input: InputState, 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. /// Timestamp of the previous frame, used to derive delta-time. `None` before the first frame.
last_frame: Option<Instant>, last_frame: Option<Instant>,
/// Handles onto the background network connection: the handshake outcome, the chunk-subscription sender, and the chunk-delivery receiver. `None` before the connection is started. /// 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, -0.5,
), ),
input: InputState::default(), input: InputState::default(),
debug: debug::DebugControls::default(),
last_frame: None, last_frame: None,
link: None, link: None,
connected: false, 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 { impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) { fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let attributes = Window::default_attributes().with_title("Synvael"); let attributes = Window::default_attributes().with_title("Synvael");
@ -194,6 +214,11 @@ impl ApplicationHandler for App {
WindowEvent::KeyboardInput { event, .. } => { WindowEvent::KeyboardInput { event, .. } => {
let pressed = event.state == ElementState::Pressed; let pressed = event.state == ElementState::Pressed;
if let PhysicalKey::Code(code) = event.physical_key { 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 { match code {
KeyCode::KeyW => self.input.forward = pressed, KeyCode::KeyW => self.input.forward = pressed,
KeyCode::KeyS => self.input.backward = pressed, KeyCode::KeyS => self.input.backward = pressed,

View file

@ -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<DebugAction> {
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);
}