feat(core): unified input state (keyboard/mouse/scroll)
Add InputState (DRAFT Etapes 15, sous-volt 15.B): pressed/held/released semantics via HashSet rotation, mouse position/delta/scroll accumulators. Wire it into App (pub field), forward winit WindowEvents in the event handler, and begin_frame/end_frame around AppHandler::update. Handleable key/mouse/scroll logic extracted into private helpers so all five unit tests avoid constructing winit KeyEvent (private fields).
This commit is contained in:
+13
-1
@@ -23,7 +23,7 @@
|
|||||||
//! once right after GPU initialization so users can register shaders/meshes/materials/entities.
|
//! once right after GPU initialization so users can register shaders/meshes/materials/entities.
|
||||||
|
|
||||||
use crate::AppHandler;
|
use crate::AppHandler;
|
||||||
use crate::core::{Context, Renderer};
|
use crate::core::{Context, InputState, Renderer};
|
||||||
use crate::scene::Scene;
|
use crate::scene::Scene;
|
||||||
use crate::utils::WsgError;
|
use crate::utils::WsgError;
|
||||||
use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH};
|
use crate::utils::conf::{APP_DEFAULT_HEIGHT, APP_DEFAULT_TITLE, APP_DEFAULT_WIDTH};
|
||||||
@@ -46,6 +46,10 @@ use winit::window::{Window, WindowAttributes};
|
|||||||
pub struct App {
|
pub struct App {
|
||||||
/// Resource depot and entity graph — users register Meshes/Materials here during `AppHandler::setup`.
|
/// Resource depot and entity graph — users register Meshes/Materials here during `AppHandler::setup`.
|
||||||
pub scene: Scene,
|
pub scene: Scene,
|
||||||
|
/// Unified input state (keyboard/mouse/scroll, DRAFT Étape 15). Fed by the winit window events
|
||||||
|
/// and rotated each frame by `begin_frame`/`end_frame` around `AppHandler::update`. Read it in
|
||||||
|
/// `update` via `app.input` (e.g. `app.input.key_held(KeyCode::KeyW)`).
|
||||||
|
pub input: InputState,
|
||||||
/// Window title, read by the runner when the window is created in `resumed`.
|
/// Window title, read by the runner when the window is created in `resumed`.
|
||||||
pub(crate) title: String,
|
pub(crate) title: String,
|
||||||
/// Window width, read by the runner when the window is created in `resumed`.
|
/// Window width, read by the runner when the window is created in `resumed`.
|
||||||
@@ -203,6 +207,7 @@ impl AppBuilder {
|
|||||||
let event_loop = EventLoop::new().map_err(|_| WsgError::WindowSystem)?;
|
let event_loop = EventLoop::new().map_err(|_| WsgError::WindowSystem)?;
|
||||||
Ok(App {
|
Ok(App {
|
||||||
scene: Scene::new(),
|
scene: Scene::new(),
|
||||||
|
input: InputState::new(),
|
||||||
title: self.title,
|
title: self.title,
|
||||||
width: self.width,
|
width: self.width,
|
||||||
height: self.height,
|
height: self.height,
|
||||||
@@ -265,6 +270,7 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
|
|||||||
|
|
||||||
let mut app = App {
|
let mut app = App {
|
||||||
scene,
|
scene,
|
||||||
|
input: InputState::new(),
|
||||||
title: self.title.clone(),
|
title: self.title.clone(),
|
||||||
width: self.width,
|
width: self.width,
|
||||||
height: self.height,
|
height: self.height,
|
||||||
@@ -284,7 +290,11 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
|
|||||||
let Some(app) = self.app.as_mut() else {
|
let Some(app) = self.app.as_mut() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
// Étape 15 (input) : débute la frame d'input (rotation pressed/released + reset deltas),
|
||||||
|
// exécute la logique utilisateur, puis clôt (nettoie les états transitoires).
|
||||||
|
app.input.begin_frame();
|
||||||
self.handler.update(app);
|
self.handler.update(app);
|
||||||
|
app.input.end_frame();
|
||||||
app.window().request_redraw();
|
app.window().request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +309,8 @@ impl<H: AppHandler> ApplicationHandler for AppRunner<H> {
|
|||||||
let Some(app) = self.app.as_mut() else {
|
let Some(app) = self.app.as_mut() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
// Étape 15 (input) : alimente l'état unifié depuis les événements winit (clavier/souris/molette).
|
||||||
|
app.input.handle_window_event(&event);
|
||||||
match event {
|
match event {
|
||||||
WindowEvent::Resized(size) => {
|
WindowEvent::Resized(size) => {
|
||||||
// Garde (D3) : minimiser la fenêtre envoie Resized(0x0) ; ne jamais reconfigurer à 0.
|
// Garde (D3) : minimiser la fenêtre envoie Resized(0x0) ; ne jamais reconfigurer à 0.
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
//! # Input Module — Unified Input State (Étape 15, ROADMAP 2.3)
|
||||||
|
//!
|
||||||
|
//! State d'entrée **unifié** (clavier / souris / molette) à la sémantique cross-frame
|
||||||
|
//! **pressed / held / released**, alimenté par les événements **winit** (`WindowEvent`), côté **CPU
|
||||||
|
//! (Rust)** — WGSL (langage de shader, GPU) n'a pas d'I/O. Ce module est incarné dans `App::input`
|
||||||
|
//! et piloté par la boucle : `begin_frame()` avant `AppHandler::update`, `end_frame()` après.
|
||||||
|
//!
|
||||||
|
//! ## Conventions
|
||||||
|
//! - **Clavier** : identifié par `KeyCode` (physique, indépendant de la disposition AZERTY/QWERTY :
|
||||||
|
//! la touche Z sur AZERTY est `KeyCode::KeyW`). `pressed`/`released` valent une seule frame,
|
||||||
|
//! `held` reste vrai tant que la touche est enfoncée.
|
||||||
|
//! - **Souris** : position absolue (pixels), delta par frame (dérivé des `CursorMoved`, donc des
|
||||||
|
//! déplacements relatifs valables pour une caméra orbitale en glisser), boutons
|
||||||
|
//! `pressed`/`held`/`released`, molette (`scroll`), `y > 0` = molette vers le haut.
|
||||||
|
//! - **Gamepad** : réservé pour une future v1 minimale (DRAFT D7) ; l'API est prête à accueillir
|
||||||
|
//! un `GamepadState` sans casser l'existant (l'exemple final n'a besoin que du clavier + souris).
|
||||||
|
//!
|
||||||
|
//! ## Exemples de requête (dans `AppHandler::update`)
|
||||||
|
//! ```
|
||||||
|
//! # use winit::keyboard::{KeyCode, PhysicalKey};
|
||||||
|
//! # fn demo(input: &wsg_lib::core::input::InputState) {
|
||||||
|
//! if input.key_held(KeyCode::KeyW) { /* avancer */ }
|
||||||
|
//! if input.key_pressed(KeyCode::Space) { /* sauter */ }
|
||||||
|
//! let (dx, dy) = input.mouse_delta();
|
||||||
|
//! if input.mouse_button_held(winit::event::MouseButton::Left) { /* orbiter */ }
|
||||||
|
//! let (_, zoom) = input.scroll_delta();
|
||||||
|
//! # }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
|
||||||
|
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||||
|
|
||||||
|
/// État de saisie unifié, agrégat des groupes clavier, souris et molette. Il est **remis à jour à
|
||||||
|
/// chaque frame** par `App` via `begin_frame`/`end_frame`, et lu par l'utilisateur dans
|
||||||
|
/// `AppHandler::update` via `app.input`.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct InputState {
|
||||||
|
// ---- Clavier ----
|
||||||
|
/// Touches physiquement enfoncées au moment présent (persiste entre les frames).
|
||||||
|
held: HashSet<KeyCode>,
|
||||||
|
/// Touches enfoncées pendant la frame courante (valides une seule frame).
|
||||||
|
pressed: HashSet<KeyCode>,
|
||||||
|
/// Touches relâchées pendant la frame courante (valides une seule frame).
|
||||||
|
released: HashSet<KeyCode>,
|
||||||
|
/// Accumulateur de `pressed` entre deux `begin_frame` (consommé à la rotation).
|
||||||
|
frame_pressed: HashSet<KeyCode>,
|
||||||
|
/// Accumulateur de `released` entre deux `begin_frame`.
|
||||||
|
frame_released: HashSet<KeyCode>,
|
||||||
|
|
||||||
|
// ---- Souris ----
|
||||||
|
/// Position absolue du curseur en pixels (dernière reçue).
|
||||||
|
mouse_position: (f32, f32),
|
||||||
|
/// Position absolue précédente, pour dériver le delta de `CursorMoved`.
|
||||||
|
last_mouse_position: Option<(f32, f32)>,
|
||||||
|
/// Déplacement relatif cumulé pendant la frame courante.
|
||||||
|
mouse_delta: (f32, f32),
|
||||||
|
/// Boutons enfoncés au moment présent.
|
||||||
|
held_buttons: HashSet<MouseButton>,
|
||||||
|
/// Boutons pressés pendant la frame courante.
|
||||||
|
pressed_buttons: HashSet<MouseButton>,
|
||||||
|
/// Boutons relâchés pendant la frame courante.
|
||||||
|
released_buttons: HashSet<MouseButton>,
|
||||||
|
/// Accumulateurs des boutons entre deux `begin_frame`.
|
||||||
|
frame_pressed_buttons: HashSet<MouseButton>,
|
||||||
|
frame_released_buttons: HashSet<MouseButton>,
|
||||||
|
|
||||||
|
// ---- Molette ----
|
||||||
|
/// Défilement cumulé pendant la frame courante (x, y).
|
||||||
|
scroll: (f32, f32),
|
||||||
|
// ---- Gamepad (réservé) ----
|
||||||
|
// (DRAFT D7 : v1 minimale optionnelle, reportée — l'API s'étendra sans rupture.)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputState {
|
||||||
|
/// Crée un `InputState` vierge (toutes états vides). Équivalent à `Default`.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consomme un événement de fenêtre winit et met à jour l'état interne (accumulateurs). Les
|
||||||
|
/// événements non pertinents sont ignorés. La rotation vers les ensembles requêtables
|
||||||
|
/// (`pressed`/`released`) se fait au prochain `begin_frame`.
|
||||||
|
pub fn handle_window_event(&mut self, event: &WindowEvent) {
|
||||||
|
match event {
|
||||||
|
WindowEvent::KeyboardInput { event: ke, .. } => {
|
||||||
|
let PhysicalKey::Code(code) = ke.physical_key else {
|
||||||
|
return; // touches non-figurées (ex. clavier système) ignorées
|
||||||
|
};
|
||||||
|
self.key_input(code, ke.state);
|
||||||
|
}
|
||||||
|
WindowEvent::MouseInput { state, button, .. } => self.mouse_button(*button, *state),
|
||||||
|
WindowEvent::CursorMoved { position, .. } => {
|
||||||
|
self.cursor_move(position.x as f32, position.y as f32);
|
||||||
|
}
|
||||||
|
WindowEvent::MouseWheel { delta, .. } => match delta {
|
||||||
|
MouseScrollDelta::LineDelta(x, y) => self.wheel(*x, *y),
|
||||||
|
MouseScrollDelta::PixelDelta(p) => self.wheel(p.x as f32, p.y as f32),
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enregistre un événement clavier brut (touche physique + état), appelé par
|
||||||
|
/// [`InputState::handle_window_event`]. Séparé pour être testable sans construire un `KeyEvent`.
|
||||||
|
fn key_input(&mut self, code: KeyCode, state: ElementState) {
|
||||||
|
match state {
|
||||||
|
ElementState::Pressed => {
|
||||||
|
self.held.insert(code);
|
||||||
|
self.frame_pressed.insert(code);
|
||||||
|
}
|
||||||
|
ElementState::Released => {
|
||||||
|
self.held.remove(&code);
|
||||||
|
self.frame_released.insert(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enregistre un événement bouton de souris brut, appelé par [`InputState::handle_window_event`].
|
||||||
|
fn mouse_button(&mut self, button: MouseButton, state: ElementState) {
|
||||||
|
match state {
|
||||||
|
ElementState::Pressed => {
|
||||||
|
self.held_buttons.insert(button);
|
||||||
|
self.frame_pressed_buttons.insert(button);
|
||||||
|
}
|
||||||
|
ElementState::Released => {
|
||||||
|
self.held_buttons.remove(&button);
|
||||||
|
self.frame_released_buttons.insert(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour la position du curseur et cumule le déplacement relatif. Appelé par
|
||||||
|
/// [`InputState::handle_window_event`].
|
||||||
|
fn cursor_move(&mut self, x: f32, y: f32) {
|
||||||
|
if let Some((px, py)) = self.last_mouse_position {
|
||||||
|
self.mouse_delta.0 += x - px;
|
||||||
|
self.mouse_delta.1 += y - py;
|
||||||
|
}
|
||||||
|
self.last_mouse_position = Some((x, y));
|
||||||
|
self.mouse_position = (x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cumule le défilement de la molette. Appelé par [`InputState::handle_window_event`].
|
||||||
|
fn wheel(&mut self, dx: f32, dy: f32) {
|
||||||
|
self.scroll.0 += dx;
|
||||||
|
self.scroll.1 += dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre une nouvelle frame pour l'input : **fait tourner** les accumulateurs d'événements
|
||||||
|
/// (accumulés entre deux `begin_frame`) vers les ensembles requêtables `pressed`/`released`, et
|
||||||
|
/// remet à zéro le delta de souris et la molette. À appeler **avant** `AppHandler::update`.
|
||||||
|
pub fn begin_frame(&mut self) {
|
||||||
|
self.pressed = std::mem::take(&mut self.frame_pressed);
|
||||||
|
self.released = std::mem::take(&mut self.frame_released);
|
||||||
|
self.pressed_buttons = std::mem::take(&mut self.frame_pressed_buttons);
|
||||||
|
self.released_buttons = std::mem::take(&mut self.frame_released_buttons);
|
||||||
|
self.mouse_delta = (0.0, 0.0);
|
||||||
|
self.scroll = (0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clôt une frame : vide les ensembles transitoires `pressed`/`released` (déjà consommés par
|
||||||
|
/// `update`). Les états `held` et la position sont conservés. À appeler **après**
|
||||||
|
/// `AppHandler::update` (ou `render`).
|
||||||
|
pub fn end_frame(&mut self) {
|
||||||
|
self.pressed.clear();
|
||||||
|
self.released.clear();
|
||||||
|
self.pressed_buttons.clear();
|
||||||
|
self.released_buttons.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Requêtes clavier ----
|
||||||
|
|
||||||
|
/// Vrai si `code` a été **enfoncée** pendant la frame courante (une seule frame).
|
||||||
|
pub fn key_pressed(&self, code: KeyCode) -> bool {
|
||||||
|
self.pressed.contains(&code)
|
||||||
|
}
|
||||||
|
/// Vrai si `code` est **maintenue** enfoncée (persiste entre les frames).
|
||||||
|
pub fn key_held(&self, code: KeyCode) -> bool {
|
||||||
|
self.held.contains(&code)
|
||||||
|
}
|
||||||
|
/// Vrai si `code` a été **relâchée** pendant la frame courante (une seule frame).
|
||||||
|
pub fn key_released(&self, code: KeyCode) -> bool {
|
||||||
|
self.released.contains(&code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Requêtes souris ----
|
||||||
|
|
||||||
|
/// Position absolue du curseur en pixels (dernière position reçue).
|
||||||
|
pub fn mouse_position(&self) -> (f32, f32) {
|
||||||
|
self.mouse_position
|
||||||
|
}
|
||||||
|
/// Déplacement relatif de la souris cumulé pendant la frame courante.
|
||||||
|
pub fn mouse_delta(&self) -> (f32, f32) {
|
||||||
|
self.mouse_delta
|
||||||
|
}
|
||||||
|
/// Défilement de la molette cumulé pendant la frame courante (`(dx, dy)`, `dy > 0` = vers le haut).
|
||||||
|
pub fn scroll_delta(&self) -> (f32, f32) {
|
||||||
|
self.scroll
|
||||||
|
}
|
||||||
|
/// Vrai si `button` a été **pressé** pendant la frame courante (une seule frame).
|
||||||
|
pub fn mouse_button_pressed(&self, button: MouseButton) -> bool {
|
||||||
|
self.pressed_buttons.contains(&button)
|
||||||
|
}
|
||||||
|
/// Vrai si `button` est **maintenu** enfoncé (persiste entre les frames).
|
||||||
|
pub fn mouse_button_held(&self, button: MouseButton) -> bool {
|
||||||
|
self.held_buttons.contains(&button)
|
||||||
|
}
|
||||||
|
/// Vrai si `button` a été **relâché** pendant la frame courante (une seule frame).
|
||||||
|
pub fn mouse_button_released(&self, button: MouseButton) -> bool {
|
||||||
|
self.released_buttons.contains(&button)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use winit::event::MouseButton;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keyboard_pressed_held_released_lifecycle() {
|
||||||
|
let mut input = InputState::new();
|
||||||
|
input.key_input(KeyCode::KeyW, ElementState::Pressed);
|
||||||
|
input.begin_frame();
|
||||||
|
assert!(input.key_pressed(KeyCode::KeyW));
|
||||||
|
assert!(input.key_held(KeyCode::KeyW));
|
||||||
|
assert!(!input.key_released(KeyCode::KeyW));
|
||||||
|
input.end_frame();
|
||||||
|
|
||||||
|
// Frame suivante sans nouvel événement : plus "pressed", toujours "held".
|
||||||
|
input.begin_frame();
|
||||||
|
assert!(!input.key_pressed(KeyCode::KeyW));
|
||||||
|
assert!(input.key_held(KeyCode::KeyW));
|
||||||
|
input.end_frame();
|
||||||
|
|
||||||
|
// Relâchement.
|
||||||
|
input.key_input(KeyCode::KeyW, ElementState::Released);
|
||||||
|
input.begin_frame();
|
||||||
|
assert!(input.key_released(KeyCode::KeyW));
|
||||||
|
assert!(!input.key_held(KeyCode::KeyW));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mouse_buttons_lifecycle() {
|
||||||
|
let mut input = InputState::new();
|
||||||
|
input.mouse_button(MouseButton::Left, ElementState::Pressed);
|
||||||
|
input.begin_frame();
|
||||||
|
assert!(input.mouse_button_pressed(MouseButton::Left));
|
||||||
|
assert!(input.mouse_button_held(MouseButton::Left));
|
||||||
|
input.end_frame();
|
||||||
|
input.begin_frame();
|
||||||
|
assert!(!input.mouse_button_pressed(MouseButton::Left));
|
||||||
|
assert!(input.mouse_button_held(MouseButton::Left));
|
||||||
|
input.mouse_button(MouseButton::Left, ElementState::Released);
|
||||||
|
input.end_frame();
|
||||||
|
input.begin_frame();
|
||||||
|
assert!(input.mouse_button_released(MouseButton::Left));
|
||||||
|
assert!(!input.mouse_button_held(MouseButton::Left));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mouse_delta_and_position_accumulate() {
|
||||||
|
let mut input = InputState::new();
|
||||||
|
input.begin_frame();
|
||||||
|
input.cursor_move(10.0, 20.0);
|
||||||
|
input.cursor_move(30.0, 40.0);
|
||||||
|
assert_eq!(input.mouse_delta(), (20.0, 20.0));
|
||||||
|
assert_eq!(input.mouse_position(), (30.0, 40.0));
|
||||||
|
input.end_frame();
|
||||||
|
|
||||||
|
// Nouvelle frame : le delta est remis à zéro au début, la position reste.
|
||||||
|
input.begin_frame();
|
||||||
|
assert_eq!(input.mouse_delta(), (0.0, 0.0));
|
||||||
|
assert_eq!(input.mouse_position(), (30.0, 40.0));
|
||||||
|
input.cursor_move(31.0, 42.0);
|
||||||
|
assert_eq!(input.mouse_delta(), (1.0, 2.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_accumulates_per_frame() {
|
||||||
|
let mut input = InputState::new();
|
||||||
|
input.begin_frame();
|
||||||
|
input.wheel(1.0, 2.0);
|
||||||
|
input.wheel(0.5, -1.0);
|
||||||
|
assert_eq!(input.scroll_delta(), (1.5, 1.0));
|
||||||
|
input.end_frame();
|
||||||
|
input.begin_frame();
|
||||||
|
assert_eq!(input.scroll_delta(), (0.0, 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_is_empty() {
|
||||||
|
let input = InputState::default();
|
||||||
|
assert!(!input.key_held(KeyCode::KeyW));
|
||||||
|
assert!(!input.mouse_button_held(MouseButton::Left));
|
||||||
|
assert_eq!(input.mouse_delta(), (0.0, 0.0));
|
||||||
|
assert_eq!(input.scroll_delta(), (0.0, 0.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,9 +11,11 @@
|
|||||||
|
|
||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
|
pub mod input;
|
||||||
pub mod renderer;
|
pub mod renderer;
|
||||||
|
|
||||||
// Re-exports
|
// Re-exports
|
||||||
pub use context::Context;
|
pub use context::Context;
|
||||||
pub use frame::Frame;
|
pub use frame::Frame;
|
||||||
|
pub use input::InputState;
|
||||||
pub use renderer::Renderer;
|
pub use renderer::Renderer;
|
||||||
|
|||||||
Reference in New Issue
Block a user