use concinnity_core::components::{
ControlsCommand, FrameInput, GamepadButton, GamepadMap, NavDirection,
};
use concinnity_core::ecs::{
Access, CursorState, EventCursor, FlyCam, FrameTime, MenuActive, PipelineContext, ScreenStack,
StepResult, System,
};
use concinnity_core::input::snapshot::InputSnapshot;
use crate::ecs::InputMailbox;
use crate::input::gamepad::{GamepadSource, PadSnapshot, PadState};
use crate::input::nav::NavRepeat;
const NAV_DT_MAX: f32 = 0.25;
fn nav_dt(frame: FrameTime) -> f32 {
frame.dt.clamp(0.0, NAV_DT_MAX)
}
#[derive(Debug, Default)]
pub(crate) struct InputSystem {
gamepad: GamepadSource,
pad: PadState,
map: GamepadMap,
deadzone: f32,
nav: NavRepeat,
controls_cursor: EventCursor,
}
impl InputSystem {
pub(crate) fn new() -> Self {
Self {
deadzone: crate::settings::DEFAULT_GAMEPAD_DEADZONE,
..Self::default()
}
}
}
fn compose_frame_input(
raw: &InputSnapshot,
pad: &PadSnapshot,
map: GamepadMap,
nav: Option<NavDirection>,
gameplay: bool,
) -> FrameInput {
let axis = |v: [f32; 2]| if gameplay { v } else { [0.0, 0.0] };
FrameInput {
forward: (raw.forward || pad.held(GamepadButton::DpadUp)) && gameplay,
backward: (raw.backward || pad.held(GamepadButton::DpadDown)) && gameplay,
left: (raw.left || pad.held(GamepadButton::DpadLeft)) && gameplay,
right: (raw.right || pad.held(GamepadButton::DpadRight)) && gameplay,
sprint: (raw.sprint || pad.held(map.sprint)) && gameplay,
interact: (raw.interact || pad.pressed(map.interact)) && gameplay,
jump: (raw.jump || pad.pressed(map.jump)) && gameplay,
move_axis: axis(pad.move_axis),
look_axis: axis(pad.look_axis),
mouse_dx: if gameplay { raw.mouse_dx } else { 0.0 },
mouse_dy: if gameplay { raw.mouse_dy } else { 0.0 },
scroll_delta: raw.scroll_delta,
mouse_x: raw.mouse_x,
mouse_y: raw.mouse_y,
left_click: raw.left_click,
left_button_down: raw.left_button_down,
right_click: raw.right_click,
viewport: [0.0, 0.0],
top_inset: 0.0,
hud_toggle: raw.hud_toggle,
escape: raw.escape || pad.pressed(GamepadButton::Start),
ctrl: raw.ctrl,
shift: raw.sprint,
alt: raw.alt,
cmd: raw.cmd,
key_events: raw.key_events.clone(),
captured_button: pad.first_pressed(),
nav,
confirm: pad.pressed(GamepadButton::South),
back: pad.pressed(GamepadButton::East),
}
}
impl System for InputSystem {
fn access(&self) -> Access {
Access::new()
.writes_components(crate::component_mask![FrameInput])
.reads_resources(crate::resource_mask![
MenuActive,
ScreenStack,
FlyCam,
ControlsCommand,
FrameTime,
])
.writes_resources(crate::resource_mask![
crate::ecs::InputMailbox,
CursorState,
FrameInput,
])
}
fn init(&mut self, ctx: &mut PipelineContext) {
let settings = crate::config::Settings::load(
ctx.resource::<concinnity_host::store::paths::StateTree>(),
);
self.map = settings.controls.gamepad_map.unwrap_or_default();
if let Some(dz) = settings.controls.gamepad_deadzone {
self.deadzone = dz;
}
if ctx.query::<FrameInput>().next().is_none() {
ctx.push(FrameInput::default());
}
}
fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
let Some(packet) = ctx
.resource_mut::<InputMailbox>()
.and_then(|mailbox| mailbox.0.take())
else {
return StepResult::Continue;
};
let raw = packet.raw;
let cursor_outside = packet.cursor_outside_window;
let (vp_w, vp_h) = packet.viewport;
let top_inset = packet.top_inset;
ctx.insert_resource(CursorState {
pos: (raw.mouse_x, raw.mouse_y),
outside_window: cursor_outside,
});
if let Some(events) = ctx.events::<ControlsCommand>() {
for cmd in events.read(&mut self.controls_cursor) {
if let Some(map) = cmd.gamepad_map {
self.map = map;
}
if let Some(dz) = cmd.gamepad_deadzone {
self.deadzone = dz;
}
}
}
self.gamepad.poll(&mut self.pad);
let pad = self.pad.snapshot(self.deadzone);
let dt = nav_dt(ctx.resource::<FrameTime>().copied().unwrap_or_default());
let dpad_held = [
pad.held(GamepadButton::DpadUp),
pad.held(GamepadButton::DpadDown),
pad.held(GamepadButton::DpadLeft),
pad.held(GamepadButton::DpadRight),
];
let nav = self.nav.step(dpad_held, pad.move_axis, dt);
let menu_active = ctx.resource::<MenuActive>().map(|m| m.0).unwrap_or(false);
let screen_captures = ctx
.resource::<ScreenStack>()
.is_some_and(|s| s.captures_input);
let fly = ctx.resource::<FlyCam>().is_some_and(|f| f.0);
let gameplay = (!menu_active && !screen_captures) || fly;
let frame_input = FrameInput {
viewport: [vp_w, vp_h],
top_inset,
..compose_frame_input(&raw, &pad, self.map, nav, gameplay)
};
ctx.insert_resource(frame_input.clone());
if let Some(slot) = ctx.query_mut::<FrameInput>().next() {
*slot = frame_input;
}
StepResult::Continue
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::gamepad::{PadAxis, PadEvent};
use concinnity_core::components::GamepadAction;
use concinnity_core::input::snapshot::InputPacket;
fn pad_snapshot(events: &[PadEvent]) -> PadSnapshot {
let mut state = PadState::default();
state.ingest(PadEvent::Connected { pad: 0 });
for &e in events {
state.ingest(e);
}
state.snapshot(0.15)
}
fn press(button: GamepadButton) -> PadEvent {
PadEvent::Button {
pad: 0,
button,
pressed: true,
}
}
#[test]
fn nav_dt_clamps_the_frame_time() {
let frame = |dt| FrameTime { dt, elapsed: 0.0 };
assert_eq!(nav_dt(frame(0.1)), 0.1);
assert_eq!(nav_dt(frame(3.0)), NAV_DT_MAX);
assert_eq!(nav_dt(frame(-1.0)), 0.0);
}
#[test]
fn a_sampled_packet_carries_the_backends_top_content_inset() {
use std::sync::{Arc, Mutex};
let state = Arc::new(Mutex::new(crate::gfx::mock_backend::MockState::default()));
let mut backend = crate::gfx::mock_backend::MockBackend::transplant(state.clone(), None);
assert_eq!(InputPacket::sample(&mut backend).top_inset, 0.0);
state.lock().unwrap().top_inset = 28.0;
assert_eq!(InputPacket::sample(&mut backend).top_inset, 28.0);
}
#[test]
fn gamepad_buttons_merge_into_the_boolean_fields() {
let pad = pad_snapshot(&[
press(GamepadButton::DpadUp),
press(GamepadMap::DEFAULT.jump),
press(GamepadMap::DEFAULT.sprint),
press(GamepadMap::DEFAULT.interact),
press(GamepadButton::Start),
]);
let input = compose_frame_input(
&InputSnapshot::default(),
&pad,
GamepadMap::DEFAULT,
None,
true,
);
assert!(input.forward, "d-pad up drives forward");
assert!(input.jump && input.sprint && input.interact);
assert!(input.escape, "Start mirrors Escape");
assert!(!input.backward && !input.left && !input.right);
}
#[test]
fn keyboard_fields_pass_through_unchanged() {
let raw = InputSnapshot {
forward: true,
sprint: true,
mouse_dx: 3.0,
mouse_dy: -2.0,
scroll_delta: 1.5,
..Default::default()
};
let input = compose_frame_input(
&raw,
&PadSnapshot::default(),
GamepadMap::DEFAULT,
None,
true,
);
assert!(input.forward && input.sprint);
assert_eq!(input.mouse_dx, 3.0);
assert_eq!(input.mouse_dy, -2.0);
assert_eq!(input.scroll_delta, 1.5);
assert_eq!(input.move_axis, [0.0, 0.0]);
assert_eq!(input.captured_button, None);
}
#[test]
fn ui_modifiers_stay_live_behind_the_menu_gate() {
let raw = InputSnapshot {
ctrl: true,
alt: true,
cmd: true,
..Default::default()
};
for gameplay in [true, false] {
let input = compose_frame_input(
&raw,
&PadSnapshot::default(),
GamepadMap::DEFAULT,
None,
gameplay,
);
assert!(input.ctrl && input.alt && input.cmd, "gameplay={gameplay}");
}
}
#[test]
fn rebound_map_routes_buttons_to_their_actions() {
let mut map = GamepadMap::DEFAULT;
map.rebind(GamepadAction::Jump, GamepadButton::North);
let pad = pad_snapshot(&[press(GamepadButton::North)]);
let input = compose_frame_input(&InputSnapshot::default(), &pad, map, None, true);
assert!(input.jump, "jump follows the rebound button");
let default_pad = pad_snapshot(&[press(GamepadButton::South)]);
let input = compose_frame_input(&InputSnapshot::default(), &default_pad, map, None, true);
assert!(!input.jump, "the old button no longer jumps");
}
#[test]
fn menu_gate_freezes_gameplay_but_keeps_capture_and_escape() {
let pad = pad_snapshot(&[
press(GamepadButton::DpadUp),
press(GamepadMap::DEFAULT.jump),
press(GamepadButton::Start),
PadEvent::Axis {
pad: 0,
axis: PadAxis::LeftY,
value: 1.0,
},
PadEvent::Axis {
pad: 0,
axis: PadAxis::RightX,
value: 1.0,
},
]);
let raw = InputSnapshot {
forward: true,
sprint: true,
mouse_dx: 5.0,
..Default::default()
};
let input = compose_frame_input(&raw, &pad, GamepadMap::DEFAULT, None, false);
assert!(!input.forward && !input.jump);
assert!(!input.sprint, "sprint freezes behind the menu");
assert!(input.shift, "the raw Shift modifier stays live for UI");
assert_eq!(input.move_axis, [0.0, 0.0]);
assert_eq!(input.look_axis, [0.0, 0.0]);
assert_eq!(input.mouse_dx, 0.0);
assert_eq!(input.captured_button, Some(GamepadMap::DEFAULT.jump));
assert!(input.escape);
}
#[test]
fn mouse_clicks_pass_the_menu_gate() {
let raw = InputSnapshot {
left_click: true,
right_click: true,
..Default::default()
};
let pad = pad_snapshot(&[]);
let input = compose_frame_input(&raw, &pad, GamepadMap::DEFAULT, None, false);
assert!(input.left_click);
assert!(input.right_click);
}
#[test]
fn stick_axes_publish_when_gameplay_is_live() {
let pad = pad_snapshot(&[
PadEvent::Axis {
pad: 0,
axis: PadAxis::LeftY,
value: 1.0,
},
PadEvent::Axis {
pad: 0,
axis: PadAxis::RightY,
value: 1.0,
},
]);
let input = compose_frame_input(
&InputSnapshot::default(),
&pad,
GamepadMap::DEFAULT,
None,
true,
);
assert!(
(input.move_axis[1] - 1.0).abs() < 1e-6,
"{:?}",
input.move_axis
);
assert!(input.look_axis[1] < -0.9, "{:?}", input.look_axis);
}
#[test]
fn nav_confirm_and_back_pass_the_menu_gate() {
let pad = pad_snapshot(&[press(GamepadButton::South), press(GamepadButton::East)]);
let nav = Some(NavDirection::Down);
let input = compose_frame_input(
&InputSnapshot::default(),
&pad,
GamepadMap::DEFAULT,
nav,
false,
);
assert_eq!(input.nav, nav);
assert!(input.confirm, "South edge surfaces behind the menu");
assert!(input.back, "East edge surfaces behind the menu");
let input = compose_frame_input(
&InputSnapshot::default(),
&pad,
GamepadMap::DEFAULT,
nav,
true,
);
assert!(input.confirm && input.back);
}
}