Merge pull request #11026 from martipops/fix/pad-read

Controllers: fixes from reviewing the pad reader, typing and joining
This commit is contained in:
rdbt authored and GitHub committed 2026-11-07 12:34:00 +00:00
commit 2f0b066f38
25 files changed
+686 -71

No files matched your search

+24 -2
View File
@@ -537,6 +537,8 @@ pub fn steam_keyboard(choice: TextKeyboard) -> bool {
pub struct PadSettings {
pub enabled: bool,
pub vibration: bool,
/// The pad keeps driving the game while its window has no focus.
pub play_in_background: bool,
/// A DualSense's triggers push back (Horizon's; docs/controllers.md).
pub adaptive_triggers: bool,
pub lightbar: Lightbar,
@@ -569,6 +571,7 @@ impl Default for PadSettings {
Self {
enabled: true,
vibration: true,
play_in_background: false,
adaptive_triggers: true,
lightbar: Lightbar::Health,
look_sensitivity: 0.5,
@@ -635,6 +638,7 @@ impl PadSettings {
match key.trim() {
"enabled" => s.enabled = flag(s.enabled),
"vibration" => s.vibration = flag(s.vibration),
"playInBackground" => s.play_in_background = flag(s.play_in_background),
"adaptiveTriggers" => s.adaptive_triggers = flag(s.adaptive_triggers),
"lightbar" => s.lightbar = Lightbar::parse(value),
"lookSensitivity" => s.look_sensitivity = unit(s.look_sensitivity),
@@ -715,6 +719,10 @@ impl PadSettings {
button.map_or("none", Button::name)
));
}
text.push_str(&format!(
"playInBackground:{}\n",
bool_text(self.play_in_background)
));
text.push_str("bindLayout:1\n");
text
}
@@ -786,7 +794,8 @@ pub fn stick_axes(
{
// Default map: RightStickX is the stick's Y, LeftZ its X; RightStickY
// is the right trigger and must not drive look.
(left, [left_z, right[0]])
// gilrs turns over only the axes it calls Y, and this Y is on one it doesn't.
(left, [left_z, -right[0]])
} else {
(left, right)
}
@@ -1752,6 +1761,15 @@ bind_key.hotbar.2:north
}
}
#[test]
fn playing_in_the_background_is_off_until_saved_on() {
assert!(!PadSettings::parse("vibration:false\n").play_in_background);
let mut on = PadSettings::default();
assert!(!on.play_in_background);
on.play_in_background = true;
assert!(PadSettings::parse(&on.serialize()).play_in_background);
}
#[test]
fn a_file_from_before_these_defaults_keeps_its_buttons() {
// Saved settings that never bound D-pad down: it stays on the advancements.
@@ -1820,7 +1838,11 @@ bind_key.hotbar.2:north
false,
);
assert_eq!(left, [0.2, 0.3]);
assert_eq!(right, [-0.5, 0.7], "X from LeftZ, Y from RightStickX");
assert_eq!(
right,
[-0.5, -0.7],
"X from LeftZ, Y from RightStickX, turned over"
);
let kept = stick_axes(
[0.2, 0.3],
[0.7, -0.9],
+5
View File
@@ -2071,6 +2071,11 @@ impl ChatScreen {
for i in 0..limit {
let y = ry + 12 * i as i32;
d::fill(out, rx, y, rx + rw, y + 12, self.layout.fill);
// A row for the pad: nothing else can pick a suggestion without Tab.
crate::widgets::target_as(
[rx as f32, y as f32, rw as f32, 12.0],
crate::widgets::Kind::Row,
);
if mouse.0 > rx && mouse.0 < rx + rw && mouse.1 > y && mouse.1 < y + 12 {
if moved {
self.select(l, (i + l.offset) as isize);
@@ -97,10 +97,16 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
vec![
(
"Controller",
vec![option(
Opt::PadEnabled,
"Off, the game ignores every controller for this player.",
)],
vec![
option(
Opt::PadEnabled,
"Off, the game ignores every controller for this player.",
),
option(
Opt::PadPlayInBackground,
"The controller keeps playing while this window isn't focused, and losing focus won't pause.",
),
],
),
(
"Look",
@@ -279,6 +285,7 @@ impl Item {
match self {
Item::Option(Opt::PadEnabled) => Some(c.enabled),
Item::Option(Opt::PadVibration) => Some(c.vibration),
Item::Option(Opt::PadPlayInBackground) => Some(c.play_in_background),
Item::Option(Opt::PadAdaptiveTriggers) => Some(c.adaptive_triggers),
Item::Option(Opt::PadInvertX) => Some(c.invert_x),
Item::Option(Opt::PadInvertY) => Some(c.invert_y),
@@ -1266,6 +1273,7 @@ mod tests {
for opt in [
Opt::PadEnabled,
Opt::PadVibration,
Opt::PadPlayInBackground,
Opt::PadAdaptiveTriggers,
Opt::PadLightbar,
Opt::PadLookSensitivity,
@@ -294,6 +294,7 @@ impl Opt {
// the namespace the client bundles (assets/horizon/lang).
Opt::PadEnabled => "horizon.options.controller",
Opt::PadVibration => "horizon.options.vibration",
Opt::PadPlayInBackground => "horizon.options.play_in_background",
Opt::PadAdaptiveTriggers => "horizon.options.adaptive_triggers",
Opt::PadLightbar => "horizon.options.lightbar",
Opt::PadLookSensitivity => "horizon.options.look_sensitivity",
@@ -829,6 +830,7 @@ impl Opt {
Opt::JapaneseGlyphs => on_off(s.japanese_glyphs),
Opt::PadEnabled => on_off(s.controller.enabled),
Opt::PadVibration => on_off(s.controller.vibration),
Opt::PadPlayInBackground => on_off(s.controller.play_in_background),
Opt::PadAdaptiveTriggers => on_off(s.controller.adaptive_triggers),
Opt::PadInvertX => on_off(s.controller.invert_x),
Opt::PadInvertY => on_off(s.controller.invert_y),
@@ -1126,6 +1128,9 @@ impl Opt {
Opt::JapaneseGlyphs => s.japanese_glyphs = !s.japanese_glyphs,
Opt::PadEnabled => s.controller.enabled = !s.controller.enabled,
Opt::PadVibration => s.controller.vibration = !s.controller.vibration,
Opt::PadPlayInBackground => {
s.controller.play_in_background = !s.controller.play_in_background
}
Opt::PadAdaptiveTriggers => {
s.controller.adaptive_triggers = !s.controller.adaptive_triggers
}
@@ -830,6 +830,7 @@ pub enum Opt {
/// Horizon's controller settings (`horizon-controller.txt`).
PadEnabled,
PadVibration,
PadPlayInBackground,
PadAdaptiveTriggers,
PadLightbar,
PadLookSensitivity,
@@ -5,6 +5,7 @@
"horizon.options.controller": "Controller",
"horizon.options.three_d_skin_layers": "3D Skin Layers",
"horizon.options.vibration": "Vibration",
"horizon.options.play_in_background": "Play in Background",
"horizon.options.adaptive_triggers": "Adaptive Triggers",
"horizon.options.lightbar": "Lightbar",
"horizon.options.look_sensitivity": "Look Sensitivity",
@@ -899,6 +900,7 @@
"Follow the controller, or always show one family's buttons.": "Follow the controller, or always show one family's buttons.",
"The buttons menus show right now.": "The buttons menus show right now.",
"Rumble when something happens to you.": "Rumble when something happens to you.",
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "The controller keeps playing while this window isn't focused, and losing focus won't pause.",
"A DualSense's triggers push back on bows and tools.": "A DualSense's triggers push back on bows and tools.",
"What a DualSense's light shows, over USB or Bluetooth.": "What a DualSense's light shows, over USB or Bluetooth.",
"Player profile": "Player profile",
+35 -5
View File
@@ -8532,6 +8532,25 @@ impl Game<'_> {
}
}
}
/// Whether the other device still holds the sneak or sprint mapping `source` released.
fn toggle_held_elsewhere(&self, slot: usize, key: KeyCode, source: Source) -> bool {
match source {
// A pad's toggle-sneak isn't holding it: it has no release that would end it.
Source::Keyboard => {
!(slot == SNEAK && self.settings.controller.toggle_sneak)
&& self
.pad
.holds_action(&self.settings.controller, ["key.sneak", "key.sprint"][slot])
}
Source::Pad => self.physical_keys.iter().any(|physical| {
self.settings
.keys
.translate_all(*physical)
.contains(&Ok(key))
}),
Source::Agent => false,
}
}
/// Apply a physical key to every mapping bound to it.
fn mapped_key_event(&mut self, key: KeyCode, pressed: bool, repeat: bool) -> Result<()> {
for mapped in self.settings.keys.translate_all(key) {
@@ -8594,6 +8613,10 @@ impl Game<'_> {
(_, false) => KeyAction::Press,
(_, true) => KeyAction::Repeat,
};
// One device letting go doesn't let go for the other still holding it.
if !pressed && self.toggle_held_elsewhere(slot, key, source) {
return Ok(());
}
let screen_open = !gameplay_has_keyboard(
self.screen,
self.title.is_some(),
@@ -8736,6 +8759,7 @@ impl Game<'_> {
&& self.player.is_some()
&& !self.options.no_pause
&& self.settings.file.bool("pauseOnLostFocus")
&& !self.pad_keeps_playing_unfocused()
&& self.book.is_none()
&& self.sign_edit.is_none()
&& self.server_packs.prompt.is_none()
@@ -15377,6 +15401,16 @@ impl Game<'_> {
}
/// The renderer for the window, with the current block sprites, GUI
/// atlas and world border texture.
/// A new renderer's atlas holds only the pack's sprites: what was drawn into it at run time
/// (skins, maps, the themed UI's glyphs and corners) has to be drawn again.
pub(super) fn forget_atlas_slots(&mut self) {
self.skin_slots.forget();
self.maps.slots.forget();
self.map_patches.clear();
self.kept_block_entities.get_mut().clear();
horizon_client::ui_theme::reset_atlas();
}
fn new_renderer(&self, window: Arc<Window>) -> Result<Renderer> {
self.renderer_for(window, &self.pack, &self.gui)
}
@@ -15438,11 +15472,7 @@ impl Game<'_> {
renderer.screenshot = screenshot;
renderer.screenshot_result = screenshot_result;
self.renderer = Some(renderer);
self.skin_slots.forget();
self.maps.slots.forget();
self.map_patches.clear();
self.kept_block_entities.get_mut().clear();
horizon_client::ui_theme::reset_atlas();
self.forget_atlas_slots();
self.clear_lod();
self.pending_uploads.clear();
let context = self.worker_context();
+15
View File
@@ -46,6 +46,9 @@ fn book_page_box() -> ui::EditBox {
edit
}
/// Where a book's text is, from its top left corner, above its page buttons.
const BOOK_TEXT: [f32; 4] = [36.0, 16.0, 120.0, 136.0];
pub(super) enum BookScreen {
Edit {
/// Inventory slot of the book (the selected hotbar slot).
@@ -1220,6 +1223,18 @@ impl Game<'_> {
r.v1 = r.v0 + 192.0 * dv;
batch.rect_uv(left, top, 192.0, 192.0, r, u32::MAX);
}
// The page, or the signing title on it: a field to a controller, which opens its keyboard.
if matches!(book, BookScreen::Edit { .. }) {
widgets::target_as(
[
left + BOOK_TEXT[0],
top + BOOK_TEXT[1],
BOOK_TEXT[2],
BOOK_TEXT[3],
],
widgets::Kind::Field,
);
}
let tr = |key: &str, fallback: &str| self.translate(key, fallback);
let page_buttons = |batch: &mut HudBatch, back: bool, forward: bool| {
for (show, x, name) in [
+29 -1
View File
@@ -96,6 +96,9 @@ mod hardware;
mod parity_tests;
pub(super) use hardware::{PadId, PadRouter};
/// How long the death screen's confirm ignores cancelling (`ConfirmScreen`).
const DEATH_CONFIRM_WAIT: Duration = Duration::from_secs(1);
/// A controller coming or going, for this player's notice (`pad_connection_notice`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Connection {
@@ -149,6 +152,7 @@ mod hardware {
}
pub(in crate::game) fn lend_output(&mut self, _: &mut Pads, _: PadId) {}
pub(in crate::game) fn reclaim_output(&mut self, _: &mut Pads) {}
pub(in crate::game) fn play_rumbles(&mut self, _: &mut Pads, _: Option<PadId>) {}
pub(in crate::game) fn report_status(&self, _: &mut Pads, _: Option<PadId>) {}
pub(in crate::game) fn connected(&self, _: PadId) -> bool {
false
@@ -428,7 +432,19 @@ impl Game<'_> {
/// Input from the pad counts while the window has focus, or always for
/// the virtual pad (a script can't focus the window).
fn pad_focused(&self) -> bool {
self.focused || self.pad.virtual_input
self.pad_has_window() || self.pad.virtual_input
}
/// The window has focus, or the player lets the pad play without it (Play in Background).
pub(super) fn pad_has_window(&self) -> bool {
self.focused || self.settings.controller.play_in_background
}
/// Losing focus doesn't pause a game the pad is playing in the background.
pub(super) fn pad_keeps_playing_unfocused(&self) -> bool {
self.settings.controller.enabled
&& self.settings.controller.play_in_background
&& self.pad.in_use
}
fn pad_button(
@@ -577,6 +593,14 @@ impl Game<'_> {
/// B and Start: what Escape does where the pad is.
pub(super) fn pad_back(&mut self) -> Result<()> {
// The death confirm's buttons wait a second, as its Escape does.
if self
.ui
.death_confirm
.is_some_and(|shown| shown.elapsed() < DEATH_CONFIRM_WAIT)
{
return Ok(());
}
if self.prompt_open() {
return self.pad_prompt_back();
}
@@ -668,6 +692,10 @@ impl Game<'_> {
/// notice. Nothing here decides what the controller drives; a player who turned controllers
/// off isn't told about them.
fn pad_connection_notice(&mut self, change: Connection) {
// The pad that was playing is gone: nothing is driving until another is touched.
if matches!(change, Connection::Disconnected { active: true }) {
self.pad.in_use = false;
}
if !self.settings.controller.enabled {
return;
}
+155 -40
View File
@@ -12,6 +12,7 @@ use std::time::{Duration, Instant};
mod face_buttons;
mod hid_layout;
mod sdl_row;
mod triggers;
/// The controllers gilrs sees and the DualSense's output.
@@ -37,6 +38,8 @@ pub(super) struct Devices {
shown: Option<horizon_dualsense::device::State>,
/// While `PadRouter` has the reader and isn't lending it: what `pad status` lists.
routed: Option<Vec<Value>>,
/// While `PadRouter` has the reader and isn't lending it: vibrations it hasn't played yet.
routed_rumbles: Vec<gamepad::Pulse>,
}
/// `pad status`'s line for a controller. `uuid` is the id an SDL mapping row starts with, so a
@@ -119,6 +122,52 @@ const EXTRA_SDL_MAPPINGS: &str = concat!(
"03000000c82d00000b31000014010000,8BitDo Ultimate 2,a:b0,b:b1,back:b6,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b8,leftshoulder:b4,leftstick:b9,lefttrigger:a2,leftx:a0,lefty:a1,rightshoulder:b5,rightstick:b10,righttrigger:a5,rightx:a3,righty:a4,start:b7,x:b2,y:b3,platform:Linux,\n",
);
/// A vibration on pad `id` through gilrs' force feedback, kept alive until its end. Platforms
/// without it (gilrs on macOS) skip it.
fn play_pulse(
gilrs: &mut gilrs::Gilrs,
id: PadId,
pulse: gamepad::Pulse,
) -> Option<(gilrs::ff::Effect, Instant)> {
if !gilrs
.connected_gamepad(id)
.is_some_and(|g| g.is_ff_supported())
{
return None;
}
use gilrs::ff::{BaseEffect, BaseEffectType, EffectBuilder, Repeat, Replay, Ticks};
let length = Ticks::from_ms(pulse.millis);
let motor = |kind| BaseEffect {
kind,
scheduling: Replay {
play_for: length,
..Default::default()
},
envelope: Default::default(),
};
let magnitude = |v: f32| (v.clamp(0.0, 1.0) * f32::from(u16::MAX)) as u16;
let effect = EffectBuilder::new()
.add_effect(motor(BaseEffectType::Strong {
magnitude: magnitude(pulse.strong),
}))
.add_effect(motor(BaseEffectType::Weak {
magnitude: magnitude(pulse.weak),
}))
.repeat(Repeat::For(length))
.gamepads(&[id])
.finish(gilrs);
match effect.and_then(|e| e.play().map(|()| e)) {
Ok(effect) => Some((
effect,
Instant::now() + Duration::from_millis(u64::from(pulse.millis) + 50),
)),
Err(e) => {
log::debug!("Horizon: controller vibration: {e}");
None
}
}
}
/// The one way to open gilrs: each reader needs the extra rows.
fn gilrs_builder() -> gilrs::GilrsBuilder {
gilrs::GilrsBuilder::new().add_mappings(EXTRA_SDL_MAPPINGS)
@@ -150,8 +199,8 @@ impl Pads {
/// Splitscreen reads the pads for every player (`PadRouter`): this game's own gilrs closes, or
/// its events would queue up unread. It opens again if this game reads the pads itself.
/// While several play, nobody gets rumble, adaptive triggers or the lightbar: the router
/// has no output path yet.
/// While several play, the router plays their rumble (`PadRouter::play_rumbles`), and nobody
/// gets adaptive triggers or the lightbar: it has no output path for those yet.
pub(in crate::game) fn release_hardware(&mut self) {
if self.devices.started {
self.devices.gilrs = None;
@@ -162,6 +211,7 @@ impl Pads {
self.devices.rumbles.clear();
self.devices.dualsense = false;
self.devices.output = None;
self.devices.shown = None;
}
}
@@ -202,11 +252,12 @@ impl Pads {
}
}
E::ButtonPressed(..) => self.devices.active = Some(id),
E::AxisChanged(_, value, _) | E::ButtonChanged(_, value, _)
if value.abs() > 0.5 =>
E::AxisChanged(axis, value, _)
if axis_takes_over(axis, value, z_is_a_stick(&gilrs.gamepad(id))) =>
{
self.devices.active = Some(id)
}
E::ButtonChanged(_, value, _) if value > TAKEOVER => self.devices.active = Some(id),
_ => {}
}
}
@@ -231,6 +282,32 @@ impl Pads {
}
}
/// How far a stick or trigger has to go for its pad to become the active one.
const TAKEOVER: f32 = 0.5;
/// Whether this pad has a stick on Z, where most pads have triggers.
fn z_is_a_stick(pad: &gilrs::Gamepad) -> bool {
hid_layout::right_stick_on_z(pad)
|| (pad.mapping_source() != gilrs::MappingSource::SdlMappings
&& (pad.vendor_id(), pad.product_id())
== (
Some(gamepad::BITDO_ULTIMATE2_VID_PID.0),
Some(gamepad::BITDO_ULTIMATE2_VID_PID.1),
))
}
/// Whether an axis this far along means someone is using its pad. Z and axes gilrs has no name for
/// can be triggers, which rest at -1, so only their pulled end counts: touching an idle pad's
/// trigger mustn't take over from the pad in use.
fn axis_takes_over(axis: gilrs::Axis, value: f32, z_is_a_stick: bool) -> bool {
use gilrs::Axis as A;
match axis {
A::LeftZ | A::RightZ if z_is_a_stick => value.abs() > TAKEOVER,
A::LeftZ | A::RightZ | A::Unknown => value > TAKEOVER,
_ => value.abs() > TAKEOVER,
}
}
/// What the controller settings say about a pad.
fn device_of(pad: &gilrs::Gamepad) -> Device {
Device {
@@ -261,6 +338,7 @@ fn state_of(pad: &gilrs::Gamepad) -> PadState {
pad.product_id(),
sdl_mapped,
);
let (left, right) = sdl_row::sticks(pad, left, right);
state.left = left;
state.right = if hid_layout::right_stick_on_z(pad) {
hid_layout::right_stick([axis(A::LeftZ), axis(A::RightZ)])
@@ -280,6 +358,8 @@ pub(in crate::game) type PadId = gilrs::GamepadId;
pub(in crate::game) struct PadRouter {
gilrs: Option<gilrs::Gilrs>,
started: bool,
/// Vibrations playing while several play, each until its end.
rumbles: Vec<(gilrs::ff::Effect, Instant)>,
}
impl PadRouter {
@@ -296,6 +376,18 @@ impl PadRouter {
}
}
/// Play on pad `id` what its player's turn asked for, and let finished vibrations go.
pub(in crate::game) fn play_rumbles(&mut self, pads: &mut Pads, id: Option<PadId>) {
let now = Instant::now();
self.rumbles.retain(|(_, end)| *end > now);
let pulses = std::mem::take(&mut pads.devices.routed_rumbles);
let (Some(gilrs), Some(id)) = (self.gilrs.as_mut(), id) else {
return;
};
self.rumbles
.extend(pulses.into_iter().filter_map(|p| play_pulse(gilrs, id, p)));
}
/// Routing ends: P1 gets the reader back, open as it was.
pub(in crate::game) fn hand_back(&mut self, pads: &mut Pads) {
self.started = false;
@@ -432,7 +524,7 @@ impl Game<'_> {
pub(super) fn pad_output_frame(&mut self) {
if self.pad.devices.dualsense
&& self.pad.in_use
&& self.focused
&& self.pad_has_window()
&& self.pad.devices.output.is_none()
{
self.pad.devices.output = Some(horizon_dualsense::Link::start());
@@ -447,7 +539,8 @@ impl Game<'_> {
/// (another game on the same pad keeps it otherwise).
fn pad_output(&self) -> Option<&horizon_dualsense::Link> {
let output = self.pad.devices.output.as_ref()?;
(self.pad.devices.dualsense && self.focused && output.open().is_some()).then_some(output)
(self.pad.devices.dualsense && self.pad_has_window() && output.open().is_some())
.then_some(output)
}
/// What the DualSense shows: while playing, its triggers feel what their actions do; in
@@ -457,9 +550,10 @@ impl Game<'_> {
return;
}
let playing = self.pad.devices.dualsense
&& self.focused
&& self.pad_has_window()
&& self.title.is_none()
&& self.screen == Screen::Playing
&& !self.screen_over_playing()
&& !self.pause;
let (using, attacking) = if playing {
self.trigger_feels()
@@ -612,43 +706,17 @@ impl Game<'_> {
output.rumble(steps.collect());
return;
}
// Splitscreen's router holds the only reader, and plays this after the player's turn.
if self.pad.feed.is_some() && self.pad.devices.gilrs.is_none() {
self.pad.devices.routed_rumbles.push(pulse);
return;
}
let (Some(gilrs), Some(id)) = (self.pad.devices.gilrs.as_mut(), self.pad.devices.active)
else {
return;
};
if !gilrs
.connected_gamepad(id)
.is_some_and(|g| g.is_ff_supported())
{
return;
}
use gilrs::ff::{BaseEffect, BaseEffectType, EffectBuilder, Repeat, Replay, Ticks};
let length = Ticks::from_ms(pulse.millis);
let motor = |kind| BaseEffect {
kind,
scheduling: Replay {
play_for: length,
..Default::default()
},
envelope: Default::default(),
};
let magnitude = |v: f32| (v.clamp(0.0, 1.0) * f32::from(u16::MAX)) as u16;
let effect = EffectBuilder::new()
.add_effect(motor(BaseEffectType::Strong {
magnitude: magnitude(pulse.strong),
}))
.add_effect(motor(BaseEffectType::Weak {
magnitude: magnitude(pulse.weak),
}))
.repeat(Repeat::For(length))
.gamepads(&[id])
.finish(gilrs);
match effect.and_then(|e| e.play().map(|()| e)) {
Ok(effect) => {
let end = Instant::now() + Duration::from_millis(u64::from(pulse.millis) + 50);
self.pad.devices.rumbles.push((effect, end));
}
Err(e) => log::debug!("Horizon: controller vibration: {e}"),
if let Some(playing) = play_pulse(gilrs, id, pulse) {
self.pad.devices.rumbles.push(playing);
}
}
@@ -666,6 +734,18 @@ mod one_reader_tests {
gilrs_builder().build().ok()
}
#[test]
fn the_router_takes_a_players_rumble_even_with_no_pad_to_play_it_on() {
let mut pads = Pads::default();
pads.devices.routed_rumbles.push(gamepad::Pulse {
strong: 1.0,
weak: 1.0,
millis: 100,
});
PadRouter::default().play_rumbles(&mut pads, None);
assert!(pads.devices.routed_rumbles.is_empty(), "it can't queue up");
}
#[test]
fn routing_takes_p1s_reader_and_gives_it_back_without_closing_it() {
let Some(gilrs) = reader() else {
@@ -720,8 +800,43 @@ mod one_reader_tests {
let mut router = PadRouter {
gilrs: Some(own),
started: true,
..Default::default()
};
router.adopt(&mut pads);
assert!(pads.devices.gilrs.is_some());
}
}
#[cfg(test)]
mod takeover_tests {
use super::*;
use gilrs::Axis as A;
#[test]
fn an_idle_pads_resting_trigger_doesnt_take_over() {
for axis in [A::LeftZ, A::RightZ, A::Unknown] {
assert!(!axis_takes_over(axis, -0.99, false), "{axis:?}");
assert!(axis_takes_over(axis, 0.9, false), "{axis:?}");
}
}
#[test]
fn a_stick_takes_over_in_either_direction() {
for axis in [A::LeftStickX, A::LeftStickY, A::RightStickX, A::RightStickY] {
assert!(axis_takes_over(axis, -0.9, false), "{axis:?}");
assert!(axis_takes_over(axis, 0.9, false), "{axis:?}");
assert!(!axis_takes_over(axis, 0.2, false), "{axis:?}");
}
}
#[test]
fn a_right_stick_on_z_takes_over_in_either_direction() {
// An Xbox pad over Bluetooth pushed left, or up.
assert!(axis_takes_over(A::LeftZ, -0.9, true));
assert!(axis_takes_over(A::RightZ, -0.9, true));
assert!(
!axis_takes_over(A::Unknown, -0.99, true),
"its triggers rest here"
);
}
}
@@ -1,7 +1,7 @@
//! Linux's codes for X and Y don't say where those buttons sit.
use super::hid_layout;
use gilrs::{Button, Gamepad, MappingSource};
use horizon_client::gamepad::GlyphStyle;
use horizon_client::gamepad::{BITDO_ULTIMATE2_VID_PID, GlyphStyle};
/// Whether this pad's X and Y reach us crossed. The kernel defines `BTN_X` as `BTN_NORTH` and
/// `BTN_Y` as `BTN_WEST`, so an Xbox-style pad's X, on the west, sends "north". gilrs' default
@@ -19,7 +19,10 @@ pub(super) fn crossed(pad: &Gamepad) -> bool {
Button::Z,
]
.map(|button| pad.button_code(button).is_some()),
hid_layout::numbered_buttons(pad),
hid_layout::numbered_buttons(pad)
// Its row has `x:b3`, BTN_NORTH, and it lists C and Z with its right stick on RX.
|| (pad.vendor_id(), pad.product_id())
== (Some(BITDO_ULTIMATE2_VID_PID.0), Some(BITDO_ULTIMATE2_VID_PID.1)),
GlyphStyle::of_device(pad.vendor_id(), pad.product_id()),
)
}
@@ -0,0 +1,107 @@
//! What gilrs drops when it reads an SDL row: `~`, which says an axis runs the other way.
use gilrs::{Gamepad, MappingSource};
/// `[left x, left y, right x, right y]`, turned over.
type Flips = [bool; 4];
/// Every Linux row in gilrs 0.11.2's copy of the database with a `~` on a stick axis, by the
/// id a row starts with.
const FLIPPED_ROWS: [(&str, Flips); 9] = [
// Cyber Gadget GameCube Controller
(
"03000000260900008888000000010000",
[false, false, false, true],
),
// Mayflash Adapter
(
"030000008f0e00001330000010010000",
[false, false, true, false],
),
// Microsoft Dual Strike
(
"030000005e0400002800000000010000",
[false, false, false, true],
),
// Nintendo GameCube Adapter
(
"03000000790000004618000010010000",
[false, false, true, true],
),
// Nintendo Switch 2 Pro Controller
(
"030000007e0500006920000011010000",
[false, true, false, true],
),
// Nintendo Switch Left Joy-Con
(
"050000007e0500000620000001800000",
[false, true, false, false],
),
// Nintendo Switch Right Joy-Con
(
"050000007e0500000720000001800000",
[true, false, false, false],
),
// NSO GameCube Controller
(
"030000007e0500007320000011010000",
[false, true, false, true],
),
// PS SS N64 Adapter
(
"03000000250900000017000010010000",
[false, false, true, false],
),
];
/// The sticks as the row meant them.
pub(super) fn sticks(pad: &Gamepad, left: [f32; 2], right: [f32; 2]) -> ([f32; 2], [f32; 2]) {
if !cfg!(target_os = "linux") || pad.mapping_source() != MappingSource::SdlMappings {
return (left, right);
}
let id: String = pad
.uuid()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
apply(flips_of(&id), left, right)
}
fn flips_of(id: &str) -> Flips {
FLIPPED_ROWS
.iter()
.find(|(row, _)| *row == id)
.map_or([false; 4], |(_, flips)| *flips)
}
fn apply(flips: Flips, left: [f32; 2], right: [f32; 2]) -> ([f32; 2], [f32; 2]) {
let turn = |value: f32, flip: bool| if flip { -value } else { value };
(
[turn(left[0], flips[0]), turn(left[1], flips[1])],
[turn(right[0], flips[2]), turn(right[1], flips[3])],
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_switch_2_pro_pushed_forward_reads_forward() {
// Nintendo Switch 2 Pro Controller
let flips = flips_of("030000007e0500006920000011010000");
assert_eq!(
apply(flips, [0.5, -1.0], [0.25, 1.0]),
([0.5, 1.0], [0.25, -1.0])
);
}
#[test]
fn a_row_without_a_tilde_is_left_alone() {
let flips = flips_of("050000005e040000130b000022050000");
assert_eq!(
apply(flips, [0.5, -1.0], [0.25, 1.0]),
([0.5, -1.0], [0.25, 1.0])
);
}
}
@@ -80,11 +80,11 @@ fn axis_triggers(
fn trigger_value(mapped: bool, value: Option<f32>, pressed: bool, axis: Option<f32>) -> f32 {
if mapped {
if pressed {
value.unwrap_or(0.0).max(1.0)
} else {
value.unwrap_or(0.0)
}
// gilrs says pressed at three quarters of the way, which isn't the player's threshold: a
// pressed trigger with no value yet is the only one that has to count as full.
value
.filter(|value| *value > 0.0)
.unwrap_or(if pressed { 1.0 } else { 0.0 })
} else {
axis.map_or(0.0, |value| ((value + 1.0) * 0.5).clamp(0.0, 1.0))
}
@@ -183,4 +183,11 @@ mod tests {
assert_eq!(trigger_value(true, Some(0.25), false, Some(1.0)), 0.25);
assert_eq!(trigger_value(true, None, true, None), 1.0);
}
#[test]
fn an_analog_trigger_reads_its_value_past_where_gilrs_says_pressed() {
// gilrs' pressed starts at 0.75, under a threshold of 0.9.
assert_eq!(trigger_value(true, Some(0.8), true, None), 0.8);
assert_eq!(trigger_value(true, Some(1.0), true, None), 1.0);
}
}
@@ -58,6 +58,22 @@ fn pad_press(game: &mut Game, button: Button, action: &str) {
game.pad_action(action, true).unwrap();
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_keyboard_release_keeps_the_sneak_the_pad_went_on_to_hold() {
playing(|game| {
// Hold mode: toggling, the pad's press would flip it.
game.settings.controller.toggle_sneak = false;
let shift = KeyCode::ShiftLeft;
game.mapped_key(shift, true, false, shift, Source::Keyboard)
.unwrap();
pad_press(game, Button::RightStick, "key.sneak");
game.mapped_key(shift, false, false, shift, Source::Keyboard)
.unwrap();
assert!(game.keys.held(Action::Sneak), "R3 is still down");
});
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn keyboard_handoff_keeps_what_the_pad_holds() {
+40 -7
View File
@@ -333,7 +333,11 @@ impl Game<'_> {
fn pad_pointer_screen(&self) -> bool {
matches!(
self.screen,
Screen::Inventory | Screen::Creative | Screen::Stats | Screen::Advancements
Screen::HudEditor
| Screen::Inventory
| Screen::Creative
| Screen::Stats
| Screen::Advancements
) || self.book.is_some()
|| (self.screen == Screen::Options && self.ui.options.page() == Page::Packs)
}
@@ -774,6 +778,10 @@ impl Game<'_> {
return Ok(());
}
}
// The screen under the keyboard went away (dying with chat open): so does the keyboard.
if self.pad.menu.osk.is_some() && !self.pad_has_typing_screen() {
self.pad.menu.osk = None;
}
// The on-screen keyboard takes every button while it is open.
if let Some(keyboard) = self.pad.menu.osk.as_mut() {
if let Some(out) = keyboard.button(button) {
@@ -841,6 +849,8 @@ impl Game<'_> {
}
}
}
// A sign has no field to press, so A opens its keyboard from anywhere on it.
Button::South if !prompt && self.sign_edit.is_some() => self.pad_open_keyboard(),
Button::South | Button::West => {
self.pad_take_pointer(event_loop);
self.pad_pointer_button(event_loop, button == Button::West, true);
@@ -1183,6 +1193,14 @@ impl Game<'_> {
/// A bumper: the next or previous tab, wrapping (creative keeps its own
/// order; other screens register their tabs).
fn pad_tab(&mut self, event_loop: &dyn EventLoopCtl, next: bool) {
// The server list has no tabs: the bumpers move the selected server.
if self
.title
.as_mut()
.is_some_and(|title| title.multiplayer_move_selected(next))
{
return;
}
if self.screen == Screen::Creative && self.title.is_none() {
self.pad_creative_tab(next);
return;
@@ -2148,6 +2166,23 @@ impl Game<'_> {
)
}
/// Whether the screen under the keyboard can still be typed in.
fn pad_has_typing_screen(&self) -> bool {
self.pad_typing()
|| self.title.is_some()
|| matches!(self.screen, Screen::Options | Screen::WorldOptions)
}
/// Enter on these goes on to a new line: a dialog's, a sign's or a book's page.
fn pad_enter_keeps_typing(&self) -> bool {
self.dialog_typing_lines()
|| self.sign_edit.is_some()
|| matches!(
self.book,
Some(super::books::BookScreen::Edit { signing: false, .. })
)
}
/// Open the on-screen keyboard over the focused field: Horizon's, or
/// Steam's on a Steam Deck (`steam://open/keyboard`, which types into
/// the window like a real keyboard).
@@ -2293,12 +2328,10 @@ impl Game<'_> {
_ => self.title_edit(text, false),
}
}
// Enter ends the typing, but for a dialog's new line; a field that
// closed with it closes the keyboard.
if (key == Some(KeyCode::Enter) && !self.dialog_typing_lines())
|| (!self.pad_typing()
&& self.title.is_none()
&& !matches!(self.screen, Screen::Options | Screen::WorldOptions))
// Enter ends the typing, but for a new line; a field that closed with it closes the
// keyboard.
if (key == Some(KeyCode::Enter) && !self.pad_enter_keeps_typing())
|| !self.pad_has_typing_screen()
{
self.pad.menu.osk = None;
}
@@ -873,6 +873,13 @@ impl Game<'_> {
return out;
}
let (xo, yo) = self.book_origin(&batch);
if self.recipe_ui.overlay.is_none() {
// The search box is painted by hand: a field, for the pad's keyboard.
out.push(Target {
rect: [xo + 25.0, yo + 13.0, 81.0, 14.0],
kind: Kind::Field,
});
}
if let Some(overlay) = &self.recipe_ui.overlay {
// The alternatives overlay takes every click while open.
let maxrow = if overlay.entries.len() <= 16 { 4 } else { 5 };
+7 -3
View File
@@ -399,8 +399,7 @@ impl<'a> Split<'a> {
}
};
self.host.renderer = Some(renderer);
// The new atlas has no downloaded skins: queue them again, as after device loss.
self.host.skin_slots.forget();
self.host.forget_atlas_slots();
self.host.split_window = !self.host.options.offscreen;
self.host.options.offscreen = true;
self.host.options.paced_by_split = true;
@@ -476,7 +475,7 @@ impl<'a> Split<'a> {
};
drop(old);
self.host.renderer = Some(renderer);
self.host.skin_slots.forget();
self.host.forget_atlas_slots();
self.host.options.offscreen = false;
self.host.split_window = false;
self.host.options.paced_by_split = false;
@@ -824,6 +823,11 @@ impl ApplicationHandler for Split<'_> {
}
}
}
self.pads
.play_rumbles(&mut self.host.pad, self.p1_pad.owner);
for g in &mut self.guests {
self.pads.play_rumbles(&mut g.game.pad, g.pad);
}
self.turn(0);
let rendered = Instant::now();
let presented = self.present();
@@ -169,6 +169,10 @@ impl Split<'_> {
}
}
self.forget_lost();
// Presses in another window aren't asking to join this one.
if !self.host.pad_has_window() {
presses.clear();
}
for (id, button) in presses {
if Some(id) == self.p1_pad.owner || self.guests.iter().any(|g| g.pad == Some(id)) {
continue;
@@ -303,9 +303,14 @@ impl Split<'_> {
horizon_client::settings::Settings::load(&options)
} else {
std::fs::create_dir_all(&profile)?;
self.host.settings.save(&options);
self.host.settings.clone()
let mut new = self.host.settings.clone();
// The Controller setting is P1's, as on the profile page (`join`).
new.controller.enabled |= self.guests[i].pad.is_some();
new.save(&options);
new
};
// A profile an earlier join saved with P1's off, or the player's pad would be dead.
settings.controller.enabled |= self.guests[i].pad.is_some();
// P1's own world through memory (`Ok`), else P1's server over the network (`Err`).
let via = match &self.host.integrated {
Some(server) => Ok(server
+21
View File
@@ -1530,6 +1530,27 @@ impl Screen {
_ => Outcome::None,
}
}
/// Move the selected saved server one row, as its arrows do: the pad's way to them, since a
/// click on a row lands at its centre. Whether there was one to move.
pub fn move_selected(&mut self, down: bool, saves: &Path) -> bool {
let Self::List {
servers, selected, ..
} = self
else {
return false;
};
let Some(k) = selected.filter(|k| *k < servers.len()) else {
return false;
};
let to = if down { k + 1 } else { k.wrapping_sub(1) };
if to < servers.len() {
servers.swap(k, to);
save_servers(&servers_file(saves), servers);
*selected = Some(to);
}
true
}
pub fn click(
&mut self,
id: Option<u8>,
+5
View File
@@ -4165,6 +4165,11 @@ impl Screen {
let x = bar_x + i as f32 * tab_w;
let selected = which == tab;
let hover = crate::ui::hovered(cursor, x, 0.0, tab_w, TAB_HEIGHT);
// Painted by hand: the bumpers look for these.
crate::widgets::target_as(
[x, 0.0, tab_w, TAB_HEIGHT],
crate::widgets::Kind::Tab(selected),
);
let sprite = match (selected, hover) {
(true, true) => "gui/sprites/widget/tab_selected_highlighted",
(true, false) => "gui/sprites/widget/tab_selected",
+7
View File
@@ -4454,6 +4454,13 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
self.last_server.clone_from(address);
}
}
/// The selected server, a row up or down, if the server list is open with one selected.
pub fn multiplayer_move_selected(&mut self, down: bool) -> bool {
let Mode::Multiplayer(screen) = &mut self.mode else {
return false;
};
screen.move_selected(down, &self.saves)
}
pub(crate) fn multiplayer_click(
&mut self,
hit: Option<u8>,
+7
View File
@@ -206,6 +206,7 @@ the crafting result, armour to the player's grid.
- **Sliders**: left and right move the value (one pixel per press like
vanilla's arrow keys, faster while held); A does nothing on a slider, as a
click would jump the handle.
- **The server list**: the bumpers move the selected server up or down, as its arrows do.
- **Lists** follow focus: a step out of a list that can still scroll that way
scrolls it and lands on the next row once it is drawn, so every row can be
reached; the buttons below or above come after its last or first row.
@@ -461,6 +462,12 @@ remaining range is rescaled to 0..1. Look uses a squared curve for precise
aim near the centre, up to 200°/s yaw and 140°/s pitch at the default
sensitivity, slower while scoping with a spyglass.
## Playing in the background
Play in Background (off by default, per player) lets the pad keep driving the game while its window
has no focus, and a game the pad is playing doesn't pause on losing focus. Every game on the PC reads
every pad: for a pad in one window and the keyboard in another, turn Controllers off in the keyboard's.
## Rumble
Vibration (on by default, off in the options): taking damage (stronger with
+124
View File
@@ -1955,6 +1955,125 @@ def osk_stays_open(game, check, what):
check(game.menu()['keyboard'], f'{what}: deleting closed the keyboard')
def osk_book(args, name):
"""A on a book and quill's page opens the keyboard, a new line keeps it open, and B closes it."""
game = Game(args, name, world=True)
check = Check()
try:
game.ctl('command', 'give @s writable_book')
time.sleep(1)
game.ctl('pad', 'trigger', 'left', 1)
time.sleep(0.4)
game.ctl('pad', 'trigger', 'left', 0)
time.sleep(1.5)
menu = game.menu()
check(menu['mode'] == 'pointer', f"no book open: {menu['mode']}")
field = next((t for t in menu['targets'] if t['kind'] == 'field'), None)
check(field is not None, f"the page isn't a field: {menu['targets']}")
x, y, w, h = field['rect']
for _ in range(40):
pointer = game.menu()['pointer']
if pointer and x <= pointer[0] < x + w and y <= pointer[1] < y + h:
break
step = 'left' if pointer and pointer[0] >= x + w else 'right' if pointer and pointer[0] < x else 'up' if pointer and pointer[1] >= y + h else 'down'
game.pad(step)
game.pad('south')
check(game.menu()['keyboard'], 'A on the page opened no keyboard')
finally:
game.quit()
return check.failures
def osk_sign(args, name):
"""A keeps a sign's keyboard to hand: Enter goes to the next line, and B then A opens it again."""
game = Game(args, name, world=True)
check = Check()
try:
game.ctl('command', 'give @s oak_sign')
game.ctl('command', 'setblock ~ ~-1 ~ stone')
time.sleep(1)
game.ctl('turn', '0', '90')
time.sleep(0.5)
game.ctl('pad', 'trigger', 'left', 1)
time.sleep(0.4)
game.ctl('pad', 'trigger', 'left', 0)
time.sleep(1.5)
check(game.menu()['keyboard'], 'a sign the pad placed opened no keyboard')
game.pad('east')
check(not game.menu()['keyboard'], 'B did not close the keyboard')
game.pad('south')
check(game.menu()['keyboard'], 'A did not open the keyboard again')
finally:
game.quit()
return check.failures
def recipe_search_keyboard(args, name):
"""A on the recipe book's search box opens the keyboard."""
game = Game(args, name, world=True)
check = Check()
try:
game.ctl('command', 'gamemode survival')
game.ctl('key', 'e', 'tap')
time.sleep(SETTLE)
game.wait_targets()
x, y, w, h = next(t for t in game.ctl('screen')['recipe_book']['targets'] if t[2:] == [20.0, 18.0])
crosshair_walk(game, x + w / 2, y + h / 2)
game.pad('south')
time.sleep(SETTLE)
field = next((t for t in game.menu()['targets'] if t['kind'] == 'field'), None)
check(field is not None, 'the search box is not a field')
if field:
x, y, w, h = field['rect']
crosshair_walk(game, x + w / 2, y + h / 2)
game.pad('south')
check(game.menu()['keyboard'], 'A on the search box opened no keyboard')
finally:
game.quit()
return check.failures
def chat_suggestion_rows(args, name):
"""A command's suggestions are rows the pad can reach."""
game = Game(args, name, world=True)
check = Check()
try:
game.ctl('key', 't', 'tap')
time.sleep(SETTLE)
game.ctl('type', '/ga')
time.sleep(SETTLE)
check(any(t['kind'] == 'row' for t in game.menu()['targets']),
f"no suggestion rows: {game.menu()['targets']}")
finally:
game.quit()
return check.failures
def server_list_bumpers(args, name):
"""The bumpers move the selected server, and the list saves it."""
game = Game(args, name, seed=seed_servers)
check = Check()
try:
game.press_through('south', 'down', 'south')
game.wait_targets()
first = game.rows()[0]['id']
game.focus_on(first)
game.pad('south')
before = [row.get('label') for row in game.rows()[:2]]
check(before[0] and before[1], f'no rows: {before}')
game.pad('rb')
time.sleep(SETTLE)
after = [row.get('label') for row in game.rows()[:2]]
check(after == before[::-1], f'the first server did not move down: {before} then {after}')
game.pad('lb')
time.sleep(SETTLE)
back = [row.get('label') for row in game.rows()[:2]]
check(back == before, f'it did not move back up: {after} then {back}')
finally:
game.quit()
return check.failures
def osk_social(args, name):
game = Game(args, name, world=True)
check = Check()
@@ -2143,6 +2262,11 @@ CASES = {'idle_a': idle_a, 'idle_b': idle_b, 'idle_x': idle_x, 'release': releas
'prompt_keeps_typing': prompt_keeps_typing,
'prompt_keeps_crosshair': prompt_keeps_crosshair,
'osk_social': osk_social,
'osk_book': osk_book,
'recipe_search_keyboard': recipe_search_keyboard,
'chat_suggestion_rows': chat_suggestion_rows,
'server_list_bumpers': server_list_bumpers,
'osk_sign': osk_sign,
'osk_debug_options': osk_debug_options,
'osk_command_block': osk_command_block,
'osk_jigsaw': osk_jigsaw,
+34
View File
@@ -51,6 +51,9 @@ CTL = lab.cargo_target() / ('fast/horizon-ctl' + ('.exe' if os.name == 'nt' else
RESOURCES = ROOT / '.local/horizon-client/26.3'
# Logical; the views are checked as fractions of what P1 gets alone, so any scale works.
WIDTH, HEIGHT = 1000, 600
# Where `scenario_keyboard_after_leave` looks for the keyboard's letters, from the top middle.
KEYBOARD_ROWS, KEYBOARD_REACH = 280, 290
KEYBOARD_LETTER_LEVEL, KEYBOARD_MIN_LETTERS = 200, 200
VERBOSE = False
failures = []
@@ -657,6 +660,36 @@ def scenario_live():
g.quit()
def scenario_keyboard_after_leave():
print('keyboard: the on-screen keyboard keeps its letters when the renderer changes')
from pad_menu_e2e import png_rows
def letters(g, tag):
"""Near-white pixels in the keyboard, at the top of P1's view over chat: its letters."""
path = g.home / f'keyboard-{tag}.png'
g.ctl(1, 'screenshot', str(path))
rows, width, channels = png_rows(path)
middle = width // 2
return sum(all(v > KEYBOARD_LETTER_LEVEL for v in row[x * channels:x * channels + 3])
for row in rows[:KEYBOARD_ROWS] for x in range(middle - KEYBOARD_REACH, middle + KEYBOARD_REACH))
g = Game(2, saved_split=True)
try:
g.ctl(1, 'pad', 'south', 'tap')
g.ctl(1, 'key', 't', 'tap')
g.wait(lambda: g.ctl(1, 'pad', 'status')['menu']['keyboard'], "P1's keyboard", seconds=10)
time.sleep(1)
before = letters(g, 'before')
check('the keyboard has letters', before > KEYBOARD_MIN_LETTERS, True)
g.ctl(2, 'quit')
g.wait(lambda: not g.alive(2), 'P2 gone', seconds=15)
g.wait(lambda: g.ctl(1, 'pad', 'status')['menu']['keyboard'], "P1's keyboard still up", seconds=10)
time.sleep(1)
check('alone on a new renderer, it still has them', letters(g, 'after') > KEYBOARD_MIN_LETTERS, True)
finally:
g.quit()
def scenario_pause():
print('pause: the game runs on while one player sits behind a pause menu')
g = Game(2)
@@ -1401,6 +1434,7 @@ SCENARIOS = {
'controls': scenario_controls,
'three': scenario_three,
'live': scenario_live,
'keyboard_after_leave': scenario_keyboard_after_leave,
'pause': scenario_pause,
'profile_availability': scenario_profile_availability,
'gui_scales': scenario_gui_scales,