fix: terminal input - timeout-based held keys, press-only jump

Terminals don't send Release events for most keys, so held keys
stayed active forever. Two problems:

1. W = infinite jump: Jump was in held_actions, fired every tick
   while held. check_on_ground returned true immediately after
   landing, causing auto-bounce.
   Fix: Jump fires only on initial Press event (jump_pressed flag),
   not on Repeat or held state.

2. A ignores D: When both A and D were held, both applied velocity
   and cancelled out. But terminal never sends Release for A, so
   D couldn't take over.
   Fix: Left/Right are mutually exclusive — most recently pressed
   key wins (compares last_seen timestamps).

3. Held key timeout: Keys expire after 80ms without a Repeat event,
   simulating Release for terminals that don't send it.

109 tests, 0 warnings, 0 failures
This commit is contained in:
Emil
2026-06-20 23:30:37 +03:00
parent 00c6b3bf87
commit e1eb9cc7cd
2 changed files with 134 additions and 81 deletions
+29 -23
View File
@@ -206,44 +206,50 @@ impl Game {
}
pub fn handle_input(&mut self, vw: usize, vh: usize) {
let one_shot = self.input.update();
match one_shot {
Action::Quit => {
self.running = false;
}
Action::Paint(brush) => {
let mat = brush.to_material();
let cx = self.cam_x + (vw as i32 / 2);
let cy = self.cam_y + (vh as i32 / 2);
let r = 2;
for dy in -r..=r {
for dx in -r..=r {
if dx * dx + dy * dy <= r * r + 1 {
if let Some(m) = mat {
self.grid.set_material(cx + dx, cy + dy, m);
} else {
self.grid.set(cx + dx, cy + dy, crate::world::cell::Cell::empty());
let one_shots = self.input.update();
for action in one_shots {
match action {
Action::Quit => {
self.running = false;
return;
}
Action::Paint(brush) => {
let mat = brush.to_material();
let cx = self.cam_x + (vw as i32 / 2);
let cy = self.cam_y + (vh as i32 / 2);
let r = 2;
for dy in -r..=r {
for dx in -r..=r {
if dx * dx + dy * dy <= r * r + 1 {
if let Some(m) = mat {
self.grid.set_material(cx + dx, cy + dy, m);
} else {
self.grid.set(cx + dx, cy + dy, crate::world::cell::Cell::empty());
}
}
}
}
}
_ => {}
}
_ => {}
}
if !self.running {
return;
}
// Jump: only on press, not held
if self.input.jump_requested() {
let on_ground = self.check_on_ground();
self.player.jump(&mut self.entities, on_ground);
}
// Movement: applied every tick while held
for action in self.input.held_actions() {
match action {
Action::Quit => self.running = false,
Action::MoveLeft => self.player.move_left(&mut self.entities),
Action::MoveRight => self.player.move_right(&mut self.entities),
Action::Jump => {
let on_ground = self.check_on_ground();
self.player.jump(&mut self.entities, on_ground);
}
Action::MoveCameraLeft => self.cam_x -= 2,
Action::MoveCameraRight => self.cam_x += 2,
Action::MoveCameraUp => self.cam_y -= 2,
+105 -58
View File
@@ -1,5 +1,5 @@
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use std::time::Duration;
use std::time::{Duration, Instant};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Action {
@@ -30,27 +30,35 @@ pub enum MaterialBrush {
Erase,
}
#[derive(Clone, Copy, PartialEq, Eq)]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum HeldKey {
Left,
Right,
Jump,
CamLeft,
CamRight,
CamUp,
CamDown,
}
struct HeldState {
last_seen: Instant,
just_pressed: bool,
}
const HOLD_TIMEOUT: Duration = Duration::from_millis(80);
pub struct InputHandler {
pub paint_brush: MaterialBrush,
held: Vec<HeldKey>,
held: std::collections::HashMap<HeldKey, HeldState>,
jump_pressed: bool,
}
impl InputHandler {
pub fn new() -> Self {
Self {
paint_brush: MaterialBrush::Sand,
held: Vec::new(),
held: std::collections::HashMap::new(),
jump_pressed: false,
}
}
@@ -58,7 +66,6 @@ impl InputHandler {
match code {
KeyCode::Left | KeyCode::Char('a') => Some(HeldKey::Left),
KeyCode::Right | KeyCode::Char('d') => Some(HeldKey::Right),
KeyCode::Up | KeyCode::Char('w') | KeyCode::Char(' ') => Some(HeldKey::Jump),
KeyCode::Char('h') => Some(HeldKey::CamLeft),
KeyCode::Char('l') => Some(HeldKey::CamRight),
KeyCode::Char('k') => Some(HeldKey::CamUp),
@@ -67,24 +74,32 @@ impl InputHandler {
}
}
fn hold(&mut self, key: HeldKey) {
if !self.held.contains(&key) {
self.held.push(key);
}
fn mark_held(&mut self, key: HeldKey) {
let state = self.held.entry(key).or_insert(HeldState { last_seen: Instant::now(), just_pressed: false });
state.last_seen = Instant::now();
}
fn mark_pressed(&mut self, key: HeldKey) {
self.held.insert(key, HeldState { last_seen: Instant::now(), just_pressed: true });
}
fn release(&mut self, key: HeldKey) {
self.held.retain(|&k| k != key);
self.held.remove(&key);
}
pub fn is_held(&self, key: HeldKey) -> bool {
self.held.contains(&key)
self.held.contains_key(&key)
}
/// Drain all pending input events, update held key state,
/// and return one-shot actions (quit, paint, etc).
pub fn update(&mut self) -> Action {
let mut one_shot = Action::None;
/// Drain all pending input events, update held key state.
/// Returns one-shot actions (quit, paint, jump-on-press).
pub fn update(&mut self) -> Vec<Action> {
let mut one_shots = Vec::new();
self.jump_pressed = false;
let now = Instant::now();
// Expire stale held keys (no Repeat received within timeout)
self.held.retain(|_, state| now.duration_since(state.last_seen) < HOLD_TIMEOUT);
while event::poll(Duration::from_millis(0)).unwrap_or(false) {
let ev = match event::read() {
@@ -93,77 +108,109 @@ impl InputHandler {
};
if let Event::Key(KeyEvent { code, modifiers, kind, .. }) = ev {
let is_press = kind == KeyEventKind::Press || kind == KeyEventKind::Repeat;
let is_press = kind == KeyEventKind::Press;
let is_repeat = kind == KeyEventKind::Repeat;
let is_release = kind == KeyEventKind::Release;
if modifiers.contains(KeyModifiers::CONTROL) && code == KeyCode::Char('c') {
return Action::Quit;
}
if let Some(held_key) = Self::key_to_held(code) {
if is_press {
self.hold(held_key);
} else if is_release {
self.release(held_key);
}
one_shots.push(Action::Quit);
continue;
}
// Jump: only on initial press, not repeat
if is_press {
match code {
KeyCode::Char('q') => return Action::Quit,
KeyCode::Char('1') => { self.paint_brush = MaterialBrush::Sand; one_shot = Action::Paint(MaterialBrush::Sand); }
KeyCode::Char('2') => { self.paint_brush = MaterialBrush::Water; one_shot = Action::Paint(MaterialBrush::Water); }
KeyCode::Char('3') => { self.paint_brush = MaterialBrush::Stone; one_shot = Action::Paint(MaterialBrush::Stone); }
KeyCode::Char('4') => { self.paint_brush = MaterialBrush::Lava; one_shot = Action::Paint(MaterialBrush::Lava); }
KeyCode::Char('5') => { self.paint_brush = MaterialBrush::Wood; one_shot = Action::Paint(MaterialBrush::Wood); }
KeyCode::Char('6') => { self.paint_brush = MaterialBrush::Acid; one_shot = Action::Paint(MaterialBrush::Acid); }
KeyCode::Char('7') => { self.paint_brush = MaterialBrush::Grass; one_shot = Action::Paint(MaterialBrush::Grass); }
KeyCode::Char('8') => { self.paint_brush = MaterialBrush::Dirt; one_shot = Action::Paint(MaterialBrush::Dirt); }
KeyCode::Char('9') => { self.paint_brush = MaterialBrush::Fire; one_shot = Action::Paint(MaterialBrush::Fire); }
KeyCode::Char('0') => { self.paint_brush = MaterialBrush::Flesh; one_shot = Action::Paint(MaterialBrush::Flesh); }
KeyCode::Char('x') => { self.paint_brush = MaterialBrush::Erase; one_shot = Action::Paint(MaterialBrush::Erase); }
KeyCode::Up | KeyCode::Char('w') | KeyCode::Char(' ') => {
self.jump_pressed = true;
continue;
}
KeyCode::Char('q') => {
one_shots.push(Action::Quit);
continue;
}
KeyCode::Char('1') => { self.paint_brush = MaterialBrush::Sand; one_shots.push(Action::Paint(MaterialBrush::Sand)); continue; }
KeyCode::Char('2') => { self.paint_brush = MaterialBrush::Water; one_shots.push(Action::Paint(MaterialBrush::Water)); continue; }
KeyCode::Char('3') => { self.paint_brush = MaterialBrush::Stone; one_shots.push(Action::Paint(MaterialBrush::Stone)); continue; }
KeyCode::Char('4') => { self.paint_brush = MaterialBrush::Lava; one_shots.push(Action::Paint(MaterialBrush::Lava)); continue; }
KeyCode::Char('5') => { self.paint_brush = MaterialBrush::Wood; one_shots.push(Action::Paint(MaterialBrush::Wood)); continue; }
KeyCode::Char('6') => { self.paint_brush = MaterialBrush::Acid; one_shots.push(Action::Paint(MaterialBrush::Acid)); continue; }
KeyCode::Char('7') => { self.paint_brush = MaterialBrush::Grass; one_shots.push(Action::Paint(MaterialBrush::Grass)); continue; }
KeyCode::Char('8') => { self.paint_brush = MaterialBrush::Dirt; one_shots.push(Action::Paint(MaterialBrush::Dirt)); continue; }
KeyCode::Char('9') => { self.paint_brush = MaterialBrush::Fire; one_shots.push(Action::Paint(MaterialBrush::Fire)); continue; }
KeyCode::Char('0') => { self.paint_brush = MaterialBrush::Flesh; one_shots.push(Action::Paint(MaterialBrush::Flesh)); continue; }
KeyCode::Char('x') => { self.paint_brush = MaterialBrush::Erase; one_shots.push(Action::Paint(MaterialBrush::Erase)); continue; }
_ => {}
}
}
// Movement keys: track held state
if let Some(held_key) = Self::key_to_held(code) {
if is_press {
self.mark_pressed(held_key);
} else if is_repeat {
self.mark_held(held_key);
} else if is_release {
self.release(held_key);
}
}
}
}
one_shot
// Clear just_pressed flags
for state in self.held.values_mut() {
state.just_pressed = false;
}
one_shots
}
pub fn held_actions(&self) -> Vec<Action> {
let mut actions = Vec::new();
if self.is_held(HeldKey::Left) {
// Left/Right: last pressed wins if both held
let left = self.is_held(HeldKey::Left);
let right = self.is_held(HeldKey::Right);
if left && !right {
actions.push(Action::MoveLeft);
}
if self.is_held(HeldKey::Right) {
} else if right && !left {
actions.push(Action::MoveRight);
} else if left && right {
// Both held — check which was pressed more recently
let left_time = self.held.get(&HeldKey::Left).map(|s| s.last_seen);
let right_time = self.held.get(&HeldKey::Right).map(|s| s.last_seen);
match (left_time, right_time) {
(Some(lt), Some(rt)) => {
if lt > rt {
actions.push(Action::MoveLeft);
} else {
actions.push(Action::MoveRight);
}
}
_ => {}
}
}
if self.is_held(HeldKey::Jump) {
actions.push(Action::Jump);
}
if self.is_held(HeldKey::CamLeft) {
actions.push(Action::MoveCameraLeft);
}
if self.is_held(HeldKey::CamRight) {
actions.push(Action::MoveCameraRight);
}
if self.is_held(HeldKey::CamUp) {
actions.push(Action::MoveCameraUp);
}
if self.is_held(HeldKey::CamDown) {
actions.push(Action::MoveCameraDown);
}
if self.is_held(HeldKey::CamLeft) { actions.push(Action::MoveCameraLeft); }
if self.is_held(HeldKey::CamRight) { actions.push(Action::MoveCameraRight); }
if self.is_held(HeldKey::CamUp) { actions.push(Action::MoveCameraUp); }
if self.is_held(HeldKey::CamDown) { actions.push(Action::MoveCameraDown); }
actions
}
pub fn jump_requested(&self) -> bool {
self.jump_pressed
}
pub fn release_all(&mut self) {
self.held.clear();
self.jump_pressed = false;
}
pub fn poll(&mut self) -> Action {
self.update()
let actions = self.update();
actions.into_iter().next().unwrap_or(Action::None)
}
}