mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge pull request #11026 from martipops/fix/pad-read
Controllers: fixes from reviewing the pad reader, typing and joining
This commit is contained in:
25 files changed
+686
-71
No files matched your search
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user