Controllers: bindable actions, prompts over gameplay, and button art
Held mappings are named by an Action rather than read by key, per device, and more actions can be bound: hotbar slots, fullscreen, toolbars, social and quick actions. Held drop repeats and throws the stack. Readers of the movement keys see the left stick, and a bumper steps the hotbar through the wheel's gates. Prompts over gameplay are pad-driven and keep what's behind them, the keyboard opens on every field, and focus follows its widget. Buttons and the crosshair draw from Horizon's own art, one file per pad family, instead of text labels. Co-Authored-By: Claude Opus 5.5 <[email protected]>
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# `keys.contains(&KeyCode::ShiftLeft)` reads a mapping by its default key: ask by `Action`, so the
|
||||
# mapping is named and every device that can hold it counts.
|
||||
disallowed-methods = [
|
||||
{ path = "horizon_client_input::held::HeldKeys::contains", reason = "use `held(Action::..)`, or `raw` for a key that is no mapping's default", allow-invalid = true },
|
||||
]
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Every input the game names: vanilla's key mappings (`Options.keyMappings`) and Horizon's
|
||||
//! pad-only actions. Held state is asked by [`Action`] ([`crate::held::HeldKeys::held`]), not by a
|
||||
//! key: code that wants a mapping says which one, and every device that can hold it counts.
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
macro_rules! actions {
|
||||
($($variant:ident, $name:literal, $key:expr;)*) => {
|
||||
/// A key mapping, or a pad-only action.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Action {
|
||||
$($variant,)*
|
||||
}
|
||||
impl Action {
|
||||
/// In `Options.keyMappings` order, the pad-only actions last.
|
||||
pub const ALL: &[Action] = &[$(Action::$variant,)*];
|
||||
/// The saved name: vanilla's for a mapping, `pad.*` for Horizon's own.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
$(Action::$variant => $name,)*
|
||||
}
|
||||
}
|
||||
/// The default key the game's held state keeps this action under. `None` for what
|
||||
/// isn't held as a key there: a mouse mapping, one with no default, a pad-only action,
|
||||
/// and the debug keys, which act on the physical key (`debug_keys.rs`) and whose
|
||||
/// defaults are other mappings' keys.
|
||||
pub fn key(self) -> Option<KeyCode> {
|
||||
match self {
|
||||
$(Action::$variant => $key,)*
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
actions! {
|
||||
Attack, "key.attack", None;
|
||||
Use, "key.use", None;
|
||||
Forward, "key.forward", Some(KeyCode::KeyW);
|
||||
Left, "key.left", Some(KeyCode::KeyA);
|
||||
Back, "key.back", Some(KeyCode::KeyS);
|
||||
Right, "key.right", Some(KeyCode::KeyD);
|
||||
Jump, "key.jump", Some(KeyCode::Space);
|
||||
Sneak, "key.sneak", Some(KeyCode::ShiftLeft);
|
||||
Sprint, "key.sprint", Some(KeyCode::ControlLeft);
|
||||
Drop, "key.drop", Some(KeyCode::KeyQ);
|
||||
Inventory, "key.inventory", Some(KeyCode::KeyE);
|
||||
Chat, "key.chat", Some(KeyCode::KeyT);
|
||||
Playerlist, "key.playerlist", Some(KeyCode::Tab);
|
||||
PickItem, "key.pickItem", None;
|
||||
Command, "key.command", Some(KeyCode::Slash);
|
||||
Friends, "key.friends", Some(KeyCode::KeyO);
|
||||
Emote, "key.emote", Some(KeyCode::KeyB);
|
||||
SocialInteractions, "key.socialInteractions", Some(KeyCode::KeyP);
|
||||
ToggleGui, "key.toggleGui", Some(KeyCode::F1);
|
||||
ToggleSpectatorShaderEffects, "key.toggleSpectatorShaderEffects", Some(KeyCode::F4);
|
||||
Screenshot, "key.screenshot", Some(KeyCode::F2);
|
||||
TogglePerspective, "key.togglePerspective", Some(KeyCode::F5);
|
||||
SmoothCamera, "key.smoothCamera", None;
|
||||
Fullscreen, "key.fullscreen", Some(KeyCode::F11);
|
||||
SpectatorOutlines, "key.spectatorOutlines", None;
|
||||
SpectatorHotbar, "key.spectatorHotbar", None;
|
||||
SwapOffhand, "key.swapOffhand", Some(KeyCode::KeyF);
|
||||
SaveToolbarActivator, "key.saveToolbarActivator", Some(KeyCode::KeyC);
|
||||
LoadToolbarActivator, "key.loadToolbarActivator", Some(KeyCode::KeyX);
|
||||
Advancements, "key.advancements", Some(KeyCode::KeyL);
|
||||
QuickActions, "key.quickActions", Some(KeyCode::KeyG);
|
||||
DebugOverlay, "key.debug.overlay", None;
|
||||
DebugModifier, "key.debug.modifier", None;
|
||||
Hotbar1, "key.hotbar.1", Some(KeyCode::Digit1);
|
||||
Hotbar2, "key.hotbar.2", Some(KeyCode::Digit2);
|
||||
Hotbar3, "key.hotbar.3", Some(KeyCode::Digit3);
|
||||
Hotbar4, "key.hotbar.4", Some(KeyCode::Digit4);
|
||||
Hotbar5, "key.hotbar.5", Some(KeyCode::Digit5);
|
||||
Hotbar6, "key.hotbar.6", Some(KeyCode::Digit6);
|
||||
Hotbar7, "key.hotbar.7", Some(KeyCode::Digit7);
|
||||
Hotbar8, "key.hotbar.8", Some(KeyCode::Digit8);
|
||||
Hotbar9, "key.hotbar.9", Some(KeyCode::Digit9);
|
||||
DebugReloadChunk, "key.debug.reloadChunk", None;
|
||||
DebugShowHitboxes, "key.debug.showHitboxes", None;
|
||||
DebugClearChat, "key.debug.clearChat", None;
|
||||
DebugCrash, "key.debug.crash", None;
|
||||
DebugShowChunkBorders, "key.debug.showChunkBorders", None;
|
||||
DebugShowAdvancedTooltips, "key.debug.showAdvancedTooltips", None;
|
||||
DebugCopyRecreateCommand, "key.debug.copyRecreateCommand", None;
|
||||
DebugSpectate, "key.debug.spectate", None;
|
||||
DebugSwitchGameMode, "key.debug.switchGameMode", None;
|
||||
DebugDebugOptions, "key.debug.debugOptions", None;
|
||||
DebugFocusPause, "key.debug.focusPause", None;
|
||||
DebugDumpDynamicTextures, "key.debug.dumpDynamicTextures", None;
|
||||
DebugReloadResourcePacks, "key.debug.reloadResourcePacks", None;
|
||||
DebugProfiling, "key.debug.profiling", None;
|
||||
DebugCopyLocation, "key.debug.copyLocation", None;
|
||||
DebugDumpVersion, "key.debug.dumpVersion", None;
|
||||
DebugProfilingChart, "key.debug.profilingChart", None;
|
||||
DebugFpsCharts, "key.debug.fpsCharts", None;
|
||||
DebugNetworkCharts, "key.debug.networkCharts", None;
|
||||
DebugLightmapTexture, "key.debug.lightmapTexture", None;
|
||||
DebugImprovedTransparency, "key.debug.improvedTransparency", None;
|
||||
HotbarPrevious, "pad.hotbarPrevious", None;
|
||||
HotbarNext, "pad.hotbarNext", None;
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
Self::ALL.iter().copied().find(|a| a.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::keybinds::{self, KeyBinds, MAPPINGS};
|
||||
|
||||
#[test]
|
||||
fn every_mapping_is_an_action_in_order() {
|
||||
let names: Vec<&str> = Action::ALL.iter().map(|a| a.name()).collect();
|
||||
let mappings: Vec<&str> = MAPPINGS.iter().map(|m| m.0).collect();
|
||||
assert_eq!(names[..mappings.len()], mappings[..]);
|
||||
for action in &names[mappings.len()..] {
|
||||
assert!(action.starts_with("pad."), "{action}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_actions_key_is_its_mappings_default_key() {
|
||||
for (name, _, category) in MAPPINGS {
|
||||
let action = Action::parse(name).unwrap();
|
||||
let want = if *category == "debug" {
|
||||
None
|
||||
} else {
|
||||
keybinds::key_code(KeyBinds::default_of(name))
|
||||
};
|
||||
assert_eq!(action.key(), want, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_bindable_action_is_an_action() {
|
||||
for (name, _) in crate::gamepad::ACTIONS {
|
||||
assert!(Action::parse(name).is_some(), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
//! Controllers: Horizon's own feature (vanilla 26.3 has no gamepad input).
|
||||
//! The default layout is Bedrock Edition's; see docs/controllers.md.
|
||||
//! Its default buttons are Horizon's own; analog walking and toggle sneak follow Bedrock Edition on
|
||||
//! consoles. See docs/controllers.md.
|
||||
//!
|
||||
//! This module is the backend-free part: positional buttons, the action
|
||||
//! bindings, deadzones and the look curve, button edges from successive pad
|
||||
//! states, and `horizon-controller.txt`, kept next to `options.txt` so that
|
||||
//! file stays exactly vanilla's. The game reads the pads with gilrs.
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::Path,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub const FILE: &str = "horizon-controller.txt";
|
||||
|
||||
@@ -316,6 +322,20 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
|
||||
("key.smoothCamera", None),
|
||||
// Horizon's emote wheel: no button is free by default; the stick's cursor picks.
|
||||
("key.emote", None),
|
||||
("key.fullscreen", None),
|
||||
("key.socialInteractions", None),
|
||||
("key.quickActions", None),
|
||||
("key.saveToolbarActivator", None),
|
||||
("key.loadToolbarActivator", None),
|
||||
("key.hotbar.1", None),
|
||||
("key.hotbar.2", None),
|
||||
("key.hotbar.3", None),
|
||||
("key.hotbar.4", None),
|
||||
("key.hotbar.5", None),
|
||||
("key.hotbar.6", None),
|
||||
("key.hotbar.7", None),
|
||||
("key.hotbar.8", None),
|
||||
("key.hotbar.9", None),
|
||||
];
|
||||
|
||||
/// How a bindable action reaches the game: one of its own handlers, shared
|
||||
@@ -330,6 +350,10 @@ pub enum Route {
|
||||
Sneak,
|
||||
Screenshot,
|
||||
SmoothCamera,
|
||||
Fullscreen,
|
||||
/// Does nothing by itself: what reads it asks whether the button is held
|
||||
/// (`Minecraft.handleKeybinds`' toolbar keys, held with a hotbar key).
|
||||
Held,
|
||||
Key(winit::keyboard::KeyCode),
|
||||
Mouse(winit::event::MouseButton),
|
||||
}
|
||||
@@ -342,6 +366,8 @@ pub fn route(action: &str) -> Option<Route> {
|
||||
"key.sneak" => Some(Route::Sneak),
|
||||
"key.screenshot" => Some(Route::Screenshot),
|
||||
"key.smoothCamera" => Some(Route::SmoothCamera),
|
||||
"key.fullscreen" => Some(Route::Fullscreen),
|
||||
"key.saveToolbarActivator" | "key.loadToolbarActivator" => Some(Route::Held),
|
||||
_ => {
|
||||
let default = crate::keybinds::KeyBinds::default_of(action);
|
||||
crate::keybinds::mouse_button(default)
|
||||
@@ -361,6 +387,46 @@ pub fn action_caption(action: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a held drop button throws single items before it throws the rest of the stack.
|
||||
pub const DROP_STACK_HOLD: Duration = Duration::from_secs(1);
|
||||
|
||||
/// What a held drop button does now, past its press.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DropStep {
|
||||
/// Held key.drop (a repeat reaches `KeyMapping.click` as a press).
|
||||
One,
|
||||
/// Control + key.drop: a pad has no Control to hold, so holding on is how it asks.
|
||||
Stack,
|
||||
}
|
||||
|
||||
/// A held drop button's repeats: a tap throws one item, as Q does; held, it repeats at the pad's
|
||||
/// repeat delay and interval; and held for [`DROP_STACK_HOLD`] it throws what's left.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct HeldDrop {
|
||||
since: Instant,
|
||||
next: Instant,
|
||||
}
|
||||
impl HeldDrop {
|
||||
pub fn new(now: Instant, delay: Duration) -> Self {
|
||||
Self {
|
||||
since: now,
|
||||
next: now + delay,
|
||||
}
|
||||
}
|
||||
/// The step due at `now`, if any; at most one per call.
|
||||
pub fn due(&mut self, now: Instant, interval: Duration) -> Option<DropStep> {
|
||||
if now < self.next {
|
||||
return None;
|
||||
}
|
||||
self.next = now + interval;
|
||||
Some(if now.duration_since(self.since) >= DROP_STACK_HOLD {
|
||||
DropStep::Stack
|
||||
} else {
|
||||
DropStep::One
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Which button pictures prompts use.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GlyphStyle {
|
||||
@@ -404,13 +470,21 @@ impl GlyphStyle {
|
||||
GlyphStyle::PlayStation,
|
||||
GlyphStyle::Nintendo,
|
||||
];
|
||||
/// The style for a pad by its USB vendor (Sony, Nintendo; Microsoft,
|
||||
/// Valve's Steam Deck and Steam Input's virtual pad, and everything else
|
||||
/// look like Xbox).
|
||||
pub fn of_vendor(vendor: Option<u16>) -> Self {
|
||||
match vendor {
|
||||
Some(0x054c) => GlyphStyle::PlayStation,
|
||||
Some(0x057e) => GlyphStyle::Nintendo,
|
||||
/// The style for a pad by its USB ids: Sony's and Nintendo's own, and a few officially licensed
|
||||
/// third-party pads made for their consoles. Microsoft, Valve's Steam Deck and Steam Input's
|
||||
/// virtual pad, and everything else look like Xbox.
|
||||
pub fn of_device(vendor: Option<u16>, product: Option<u16>) -> Self {
|
||||
match (vendor, product) {
|
||||
(Some(0x054c), _) => GlyphStyle::PlayStation,
|
||||
(Some(0x057e), _) => GlyphStyle::Nintendo,
|
||||
// HORIPAD 4 FPS and FPS Plus; Razer Raiju; Nacon Revolution Pro.
|
||||
(Some(0x0f0d), Some(0x0055 | 0x0066))
|
||||
| (Some(0x1532), Some(0x1000))
|
||||
| (Some(0x146b), Some(0x0d01)) => GlyphStyle::PlayStation,
|
||||
// HORIPAD for Nintendo Switch; PowerA Wired Plus; PDP Faceoff Wired Pro.
|
||||
(Some(0x0f0d), Some(0x00c1))
|
||||
| (Some(0x20d6), Some(0xa711))
|
||||
| (Some(0x0e6f), Some(0x0180)) => GlyphStyle::Nintendo,
|
||||
_ => GlyphStyle::Xbox,
|
||||
}
|
||||
}
|
||||
@@ -718,6 +792,29 @@ pub fn stick_axes(
|
||||
}
|
||||
}
|
||||
|
||||
/// The tilt from which a stick holds a key in [`eight_direction_keys`]: holding one is full speed,
|
||||
/// so the small tilts past the deadzone stay still.
|
||||
const KEY_TILT: f32 = 0.3;
|
||||
/// tan(22.5 degrees): where one of the 8 sectors meets the next.
|
||||
const SECTOR_EDGE: f32 = 0.414_213_57;
|
||||
|
||||
/// The movement keys (forward, backward, left, right) a move vector (`[left, forward]`) holds: the
|
||||
/// one of the 8 headings it is nearest, so it is at most 22.5 degrees off. An edge goes to the
|
||||
/// straight heading.
|
||||
pub fn eight_direction_keys([left, forward]: [f32; 2]) -> [bool; 4] {
|
||||
if length([left, forward]) < KEY_TILT {
|
||||
return [false; 4];
|
||||
}
|
||||
let along = forward.abs() > SECTOR_EDGE * left.abs();
|
||||
let across = left.abs() > SECTOR_EDGE * forward.abs();
|
||||
[
|
||||
along && forward > 0.0,
|
||||
along && forward < 0.0,
|
||||
across && left > 0.0,
|
||||
across && left < 0.0,
|
||||
]
|
||||
}
|
||||
|
||||
/// A radial deadzone: inside it the stick is centred; beyond it the rest of
|
||||
/// the travel is rescaled to 0..1 in the same direction, so diagonals aren't
|
||||
/// clipped and the first movement past the deadzone is small.
|
||||
@@ -766,15 +863,6 @@ pub fn look_delta(
|
||||
)
|
||||
}
|
||||
|
||||
/// The hotbar slot after a bumper: one step with wrap-around.
|
||||
pub fn hotbar_step(selected: usize, forward: bool) -> usize {
|
||||
if forward {
|
||||
(selected + 1) % 9
|
||||
} else {
|
||||
(selected + 8) % 9
|
||||
}
|
||||
}
|
||||
|
||||
/// Menu cursor speed in GUI pixels per second at full tilt.
|
||||
pub const CURSOR_SPEED: f32 = 300.0;
|
||||
|
||||
@@ -1481,6 +1569,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_drop_repeats_then_throws_the_stack() {
|
||||
let t = Instant::now();
|
||||
let at = |ms: u64| t + Duration::from_millis(ms);
|
||||
let (delay, interval) = (Duration::from_millis(350), Duration::from_millis(90));
|
||||
let mut held = HeldDrop::new(t, delay);
|
||||
assert_eq!(held.due(at(100), interval), None, "a tap is one item");
|
||||
assert_eq!(held.due(at(349), interval), None);
|
||||
assert_eq!(held.due(at(350), interval), Some(DropStep::One));
|
||||
assert_eq!(held.due(at(400), interval), None, "not before the interval");
|
||||
assert_eq!(held.due(at(440), interval), Some(DropStep::One));
|
||||
assert_eq!(held.due(at(999), interval), Some(DropStep::One));
|
||||
assert_eq!(held.due(at(1000), interval), None);
|
||||
assert_eq!(held.due(at(1100), interval), Some(DropStep::Stack));
|
||||
assert_eq!(held.due(at(1200), interval), Some(DropStep::Stack));
|
||||
assert_eq!(held.due(at(1201), interval), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_later_actions_have_no_default_button_and_leave_saved_bindings() {
|
||||
let defaults = PadSettings::default();
|
||||
for action in [
|
||||
"key.fullscreen",
|
||||
"key.socialInteractions",
|
||||
"key.quickActions",
|
||||
"key.saveToolbarActivator",
|
||||
"key.loadToolbarActivator",
|
||||
"key.hotbar.1",
|
||||
"key.hotbar.9",
|
||||
] {
|
||||
assert_eq!(defaults.binding(action), None, "{action}");
|
||||
}
|
||||
let read = PadSettings::parse(
|
||||
"bindLayout:1
|
||||
bind_key.jump:east
|
||||
bind_key.hotbar.2:north
|
||||
",
|
||||
);
|
||||
assert_eq!(read.binding("key.jump"), Some(Button::East));
|
||||
assert_eq!(read.binding("key.inventory"), Some(Button::North));
|
||||
assert_eq!(
|
||||
read.actions_of(Button::North),
|
||||
["key.inventory", "key.hotbar.2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_preferences_migrate_and_stay_with_each_player() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
@@ -1600,7 +1734,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_follow_bedrock() {
|
||||
fn every_action_has_its_default() {
|
||||
let s = PadSettings::default();
|
||||
assert_eq!(s.actions_of(Button::South), ["key.jump"]);
|
||||
assert_eq!(s.actions_of(Button::RightTrigger), ["key.attack"]);
|
||||
@@ -1728,6 +1862,63 @@ mod tests {
|
||||
assert_eq!(Button::North.by_letter().label(GlyphStyle::Nintendo), "Y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stick_holds_the_keys_of_its_nearest_heading() {
|
||||
// Forward, backward, left, right, by the heading counted from forward toward left.
|
||||
let headings = [
|
||||
[true, false, false, false],
|
||||
[true, false, true, false],
|
||||
[false, false, true, false],
|
||||
[false, true, true, false],
|
||||
[false, true, false, false],
|
||||
[false, true, false, true],
|
||||
[false, false, false, true],
|
||||
[true, false, false, true],
|
||||
];
|
||||
for tenth in 0..3600 {
|
||||
let degrees = tenth as f32 / 10.0;
|
||||
if degrees % 45.0 == 22.5 {
|
||||
continue;
|
||||
}
|
||||
for tilt in [0.31, 0.5, 1.0] {
|
||||
let (s, c) = degrees.to_radians().sin_cos();
|
||||
let keys = eight_direction_keys([tilt * s, tilt * c]);
|
||||
let sector = ((degrees / 45.0).round() as usize) % 8;
|
||||
assert_eq!(keys, headings[sector], "{degrees} at {tilt}");
|
||||
let [left, forward] =
|
||||
crate::input::keyboard_direction(keys[0], keys[1], keys[2], keys[3]);
|
||||
let off = (left.atan2(forward).to_degrees() - degrees).rem_euclid(360.0);
|
||||
assert!(off.min(360.0 - off) <= 22.5 + 1e-3, "{degrees}: {off}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sector_edge_goes_to_the_straight_heading() {
|
||||
assert_eq!(
|
||||
eight_direction_keys([0.414_213_57, 1.0]),
|
||||
[true, false, false, false]
|
||||
);
|
||||
assert_eq!(
|
||||
eight_direction_keys([0.4143, 1.0]),
|
||||
[true, false, true, false]
|
||||
);
|
||||
assert_eq!(
|
||||
eight_direction_keys([1.0, 0.414_213_57]),
|
||||
[false, false, true, false]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_tilt_holds_no_key() {
|
||||
assert_eq!(eight_direction_keys([0.0, 0.0]), [false; 4]);
|
||||
assert_eq!(eight_direction_keys([0.2, 0.2]), [false; 4]);
|
||||
assert_eq!(
|
||||
eight_direction_keys([0.0, 0.31]),
|
||||
[true, false, false, false]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_deadzone_rescales() {
|
||||
assert_eq!(radial([0.1, 0.05], 0.15), [0.0, 0.0]);
|
||||
@@ -2258,12 +2449,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotbar_wraps_and_glyphs_follow_the_vendor() {
|
||||
assert_eq!(hotbar_step(8, true), 0);
|
||||
assert_eq!(hotbar_step(0, false), 8);
|
||||
assert_eq!(hotbar_step(3, true), 4);
|
||||
assert_eq!(GlyphStyle::of_vendor(Some(0x054c)), GlyphStyle::PlayStation);
|
||||
assert_eq!(GlyphStyle::of_vendor(Some(0x28de)), GlyphStyle::Xbox);
|
||||
assert_eq!(GlyphStyle::of_vendor(None), GlyphStyle::Xbox);
|
||||
fn glyphs_follow_the_vendor() {
|
||||
let of = |vendor, product| GlyphStyle::of_device(Some(vendor), Some(product));
|
||||
// Any product of the consoles' makers.
|
||||
assert_eq!(of(0x054c, 0x0ce6), GlyphStyle::PlayStation);
|
||||
assert_eq!(of(0x054c, 0x0001), GlyphStyle::PlayStation);
|
||||
assert_eq!(of(0x057e, 0x2009), GlyphStyle::Nintendo);
|
||||
// A pad made for a console by another maker, and that maker's others.
|
||||
assert_eq!(of(0x0f0d, 0x0066), GlyphStyle::PlayStation);
|
||||
assert_eq!(of(0x0f0d, 0x00c1), GlyphStyle::Nintendo);
|
||||
assert_eq!(of(0x0f0d, 0x0067), GlyphStyle::Xbox);
|
||||
assert_eq!(of(0x20d6, 0xa711), GlyphStyle::Nintendo);
|
||||
// Valve's virtual pad, Microsoft's, nothing known, nothing reported.
|
||||
assert_eq!(of(0x28de, 0x11ff), GlyphStyle::Xbox);
|
||||
assert_eq!(of(0x045e, 0x0b12), GlyphStyle::Xbox);
|
||||
assert_eq!(of(0x1234, 0x5678), GlyphStyle::Xbox);
|
||||
assert_eq!(GlyphStyle::of_device(None, None), GlyphStyle::Xbox);
|
||||
// A vendor alone is enough only for the consoles' makers.
|
||||
assert_eq!(
|
||||
GlyphStyle::of_device(Some(0x054c), None),
|
||||
GlyphStyle::PlayStation
|
||||
);
|
||||
assert_eq!(GlyphStyle::of_device(Some(0x0f0d), None), GlyphStyle::Xbox);
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,18 @@
|
||||
//! device letting go (splitscreen moving the keyboard to another player, say)
|
||||
//! can't release what another still presses.
|
||||
|
||||
use crate::actions::Action;
|
||||
use std::collections::HashMap;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// Where a press came from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// The keyboard and mouse, and the scripting socket acting as them.
|
||||
/// The keyboard and mouse.
|
||||
Keyboard,
|
||||
Pad,
|
||||
/// `horizon-ctl`'s `key` and `mouse` and the agent commands that walk and click.
|
||||
Agent,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
@@ -19,6 +22,7 @@ impl Source {
|
||||
match self {
|
||||
Source::Keyboard => 1,
|
||||
Source::Pad => 2,
|
||||
Source::Agent => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,9 +33,20 @@ pub struct HeldKeys {
|
||||
}
|
||||
|
||||
impl HeldKeys {
|
||||
pub fn contains(&self, key: &KeyCode) -> bool {
|
||||
/// Whether anything holds this mapping: every device, whatever the mapping is bound to.
|
||||
pub fn held(&self, action: Action) -> bool {
|
||||
action.key().is_some_and(|key| self.raw(&key))
|
||||
}
|
||||
/// Whether a key that is no mapping's default is held (Right Shift, the arrows, Alt, the system
|
||||
/// key), or the key a caller already holds as a key: most code wants [`HeldKeys::held`].
|
||||
pub fn raw(&self, key: &KeyCode) -> bool {
|
||||
self.held.contains_key(key)
|
||||
}
|
||||
/// The old way of asking, by a mapping's default key; clippy refuses it (`clippy.toml`). It
|
||||
/// stays so that a change written against it says what to use instead.
|
||||
pub fn contains(&self, key: &KeyCode) -> bool {
|
||||
self.raw(key)
|
||||
}
|
||||
/// `source` holds `key`. True when nothing held it before.
|
||||
pub fn press(&mut self, key: KeyCode, source: Source) -> bool {
|
||||
let sources = self.held.entry(key).or_insert(0);
|
||||
@@ -72,7 +87,7 @@ impl HeldKeys {
|
||||
/// Held, by the keyboard unless a device already holds it: for callers
|
||||
/// that don't say where the press came from.
|
||||
pub fn insert(&mut self, key: KeyCode) -> bool {
|
||||
if self.contains(&key) {
|
||||
if self.raw(&key) {
|
||||
return false;
|
||||
}
|
||||
self.press(key, Source::Keyboard)
|
||||
@@ -132,13 +147,25 @@ mod tests {
|
||||
assert!(!keys.press(KeyCode::Space, Source::Keyboard));
|
||||
assert!(keys.press(KeyCode::KeyW, Source::Keyboard));
|
||||
assert_eq!(keys.release_source(Source::Keyboard), vec![KeyCode::KeyW]);
|
||||
assert!(keys.contains(&KeyCode::Space) && !keys.contains(&KeyCode::KeyW));
|
||||
assert!(keys.held(Action::Jump) && !keys.held(Action::Forward));
|
||||
assert!(keys.held_by(&KeyCode::Space, Source::Pad));
|
||||
assert!(!keys.held_by(&KeyCode::Space, Source::Keyboard));
|
||||
assert!(keys.release(&KeyCode::Space, Source::Pad));
|
||||
assert!(keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_holds_a_key_by_itself() {
|
||||
let mut keys = HeldKeys::default();
|
||||
keys.press(KeyCode::KeyW, Source::Agent);
|
||||
keys.press(KeyCode::KeyW, Source::Keyboard);
|
||||
assert!(!keys.release(&KeyCode::KeyW, Source::Keyboard));
|
||||
assert!(keys.held(Action::Forward));
|
||||
assert!(keys.release_source(Source::Pad).is_empty());
|
||||
assert_eq!(keys.release_source(Source::Agent), vec![KeyCode::KeyW]);
|
||||
assert!(!keys.held(Action::Forward));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untagged_inserts_and_removes_act_as_before() {
|
||||
let mut keys = HeldKeys::default();
|
||||
@@ -149,7 +176,7 @@ mod tests {
|
||||
keys.remove(&KeyCode::ShiftLeft),
|
||||
"remove lets go for everyone"
|
||||
);
|
||||
assert!(!keys.contains(&KeyCode::ShiftLeft));
|
||||
assert!(!keys.held(Action::Sneak));
|
||||
assert!(keys.release(&KeyCode::KeyA, Source::Keyboard), "never held");
|
||||
}
|
||||
}
|
||||
@@ -442,6 +442,18 @@ impl KeyBinds {
|
||||
}
|
||||
vec![Ok(key)]
|
||||
}
|
||||
/// `Minecraft.handleKeybinds`: key.pickItem and key.spectatorHotbar share the middle button by
|
||||
/// default and are separate mappings, so a press of what `input` is bound to acts only as the
|
||||
/// one this player can use: the spectator hotbar for a spectator, pick block otherwise. (Pick
|
||||
/// block does nothing a spectator would see.)
|
||||
pub fn middle_acts(&self, input: &str, spectator: bool) -> bool {
|
||||
let mapping = if spectator {
|
||||
"key.spectatorHotbar"
|
||||
} else {
|
||||
"key.pickItem"
|
||||
};
|
||||
self.get(mapping) == input
|
||||
}
|
||||
/// Translate a mouse button the same way (keys bound to mouse buttons).
|
||||
pub fn translate_mouse(&self, button: MouseButton) -> Option<Result<KeyCode, MouseButton>> {
|
||||
self.translate_mouse_all(button).into_iter().next()
|
||||
@@ -505,6 +517,27 @@ impl KeyBinds {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mappings_sharing_the_middle_button_act_as_themselves() {
|
||||
let middle = "key.mouse.middle";
|
||||
let z = "key.keyboard.z";
|
||||
let mut keys = KeyBinds::default();
|
||||
assert!(keys.middle_acts(middle, false) && keys.middle_acts(middle, true));
|
||||
// The spectator hotbar on Z: the middle button only picks, and Z never picks.
|
||||
keys.set("key.spectatorHotbar", z);
|
||||
assert!(keys.middle_acts(middle, false));
|
||||
assert!(!keys.middle_acts(middle, true));
|
||||
assert!(keys.middle_acts(z, true));
|
||||
assert!(!keys.middle_acts(z, false));
|
||||
// Pick block on Z instead: the other way round.
|
||||
keys.reset_all();
|
||||
keys.set("key.pickItem", z);
|
||||
assert!(keys.middle_acts(z, false));
|
||||
assert!(!keys.middle_acts(z, true));
|
||||
assert!(keys.middle_acts(middle, true));
|
||||
assert!(!keys.middle_acts(middle, false));
|
||||
}
|
||||
#[test]
|
||||
fn container_mouse_hotkeys_follow_slot_bindings_and_pick_item_priority() {
|
||||
for button in [MouseButton::Back, MouseButton::Forward, MouseButton::Middle] {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! options. horizon-client re-exports each module under its old path
|
||||
//! (`horizon_client::keybinds`, ...).
|
||||
#![forbid(unsafe_code)]
|
||||
pub mod actions;
|
||||
pub mod clipboard;
|
||||
pub mod gamepad;
|
||||
pub mod held;
|
||||
|
||||
@@ -133,6 +133,13 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Play",
|
||||
vec![option(
|
||||
Opt::PadTriggerThreshold,
|
||||
"How far a trigger is pulled before it counts as pressed.",
|
||||
)],
|
||||
),
|
||||
(
|
||||
"Menus",
|
||||
vec![
|
||||
@@ -1266,6 +1273,7 @@ mod tests {
|
||||
Opt::PadInvertY,
|
||||
Opt::PadMoveDeadzone,
|
||||
Opt::PadLookDeadzone,
|
||||
Opt::PadTriggerThreshold,
|
||||
Opt::PadGlyphs,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadPointerSpeed,
|
||||
|
||||
@@ -1313,12 +1313,13 @@ impl HudAtlas {
|
||||
edition.3 = pixels;
|
||||
}
|
||||
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
|
||||
images.push((
|
||||
crate::pad_cursor::SPRITE.into(),
|
||||
crate::pad_cursor::SIZE,
|
||||
crate::pad_cursor::SIZE,
|
||||
crate::pad_cursor::pixels(),
|
||||
));
|
||||
// The controller's art, unless a pack brought its own (walked above, as a mod's GUI
|
||||
// textures are).
|
||||
for image in crate::pad_glyph::images() {
|
||||
if !images.iter().any(|i| i.0 == image.0) {
|
||||
images.push(image);
|
||||
}
|
||||
}
|
||||
if full {
|
||||
Self::slots(&mut images, crate::trims::slot_size(root), mod_image);
|
||||
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
|
||||
|
||||
@@ -76,6 +76,7 @@ pub mod pack_browser;
|
||||
pub mod pack_screen;
|
||||
pub mod packs;
|
||||
pub mod pad_cursor;
|
||||
pub mod pad_glyph;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
|
||||
@@ -281,6 +281,7 @@ impl Opt {
|
||||
Opt::PadToggleSneak => "horizon.options.sneak_button",
|
||||
Opt::PadMoveDeadzone => "horizon.options.move_deadzone",
|
||||
Opt::PadLookDeadzone => "horizon.options.look_deadzone",
|
||||
Opt::PadTriggerThreshold => "horizon.options.trigger_threshold",
|
||||
Opt::PadGlyphs => "horizon.options.button_glyphs",
|
||||
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
|
||||
Opt::ModelPart(i) => [
|
||||
@@ -361,6 +362,7 @@ impl Opt {
|
||||
Opt::PadRepeatInterval => unit(s.controller.repeat_interval, 0.03, 0.3),
|
||||
Opt::PadMoveDeadzone => unit(s.controller.move_deadzone, 0.0, 0.5),
|
||||
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
|
||||
Opt::PadTriggerThreshold => unit(s.controller.trigger_threshold, 0.1, 0.9),
|
||||
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
|
||||
_ => Kind::Cycle,
|
||||
}
|
||||
@@ -425,6 +427,7 @@ impl Opt {
|
||||
Opt::MenuBlur => Some(10),
|
||||
Opt::NotificationDisplayTime => Some(95),
|
||||
Opt::PadMoveDeadzone | Opt::PadLookDeadzone => Some(50),
|
||||
Opt::PadTriggerThreshold => Some(80),
|
||||
Opt::SprintWindow => Some(10),
|
||||
_ => None,
|
||||
}
|
||||
@@ -634,11 +637,11 @@ impl Opt {
|
||||
)],
|
||||
),
|
||||
),
|
||||
Opt::PadMoveDeadzone | Opt::PadLookDeadzone => {
|
||||
let v = if self == Opt::PadMoveDeadzone {
|
||||
s.controller.move_deadzone
|
||||
} else {
|
||||
s.controller.look_deadzone
|
||||
Opt::PadMoveDeadzone | Opt::PadLookDeadzone | Opt::PadTriggerThreshold => {
|
||||
let v = match self {
|
||||
Opt::PadMoveDeadzone => s.controller.move_deadzone,
|
||||
Opt::PadLookDeadzone => s.controller.look_deadzone,
|
||||
_ => s.controller.trigger_threshold,
|
||||
};
|
||||
Text::translate(
|
||||
"options.percent_value",
|
||||
@@ -897,6 +900,7 @@ impl Opt {
|
||||
Opt::PadRepeatInterval => s.controller.repeat_interval = step(30.0, 300.0) / 1000.0,
|
||||
Opt::PadMoveDeadzone => s.controller.move_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::PadTriggerThreshold => s.controller.trigger_threshold = step(10.0, 90.0) / 100.0,
|
||||
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
|
||||
_ => {}
|
||||
}
|
||||
@@ -1564,7 +1568,7 @@ pub struct OptionsUi {
|
||||
pub selected_key: Option<&'static str>,
|
||||
/// The native mod's key waiting: (mod, key) in `mods.installed`.
|
||||
selected_native_key: Option<(usize, usize)>,
|
||||
/// Controller Binds: the action waiting for a pad button.
|
||||
/// Bindings: the action waiting for a pad button.
|
||||
pub selected_pad_action: Option<&'static str>,
|
||||
/// The glyphs the binds list shows (the setting, else the pad's family).
|
||||
pub pad_style: GlyphStyle,
|
||||
@@ -8249,6 +8253,7 @@ mod tests {
|
||||
Opt::NotificationDisplayTime,
|
||||
Opt::PadMoveDeadzone,
|
||||
Opt::PadLookDeadzone,
|
||||
Opt::PadTriggerThreshold,
|
||||
Opt::SprintWindow,
|
||||
];
|
||||
for opt in stepped_opts {
|
||||
|
||||
@@ -1,43 +1,13 @@
|
||||
//! The controller's menu cursor in inventories: a crosshair whose centre is
|
||||
//! the point it clicks. Horizon's own art (pad_cursor.png).
|
||||
//! the point it clicks. Horizon's own art, packed with the button glyphs (`pad_glyph`).
|
||||
|
||||
/// The atlas sprite.
|
||||
pub const SPRITE: &str = "horizon/pad_cursor";
|
||||
/// The texture's size in texels, and the size it's drawn at in GUI pixels
|
||||
/// (an item's).
|
||||
pub const SIZE: u32 = 32;
|
||||
pub const SPRITE: &str = "horizon:gui/controller/crosshair_inv";
|
||||
/// The size it's drawn at in GUI pixels (an item's); its texture is twice that.
|
||||
pub const DRAWN: f32 = 16.0;
|
||||
|
||||
const PNG: &[u8] = include_bytes!("pad_cursor.png");
|
||||
|
||||
/// The texture as `SIZE` square RGBA texels.
|
||||
pub fn pixels() -> Vec<u8> {
|
||||
decode(PNG).expect("pad_cursor.png is a 32x32 PNG")
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
let info = reader.info();
|
||||
if (info.width, info.height) != (SIZE, SIZE) {
|
||||
return None;
|
||||
}
|
||||
let mut buf = vec![0; reader.output_buffer_size()?];
|
||||
let frame = reader.next_frame(&mut buf).ok()?;
|
||||
let channels = frame.color_type.samples();
|
||||
Some(
|
||||
buf.chunks(channels)
|
||||
.take((SIZE * SIZE) as usize)
|
||||
.flat_map(|p| match channels {
|
||||
4 => [p[0], p[1], p[2], p[3]],
|
||||
3 => [p[0], p[1], p[2], 255],
|
||||
2 => [p[0], p[0], p[0], p[1]],
|
||||
_ => [p[0], p[0], p[0], 255],
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
pub(crate) const PNG: &[u8] =
|
||||
include_bytes!("../../horizon-client/assets/horizon/textures/gui/controller/crosshair_inv.png");
|
||||
|
||||
/// Where to draw the cursor (top-left, GUI pixels) so its centre lands on
|
||||
/// the GUI point `at`.
|
||||
@@ -51,10 +21,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_cursor_is_a_crosshair_centred_on_its_point() {
|
||||
let px = pixels();
|
||||
assert_eq!(px.len(), (SIZE * SIZE * 4) as usize);
|
||||
let alpha = |x: u32, y: u32| px[((y * SIZE + x) * 4 + 3) as usize];
|
||||
let middle = (SIZE / 2 - 1)..=(SIZE / 2);
|
||||
let (_, w, h, px) = crate::pad_glyph::images()
|
||||
.find(|i| i.0 == SPRITE)
|
||||
.expect("the cursor is packed");
|
||||
assert_eq!((w as f32, h as f32), (DRAWN * 2.0, DRAWN * 2.0));
|
||||
let alpha = |x: u32, y: u32| px[((y * w + x) * 4 + 3) as usize];
|
||||
let middle = (w / 2 - 1)..=(w / 2);
|
||||
assert!(
|
||||
middle
|
||||
.clone()
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
//! A controller button as the player sees it: Horizon's own art where the pad's family has one
|
||||
//! (`assets/horizon/textures/gui/controller/`, which a resource pack can replace), else the label
|
||||
//! printed on the pad, in its colour, on a dark key. Whatever shows a button draws and measures it
|
||||
//! here; `Button::label` stays for plain text and narration.
|
||||
use crate::gamepad::{Button, GlyphStyle};
|
||||
use crate::hud::{HudAtlas, HudBatch, rgba};
|
||||
use crate::pad_cursor;
|
||||
|
||||
/// The art's height as drawn, in GUI pixels. Its texture is twice that, so it maps pixel for pixel
|
||||
/// at GUI scale 2 and to whole pixels at 4.
|
||||
const DRAWN_HEIGHT: f32 = 16.0;
|
||||
/// The clear edge the art has all round.
|
||||
const MARGIN: f32 = 2.0;
|
||||
/// How tall a glyph shows, art or key: the text line and 2 above and below it.
|
||||
pub const HEIGHT: f32 = DRAWN_HEIGHT - 2.0 * MARGIN;
|
||||
/// How wide a face button's art shows; a declared button's label leaves it room.
|
||||
pub const FACE_WIDTH: f32 = 12.0;
|
||||
|
||||
/// A picture of a button in a family (Auto reads as Xbox, as `Button::label` has it). Every family
|
||||
/// has its own file for every button, some of them a copy of another's until theirs is drawn.
|
||||
struct Art {
|
||||
family: GlyphStyle,
|
||||
button: Button,
|
||||
/// The atlas sprite: where a pack's own file lands.
|
||||
sprite: &'static str,
|
||||
png: &'static [u8],
|
||||
}
|
||||
|
||||
macro_rules! art {
|
||||
($($family:ident $button:ident $file:literal,)*) => {
|
||||
&[$(Art {
|
||||
family: GlyphStyle::$family,
|
||||
button: Button::$button,
|
||||
sprite: concat!("horizon:gui/controller/", $file),
|
||||
png: include_bytes!(concat!(
|
||||
"../../horizon-client/assets/horizon/textures/gui/controller/",
|
||||
$file,
|
||||
".png"
|
||||
)),
|
||||
},)*]
|
||||
};
|
||||
}
|
||||
|
||||
/// All the art. A new picture is one line here and its file: a button not listed is text, and a
|
||||
/// picture is measured from its texture, so it can be as wide as it needs.
|
||||
static ART: &[Art] = art![
|
||||
Xbox South "xb_a",
|
||||
Xbox East "xb_b",
|
||||
Xbox West "xb_x",
|
||||
Xbox North "xb_y",
|
||||
Xbox LeftBumper "xb_lb",
|
||||
Xbox RightBumper "xb_rb",
|
||||
Xbox LeftTrigger "xb_lt",
|
||||
Xbox RightTrigger "xb_rt",
|
||||
Xbox Select "xb_view",
|
||||
Xbox Start "xb_menu",
|
||||
Xbox LeftStick "xb_l3",
|
||||
Xbox RightStick "xb_r3",
|
||||
Xbox DPadUp "xb_up",
|
||||
Xbox DPadDown "xb_down",
|
||||
Xbox DPadLeft "xb_left",
|
||||
Xbox DPadRight "xb_right",
|
||||
PlayStation South "psx_cross",
|
||||
PlayStation East "psx_circle",
|
||||
PlayStation West "psx_square",
|
||||
PlayStation North "psx_triangle",
|
||||
PlayStation LeftBumper "psx_l1",
|
||||
PlayStation RightBumper "psx_r1",
|
||||
PlayStation LeftTrigger "psx_l2",
|
||||
PlayStation RightTrigger "psx_r2",
|
||||
PlayStation Select "psx_create",
|
||||
PlayStation Start "psx_options",
|
||||
PlayStation LeftStick "psx_l3",
|
||||
PlayStation RightStick "psx_r3",
|
||||
PlayStation DPadUp "psx_up",
|
||||
PlayStation DPadDown "psx_down",
|
||||
PlayStation DPadLeft "psx_left",
|
||||
PlayStation DPadRight "psx_right",
|
||||
Nintendo South "nx_b",
|
||||
Nintendo East "nx_a",
|
||||
Nintendo West "nx_y",
|
||||
Nintendo North "nx_x",
|
||||
Nintendo LeftBumper "nx_l",
|
||||
Nintendo RightBumper "nx_r",
|
||||
Nintendo LeftTrigger "nx_zl",
|
||||
Nintendo RightTrigger "nx_zr",
|
||||
Nintendo Select "nx_minus",
|
||||
Nintendo Start "nx_plus",
|
||||
Nintendo LeftStick "nx_l3",
|
||||
Nintendo RightStick "nx_r3",
|
||||
Nintendo DPadUp "nx_up",
|
||||
Nintendo DPadDown "nx_down",
|
||||
Nintendo DPadLeft "nx_left",
|
||||
Nintendo DPadRight "nx_right",
|
||||
];
|
||||
|
||||
fn find(button: Button, style: GlyphStyle) -> Option<&'static Art> {
|
||||
let family = match style {
|
||||
GlyphStyle::Auto => GlyphStyle::Xbox,
|
||||
style => style,
|
||||
};
|
||||
ART.iter()
|
||||
.find(|art| art.family == family && art.button == button)
|
||||
}
|
||||
|
||||
/// The sprite of a button in a family, if it has one.
|
||||
pub fn sprite(button: Button, style: GlyphStyle) -> Option<&'static str> {
|
||||
find(button, style).map(|art| art.sprite)
|
||||
}
|
||||
|
||||
/// The sprite this atlas can draw for the button; none (the boot screen's atlas) means text.
|
||||
pub fn art(atlas: &HudAtlas, button: Button, style: GlyphStyle) -> Option<&'static str> {
|
||||
sprite(button, style).filter(|name| atlas.sprites.contains_key(*name))
|
||||
}
|
||||
|
||||
/// How wide the art shows: half its texture (IHDR's width, in a PNG's first bytes) less its edge.
|
||||
/// A pack's own texture of another size is drawn in the same place.
|
||||
fn art_width(art: &Art) -> f32 {
|
||||
let texels = u32::from_be_bytes([art.png[16], art.png[17], art.png[18], art.png[19]]);
|
||||
texels as f32 / 2.0 - 2.0 * MARGIN
|
||||
}
|
||||
|
||||
/// How wide the glyph shows.
|
||||
pub fn width(atlas: &HudAtlas, button: Button, style: GlyphStyle) -> f32 {
|
||||
match find(button, style).filter(|a| atlas.sprites.contains_key(a.sprite)) {
|
||||
Some(art) => art_width(art),
|
||||
None => HudBatch::text_width(atlas, button.label(style)).ceil() + 4.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the glyph covers with its label's text at `(x, y)`: `[x, y, w, h]`.
|
||||
pub fn rect(atlas: &HudAtlas, button: Button, style: GlyphStyle, x: f32, y: f32) -> [f32; 4] {
|
||||
[x - 1.0, y - 2.0, width(atlas, button, style), HEIGHT]
|
||||
}
|
||||
|
||||
/// Draw the glyph over the 8 pixel text line at `(x, y)`, filling [`rect`]. A dark key backs text
|
||||
/// only: the art brings its own.
|
||||
pub fn draw(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
button: Button,
|
||||
style: GlyphStyle,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let [left, top, wide, tall] = rect(atlas, button, style, x, y);
|
||||
if let Some(name) = art(atlas, button, style) {
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
name,
|
||||
left - MARGIN,
|
||||
top - MARGIN,
|
||||
wide + 2.0 * MARGIN,
|
||||
tall + 2.0 * MARGIN,
|
||||
u32::MAX,
|
||||
);
|
||||
} else {
|
||||
let [r, g, b] = button.color(style);
|
||||
batch.fill(atlas, left, top, wide, tall, rgba(40, 40, 40, 255));
|
||||
batch.text(
|
||||
atlas,
|
||||
button.label(style),
|
||||
x + 1.0,
|
||||
y,
|
||||
rgba(r, g, b, 255),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's art for the atlas: each `(sprite, width, height, RGBA texels)`.
|
||||
pub fn images() -> impl Iterator<Item = (String, u32, u32, Vec<u8>)> {
|
||||
ART.iter()
|
||||
.map(|art| (art.sprite, art.png))
|
||||
.chain([(pad_cursor::SPRITE, pad_cursor::PNG)])
|
||||
.map(|(name, png)| {
|
||||
let (w, h, pixels) = decode(png).expect("Horizon's controller art is PNG");
|
||||
(name.to_owned(), w, h, pixels)
|
||||
})
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Option<(u32, u32, Vec<u8>)> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
let (w, h) = (reader.info().width, reader.info().height);
|
||||
let mut buf = vec![0; reader.output_buffer_size()?];
|
||||
let frame = reader.next_frame(&mut buf).ok()?;
|
||||
let channels = frame.color_type.samples();
|
||||
let pixels = buf
|
||||
.chunks(channels)
|
||||
.take((w * h) as usize)
|
||||
.flat_map(|p| match channels {
|
||||
4 => [p[0], p[1], p[2], p[3]],
|
||||
3 => [p[0], p[1], p[2], 255],
|
||||
2 => [p[0], p[0], p[0], p[1]],
|
||||
_ => [p[0], p[0], p[0], 255],
|
||||
})
|
||||
.collect();
|
||||
Some((w, h, pixels))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
fn file(button: Button, style: GlyphStyle) -> Option<&'static str> {
|
||||
sprite(button, style).and_then(|name| name.strip_prefix("horizon:gui/controller/"))
|
||||
}
|
||||
|
||||
/// The batch's quads as `[left, top, right, bottom]`.
|
||||
fn quads(batch: &HudBatch) -> Vec<[f32; 4]> {
|
||||
batch
|
||||
.vertices
|
||||
.as_chunks::<6>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|quad| {
|
||||
let xs = quad.iter().map(|v| v.pos[0]);
|
||||
let ys = quad.iter().map(|v| v.pos[1]);
|
||||
[
|
||||
xs.clone().fold(f32::MAX, f32::min),
|
||||
ys.clone().fold(f32::MAX, f32::min),
|
||||
xs.fold(f32::MIN, f32::max),
|
||||
ys.fold(f32::MIN, f32::max),
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xbox_and_auto_show_the_letters_and_shoulders() {
|
||||
for style in [GlyphStyle::Xbox, GlyphStyle::Auto] {
|
||||
for (button, expected) in [
|
||||
(Button::South, "xb_a"),
|
||||
(Button::East, "xb_b"),
|
||||
(Button::West, "xb_x"),
|
||||
(Button::North, "xb_y"),
|
||||
(Button::LeftBumper, "xb_lb"),
|
||||
(Button::RightBumper, "xb_rb"),
|
||||
(Button::LeftTrigger, "xb_lt"),
|
||||
(Button::RightTrigger, "xb_rt"),
|
||||
(Button::Select, "xb_view"),
|
||||
(Button::Start, "xb_menu"),
|
||||
] {
|
||||
assert_eq!(file(button, style), Some(expected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playstation_shows_its_symbols_by_position_and_its_own_shoulders() {
|
||||
let style = GlyphStyle::PlayStation;
|
||||
assert_eq!(file(Button::South, style), Some("psx_cross"));
|
||||
assert_eq!(file(Button::East, style), Some("psx_circle"));
|
||||
assert_eq!(file(Button::West, style), Some("psx_square"));
|
||||
assert_eq!(file(Button::North, style), Some("psx_triangle"));
|
||||
assert_eq!(file(Button::LeftBumper, style), Some("psx_l1"));
|
||||
assert_eq!(file(Button::RightTrigger, style), Some("psx_r2"));
|
||||
assert_eq!(file(Button::Select, style), Some("psx_create"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nintendo_shows_what_its_pad_prints_at_each_position() {
|
||||
let style = GlyphStyle::Nintendo;
|
||||
assert_eq!(file(Button::South, style), Some("nx_b"));
|
||||
assert_eq!(file(Button::East, style), Some("nx_a"));
|
||||
assert_eq!(file(Button::West, style), Some("nx_y"));
|
||||
assert_eq!(file(Button::North, style), Some("nx_x"));
|
||||
assert_eq!(file(Button::LeftTrigger, style), Some("nx_zl"));
|
||||
assert_eq!(file(Button::Start, style), Some("nx_plus"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_family_has_its_own_file_for_every_button() {
|
||||
for (style, prefix) in [
|
||||
(GlyphStyle::Xbox, "xb_"),
|
||||
(GlyphStyle::PlayStation, "psx_"),
|
||||
(GlyphStyle::Nintendo, "nx_"),
|
||||
] {
|
||||
for button in Button::ALL {
|
||||
let name = file(button, style).unwrap();
|
||||
assert!(name.starts_with(prefix), "{button:?} {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_sprite_has_its_texture_at_twice_gui_size() {
|
||||
let images: Vec<_> = images().collect();
|
||||
assert_eq!(images.len(), ART.len() + 1, "the art and the cursor");
|
||||
for (name, w, h, pixels) in &images {
|
||||
assert_eq!(pixels.len(), (w * h * 4) as usize, "{name}");
|
||||
assert_eq!(*h as f32, DRAWN_HEIGHT * 2.0, "{name}");
|
||||
}
|
||||
for art in ART {
|
||||
let (_, w, ..) = images.iter().find(|i| i.0 == art.sprite).unwrap();
|
||||
assert_eq!(art_width(art), *w as f32 / 2.0 - 4.0, "{}", art.sprite);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_button_has_two_pictures_in_a_family() {
|
||||
for (i, art) in ART.iter().enumerate() {
|
||||
assert!(
|
||||
!ART[..i]
|
||||
.iter()
|
||||
.any(|a| a.family == art.family && a.button == art.button),
|
||||
"{}",
|
||||
art.sprite
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_measures_from_its_shape_and_text_from_its_label() {
|
||||
let atlas = atlas();
|
||||
assert_eq!(width(&atlas, Button::South, GlyphStyle::Xbox), 12.0);
|
||||
assert_eq!(width(&atlas, Button::LeftBumper, GlyphStyle::Xbox), 20.0);
|
||||
assert_eq!(
|
||||
width(&atlas, Button::LeftBumper, GlyphStyle::PlayStation),
|
||||
20.0
|
||||
);
|
||||
assert_eq!(width(&atlas, Button::DPadUp, GlyphStyle::Nintendo), 12.0);
|
||||
let mut no_art = atlas;
|
||||
no_art
|
||||
.sprites
|
||||
.remove(sprite(Button::LeftTrigger, GlyphStyle::Nintendo).unwrap());
|
||||
let label = HudBatch::text_width(&no_art, "ZL").ceil() + 4.0;
|
||||
assert_eq!(
|
||||
width(&no_art, Button::LeftTrigger, GlyphStyle::Nintendo),
|
||||
label
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_atlas_without_the_art_falls_back_to_text() {
|
||||
let mut atlas = atlas();
|
||||
atlas
|
||||
.sprites
|
||||
.remove(sprite(Button::South, GlyphStyle::Xbox).unwrap());
|
||||
assert_eq!(art(&atlas, Button::South, GlyphStyle::Xbox), None);
|
||||
let label = HudBatch::text_width(&atlas, "A").ceil() + 4.0;
|
||||
assert_eq!(width(&atlas, Button::South, GlyphStyle::Xbox), label);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_is_one_quad_centred_on_the_text_line_with_no_key_behind_it() {
|
||||
let atlas = atlas();
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw(
|
||||
&mut batch,
|
||||
&atlas,
|
||||
Button::South,
|
||||
GlyphStyle::Xbox,
|
||||
10.0,
|
||||
20.0,
|
||||
);
|
||||
assert_eq!(quads(&batch), vec![[7.0, 16.0, 23.0, 32.0]]);
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw(
|
||||
&mut batch,
|
||||
&atlas,
|
||||
Button::LeftBumper,
|
||||
GlyphStyle::Xbox,
|
||||
10.0,
|
||||
20.0,
|
||||
);
|
||||
assert_eq!(quads(&batch), vec![[7.0, 16.0, 31.0, 32.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_sits_on_a_key_covering_what_rect_says() {
|
||||
let mut atlas = atlas();
|
||||
let style = GlyphStyle::Nintendo;
|
||||
atlas.sprites.remove(sprite(Button::South, style).unwrap());
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw(&mut batch, &atlas, Button::South, style, 10.0, 20.0);
|
||||
let [x, y, w, h] = rect(&atlas, Button::South, style, 10.0, 20.0);
|
||||
assert_eq!(quads(&batch)[0], [x, y, x + w, y + h]);
|
||||
}
|
||||
}
|
||||
@@ -814,6 +814,7 @@ pub enum Opt {
|
||||
PadToggleSneak,
|
||||
PadMoveDeadzone,
|
||||
PadLookDeadzone,
|
||||
PadTriggerThreshold,
|
||||
PadGlyphs,
|
||||
PadKeyboard,
|
||||
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::{
|
||||
gamepad::{Button as PadButton, GlyphStyle},
|
||||
graphics_style,
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
lang,
|
||||
lang, pad_glyph,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
@@ -411,30 +411,23 @@ impl SplitProfiles {
|
||||
None => tr("horizon.splitscreen.profiles.move", "Move where you play"),
|
||||
};
|
||||
let keys = [PadButton::LeftBumper, PadButton::RightBumper];
|
||||
let key_width = |b: PadButton| width(b.label(style)) + 4.0;
|
||||
let key_width = |b: PadButton| pad_glyph::width(atlas, b, style);
|
||||
let total = keys.iter().map(|&b| key_width(b) + 4.0).sum::<f32>() + 2.0 + width(&words);
|
||||
let (mut x, y) = (((batch.width - total) / 2.0).floor(), 30.0);
|
||||
for b in keys {
|
||||
let [r, g, bl] = b.color(style);
|
||||
let kw = key_width(b);
|
||||
// A light edge around the dark key: it stands out on the menu's dark background.
|
||||
// A light edge around a dark key: it stands out on the menu's dark background.
|
||||
if pad_glyph::art(atlas, b, style).is_none() {
|
||||
batch.fill(
|
||||
atlas,
|
||||
x - 2.0,
|
||||
y - 3.0,
|
||||
kw + 2.0,
|
||||
13.0,
|
||||
pad_glyph::HEIGHT + 2.0,
|
||||
rgba(170, 170, 170, 255),
|
||||
);
|
||||
batch.fill(atlas, x - 1.0, y - 2.0, kw, 11.0, rgba(40, 40, 40, 255));
|
||||
batch.text(
|
||||
atlas,
|
||||
b.label(style),
|
||||
x + 1.0,
|
||||
y,
|
||||
rgba(r, g, bl, 255),
|
||||
false,
|
||||
);
|
||||
}
|
||||
pad_glyph::draw(batch, atlas, b, style, x, y);
|
||||
x += kw + 4.0;
|
||||
}
|
||||
batch.text(atlas, &words, x + 2.0, y, rgba(240, 240, 240, 255), true);
|
||||
|
||||
@@ -201,9 +201,14 @@ pub fn set_action_glyphs(on: bool) {
|
||||
ACTION_GLYPHS.set(on);
|
||||
}
|
||||
|
||||
/// How much of a declared button's left the glyph takes from its label.
|
||||
/// How much of a declared button's left the glyph takes from its label: a face button's, the only
|
||||
/// kind declared buttons show, and no family's is wider than its art.
|
||||
pub fn action_glyph_room() -> f32 {
|
||||
if ACTION_GLYPHS.get() { 14.0 } else { 0.0 }
|
||||
if ACTION_GLYPHS.get() {
|
||||
crate::pad_glyph::FACE_WIDTH + 3.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// An interactive rectangle (GUI pixels) a controller can focus and click.
|
||||
@@ -244,6 +249,41 @@ pub fn clip_targets(mark: usize, y0: f32, y1: f32) {
|
||||
});
|
||||
}
|
||||
|
||||
/// How many labelled widgets and targets the frame has so far, for [`clip_frame`].
|
||||
pub fn frame_mark() -> (usize, usize) {
|
||||
let labelled = WIDGETS.with_borrow(|(current, _, _)| current.len());
|
||||
(labelled, targets_mark())
|
||||
}
|
||||
|
||||
/// Drop the labelled widgets and targets since `mark` whose middle isn't in `y0..y1` (GUI pixels):
|
||||
/// what a scrolling body clipped away, where a press wouldn't land.
|
||||
pub fn clip_frame((labelled, targets): (usize, usize), y0: f32, y1: f32) {
|
||||
let shown = |y: f32, h: f32| (y0..y1).contains(&(y + h / 2.0));
|
||||
WIDGETS.with_borrow_mut(|(current, _, _)| {
|
||||
let mut at = 0;
|
||||
current.retain(|w| {
|
||||
at += 1;
|
||||
at <= labelled || shown(w.y, w.h)
|
||||
});
|
||||
});
|
||||
TARGETS.with_borrow_mut(|(current, _, _)| {
|
||||
let mut at = 0;
|
||||
current.retain(|t| {
|
||||
at += 1;
|
||||
at <= targets || shown(t.rect[1], t.rect[3])
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A prompt comes up over what the frame drew so far: what it covers can't be reached, by the
|
||||
/// pointer or a controller, so its widgets, targets, declared buttons and lists go.
|
||||
pub fn cover_frame() {
|
||||
WIDGETS.with_borrow_mut(|(current, _, _)| current.clear());
|
||||
TARGETS.with_borrow_mut(|(current, _, _)| current.clear());
|
||||
ACTIONS.with_borrow_mut(|(current, _)| current.clear());
|
||||
SCROLL_AREAS.with_borrow_mut(|(current, _)| current.clear());
|
||||
}
|
||||
|
||||
/// This frame drew a list with something to scroll, in `rect`, scrolled `amount`
|
||||
/// of `max`: a focus step goes through its hidden rows before it leaves
|
||||
/// (`gamepad::list_step`).
|
||||
@@ -1349,4 +1389,42 @@ mod tests {
|
||||
"the clipped row is gone, earlier ones stay"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scrolled_body_loses_what_it_clips_and_nothing_before_it() {
|
||||
let _ = take();
|
||||
target([0.0, 0.0, 5.0, 5.0]);
|
||||
record("warning", [0.0, 0.0, 5.0, 5.0], true);
|
||||
let mark = frame_mark();
|
||||
record("shown", [0.0, 10.0, 5.0, 10.0], true);
|
||||
target([0.0, 10.0, 5.0, 10.0]);
|
||||
// Half in view is still pressed at its middle, which a mouse could reach.
|
||||
record("half", [0.0, 25.0, 5.0, 10.0], true);
|
||||
target([0.0, 25.0, 5.0, 10.0]);
|
||||
record("gone", [0.0, 40.0, 5.0, 10.0], true);
|
||||
target([0.0, 40.0, 5.0, 10.0]);
|
||||
clip_frame(mark, 8.0, 32.0);
|
||||
end_frame(101);
|
||||
let names: Vec<String> = last_labelled().into_iter().map(|w| w.label).collect();
|
||||
assert_eq!(names, ["warning", "shown", "half"]);
|
||||
assert_eq!(last_targets().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_covers_what_was_drawn_before_it() {
|
||||
let _ = take();
|
||||
record("under", [0.0, 0.0, 5.0, 5.0], true);
|
||||
target([0.0, 10.0, 5.0, 5.0]);
|
||||
declare_action(Action::Done, [0.0, 0.0, 5.0, 5.0], true);
|
||||
scroll_area([0.0, 0.0, 5.0, 5.0], 0.0, 5.0);
|
||||
cover_frame();
|
||||
record("over", [0.0, 20.0, 5.0, 5.0], true);
|
||||
set_action_screen(7);
|
||||
end_frame(102);
|
||||
let names: Vec<String> = last_labelled().into_iter().map(|w| w.label).collect();
|
||||
assert_eq!(names, ["over"]);
|
||||
assert!(last_targets().is_empty());
|
||||
assert!(last_actions(7).is_empty());
|
||||
assert!(last_scroll_areas().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"horizon.options.sneak_button": "Sneak Button",
|
||||
"horizon.options.move_deadzone": "Move Deadzone",
|
||||
"horizon.options.look_deadzone": "Look Deadzone",
|
||||
"horizon.options.trigger_threshold": "Trigger Threshold",
|
||||
"horizon.options.button_glyphs": "Button Glyphs",
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
|
||||
"horizon.options.chat_delay": "Chat Delay",
|
||||
@@ -80,7 +81,7 @@
|
||||
"horizon.pages.open.mods": "Mods...",
|
||||
"horizon.pages.open.agent": "AI Agent...",
|
||||
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
|
||||
"horizon.pages.open.controller_binds": "Controller Binds...",
|
||||
"horizon.pages.open.controller_binds": "Bindings...",
|
||||
"horizon.pages.open.horizon_settings": "Horizon Settings...",
|
||||
"horizon.pages.open.studio": "Replay Studio...",
|
||||
"horizon.studio.saved_recordings": "Saved Recordings",
|
||||
@@ -791,6 +792,7 @@
|
||||
"horizon.pvp.preset": "PvP preset",
|
||||
"horizon.pvp.preset.tooltip": "Turns on the custom crosshair, the armour and effects widgets, toggle sprint and sneak, lower fire, and the FPS counter and keystrokes mods if installed. Your settings for each stay.",
|
||||
"Bindings": "Bindings",
|
||||
"Play": "Play",
|
||||
"Device": "Device",
|
||||
"Look": "Look",
|
||||
"Movement": "Movement",
|
||||
@@ -812,6 +814,7 @@
|
||||
"How far the right stick moves before the view turns. Raise it if the view drifts.": "How far the right stick moves before the view turns. Raise it if the view drifts.",
|
||||
"Hold the button to sneak, or press it once to start and again to stop.": "Hold the button to sneak, or press it once to start and again to stop.",
|
||||
"How far the left stick moves before you walk. Raise it if you drift.": "How far the left stick moves before you walk. Raise it if you drift.",
|
||||
"How far a trigger is pulled before it counts as pressed.": "How far a trigger is pulled before it counts as pressed.",
|
||||
"How fast the stick moves the crosshair in inventories.": "How fast the stick moves the crosshair in inventories.",
|
||||
"How fast the right stick scrolls a list.": "How fast the right stick scrolls a list.",
|
||||
"How long a held direction waits before it repeats.": "How long a held direction waits before it repeats.",
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 866 B |
|
After Width: | Height: | Size: 1020 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 856 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1020 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 866 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1013 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 856 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 866 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 856 B |
|
After Width: | Height: | Size: 1020 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -17,7 +17,7 @@ pub use horizon_client_render::{
|
||||
hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block,
|
||||
json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
|
||||
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
|
||||
options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor, par, particles,
|
||||
options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor, pad_glyph, par, particles,
|
||||
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw,
|
||||
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
|
||||
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
|
||||
@@ -157,6 +157,7 @@ pub use horizon_client_core::nbt_path;
|
||||
pub use horizon_client_core::scoreboard;
|
||||
pub use horizon_client_core::snbt;
|
||||
pub use horizon_client_core::text;
|
||||
pub use horizon_client_input::actions;
|
||||
pub use horizon_client_input::clipboard;
|
||||
pub use horizon_client_input::gamepad;
|
||||
pub use horizon_client_input::held;
|
||||
|
||||
@@ -14,6 +14,8 @@ pub struct Confirm {
|
||||
/// The yes and no buttons' corners.
|
||||
pub buttons: [(f32, f32); 2],
|
||||
pub focus: Option<usize>,
|
||||
/// `declare_no` was called.
|
||||
declares_no: bool,
|
||||
}
|
||||
impl Confirm {
|
||||
pub fn new(font: &Font, size: (f32, f32), title: &Text, message: &Text) -> Self {
|
||||
@@ -22,6 +24,7 @@ impl Confirm {
|
||||
lines,
|
||||
buttons: [buttons[0], buttons[1]],
|
||||
focus: None,
|
||||
declares_no: false,
|
||||
}
|
||||
}
|
||||
/// Tab cycles focus between yes (0) and no (1).
|
||||
@@ -67,10 +70,9 @@ impl Confirm {
|
||||
) {
|
||||
self.draw_active(batch, atlas, cursor, labels, true);
|
||||
}
|
||||
/// The second button answers no, as Escape does (`widgets::Action`).
|
||||
pub fn declare_no(&self) {
|
||||
let (x, y) = self.buttons[1];
|
||||
crate::widgets::declare_action(crate::widgets::Action::Cancel, [x, y, 150.0, 20.0], true);
|
||||
/// The second button answers no, as Escape does (`widgets::Action`), once drawn.
|
||||
pub fn declare_no(&mut self) {
|
||||
self.declares_no = true;
|
||||
}
|
||||
/// `draw` with the buttons greyed out while `setDelay` runs (`active` false).
|
||||
pub fn draw_active(
|
||||
@@ -87,9 +89,16 @@ impl Confirm {
|
||||
for (i, ((bx, by), label)) in self.buttons.into_iter().zip(labels).enumerate() {
|
||||
let is_focused = self.focus == Some(i);
|
||||
let hover = active && (ui::hovered(cursor, bx, by, 150.0, 20.0) || is_focused);
|
||||
if self.declares_no && i == 1 {
|
||||
let action = crate::widgets::Action::Cancel;
|
||||
ui::action_button(
|
||||
batch, atlas, action, bx, by, 150.0, 20.0, label, active, hover,
|
||||
);
|
||||
} else {
|
||||
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, active, hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ConfirmScreen` with a row of `count` buttons `width` wide, 4 apart: `ConfirmLinkScreen`'s
|
||||
@@ -101,6 +110,8 @@ pub struct Choice {
|
||||
pub buttons: Vec<(f32, f32)>,
|
||||
pub width: f32,
|
||||
pub focus: Option<usize>,
|
||||
/// The button `declare_no` named.
|
||||
no_button: Option<usize>,
|
||||
}
|
||||
impl Choice {
|
||||
pub fn new(
|
||||
@@ -142,6 +153,7 @@ impl Choice {
|
||||
.collect(),
|
||||
width,
|
||||
focus: None,
|
||||
no_button: None,
|
||||
}
|
||||
}
|
||||
/// Tab cycles focus across the buttons.
|
||||
@@ -188,15 +200,9 @@ impl Choice {
|
||||
.iter()
|
||||
.position(|&(x, y)| ui::hovered(cursor, x, y, self.width, 20.0))
|
||||
}
|
||||
/// Button `i` answers no, as Escape does (`widgets::Action`).
|
||||
pub fn declare_no(&self, i: usize) {
|
||||
if let Some(&(x, y)) = self.buttons.get(i) {
|
||||
crate::widgets::declare_action(
|
||||
crate::widgets::Action::Cancel,
|
||||
[x, y, self.width, 20.0],
|
||||
true,
|
||||
);
|
||||
}
|
||||
/// Button `i` answers no, as Escape does (`widgets::Action`), once drawn.
|
||||
pub fn declare_no(&mut self, i: usize) {
|
||||
self.no_button = Some(i);
|
||||
}
|
||||
/// The lines and the buttons, over whatever is behind.
|
||||
pub fn draw(
|
||||
@@ -212,9 +218,16 @@ impl Choice {
|
||||
for (i, (&(bx, by), label)) in self.buttons.iter().zip(labels).enumerate() {
|
||||
let is_focused = self.focus == Some(i);
|
||||
let hover = ui::hovered(cursor, bx, by, self.width, 20.0) || is_focused;
|
||||
if self.no_button == Some(i) {
|
||||
let action = crate::widgets::Action::Cancel;
|
||||
ui::action_button(
|
||||
batch, atlas, action, bx, by, self.width, 20.0, label, true, hover,
|
||||
);
|
||||
} else {
|
||||
ui::button(batch, atlas, bx, by, self.width, 20.0, label, true, hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -258,6 +271,55 @@ mod tests {
|
||||
assert_eq!(two.buttons[1].0, 242.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_button_declared_no_gets_its_job() {
|
||||
use crate::widgets::{self, Action, DeclaredAction};
|
||||
let atlas = HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
let (title, message) = (Text::literal("Title"), Text::literal("Message"));
|
||||
let labels = [
|
||||
Text::literal("Yes"),
|
||||
Text::literal("Copy"),
|
||||
Text::literal("No"),
|
||||
];
|
||||
let drawn = |frame: u64, draw: &dyn Fn(&mut HudBatch)| {
|
||||
widgets::set_action_screen(7);
|
||||
draw(&mut HudBatch::new([480, 270]));
|
||||
widgets::end_frame(frame);
|
||||
widgets::last_actions(7)
|
||||
};
|
||||
let no = |rect| {
|
||||
vec![DeclaredAction {
|
||||
action: Action::Cancel,
|
||||
rect,
|
||||
enabled: true,
|
||||
}]
|
||||
};
|
||||
|
||||
let mut confirm = Confirm::new(&font(), (480.0, 270.0), &title, &message);
|
||||
let draw_confirm = |confirm: &Confirm, batch: &mut HudBatch| {
|
||||
confirm.draw(batch, &atlas, (0.0, 0.0), [&labels[0], &labels[2]]);
|
||||
};
|
||||
assert!(drawn(1, &|b| draw_confirm(&confirm, b)).is_empty());
|
||||
confirm.declare_no();
|
||||
let (x, y) = confirm.buttons[1];
|
||||
assert_eq!(
|
||||
drawn(2, &|b| draw_confirm(&confirm, b)),
|
||||
no([x, y, 150.0, 20.0])
|
||||
);
|
||||
|
||||
let mut choice = Choice::new(&font(), (480.0, 270.0), &title, &message, 3, 100.0);
|
||||
let draw_choice = |choice: &Choice, batch: &mut HudBatch| {
|
||||
choice.draw(batch, &atlas, (0.0, 0.0), &labels);
|
||||
};
|
||||
assert!(drawn(3, &|b| draw_choice(&choice, b)).is_empty());
|
||||
choice.declare_no(2);
|
||||
let (x, y) = choice.buttons[2];
|
||||
assert_eq!(
|
||||
drawn(4, &|b| draw_choice(&choice, b)),
|
||||
no([x, y, 100.0, 20.0])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_and_choice_keyboard_navigation() {
|
||||
let (title, message) = (Text::literal("Title"), Text::literal("Message"));
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! reconnects, a solo game's controller coming and going) decides that for itself.
|
||||
use crate::gamepad::{Button, GlyphStyle};
|
||||
use crate::hud::{HudAtlas, HudBatch, rgba};
|
||||
use crate::pad_glyph;
|
||||
use crate::toasts::Slide;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -129,12 +130,9 @@ impl NoticeSlot {
|
||||
let w = batch.width;
|
||||
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
|
||||
let max = (w - 24.0).min(260.0);
|
||||
let glyph = notice.glyph.map(|g| {
|
||||
(
|
||||
g.button.label(g.style),
|
||||
width(g.button.label(g.style)) + 4.0,
|
||||
)
|
||||
});
|
||||
let glyph = notice
|
||||
.glyph
|
||||
.map(|g| (g, pad_glyph::width(atlas, g.button, g.style) + 4.0));
|
||||
let indent = glyph.map_or(0.0, |g| g.1);
|
||||
let lines = crate::split_profiles::wrap(¬ice.body, max - indent, width);
|
||||
let inner = lines
|
||||
@@ -151,8 +149,8 @@ impl NoticeSlot {
|
||||
for (k, line) in lines.iter().enumerate() {
|
||||
let lx = (px + (pw - width(line) - indent) / 2.0).floor();
|
||||
let y = py + 18.0 + 10.0 * k as f32;
|
||||
if let (0, Some((label, _))) = (k, glyph) {
|
||||
batch.text(atlas, label, lx, y, rgba(255, 255, 255, 255), false);
|
||||
if let (0, Some((g, _))) = (k, glyph) {
|
||||
pad_glyph::draw(batch, atlas, g.button, g.style, lx, y);
|
||||
}
|
||||
batch.text(atlas, line, lx + indent, y, rgba(200, 200, 200, 255), false);
|
||||
}
|
||||
@@ -173,20 +171,6 @@ pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, [px, py, pw, ph]: [f32; 4])
|
||||
}
|
||||
}
|
||||
|
||||
/// A button as printed on the pad, in its colour, on a dark key `gw` wide.
|
||||
pub fn key(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
label: &str,
|
||||
color: u32,
|
||||
x: f32,
|
||||
y: f32,
|
||||
gw: f32,
|
||||
) {
|
||||
batch.fill(atlas, x - 1.0, y - 2.0, gw, 11.0, rgba(40, 40, 40, 255));
|
||||
batch.text(atlas, label, x + 1.0, y, color, false);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -375,6 +375,8 @@ mod world_primitives;
|
||||
mod zombie_reinforcements;
|
||||
mod zombie_villagers;
|
||||
use event_loop::EventLoopCtl;
|
||||
// `Action` here is the title screen's buttons (`title::Action`).
|
||||
use horizon_client::actions::Action as Mapping;
|
||||
use horizon_client::held::Source;
|
||||
use horizon_client::spans::startup;
|
||||
use horizon_client::{
|
||||
@@ -6633,7 +6635,7 @@ impl Game<'_> {
|
||||
self.use_failed = false;
|
||||
// isSecondaryUseActive with something in either hand keeps blocks
|
||||
// from reacting.
|
||||
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let sneaking = self.keys.held(Mapping::Sneak);
|
||||
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
|
||||
let mut anchor_passed = false;
|
||||
// Native mods, where ServerPlayerGameMode.useItemOn starts: a
|
||||
@@ -7065,7 +7067,7 @@ impl Game<'_> {
|
||||
inside: false,
|
||||
yaw: self.yaw,
|
||||
pitch: self.pitch,
|
||||
sneaking: self.keys.contains(&KeyCode::ShiftLeft),
|
||||
sneaking: self.keys.held(Mapping::Sneak),
|
||||
item: stack.item.to_string(),
|
||||
can_use_game_master_blocks: self.can_use_game_master_blocks(),
|
||||
};
|
||||
@@ -8180,7 +8182,10 @@ impl Game<'_> {
|
||||
),
|
||||
action,
|
||||
) {
|
||||
self.play_mouse(button, pressed)?;
|
||||
if self.shared_default_acts(button, horizon_client::keybinds::key_name(key))
|
||||
{
|
||||
self.mouse_mapping(button, pressed, Source::Keyboard)?;
|
||||
}
|
||||
} else if container_key_clones(button, action) {
|
||||
self.inventory_pick_key();
|
||||
}
|
||||
@@ -8225,7 +8230,7 @@ impl Game<'_> {
|
||||
if !pressed {
|
||||
return Ok(());
|
||||
}
|
||||
if self.keys.contains(&key) {
|
||||
if self.keys.raw(&key) {
|
||||
self.keys.press(key, source);
|
||||
}
|
||||
} else if !pressed {
|
||||
@@ -8329,9 +8334,12 @@ impl Game<'_> {
|
||||
/// lectern, a sign, a server dialog, the code of conduct, a link or
|
||||
/// server pack prompt (vanilla's `Minecraft.screen` is set for each).
|
||||
fn screen_over_playing(&self) -> bool {
|
||||
self.book.is_some()
|
||||
|| self.sign_edit.is_some()
|
||||
|| self.dialog_open()
|
||||
self.book.is_some() || self.sign_edit.is_some() || self.prompt_open()
|
||||
}
|
||||
/// A prompt is up: over the world or over another open screen, it takes the input and is
|
||||
/// answered first, in the order of `handle_window_event`'s arms and of what draws on top.
|
||||
fn prompt_open(&self) -> bool {
|
||||
self.dialog_open()
|
||||
|| self.code_of_conduct.is_some()
|
||||
|| self.link_prompt.is_some()
|
||||
|| self.command_prompt.is_some()
|
||||
@@ -13370,7 +13378,7 @@ impl Game<'_> {
|
||||
let Some(held) = held else {
|
||||
return Outcome::Pass;
|
||||
};
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
if self.keys.held(Mapping::Sneak) {
|
||||
return Outcome::Pass;
|
||||
}
|
||||
let Some((state, under_water)) = self.cauldron_at(pos) else {
|
||||
@@ -13420,7 +13428,7 @@ impl Game<'_> {
|
||||
return Ok(false);
|
||||
};
|
||||
// Player.isSecondaryUseActive with an item in hand skips useItemOn.
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
if self.keys.held(Mapping::Sneak) {
|
||||
return Ok(false);
|
||||
}
|
||||
let removes_dye = cauldron::removes_dye(self.items(), &held.item);
|
||||
@@ -13489,7 +13497,7 @@ impl Game<'_> {
|
||||
.offhand
|
||||
.as_ref()
|
||||
.is_some_and(|s| self.blocks_attacks(s).is_some());
|
||||
if shield && !self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
if shield && !self.keys.held(Mapping::Sneak) {
|
||||
return None;
|
||||
}
|
||||
let face_name = match face {
|
||||
@@ -13807,7 +13815,7 @@ impl Game<'_> {
|
||||
// `Player.isShiftKeyDown`: a block that refuses the fluid (any
|
||||
// non-air block under Shift) passes it to the block beside, where
|
||||
// Shift no longer counts.
|
||||
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let sneaking = self.keys.held(Mapping::Sneak);
|
||||
let creative = self.creative;
|
||||
let emptied = self.with_level(|b, level, ticks| {
|
||||
b.bucket_empty(
|
||||
@@ -15222,7 +15230,7 @@ impl Game<'_> {
|
||||
let bed_gone = !self.res.facts().get(bed).name.ends_with("_bed");
|
||||
// Vanilla has no Shift wake-up: the chat screen's capitals must
|
||||
// not get the sleeper up.
|
||||
if (self.screen != Screen::Chat && self.keys.contains(&KeyCode::ShiftLeft))
|
||||
if (self.screen != Screen::Chat && self.keys.held(Mapping::Sneak))
|
||||
|| bed_gone
|
||||
|| self
|
||||
.player
|
||||
@@ -17030,7 +17038,8 @@ impl Game<'_> {
|
||||
KeyCode::KeyQ if self.screen == Screen::Playing => {
|
||||
// handleKeybinds: the number keys select before key.drop.
|
||||
self.select_queued_hotbar();
|
||||
let all = drop_whole_stack(self.modifiers);
|
||||
let all =
|
||||
drop_whole_stack(self.modifiers) || std::mem::take(&mut self.pad.drop_stack);
|
||||
self.drop_held(all);
|
||||
}
|
||||
KeyCode::Digit1
|
||||
@@ -17080,6 +17089,104 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `MouseHandler.onScroll`: the hotbar (or the spectator menu) takes the wheel only when no
|
||||
/// screen is open; a book, lectern, sign or prompt drawn over gameplay is one.
|
||||
fn hotbar_takes_wheel(&self) -> bool {
|
||||
self.screen == Screen::Playing
|
||||
&& !self.screen_over_playing()
|
||||
&& self.sleeping.is_none()
|
||||
&& self.player.is_some()
|
||||
}
|
||||
/// A mod's `MouseScrolling` cancels the step about to turn the hotbar.
|
||||
fn hotbar_scroll_canceled(&mut self, scroll: [f64; 2], steps: [i32; 2]) -> bool {
|
||||
let mut canceled = false;
|
||||
self.native_mouse_scrolling(
|
||||
scroll,
|
||||
steps,
|
||||
[
|
||||
self.physical_buttons.contains(&MouseButton::Left),
|
||||
self.physical_buttons.contains(&MouseButton::Middle),
|
||||
self.physical_buttons.contains(&MouseButton::Right),
|
||||
],
|
||||
[self.cursor.0, self.cursor.1],
|
||||
glfw_modifiers(self.modifiers),
|
||||
&mut canceled,
|
||||
);
|
||||
canceled
|
||||
}
|
||||
/// What one whole wheel step does in play (`MouseHandler.onScroll`): a spectator's menu or
|
||||
/// flying speed, else the selected hotbar slot.
|
||||
fn hotbar_wheel_step(&mut self, wheel: i32, vertical_steps: i32) {
|
||||
if self.spectator() && self.spectator_gui.is_menu_active() {
|
||||
self.spectator_scroll(-wheel);
|
||||
} else if self.spectator() {
|
||||
if let Some(player) = &mut self.player {
|
||||
player.flying_speed = horizon_client::mouse::spectator_flying_speed(
|
||||
player.flying_speed,
|
||||
vertical_steps,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.selected = horizon_client::mouse::ScrollWheelHandler::next_selection(
|
||||
f64::from(wheel),
|
||||
self.selected as i32,
|
||||
9,
|
||||
) as usize;
|
||||
}
|
||||
}
|
||||
/// A step of the hotbar from a controller's bumper: one wheel notch, down for the next slot,
|
||||
/// through everything the wheel goes through.
|
||||
fn hotbar_step(&mut self, next: bool) {
|
||||
let wheel = if next { -1 } else { 1 };
|
||||
if self.hotbar_takes_wheel()
|
||||
&& !self.native_wheel_event(wheel as f32)
|
||||
&& !self.hotbar_scroll_canceled([0.0, f64::from(wheel)], [0, wheel])
|
||||
{
|
||||
self.hotbar_wheel_step(wheel, wheel);
|
||||
}
|
||||
}
|
||||
/// A mouse mapping pressed or let go by `source`. A release waits while another device holds
|
||||
/// the mapping, so letting go of one can't end what the other still presses, as for keys
|
||||
/// (`HeldKeys`).
|
||||
fn mouse_mapping(&mut self, button: MouseButton, pressed: bool, source: Source) -> Result<()> {
|
||||
if !pressed && self.mouse_mapping_held_elsewhere(button, source) {
|
||||
return Ok(());
|
||||
}
|
||||
self.play_mouse(button, pressed)
|
||||
}
|
||||
/// Whether a device other than `source` holds what `button` stands for. Asks each device's own
|
||||
/// live state rather than keeping a count: a screen takes a mouse release without telling the
|
||||
/// mappings, and a count would then hold the attack for good. A script keeps no such state, so
|
||||
/// nothing waits for a script's mouse hold.
|
||||
fn mouse_mapping_held_elsewhere(&self, button: MouseButton, source: Source) -> bool {
|
||||
let mapping = match button {
|
||||
MouseButton::Left => "key.attack",
|
||||
MouseButton::Right => "key.use",
|
||||
_ => return false,
|
||||
};
|
||||
match source {
|
||||
Source::Keyboard | Source::Agent => {
|
||||
self.pad.holds_action(&self.settings.controller, mapping)
|
||||
}
|
||||
Source::Pad => {
|
||||
let keys = &self.settings.keys;
|
||||
self.physical_buttons
|
||||
.iter()
|
||||
.any(|held| keys.translate_mouse_all(*held).contains(&Err(button)))
|
||||
|| self
|
||||
.physical_keys
|
||||
.iter()
|
||||
.any(|held| keys.translate_all(*held).contains(&Err(button)))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Two mappings with one default button are separate mappings all the same
|
||||
/// (`Minecraft.handleKeybinds`): whether the middle-button action a physical `input` translated
|
||||
/// to is one of those it is bound to, for this player now.
|
||||
fn shared_default_acts(&self, button: MouseButton, input: Option<&str>) -> bool {
|
||||
button != MouseButton::Middle
|
||||
|| input.is_none_or(|input| self.settings.keys.middle_acts(input, self.spectator()))
|
||||
}
|
||||
/// A mouse button while playing (window or control socket).
|
||||
fn play_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
|
||||
if pressed {
|
||||
@@ -17765,6 +17872,13 @@ impl Game<'_> {
|
||||
self.native_mouse_button_post(button, pressed, modifiers);
|
||||
}
|
||||
}
|
||||
/// One window event, in the order vanilla's `KeyboardHandler` and `MouseHandler` see it.
|
||||
///
|
||||
/// A controller doesn't come through here in play: `pad_action` goes straight to the mappings
|
||||
/// (`mapped_key`, `mouse_mapping`). So a key handled below before `mapped_key_event` (a window
|
||||
/// shortcut, a special case) never reaches a controller. If a player could want it on one, give
|
||||
/// it a handler both can call and a `gamepad::Route` (screenshot and fullscreen do this). A
|
||||
/// screen's own raw keys, text and mods' key hooks stay raw on purpose.
|
||||
fn handle_window_event_inner(&mut self, event_loop: &dyn EventLoopCtl, mut event: WindowEvent) {
|
||||
if let WindowEvent::Focused(false) = &event {
|
||||
self.physical_keys.clear();
|
||||
@@ -18258,13 +18372,21 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let stick_scroll = self.pad_stick_scroll();
|
||||
let step_scroll = self.pad_step_scroll();
|
||||
if self.dialog_open() {
|
||||
self.dialog_scroll(d);
|
||||
// A prompt is vanilla's `Minecraft.screen`, the only one MouseHandler.onScroll
|
||||
// tells: the top one hears the wheel, as the click arms below pick it, and
|
||||
// nothing behind it does.
|
||||
if self.prompt_open() {
|
||||
if self.command_prompt.is_some() || self.link_prompt.is_some() {
|
||||
} else if self.code_of_conduct.is_some() {
|
||||
self.code_of_conduct_scroll(d);
|
||||
} else if let Some(title) = &mut self.title
|
||||
} else if self.server_packs.prompt.is_none() {
|
||||
self.dialog_scroll(d);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let stick_scroll = self.pad_stick_scroll();
|
||||
let step_scroll = self.pad_step_scroll();
|
||||
if let Some(title) = &mut self.title
|
||||
&& self.screen != Screen::Win
|
||||
{
|
||||
let size = self
|
||||
@@ -18285,50 +18407,21 @@ impl Game<'_> {
|
||||
// MouseHandler.onScroll: the hotbar (or the spectator
|
||||
// menu) takes the wheel only when no screen is open; a
|
||||
// book, lectern, sign or prompt drawn over gameplay is one.
|
||||
if self.screen == Screen::Playing
|
||||
&& !self.screen_over_playing()
|
||||
&& self.sleeping.is_none()
|
||||
&& self.player.is_some()
|
||||
if self.hotbar_takes_wheel()
|
||||
&& !self.native_wheel_event(d)
|
||||
&& let Some((wheel, (horizontal_steps, vertical_steps))) = self
|
||||
.mouse
|
||||
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
|
||||
{
|
||||
if !self.pad_synthetic_input() {
|
||||
let mut canceled = false;
|
||||
self.native_mouse_scrolling(
|
||||
if !self.pad_synthetic_input()
|
||||
&& self.hotbar_scroll_canceled(
|
||||
[scaled(raw_x), scaled(raw_y)],
|
||||
[horizontal_steps, vertical_steps],
|
||||
[
|
||||
self.physical_buttons.contains(&MouseButton::Left),
|
||||
self.physical_buttons.contains(&MouseButton::Middle),
|
||||
self.physical_buttons.contains(&MouseButton::Right),
|
||||
],
|
||||
[self.cursor.0, self.cursor.1],
|
||||
glfw_modifiers(self.modifiers),
|
||||
&mut canceled,
|
||||
);
|
||||
if canceled {
|
||||
)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if self.spectator() && self.spectator_gui.is_menu_active() {
|
||||
self.spectator_scroll(-wheel);
|
||||
} else if self.spectator() {
|
||||
if let Some(player) = &mut self.player {
|
||||
player.flying_speed = horizon_client::mouse::spectator_flying_speed(
|
||||
player.flying_speed,
|
||||
vertical_steps,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.selected =
|
||||
horizon_client::mouse::ScrollWheelHandler::next_selection(
|
||||
f64::from(wheel),
|
||||
self.selected as i32,
|
||||
9,
|
||||
) as usize;
|
||||
}
|
||||
self.hotbar_wheel_step(wheel, vertical_steps);
|
||||
}
|
||||
if self.screen == Screen::Chat && d != 0.0 {
|
||||
self.chat_scroll(d);
|
||||
@@ -18527,9 +18620,12 @@ impl Game<'_> {
|
||||
{
|
||||
self.smooth_camera_action();
|
||||
}
|
||||
let input = horizon_client::keybinds::mouse_name(button);
|
||||
for mapped in self.settings.keys.translate_mouse_all(button) {
|
||||
match mapped {
|
||||
Err(b) => self.play_mouse(b, pressed)?,
|
||||
Err(b)
|
||||
if !self.shared_default_acts(b, Some(input.as_str())) => {}
|
||||
Err(b) => self.mouse_mapping(b, pressed, Source::Keyboard)?,
|
||||
Ok(k) if pressed => {
|
||||
self.keys.insert(k);
|
||||
self.key_pressed(k, k)?;
|
||||
@@ -18959,7 +19055,7 @@ impl Game<'_> {
|
||||
&& self.book.is_none()
|
||||
&& !self.dialog_open()
|
||||
{
|
||||
let turn = |k| f32::from(u8::from(self.keys.contains(&k)));
|
||||
let turn = |k| f32::from(u8::from(self.keys.raw(&k)));
|
||||
let secs = elapsed.as_secs_f32().min(0.1);
|
||||
let yaw = (turn(KeyCode::ArrowRight) - turn(KeyCode::ArrowLeft)) * 90.0;
|
||||
let pitch = (turn(KeyCode::ArrowDown) - turn(KeyCode::ArrowUp)) * 90.0;
|
||||
@@ -23107,7 +23203,7 @@ mod tests {
|
||||
let mut left = true;
|
||||
let mut right = true;
|
||||
drop_held_behind_dialog(false, &mut keys, &mut left, &mut right);
|
||||
assert!(keys.contains(&KeyCode::KeyW) && left && right);
|
||||
assert!(keys.raw(&KeyCode::KeyW) && left && right);
|
||||
drop_held_behind_dialog(true, &mut keys, &mut left, &mut right);
|
||||
assert!(keys.is_empty() && !left && !right);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! events (`CriteriaTriggers`), awarding with rewards, the toast and the
|
||||
//! chat announcement, and `players/advancements/<uuid>.json`.
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::{
|
||||
advancements::{self as adv, EntityView, Frame, ItemView, Progress, Tree},
|
||||
hud::{HudBatch, rgba},
|
||||
@@ -596,7 +597,7 @@ impl Game<'_> {
|
||||
on_fire: p.fire_ticks > 0,
|
||||
sneaking: p.pose == horizon_client::pose::Pose::Crouching,
|
||||
// Input.shift: the held key, as `BlockItem.place`'s sneaking reads it.
|
||||
input_sneak: self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft),
|
||||
input_sneak: self.keys.held(Action::Sneak),
|
||||
sprinting: p.sprinting,
|
||||
swimming: p.pose == horizon_client::pose::Pose::Swimming,
|
||||
flying: p.flying,
|
||||
|
||||
@@ -8,6 +8,8 @@ mod navigation;
|
||||
|
||||
use super::event_loop::EventLoopCtl;
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::held::Source;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
block_entity_anim::Ticker,
|
||||
@@ -487,7 +489,7 @@ impl Game<'_> {
|
||||
"on_ground": p.ground.on_ground,
|
||||
"flying": p.flying,
|
||||
"fall_flying": p.fall_flying,
|
||||
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
|
||||
"sneaking": self.keys.held(Action::Sneak),
|
||||
"sprinting": p.sprinting,
|
||||
"fov_modifier": self.view.fov_modifier,
|
||||
"health": p.health.health(),
|
||||
@@ -896,11 +898,11 @@ impl Game<'_> {
|
||||
|
||||
/// A left or right click, as the mouse would.
|
||||
fn agent_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
|
||||
self.play_mouse(button, pressed)
|
||||
self.mouse_mapping(button, pressed, Source::Agent)
|
||||
}
|
||||
fn release_agent_keys(&mut self) {
|
||||
for k in MOVE_KEYS {
|
||||
self.keys.remove(&k);
|
||||
self.keys.release(&k, Source::Agent);
|
||||
}
|
||||
if self.left {
|
||||
let _ = self.agent_mouse(MouseButton::Left, false);
|
||||
@@ -1155,7 +1157,7 @@ impl Game<'_> {
|
||||
keys.push(KeyCode::Space);
|
||||
}
|
||||
for &k in &keys {
|
||||
self.keys.insert(k);
|
||||
self.keys.press(k, Source::Agent);
|
||||
}
|
||||
let from = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
let until = Instant::now() + Duration::from_secs_f64(seconds);
|
||||
@@ -1168,7 +1170,7 @@ impl Game<'_> {
|
||||
}
|
||||
"jump" => {
|
||||
self.playing()?;
|
||||
self.keys.insert(KeyCode::Space);
|
||||
self.keys.press(KeyCode::Space, Source::Agent);
|
||||
let from_y = self.player.as_ref().map_or(0.0, |p| p.position.y);
|
||||
let release_tick = self.ticks + 3;
|
||||
self.start_task(
|
||||
@@ -1894,7 +1896,7 @@ impl Game<'_> {
|
||||
return Some(json!({ "error": why, "position": self.position() }));
|
||||
}
|
||||
for &k in keys.iter() {
|
||||
self.keys.insert(k);
|
||||
self.keys.press(k, Source::Agent);
|
||||
}
|
||||
if Instant::now() < *until && !timed_out {
|
||||
return None;
|
||||
@@ -1921,7 +1923,7 @@ impl Game<'_> {
|
||||
from_y,
|
||||
} => {
|
||||
if self.ticks >= *release_tick || timed_out {
|
||||
self.keys.remove(&KeyCode::Space);
|
||||
self.keys.release(&KeyCode::Space, Source::Agent);
|
||||
let on_ground = self.player.as_ref().is_some_and(|p| p.ground.on_ground);
|
||||
if on_ground || timed_out {
|
||||
return Some(
|
||||
@@ -2089,9 +2091,9 @@ impl Game<'_> {
|
||||
if d[0].abs() + d[1].abs() > 1e-3 {
|
||||
self.yaw = (-d[0]).atan2(d[1]).to_degrees() as f32;
|
||||
}
|
||||
self.keys.insert(KeyCode::KeyW);
|
||||
self.keys.press(KeyCode::KeyW, Source::Agent);
|
||||
if g.sprint {
|
||||
self.keys.insert(KeyCode::ControlLeft);
|
||||
self.keys.press(KeyCode::ControlLeft, Source::Agent);
|
||||
}
|
||||
// Step up onto the next block, or out of water.
|
||||
let in_water = {
|
||||
@@ -2106,9 +2108,9 @@ impl Game<'_> {
|
||||
g.jump_until = self.ticks + 4;
|
||||
}
|
||||
if self.ticks < g.jump_until || in_water {
|
||||
self.keys.insert(KeyCode::Space);
|
||||
self.keys.press(KeyCode::Space, Source::Agent);
|
||||
} else {
|
||||
self.keys.remove(&KeyCode::Space);
|
||||
self.keys.release(&KeyCode::Space, Source::Agent);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@ impl Game<'_> {
|
||||
let b = HudBatch::new(size);
|
||||
let cursor = self.cursor;
|
||||
let shift = self.has_shift_down();
|
||||
let alt = self.keys.contains(&KeyCode::AltLeft);
|
||||
let alt = self.keys.raw(&KeyCode::AltLeft);
|
||||
let atlas_click = |game: &Game<'_>, edit: &mut EditBox| {
|
||||
edit.click(&game.gui.atlas, cursor.0, false, false);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! it by the rope either. Here the pull lands on the server's copy and the
|
||||
//! rider's client overwrites it the same way (`tick_boats`).
|
||||
use super::{Game, boats::Boat};
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::mobs::leash::{self, Holder, HolderState, Pending};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::nbt::Tag;
|
||||
@@ -418,7 +419,7 @@ impl Game<'_> {
|
||||
let id = self.boats[i].id;
|
||||
// The sneak branch: the player's mobs and boats on leads are re-tied
|
||||
// to this boat.
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
let sneaking = self.keys.held(Action::Sneak);
|
||||
if sneaking
|
||||
&& self.tie_player_leads(glam::DVec3::new(c.x, c.y, c.z), Holder::Boat(id), None)
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
mod placement;
|
||||
|
||||
use super::{Game, InteractTarget, entity::Entity, entity_culling::at, view_vector};
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::entity_rules::EntityRules;
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
@@ -1330,7 +1331,7 @@ impl Game<'_> {
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
let sneaking = self.keys.held(Action::Sneak);
|
||||
// Only a client's own pick is stopped by a block: the range check passed.
|
||||
if target.is_none() && !self.before_block(d) {
|
||||
return false;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! `CampfireBlock.useItemOn`: an item in `#minecraft:douses_campfires` (the
|
||||
//! shovels) put on a lit campfire puts it out.
|
||||
use super::Game;
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::chunk::BlockState;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The lit campfire at `pos` that `held` puts out, when the item is in
|
||||
@@ -38,7 +38,7 @@ impl Game<'_> {
|
||||
seq: i32,
|
||||
) -> bool {
|
||||
// Player.isSecondaryUseActive: a held item means both hands count.
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
if self.keys.held(Action::Sneak) {
|
||||
return false;
|
||||
}
|
||||
let Some(mut state) = self.campfire_to_douse(pos, held) else {
|
||||
|
||||
@@ -1223,6 +1223,7 @@ impl Game<'_> {
|
||||
let Some(url) = &self.link_prompt else {
|
||||
return;
|
||||
};
|
||||
horizon_client::widgets::cover_frame();
|
||||
// ConfirmLinkScreen: the question and the link (its warning is a
|
||||
// widget of its own, not narrated).
|
||||
horizon_client::widgets::begin_screen(|| {
|
||||
@@ -1240,7 +1241,7 @@ impl Game<'_> {
|
||||
Text::translate("chat.copy", &[]),
|
||||
Text::translate("gui.no", &[]),
|
||||
];
|
||||
let layout = self.link_prompt_layout(url, (w, h));
|
||||
let mut layout = self.link_prompt_layout(url, (w, h));
|
||||
layout.declare_no(2);
|
||||
layout.draw(batch, atlas, self.cursor, &labels);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ impl Game<'_> {
|
||||
return None;
|
||||
}
|
||||
let m = self.modifiers;
|
||||
let held = |keys: [KeyCode; 2]| keys.iter().any(|k| self.keys.contains(k));
|
||||
let held = |keys: [KeyCode; 2]| keys.iter().any(|k| self.keys.raw(k));
|
||||
clipboard::edit_shortcut(
|
||||
clipboard::shortcut_letter(&event.logical_key, event.physical_key),
|
||||
self.has_control_down_with_quirk(),
|
||||
@@ -25,7 +25,7 @@ impl Game<'_> {
|
||||
/// modifier (`InputQuirks.EDIT_SHORTCUT_KEY_MODIFIER`: Cmd on macOS, Ctrl
|
||||
/// elsewhere), held on the keyboard or by an agent.
|
||||
pub(super) fn has_control_down_with_quirk(&self) -> bool {
|
||||
let held = |keys: [KeyCode; 2]| keys.iter().any(|k| self.keys.contains(k));
|
||||
let held = |keys: [KeyCode; 2]| keys.iter().any(|k| self.keys.raw(k));
|
||||
if cfg!(target_os = "macos") {
|
||||
self.modifiers.super_key() || held([KeyCode::SuperLeft, KeyCode::SuperRight])
|
||||
} else {
|
||||
|
||||
@@ -169,6 +169,7 @@ impl Game<'_> {
|
||||
let Some(prompt) = &self.code_of_conduct else {
|
||||
return;
|
||||
};
|
||||
horizon_client::widgets::cover_frame();
|
||||
// CodeOfConductScreen: WarningScreen's narration, the title and the
|
||||
// text on the next line.
|
||||
horizon_client::widgets::begin_screen(|| {
|
||||
@@ -218,6 +219,7 @@ impl Game<'_> {
|
||||
|
||||
let [x, y, cw, ch] = layout.check;
|
||||
let hover = ui::hovered(cursor, x, y, cw, ch);
|
||||
horizon_client::widgets::record(&layout.label.plain(), layout.check, true);
|
||||
horizon_client::widgets::narratable(|| {
|
||||
horizon_client::widgets::Narratable::new(
|
||||
horizon_client::widgets::What::Checkbox {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! `/fetchprofile`, `/publish`, `/unpublish`, `/debug`, `/jfr` and
|
||||
//! `/version`, with vanilla's feedback and failures.
|
||||
use super::{Arg, Context, Game, R, Target, err};
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::{
|
||||
command::CommandError,
|
||||
skins::{NameOrId, Resolve, ResolvedProfile},
|
||||
@@ -292,8 +293,7 @@ impl Game<'_> {
|
||||
self.set_camera(None);
|
||||
return;
|
||||
}
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|
||||
&& self.screen == super::Screen::Playing;
|
||||
let sneaking = self.keys.held(Action::Sneak) && self.screen == super::Screen::Playing;
|
||||
match self.target_position(t) {
|
||||
Some(p) if !sneaking => {
|
||||
let rot = self.target_rotation(t);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Commands from the control socket (`horizon-ctl`), run on the game thread.
|
||||
use super::event_loop::EventLoopCtl;
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::held::Source;
|
||||
use horizon_client::{Result, items::ItemStack};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::{Value, json};
|
||||
@@ -588,7 +589,7 @@ impl Game<'_> {
|
||||
// A held edit modifier (`key super down`) and A/C/V/X: the
|
||||
// open book's Select All, Copy, Paste or Cut.
|
||||
if self.book.is_some() && arg(2)? != "up" {
|
||||
let held = |k: [KeyCode; 2]| k.iter().any(|k| self.keys.contains(k));
|
||||
let held = |k: [KeyCode; 2]| k.iter().any(|k| self.keys.raw(k));
|
||||
let edit = if cfg!(target_os = "macos") {
|
||||
held([KeyCode::SuperLeft, KeyCode::SuperRight])
|
||||
} else {
|
||||
@@ -662,15 +663,23 @@ impl Game<'_> {
|
||||
Some(Ok(k)) => k,
|
||||
Some(Err(button)) => {
|
||||
let action = arg(2)?;
|
||||
if self.screen == Screen::Playing {
|
||||
if self.screen == Screen::Playing
|
||||
&& self.shared_default_acts(
|
||||
button,
|
||||
horizon_client::keybinds::key_name(physical),
|
||||
)
|
||||
{
|
||||
if action != "up" {
|
||||
self.play_mouse(button, true)?;
|
||||
self.mouse_mapping(button, true, Source::Agent)?;
|
||||
}
|
||||
// A repeat keeps the key held, as the window's.
|
||||
if action == "up" || action == "tap" {
|
||||
self.play_mouse(button, false)?;
|
||||
self.mouse_mapping(button, false, Source::Agent)?;
|
||||
}
|
||||
}
|
||||
if action == "tap" {
|
||||
self.physical_keys.remove(&physical);
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
None => return ok(),
|
||||
@@ -679,23 +688,17 @@ impl Game<'_> {
|
||||
match arg(2)? {
|
||||
"down" => self.control_key_down(key, physical)?,
|
||||
"up" => {
|
||||
self.keys.remove(&key);
|
||||
self.control_key_up(key);
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
// The system's auto-repeat while the key is held.
|
||||
"repeat" => {
|
||||
self.physical_keys.insert(physical);
|
||||
self.mapped_key(
|
||||
key,
|
||||
true,
|
||||
true,
|
||||
key,
|
||||
horizon_client::held::Source::Keyboard,
|
||||
)?;
|
||||
self.mapped_key(key, true, true, key, Source::Agent)?;
|
||||
}
|
||||
"tap" => {
|
||||
self.control_key_down(key, physical)?;
|
||||
self.keys.remove(&key);
|
||||
self.control_key_up(key);
|
||||
self.physical_keys.remove(&physical);
|
||||
self.debug_key(physical, horizon_client::debug_keys::KeyAction::Release);
|
||||
}
|
||||
@@ -745,17 +748,17 @@ impl Game<'_> {
|
||||
match arg(2)? {
|
||||
"down" => {
|
||||
self.extras_mouse(button, true);
|
||||
self.play_mouse(button, true)?
|
||||
self.mouse_mapping(button, true, Source::Agent)?
|
||||
}
|
||||
"up" => {
|
||||
self.extras_mouse(button, false);
|
||||
self.play_mouse(button, false)?
|
||||
self.mouse_mapping(button, false, Source::Agent)?
|
||||
}
|
||||
"click" => {
|
||||
self.extras_mouse(button, true);
|
||||
self.extras_mouse(button, false);
|
||||
self.play_mouse(button, true)?;
|
||||
self.play_mouse(button, false)?;
|
||||
self.mouse_mapping(button, true, Source::Agent)?;
|
||||
self.mouse_mapping(button, false, Source::Agent)?;
|
||||
}
|
||||
other => {
|
||||
return Err(
|
||||
@@ -1239,12 +1242,19 @@ impl Game<'_> {
|
||||
self.block_editor_key_code(key, None, None);
|
||||
return Ok(());
|
||||
}
|
||||
if self.keys.insert(key) {
|
||||
if self.keys.press(key, Source::Agent) {
|
||||
self.key_pressed(key, raw)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `key ... up`: the script lets go. What it pressed as the keyboard's stand-in goes with it
|
||||
/// (a fixture's untagged press), and a controller's hold stays.
|
||||
fn control_key_up(&mut self, key: KeyCode) {
|
||||
self.keys.release(&key, Source::Agent);
|
||||
self.keys.release(&key, Source::Keyboard);
|
||||
}
|
||||
|
||||
fn screen_name(&self) -> &'static str {
|
||||
if self.screen == Screen::Win {
|
||||
return "win";
|
||||
|
||||
@@ -5,10 +5,11 @@ use horizon_client::{hotbar, items::ItemStack, keybinds, text::Text};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether the key bound to `mapping` is held.
|
||||
/// Whether the key bound to `mapping` is held, or the controller button bound to it.
|
||||
pub(super) fn mapped_key_down(&self, mapping: &str) -> bool {
|
||||
keybinds::key_code(self.settings.keys.get(mapping))
|
||||
.is_some_and(|k| self.physical_keys.contains(&k))
|
||||
|| self.pad.holds_action(&self.settings.controller, mapping)
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.selectTab` on the HOTBAR tab: each saved
|
||||
|
||||
@@ -9,6 +9,7 @@ use super::{
|
||||
native_mob_draw::NativePlayerRender, native_projectiles::Impact, view_vector,
|
||||
};
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
arrows::EntityHit,
|
||||
@@ -322,7 +323,7 @@ impl Game<'_> {
|
||||
alive: !p.health.dead_or_dying(),
|
||||
// EntitySelector.NO_CREATIVE_OR_SPECTATOR.
|
||||
creative: self.creative || self.spectator(),
|
||||
sneaking: self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft),
|
||||
sneaking: self.keys.held(Action::Sneak),
|
||||
swimming: p.swimming,
|
||||
look: view_vector(self.yaw, self.pitch),
|
||||
main_hand: self.held().map(|s| s.item.clone()),
|
||||
@@ -5784,8 +5785,8 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
let acting = self.acting_player_uuid();
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|
||||
|| self.keys.contains(&winit::keyboard::KeyCode::ShiftRight);
|
||||
let sneaking =
|
||||
self.keys.held(Action::Sneak) || self.keys.raw(&winit::keyboard::KeyCode::ShiftRight);
|
||||
let held_damage = held.as_ref().map_or(0, |h| h.damage as i32);
|
||||
let held_extra = held.as_ref().and_then(|h| h.extra.clone());
|
||||
let m = &mut self.mobs[index];
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
//! only an entity whose `Pos` is the click point's height. Placement uses
|
||||
//! outline bounds, collision shapes and suffocation predicates.
|
||||
use super::{Game, Hit, InteractTarget, Keys, entity_culling::default_range, ray_box};
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::entities::{Cushion, dye_index};
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::mobs::anim::Poses;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// `Direction.fromYRot(yaw).toYRot()`: the nearest 90 degree step.
|
||||
pub(crate) fn snap_yaw(yaw: f32) -> f32 {
|
||||
@@ -324,7 +324,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
// isSecondaryUseActive.
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
if self.keys.held(Action::Sneak) {
|
||||
return false;
|
||||
}
|
||||
let id = self.cushions[i].id;
|
||||
|
||||
@@ -481,6 +481,14 @@ impl Game<'_> {
|
||||
self.debug_options_type(text);
|
||||
}
|
||||
}
|
||||
/// One character off the search box, for the controller's on-screen keyboard.
|
||||
pub(super) fn debug_options_backspace(&mut self) {
|
||||
if let Some(s) = &mut self.debug_options
|
||||
&& s.search.focused
|
||||
{
|
||||
s.search.key(KeyCode::Backspace, false, false);
|
||||
}
|
||||
}
|
||||
/// Characters typed into the search box (`EditBox.charTyped`).
|
||||
pub(super) fn debug_options_type(&mut self, typed: &str) {
|
||||
let allowed: String = typed
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
//! compound of block id to property name. Horizon keeps unmodelled components
|
||||
//! in `StackData::other`, so the component travels through NBT unchanged.
|
||||
use super::Game;
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::{Result, items::ItemStack, text::Text};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::level::flags;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
const DEBUG_STICK: &str = "minecraft:debug_stick";
|
||||
|
||||
@@ -81,7 +81,7 @@ impl Game<'_> {
|
||||
// `Player.isSecondaryUseActive` / `isShiftKeyDown`: the acting
|
||||
// player's sneak input. `keys` holds the host's, toggled sneak
|
||||
// included; `enter_guest` fills it from the guest's sneak state.
|
||||
let backward = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let backward = self.keys.held(Action::Sneak);
|
||||
|
||||
if cycle {
|
||||
// `cycleState`: a property selected on another block falls back to
|
||||
|
||||
@@ -116,6 +116,35 @@ impl Game<'_> {
|
||||
let d = &self.dialogs;
|
||||
d.open.is_some() || d.waiting.is_some() || d.warning
|
||||
}
|
||||
/// What tells one dialog screen from another for a controller's focus: a new dialog is a
|
||||
/// new screen, and so are the warning and waiting screens.
|
||||
pub(super) fn dialog_screen_key(&self) -> (bool, bool, Option<String>) {
|
||||
let d = &self.dialogs;
|
||||
(
|
||||
d.warning,
|
||||
d.waiting.is_some(),
|
||||
d.open.as_ref().map(|o| o.dialog.title.plain()),
|
||||
)
|
||||
}
|
||||
/// The warning icon in the dialog's corner, while the dialog itself shows.
|
||||
pub(super) fn dialog_warning_icon(&self) -> Option<[f32; 4]> {
|
||||
let d = &self.dialogs;
|
||||
if d.warning || d.waiting.is_some() {
|
||||
return None;
|
||||
}
|
||||
self.dialog_layout().map(|layout| layout.warning)
|
||||
}
|
||||
/// The dialog itself shows, with a text input to start on.
|
||||
pub(super) fn dialog_starts_typing(&self) -> bool {
|
||||
!self.dialogs.warning && self.dialogs.waiting.is_none() && self.dialog_has_text_field()
|
||||
}
|
||||
/// The dialog's focus is a text input of many lines, where Enter is a new line.
|
||||
pub(super) fn dialog_typing_lines(&self) -> bool {
|
||||
self.dialogs.open.as_ref().is_some_and(|o| {
|
||||
let field = o.focus.and_then(|f| o.fields.get(f));
|
||||
matches!(field, Some(Some(Field::Multi(_))))
|
||||
})
|
||||
}
|
||||
/// Whether the dialog's focus is one of its text inputs, which takes the
|
||||
/// keys.
|
||||
pub(super) fn dialog_typing(&self) -> bool {
|
||||
@@ -400,6 +429,7 @@ impl Game<'_> {
|
||||
if !self.dialog_open() {
|
||||
return;
|
||||
}
|
||||
widgets::cover_frame();
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let cursor = self.cursor;
|
||||
let atlas = &self.gui.atlas;
|
||||
@@ -418,7 +448,7 @@ impl Game<'_> {
|
||||
widgets::join_for_narration(&[&title.plain(), &message.plain()]),
|
||||
)
|
||||
});
|
||||
let confirm = Confirm::new(&atlas.font, (w, h), &title, &message);
|
||||
let mut confirm = Confirm::new(&atlas.font, (w, h), &title, &message);
|
||||
confirm.declare_no();
|
||||
confirm.draw(batch, atlas, cursor, [&disconnect, &back]);
|
||||
return;
|
||||
@@ -515,11 +545,16 @@ impl Game<'_> {
|
||||
let mut tooltip = None;
|
||||
let mut item_tip = None;
|
||||
let mut start = None;
|
||||
let mut mark = None;
|
||||
for (part, clipped) in layout.placed(open.scroll.amount) {
|
||||
if clipped && start.is_none() {
|
||||
start = Some(batch.vertices.len());
|
||||
mark = Some(widgets::frame_mark());
|
||||
} else if !clipped && let Some(s) = start.take() {
|
||||
batch.clip_since(s, ax, ay, ax + aw, ay + ah);
|
||||
if let Some(mark) = mark.take() {
|
||||
widgets::clip_frame(mark, ay, ay + ah);
|
||||
}
|
||||
}
|
||||
match part {
|
||||
Part::Line(text, x, y) => {
|
||||
@@ -681,9 +716,13 @@ impl Game<'_> {
|
||||
(!clipped || in_area(cursor.1)) && ui::hovered(cursor, x, y, bw, bh);
|
||||
let b = buttons[index];
|
||||
if open.dialog.escape_button() == Some(index) {
|
||||
widgets::declare_action(widgets::Action::Cancel, rect, true);
|
||||
}
|
||||
let action = widgets::Action::Cancel;
|
||||
ui::action_button(
|
||||
batch, atlas, action, x, y, bw, bh, &b.label, true, hover,
|
||||
);
|
||||
} else {
|
||||
ui::button(batch, atlas, x, y, bw, bh, &b.label, true, hover);
|
||||
}
|
||||
if let Some(t) = &b.tooltip {
|
||||
widgets::last_narratable(|n| n.hint = Some(t.plain()));
|
||||
}
|
||||
@@ -696,7 +735,12 @@ impl Game<'_> {
|
||||
if let Some(s) = start {
|
||||
batch.clip_since(s, ax, ay, ax + aw, ay + ah);
|
||||
}
|
||||
if let Some(mark) = mark {
|
||||
widgets::clip_frame(mark, ay, ay + ah);
|
||||
}
|
||||
if layout.content > ah {
|
||||
// A controller's focus step goes through the body's hidden parts before it leaves.
|
||||
widgets::scroll_area(layout.area, open.scroll.amount, layout.content - ah);
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! off), riding, getting off on top, and the ghastling a dried ghast block
|
||||
//! hatches.
|
||||
use super::Game;
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
mobs::{MobEvent, happy_ghast},
|
||||
@@ -65,7 +66,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let at = m.center();
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
let sneaking = self.keys.held(Action::Sneak);
|
||||
let harnessed = m.extra.body.is_some();
|
||||
// Equippable.equipOnInteract: a harness goes on a bare adult.
|
||||
if item.ends_with("_harness") && item.starts_with("minecraft:") && !harnessed {
|
||||
|
||||
@@ -447,7 +447,7 @@ impl Game<'_> {
|
||||
winit::keyboard::KeyCode::KeyD,
|
||||
]
|
||||
.iter()
|
||||
.any(|k| self.keys.contains(k))
|
||||
.any(|k| self.movement_key_held(*k))
|
||||
}
|
||||
/// `MultiPlayerGameMode.startUseItem`'s bookkeeping around the game's
|
||||
/// use: nothing starts while a block is being broken or the hands are
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! `LeashFenceKnotEntity.interact`), loading and saving who holds what,
|
||||
//! breaking, and drawing the rope (`LeashFeatureRenderer`) and the knot.
|
||||
use super::Game;
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
@@ -357,7 +358,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Entity.interact`'s leash part for a mob. Returns whether it was used.
|
||||
pub(super) fn interact_leash(&mut self, index: usize, item: &str) -> bool {
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
let sneaking = self.keys.held(Action::Sneak);
|
||||
let id = self.mobs[index].id;
|
||||
let at = self.mobs[index].center();
|
||||
if !self.mobs[index].alive() {
|
||||
@@ -476,7 +477,7 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let existing_boats = self.boats_on(Holder::Knot(knot_pos));
|
||||
let held_item = self.held().map(|s| s.item.to_string());
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
let sneaking = self.keys.held(Action::Sneak);
|
||||
// Shears snip every lead on the knot.
|
||||
if knot_hit && held_item.as_deref() == Some("minecraft:shears") {
|
||||
for i in existing {
|
||||
@@ -758,7 +759,7 @@ impl Game<'_> {
|
||||
return rot_y(v, -yaw) + eye;
|
||||
}
|
||||
let yaw = f64::from(self.yaw.to_radians());
|
||||
let crouching = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
let crouching = self.keys.held(Action::Sneak);
|
||||
let v = glam::DVec3::new(
|
||||
0.22 * hand_dir,
|
||||
f64::from(p.dimensions.height) - 1.0,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! `Llama.handleEating` (wheat and hay), chests, carpets, shearing a carpet
|
||||
//! off and riding.
|
||||
use super::Game;
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
mobs::{equine, llama},
|
||||
@@ -78,7 +79,7 @@ impl Game<'_> {
|
||||
if !m.alive() || Some(m.id) == self.riding {
|
||||
return false;
|
||||
}
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
let sneaking = self.keys.held(Action::Sneak);
|
||||
let (baby, tamed, vehicle) = (m.is_baby(), m.extra.tame, m.rider.is_some());
|
||||
let open_inventory = !baby && tamed && sneaking;
|
||||
if !vehicle && !open_inventory && !item.is_empty() {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! furnace and TNT variants, saving, and `AbstractMinecartRenderer`'s old
|
||||
//! rail-following render with the display block.
|
||||
use super::{Game, Hit, InteractTarget, Keys, entity::Entity};
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::entity_rules::EntityRules;
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
@@ -2150,7 +2151,7 @@ impl Game<'_> {
|
||||
if target.is_none() && !self.before_block(d) {
|
||||
return false;
|
||||
}
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
let sneaking = self.keys.held(Action::Sneak);
|
||||
let held = self.held().cloned();
|
||||
match self.carts[i].kind {
|
||||
CartKind::Rideable => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! (`LocalPlayer.aiStep`'s riding jump), positioning, dismounting, and the
|
||||
//! mount's health and jump bar on the HUD.
|
||||
use super::Game;
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
hud::HudBatch,
|
||||
@@ -156,7 +157,7 @@ impl Game<'_> {
|
||||
{
|
||||
return Some(false);
|
||||
}
|
||||
if self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft) {
|
||||
if self.keys.held(Action::Sneak) {
|
||||
return None;
|
||||
}
|
||||
let saddle = m.saddle_item()?;
|
||||
@@ -198,7 +199,7 @@ impl Game<'_> {
|
||||
}
|
||||
// Pig.mobInteract: held food goes to Animal.mobInteract, not a ride.
|
||||
let food = m.kind.food.iter().any(|f| f.as_str() == item);
|
||||
if !m.extra.saddled || food || self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft) {
|
||||
if !m.extra.saddled || food || self.keys.held(Action::Sneak) {
|
||||
return false;
|
||||
}
|
||||
let id = m.id;
|
||||
@@ -294,8 +295,8 @@ impl Game<'_> {
|
||||
if species == Species::SkeletonHorse && !m.extra.tame {
|
||||
return false;
|
||||
}
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|
||||
|| self.keys.contains(&winit::keyboard::KeyCode::ShiftRight);
|
||||
let sneaking =
|
||||
self.keys.held(Action::Sneak) || self.keys.raw(&winit::keyboard::KeyCode::ShiftRight);
|
||||
// Horse / ZombieHorse.mobInteract: food is fed, and anything else in
|
||||
// hand makes an untamed one mad instead of seating the player.
|
||||
let dandelion = [self.held(), self.offhand.as_ref()]
|
||||
@@ -531,7 +532,7 @@ impl Game<'_> {
|
||||
pitch: self.pitch,
|
||||
jump: if jump > 0.0 { jump } else { pending },
|
||||
steer,
|
||||
sprint: keys.sprint || self.keys.contains(&winit::keyboard::KeyCode::ControlLeft),
|
||||
sprint: keys.sprint || self.keys.held(Action::Sprint),
|
||||
jumping: keys.jump,
|
||||
// A server's player (a LAN guest, single player's window) drives
|
||||
// a horse from its own client (`handleMoveVehicle`).
|
||||
|
||||
@@ -9297,7 +9297,7 @@ impl Game<'_> {
|
||||
let &(_, key) = MAPPINGS.iter().find(|m| m.0 == name)?;
|
||||
let label = keybinds::display_name(self.settings.keys.get(name)).plain();
|
||||
let down = match key {
|
||||
Some(k) => self.keys.contains(&k),
|
||||
Some(k) => self.movement_key_held(k),
|
||||
None => self.mouse_reading().1[usize::from(name == "key.use")],
|
||||
};
|
||||
Some((down, label))
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
//! (docs/native-mods.md, "Trait mods"). Each goes through the helper or the
|
||||
//! state the game's own code uses, so both answer alike.
|
||||
use super::Game;
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, ModBlockEntity},
|
||||
items::ItemStack,
|
||||
particles::ParticleOptions,
|
||||
};
|
||||
use horizon_world::{player_inventory::EquipmentSlot, registry::BlockStateId};
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
@@ -117,7 +117,7 @@ impl Game<'_> {
|
||||
/// (`as_guest` holds sneak for them); on a client the player's own key.
|
||||
/// API @API:host_bridge@.
|
||||
pub(super) fn native_sneaking(&self) -> bool {
|
||||
self.keys.contains(&KeyCode::ShiftLeft)
|
||||
self.keys.held(Action::Sneak)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! pufferfish, feeding, the saddle and armour, riding, and the zombie
|
||||
//! nautilus a drowned with a trident sometimes rides.
|
||||
use super::Game;
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
mobs::{Species, nautilus},
|
||||
@@ -49,8 +50,8 @@ impl Game<'_> {
|
||||
.map(|t| t.iter().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
let food = nautilus::is_food(m, item, &taming);
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|
||||
|| self.keys.contains(&winit::keyboard::KeyCode::ShiftRight);
|
||||
let sneaking =
|
||||
self.keys.held(Action::Sneak) || self.keys.raw(&winit::keyboard::KeyCode::ShiftRight);
|
||||
let tame = m.extra.tame;
|
||||
if m.is_baby() {
|
||||
// Animal.mobInteract: food grows a baby up by a tenth.
|
||||
|
||||
@@ -686,7 +686,7 @@ fn detected_controller_style(
|
||||
fallback: horizon_client::gamepad::GlyphStyle,
|
||||
) -> horizon_client::gamepad::GlyphStyle {
|
||||
device.map_or(fallback, |d| {
|
||||
horizon_client::gamepad::GlyphStyle::of_vendor(d.vendor)
|
||||
horizon_client::gamepad::GlyphStyle::of_device(d.vendor, d.product)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! injects a virtual pad on top of the real one.
|
||||
use super::event_loop::EventLoopCtl;
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::held::Source;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
@@ -14,7 +15,8 @@ use horizon_client::{
|
||||
keymapping::SNEAK,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
use winit::event::MouseButton;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
pub(super) const HELP: &[&str] = &[
|
||||
@@ -69,6 +71,11 @@ pub(super) struct Pads {
|
||||
pub(super) menu: super::pad_menu::MenuCursor,
|
||||
/// The last vibration asked for, for `pad status`.
|
||||
last_rumble: Option<(&'static str, gamepad::Pulse)>,
|
||||
/// The drop button, while it is held in play.
|
||||
held_drop: Option<gamepad::HeldDrop>,
|
||||
/// The drop about to happen is the pad's stack form: it has no Control for `drop_whole_stack`.
|
||||
/// On a server the drop waits for the tick.
|
||||
pub(super) drop_stack: bool,
|
||||
/// The player's health between polls (a drop is damage taken).
|
||||
health: HealthWatch,
|
||||
}
|
||||
@@ -323,6 +330,7 @@ impl Game<'_> {
|
||||
self.pad.in_use = true;
|
||||
}
|
||||
self.pad.state = state;
|
||||
self.pad_drop_frame();
|
||||
self.pad_output_frame();
|
||||
// Damage from any source, here or on a server; nothing counts on
|
||||
// the title screen.
|
||||
@@ -468,8 +476,8 @@ impl Game<'_> {
|
||||
};
|
||||
match route {
|
||||
Route::HotbarStep { next } => {
|
||||
if down && self.screen == Screen::Playing {
|
||||
self.selected = gamepad::hotbar_step(self.selected, next);
|
||||
if down {
|
||||
self.hotbar_step(next);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -483,10 +491,10 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
let screen_open = self.screen != Screen::Playing || self.title.is_some();
|
||||
let was = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let was = self.keys.held(Action::Sneak);
|
||||
self.toggles.pad_toggle(SNEAK, screen_open);
|
||||
self.sync_toggle(SNEAK);
|
||||
if !was && self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
if !was && self.keys.held(Action::Sneak) {
|
||||
// The pad's latch: a keyboard handoff leaves it.
|
||||
self.keys.press(KeyCode::ShiftLeft, Source::Pad);
|
||||
self.keys.release(&KeyCode::ShiftLeft, Source::Keyboard);
|
||||
@@ -512,18 +520,65 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Route::Mouse(button) => {
|
||||
if !down || (self.title.is_none() && self.screen == Screen::Playing) {
|
||||
self.play_mouse(button, down)?;
|
||||
Route::Fullscreen => {
|
||||
if down {
|
||||
self.toggle_fullscreen();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Route::Held => Ok(()),
|
||||
Route::Mouse(button) => {
|
||||
let playing = self.title.is_none() && self.screen == Screen::Playing;
|
||||
// What the window path tells mods and the click counters. Pick block isn't one
|
||||
// they count.
|
||||
if playing
|
||||
&& matches!(button, MouseButton::Left | MouseButton::Right)
|
||||
&& (down || !self.mouse_mapping_held_elsewhere(button, Source::Pad))
|
||||
{
|
||||
self.extras_mouse(button, down);
|
||||
}
|
||||
if !down || playing {
|
||||
self.mouse_mapping(button, down, Source::Pad)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Route::Key(KeyCode::KeyQ) => {
|
||||
self.pad.drop_stack = false;
|
||||
self.pad.held_drop = down.then(|| {
|
||||
let delay = self.settings.controller.repeat_delay;
|
||||
gamepad::HeldDrop::new(Instant::now(), Duration::from_secs_f32(delay))
|
||||
});
|
||||
self.mapped_key(KeyCode::KeyQ, down, false, KeyCode::KeyQ, Source::Pad)
|
||||
}
|
||||
Route::Key(key) => self.mapped_key(key, down, false, key, Source::Pad),
|
||||
}
|
||||
}
|
||||
|
||||
/// Each poll: a held drop button, as a held Q's key repeats reach the mapping.
|
||||
fn pad_drop_frame(&mut self) {
|
||||
if self.pad_in_menu() || self.screen_over_playing() {
|
||||
return;
|
||||
}
|
||||
let interval = Duration::from_secs_f32(self.settings.controller.repeat_interval);
|
||||
let step = self
|
||||
.pad
|
||||
.held_drop
|
||||
.as_mut()
|
||||
.and_then(|held| held.due(Instant::now(), interval));
|
||||
let Some(step) = step else {
|
||||
return;
|
||||
};
|
||||
self.pad.drop_stack = step == gamepad::DropStep::Stack;
|
||||
if let Err(e) = self.mapped_key(KeyCode::KeyQ, true, true, KeyCode::KeyQ, Source::Pad) {
|
||||
log::warn!("Horizon: controller drop: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// B and Start: what Escape does where the pad is.
|
||||
pub(super) fn pad_back(&mut self) -> Result<()> {
|
||||
if self.prompt_open() {
|
||||
return self.pad_prompt_back();
|
||||
}
|
||||
// The options open over the title screen too: they come first, as
|
||||
// for the keyboard's Escape.
|
||||
if self.screen == Screen::Options {
|
||||
@@ -554,6 +609,26 @@ impl Game<'_> {
|
||||
gamepad::move_vector(self.pad.state.left, settings.move_deadzone)
|
||||
}
|
||||
|
||||
/// Whether `key` is held, a movement key by the left stick too: as the keys the stick holds
|
||||
/// (`eight_direction_keys`), whatever moves the player. For readers of the keys that aren't
|
||||
/// the movement itself.
|
||||
pub(super) fn movement_key_held(&self, key: KeyCode) -> bool {
|
||||
if self.keys.raw(&key) {
|
||||
return true;
|
||||
}
|
||||
let Some(stick) = self.pad_move() else {
|
||||
return false;
|
||||
};
|
||||
let [forward, backward, left, right] = gamepad::eight_direction_keys(stick);
|
||||
match key {
|
||||
KeyCode::KeyW => forward,
|
||||
KeyCode::KeyS => backward,
|
||||
KeyCode::KeyA => left,
|
||||
KeyCode::KeyD => right,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The right stick turns the view, once per frame (`Entity.turn`).
|
||||
pub(super) fn pad_look(&mut self, seconds: f32) {
|
||||
let settings = &self.settings.controller;
|
||||
|
||||
@@ -134,9 +134,9 @@ impl Pads {
|
||||
// Some backends report the controllers already there as they open.
|
||||
if !self.devices.known.contains(&id) {
|
||||
self.devices.known.push(id);
|
||||
let vendor = gilrs.gamepad(id).vendor_id();
|
||||
self.connections
|
||||
.push(Connection::Connected(GlyphStyle::of_vendor(vendor)));
|
||||
let pad = gilrs.gamepad(id);
|
||||
let style = GlyphStyle::of_device(pad.vendor_id(), pad.product_id());
|
||||
self.connections.push(Connection::Connected(style));
|
||||
}
|
||||
if let Some(output) = &self.devices.output {
|
||||
output.scan();
|
||||
@@ -179,7 +179,7 @@ impl Pads {
|
||||
return PadState::default();
|
||||
};
|
||||
self.device = Some(device_of(&pad));
|
||||
self.style = GlyphStyle::of_vendor(pad.vendor_id());
|
||||
self.style = GlyphStyle::of_device(pad.vendor_id(), pad.product_id());
|
||||
self.devices.dualsense = is_dualsense(&pad);
|
||||
state_of(&pad)
|
||||
}
|
||||
@@ -340,7 +340,7 @@ impl PadRouter {
|
||||
.as_ref()
|
||||
.and_then(|g| g.connected_gamepad(id))
|
||||
.map_or(GlyphStyle::Auto, |pad| {
|
||||
GlyphStyle::of_vendor(pad.vendor_id())
|
||||
GlyphStyle::of_device(pad.vendor_id(), pad.product_id())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ pub(super) fn crossed(pad: &Gamepad) -> bool {
|
||||
Button::Z,
|
||||
]
|
||||
.map(|button| pad.button_code(button).is_some()),
|
||||
GlyphStyle::of_vendor(pad.vendor_id()),
|
||||
GlyphStyle::of_device(pad.vendor_id(), pad.product_id()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ mod tests {
|
||||
#[test]
|
||||
fn an_unmapped_xbox_style_pad_is_crossed() {
|
||||
// The GameSir Nova Lite 2 of #7323: `3537:1098`, no SDL row, X sends BTN_NORTH.
|
||||
let style = GlyphStyle::of_vendor(Some(0x3537));
|
||||
let style = GlyphStyle::of_device(Some(0x3537), Some(0x1098));
|
||||
assert!(crossed_layout(MappingSource::Driver, XBOX_BUTTONS, style));
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_sony_or_nintendo_pad_means_what_its_codes_say() {
|
||||
for vendor in [0x054c, 0x057e] {
|
||||
let style = GlyphStyle::of_vendor(Some(vendor));
|
||||
let style = GlyphStyle::of_device(Some(vendor), None);
|
||||
assert!(!crossed_layout(MappingSource::Driver, XBOX_BUTTONS, style));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
//! What needs a renderer (inventory slots, screenshots) is covered by tools/pad_parity_e2e.py.
|
||||
|
||||
use super::super::{Audio, Game, Options, Progress, Screen, Settings, load_resources};
|
||||
use horizon_client::actions::Action;
|
||||
use horizon_client::command::args::GameMode;
|
||||
use horizon_client::gamepad::{Button, PadState};
|
||||
use horizon_client::held::Source;
|
||||
use horizon_client::motion::PlayerMotion;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use winit::event::MouseButton;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// A game at play with no world: input reaches the mappings, as in `Screen::Playing`.
|
||||
@@ -43,7 +48,12 @@ fn playing(test: impl FnOnce(&mut Game)) {
|
||||
/// Hold `button` on the pad, as a poll that saw it go down would.
|
||||
fn pad_press(game: &mut Game, button: Button, action: &str) {
|
||||
let mut state = PadState::default();
|
||||
state.set(button, true);
|
||||
// A trigger is held by how far it's pulled, not by a button bit.
|
||||
match button {
|
||||
Button::LeftTrigger => state.triggers[0] = 1.0,
|
||||
Button::RightTrigger => state.triggers[1] = 1.0,
|
||||
_ => state.set(button, true),
|
||||
}
|
||||
game.pad.edges.update(&state, 0.4);
|
||||
game.pad_action(action, true).unwrap();
|
||||
}
|
||||
@@ -55,15 +65,12 @@ fn keyboard_handoff_keeps_what_the_pad_holds() {
|
||||
game.mapped_key(KeyCode::KeyW, true, false, KeyCode::KeyW, Source::Keyboard)
|
||||
.unwrap();
|
||||
pad_press(game, Button::South, "key.jump");
|
||||
assert!(game.keys.contains(&KeyCode::Space) && game.keys.contains(&KeyCode::KeyW));
|
||||
assert!(game.keys.held(Action::Jump) && game.keys.held(Action::Forward));
|
||||
// Splitscreen: keyboard and mouse move to another player.
|
||||
game.release_keyboard_mouse();
|
||||
assert!(!game.keys.held(Action::Forward), "the keyboard's walk ends");
|
||||
assert!(
|
||||
!game.keys.contains(&KeyCode::KeyW),
|
||||
"the keyboard's walk ends"
|
||||
);
|
||||
assert!(
|
||||
game.keys.contains(&KeyCode::Space),
|
||||
game.keys.held(Action::Jump),
|
||||
"the pad still holds jump; only the keyboard and mouse left"
|
||||
);
|
||||
});
|
||||
@@ -81,3 +88,165 @@ fn pad_smooth_camera_binding_toggles_the_cinematic_camera() {
|
||||
assert!(game.smooth_camera, "as its key does in play");
|
||||
});
|
||||
}
|
||||
|
||||
/// A player at the origin: the wheel's gates ask for one.
|
||||
fn with_player(game: &mut Game) {
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::ZERO).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn bumpers_step_the_hotbar_where_the_wheel_does() {
|
||||
playing(|game| {
|
||||
game.selected = 8;
|
||||
game.pad_action("pad.hotbarNext", true).unwrap();
|
||||
assert_eq!(game.selected, 8, "no player, no hotbar");
|
||||
with_player(game);
|
||||
game.pad_action("pad.hotbarNext", true).unwrap();
|
||||
assert_eq!(game.selected, 0, "wraps past the last slot");
|
||||
game.pad_action("pad.hotbarPrevious", true).unwrap();
|
||||
assert_eq!(game.selected, 8, "and back");
|
||||
game.pad_action("pad.hotbarNext", false).unwrap();
|
||||
assert_eq!(game.selected, 8, "a release does nothing");
|
||||
game.screen = Screen::Paused;
|
||||
game.pad_action("pad.hotbarNext", true).unwrap();
|
||||
assert_eq!(game.selected, 8, "a screen keeps the wheel from the hotbar");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_spectators_bumpers_set_the_flying_speed_as_the_wheel_does() {
|
||||
playing(|game| {
|
||||
with_player(game);
|
||||
game.mode = GameMode::Spectator;
|
||||
game.selected = 3;
|
||||
let speed = game.player.as_ref().unwrap().flying_speed;
|
||||
game.pad_action("pad.hotbarPrevious", true).unwrap();
|
||||
// One wheel notch up: `MouseHandler.onScroll` adds 0.005.
|
||||
let after = game.player.as_ref().unwrap().flying_speed;
|
||||
assert!((after - speed - 0.005).abs() < 1e-6, "{speed} -> {after}");
|
||||
assert_eq!(game.selected, 3, "a spectator's hotbar doesn't step");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn readers_of_the_movement_keys_see_the_stick() {
|
||||
playing(|game| {
|
||||
game.focused = true;
|
||||
assert!(!game.native_mapping("key.forward").unwrap().0);
|
||||
// Up and to the left, on a diagonal: two keys, as a keyboard would hold.
|
||||
game.pad.state.left = [-0.7, 0.7];
|
||||
assert!(game.native_mapping("key.forward").unwrap().0);
|
||||
assert!(game.native_mapping("key.left").unwrap().0);
|
||||
assert!(!game.native_mapping("key.back").unwrap().0);
|
||||
assert!(!game.native_mapping("key.right").unwrap().0);
|
||||
assert!(
|
||||
!game.native_mapping("key.jump").unwrap().0,
|
||||
"the stick holds only the movement keys"
|
||||
);
|
||||
// A screen takes the stick away.
|
||||
game.screen = Screen::Paused;
|
||||
assert!(!game.native_mapping("key.forward").unwrap().0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn pad_number_actions_select_the_hotbar_as_their_keys_do() {
|
||||
playing(|game| {
|
||||
with_player(game);
|
||||
game.settings
|
||||
.controller
|
||||
.bind("key.hotbar.3", Some(Button::DPadUp));
|
||||
pad_press(game, Button::DPadUp, "key.hotbar.3");
|
||||
assert_eq!(game.hotbar_presses, [2]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_held_pad_toolbar_action_counts_as_the_toolbar_key() {
|
||||
playing(|game| {
|
||||
game.settings
|
||||
.controller
|
||||
.bind("key.saveToolbarActivator", Some(Button::DPadUp));
|
||||
assert!(!game.mapped_key_down("key.saveToolbarActivator"));
|
||||
pad_press(game, Button::DPadUp, "key.saveToolbarActivator");
|
||||
assert!(game.mapped_key_down("key.saveToolbarActivator"));
|
||||
assert!(!game.mapped_key_down("key.loadToolbarActivator"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn the_pads_attack_and_use_reach_what_the_mouse_tells_mods() {
|
||||
playing(|game| {
|
||||
assert_eq!(game.native_clicks(), (0, 0));
|
||||
pad_press(game, Button::RightTrigger, "key.attack");
|
||||
assert!(game.native_mapping("key.attack").unwrap().0);
|
||||
assert_eq!(game.native_clicks(), (1, 0));
|
||||
game.pad_action("key.attack", false).unwrap();
|
||||
assert!(!game.native_mapping("key.attack").unwrap().0);
|
||||
pad_press(game, Button::LeftTrigger, "key.use");
|
||||
assert!(game.native_mapping("key.use").unwrap().0);
|
||||
assert_eq!(game.native_clicks(), (1, 1));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn the_mouse_letting_go_leaves_what_the_pad_holds_and_back() {
|
||||
playing(|game| {
|
||||
game.mouse_mapping(MouseButton::Left, true, Source::Keyboard)
|
||||
.unwrap();
|
||||
pad_press(game, Button::RightTrigger, "key.attack");
|
||||
game.mouse_mapping(MouseButton::Left, false, Source::Keyboard)
|
||||
.unwrap();
|
||||
assert!(game.left, "the pad still holds the attack");
|
||||
// The pad let go, so its edges are no longer held either.
|
||||
game.pad.edges.update(&PadState::default(), 0.4);
|
||||
game.pad_action("key.attack", false).unwrap();
|
||||
assert!(!game.left);
|
||||
|
||||
// The other way round: the mouse button is physically down.
|
||||
game.physical_buttons.insert(MouseButton::Right);
|
||||
game.mouse_mapping(MouseButton::Right, true, Source::Keyboard)
|
||||
.unwrap();
|
||||
pad_press(game, Button::LeftTrigger, "key.use");
|
||||
game.pad.edges.update(&PadState::default(), 0.4);
|
||||
game.pad_action("key.use", false).unwrap();
|
||||
assert!(game.right, "the mouse still holds the use");
|
||||
game.physical_buttons.remove(&MouseButton::Right);
|
||||
game.mouse_mapping(MouseButton::Right, false, Source::Keyboard)
|
||||
.unwrap();
|
||||
assert!(!game.right);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_release_from_a_screen_can_not_strand_the_pads_attack() {
|
||||
playing(|game| {
|
||||
// The mouse went down in play, and came up where the mappings didn't hear it.
|
||||
game.mouse_mapping(MouseButton::Left, true, Source::Keyboard)
|
||||
.unwrap();
|
||||
game.left = false;
|
||||
pad_press(game, Button::RightTrigger, "key.attack");
|
||||
assert!(game.left);
|
||||
game.pad.edges.update(&PadState::default(), 0.4);
|
||||
game.pad_action("key.attack", false).unwrap();
|
||||
assert!(!game.left, "nothing is physically held");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn keyboard_handoff_keeps_the_attack_the_pad_holds() {
|
||||
playing(|game| {
|
||||
pad_press(game, Button::RightTrigger, "key.attack");
|
||||
game.release_keyboard_mouse();
|
||||
assert!(game.left);
|
||||
});
|
||||
}
|
||||