From f1f8c0d55484c1dfc929eaaf51b8b48731e91ee5 Mon Sep 17 00:00:00 2001 From: chazmm <246197476+chasmlol@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:04:22 +0100 Subject: [PATCH] Add controller support: button layouts, stick aim and aim assist Controller buttons bind through the same table as keys: every command on the controls page shows its key and its button, and either is rebound by pressing a key or a button. The options gain a Controller page: button layout (Default, Tactical, Lefty, Bumper Jumper, Bumper Jumper Tactical), stick layout, look and aim-down-sight sensitivity, invert, response curve, aim assist, vibration and deadzones, all kept in settings.cfg. The look stick turns at rates that build up to full deflection, slower down the sight and scaled by the zoom. Aim assist works over visible enemies within each weapon's own aim assist ranges, read from the weapon data: slowdown and lock-on, and on Full a pull onto a target when the sight comes up. It applies only while aiming with the controller. Menus take the D-pad or left stick, A and B, and Start opens them. Focus moves by screen position rather than by menu file order, which jumped around the options pages for arrow keys too; left and right on a button cross columns. The Advanced Video page no longer draws the FOV slider over Bloom. The controller in use is the last one pressed or moved, so a virtual joystick or an idle second pad stays out of the way; rumble follows it and the vibration setting. Use/reload reloads when nothing usable is in reach. Co-Authored-By: Claude Opus 5.5 --- crates/asset_game/src/weapon_catalog.rs | 14 + crates/audio/src/rumble.rs | 15 +- crates/console/src/binds.rs | 211 +++++- crates/console/src/binds/key_names.rs | 25 +- crates/console/src/gamepad.rs | 167 +++++ crates/console/src/lib.rs | 5 +- crates/console/src/plugin/mod.rs | 53 +- crates/console/src/user_settings.rs | 136 +++- crates/fastfile_iw4/src/load/weapon.rs | 9 + crates/fastfile_iw4/src/zone.rs | 3 + crates/frame/src/lib.rs | 2 + crates/frame/src/pad.rs | 6 + crates/frame/src/settings.rs | 50 ++ crates/hud/src/menus/mod.rs | 14 +- crates/hud/src/menus/runtime.rs | 89 +++ crates/net/src/client/input.rs | 38 +- crates/net/src/client/mod.rs | 1 + crates/net/src/client/pad_aim.rs | 272 ++++++++ crates/net/src/client/runtime.rs | 90 +++ crates/session/src/combat_table.rs | 5 + crates/ui/menus/controller.json | 866 ++++++++++++++++++++++++ crates/ui/menus/settings.json | 2 + crates/ui/src/menu.rs | 14 +- crates/weapon_iw4/src/lib.rs | 2 +- crates/weapon_iw4/src/tick.rs | 19 + 25 files changed, 2082 insertions(+), 26 deletions(-) create mode 100644 crates/console/src/gamepad.rs create mode 100644 crates/frame/src/pad.rs create mode 100644 crates/net/src/client/pad_aim.rs create mode 100644 crates/ui/menus/controller.json diff --git a/crates/asset_game/src/weapon_catalog.rs b/crates/asset_game/src/weapon_catalog.rs index d6ea6a8..78bcc4c 100644 --- a/crates/asset_game/src/weapon_catalog.rs +++ b/crates/asset_game/src/weapon_catalog.rs @@ -217,6 +217,12 @@ pub struct WeaponBodyFacts { pub sprint_drop_time_ms: i32, pub fuse_time_ms: i32, + pub auto_aim_range: f32, + + pub aim_assist_range: f32, + + pub aim_assist_range_ads: f32, + pub cook_off_hold: bool, pub clip_only: bool, @@ -1729,6 +1735,9 @@ impl WeaponCatalog { sprint_loop_time_ms: geometry.sprint_loop_time_ms, sprint_drop_time_ms: geometry.sprint_drop_time_ms, fuse_time_ms: geometry.fuse_time_ms, + auto_aim_range: geometry.auto_aim_range, + aim_assist_range: geometry.aim_assist_range, + aim_assist_range_ads: geometry.aim_assist_range_ads, cook_off_hold: geometry.cook_off_hold, clip_only: geometry.clip_only, timed_detonation: geometry.timed_detonation, @@ -4691,6 +4700,11 @@ fn merge_body_facts(dst: &mut WeaponBodyFacts, src: WeaponBodyFacts) { if dst.fuse_time_ms == 0 { dst.fuse_time_ms = src.fuse_time_ms; } + if dst.aim_assist_range == 0.0 { + dst.auto_aim_range = src.auto_aim_range; + dst.aim_assist_range = src.aim_assist_range; + dst.aim_assist_range_ads = src.aim_assist_range_ads; + } if dst.sprint_raise_time_ms == 0 { dst.sprint_raise_time_ms = src.sprint_raise_time_ms; } diff --git a/crates/audio/src/rumble.rs b/crates/audio/src/rumble.rs index 09888b5..1ef441b 100644 --- a/crates/audio/src/rumble.rs +++ b/crates/audio/src/rumble.rs @@ -151,8 +151,18 @@ fn update( gamepads: Query>, mut state: Local, mut output: MessageWriter, + settings: Res, + active: Option>, ) { let now = clock.time(); + if !settings.pad_vibration { + for _ in requests.read() {} + state.active.clear(); + if let Some((gamepad, _)) = state.output.take() { + output.write(GamepadRumbleRequest::Stop { gamepad }); + } + return; + } let owner = bank.as_ref().and_then(|bank| { let meta = presented.snapshot()?.meta.for_client(local.0)?; (meta.lifecycle == sim::ClientLifecycle::Alive).then_some(( @@ -198,7 +208,10 @@ fn update( intensity.strong_motor = intensity.strong_motor.max(current.strong_motor); intensity.weak_motor = intensity.weak_motor.max(current.weak_motor); } - let selected = gamepads.iter().min_by_key(|entity| entity.to_bits()); + let selected = active + .and_then(|active| active.0) + .filter(|entity| gamepads.contains(*entity)) + .or_else(|| gamepads.iter().min_by_key(|entity| entity.to_bits())); if state .output .is_some_and(|(entity, _)| Some(entity) != selected) diff --git a/crates/console/src/binds.rs b/crates/console/src/binds.rs index 855d183..1542fe2 100644 --- a/crates/console/src/binds.rs +++ b/crates/console/src/binds.rs @@ -3,6 +3,7 @@ use input_iw4::{ClientInput, command_id_lookup, command_name, key_event}; use std::collections::HashMap; use bevy::input::ButtonInput; +use bevy::input::gamepad::{Gamepad, GamepadButton}; use bevy::input::keyboard::KeyCode; use bevy::input::mouse::{MouseButton, MouseScrollUnit}; use bevy::prelude::Resource; @@ -21,6 +22,174 @@ pub enum BindButton { Mouse(MouseButton), WheelUp, WheelDown, + Pad(PadButton), +} + +impl BindButton { + pub fn is_pad(self) -> bool { + matches!(self, Self::Pad(_)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum PadButton { + A, + B, + X, + Y, + LeftBumper, + RightBumper, + LeftTrigger, + RightTrigger, + LeftStick, + RightStick, + DpadUp, + DpadDown, + DpadLeft, + DpadRight, + Back, + Start, +} + +impl PadButton { + pub const ALL: [Self; 16] = [ + Self::A, + Self::B, + Self::X, + Self::Y, + Self::LeftBumper, + Self::RightBumper, + Self::LeftTrigger, + Self::RightTrigger, + Self::LeftStick, + Self::RightStick, + Self::DpadUp, + Self::DpadDown, + Self::DpadLeft, + Self::DpadRight, + Self::Back, + Self::Start, + ]; + + pub const fn gamepad_button(self) -> GamepadButton { + match self { + Self::A => GamepadButton::South, + Self::B => GamepadButton::East, + Self::X => GamepadButton::West, + Self::Y => GamepadButton::North, + Self::LeftBumper => GamepadButton::LeftTrigger, + Self::RightBumper => GamepadButton::RightTrigger, + Self::LeftTrigger => GamepadButton::LeftTrigger2, + Self::RightTrigger => GamepadButton::RightTrigger2, + Self::LeftStick => GamepadButton::LeftThumb, + Self::RightStick => GamepadButton::RightThumb, + Self::DpadUp => GamepadButton::DPadUp, + Self::DpadDown => GamepadButton::DPadDown, + Self::DpadLeft => GamepadButton::DPadLeft, + Self::DpadRight => GamepadButton::DPadRight, + Self::Back => GamepadButton::Select, + Self::Start => GamepadButton::Start, + } + } + + pub fn from_gamepad_button(button: GamepadButton) -> Option { + Self::ALL + .into_iter() + .find(|pad| pad.gamepad_button() == button) + } + + pub const fn console_name(self) -> &'static str { + match self { + Self::A => "BUTTON_A", + Self::B => "BUTTON_B", + Self::X => "BUTTON_X", + Self::Y => "BUTTON_Y", + Self::LeftBumper => "BUTTON_LSHLDR", + Self::RightBumper => "BUTTON_RSHLDR", + Self::LeftTrigger => "BUTTON_LTRIG", + Self::RightTrigger => "BUTTON_RTRIG", + Self::LeftStick => "BUTTON_LSTICK", + Self::RightStick => "BUTTON_RSTICK", + Self::DpadUp => "DPAD_UP", + Self::DpadDown => "DPAD_DOWN", + Self::DpadLeft => "DPAD_LEFT", + Self::DpadRight => "DPAD_RIGHT", + Self::Back => "BUTTON_BACK", + Self::Start => "BUTTON_START", + } + } + + pub const fn label(self) -> &'static str { + match self { + Self::A => "A", + Self::B => "B", + Self::X => "X", + Self::Y => "Y", + Self::LeftBumper => "LB", + Self::RightBumper => "RB", + Self::LeftTrigger => "LT", + Self::RightTrigger => "RT", + Self::LeftStick => "LS", + Self::RightStick => "RS", + Self::DpadUp => "D-UP", + Self::DpadDown => "D-DOWN", + Self::DpadLeft => "D-LEFT", + Self::DpadRight => "D-RIGHT", + Self::Back => "BACK", + Self::Start => "START", + } + } + + const fn keynum(self) -> usize { + 190 + self as usize + } +} + +pub fn pad_layout(layout: usize) -> Vec<(PadButton, &'static str)> { + use PadButton::*; + let mut binds = vec![ + (RightTrigger, "+attack"), + (LeftTrigger, "+speed_throw"), + (RightBumper, "+frag"), + (LeftBumper, "+smoke"), + (A, "+gostand"), + (B, "+stance"), + (X, "+usereload"), + (Y, "weapnext"), + (LeftStick, "+breath_sprint"), + (RightStick, "+melee"), + (DpadUp, "+actionslot 1"), + (DpadDown, "+actionslot 2"), + (DpadLeft, "+actionslot 3"), + (DpadRight, "+actionslot 4"), + (Back, "+scores"), + ]; + let mut set = |button: PadButton, command: &'static str| { + binds.retain(|(b, _)| *b != button); + binds.push((button, command)); + }; + let tactical = |set: &mut dyn FnMut(PadButton, &'static str)| { + set(B, "+melee"); + set(RightStick, "+stance"); + }; + match layout { + 1 => tactical(&mut set), + 2 => { + set(LeftTrigger, "+attack"); + set(RightTrigger, "+speed_throw"); + set(LeftBumper, "+frag"); + set(RightBumper, "+smoke"); + } + 3 | 4 => { + set(LeftBumper, "+gostand"); + set(A, "+smoke"); + if layout == 4 { + tactical(&mut set); + } + } + _ => {} + } + binds } pub(crate) fn wheel_button(y: f32) -> Option { @@ -50,11 +219,21 @@ pub(crate) fn wheel_detents(unit: MouseScrollUnit, y: f32, carry: &mut f32) -> i pub struct BindInputs<'a> { pub keys: &'a ButtonInput, pub mouse: &'a ButtonInput, + pub pad: Option<&'a Gamepad>, } impl<'a> BindInputs<'a> { pub fn new(keys: &'a ButtonInput, mouse: &'a ButtonInput) -> Self { - Self { keys, mouse } + Self { + keys, + mouse, + pad: None, + } + } + + pub fn with_pad(mut self, pad: Option<&'a Gamepad>) -> Self { + self.pad = pad; + self } pub fn pressed(&self, button: BindButton) -> bool { @@ -62,6 +241,9 @@ impl<'a> BindInputs<'a> { BindButton::Key(key) => self.keys.pressed(key), BindButton::Mouse(btn) => self.mouse.pressed(btn), BindButton::WheelUp | BindButton::WheelDown => false, + BindButton::Pad(btn) => self + .pad + .is_some_and(|pad| pad.pressed(btn.gamepad_button())), } } @@ -70,6 +252,9 @@ impl<'a> BindInputs<'a> { BindButton::Key(key) => self.keys.just_pressed(key), BindButton::Mouse(btn) => self.mouse.just_pressed(btn), BindButton::WheelUp | BindButton::WheelDown => false, + BindButton::Pad(btn) => self + .pad + .is_some_and(|pad| pad.just_pressed(btn.gamepad_button())), } } @@ -78,6 +263,9 @@ impl<'a> BindInputs<'a> { BindButton::Key(key) => self.keys.just_released(key), BindButton::Mouse(btn) => self.mouse.just_released(btn), BindButton::WheelUp | BindButton::WheelDown => false, + BindButton::Pad(btn) => self + .pad + .is_some_and(|pad| pad.just_released(btn.gamepad_button())), } } } @@ -91,6 +279,7 @@ impl KeyBinds { pub fn apply_defaults(&mut self) { self.map.clear(); let _ = self.apply_script(DEFAULT_CONTROLS); + self.apply_pad_layout(0); } pub fn apply_script(&mut self, script: &str) -> Vec { @@ -154,6 +343,26 @@ impl KeyBinds { self.map.clear(); } + pub fn clear_command_on(&mut self, id: u32, pad: bool) -> bool { + let before = self.map.len(); + self.map + .retain(|button, bound| *bound != id || button.is_pad() != pad); + self.map.len() != before + } + + pub fn apply_pad_layout(&mut self, layout: usize) { + self.map.retain(|button, _| !button.is_pad()); + for (button, command) in pad_layout(layout) { + if let Some(id) = command_id_lookup(command) { + self.set(BindButton::Pad(button), id); + } + } + } + + pub fn has_pad_binds(&self) -> bool { + self.map.keys().any(|button| button.is_pad()) + } + pub fn get(&self, button: BindButton) -> Option { self.map.get(&button).copied() } diff --git a/crates/console/src/binds/key_names.rs b/crates/console/src/binds/key_names.rs index 0deb946..0f930bc 100644 --- a/crates/console/src/binds/key_names.rs +++ b/crates/console/src/binds/key_names.rs @@ -33,6 +33,21 @@ macro_rules! key_catalog { $( $($m_hint,)? )* "mwheelup", "mwheeldown", + "BUTTON_A", + "BUTTON_B", + "BUTTON_X", + "BUTTON_Y", + "BUTTON_LSHLDR", + "BUTTON_RSHLDR", + "BUTTON_LTRIG", + "BUTTON_RTRIG", + "BUTTON_LSTICK", + "BUTTON_RSTICK", + "DPAD_UP", + "DPAD_DOWN", + "DPAD_LEFT", + "DPAD_RIGHT", + "BUTTON_BACK", ]; pub fn host_keynum(button: BindButton) -> usize { @@ -43,6 +58,7 @@ macro_rules! key_catalog { }, BindButton::WheelUp => 186, BindButton::WheelDown => 187, + BindButton::Pad(pad) => pad.keynum(), BindButton::Key(key) => match key { $(KeyCode::$k_button => $k_code,)* _ => 62, @@ -65,7 +81,13 @@ macro_rules! key_catalog { )* "mwheelup" | "wheelup" => Some(vec![BindButton::WheelUp]), "mwheeldown" | "wheeldown" => Some(vec![BindButton::WheelDown]), - _ => None, + other => { + let upper = other.to_ascii_uppercase(); + super::PadButton::ALL + .into_iter() + .find(|pad| pad.console_name() == upper || pad.label() == upper) + .map(|pad| vec![BindButton::Pad(pad)]) + } } } @@ -82,6 +104,7 @@ macro_rules! key_catalog { }, BindButton::WheelUp => "MWHEELUP".into(), BindButton::WheelDown => "MWHEELDOWN".into(), + BindButton::Pad(pad) => pad.console_name().into(), } } diff --git a/crates/console/src/gamepad.rs b/crates/console/src/gamepad.rs new file mode 100644 index 0000000..5538b7b --- /dev/null +++ b/crates/console/src/gamepad.rs @@ -0,0 +1,167 @@ +use bevy::input::gamepad::{Gamepad, GamepadButton, GamepadConnection, GamepadConnectionEvent}; +use bevy::prelude::*; +use frame::{UiMenuKey, UiMenuRequest}; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct Sticks { + /// Forward and right. + pub movement: Vec2, + /// Right and up. + pub look: Vec2, +} + +fn radial(stick: Vec2, deadzone: f32) -> Vec2 { + let length = stick.length(); + if length <= deadzone || length <= f32::EPSILON { + return Vec2::ZERO; + } + let scaled = ((length - deadzone) / (1.0 - deadzone).max(0.01)).min(1.0); + stick / length * scaled +} + +pub(crate) fn sticks(pad: &Gamepad, settings: &frame::GameSettings) -> Sticks { + let left = radial(pad.left_stick(), settings.pad_deadzone_left); + let right = radial(pad.right_stick(), settings.pad_deadzone_right); + match settings.pad_stick_layout { + 1 => Sticks { + movement: Vec2::new(right.y, right.x), + look: left, + }, + 2 => Sticks { + movement: Vec2::new(left.y, right.x), + look: Vec2::new(left.x, right.y), + }, + 3 => Sticks { + movement: Vec2::new(right.y, left.x), + look: Vec2::new(right.x, left.y), + }, + _ => Sticks { + movement: Vec2::new(left.y, left.x), + look: right, + }, + } +} + +fn curve(deflection: f32, kind: u8) -> f32 { + let d = deflection.clamp(0.0, 1.0); + match kind { + 1 => d, + 2 => 1.0 - (1.0 - d) * (1.0 - d), + _ => 0.35 * d + 0.65 * d * d * d, + } +} + +pub(crate) fn shaped_look(look: Vec2, settings: &frame::GameSettings) -> Vec2 { + let deflection = look.length(); + if deflection <= f32::EPSILON { + return Vec2::ZERO; + } + let shaped = look / deflection * curve(deflection, settings.pad_curve); + Vec2::new( + shaped.x, + if settings.pad_invert { + -shaped.y + } else { + shaped.y + }, + ) +} + +pub(crate) fn track_active_pad( + gamepads: Query<(Entity, &Gamepad, Option<&Name>)>, + mut connections: MessageReader, + mut active: ResMut, +) { + for event in connections.read() { + match &event.connection { + GamepadConnection::Connected { name, .. } => { + diag::info!(Ui, "controller connected: {name}"); + } + GamepadConnection::Disconnected => { + diag::info!(Ui, "controller disconnected"); + } + } + } + if active.0.is_some_and(|entity| gamepads.get(entity).is_err()) { + active.0 = None; + } + for (entity, pad, name) in &gamepads { + let moved = pad.left_stick().length() > 0.5 || pad.right_stick().length() > 0.5; + if (pad.get_just_pressed().next().is_some() || moved) && active.0 != Some(entity) { + diag::info!( + Ui, + "controller in use: {}", + name.map_or("unnamed", |n| n.as_str()) + ); + active.0 = Some(entity); + } + } +} + +const REPEAT_DELAY: f32 = 0.4; +const REPEAT_EVERY: f32 = 0.12; + +pub(crate) fn drive_menus_with_pad( + gamepads: Query<&Gamepad>, + active: Res, + script_menus: Option>, + capture: Res, + time: Res