mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 02:21:33 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ff33fd853 | ||
|
|
536c0f938b | ||
|
|
398bec3d6e |
No files matched your search
Generated
-1
@@ -1584,7 +1584,6 @@ dependencies = [
|
||||
"horizon-client-input",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-cosmetics",
|
||||
"horizon-hdr",
|
||||
"horizon-hooks",
|
||||
"horizon-items",
|
||||
|
||||
@@ -98,7 +98,6 @@ actions! {
|
||||
Emote, "key.emote", Some(KeyCode::KeyB);
|
||||
HotbarPrevious, "pad.hotbarPrevious", None;
|
||||
HotbarNext, "pad.hotbarNext", None;
|
||||
Radial, "pad.radial", None;
|
||||
}
|
||||
|
||||
impl Action {
|
||||
|
||||
@@ -253,17 +253,6 @@ impl Activity {
|
||||
/// Horizon's pad-only actions (no vanilla key mapping).
|
||||
pub const HOTBAR_PREVIOUS: &str = "pad.hotbarPrevious";
|
||||
pub const HOTBAR_NEXT: &str = "pad.hotbarNext";
|
||||
/// Held, opens the radial menu (`radial.rs`).
|
||||
pub const RADIAL: &str = "pad.radial";
|
||||
|
||||
mod radial_file;
|
||||
pub use radial_file::{RadialMode, RadialStick, opener_refused};
|
||||
|
||||
/// The `bindLayout` the settings file is written in. 1: the defaults of the binds list as they
|
||||
/// are; 2: the radial menu's opener resolved against the player's own buttons.
|
||||
const BIND_LAYOUT: u32 = 2;
|
||||
/// The layout from which the file, not the default, says where the opener is.
|
||||
const OPENER_LAYOUT: u32 = 2;
|
||||
|
||||
/// Bindable actions and their default buttons: vanilla key mappings (the
|
||||
/// game acts as their default key) and Horizon's pad-only actions. Start is
|
||||
@@ -282,7 +271,6 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
|
||||
("key.togglePerspective", Some(Button::Select)),
|
||||
("key.pickItem", Some(Button::DPadLeft)),
|
||||
("key.chat", Some(Button::DPadUp)),
|
||||
(RADIAL, Some(Button::DPadRight)),
|
||||
("key.advancements", None),
|
||||
("key.playerlist", None),
|
||||
("key.command", None),
|
||||
@@ -315,14 +303,11 @@ pub enum Route {
|
||||
HotbarStep {
|
||||
next: bool,
|
||||
},
|
||||
Radial,
|
||||
/// Held, or flipped when the pad's toggle sneak is on.
|
||||
Sneak,
|
||||
Screenshot,
|
||||
SmoothCamera,
|
||||
Fullscreen,
|
||||
/// F3's debug screen, shown or hidden as F3 alone does.
|
||||
DebugOverlay,
|
||||
/// Does nothing by itself: what reads it asks whether the button is held
|
||||
/// (`Minecraft.handleKeybinds`' toolbar keys, held with a hotbar key).
|
||||
Held,
|
||||
@@ -335,12 +320,10 @@ pub fn route(action: &str) -> Option<Route> {
|
||||
match action {
|
||||
HOTBAR_PREVIOUS => Some(Route::HotbarStep { next: false }),
|
||||
HOTBAR_NEXT => Some(Route::HotbarStep { next: true }),
|
||||
RADIAL => Some(Route::Radial),
|
||||
"key.sneak" => Some(Route::Sneak),
|
||||
"key.screenshot" => Some(Route::Screenshot),
|
||||
"key.smoothCamera" => Some(Route::SmoothCamera),
|
||||
"key.fullscreen" => Some(Route::Fullscreen),
|
||||
"key.debug.overlay" => Some(Route::DebugOverlay),
|
||||
"key.saveToolbarActivator" | "key.loadToolbarActivator" => Some(Route::Held),
|
||||
_ => {
|
||||
let default = crate::keybinds::KeyBinds::default_of(action);
|
||||
@@ -351,16 +334,12 @@ pub fn route(action: &str) -> Option<Route> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The radial menu's opener, as the binds list and the gameplay guide name it.
|
||||
pub const RADIAL_CAPTION: &str = "horizon.pad.action.radial";
|
||||
|
||||
/// An action's name in the binds list: vanilla's translation key, or
|
||||
/// Horizon's own English for the pad-only actions.
|
||||
pub fn action_caption(action: &str) -> &str {
|
||||
match action {
|
||||
HOTBAR_PREVIOUS => "Hotbar: Previous Slot",
|
||||
HOTBAR_NEXT => "Hotbar: Next Slot",
|
||||
RADIAL => RADIAL_CAPTION,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
@@ -675,21 +654,6 @@ pub struct PadSettings {
|
||||
pub repeat_interval: f32,
|
||||
/// Action name -> button, `None` for unbound; missing means the default.
|
||||
bindings: BTreeMap<&'static str, Option<Button>>,
|
||||
/// The radial menu's pages.
|
||||
pub radial: crate::radial::Pages,
|
||||
/// Whether the radial menu's opener is held or pressed once.
|
||||
pub radial_mode: RadialMode,
|
||||
/// The stick that selects in it.
|
||||
pub radial_stick: RadialStick,
|
||||
/// Lines this game doesn't read (a newer Horizon's settings, an action it doesn't have, a
|
||||
/// button it doesn't know), kept as they were so a save here loses nothing.
|
||||
extra: Vec<(String, String)>,
|
||||
/// The newest `bindLayout` the file has been written in: this game's, or a newer Horizon's,
|
||||
/// kept so that one doesn't resolve its file again as older than it is.
|
||||
bind_layout: u32,
|
||||
/// The opener last used with each stick (`RadialStick::ALL` order), so stepping the Selects
|
||||
/// With row and back gives each stick's own opener again.
|
||||
openers: radial_file::StickOpeners,
|
||||
}
|
||||
impl Default for PadSettings {
|
||||
fn default() -> Self {
|
||||
@@ -716,12 +680,6 @@ impl Default for PadSettings {
|
||||
repeat_delay: 0.35,
|
||||
repeat_interval: 0.09,
|
||||
bindings: BTreeMap::new(),
|
||||
radial: Default::default(),
|
||||
radial_mode: RadialMode::Hold,
|
||||
radial_stick: RadialStick::Right,
|
||||
extra: Vec::new(),
|
||||
bind_layout: BIND_LAYOUT,
|
||||
openers: radial_file::StickOpeners::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -748,7 +706,6 @@ impl PadSettings {
|
||||
}
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let mut s = Self::default();
|
||||
let mut saved_opener = None;
|
||||
for line in text.lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
@@ -797,85 +754,22 @@ impl PadSettings {
|
||||
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
|
||||
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
|
||||
"repeatInterval" => s.repeat_interval = bounded(s.repeat_interval, 0.03, 0.3),
|
||||
"radialMode" => s.radial_mode = RadialMode::parse(value),
|
||||
"radialStick" => s.radial_stick = RadialStick::parse(value),
|
||||
// Read after the loop, from the whole text.
|
||||
"bindLayout" => {}
|
||||
// The radial menu's pages (`radial::Pages::parse`).
|
||||
other if other.starts_with("radial_") || other == crate::radial::LAYOUT_KEY => {}
|
||||
other => {
|
||||
let known = other
|
||||
.strip_prefix("bind_")
|
||||
.filter(|action| ACTIONS.iter().any(|a| a.0 == *action));
|
||||
let button = if value == "none" {
|
||||
Some(None)
|
||||
} else {
|
||||
Button::parse(value).map(Some)
|
||||
};
|
||||
match (known, button) {
|
||||
// The opener once the selecting stick is known.
|
||||
(Some(RADIAL), Some(button)) => saved_opener = Some(button),
|
||||
(Some(action), Some(button)) => s.bind(action, button),
|
||||
_ => s.extra.push((other.to_owned(), value.to_owned())),
|
||||
if let Some(action) = other.strip_prefix("bind_") {
|
||||
let button = if value == "none" {
|
||||
Some(None)
|
||||
} else {
|
||||
Button::parse(value).map(Some)
|
||||
};
|
||||
if let Some(button) = button {
|
||||
s.bind(action, button);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// An opener on a button it can't be with this stick reads as never saved.
|
||||
let refused_opener = match saved_opener {
|
||||
Some(Some(b)) if opener_refused(s.radial_stick, b) => true,
|
||||
Some(button) => {
|
||||
s.bind(RADIAL, button);
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if let Some(pages) = crate::radial::Pages::parse(
|
||||
text.lines()
|
||||
.filter_map(|line| line.split_once(':'))
|
||||
.map(|(key, value)| (key.trim(), value.trim())),
|
||||
) {
|
||||
s.radial = pages;
|
||||
}
|
||||
let lines = || text.lines().map(str::trim);
|
||||
let saved = |action: &str| {
|
||||
let prefix = format!("bind_{action}:");
|
||||
lines().any(|line| line.starts_with(&prefix))
|
||||
};
|
||||
// The layout the file was written in; none in a file from before these defaults.
|
||||
let layout = lines()
|
||||
.find_map(|line| line.strip_prefix("bindLayout:"))
|
||||
.map(|value| value.trim().parse::<u32>().unwrap_or(0));
|
||||
s.bind_layout = layout.map_or(BIND_LAYOUT, |layout| layout.max(BIND_LAYOUT));
|
||||
// Once, for a file from before the radial menu's opener, or one with the opener on a
|
||||
// button it can no longer be: a button the player bound by hand stays theirs, and the
|
||||
// opener's default doesn't take it. From then on the file says where the opener is, and
|
||||
// a conflict made since stays shown in the binds list.
|
||||
let before_opener = layout.is_none_or(|layout| layout < OPENER_LAYOUT);
|
||||
if !text.is_empty() && (refused_opener || (before_opener && !saved(RADIAL))) {
|
||||
let default_opener = ACTIONS
|
||||
.iter()
|
||||
.find(|(action, _)| *action == RADIAL)
|
||||
.and_then(|(_, button)| *button);
|
||||
let taken = s.bindings.iter().any(|(action, button)| {
|
||||
*action != RADIAL && button.is_some() && *button == default_opener
|
||||
});
|
||||
if taken {
|
||||
s.bindings.insert(RADIAL, None);
|
||||
}
|
||||
}
|
||||
// The default too can be under the selecting thumb (D-pad Right, the left stick
|
||||
// selecting): no button opens the menu until the player picks one.
|
||||
if s.binding(RADIAL)
|
||||
.is_some_and(|b| opener_refused(s.radial_stick, b))
|
||||
{
|
||||
s.bindings.insert(RADIAL, None);
|
||||
}
|
||||
// A file from before these defaults: its player's buttons stay where they were, and
|
||||
// nothing they saved shares a button with the radial menu's opener. They can bind one
|
||||
// in the binds list.
|
||||
if !text.is_empty() && layout.is_none() {
|
||||
s.bindings.entry(RADIAL).or_insert(None);
|
||||
// A file from before these defaults: its player's buttons stay where they were.
|
||||
if !text.is_empty() && !text.lines().any(|line| line.starts_with("bindLayout:")) {
|
||||
for (action, button) in [
|
||||
("key.drop", Button::East),
|
||||
("key.togglePerspective", Button::DPadUp),
|
||||
@@ -884,7 +778,10 @@ impl PadSettings {
|
||||
("key.advancements", Button::DPadDown),
|
||||
] {
|
||||
// A button saved by hand stays, even one that's now this action's default.
|
||||
if !saved(action) {
|
||||
if !text
|
||||
.lines()
|
||||
.any(|line| line.starts_with(&format!("bind_{action}:")))
|
||||
{
|
||||
s.bindings.entry(action).or_insert(Some(button));
|
||||
}
|
||||
}
|
||||
@@ -925,25 +822,7 @@ impl PadSettings {
|
||||
"playInBackground:{}\n",
|
||||
bool_text(self.play_in_background)
|
||||
));
|
||||
text.push_str(&format!(
|
||||
"radialMode:{}\nradialStick:{}\n",
|
||||
self.radial_mode.name(),
|
||||
self.radial_stick.name()
|
||||
));
|
||||
text.push_str(&format!("bindLayout:{}\n", self.bind_layout));
|
||||
for (key, value) in &self.extra {
|
||||
// A button this game didn't know, for an action the player has bound here since.
|
||||
let rebound = key
|
||||
.strip_prefix("bind_")
|
||||
.is_some_and(|action| self.bindings.contains_key(action));
|
||||
if !rebound {
|
||||
text.push_str(&format!("{key}:{value}\n"));
|
||||
}
|
||||
}
|
||||
// The default pages aren't written: they can improve for a player who never changed them.
|
||||
if self.radial != crate::radial::Pages::default() {
|
||||
text.push_str(&self.radial.serialize());
|
||||
}
|
||||
text.push_str("bindLayout:1\n");
|
||||
text
|
||||
}
|
||||
pub fn default_of(action: &str) -> Option<Button> {
|
||||
@@ -956,15 +835,11 @@ impl PadSettings {
|
||||
None => Self::default_of(action),
|
||||
}
|
||||
}
|
||||
/// Put `action` on `button`, or unbind it. An unknown action, or a button it can't take
|
||||
/// (`can_bind`), changes nothing.
|
||||
/// Bind an action (`None` unbinds); unknown actions are ignored.
|
||||
pub fn bind(&mut self, action: &str, button: Option<Button>) {
|
||||
let Some((name, default)) = ACTIONS.iter().find(|a| a.0 == action) else {
|
||||
return;
|
||||
};
|
||||
if button.is_some_and(|b| !self.can_bind(action, b)) {
|
||||
return;
|
||||
}
|
||||
if button == *default {
|
||||
self.bindings.remove(name);
|
||||
} else {
|
||||
@@ -1565,14 +1440,6 @@ pub enum Rumble {
|
||||
radius: f32,
|
||||
},
|
||||
Totem,
|
||||
/// The radial menu's stick reached a slot that would run: a tick.
|
||||
RadialSlot,
|
||||
/// The radial menu's page turned: a shorter tick.
|
||||
RadialPage,
|
||||
/// The page turn went round an end: a second tick after the first.
|
||||
RadialWrap,
|
||||
/// A release ran nothing after something had been selected: a soft buzz.
|
||||
RadialNothing,
|
||||
}
|
||||
impl From<crate::input_source::Feedback> for Rumble {
|
||||
fn from(event: crate::input_source::Feedback) -> Self {
|
||||
@@ -1592,7 +1459,6 @@ pub struct Pulse {
|
||||
pub weak: f32,
|
||||
pub millis: u32,
|
||||
}
|
||||
use crate::radial::{RADIAL_NOTHING, RADIAL_PAGE_TICK, RADIAL_TICK};
|
||||
impl Rumble {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
@@ -1600,10 +1466,6 @@ impl Rumble {
|
||||
Rumble::BlockBroken => "block",
|
||||
Rumble::Explosion { .. } => "explosion",
|
||||
Rumble::Totem => "totem",
|
||||
Rumble::RadialSlot => "radial",
|
||||
Rumble::RadialPage => "radial_page",
|
||||
Rumble::RadialWrap => "radial_wrap",
|
||||
Rumble::RadialNothing => "radial_nothing",
|
||||
}
|
||||
}
|
||||
/// The pulse, or `None` when it is too faint to feel.
|
||||
@@ -1630,9 +1492,6 @@ impl Rumble {
|
||||
millis: 350,
|
||||
})
|
||||
}
|
||||
Rumble::RadialSlot => Some(RADIAL_TICK),
|
||||
Rumble::RadialPage | Rumble::RadialWrap => Some(RADIAL_PAGE_TICK),
|
||||
Rumble::RadialNothing => Some(RADIAL_NOTHING),
|
||||
Rumble::Totem => Some(Pulse {
|
||||
strong: 0.7,
|
||||
weak: 0.7,
|
||||
@@ -1654,10 +1513,6 @@ impl Rumble {
|
||||
};
|
||||
match self {
|
||||
Rumble::BlockBroken => vec![Pulse { millis: 35, ..p }],
|
||||
Rumble::RadialSlot
|
||||
| Rumble::RadialPage
|
||||
| Rumble::RadialWrap
|
||||
| Rumble::RadialNothing => vec![p],
|
||||
Rumble::Hurt(_) => {
|
||||
let tail = p.millis.saturating_sub(50) / 2;
|
||||
vec![step(1.0, 50), step(0.55, tail), step(0.25, tail)]
|
||||
@@ -2018,55 +1873,7 @@ bind_key.hotbar.2:north
|
||||
let odd = PadSettings::parse(
|
||||
"lookSensitivity:NaN\nbind_key.nope:south\nbind_key.jump:x9\nbindLayout:1\n",
|
||||
);
|
||||
assert_eq!(
|
||||
odd.look_sensitivity,
|
||||
PadSettings::default().look_sensitivity
|
||||
);
|
||||
assert_eq!(odd.binding("key.jump"), Some(Button::South));
|
||||
assert_eq!(odd.actions_of(Button::South), ["key.jump"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_this_game_doesnt_read_survive_a_save() {
|
||||
// A newer Horizon's setting, an action this game doesn't have, a button it doesn't know.
|
||||
let text =
|
||||
"bindLayout:2\nhapticStrength:0.4\nbind_horizon:dash:south\nbind_key.jump:paddle1\n";
|
||||
let s = PadSettings::parse(text);
|
||||
assert_eq!(
|
||||
s.binding("key.jump"),
|
||||
Some(Button::South),
|
||||
"the default meanwhile"
|
||||
);
|
||||
let saved = s.serialize();
|
||||
for line in [
|
||||
"hapticStrength:0.4\n",
|
||||
"bind_horizon:dash:south\n",
|
||||
"bind_key.jump:paddle1\n",
|
||||
] {
|
||||
assert!(saved.contains(line), "{line} lost from\n{saved}");
|
||||
}
|
||||
assert_eq!(PadSettings::parse(&saved), s);
|
||||
// Bound here since, the action's own line is the one written.
|
||||
let mut s = s;
|
||||
s.bind("key.jump", Some(Button::West));
|
||||
let saved = s.serialize();
|
||||
assert!(!saved.contains("paddle1"), "{saved}");
|
||||
assert_eq!(
|
||||
PadSettings::parse(&saved).binding("key.jump"),
|
||||
Some(Button::West)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_bind_layout_is_kept() {
|
||||
let newer = BIND_LAYOUT + 1;
|
||||
let s = PadSettings::parse(&format!("bindLayout:{newer}\nvibration:false\n"));
|
||||
assert!(s.serialize().contains(&format!("bindLayout:{newer}\n")));
|
||||
let s = PadSettings::parse("bindLayout:1\nvibration:false\n");
|
||||
assert!(
|
||||
s.serialize()
|
||||
.contains(&format!("bindLayout:{BIND_LAYOUT}\n"))
|
||||
);
|
||||
assert_eq!(odd, PadSettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
//! The radial menu's settings in the controller file (`horizon-controller.txt`): how its opener
|
||||
//! holds it open, the stick that selects, which buttons the opener may be with that stick, and the
|
||||
//! opener each stick had this session. `PadSettings::parse` and `serialize` read and write their
|
||||
//! lines (`radialMode`, `radialStick`, `bind_pad.radial`).
|
||||
use super::{Button, PadSettings, RADIAL};
|
||||
|
||||
/// The buttons under the right thumb, which steers the right stick.
|
||||
const RIGHT_THUMB: [Button; 4] = [
|
||||
Button::South,
|
||||
Button::West,
|
||||
Button::North,
|
||||
Button::RightStick,
|
||||
];
|
||||
/// The buttons under the left thumb, which steers the left stick.
|
||||
const LEFT_THUMB: [Button; 5] = [
|
||||
Button::DPadUp,
|
||||
Button::DPadDown,
|
||||
Button::DPadLeft,
|
||||
Button::DPadRight,
|
||||
Button::LeftStick,
|
||||
];
|
||||
|
||||
/// Whether the radial menu's opener can't be `button` while `stick` selects. One rule: the opener
|
||||
/// is never under a thumb that selects (holding it and steering are two jobs for one thumb), nor
|
||||
/// B, which cancels the open menu. So with the right stick the face buttons and R3 go, with the
|
||||
/// left the D-pad and L3, with either both; a bumper, a trigger and View are always free, and
|
||||
/// with a bumper for the opener the other bumper turns the pages.
|
||||
pub fn opener_refused(stick: RadialStick, button: Button) -> bool {
|
||||
button == Button::East
|
||||
|| (stick != RadialStick::Left && RIGHT_THUMB.contains(&button))
|
||||
|| (stick != RadialStick::Right && LEFT_THUMB.contains(&button))
|
||||
}
|
||||
|
||||
/// How the radial menu's opener holds it open.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum RadialMode {
|
||||
/// Held: letting go runs what is selected.
|
||||
#[default]
|
||||
Hold,
|
||||
/// A press opens it; A or the opener again runs what is selected, B closes it.
|
||||
Toggle,
|
||||
}
|
||||
impl RadialMode {
|
||||
pub const ALL: [Self; 2] = [Self::Hold, Self::Toggle];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hold => "hold",
|
||||
Self::Toggle => "toggle",
|
||||
}
|
||||
}
|
||||
pub fn parse(value: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|mode| mode.name() == value)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The stick that selects in the radial menu.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum RadialStick {
|
||||
#[default]
|
||||
Right,
|
||||
Left,
|
||||
/// The last stick to lean past the select tilt.
|
||||
Either,
|
||||
}
|
||||
impl RadialStick {
|
||||
pub const ALL: [Self; 3] = [Self::Right, Self::Left, Self::Either];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Right => "right",
|
||||
Self::Left => "left",
|
||||
Self::Either => "either",
|
||||
}
|
||||
}
|
||||
pub fn parse(value: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|stick| stick.name() == value)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The opener each stick had, for this session: what the player stepped through, not a setting,
|
||||
/// so it is neither saved nor compared.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(super) struct StickOpeners([Option<Button>; 3]);
|
||||
impl PartialEq for StickOpeners {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
impl StickOpeners {
|
||||
fn slot(&mut self, stick: RadialStick) -> &mut Option<Button> {
|
||||
let at = RadialStick::ALL
|
||||
.iter()
|
||||
.position(|s| *s == stick)
|
||||
.unwrap_or(0);
|
||||
&mut self.0[at]
|
||||
}
|
||||
}
|
||||
|
||||
impl PadSettings {
|
||||
/// Whether `button` can be bound to `action`: anything but the radial menu's opener under
|
||||
/// a thumb that selects, or on B (`opener_refused`).
|
||||
pub fn can_bind(&self, action: &str, button: Button) -> bool {
|
||||
action != RADIAL || !opener_refused(self.radial_stick, button)
|
||||
}
|
||||
/// The stick the radial menu selects with. An opener under the thumb that now selects is
|
||||
/// unbound, and its row in the radial page says so. An unbound opener then takes the one this
|
||||
/// stick had before in this session, or else its default where nothing else has it (D-pad
|
||||
/// Right, back on the right stick).
|
||||
pub fn set_radial_stick(&mut self, stick: RadialStick) {
|
||||
let was = self.radial_stick;
|
||||
*self.openers.slot(was) = self.binding(RADIAL);
|
||||
self.radial_stick = stick;
|
||||
if self
|
||||
.binding(RADIAL)
|
||||
.is_some_and(|b| opener_refused(stick, b))
|
||||
{
|
||||
self.bindings.insert(RADIAL, None);
|
||||
}
|
||||
if self.binding(RADIAL).is_some() {
|
||||
return;
|
||||
}
|
||||
// The player's own, shared or not: they put it there.
|
||||
if let Some(own) = self
|
||||
.openers
|
||||
.slot(stick)
|
||||
.filter(|b| !opener_refused(stick, *b))
|
||||
{
|
||||
self.bind(RADIAL, Some(own));
|
||||
return;
|
||||
}
|
||||
let default = Self::default_of(RADIAL);
|
||||
let free =
|
||||
default.is_some_and(|d| !opener_refused(stick, d) && self.actions_of(d).is_empty());
|
||||
if free {
|
||||
self.bindings.remove(RADIAL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::Rumble;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_new_player_holds_d_pad_right_for_the_radial_menu() {
|
||||
for s in [PadSettings::default(), PadSettings::parse("")] {
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
assert!(!s.conflicts(RADIAL));
|
||||
}
|
||||
let s = PadSettings::parse(&PadSettings::default().serialize());
|
||||
assert_eq!(s, PadSettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_button_bound_by_hand_stays_with_it_over_the_openers_default() {
|
||||
let s = PadSettings::parse("bindLayout:1\nbind_key.playerlist:right\n");
|
||||
assert_eq!(s.binding("key.playerlist"), Some(Button::DPadRight));
|
||||
assert_eq!(s.binding(RADIAL), None);
|
||||
assert!(!s.conflicts("key.playerlist"));
|
||||
assert_eq!(PadSettings::parse(&s.serialize()), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_opener_stays_where_it_was_saved() {
|
||||
let s =
|
||||
PadSettings::parse("bindLayout:1\nbind_pad.radial:right\nbind_key.playerlist:right\n");
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conflict_made_since_the_migration_stays_after_a_restart() {
|
||||
// Bound in the binds list onto the opener's button: both stay, and the list marks it.
|
||||
let mut s = PadSettings::default();
|
||||
s.bind("key.playerlist", Some(Button::DPadRight));
|
||||
let text = s.serialize();
|
||||
assert!(text.contains("bindLayout:2"));
|
||||
let read = PadSettings::parse(&text);
|
||||
assert_eq!(read.binding(RADIAL), Some(Button::DPadRight));
|
||||
assert!(read.conflicts(RADIAL));
|
||||
assert_eq!(read, s);
|
||||
// The same, hand-written with spaces around the lines.
|
||||
let read = PadSettings::parse(" bindLayout:2\n bind_key.playerlist:right\n");
|
||||
assert_eq!(read.binding(RADIAL), Some(Button::DPadRight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layout_one_file_resolves_the_opener_once() {
|
||||
let s = PadSettings::parse("bind_key.chat:up\n bindLayout:1\nbind_key.playerlist:right\n");
|
||||
assert_eq!(s.binding(RADIAL), None, "the player's own button wins");
|
||||
assert_eq!(s.binding("key.playerlist"), Some(Button::DPadRight));
|
||||
assert_eq!(s.binding("key.chat"), Some(Button::DPadUp));
|
||||
let text = s.serialize();
|
||||
assert!(text.contains("bindLayout:2\n") && text.contains("bind_pad.radial:none\n"));
|
||||
assert_eq!(PadSettings::parse(&text), s);
|
||||
// Without a conflict the opener keeps its default.
|
||||
let s = PadSettings::parse("bindLayout:1\nvibration:false\n");
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
// A line with leading spaces is still a saved opener.
|
||||
let s =
|
||||
PadSettings::parse("bindLayout:1\n bind_pad.radial:right\nbind_key.playerlist:right\n");
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_opener_never_goes_under_the_selecting_thumb_nor_on_b() {
|
||||
use Button as B;
|
||||
let face = [B::South, B::West, B::North, B::RightStick];
|
||||
let dpad = [
|
||||
B::DPadUp,
|
||||
B::DPadDown,
|
||||
B::DPadLeft,
|
||||
B::DPadRight,
|
||||
B::LeftStick,
|
||||
];
|
||||
for stick in RadialStick::ALL {
|
||||
let mut s = PadSettings::default();
|
||||
s.set_radial_stick(stick);
|
||||
for button in Button::ALL {
|
||||
let refused = button == B::East
|
||||
|| (stick != RadialStick::Left && face.contains(&button))
|
||||
|| (stick != RadialStick::Right && dpad.contains(&button));
|
||||
assert_eq!(!s.can_bind(RADIAL, button), refused, "{stick:?} {button:?}");
|
||||
// Other actions still take any of them.
|
||||
assert!(s.can_bind("key.chat", button));
|
||||
}
|
||||
// A bumper is always free, and stays one through the file.
|
||||
s.bind(RADIAL, Some(B::LeftBumper));
|
||||
assert_eq!(s.binding(RADIAL), Some(B::LeftBumper), "{stick:?}");
|
||||
assert_eq!(PadSettings::parse(&s.serialize()), s, "{stick:?}");
|
||||
}
|
||||
// A file with a refused opener reads as never saved: the default, unless taken.
|
||||
for button in [B::South, B::East, B::West, B::North, B::RightStick] {
|
||||
let text = format!("bindLayout:2\nbind_pad.radial:{}\n", button.name());
|
||||
let read = PadSettings::parse(&text);
|
||||
assert_eq!(read.binding(RADIAL), Some(B::DPadRight), "{button:?}");
|
||||
let taken = format!("{text}bind_key.playerlist:right\n");
|
||||
assert_eq!(
|
||||
PadSettings::parse(&taken).binding(RADIAL),
|
||||
None,
|
||||
"{button:?}"
|
||||
);
|
||||
}
|
||||
// Read with the stick it was saved for, wherever the stick's line is.
|
||||
let left = PadSettings::parse("bind_pad.radial:north\nbindLayout:2\nradialStick:left\n");
|
||||
assert_eq!(left.binding(RADIAL), Some(B::North));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stick_that_takes_the_openers_thumb_unbinds_it_and_the_right_gives_it_back() {
|
||||
let mut s = PadSettings::default();
|
||||
s.set_radial_stick(RadialStick::Left);
|
||||
assert_eq!(
|
||||
s.binding(RADIAL),
|
||||
None,
|
||||
"D-pad Right is under the left thumb"
|
||||
);
|
||||
assert_eq!(PadSettings::parse(&s.serialize()).binding(RADIAL), None);
|
||||
s.set_radial_stick(RadialStick::Right);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
// An opener the player picked that both sticks allow stays.
|
||||
s.bind(RADIAL, Some(Button::RightBumper));
|
||||
s.set_radial_stick(RadialStick::Either);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::RightBumper));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stepping_the_stick_and_back_gives_each_stick_its_own_opener() {
|
||||
// Left with Y, back to the right stick: Y is under the right thumb, D-pad Right opens.
|
||||
let mut s = PadSettings::default();
|
||||
s.set_radial_stick(RadialStick::Left);
|
||||
s.bind(RADIAL, Some(Button::North));
|
||||
s.set_radial_stick(RadialStick::Right);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
// And Left again has its Y back, shared with Inventory as the player left it.
|
||||
s.set_radial_stick(RadialStick::Left);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::North));
|
||||
// Right with D-pad Left: the left stick refuses it, and the right gets it back.
|
||||
let mut s = PadSettings::default();
|
||||
s.bind(RADIAL, Some(Button::DPadLeft));
|
||||
s.set_radial_stick(RadialStick::Left);
|
||||
assert_eq!(s.binding(RADIAL), None);
|
||||
s.set_radial_stick(RadialStick::Either);
|
||||
assert_eq!(s.binding(RADIAL), None);
|
||||
s.set_radial_stick(RadialStick::Right);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadLeft));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_before_the_radial_menu_binds_no_opener() {
|
||||
let s = PadSettings::parse("vibration:false\nbind_key.chat:up\n");
|
||||
assert_eq!(s.binding(RADIAL), None);
|
||||
assert_eq!(PadSettings::parse(&s.serialize()), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_radial_menus_ticks_are_felt_and_tell_apart() {
|
||||
let tick = Rumble::RadialSlot.pulse().unwrap();
|
||||
assert!((40..=50).contains(&tick.millis), "{tick:?}");
|
||||
assert!((0.25..=0.35).contains(&tick.weak), "{tick:?}");
|
||||
let page = Rumble::RadialPage.pulse().unwrap();
|
||||
assert!(page.millis < tick.millis, "a page turn is the shorter tick");
|
||||
assert_eq!(Rumble::RadialWrap.pulse(), Some(page));
|
||||
let nothing = Rumble::RadialNothing.pulse().unwrap();
|
||||
assert!(
|
||||
nothing.strong > 0.0 && nothing.millis > tick.millis,
|
||||
"{nothing:?}"
|
||||
);
|
||||
assert!(
|
||||
nothing.strong.max(nothing.weak) < 0.3,
|
||||
"a soft buzz: {nothing:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_radial_menus_mode_and_stick_survive_the_settings_file() {
|
||||
let s = PadSettings::default();
|
||||
assert_eq!(
|
||||
(s.radial_mode, s.radial_stick),
|
||||
(RadialMode::Hold, RadialStick::Right)
|
||||
);
|
||||
for mode in RadialMode::ALL {
|
||||
for stick in RadialStick::ALL {
|
||||
let mut s = PadSettings {
|
||||
radial_mode: mode,
|
||||
..PadSettings::default()
|
||||
};
|
||||
s.set_radial_stick(stick);
|
||||
assert_eq!(PadSettings::parse(&s.serialize()), s);
|
||||
}
|
||||
}
|
||||
let read = PadSettings::parse("radialMode:sideways\nradialStick:up\n");
|
||||
assert_eq!(
|
||||
(read.radial_mode, read.radial_stick),
|
||||
(RadialMode::Hold, RadialStick::Right)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_pages_survive_the_settings_file() {
|
||||
let mut s = PadSettings::default();
|
||||
let pages = "radial_0_label:Mine\nradial_0_slots:key.chat,,,,,,,\n";
|
||||
s.radial =
|
||||
crate::radial::Pages::parse(pages.lines().filter_map(|l| l.split_once(':'))).unwrap();
|
||||
let read = PadSettings::parse(&s.serialize());
|
||||
assert_eq!(read.radial, s.radial);
|
||||
assert_ne!(read.radial, crate::radial::Pages::default());
|
||||
}
|
||||
}
|
||||
@@ -141,13 +141,8 @@ impl Guide {
|
||||
pub fn at_detail(self, detail: GuideDetail) -> Self {
|
||||
match detail {
|
||||
GuideDetail::Full => self,
|
||||
// Nothing else in play names the radial menu.
|
||||
GuideDetail::Basic => Guide {
|
||||
left: self
|
||||
.left
|
||||
.into_iter()
|
||||
.filter(|row| row.caption == crate::gamepad::RADIAL_CAPTION)
|
||||
.collect(),
|
||||
left: Vec::new(),
|
||||
right: self.right,
|
||||
},
|
||||
GuideDetail::Off => Guide {
|
||||
@@ -183,8 +178,6 @@ pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
|
||||
"key.swapOffhand",
|
||||
(player && any_item).then_some("horizon.controller.guide.swap"),
|
||||
),
|
||||
// Nothing else in play says the menu is there; it opens for a spectator too.
|
||||
row(crate::gamepad::RADIAL, Some(crate::gamepad::RADIAL_CAPTION)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -573,7 +566,6 @@ mod tests {
|
||||
(Button::North, "key.inventory"),
|
||||
(Button::DPadDown, "horizon.controller.guide.drop"),
|
||||
(Button::West, "horizon.controller.guide.swap"),
|
||||
(Button::DPadRight, crate::gamepad::RADIAL_CAPTION),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -744,12 +736,12 @@ mod tests {
|
||||
gliding: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
["key.inventory", crate::gamepad::RADIAL_CAPTION]
|
||||
["key.inventory"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spectator_only_flies_opens_spectates_and_has_its_menus() {
|
||||
fn a_spectator_only_flies_opens_spectates_and_has_its_menu() {
|
||||
let facts = Facts {
|
||||
spectator: true,
|
||||
flying: true,
|
||||
@@ -773,8 +765,7 @@ mod tests {
|
||||
captions(&guide.left),
|
||||
[
|
||||
"horizon.controller.guide.fly_up",
|
||||
"horizon.controller.guide.fly_down",
|
||||
crate::gamepad::RADIAL_CAPTION
|
||||
"horizon.controller.guide.fly_down"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,5 @@ pub mod keybinds;
|
||||
pub mod keymapping;
|
||||
pub mod modifiers;
|
||||
pub mod mouse;
|
||||
pub mod radial;
|
||||
pub mod snap;
|
||||
pub mod window;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,6 @@ description = "The client's world data, rendering and screens: chunks, meshing,
|
||||
[dependencies]
|
||||
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
|
||||
horizon-hooks = { path = "../horizon-hooks" }
|
||||
horizon-cosmetics = { path = "../horizon-cosmetics" }
|
||||
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
|
||||
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
|
||||
@@ -57,8 +57,6 @@ pub enum Item {
|
||||
/// Wait for a button to bind to this action.
|
||||
Bind(&'static str),
|
||||
ResetBinds,
|
||||
/// Opens the radial menu's pages (`radial_editor`).
|
||||
Radial,
|
||||
Info(Info),
|
||||
}
|
||||
|
||||
@@ -108,15 +106,6 @@ fn choice_description(opt: Opt, pad: &PadSettings) -> Option<&'static str> {
|
||||
}
|
||||
gamepad::Lightbar::Off => "A DualSense keeps its own light.",
|
||||
},
|
||||
Opt::PadRadialMode => match pad.radial_mode {
|
||||
gamepad::RadialMode::Hold => "horizon.options.radial_mode.hold.about",
|
||||
gamepad::RadialMode::Toggle => "horizon.options.radial_mode.toggle.about",
|
||||
},
|
||||
Opt::PadRadialStick => match pad.radial_stick {
|
||||
gamepad::RadialStick::Right => "horizon.options.radial_stick.right.about",
|
||||
gamepad::RadialStick::Left => "horizon.options.radial_stick.left.about",
|
||||
gamepad::RadialStick::Either => "horizon.options.radial_stick.either.about",
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -134,11 +123,6 @@ fn info(info: Info, text: &'static str) -> Setting {
|
||||
}
|
||||
}
|
||||
|
||||
/// The radial menu's group, on General (how it opens and selects) and Bindings (its pages).
|
||||
const RADIAL_GROUP: &str = "Radial Menu";
|
||||
/// The row that opens the radial menu's pages.
|
||||
const RADIAL_PAGES: &str = "horizon.pages.open.controller_radial";
|
||||
|
||||
/// Shown on a binding's row until it is focused, and in the search.
|
||||
const BIND_TEXT: &str = "Press this row, then the button that should do it.";
|
||||
|
||||
@@ -217,13 +201,6 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
RADIAL_GROUP,
|
||||
vec![
|
||||
option(Opt::PadRadialMode, "horizon.options.radial_mode.about"),
|
||||
option(Opt::PadRadialStick, "horizon.options.radial_stick.about"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Menus",
|
||||
vec![
|
||||
@@ -254,13 +231,6 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
(
|
||||
Page::Bindings,
|
||||
vec![
|
||||
(
|
||||
RADIAL_GROUP,
|
||||
vec![Setting {
|
||||
item: Item::Radial,
|
||||
text: "Named pages of eight actions, held open with a button.",
|
||||
}],
|
||||
),
|
||||
("Actions", binds),
|
||||
(
|
||||
"Reset",
|
||||
@@ -467,7 +437,6 @@ fn row(setting: Setting, s: &Settings, context: &Context) -> Row<Item> {
|
||||
Row::new(item, name, value).about(about)
|
||||
}
|
||||
Item::ResetBinds => Row::new(item, lang::get(RESET_ALL), Value::Press).about(about),
|
||||
Item::Radial => Row::new(item, lang::get(RADIAL_PAGES), Value::Press).about(about),
|
||||
Item::Info(which) => {
|
||||
let (name, value) = context.read(which);
|
||||
Row::new(item, lang::get(name), Value::Read(value)).about(about)
|
||||
@@ -527,7 +496,6 @@ impl Screen for ControllerScreen {
|
||||
s.controller.reset_bindings();
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
Item::Radial => vec![Output::Push(crate::options_ui::Page::ControllerRadial)],
|
||||
Item::Bind(_) | Item::Info(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -565,21 +533,15 @@ impl Screen for ControllerScreen {
|
||||
fn undoes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// This player's controller settings, bindings included, as the screen found them. The
|
||||
/// radial menu's pages stay as they are: their own screen has its own Undo.
|
||||
/// This player's controller settings, bindings included, as the screen found them.
|
||||
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
|
||||
let radial = std::mem::take(&mut s.controller.radial);
|
||||
s.controller = before.controller.clone();
|
||||
s.controller.radial = radial;
|
||||
Vec::new()
|
||||
}
|
||||
/// Undo is there exactly while the controller settings differ from what the screen opened
|
||||
/// with: a binding the game made while the row waited counts, a value stepped back doesn't,
|
||||
/// and nor do the radial menu's pages, which this Undo keeps.
|
||||
/// with: a binding the game made while the row waited counts, a value stepped back doesn't.
|
||||
fn differs(&self, s: &Settings, before: &Settings) -> Option<bool> {
|
||||
let mut now = s.controller.clone();
|
||||
now.radial = before.controller.radial.clone();
|
||||
Some(now != before.controller)
|
||||
Some(s.controller != before.controller)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -831,48 +793,6 @@ mod tests {
|
||||
assert_eq!(arts(&batch, row), 0);
|
||||
}
|
||||
|
||||
/// The radial menu's pages have their own screen and Undo: editing them shows no Undo here,
|
||||
/// and another change's Undo keeps them as edited.
|
||||
#[test]
|
||||
fn radial_pages_edited_on_their_own_screen_leave_undo_alone() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = ui(Page::General);
|
||||
draw(&mut ui, &s, None);
|
||||
s.controller.radial.remove(1);
|
||||
draw(&mut ui, &s, None);
|
||||
assert!(
|
||||
!ui.changed(),
|
||||
"Undo here keeps the pages: it has nothing to do"
|
||||
);
|
||||
s.controller.invert_y = !s.controller.invert_y;
|
||||
draw(&mut ui, &s, None);
|
||||
assert!(ui.changed());
|
||||
let reached = (0..100).any(|_| {
|
||||
key(&mut ui, &mut s, K::Tab);
|
||||
ui.focused() == Some(Target::Undo)
|
||||
});
|
||||
assert!(reached);
|
||||
key(&mut ui, &mut s, K::Enter);
|
||||
assert_eq!(s.controller.radial.len(), 1);
|
||||
assert_eq!(s.controller.invert_y, PadSettings::default().invert_y);
|
||||
}
|
||||
|
||||
/// The Bindings page's radial row opens the radial menu's pages.
|
||||
#[test]
|
||||
fn the_radial_row_opens_the_radial_pages() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = ui(Page::Bindings);
|
||||
let at = ui.point_at(&s, SIZE, Item::Radial);
|
||||
let outputs = ui.click(&mut s, at, SIZE, false, false, false);
|
||||
assert_eq!(
|
||||
outputs,
|
||||
[
|
||||
CLICK,
|
||||
Output::Push(crate::options_ui::Page::ControllerRadial)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_finds_rows_across_pages_and_drops_empty_pages() {
|
||||
let s = Settings::default();
|
||||
@@ -896,8 +816,8 @@ mod tests {
|
||||
let mut s = Settings::default();
|
||||
s.controller.bind(JUMP, Some(Button::North));
|
||||
let mut ui = ui(Page::Bindings);
|
||||
// Search, the three pages, the radial menu's row, then the first action.
|
||||
for _ in 0..Page::ALL.len() + 3 {
|
||||
// Search, the three pages, then the first action.
|
||||
for _ in 0..Page::ALL.len() + 2 {
|
||||
key(&mut ui, &mut s, K::Tab);
|
||||
}
|
||||
assert_eq!(ui.focused(), Some(Target::Row(Item::Bind(JUMP))));
|
||||
@@ -1019,8 +939,6 @@ mod tests {
|
||||
(Opt::PadNintendoButtons, 2),
|
||||
(Opt::PadKeyboard, 3),
|
||||
(Opt::PadLightbar, 3),
|
||||
(Opt::PadRadialMode, 2),
|
||||
(Opt::PadRadialStick, 3),
|
||||
] {
|
||||
let setting = option(opt, "the fixed sentence");
|
||||
let mut seen = Vec::new();
|
||||
@@ -1054,8 +972,6 @@ mod tests {
|
||||
Opt::PadNintendoButtons,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadLightbar,
|
||||
Opt::PadRadialMode,
|
||||
Opt::PadRadialStick,
|
||||
] {
|
||||
for _ in 0..4 {
|
||||
lang::get(choice_description(opt, &settings.controller).unwrap());
|
||||
@@ -1074,7 +990,6 @@ mod tests {
|
||||
}
|
||||
ControllerScreen::default().side();
|
||||
lang::get(TITLE);
|
||||
lang::get(RADIAL_PAGES);
|
||||
for text in [
|
||||
"Press the button for this. %s cancels.",
|
||||
"This button also does: %s",
|
||||
|
||||
@@ -166,24 +166,6 @@ pub struct Extras {
|
||||
/// built-in track and leaving the off ones out (`musicPage`, off by
|
||||
/// default: vanilla plays exactly as before).
|
||||
pub music_page: bool,
|
||||
/// Cosmetics (`horizon_cosmetics`, off unless enabled), the lines `horizon_cosmetics::prefs`
|
||||
/// shares with the launcher's Wardrobe: Show my cosmetics (the account's, sent to the service;
|
||||
/// on by default), how much of other players' to show (no line until chosen:
|
||||
/// `effective_others`), and whether their trails and auras are hidden (off by default).
|
||||
pub cosmetics_mine: bool,
|
||||
pub cosmetics_others: Option<horizon_cosmetics::prefs::Others>,
|
||||
pub cosmetics_hide_fx: bool,
|
||||
/// My cosmetics in single player too (`cosmeticsSolo`, off by default): the window's own server
|
||||
/// names the player by their offline profile, which the service doesn't know, so their own
|
||||
/// cosmetics and name tag style show only with this on.
|
||||
pub cosmetics_solo: bool,
|
||||
/// Whether the signed-in account owns a cosmetic, as the service said (never saved; the game
|
||||
/// sets it): with no choice made, others' cosmetics show only then.
|
||||
pub cosmetics_owner: bool,
|
||||
/// Horizon's mark before the names of other Horizon players (name tags, player list), and
|
||||
/// whether the service may say this player plays Horizon: both on by default.
|
||||
pub horizon_marks: bool,
|
||||
pub horizon_listed: bool,
|
||||
/// 3D skin layers (`threeDSkinLayers`, Options → Skin Customization): each
|
||||
/// painted texel of a player's outer layer drawn as a slab
|
||||
/// (`horizon_client::mobs::skin_layers`).
|
||||
@@ -238,13 +220,6 @@ impl Default for Extras {
|
||||
wheel_cycles_settings: false,
|
||||
music_off: Vec::new(),
|
||||
music_page: false,
|
||||
cosmetics_mine: true,
|
||||
cosmetics_others: None,
|
||||
cosmetics_hide_fx: false,
|
||||
cosmetics_solo: false,
|
||||
cosmetics_owner: false,
|
||||
horizon_marks: true,
|
||||
horizon_listed: true,
|
||||
three_d_skin_layers: false,
|
||||
pvp: Default::default(),
|
||||
}
|
||||
@@ -254,11 +229,6 @@ fn bool_text(v: bool) -> &'static str {
|
||||
if v { "true" } else { "false" }
|
||||
}
|
||||
impl Extras {
|
||||
/// How much of other players' cosmetics shows: the player's choice, else all of it for an
|
||||
/// account that owns a cosmetic and none for any other (a vanilla game).
|
||||
pub fn effective_others(&self) -> horizon_cosmetics::prefs::Others {
|
||||
horizon_cosmetics::prefs::Others::effective(self.cosmetics_others, self.cosmetics_owner)
|
||||
}
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
fs::read_to_string(dir.join(FILE))
|
||||
.map(|t| Self::parse(&t))
|
||||
@@ -353,15 +323,6 @@ impl Extras {
|
||||
.collect()
|
||||
}
|
||||
"musicPage" => e.music_page = flag(e.music_page),
|
||||
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
|
||||
"cosmeticsOthers" => {
|
||||
e.cosmetics_others =
|
||||
horizon_cosmetics::prefs::Others::parse(value).or(e.cosmetics_others)
|
||||
}
|
||||
"cosmeticsHideFx" => e.cosmetics_hide_fx = flag(e.cosmetics_hide_fx),
|
||||
"cosmeticsSolo" => e.cosmetics_solo = flag(e.cosmetics_solo),
|
||||
horizon_cosmetics::prefs::MARKS => e.horizon_marks = flag(e.horizon_marks),
|
||||
horizon_cosmetics::prefs::LISTED => e.horizon_listed = flag(e.horizon_listed),
|
||||
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
|
||||
key => {
|
||||
e.pvp.parse_line(key, value);
|
||||
@@ -372,7 +333,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
|
||||
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\n{}:{}\n",
|
||||
bool_text(self.pack_browser),
|
||||
bool_text(self.pregen),
|
||||
bool_text(self.fullbright),
|
||||
@@ -421,16 +382,6 @@ impl Extras {
|
||||
bool_text(self.wheel_cycles_settings),
|
||||
self.music_off.join(","),
|
||||
bool_text(self.music_page),
|
||||
bool_text(self.cosmetics_mine),
|
||||
// Written once the player chose.
|
||||
self.cosmetics_others
|
||||
.map_or(String::new(), |o| format!("cosmeticsOthers:{}\n", o.name())),
|
||||
bool_text(self.cosmetics_hide_fx),
|
||||
bool_text(self.cosmetics_solo),
|
||||
horizon_cosmetics::prefs::MARKS,
|
||||
bool_text(self.horizon_marks),
|
||||
horizon_cosmetics::prefs::LISTED,
|
||||
bool_text(self.horizon_listed),
|
||||
SKIN_LAYERS_KEY,
|
||||
bool_text(self.three_d_skin_layers),
|
||||
);
|
||||
@@ -616,13 +567,6 @@ pub enum Item {
|
||||
/// The Select Resource Packs screen's Add button (Modrinth).
|
||||
PackBrowser,
|
||||
Pregen,
|
||||
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
|
||||
CosmeticsMine,
|
||||
CosmeticsOthers,
|
||||
CosmeticsHideFx,
|
||||
CosmeticsSolo,
|
||||
HorizonMarks,
|
||||
HorizonMe,
|
||||
/// On the PvP & HUD page (`pvp::rows`).
|
||||
Pvp(crate::pvp::Opt),
|
||||
}
|
||||
@@ -630,8 +574,10 @@ pub enum Item {
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
use Item::*;
|
||||
// Fullbright, Dynamic lights and Hide block-break particles change the picture: they
|
||||
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here.
|
||||
let mut rows = vec![
|
||||
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here. The Cosmetics
|
||||
// group comes last, from the cosmetics' own section (`pages::Place::Extras`) while they are
|
||||
// offered.
|
||||
vec![
|
||||
Err("horizon.extras.header.third_person"),
|
||||
Ok(OwnNameTag),
|
||||
Err("horizon.extras.header.inventory"),
|
||||
@@ -652,19 +598,7 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Err("horizon.extras.header.menus"),
|
||||
Ok(WheelCyclesSettings),
|
||||
Ok(PackBrowser),
|
||||
];
|
||||
if horizon_cosmetics::enabled() {
|
||||
rows.extend([
|
||||
Err("horizon.extras.header.cosmetics"),
|
||||
Ok(CosmeticsMine),
|
||||
Ok(CosmeticsSolo),
|
||||
Ok(CosmeticsOthers),
|
||||
Ok(CosmeticsHideFx),
|
||||
Ok(HorizonMarks),
|
||||
Ok(HorizonMe),
|
||||
]);
|
||||
}
|
||||
rows
|
||||
]
|
||||
}
|
||||
impl Item {
|
||||
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
|
||||
@@ -690,12 +624,6 @@ impl Item {
|
||||
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
|
||||
Item::PackBrowser => "horizon.extras.pack_browser",
|
||||
Item::Pregen => "horizon.extras.pregen",
|
||||
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
|
||||
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
|
||||
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx",
|
||||
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo",
|
||||
Item::HorizonMarks => "horizon.extras.horizon_marks",
|
||||
Item::HorizonMe => "horizon.extras.horizon_me",
|
||||
Item::Pvp(o) => o.caption_key(),
|
||||
}
|
||||
}
|
||||
@@ -722,12 +650,6 @@ impl Item {
|
||||
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_list_settings.tooltip",
|
||||
Item::PackBrowser => "horizon.extras.pack_browser.tooltip",
|
||||
Item::Pregen => "horizon.extras.pregen.tooltip",
|
||||
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
|
||||
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
|
||||
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx.tooltip",
|
||||
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo.tooltip",
|
||||
Item::HorizonMarks => "horizon.extras.horizon_marks.tooltip",
|
||||
Item::HorizonMe => "horizon.extras.horizon_me.tooltip",
|
||||
Item::Pvp(o) => o.description_key(),
|
||||
}
|
||||
}
|
||||
@@ -763,12 +685,6 @@ impl Item {
|
||||
Item::WheelCyclesSettings => "Scroll wheel changes settings",
|
||||
Item::PackBrowser => "Resource pack browser",
|
||||
Item::Pregen => "Chunk pregeneration",
|
||||
Item::CosmeticsMine => "My cosmetics",
|
||||
Item::CosmeticsOthers => "Other players' cosmetics",
|
||||
Item::CosmeticsHideFx => "Hide other players' trails and auras",
|
||||
Item::CosmeticsSolo => "In single player",
|
||||
Item::HorizonMarks => "Horizon logos",
|
||||
Item::HorizonMe => "Show me as Horizon",
|
||||
Item::Pvp(o) => o.caption(),
|
||||
}
|
||||
}
|
||||
@@ -783,8 +699,7 @@ impl Item {
|
||||
Pvp(o) => return o.parent().map(Pvp),
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
|
||||
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
|
||||
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
|
||||
| WheelCyclesSettings | MusicPage | PackBrowser | Pregen => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -842,24 +757,6 @@ impl Item {
|
||||
Item::ReproKeys => {
|
||||
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
|
||||
}
|
||||
Item::CosmeticsMine => {
|
||||
"Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe."
|
||||
}
|
||||
Item::CosmeticsOthers => {
|
||||
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear."
|
||||
}
|
||||
Item::CosmeticsHideFx => {
|
||||
"Other players' trails and auras stay hidden; their other cosmetics still show. Off by default."
|
||||
}
|
||||
Item::CosmeticsSolo => {
|
||||
"Show your cosmetics and your name tag's badge in your single-player worlds too (your name tag with Own name tag). Off: only on servers."
|
||||
}
|
||||
Item::HorizonMarks => {
|
||||
"Show the Horizon logo next to Horizon players: a small logo before the names of other players who use Horizon, above their heads and in the player list. Only Horizon players see it: servers receive nothing."
|
||||
}
|
||||
Item::HorizonMe => {
|
||||
"Show me as a Horizon player: other Horizon players see the logo by your name. On, the service answers anyone who asks about you that you played Horizon in the last 3 hours; off, it never reports you."
|
||||
}
|
||||
Item::Pvp(o) => o.description(),
|
||||
Item::WheelCyclesSettings => {
|
||||
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
|
||||
@@ -886,12 +783,6 @@ impl Item {
|
||||
| Item::Splitscreen
|
||||
| Item::WheelCyclesSettings
|
||||
| Item::PackBrowser => Some(Cost::None),
|
||||
Item::CosmeticsMine
|
||||
| Item::CosmeticsOthers
|
||||
| Item::CosmeticsHideFx
|
||||
| Item::CosmeticsSolo
|
||||
| Item::HorizonMarks
|
||||
| Item::HorizonMe => Some(Cost::Low),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -917,10 +808,6 @@ impl Item {
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings,
|
||||
Item::PackBrowser => e.pack_browser,
|
||||
Item::Pregen => e.pregen,
|
||||
Item::CosmeticsHideFx => e.cosmetics_hide_fx,
|
||||
Item::CosmeticsSolo => e.cosmetics_solo,
|
||||
Item::HorizonMarks => e.horizon_marks,
|
||||
Item::HorizonMe => e.horizon_listed,
|
||||
Item::Pvp(o) => return o.switch(&e.pvp),
|
||||
_ => return None,
|
||||
})
|
||||
@@ -930,30 +817,14 @@ impl Item {
|
||||
match self {
|
||||
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
|
||||
Item::Pvp(o) if o.switch(&e.pvp).is_none() => o.value(&e.pvp).unwrap_or_default(),
|
||||
Item::CosmeticsMine => match e.cosmetics_mine {
|
||||
true => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
|
||||
false => crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden"),
|
||||
},
|
||||
Item::CosmeticsOthers => {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
match e.effective_others() {
|
||||
Others::Shown => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
|
||||
Others::NameTags => {
|
||||
crate::lang::get_or("horizon.extras.cosmetics.name_tags", "Name tags only")
|
||||
}
|
||||
Others::Hidden => {
|
||||
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
|
||||
}
|
||||
}
|
||||
}
|
||||
other => match other.switch(e) {
|
||||
Some(true) => crate::lang::get_or("On", "On"),
|
||||
_ => crate::lang::get_or("Off", "Off"),
|
||||
},
|
||||
}
|
||||
}
|
||||
/// A click (`back` for the right button: it turns the three-way cosmetics button the other
|
||||
/// way; every other item left is a switch, so both flip it). The sort key binds instead.
|
||||
/// A click (`back` for the right button: a PvP & HUD choice steps back; a switch flips
|
||||
/// either way). The sort key binds instead.
|
||||
pub fn cycle(self, e: &mut Extras, back: bool) {
|
||||
match self {
|
||||
Item::Fullbright => e.fullbright = !e.fullbright,
|
||||
@@ -975,16 +846,6 @@ impl Item {
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
|
||||
Item::PackBrowser => e.pack_browser = !e.pack_browser,
|
||||
Item::Pregen => e.pregen = !e.pregen,
|
||||
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
|
||||
Item::CosmeticsOthers => {
|
||||
// From what the button shows; the result is the player's choice from now on.
|
||||
let o = e.effective_others();
|
||||
e.cosmetics_others = Some(if back { o.next().next() } else { o.next() });
|
||||
}
|
||||
Item::CosmeticsHideFx => e.cosmetics_hide_fx = !e.cosmetics_hide_fx,
|
||||
Item::CosmeticsSolo => e.cosmetics_solo = !e.cosmetics_solo,
|
||||
Item::HorizonMarks => e.horizon_marks = !e.horizon_marks,
|
||||
Item::HorizonMe => e.horizon_listed = !e.horizon_listed,
|
||||
Item::Pvp(o) => o.cycle(&mut e.pvp, back),
|
||||
Item::SortKey => {}
|
||||
}
|
||||
@@ -1003,73 +864,6 @@ impl Item {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn other_players_cosmetics_cycle_three_ways_and_read_the_old_switch() {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
let mut e = Extras {
|
||||
cosmetics_owner: true,
|
||||
..Extras::default()
|
||||
};
|
||||
assert_eq!(
|
||||
(e.effective_others(), e.cosmetics_hide_fx, e.cosmetics_mine),
|
||||
(Others::Shown, false, true)
|
||||
);
|
||||
Item::CosmeticsOthers.cycle(&mut e, false);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
|
||||
Item::CosmeticsOthers.cycle(&mut e, false);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::Hidden));
|
||||
// The right button goes back.
|
||||
Item::CosmeticsOthers.cycle(&mut e, true);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
|
||||
// Ownership is the service's, never written.
|
||||
assert_eq!(
|
||||
Extras::parse(&e.serialize()),
|
||||
Extras {
|
||||
cosmetics_owner: false,
|
||||
..e.clone()
|
||||
}
|
||||
);
|
||||
// The launcher's Wardrobe writes the same lines.
|
||||
assert_eq!(
|
||||
horizon_cosmetics::prefs::Prefs::parse(&e.serialize()).others,
|
||||
Some(Others::NameTags)
|
||||
);
|
||||
// Before this, the line was on or off.
|
||||
assert_eq!(
|
||||
Extras::parse("cosmeticsOthers:false").cosmetics_others,
|
||||
Some(Others::Hidden)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn others_cosmetics_are_off_for_a_player_who_owns_none_until_chosen() {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
let mut e = Extras::default();
|
||||
// Nothing chosen, nothing owned: the button shows Hidden and no line is written.
|
||||
assert_eq!(e.effective_others(), Others::Hidden);
|
||||
assert_eq!(
|
||||
Item::CosmeticsOthers.value(&e),
|
||||
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
|
||||
);
|
||||
assert!(!e.serialize().contains("cosmeticsOthers"));
|
||||
// The service says the account owns one: shown, still not a choice.
|
||||
e.cosmetics_owner = true;
|
||||
assert_eq!(e.effective_others(), Others::Shown);
|
||||
assert!(!e.serialize().contains("cosmeticsOthers"));
|
||||
// A non-owner pressing the button goes on from Hidden, and that choice is kept.
|
||||
e.cosmetics_owner = false;
|
||||
Item::CosmeticsOthers.cycle(&mut e, false);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::Shown));
|
||||
let saved = Extras::parse(&e.serialize());
|
||||
assert_eq!(saved.effective_others(), Others::Shown);
|
||||
// An owner who chose Hidden keeps it.
|
||||
let hidden = Extras {
|
||||
cosmetics_owner: true,
|
||||
..Extras::parse("cosmeticsOthers:hidden\n")
|
||||
};
|
||||
assert_eq!(hidden.effective_others(), Others::Hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_wheel_setting_from_before_the_default_changed_is_dropped() {
|
||||
assert!(!Extras::parse("wheelCyclesSettings:true\n").wheel_cycles_settings);
|
||||
|
||||
@@ -206,14 +206,13 @@ impl Screen for ExtrasScreen {
|
||||
fn note_lines(&self) -> usize {
|
||||
NOTE_LINES
|
||||
}
|
||||
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
|
||||
/// The rows read the extras, the mods and the sections' rows.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
let mut added = Vec::new();
|
||||
for (_, section) in sections() {
|
||||
section.rows(&mut added);
|
||||
}
|
||||
let cosmetics = horizon_cosmetics::enabled();
|
||||
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
|
||||
shell::stamp_of(&(&s.extras, &s.mods, added))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
|
||||
let mut groups = vec![Group {
|
||||
@@ -321,22 +320,6 @@ mod tests {
|
||||
assert_ne!(s.extras.mouse_tweaks_wheel, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_way_cosmetics_row_turns_both_ways() {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
let mut s = Settings::default();
|
||||
let mut screen = ExtrasScreen;
|
||||
let row = ExtraRow::Item(Item::CosmeticsOthers);
|
||||
// Not chosen, nothing owned: it shows Hidden, and forward wraps to Shown.
|
||||
assert_eq!(s.extras.effective_others(), Others::Hidden);
|
||||
screen.press(&mut s, row, false);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
|
||||
screen.press(&mut s, row, true);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
|
||||
screen.press(&mut s, row, true);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
|
||||
}
|
||||
|
||||
/// The sort key binds instead of cycling; the wheel leaves it be.
|
||||
#[test]
|
||||
fn the_key_row_waits_for_a_key() {
|
||||
|
||||
@@ -74,7 +74,6 @@ pub use entity_batch::{
|
||||
WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY, WORLD_TEXT_POLYGON_OFFSET_OVERLAY, is_native_terrain,
|
||||
mark_oit,
|
||||
};
|
||||
use external::{ShadowCull, shadow_box, shadow_casters, shadow_runs};
|
||||
pub use view::{View, ViewSet};
|
||||
|
||||
/// Window.getAppropriateLineWidth, in framebuffer pixels.
|
||||
@@ -190,7 +189,6 @@ mod cull;
|
||||
mod dx12_compiler;
|
||||
mod end_sky;
|
||||
pub mod entity_skin;
|
||||
pub mod external;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod gpu_culling;
|
||||
@@ -1420,10 +1418,8 @@ pub struct Renderer {
|
||||
/// Per-frame scratch, kept to avoid allocating every frame.
|
||||
visible: Vec<resident::Visible>,
|
||||
visible_lods: Vec<lod::Visible>,
|
||||
/// The sections a shader pack's shadow camera sees (`external::ShadowView`).
|
||||
/// The sections a shader pack's shadow camera sees (`pack_post::ShadowView`).
|
||||
shadow_visible: Vec<resident::Visible>,
|
||||
/// The external shader pipeline world frames call (`external`); `None` draws vanilla's way.
|
||||
external: Option<Box<dyn external::ExternalPipeline>>,
|
||||
translucent_draws: Vec<(u64, usize, [u32; 5], (u32, Option<u32>))>,
|
||||
origins: Vec<[f32; 4]>,
|
||||
/// `DrawIndexedIndirectArgs` words: index count, instance count, first
|
||||
@@ -5294,7 +5290,6 @@ impl Renderer {
|
||||
solid_runs: page_runs::PageRuns::default(),
|
||||
phase2_runs: page_runs::PageRuns::default(),
|
||||
shadow_runs: page_runs::PageRuns::default(),
|
||||
external: None,
|
||||
block_entities: Default::default(),
|
||||
block_entity_section_revisions: Default::default(),
|
||||
block_entity_membership_revisions: Default::default(),
|
||||
@@ -5678,9 +5673,6 @@ impl Renderer {
|
||||
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
|
||||
}
|
||||
self.depth = Self::depth(&self.device, &self.config);
|
||||
if let Some(pipeline) = &mut self.external {
|
||||
pipeline.resize([width, height]);
|
||||
}
|
||||
}
|
||||
/// Whether sections are drawn as one index list per frame (`flat`).
|
||||
pub fn flat_draws(&self) -> bool {
|
||||
@@ -7297,19 +7289,12 @@ impl Renderer {
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
/// Sets the external shader pipeline world frames call (`external`), or none.
|
||||
pub fn set_external_pipeline(&mut self, pipeline: Option<Box<dyn external::ExternalPipeline>>) {
|
||||
self.external = pipeline;
|
||||
}
|
||||
/// Makes the running shader pack, of either kind, forget what it kept from earlier frames:
|
||||
/// the next frame draws as a freshly loaded pack's first. Nothing to do without a pack.
|
||||
pub fn reset_shader_pack_history(&mut self) {
|
||||
// A frame still on the submit thread uses the targets and readbacks replaced
|
||||
// here.
|
||||
self.land_frame();
|
||||
if let Some(pipeline) = &mut self.external {
|
||||
pipeline.reset_history();
|
||||
}
|
||||
if let Some((_, Some(post))) = &mut self.pack_post {
|
||||
post.reset_history(&self.device);
|
||||
}
|
||||
@@ -8644,13 +8629,6 @@ impl Renderer {
|
||||
// A shader pack's shadow pass draws the sections in its camera's box, all of them (the
|
||||
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul; for
|
||||
// an Iris-format pack, only those whose shadow can fall into the camera's view.
|
||||
let state = external::FrameState {
|
||||
frame,
|
||||
output: &output,
|
||||
};
|
||||
if let Some(pipeline) = self.external.as_mut().filter(|_| !frame.title) {
|
||||
pipeline.begin_frame(&state);
|
||||
}
|
||||
let shadow_view = self
|
||||
.pack_post
|
||||
.as_ref()
|
||||
@@ -8662,12 +8640,6 @@ impl Renderer {
|
||||
let native = self.native.as_ref().filter(|_| !frame.title)?;
|
||||
native.shadow.as_ref()?;
|
||||
native.frame.shadow_view(frame)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.external
|
||||
.as_ref()
|
||||
.filter(|_| !frame.title)?
|
||||
.shadow_request(&state)
|
||||
});
|
||||
// `LevelRenderer.extractSectionDrawGroups`' fade-in time, for the instances.
|
||||
let fade_ms = fade_millis(frame.chunk_fade);
|
||||
@@ -12799,6 +12771,61 @@ impl Cull for Frusta {
|
||||
self.0.contains(min, max) || self.1.is_some_and(|f| f.contains(min, max))
|
||||
}
|
||||
}
|
||||
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
|
||||
/// (`shadow_casters`): a box must meet all of them.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ShadowCull {
|
||||
pub light: Frustum,
|
||||
pub casters: Option<[Frustum; 2]>,
|
||||
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
|
||||
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
|
||||
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
|
||||
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
|
||||
pub reach: Option<f32>,
|
||||
}
|
||||
impl ShadowCull {
|
||||
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
|
||||
/// within) `reach`'s cube.
|
||||
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
|
||||
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.reach.is_none_or(|d| {
|
||||
if whole {
|
||||
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
|
||||
} else {
|
||||
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
|
||||
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
|
||||
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
|
||||
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
|
||||
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
|
||||
/// the wedge lose a column of terrain.
|
||||
const SHADOW_CASTER_MARGIN: f32 = 1.125;
|
||||
impl Cull for ShadowCull {
|
||||
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
|
||||
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.within_reach(min, max, false)
|
||||
&& self.light.intersects(min, max)
|
||||
&& self
|
||||
.casters
|
||||
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
|
||||
}
|
||||
fn contains(&self, min: Vec3, max: Vec3) -> bool {
|
||||
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.within_reach(min, max, true)
|
||||
&& self.light.contains(min, max)
|
||||
&& self
|
||||
.casters
|
||||
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
|
||||
}
|
||||
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
|
||||
fn tight(&self) -> bool {
|
||||
self.casters.is_none()
|
||||
}
|
||||
}
|
||||
/// The block entities of `sections` whose box, a block wider on every side, comes within the
|
||||
/// 64 blocks block entities are drawn to (`Renderer::visible_block_entities_in_frustum`).
|
||||
fn block_entities_within_reach<'a>(
|
||||
@@ -12821,6 +12848,17 @@ fn block_entities_within_reach<'a>(
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
|
||||
/// its near and far planes only with `depth_planes`.
|
||||
fn shadow_box(view: &pack_post::ShadowView) -> Frustum {
|
||||
let mut planes = frustum(view.cull);
|
||||
if !view.depth_planes {
|
||||
// `frustum`'s faces at depth 0 and 1 take in everything.
|
||||
planes[4] = Vec4::W;
|
||||
planes[5] = Vec4::W;
|
||||
}
|
||||
Frustum { planes }
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
@@ -12944,6 +12982,21 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
|
||||
let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w);
|
||||
Some((a.inverse() * -b).as_vec3())
|
||||
}
|
||||
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
|
||||
/// distance, or everywhere for `every_face`; else the quads that can face the light.
|
||||
fn shadow_runs(
|
||||
sides: &Sides,
|
||||
view: &pack_post::ShadowView,
|
||||
origin: [f32; 3],
|
||||
draw: impl FnMut(u32, u32),
|
||||
) {
|
||||
let unculled = view.unculled.is_some_and(|d| {
|
||||
let o = Vec3::from(origin) + 8.0;
|
||||
o.x.abs().max(o.z.abs()) <= d + 8.0
|
||||
});
|
||||
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
|
||||
facing_runs(sides, toward, origin, draw);
|
||||
}
|
||||
|
||||
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
|
||||
/// face `eye` (`projection_center`), the mesh at the camera-relative
|
||||
@@ -12970,6 +13023,68 @@ fn facing_runs_from(
|
||||
None => sides.every(part, draw),
|
||||
}
|
||||
}
|
||||
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
|
||||
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
|
||||
/// view. Its planes are the view's faces turned toward the light, and through each edge between
|
||||
/// such a face and one turned away, the plane along the light; unused ones take in everything.
|
||||
fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
|
||||
let faces = frustum(view_proj);
|
||||
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
|
||||
let inverse = view_proj.inverse();
|
||||
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
|
||||
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
|
||||
let corner = |c: [usize; 3]| {
|
||||
inverse.project_point3(Vec3::new(
|
||||
c[0] as f32 * 2.0 - 1.0,
|
||||
c[1] as f32 * 2.0 - 1.0,
|
||||
c[2] as f32,
|
||||
))
|
||||
};
|
||||
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
|
||||
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
|
||||
let mut planes = [Vec4::W; 12];
|
||||
let mut n = 0;
|
||||
for (k, &face) in faces.iter().enumerate() {
|
||||
if toward[k] {
|
||||
planes[n] = face;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for a in &corners {
|
||||
for axis in 0..3 {
|
||||
if a[axis] == 1 {
|
||||
continue;
|
||||
}
|
||||
let mut b = *a;
|
||||
b[axis] = 1;
|
||||
// The edge from a to b lies on the faces of the two other axes.
|
||||
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
|
||||
if toward[f] == toward[g] {
|
||||
continue;
|
||||
}
|
||||
let (p, q) = (corner(*a), corner(b));
|
||||
let normal = (q - p).cross(light);
|
||||
if normal.length_squared() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let normal = normal.normalize();
|
||||
let side = if normal.dot(centre - p) < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
// At most six such edges with at most four faces toward the light: 12 planes.
|
||||
if let Some(plane) = planes.get_mut(n) {
|
||||
*plane = (normal * side).extend(-normal.dot(p) * side);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let [a, b] = [0, 6].map(|i| Frustum {
|
||||
planes: planes[i..i + 6].try_into().expect("six planes"),
|
||||
});
|
||||
[a, b]
|
||||
}
|
||||
fn taa_frustum(view_proj: Mat4, rendered_size: [u32; 2]) -> Frustum {
|
||||
// TAA shifts clip x/y by at most half a rendered pixel times clip w.
|
||||
// Widen x/y before extracting the planes: this includes every such shifted
|
||||
@@ -15268,7 +15383,7 @@ mod shadow_face_tests {
|
||||
}
|
||||
|
||||
/// The layers (faces) of the quads the shadow pass draws of `solid`.
|
||||
fn drawn(view: &external::ShadowView, origin: [f32; 3]) -> Vec<u16> {
|
||||
fn drawn(view: &pack_post::ShadowView, origin: [f32; 3]) -> Vec<u16> {
|
||||
let mut solid = block([7.0, 7.0, 7.0]);
|
||||
let sides = crate::mesher::sort_tinted(&mut solid, &mut Vec::new());
|
||||
let mut layers = Vec::new();
|
||||
@@ -15281,8 +15396,8 @@ mod shadow_face_tests {
|
||||
layers
|
||||
}
|
||||
|
||||
fn view(every_face: bool) -> external::ShadowView {
|
||||
external::ShadowView {
|
||||
fn view(every_face: bool) -> pack_post::ShadowView {
|
||||
pack_post::ShadowView {
|
||||
cull: Mat4::IDENTITY,
|
||||
light: Vec3::Y,
|
||||
distance: None,
|
||||
@@ -15496,7 +15611,7 @@ mod shadow_caster_tests {
|
||||
..horizon_shaders::Shadow::default()
|
||||
};
|
||||
let camera = DVec3::new(13.5, y + 1.62, -13.5);
|
||||
let view = external::ShadowView {
|
||||
let view = pack_post::ShadowView {
|
||||
cull: pack_uniforms::shadow_culling_at(Vec3::Y, camera, &shadow),
|
||||
light: Vec3::Y,
|
||||
distance: None,
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
//! The hook an external shader pipeline fills (#14035, layer plan M6). A shader pack's runtime
|
||||
//! (a platform crate, later) implements [`ExternalPipeline`]; the renderer calls it at fixed
|
||||
//! points of a world frame and draws vanilla's way wherever it doesn't answer. With none set
|
||||
//! (`Renderer::set_external_pipeline`), each point is one `Option` test.
|
||||
//!
|
||||
//! Workspace-internal: the trait names the engine's frame, its mesh format and its pass order,
|
||||
//! has one implementer in this workspace, carries no stability promise and is never exposed to
|
||||
//! mods. A mesher or pass change updates its implementer in the same PR. HDR output
|
||||
//! (`output.rs`, `frame_policy`) is not an implementer: it stays the one output stage after the
|
||||
//! frame, and a pipeline draws into the frame it gives.
|
||||
//!
|
||||
//! Shadows are an engine frame point, on request: a pipeline that wants a shadow pass answers
|
||||
//! [`ShadowView`], the engine culls the sections from it ([`ShadowCull`]) and hands back the
|
||||
//! terrain seen from the light. Vanilla's path never asks, so it never computes shadow
|
||||
//! visibility.
|
||||
|
||||
use glam::{Mat4, Vec3, Vec4};
|
||||
|
||||
use super::{Cull, EffectiveOutput, Frame, Frustum, Sides, facing_runs, frustum};
|
||||
|
||||
/// What a world frame gives an external pipeline at its points: the frame's own values, and
|
||||
/// the display's from the renderer's one output snapshot (`EffectiveOutput`: whether HDR is on,
|
||||
/// paper white, peak), never worked out again, so each split-screen window keeps its own.
|
||||
pub struct FrameState<'a> {
|
||||
pub frame: &'a Frame<'a>,
|
||||
pub output: &'a EffectiveOutput,
|
||||
}
|
||||
|
||||
/// An external shader pipeline, called by the renderer at fixed points of a world frame (not
|
||||
/// title frames). Every method has a default that leaves vanilla's way in place.
|
||||
pub trait ExternalPipeline: Send {
|
||||
/// A world frame starts, before culling.
|
||||
fn begin_frame(&mut self, _state: &FrameState<'_>) {}
|
||||
/// The shadow pass this frame wants; `None` draws none.
|
||||
fn shadow_request(&self, _state: &FrameState<'_>) -> Option<ShadowView> {
|
||||
None
|
||||
}
|
||||
/// The window's drawable size changed.
|
||||
fn resize(&mut self, _size: [u32; 2]) {}
|
||||
/// Forget what was kept from earlier frames: the next is drawn as a fresh pipeline's first.
|
||||
fn reset_history(&mut self) {}
|
||||
}
|
||||
|
||||
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
|
||||
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
|
||||
/// terrain is drawn (`shadowDistanceRenderMul`).
|
||||
pub struct ShadowView {
|
||||
pub cull: glam::Mat4,
|
||||
pub light: glam::Vec3,
|
||||
pub distance: Option<f32>,
|
||||
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
|
||||
/// packs that voxelize the world in their shadow pass need all of it.
|
||||
pub unculled: Option<f32>,
|
||||
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
|
||||
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
|
||||
/// a cave's ceiling, which faces away, is often all that shadows the cave.
|
||||
pub every_face: bool,
|
||||
/// Only the sections whose shadow can fall into the camera's view: those the camera's
|
||||
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
|
||||
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
|
||||
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
|
||||
pub view_casters: bool,
|
||||
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
|
||||
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
|
||||
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
|
||||
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
|
||||
pub depth_planes: bool,
|
||||
}
|
||||
|
||||
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
|
||||
/// (`shadow_casters`): a box must meet all of them.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ShadowCull {
|
||||
pub light: Frustum,
|
||||
pub casters: Option<[Frustum; 2]>,
|
||||
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
|
||||
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
|
||||
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
|
||||
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
|
||||
pub reach: Option<f32>,
|
||||
}
|
||||
impl ShadowCull {
|
||||
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
|
||||
/// within) `reach`'s cube.
|
||||
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
|
||||
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.reach.is_none_or(|d| {
|
||||
if whole {
|
||||
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
|
||||
} else {
|
||||
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
|
||||
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
|
||||
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
|
||||
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
|
||||
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
|
||||
/// the wedge lose a column of terrain.
|
||||
const SHADOW_CASTER_MARGIN: f32 = 1.125;
|
||||
impl Cull for ShadowCull {
|
||||
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
|
||||
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.within_reach(min, max, false)
|
||||
&& self.light.intersects(min, max)
|
||||
&& self
|
||||
.casters
|
||||
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
|
||||
}
|
||||
fn contains(&self, min: Vec3, max: Vec3) -> bool {
|
||||
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.within_reach(min, max, true)
|
||||
&& self.light.contains(min, max)
|
||||
&& self
|
||||
.casters
|
||||
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
|
||||
}
|
||||
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
|
||||
fn tight(&self) -> bool {
|
||||
self.casters.is_none()
|
||||
}
|
||||
}
|
||||
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
|
||||
/// its near and far planes only with `depth_planes`.
|
||||
pub(super) fn shadow_box(view: &ShadowView) -> Frustum {
|
||||
let mut planes = frustum(view.cull);
|
||||
if !view.depth_planes {
|
||||
// `frustum`'s faces at depth 0 and 1 take in everything.
|
||||
planes[4] = Vec4::W;
|
||||
planes[5] = Vec4::W;
|
||||
}
|
||||
Frustum { planes }
|
||||
}
|
||||
|
||||
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
|
||||
/// distance, or everywhere for `every_face`; else the quads that can face the light.
|
||||
pub(super) fn shadow_runs(
|
||||
sides: &Sides,
|
||||
view: &ShadowView,
|
||||
origin: [f32; 3],
|
||||
draw: impl FnMut(u32, u32),
|
||||
) {
|
||||
let unculled = view.unculled.is_some_and(|d| {
|
||||
let o = Vec3::from(origin) + 8.0;
|
||||
o.x.abs().max(o.z.abs()) <= d + 8.0
|
||||
});
|
||||
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
|
||||
facing_runs(sides, toward, origin, draw);
|
||||
}
|
||||
|
||||
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
|
||||
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
|
||||
/// view. Its planes are the view's faces turned toward the light, and through each edge between
|
||||
/// such a face and one turned away, the plane along the light; unused ones take in everything.
|
||||
pub(super) fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
|
||||
let faces = frustum(view_proj);
|
||||
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
|
||||
let inverse = view_proj.inverse();
|
||||
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
|
||||
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
|
||||
let corner = |c: [usize; 3]| {
|
||||
inverse.project_point3(Vec3::new(
|
||||
c[0] as f32 * 2.0 - 1.0,
|
||||
c[1] as f32 * 2.0 - 1.0,
|
||||
c[2] as f32,
|
||||
))
|
||||
};
|
||||
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
|
||||
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
|
||||
let mut planes = [Vec4::W; 12];
|
||||
let mut n = 0;
|
||||
for (k, &face) in faces.iter().enumerate() {
|
||||
if toward[k] {
|
||||
planes[n] = face;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for a in &corners {
|
||||
for axis in 0..3 {
|
||||
if a[axis] == 1 {
|
||||
continue;
|
||||
}
|
||||
let mut b = *a;
|
||||
b[axis] = 1;
|
||||
// The edge from a to b lies on the faces of the two other axes.
|
||||
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
|
||||
if toward[f] == toward[g] {
|
||||
continue;
|
||||
}
|
||||
let (p, q) = (corner(*a), corner(b));
|
||||
let normal = (q - p).cross(light);
|
||||
if normal.length_squared() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let normal = normal.normalize();
|
||||
let side = if normal.dot(centre - p) < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
// At most six such edges with at most four faces toward the light: 12 planes.
|
||||
if let Some(plane) = planes.get_mut(n) {
|
||||
*plane = (normal * side).extend(-normal.dot(p) * side);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let [a, b] = [0, 6].map(|i| Frustum {
|
||||
planes: planes[i..i + 6].try_into().expect("six planes"),
|
||||
});
|
||||
[a, b]
|
||||
}
|
||||
@@ -1201,7 +1201,31 @@ pub(super) type TerrainDraw<'a> = dyn FnMut(
|
||||
Option<(u32, &wgpu::BindGroupLayout)>,
|
||||
) + 'a;
|
||||
|
||||
pub(super) use super::external::ShadowView;
|
||||
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
|
||||
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
|
||||
/// terrain is drawn (`shadowDistanceRenderMul`).
|
||||
pub(super) struct ShadowView {
|
||||
pub cull: glam::Mat4,
|
||||
pub light: glam::Vec3,
|
||||
pub distance: Option<f32>,
|
||||
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
|
||||
/// packs that voxelize the world in their shadow pass need all of it.
|
||||
pub unculled: Option<f32>,
|
||||
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
|
||||
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
|
||||
/// a cave's ceiling, which faces away, is often all that shadows the cave.
|
||||
pub every_face: bool,
|
||||
/// Only the sections whose shadow can fall into the camera's view: those the camera's
|
||||
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
|
||||
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
|
||||
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
|
||||
pub view_casters: bool,
|
||||
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
|
||||
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
|
||||
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
|
||||
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
|
||||
pub depth_planes: bool,
|
||||
}
|
||||
|
||||
/// An arena page's key, native vertices and optional per-quad source records.
|
||||
pub(super) type TerrainPage = (u32, wgpu::Buffer, Option<wgpu::Buffer>);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Room: what of a view Horizon's own HUD overlays may use. Vanilla's HUD elements are
|
||||
//! keep-outs; an overlay asks the room where it fits instead of assuming a fixed spot, so it
|
||||
//! doesn't cover them. The keep-outs: the boss bars at the top centre, and the status rows above
|
||||
//! the hotbar at the bottom centre.
|
||||
//! doesn't cover them. Today the only keep-out is the boss bars at the top centre.
|
||||
//!
|
||||
//! The room is built once a frame from what the HUD shows (`Game::hud_room` in gameplay). With
|
||||
//! nothing showing it is empty, and every overlay stays exactly where it always was.
|
||||
@@ -35,16 +34,6 @@ pub const GAP: f32 = 2.0;
|
||||
pub enum Keep {
|
||||
/// The boss bars and their names at the top centre (`hud_overlays::boss_bars_bottom`).
|
||||
BossBars,
|
||||
/// The survival rows above the hotbar at the bottom centre: health with its absorption rows,
|
||||
/// armour, food or the mount's hearts, and air; from the highest row's top to the view's
|
||||
/// bottom.
|
||||
StatusRows,
|
||||
}
|
||||
impl Keep {
|
||||
/// It sits at the top of the view, not the bottom.
|
||||
fn at_top(self) -> bool {
|
||||
matches!(self, Self::BossBars)
|
||||
}
|
||||
}
|
||||
|
||||
/// A view and the keep-outs its HUD shows this frame.
|
||||
@@ -101,20 +90,10 @@ impl Room {
|
||||
/// bars' bottom, or 0 with none.
|
||||
pub fn top(&self) -> f32 {
|
||||
self.keeps()
|
||||
.filter(|(keep, _)| keep.at_top())
|
||||
.map(|(_, [_, y, _, h])| y + h)
|
||||
.map(|(Keep::BossBars, [_, y, _, h])| y + h)
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// How far, in GUI px from the view's top, the keep-outs at the bottom reach up: the status
|
||||
/// rows' top, or the view's height with none.
|
||||
pub fn bottom(&self) -> f32 {
|
||||
self.keeps()
|
||||
.filter(|(keep, _)| !keep.at_top())
|
||||
.map(|(_, [_, y, _, _])| y)
|
||||
.fold(self.height, f32::min)
|
||||
}
|
||||
|
||||
/// Where a centred overlay that wants its top at `wanted` (GUI px) goes: at `wanted` unless
|
||||
/// a keep-out reaches past it (`top() > wanted`, a real overlap; touching isn't one), else
|
||||
/// `GAP` below that keep-out.
|
||||
@@ -155,15 +134,4 @@ mod tests {
|
||||
assert_eq!(bars(36.0).below_top(18.0), 38.0);
|
||||
assert_eq!(bars(36.0).usable(), [0.0, 38.0, 500.0, 262.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_status_rows_are_kept_at_the_bottom_and_leave_the_top_alone() {
|
||||
let room = Room::new(427.0, 240.0);
|
||||
assert_eq!(room.bottom(), 240.0);
|
||||
// Hearts and armour from y 191 down.
|
||||
let rows = room.with(Keep::StatusRows, [122.0, 191.0, 182.0, 49.0]);
|
||||
assert_eq!(rows.bottom(), 191.0);
|
||||
assert_eq!(rows.top(), 0.0);
|
||||
assert_eq!(rows.below_top(18.0), 18.0);
|
||||
}
|
||||
}
|
||||
@@ -131,23 +131,6 @@ pub enum GuiDraw {
|
||||
|
||||
pub const WHITE: u32 = 0xFFFF_FFFF;
|
||||
|
||||
/// The highest top, in GUI px, of the draws that sit on the view (`None` for none): where a row
|
||||
/// of the HUD begins.
|
||||
pub fn draws_top(draws: &[GuiDraw]) -> Option<i32> {
|
||||
draws
|
||||
.iter()
|
||||
.filter_map(|d| match *d {
|
||||
GuiDraw::Sprite { y, .. }
|
||||
| GuiDraw::AtlasSprite { y, .. }
|
||||
| GuiDraw::Blit { y, .. }
|
||||
| GuiDraw::Text { y, .. }
|
||||
| GuiDraw::Item { y, .. } => Some(y),
|
||||
GuiDraw::Fill { y0, .. } => Some(y0),
|
||||
GuiDraw::Push(_) | GuiDraw::Pop => None,
|
||||
})
|
||||
.min()
|
||||
}
|
||||
|
||||
fn sprite(sprite: &'static str, x: i32, y: i32, w: i32, h: i32) -> GuiDraw {
|
||||
GuiDraw::Sprite {
|
||||
sprite: Cow::Borrowed(sprite),
|
||||
|
||||
@@ -583,9 +583,6 @@ pub const BOSS_BAR_STEP: i32 = 10 + 9;
|
||||
pub const BOSS_BAR_HEIGHT: i32 = 5;
|
||||
/// A boss bar's sprite width, in GUI px; it is centred on the view.
|
||||
pub const BOSS_BAR_WIDTH: i32 = 182;
|
||||
/// Half the hotbar's width, in GUI px (`Hud`'s `screenWidth / 2 - 91`): the status rows above it
|
||||
/// span the same.
|
||||
pub const HOTBAR_HALF_WIDTH: i32 = 91;
|
||||
|
||||
/// How many of `n` boss bars `BossHealthOverlay.extractRenderState` draws on a view `h` GUI px
|
||||
/// tall: it stops once the next bar's top reaches a third of the height (integer division,
|
||||
|
||||
@@ -110,8 +110,6 @@ pub mod preedit;
|
||||
pub mod providers;
|
||||
pub mod pvp;
|
||||
pub mod pvp_hud;
|
||||
pub mod radial_editor;
|
||||
pub mod radial_overlay;
|
||||
pub mod random_source;
|
||||
pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
@@ -172,7 +170,6 @@ pub use horizon_client_input::key_names;
|
||||
pub use horizon_client_input::keybinds;
|
||||
pub use horizon_client_input::keymapping;
|
||||
pub use horizon_client_input::mouse;
|
||||
pub use horizon_client_input::radial;
|
||||
pub use horizon_client_input::window;
|
||||
pub use horizon_command as command;
|
||||
pub use horizon_items::blocks;
|
||||
|
||||
@@ -58,9 +58,6 @@ pub struct OverlayColours {
|
||||
/// The main action, and its text.
|
||||
pub accent: u32,
|
||||
pub on_accent: u32,
|
||||
/// A lit choice and its bright edge (the controller dial's sector).
|
||||
pub lit: u32,
|
||||
pub lit_edge: u32,
|
||||
}
|
||||
|
||||
/// `ui_theme`'s type scale and shapes, in dp.
|
||||
@@ -112,8 +109,6 @@ pub const NEUTRAL: MenuStyle = MenuStyle {
|
||||
rule: 0x303030,
|
||||
accent: 0xffffff,
|
||||
on_accent: 0x000000,
|
||||
lit: 0xffffff,
|
||||
lit_edge: 0xffffff,
|
||||
},
|
||||
sizes: Sizes {
|
||||
caption: 12.0,
|
||||
|
||||
@@ -18,8 +18,6 @@ use crate::{
|
||||
ui,
|
||||
};
|
||||
|
||||
mod shell_pages;
|
||||
|
||||
fn tr(key: &str) -> Text {
|
||||
Text::translate(key, &[])
|
||||
}
|
||||
@@ -324,8 +322,6 @@ impl Opt {
|
||||
Opt::PadNintendoButtons => "horizon.options.nintendo_buttons",
|
||||
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
|
||||
Opt::PadGameplayGuide => "horizon.options.gameplay_guide",
|
||||
Opt::PadRadialMode => "horizon.options.radial_mode",
|
||||
Opt::PadRadialStick => "horizon.options.radial_stick",
|
||||
Opt::ModelPart(i) => [
|
||||
"options.modelPart.cape",
|
||||
"options.modelPart.jacket",
|
||||
@@ -884,7 +880,6 @@ impl Opt {
|
||||
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
|
||||
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
|
||||
}),
|
||||
Opt::PadRadialMode | Opt::PadRadialStick => self.radial_text(s),
|
||||
Opt::Page(page, row) => pages::slider_text(page, row),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
@@ -1206,7 +1201,6 @@ impl Opt {
|
||||
.unwrap_or(0) as u8;
|
||||
s.controller.keyboard = all[usize::from(next(i, 3))];
|
||||
}
|
||||
Opt::PadRadialMode | Opt::PadRadialStick => self.radial_step(s, back),
|
||||
_ => {}
|
||||
}
|
||||
if self.affects_preset() {
|
||||
@@ -1358,11 +1352,6 @@ pub enum Page {
|
||||
/// Horizon's controller settings and button binds.
|
||||
Controller,
|
||||
PadBinds,
|
||||
/// The radial menu's pages (`radial_editor`), on the settings shell, and a page's name.
|
||||
ControllerRadial,
|
||||
ControllerRadialName,
|
||||
/// The list of every action for one of its slots.
|
||||
ControllerRadialPick,
|
||||
Extras,
|
||||
/// PvP & HUD (`pvp`): the extras a PvP player sets, on their own page.
|
||||
Pvp,
|
||||
@@ -1694,10 +1683,6 @@ pub struct OptionsUi {
|
||||
controller: shell::Shell<crate::controller_ui::ControllerScreen>,
|
||||
/// Horizon Extras, on the settings shell.
|
||||
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
|
||||
/// The radial menu's pages, on the settings shell.
|
||||
radial: shell::Shell<crate::radial_editor::RadialScreen>,
|
||||
/// The list of actions for one of the radial menu's slots.
|
||||
radial_pick: shell::Shell<crate::radial_editor::PickScreen>,
|
||||
/// Shaders: the shader packs and the picked one's options, on the settings shell.
|
||||
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
|
||||
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
|
||||
@@ -1782,8 +1767,6 @@ impl Default for OptionsUi {
|
||||
graphics: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
radial: Default::default(),
|
||||
radial_pick: Default::default(),
|
||||
shader: Default::default(),
|
||||
pack_options: Default::default(),
|
||||
pack_configs: Default::default(),
|
||||
@@ -2129,7 +2112,6 @@ impl OptionsUi {
|
||||
.pack_screen
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.search_focused()),
|
||||
Page::ControllerRadialName => self.radial_name_typing(),
|
||||
_ => match self.shell() {
|
||||
Some(shell) => shell.typing(),
|
||||
None => self.settings_search_active(),
|
||||
@@ -2262,16 +2244,95 @@ impl OptionsUi {
|
||||
self.studio.refresh();
|
||||
}
|
||||
}
|
||||
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
|
||||
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
|
||||
/// through them, so no input path can miss one.
|
||||
fn shell(&self) -> Option<&dyn shell::Hosted> {
|
||||
match self.page() {
|
||||
Page::Extras => Some(&self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
|
||||
page if Self::controller_page(page).is_some() => Some(&self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&self.shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
|
||||
self.graphics.screen.output = self.output;
|
||||
match self.page() {
|
||||
Page::Extras => Some(&mut self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
|
||||
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A controller's or the keyboard's focus landed on `rect`: a row of a shell screen's list
|
||||
/// that is partly hidden scrolls all into view. How far it moved down, when it moved.
|
||||
pub fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32> {
|
||||
self.shell_mut()?.reveal(s, size, rect)
|
||||
}
|
||||
/// The window's size, for the graphics and controller screens' rows that step an option
|
||||
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
|
||||
fn shell_framebuffers(&mut self) {
|
||||
self.graphics.screen.framebuffer = self.framebuffer;
|
||||
self.controller.screen.framebuffer = self.framebuffer;
|
||||
}
|
||||
/// Who and what Controller Settings is about: the game sets it before each draw.
|
||||
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
|
||||
self.controller.screen.context = context;
|
||||
}
|
||||
/// The binding the screen waits on: a controller button's, else a key's.
|
||||
fn waiting(&self) -> Option<shell::Capture> {
|
||||
let pad = self.selected_pad_action.map(shell::Capture::Pad);
|
||||
pad.or(self.selected_key.map(shell::Capture::Key))
|
||||
}
|
||||
/// What a shell screen's outputs do to these options.
|
||||
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
for output in outputs {
|
||||
match output {
|
||||
shell::Output::Event(event) => events.push(event),
|
||||
shell::Output::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
|
||||
shell::Output::Bind(shell::Capture::Pad(action)) => {
|
||||
self.selected_pad_action = Some(action)
|
||||
}
|
||||
shell::Output::Push(page) => {
|
||||
if page == Page::HdrCalibration {
|
||||
self.calibration =
|
||||
Some(crate::hdr_calibration::Calibration::new(s, &self.output));
|
||||
}
|
||||
self.push(page);
|
||||
}
|
||||
shell::Output::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
/// Where the shader screen opens for `page`, if it is one of its pages.
|
||||
fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
|
||||
match page {
|
||||
Page::ShaderPacks => Some(crate::shader_ui::Tab::Packs),
|
||||
Page::ShaderOptions => Some(crate::shader_ui::Tab::Screen(0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Where the controller screen opens for `page`, if it is one of its pages.
|
||||
fn controller_page(page: Page) -> Option<crate::controller_ui::Page> {
|
||||
match page {
|
||||
Page::Controller => Some(crate::controller_ui::Page::General),
|
||||
Page::PadBinds => Some(crate::controller_ui::Page::Bindings),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Whether a video settings screen is open, Horizon's or vanilla's: the fullscreen mode
|
||||
/// waits for it to close, and Ctrl with the wheel steps the GUI scale.
|
||||
pub fn video_open(&self) -> bool {
|
||||
self.page() == Page::Video || Self::graphics_page(self.page()).is_some()
|
||||
}
|
||||
/// Whether the controller screen is open: a controller button pressed now may bind.
|
||||
pub fn controller_open(&self) -> bool {
|
||||
Self::controller_page(self.page()).is_some()
|
||||
}
|
||||
/// Where the graphics screen opens for `page`, if it is one of its pages.
|
||||
fn graphics_page(page: Page) -> Option<crate::graphics_ui::Page> {
|
||||
match page {
|
||||
@@ -2308,9 +2369,6 @@ impl OptionsUi {
|
||||
"options.videoTitle"
|
||||
}
|
||||
Page::Controller | Page::PadBinds => "horizon.pages.controller",
|
||||
Page::ControllerRadial => "horizon.pages.controller_radial",
|
||||
Page::ControllerRadialName => "horizon.radial.name_title",
|
||||
Page::ControllerRadialPick => "horizon.radial.pick_title",
|
||||
Page::Extras => "horizon.pages.extras",
|
||||
Page::Pvp => "horizon.pages.pvp",
|
||||
Page::Credits => "credits_and_attribution.screen.title",
|
||||
@@ -3544,9 +3602,6 @@ impl OptionsUi {
|
||||
}
|
||||
let waiting = self.waiting();
|
||||
let in_world = self.in_world;
|
||||
if self.draw_radial_name(batch, atlas, cursor, in_world) {
|
||||
return;
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.draw(s, batch, atlas, cursor, in_world, waiting);
|
||||
// The keys lay the screen out at the size it was drawn at.
|
||||
@@ -3898,9 +3953,6 @@ impl OptionsUi {
|
||||
let outcome = c.click(&atlas.font, (x, y), size, drag);
|
||||
return self.calibration_outcome(s, outcome);
|
||||
}
|
||||
if self.page() == Page::ControllerRadialName {
|
||||
return self.click_radial_name(s, atlas, (x, y), size, drag || right);
|
||||
}
|
||||
if self.shell().is_some() {
|
||||
// Waiting on a controller button: a click cancels the wait.
|
||||
if !drag && self.selected_pad_action.take().is_some() {
|
||||
@@ -4596,8 +4648,6 @@ impl OptionsUi {
|
||||
self.scroll = 0.0;
|
||||
} else if self.page() == Page::SplitProfiles {
|
||||
self.split_profiles.type_text(text);
|
||||
} else if self.page() == Page::ControllerRadialName {
|
||||
self.type_radial_name(text);
|
||||
} else if let Some(shell) = self.shell_mut() {
|
||||
if shell.typing() {
|
||||
shell.type_text(text);
|
||||
@@ -4633,9 +4683,6 @@ impl OptionsUi {
|
||||
if self.page() == Page::SplitProfiles {
|
||||
return self.split_profiles.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if self.page() == Page::ControllerRadialName {
|
||||
return self.radial_name_shortcut(shortcut, clipboard);
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
return shell.shortcut(shortcut, clipboard);
|
||||
}
|
||||
@@ -4739,9 +4786,6 @@ impl OptionsUi {
|
||||
if self.page() == Page::SplitProfiles && self.split_profiles.key(key, text) {
|
||||
return Vec::new();
|
||||
}
|
||||
if self.page() == Page::ControllerRadialName {
|
||||
return self.radial_name_key(s, key, text);
|
||||
}
|
||||
|
||||
// The size it was drawn at; before the first draw, one its keys can lay out at.
|
||||
let gui = if self.size.0 > 0.0 && self.size.1 > 0.0 {
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
//! The pages Options shows on the settings shell (`settings_shell`): which shell each page
|
||||
//! opens, what a shell's outputs do here, and the radial menu's pages (`radial_editor`), whose
|
||||
//! name entry is drawn here in place of a shell, and whose rows of Controller Settings step here.
|
||||
use super::{Event, OptionsUi, Page, tr};
|
||||
use crate::{
|
||||
gamepad,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
radial_editor::Named,
|
||||
settings::{Opt, Settings},
|
||||
settings_shell as shell,
|
||||
text::Text,
|
||||
};
|
||||
|
||||
impl OptionsUi {
|
||||
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
|
||||
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
|
||||
/// through them, so no input path can miss one.
|
||||
pub(super) fn shell(&self) -> Option<&dyn shell::Hosted> {
|
||||
match self.page() {
|
||||
Page::Extras => Some(&self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
|
||||
Page::ControllerRadial => Some(&self.radial),
|
||||
Page::ControllerRadialPick => Some(&self.radial_pick),
|
||||
page if Self::controller_page(page).is_some() => Some(&self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&self.shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
|
||||
self.graphics.screen.output = self.output;
|
||||
match self.page() {
|
||||
Page::Extras => Some(&mut self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
|
||||
Page::ControllerRadial => Some(&mut self.radial),
|
||||
Page::ControllerRadialPick => Some(&mut self.radial_pick),
|
||||
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Where the open shell screen asked the focus to open (a radial slot's list, on the slot's
|
||||
/// action), for a controller's focus to land there.
|
||||
pub fn shell_opened_focus(&self) -> Option<[f32; 4]> {
|
||||
self.shell()?.opened_focus()
|
||||
}
|
||||
/// The clicks until it is set back are a controller's Left or Right on a row: a choice of a
|
||||
/// settings shell steps, rather than opens.
|
||||
pub fn pad_clicks_step(&mut self, on: bool) {
|
||||
if on {
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.clicks_step(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Every shell: the click may have changed the page.
|
||||
let shells: [&mut dyn shell::Hosted; 6] = [
|
||||
&mut self.extras,
|
||||
&mut self.graphics,
|
||||
&mut self.controller,
|
||||
&mut self.radial,
|
||||
&mut self.radial_pick,
|
||||
&mut self.shader,
|
||||
];
|
||||
for shell in shells {
|
||||
shell.clicks_step(false);
|
||||
}
|
||||
}
|
||||
/// The controller's focus landed there.
|
||||
pub fn clear_shell_opened_focus(&mut self) {
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.clear_opened_focus();
|
||||
}
|
||||
}
|
||||
/// The window's size, for the graphics and controller screens' rows that step an option
|
||||
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
|
||||
pub(super) fn shell_framebuffers(&mut self) {
|
||||
self.graphics.screen.framebuffer = self.framebuffer;
|
||||
self.controller.screen.framebuffer = self.framebuffer;
|
||||
}
|
||||
/// Who and what Controller Settings is about: the game sets it before each draw.
|
||||
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
|
||||
self.controller.screen.context = context;
|
||||
}
|
||||
/// The binding the screen waits on: a controller button's, else a key's.
|
||||
pub(super) fn waiting(&self) -> Option<shell::Capture> {
|
||||
let pad = self.selected_pad_action.map(shell::Capture::Pad);
|
||||
pad.or(self.selected_key.map(shell::Capture::Key))
|
||||
}
|
||||
/// What a shell screen's outputs do to these options.
|
||||
pub(super) fn shell_events(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
outputs: Vec<shell::Output>,
|
||||
) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
for output in outputs {
|
||||
match output {
|
||||
shell::Output::Event(event) => events.push(event),
|
||||
shell::Output::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
|
||||
shell::Output::Bind(shell::Capture::Pad(action)) => {
|
||||
self.selected_pad_action = Some(action)
|
||||
}
|
||||
shell::Output::Push(page) => {
|
||||
if page == Page::HdrCalibration {
|
||||
self.calibration =
|
||||
Some(crate::hdr_calibration::Calibration::new(s, &self.output));
|
||||
}
|
||||
self.push(page);
|
||||
// A slot's list, for the slot, opening on what it holds.
|
||||
if page == Page::ControllerRadialPick
|
||||
&& let Some(at) = self.radial.screen.picking.take()
|
||||
{
|
||||
let row = self.radial_pick.screen.open_on(at, &s.controller.radial);
|
||||
self.radial_pick.focus_soon(row);
|
||||
}
|
||||
}
|
||||
shell::Output::Done => {
|
||||
// Back from a slot's list, the focus is on the slot.
|
||||
let slot = (self.page() == Page::ControllerRadialPick)
|
||||
.then(|| self.radial_pick.screen.at());
|
||||
events.extend(self.back(s));
|
||||
if let Some((page, slot)) = slot {
|
||||
self.radial
|
||||
.focus_soon(crate::radial_editor::RadialRow::Slot(page, slot));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A pick on a slot's list is a change of the radial page's, which its Undo takes back.
|
||||
if self.radial_pick.screen.take_picked() {
|
||||
self.radial.changed_elsewhere();
|
||||
}
|
||||
// A radial page added or deleted: the one to show now.
|
||||
if let Some(page) = self.radial.screen.take_shown() {
|
||||
self.radial.show(page);
|
||||
}
|
||||
events
|
||||
}
|
||||
/// What a press on a radial page's name does here.
|
||||
pub(super) fn name_events(
|
||||
&mut self,
|
||||
named: crate::radial_editor::Named,
|
||||
s: &mut Settings,
|
||||
) -> Vec<Event> {
|
||||
use crate::radial_editor::Named;
|
||||
let mut events = vec![Event::ButtonClick];
|
||||
match named {
|
||||
Named::Nothing => return Vec::new(),
|
||||
Named::Done => {
|
||||
let entry = self.radial.screen.naming.take();
|
||||
if entry.is_some_and(|entry| entry.apply(&mut s.controller.radial)) {
|
||||
self.radial.changed_elsewhere();
|
||||
}
|
||||
events.push(Event::Changed);
|
||||
events.extend(self.back(s));
|
||||
}
|
||||
Named::Back => events.extend(self.back(s)),
|
||||
}
|
||||
events
|
||||
}
|
||||
/// Where the shader screen opens for `page`, if it is one of its pages.
|
||||
pub(super) fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
|
||||
match page {
|
||||
Page::ShaderPacks => Some(crate::shader_ui::Tab::Packs),
|
||||
Page::ShaderOptions => Some(crate::shader_ui::Tab::Screen(0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Where the controller screen opens for `page`, if it is one of its pages.
|
||||
pub(super) fn controller_page(page: Page) -> Option<crate::controller_ui::Page> {
|
||||
match page {
|
||||
Page::Controller => Some(crate::controller_ui::Page::General),
|
||||
Page::PadBinds => Some(crate::controller_ui::Page::Bindings),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Whether the controller screen is open: a controller button pressed now may bind.
|
||||
pub fn controller_open(&self) -> bool {
|
||||
Self::controller_page(self.page()).is_some()
|
||||
}
|
||||
/// The radial screens' glyphs this frame, and the name of a radial page being typed, drawn
|
||||
/// in place of a shell: whether it was.
|
||||
pub(super) fn draw_radial_name(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
) -> bool {
|
||||
self.radial.screen.glyphs = self.pad_glyphs;
|
||||
if self.page() == Page::ControllerRadialName
|
||||
&& let Some(entry) = &mut self.radial.screen.naming
|
||||
{
|
||||
entry.draw(batch, atlas, cursor, in_world);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
/// A click on a radial page's name entry (`ignored`: a drag or a right click).
|
||||
pub(super) fn click_radial_name(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
atlas: &HudAtlas,
|
||||
at: (f32, f32),
|
||||
size: (f32, f32),
|
||||
ignored: bool,
|
||||
) -> Vec<Event> {
|
||||
let entry = self.radial.screen.naming.as_mut();
|
||||
let named = match entry.filter(|_| !ignored) {
|
||||
Some(entry) => entry.click(atlas, at, size),
|
||||
None => Named::Nothing,
|
||||
};
|
||||
self.name_events(named, s)
|
||||
}
|
||||
/// Whether a radial page's name is being typed.
|
||||
pub(super) fn radial_name_typing(&self) -> bool {
|
||||
self.radial
|
||||
.screen
|
||||
.naming
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.typing())
|
||||
}
|
||||
/// Text typed into a radial page's name.
|
||||
pub(super) fn type_radial_name(&mut self, text: &str) {
|
||||
if let Some(entry) = &mut self.radial.screen.naming {
|
||||
entry.type_text(text);
|
||||
}
|
||||
}
|
||||
/// An edit shortcut in a radial page's name: whether it did something.
|
||||
pub(super) fn radial_name_shortcut(
|
||||
&mut self,
|
||||
shortcut: crate::clipboard::EditShortcut,
|
||||
clipboard: &mut dyn crate::clipboard::Clipboard,
|
||||
) -> bool {
|
||||
let entry = self.radial.screen.naming.as_mut();
|
||||
entry.is_some_and(|entry| entry.shortcut(shortcut, clipboard))
|
||||
}
|
||||
/// A key on a radial page's name entry.
|
||||
pub(super) fn radial_name_key(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
key: winit::keyboard::KeyCode,
|
||||
text: Option<&str>,
|
||||
) -> Vec<Event> {
|
||||
let named = match &mut self.radial.screen.naming {
|
||||
Some(entry) => entry.key(key, text),
|
||||
// Nothing to name: Escape still leaves.
|
||||
None if key == winit::keyboard::KeyCode::Escape => Named::Back,
|
||||
None => Named::Nothing,
|
||||
};
|
||||
self.name_events(named, s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Opt {
|
||||
/// The radial menu's rows of Controller Settings: their value.
|
||||
pub(super) fn radial_text(self, s: &Settings) -> Text {
|
||||
match self {
|
||||
Opt::PadRadialMode => tr(match s.controller.radial_mode {
|
||||
gamepad::RadialMode::Hold => "options.key.hold",
|
||||
gamepad::RadialMode::Toggle => "options.key.toggle",
|
||||
}),
|
||||
Opt::PadRadialStick => tr(match s.controller.radial_stick {
|
||||
gamepad::RadialStick::Right => "horizon.options.radial_stick.right",
|
||||
gamepad::RadialStick::Left => "horizon.options.radial_stick.left",
|
||||
gamepad::RadialStick::Either => "horizon.options.radial_stick.either",
|
||||
}),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
}
|
||||
/// The radial menu's rows of Controller Settings: a step, back or on.
|
||||
pub(super) fn radial_step(self, s: &mut Settings, back: bool) {
|
||||
let next = |n: u8, count: u8| {
|
||||
if back {
|
||||
(n + count - 1) % count
|
||||
} else {
|
||||
(n + 1) % count
|
||||
}
|
||||
};
|
||||
match self {
|
||||
Opt::PadRadialMode => {
|
||||
let all = gamepad::RadialMode::ALL;
|
||||
let i = all
|
||||
.iter()
|
||||
.position(|m| *m == s.controller.radial_mode)
|
||||
.unwrap_or(0) as u8;
|
||||
s.controller.radial_mode = all[usize::from(next(i, all.len() as u8))];
|
||||
}
|
||||
Opt::PadRadialStick => {
|
||||
let all = gamepad::RadialStick::ALL;
|
||||
let i = all
|
||||
.iter()
|
||||
.position(|k| *k == s.controller.radial_stick)
|
||||
.unwrap_or(0) as u8;
|
||||
// An opener under the thumb that now selects goes unbound.
|
||||
s.controller
|
||||
.set_radial_stick(all[usize::from(next(i, all.len() as u8))]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,844 +0,0 @@
|
||||
//! The radial menu's pages in the controller settings (Horizon's own; `radial.rs` has their
|
||||
//! rules, `radial_overlay.rs` draws them in play), on the settings shell (`settings_shell`): a
|
||||
//! sheet per page with its eight slots, what changes the page, and what changes them all. A
|
||||
//! page's own name is typed on a small page of its own, [`NameEntry`].
|
||||
use crate::{
|
||||
gamepad::{self, GlyphOf, MenuJob},
|
||||
graphics_style::{self as style, BUTTON_H},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
options_ui::{Event, Page},
|
||||
pad_glyph::{Glyph, Glyphs},
|
||||
radial::{self, Pages, RadialAction, SLOTS, Slot},
|
||||
radial_overlay::{page_label, slot_caption},
|
||||
settings::Settings,
|
||||
settings_shell::{
|
||||
self as shell, Binding, Capture, Group, Output, Reset, Row, Screen, Sheet, Shown, Value,
|
||||
},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
|
||||
const TITLE: &str = "horizon.pages.controller_radial";
|
||||
const SLOTS_GROUP: &str = "horizon.radial.group.slots";
|
||||
const PAGE_GROUP: &str = "horizon.radial.group.page";
|
||||
const ALL_GROUP: &str = "horizon.radial.group.all";
|
||||
/// The opener's row binds the menu's own pad action, in place.
|
||||
const OPENER: &str = gamepad::RADIAL;
|
||||
|
||||
/// A row of a page; `usize`s are the page and the slot.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum RadialRow {
|
||||
Slot(usize, usize),
|
||||
Rename(usize),
|
||||
NewPage,
|
||||
Delete(usize),
|
||||
Reset,
|
||||
Opener,
|
||||
}
|
||||
|
||||
/// A slot's action, that it holds none, or that its action is missing.
|
||||
fn slot_label(slot: &Slot) -> String {
|
||||
slot_caption(slot).unwrap_or_else(|| lang::get("horizon.radial.empty"))
|
||||
}
|
||||
|
||||
/// What the slot after `slot` (before, `back`) holds: Empty between the last action and the first.
|
||||
/// A missing action steps as Empty does.
|
||||
fn step(slot: &Slot, back: bool) -> Slot {
|
||||
let order: Vec<_> = std::iter::once(None)
|
||||
.chain(RadialAction::ALL.map(Some))
|
||||
.collect();
|
||||
let at = order
|
||||
.iter()
|
||||
.position(|entry| *entry == slot.action())
|
||||
.unwrap_or(0);
|
||||
let next = if back { at + order.len() - 1 } else { at + 1 };
|
||||
order[next % order.len()].into()
|
||||
}
|
||||
|
||||
fn page_of(page: usize, pages: usize) -> String {
|
||||
lang::tr("horizon.radial.page_of", &[&(page + 1), &pages])
|
||||
}
|
||||
|
||||
/// The opener's row: a binding of the menu's own pad action, in place, on its button's art. Its
|
||||
/// strip says what stands in the way (no button, or one another action shares); while it waits,
|
||||
/// the shell says how to bind it.
|
||||
fn opener_row(s: &Settings, glyphs: Glyphs) -> Row<RadialRow> {
|
||||
let pad = &s.controller;
|
||||
// Through the player's glyphs, so a Nintendo pad going by its letters names its own button.
|
||||
let glyph = Glyph::of(GlyphOf::Bound(OPENER), pad, glyphs);
|
||||
let warning = match pad.binding(OPENER) {
|
||||
None => Some(lang::get("horizon.radial.unbound")),
|
||||
Some(_) => pad
|
||||
.conflicts(OPENER)
|
||||
.then(|| lang::get("horizon.radial.conflict")),
|
||||
};
|
||||
let binding = Binding {
|
||||
capture: Capture::Pad(OPENER),
|
||||
shown: Shown::Pad {
|
||||
glyph,
|
||||
cancel: Glyph::menu(MenuJob::Start, glyphs),
|
||||
},
|
||||
shared: Vec::new(),
|
||||
// The default (D-pad Right) is refused with some sticks: no reset, only a new button.
|
||||
reset: Reset::None,
|
||||
};
|
||||
let name = lang::get("horizon.radial.opener");
|
||||
Row {
|
||||
chosen: warning,
|
||||
..Row::new(RadialRow::Opener, name, Value::Binding(binding))
|
||||
.about(lang::get(opener_about(pad.radial_stick)))
|
||||
}
|
||||
}
|
||||
|
||||
/// What the opener's row says it can be, with the stick that selects: never under that thumb.
|
||||
fn opener_about(stick: gamepad::RadialStick) -> &'static str {
|
||||
match stick {
|
||||
gamepad::RadialStick::Right => "horizon.radial.opener.about",
|
||||
gamepad::RadialStick::Left => "horizon.radial.opener.about.left",
|
||||
gamepad::RadialStick::Either => "horizon.radial.opener.about.either",
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's rows: its slots, then what changes it.
|
||||
fn page_groups(pages: &Pages, page: usize) -> [Group<RadialRow>; 2] {
|
||||
let slots = (0..SLOTS).map(|slot| {
|
||||
let name = lang::get(&format!("horizon.radial.slot.{slot}"));
|
||||
let cell = pages.page(page).slot(slot);
|
||||
let value = Value::Choice(slot_label(cell));
|
||||
let missing = match cell {
|
||||
Slot::Missing(id) => Some(lang::tr("horizon.radial.missing.about", &[id])),
|
||||
_ => None,
|
||||
};
|
||||
Row {
|
||||
chosen: missing,
|
||||
..Row::new(RadialRow::Slot(page, slot), name, value)
|
||||
.about(lang::get("horizon.radial.slot.about"))
|
||||
}
|
||||
});
|
||||
let full = (pages.len() >= radial::MAX_PAGES)
|
||||
.then(|| lang::tr("horizon.radial.new_page.full", &[&radial::MAX_PAGES]));
|
||||
let last = (pages.len() <= 1).then(|| lang::get("horizon.radial.delete_page.last"));
|
||||
let changes = vec![
|
||||
Row::new(
|
||||
RadialRow::Rename(page),
|
||||
lang::get("horizon.radial.rename"),
|
||||
Value::Press,
|
||||
)
|
||||
.about(lang::get("horizon.radial.rename.about")),
|
||||
Row::new(
|
||||
RadialRow::NewPage,
|
||||
lang::get(radial::NEW_PAGE_NAME),
|
||||
Value::Press,
|
||||
)
|
||||
.about(lang::get("horizon.radial.new_page.about"))
|
||||
.blocked(full),
|
||||
Row::new(
|
||||
RadialRow::Delete(page),
|
||||
lang::get("horizon.radial.delete_page"),
|
||||
Value::Press,
|
||||
)
|
||||
.about(lang::get("horizon.radial.delete_page.about"))
|
||||
.blocked(last),
|
||||
];
|
||||
[
|
||||
Group {
|
||||
name: SLOTS_GROUP,
|
||||
rows: slots.collect(),
|
||||
},
|
||||
Group {
|
||||
name: PAGE_GROUP,
|
||||
rows: changes,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// The screen's own state; the pages are the player's settings.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct RadialScreen {
|
||||
/// The buttons' names follow the controller in use: the options screen says which.
|
||||
pub(crate) glyphs: Glyphs,
|
||||
/// The page to show next: one just added, or where a deleted one was.
|
||||
show: Option<usize>,
|
||||
/// The page whose name is being typed, on its own page (`Page::ControllerRadialName`).
|
||||
pub(crate) naming: Option<NameEntry>,
|
||||
/// The slot (page, slot) whose list of actions opens next (`Page::ControllerRadialPick`).
|
||||
pub(crate) picking: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl RadialScreen {
|
||||
/// The page the shell should show now, once.
|
||||
pub(crate) fn take_shown(&mut self) -> Option<usize> {
|
||||
self.show.take()
|
||||
}
|
||||
}
|
||||
|
||||
impl Screen for RadialScreen {
|
||||
type Page = usize;
|
||||
type Row = RadialRow;
|
||||
fn title(&self) -> &'static str {
|
||||
TITLE
|
||||
}
|
||||
/// The rows read the pages, the opener's button, the stick that selects and the glyphs it is
|
||||
/// named with.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
let pad = &s.controller;
|
||||
let opener = (pad.binding(OPENER), pad.conflicts(OPENER));
|
||||
shell::stamp_of(&(&pad.radial, opener, pad.radial_stick, self.glyphs))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<usize, RadialRow>> {
|
||||
let pages = &s.controller.radial;
|
||||
let default = *pages == Pages::default();
|
||||
let all = Group {
|
||||
name: ALL_GROUP,
|
||||
rows: vec![
|
||||
Row::new(
|
||||
RadialRow::Reset,
|
||||
lang::get("horizon.radial.reset"),
|
||||
Value::Press,
|
||||
)
|
||||
.about(lang::get("horizon.radial.reset.about"))
|
||||
.blocked(default.then(|| lang::get("horizon.radial.reset.default"))),
|
||||
opener_row(s, self.glyphs),
|
||||
],
|
||||
};
|
||||
(0..pages.len())
|
||||
.map(|page| {
|
||||
let [slots, changes] = page_groups(pages, page);
|
||||
let all = Group {
|
||||
name: all.name,
|
||||
rows: all.rows.clone(),
|
||||
};
|
||||
Sheet::new(
|
||||
page,
|
||||
page_label(pages.page(page)),
|
||||
page_of(page, pages.len()),
|
||||
vec![slots, changes, all],
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: RadialRow, back: bool) -> Vec<Output> {
|
||||
let pages = &mut s.controller.radial;
|
||||
match row {
|
||||
RadialRow::Slot(page, slot) => {
|
||||
let next = step(pages.page(page).slot(slot), back);
|
||||
pages.set_slot(page, slot, next);
|
||||
}
|
||||
RadialRow::Rename(page) => {
|
||||
self.naming = Some(NameEntry::new(page, &page_label(pages.page(page))));
|
||||
return vec![Output::Push(Page::ControllerRadialName)];
|
||||
}
|
||||
RadialRow::NewPage => self.show = pages.add(),
|
||||
RadialRow::Delete(page) => {
|
||||
pages.remove(page);
|
||||
self.show = Some(pages.clamp(page));
|
||||
}
|
||||
RadialRow::Reset => {
|
||||
*pages = Pages::default();
|
||||
self.show = Some(0);
|
||||
}
|
||||
// The shell starts its wait (`gamepad::opener_refused` refused, Start cancels).
|
||||
RadialRow::Opener => return Vec::new(),
|
||||
}
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
/// A press on a slot opens the list of every action for it; Left and Right still step it.
|
||||
fn choose(&mut self, _s: &mut Settings, row: RadialRow) -> Option<Vec<Output>> {
|
||||
let RadialRow::Slot(page, slot) = row else {
|
||||
return None;
|
||||
};
|
||||
self.picking = Some((page, slot));
|
||||
Some(vec![Output::Push(Page::ControllerRadialPick)])
|
||||
}
|
||||
/// The wheel over a slot steps it, as a press does.
|
||||
fn wheel_step(&mut self, s: &mut Settings, row: RadialRow, back: bool) -> Option<Vec<Output>> {
|
||||
matches!(row, RadialRow::Slot(..)).then(|| self.press(s, row, back))
|
||||
}
|
||||
/// Undo is how a delete or a reset is taken back: they act at once.
|
||||
fn undoes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// The pages, and the opener's button, which its row binds.
|
||||
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
|
||||
s.controller.radial = before.controller.radial.clone();
|
||||
s.controller.bind(OPENER, before.controller.binding(OPENER));
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
const PICK_TITLE: &str = "horizon.radial.pick_title";
|
||||
const PICK_GROUP: &str = "horizon.radial.group.actions";
|
||||
|
||||
/// A row of a slot's list: the line naming the slot, then Empty and every action.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PickRow {
|
||||
Slot,
|
||||
Pick(Option<RadialAction>),
|
||||
}
|
||||
|
||||
/// One slot's list of every action and Empty, on the settings shell: it names the slot, marks
|
||||
/// what the slot holds and opens on it; a press puts that in the slot and goes back.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PickScreen {
|
||||
/// The page and the slot.
|
||||
at: (usize, usize),
|
||||
picked: bool,
|
||||
}
|
||||
|
||||
impl PickScreen {
|
||||
/// For `at`; the row the focus opens on, what the slot holds.
|
||||
pub(crate) fn open_on(&mut self, at: (usize, usize), pages: &Pages) -> PickRow {
|
||||
*self = Self { at, picked: false };
|
||||
self.current(pages).unwrap_or(PickRow::Pick(None))
|
||||
}
|
||||
/// The page and the slot it is for.
|
||||
pub(crate) fn at(&self) -> (usize, usize) {
|
||||
self.at
|
||||
}
|
||||
/// A pick changed the pages since the last call, which the editor's Undo takes back.
|
||||
pub(crate) fn take_picked(&mut self) -> bool {
|
||||
std::mem::take(&mut self.picked)
|
||||
}
|
||||
/// The row of what the slot holds; none for an action this game doesn't know.
|
||||
fn current(&self, pages: &Pages) -> Option<PickRow> {
|
||||
match pages.page(self.at.0).slot(self.at.1) {
|
||||
Slot::Empty => Some(PickRow::Pick(None)),
|
||||
Slot::Action(action) => Some(PickRow::Pick(Some(*action))),
|
||||
Slot::Missing(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Screen for PickScreen {
|
||||
type Page = ();
|
||||
type Row = PickRow;
|
||||
fn title(&self) -> &'static str {
|
||||
PICK_TITLE
|
||||
}
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
let (page, slot) = self.at;
|
||||
shell::stamp_of(&(self.at, s.controller.radial.page(page).slot(slot)))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), PickRow>> {
|
||||
let pages = &s.controller.radial;
|
||||
let (page, slot) = self.at;
|
||||
let direction = lang::get(&format!("horizon.radial.slot.{slot}"));
|
||||
let named = lang::tr(
|
||||
"horizon.radial.pick.slot",
|
||||
&[&direction, &page_label(pages.page(page))],
|
||||
);
|
||||
let current = self.current(pages);
|
||||
let about = lang::get("horizon.radial.pick.about");
|
||||
let mut rows =
|
||||
vec![Row::new(PickRow::Slot, named.clone(), Value::Note).about(about.clone())];
|
||||
for action in std::iter::once(None).chain(RadialAction::ALL.map(Some)) {
|
||||
let id = PickRow::Pick(action);
|
||||
let value = if current == Some(id) {
|
||||
Value::Choice(lang::get("horizon.radial.pick.current"))
|
||||
} else {
|
||||
Value::Press
|
||||
};
|
||||
let name = slot_label(&action.into());
|
||||
rows.push(Row::new(id, name, value).about(about.clone()));
|
||||
}
|
||||
vec![Sheet::new(
|
||||
(),
|
||||
named,
|
||||
about,
|
||||
vec![Group {
|
||||
name: PICK_GROUP,
|
||||
rows,
|
||||
}],
|
||||
)]
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: PickRow, _back: bool) -> Vec<Output> {
|
||||
let PickRow::Pick(action) = row else {
|
||||
return Vec::new();
|
||||
};
|
||||
let (page, slot) = self.at;
|
||||
s.controller.radial.set_slot(page, slot, action.into());
|
||||
self.picked = true;
|
||||
vec![Output::Event(Event::Changed), Output::Done]
|
||||
}
|
||||
}
|
||||
|
||||
/// The name field and its buttons, centred in the screen but under the title, one row apart.
|
||||
const NAME_W: f32 = 200.0;
|
||||
const NAME_BLOCK_H: f32 = 100.0;
|
||||
const NAME_TOP_MIN: f32 = 30.0;
|
||||
const NAME_STEP: f32 = 25.0;
|
||||
|
||||
/// What a press on the name's page did.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Named {
|
||||
Nothing,
|
||||
/// Done: the page takes the name.
|
||||
Done,
|
||||
/// Cancel, keeping the old name.
|
||||
Back,
|
||||
}
|
||||
|
||||
/// A page's name being typed.
|
||||
pub(crate) struct NameEntry {
|
||||
page: usize,
|
||||
field: ui::EditBox,
|
||||
}
|
||||
|
||||
impl NameEntry {
|
||||
fn new(page: usize, name: &str) -> Self {
|
||||
let mut field = ui::EditBox::new(0.0, 0.0, NAME_W, BUTTON_H);
|
||||
field.max_length = radial::NAME_MAX;
|
||||
field.set_value(name);
|
||||
field.focused = true;
|
||||
Self { page, field }
|
||||
}
|
||||
|
||||
/// Done and Cancel, under the field.
|
||||
fn buttons(w: f32, h: f32) -> [([f32; 4], Named); 2] {
|
||||
let (x, top) = ((w / 2.0).floor() - NAME_W / 2.0, name_top(h));
|
||||
let at = |row: f32| [x, top + row * NAME_STEP, NAME_W, BUTTON_H];
|
||||
[(at(1.0), Named::Done), (at(2.0), Named::Back)]
|
||||
}
|
||||
|
||||
pub(crate) fn draw(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width.ceil(), batch.height.ceil());
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
let title = Text::literal(lang::get("horizon.radial.name_title"));
|
||||
style::title(batch, atlas, &title);
|
||||
let field = &mut self.field;
|
||||
let (x, top) = ((w / 2.0).floor() - NAME_W / 2.0, name_top(h));
|
||||
(field.x, field.y, field.width, field.height) = (x, top, NAME_W, BUTTON_H);
|
||||
field.draw(batch, atlas, None);
|
||||
for ([x, y, bw, bh], named) in Self::buttons(w, h) {
|
||||
let (label, action) = match named {
|
||||
Named::Done => ("gui.done", crate::widgets::Action::Done),
|
||||
_ => ("gui.cancel", crate::widgets::Action::Cancel),
|
||||
};
|
||||
let hover = ui::hovered(cursor, x, y, bw, bh);
|
||||
let text = Text::literal(lang::get(label));
|
||||
ui::action_button(batch, atlas, action, x, y, bw, bh, &text, true, hover);
|
||||
}
|
||||
}
|
||||
|
||||
/// A press at `(x, y)` on a screen `w` x `h`.
|
||||
pub(crate) fn click(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
(x, y): (f32, f32),
|
||||
(w, h): (f32, f32),
|
||||
) -> Named {
|
||||
let hit = Self::buttons(w, h)
|
||||
.into_iter()
|
||||
.find(|([bx, by, bw, bh], _)| ui::hovered((x, y), *bx, *by, *bw, *bh));
|
||||
if let Some((_, named)) = hit {
|
||||
return named;
|
||||
}
|
||||
let field = &mut self.field;
|
||||
field.focused = ui::hovered((x, y), field.x, field.y, field.width, field.height);
|
||||
if field.focused {
|
||||
field.click(atlas, x, false, false);
|
||||
}
|
||||
Named::Nothing
|
||||
}
|
||||
|
||||
/// The name field takes the keys.
|
||||
pub(crate) fn typing(&self) -> bool {
|
||||
self.field.focused
|
||||
}
|
||||
|
||||
pub(crate) fn type_text(&mut self, text: &str) {
|
||||
if self.field.focused {
|
||||
self.field.insert_text(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// A key: Enter is Done, Escape is Cancel, the rest the field's.
|
||||
pub(crate) fn key(&mut self, key: winit::keyboard::KeyCode, text: Option<&str>) -> Named {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
match key {
|
||||
K::Escape => return Named::Back,
|
||||
K::Enter | K::NumpadEnter => return Named::Done,
|
||||
_ if self.field.focused => {
|
||||
if !self.field.key(key, false, false)
|
||||
&& let Some(t) = text
|
||||
{
|
||||
let typed: String = t.chars().filter(|c| !c.is_control()).collect();
|
||||
self.field.insert_text(&typed);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Named::Nothing
|
||||
}
|
||||
|
||||
/// Ctrl+A/C/V/X in the name field; false when it hasn't the focus.
|
||||
pub(crate) fn shortcut(
|
||||
&mut self,
|
||||
shortcut: crate::clipboard::EditShortcut,
|
||||
clipboard: &mut dyn crate::clipboard::Clipboard,
|
||||
) -> bool {
|
||||
if self.field.focused {
|
||||
self.field.shortcut(shortcut, clipboard);
|
||||
}
|
||||
self.field.focused
|
||||
}
|
||||
|
||||
/// The name into the pages. Left as it was, a default page keeps following the language.
|
||||
pub(crate) fn apply(&self, pages: &mut Pages) -> bool {
|
||||
let changed = self.field.value != page_label(pages.page(self.page));
|
||||
if changed {
|
||||
pages.rename(self.page, &self.field.value);
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
/// The name field's top.
|
||||
fn name_top(h: f32) -> f32 {
|
||||
((h - NAME_BLOCK_H) / 2.0).floor().max(NAME_TOP_MIN)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gamepad::Button as PadButton;
|
||||
use crate::radial::PageName;
|
||||
use crate::settings_shell::{Hosted, Shell};
|
||||
use winit::keyboard::KeyCode as K;
|
||||
|
||||
const SIZE: (f32, f32) = (854.0, 480.0);
|
||||
|
||||
fn rows_of(s: &Settings, page: usize) -> Vec<Row<RadialRow>> {
|
||||
let sheets = RadialScreen::default().sheets(s);
|
||||
let groups = sheets.into_iter().nth(page).unwrap().groups;
|
||||
groups.into_iter().flat_map(|group| group.rows).collect()
|
||||
}
|
||||
fn row(s: &Settings, id: RadialRow) -> Row<RadialRow> {
|
||||
let page = match id {
|
||||
RadialRow::Slot(page, _) | RadialRow::Rename(page) | RadialRow::Delete(page) => page,
|
||||
_ => 0,
|
||||
};
|
||||
rows_of(s, page).into_iter().find(|r| r.id == id).unwrap()
|
||||
}
|
||||
/// A click on `id` through the shell, as the mouse or a controller's A gives it.
|
||||
fn click(shell: &mut Shell<RadialScreen>, s: &mut Settings, id: RadialRow) -> Vec<Output> {
|
||||
let at = shell.point_at(s, SIZE, id);
|
||||
shell.click(s, at, SIZE, false, false, false)
|
||||
}
|
||||
|
||||
/// Undo, at the foot over Done.
|
||||
fn undo(shell: &mut Shell<RadialScreen>, s: &mut Settings) {
|
||||
let [x, y, ..] = shell::Columns::of(SIZE.0, SIZE.1).button(1);
|
||||
shell.click(s, (x + 2.0, y + 2.0), SIZE, false, false, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slot_steps_through_empty_and_every_action_both_ways() {
|
||||
let mut seen = vec![Slot::Empty];
|
||||
let mut slot = Slot::Empty;
|
||||
for _ in RadialAction::ALL {
|
||||
slot = step(&slot, false);
|
||||
seen.push(slot.clone());
|
||||
}
|
||||
assert_eq!(step(&slot, false), Slot::Empty, "the last wraps to Empty");
|
||||
let actions: Vec<_> = seen.iter().filter_map(Slot::action).collect();
|
||||
assert_eq!(actions, RadialAction::ALL.to_vec());
|
||||
assert_eq!(
|
||||
step(&Slot::Empty, true).action(),
|
||||
RadialAction::ALL.last().copied()
|
||||
);
|
||||
let chat = Slot::Action(RadialAction::Chat);
|
||||
assert_eq!(step(&step(&chat, true), false), chat);
|
||||
// A missing action steps on as Empty would.
|
||||
let missing = Slot::Missing("mymod:dash".to_owned());
|
||||
assert_eq!(step(&missing, false), step(&Slot::Empty, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_on_a_slot_opens_its_list_and_left_and_right_still_step_it() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<RadialScreen>::default();
|
||||
let before = s.controller.radial.clone();
|
||||
let outputs = click(&mut shell, &mut s, RadialRow::Slot(0, 2));
|
||||
assert!(outputs.contains(&Output::Push(Page::ControllerRadialPick)));
|
||||
assert_eq!(shell.screen.picking, Some((0, 2)));
|
||||
assert_eq!(
|
||||
s.controller.radial, before,
|
||||
"the press itself changes nothing"
|
||||
);
|
||||
// Right on the focused row steps it.
|
||||
shell.set_focus(Some(shell::Target::Row(RadialRow::Slot(0, 2))));
|
||||
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
|
||||
let stepped = step(before.page(0).slot(2), false);
|
||||
assert_eq!(*s.controller.radial.page(0).slot(2), stepped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_names_the_slot_marks_its_action_and_a_pick_fills_it() {
|
||||
let mut s = Settings::default();
|
||||
let mut picker = PickScreen::default();
|
||||
// General's right slot holds Commands.
|
||||
let opens_on = picker.open_on((0, 2), &s.controller.radial);
|
||||
assert_eq!(opens_on, PickRow::Pick(Some(RadialAction::Command)));
|
||||
let rows: Vec<_> = picker
|
||||
.sheets(&s)
|
||||
.into_iter()
|
||||
.flat_map(|sheet| sheet.groups)
|
||||
.flat_map(|group| group.rows)
|
||||
.collect();
|
||||
assert_eq!(rows[0].id, PickRow::Slot);
|
||||
assert_eq!(rows[0].value, Value::Note);
|
||||
// Empty, then every action, each once; only the slot's own marked.
|
||||
let picks: Vec<_> = rows[1..].iter().map(|row| row.id).collect();
|
||||
let every: Vec<_> = std::iter::once(None)
|
||||
.chain(RadialAction::ALL.map(Some))
|
||||
.map(PickRow::Pick)
|
||||
.collect();
|
||||
assert_eq!(picks, every);
|
||||
let marked: Vec<_> = rows
|
||||
.iter()
|
||||
.filter(|row| matches!(row.value, Value::Choice(_)))
|
||||
.map(|row| row.id)
|
||||
.collect();
|
||||
assert_eq!(marked, [opens_on]);
|
||||
let outputs = picker.press(&mut s, PickRow::Pick(Some(RadialAction::Chat)), false);
|
||||
assert_eq!(outputs, [Output::Event(Event::Changed), Output::Done]);
|
||||
assert_eq!(
|
||||
*s.controller.radial.page(0).slot(2),
|
||||
Slot::Action(RadialAction::Chat)
|
||||
);
|
||||
assert!(picker.take_picked() && !picker.take_picked());
|
||||
// Empty empties it.
|
||||
picker.press(&mut s, PickRow::Pick(None), false);
|
||||
assert!(s.controller.radial.page(0).slot(2).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_action_says_which_it_is() {
|
||||
let mut s = Settings::default();
|
||||
s.controller
|
||||
.radial
|
||||
.set_slot(0, 1, Slot::Missing("mymod:dash".to_owned()));
|
||||
let english = lang::Language::load(&[], "en_us");
|
||||
let (chosen, _) = lang::scoped(english, || row(&s, RadialRow::Slot(0, 1)).chosen);
|
||||
assert!(chosen.is_some_and(|text| text.contains("mymod:dash")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slot_changes_alone_and_undo_puts_back_what_the_screen_found() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<RadialScreen>::default();
|
||||
let before = s.controller.radial.page(0).clone();
|
||||
shell.set_focus(Some(shell::Target::Row(RadialRow::Slot(0, 0))));
|
||||
let outputs = shell.key(&mut s, K::ArrowRight, None, false, SIZE);
|
||||
assert!(outputs.contains(&Output::Event(Event::Changed)));
|
||||
let after = s.controller.radial.page(0);
|
||||
assert_eq!(*after.slot(0), step(before.slot(0), false));
|
||||
assert_eq!(after.slots()[1..], before.slots()[1..]);
|
||||
assert!(shell.changed());
|
||||
undo(&mut shell, &mut s);
|
||||
assert_eq!(s.controller.radial, Pages::default());
|
||||
assert!(!shell.changed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_and_reset_act_at_once_and_undo_takes_them_back() {
|
||||
let mut s = Settings::default();
|
||||
s.controller.radial.rename(1, "Mine");
|
||||
let found = s.controller.radial.clone();
|
||||
let mut shell = Shell::<RadialScreen>::default();
|
||||
click(&mut shell, &mut s, RadialRow::Delete(0));
|
||||
assert_eq!(s.controller.radial.len(), 1);
|
||||
assert_eq!(page_label(s.controller.radial.page(0)), "Mine");
|
||||
// One page left: it can't go, and the strip says why.
|
||||
let delete = row(&s, RadialRow::Delete(0));
|
||||
assert!(!delete.live && delete.why.is_some());
|
||||
let reset = row(&s, RadialRow::Reset);
|
||||
assert!(reset.live);
|
||||
shell.screen.take_shown();
|
||||
click(&mut shell, &mut s, RadialRow::Reset);
|
||||
assert_eq!(s.controller.radial, Pages::default());
|
||||
assert!(!row(&s, RadialRow::Reset).live, "already the defaults");
|
||||
assert_eq!(shell.screen.take_shown(), Some(0));
|
||||
undo(&mut shell, &mut s);
|
||||
assert_eq!(s.controller.radial, found);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_pages_stop_at_the_most_and_the_new_one_shows() {
|
||||
let mut s = Settings::default();
|
||||
let mut screen = RadialScreen::default();
|
||||
while row(&s, RadialRow::NewPage).live {
|
||||
screen.press(&mut s, RadialRow::NewPage, false);
|
||||
assert_eq!(screen.take_shown(), Some(s.controller.radial.len() - 1));
|
||||
}
|
||||
assert_eq!(s.controller.radial.len(), radial::MAX_PAGES);
|
||||
assert!(row(&s, RadialRow::NewPage).why.is_some());
|
||||
// Every page is a sheet, named as the overlay names it.
|
||||
let sheets = screen.sheets(&s);
|
||||
assert_eq!(sheets.len(), radial::MAX_PAGES);
|
||||
assert_eq!(sheets[0].name, page_label(s.controller.radial.page(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deleted_last_page_leaves_the_one_before_it_showing() {
|
||||
let mut s = Settings::default();
|
||||
let mut screen = RadialScreen::default();
|
||||
screen.press(&mut s, RadialRow::Delete(1), false);
|
||||
assert_eq!(screen.take_shown(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_opens_the_name_page_and_only_done_with_a_new_name_renames() {
|
||||
let general = Pages::default().page(0).name.clone();
|
||||
let mut s = Settings::default();
|
||||
let mut screen = RadialScreen::default();
|
||||
let outputs = screen.press(&mut s, RadialRow::Rename(0), false);
|
||||
assert_eq!(outputs, vec![Output::Push(Page::ControllerRadialName)]);
|
||||
let entry = screen.naming.as_mut().unwrap();
|
||||
// Done with the name as it was: a default page keeps following the language.
|
||||
assert_eq!(entry.key(K::Enter, None), Named::Done);
|
||||
assert!(!entry.apply(&mut s.controller.radial));
|
||||
assert_eq!(s.controller.radial.page(0).name, general);
|
||||
entry.type_text("!");
|
||||
assert_eq!(entry.key(K::Escape, None), Named::Back);
|
||||
entry.field.set_value("Mine");
|
||||
entry.type_text("!");
|
||||
assert!(entry.apply(&mut s.controller.radial));
|
||||
assert_eq!(
|
||||
s.controller.radial.page(0).name,
|
||||
PageName::Custom("Mine!".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_puts_back_a_name_given_on_the_name_page() {
|
||||
let mut s = Settings::default();
|
||||
s.controller.radial.rename(1, "Mine");
|
||||
let mut shell = Shell::<RadialScreen>::default();
|
||||
shell.show(1);
|
||||
click(&mut shell, &mut s, RadialRow::Rename(1));
|
||||
let entry = shell.screen.naming.as_mut().unwrap();
|
||||
entry.field.set_value("Building");
|
||||
// As the options screen does on Done: the name page changed what Undo puts back.
|
||||
let entry = shell.screen.naming.take().unwrap();
|
||||
assert!(entry.apply(&mut s.controller.radial));
|
||||
shell.changed_elsewhere();
|
||||
assert_eq!(page_label(s.controller.radial.page(1)), "Building");
|
||||
assert!(shell.changed());
|
||||
undo(&mut shell, &mut s);
|
||||
assert_eq!(page_label(s.controller.radial.page(1)), "Mine");
|
||||
assert!(!shell.changed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nintendo_pad_going_by_its_letters_names_its_own_opener() {
|
||||
let mut s = Settings::default();
|
||||
// Selecting with the left stick, the top face button (North) can open it.
|
||||
s.controller.radial_stick = gamepad::RadialStick::Left;
|
||||
s.controller.bind(OPENER, Some(gamepad::Button::North));
|
||||
s.controller.glyphs = gamepad::GlyphStyle::Nintendo;
|
||||
let label = |s: &Settings| {
|
||||
let glyphs = Glyphs::of(&s.controller, gamepad::GlyphStyle::Nintendo);
|
||||
match opener_row(s, glyphs).value {
|
||||
Value::Binding(Binding {
|
||||
shown: Shown::Pad { glyph, .. },
|
||||
..
|
||||
}) => glyph.expect("bound").label().to_owned(),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
};
|
||||
// By places, the top button: printed X on a Nintendo pad.
|
||||
s.controller.nintendo_letters = false;
|
||||
assert_eq!(label(&s), "X");
|
||||
// By letters, the part is played by the button printed Y (on the left).
|
||||
s.controller.nintendo_letters = true;
|
||||
assert_eq!(label(&s), "Y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_opener_row_names_its_button_and_says_what_gets_in_the_way() {
|
||||
let mut s = Settings::default();
|
||||
let glyphs = Glyphs::of(&s.controller, gamepad::GlyphStyle::Xbox);
|
||||
let shown = |s: &Settings| match opener_row(s, glyphs).value {
|
||||
Value::Binding(Binding {
|
||||
capture,
|
||||
shown: Shown::Pad { glyph, .. },
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(capture, Capture::Pad(OPENER));
|
||||
glyph
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let bound = s.controller.binding(OPENER).unwrap();
|
||||
// The button on its glyph.
|
||||
let glyph = shown(&s).expect("the opener's glyph");
|
||||
assert_eq!(glyph.part(), bound);
|
||||
assert_eq!(glyph.label(), "→");
|
||||
assert_eq!(opener_row(&s, glyphs).chosen, None);
|
||||
s.controller.bind("key.chat", Some(bound));
|
||||
let shared = opener_row(&s, glyphs).chosen;
|
||||
assert!(shared.is_some(), "a shared button");
|
||||
s.controller.bind(OPENER, None);
|
||||
let unbound = opener_row(&s, glyphs).chosen;
|
||||
assert!(unbound.is_some() && unbound != shared && shown(&s).is_none());
|
||||
// A press waits for a button on the row, and Undo takes a new one back.
|
||||
let mut shell = Shell::<RadialScreen>::default();
|
||||
s.controller.bind(OPENER, Some(bound));
|
||||
let outputs = click(&mut shell, &mut s, RadialRow::Opener);
|
||||
assert!(outputs.contains(&Output::Bind(Capture::Pad(OPENER))));
|
||||
assert!(shell.changed());
|
||||
s.controller.bind(OPENER, Some(PadButton::North));
|
||||
undo(&mut shell, &mut s);
|
||||
assert_eq!(s.controller.binding(OPENER), Some(bound));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_string_has_a_horizon_key() {
|
||||
let atlas =
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
let english = lang::Language::load(&[], "en_us");
|
||||
let ((), missed) = lang::scoped(english, || {
|
||||
let mut s = Settings::default();
|
||||
s.controller.bind("key.chat", Some(PadButton::DPadRight));
|
||||
let mut shell = Shell::<RadialScreen>::default();
|
||||
let draw = |shell: &mut Shell<RadialScreen>, s: &Settings| {
|
||||
let mut batch = HudBatch::new([854, 480]);
|
||||
shell.draw(s, &mut batch, &atlas, (0.0, 0.0), false, None);
|
||||
};
|
||||
// Every page's rows are built, with what the strip says of each.
|
||||
draw(&mut shell, &s);
|
||||
s.controller.bind(OPENER, None);
|
||||
s.controller.radial.remove(1);
|
||||
while s.controller.radial.add().is_some() {}
|
||||
draw(&mut shell, &s);
|
||||
s.controller.radial = Pages::default();
|
||||
s.controller.radial.remove(1);
|
||||
draw(&mut shell, &s);
|
||||
let mut batch = HudBatch::new([854, 480]);
|
||||
let waiting = Some(Capture::Pad(OPENER));
|
||||
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, waiting);
|
||||
let mut entry = NameEntry::new(0, "Mine");
|
||||
let mut batch = HudBatch::new([854, 480]);
|
||||
entry.draw(&mut batch, &atlas, (0.0, 0.0), false);
|
||||
let mut picker = Shell::<PickScreen>::default();
|
||||
picker.screen.open_on((0, 1), &s.controller.radial);
|
||||
let mut batch = HudBatch::new([854, 480]);
|
||||
picker.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
|
||||
});
|
||||
// Vanilla's own (gui.done...) come from the game's resources, not bundled ones.
|
||||
let missed: Vec<_> = missed
|
||||
.iter()
|
||||
.filter(|key| key.starts_with("horizon.") || !key.contains('.'))
|
||||
.collect();
|
||||
assert!(missed.is_empty(), "no text for {missed:?}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -869,10 +869,6 @@ pub enum Opt {
|
||||
PadNintendoButtons,
|
||||
PadKeyboard,
|
||||
PadGameplayGuide,
|
||||
/// The radial menu's opener held or pressed once (`PadSettings::radial_mode`).
|
||||
PadRadialMode,
|
||||
/// The stick the radial menu selects with (`PadSettings::radial_stick`).
|
||||
PadRadialStick,
|
||||
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
|
||||
ModelPart(usize),
|
||||
/// A slider of a page a crate registered (`horizon_hooks::pages`): (page, row).
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,815 +0,0 @@
|
||||
use super::*;
|
||||
use crate::options_ui::Event;
|
||||
use winit::keyboard::KeyCode as K;
|
||||
|
||||
const SIZE: (f32, f32) = (854.0, 480.0);
|
||||
const SHORT: (f32, f32) = (480.0, 240.0);
|
||||
const CHANGED: Output = Output::Event(Event::Changed);
|
||||
|
||||
/// A screen with what Extras doesn't have: two pages, a slider, a value with descriptions of
|
||||
/// its own, and a row a wheel steps.
|
||||
#[derive(Default)]
|
||||
struct Probe {
|
||||
slider: f32,
|
||||
choice: usize,
|
||||
pressed: Vec<(u32, bool)>,
|
||||
/// How many times its rows were built.
|
||||
built: std::cell::Cell<usize>,
|
||||
}
|
||||
const CHOICES: [(&str, &str); 2] = [("Red", "Paints it red."), ("Blue", "Paints it blue.")];
|
||||
const SLIDER: u32 = 0;
|
||||
const CHOICE: u32 = 1;
|
||||
const FIRST_FILLER: u32 = 10;
|
||||
const FILLERS: u32 = 40;
|
||||
const GREY: u32 = 100;
|
||||
const WHY: &str = "Not here.";
|
||||
impl Screen for Probe {
|
||||
type Page = u8;
|
||||
type Row = u32;
|
||||
fn title(&self) -> &'static str {
|
||||
"Probe"
|
||||
}
|
||||
/// Its rows read the field of view, besides its own state.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
stamp_of(&s.fov)
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<u8, u32>> {
|
||||
self.built.set(self.built.get() + 1);
|
||||
let (value, chosen) = CHOICES[self.choice];
|
||||
let mut rows = vec![
|
||||
Row::new(
|
||||
SLIDER,
|
||||
"Level".to_owned(),
|
||||
Value::Slider {
|
||||
position: self.slider,
|
||||
steps: Some(10),
|
||||
text: s.fov.to_string(),
|
||||
},
|
||||
),
|
||||
Row {
|
||||
chosen: Some(chosen.to_owned()),
|
||||
..Row::new(CHOICE, "Paint".to_owned(), Value::Choice(value.to_owned()))
|
||||
.about("Picks a colour.".to_owned())
|
||||
},
|
||||
Row::new(GREY, "Greyed".to_owned(), Value::Press)
|
||||
.about("Does a thing.".to_owned())
|
||||
.blocked(Some(WHY.to_owned())),
|
||||
];
|
||||
rows.extend((0..FILLERS).map(|i| {
|
||||
Row::new(FIRST_FILLER + i, format!("Filler {i}"), Value::Press)
|
||||
.about("Does nothing.".to_owned())
|
||||
}));
|
||||
let elsewhere = vec![Row::new(99, "Elsewhere".to_owned(), Value::Press)];
|
||||
vec![
|
||||
Sheet::new(
|
||||
0,
|
||||
"One".to_owned(),
|
||||
"The first.".to_owned(),
|
||||
vec![Group { name: "G", rows }],
|
||||
),
|
||||
Sheet::new(
|
||||
1,
|
||||
"Two".to_owned(),
|
||||
"The second.".to_owned(),
|
||||
vec![Group {
|
||||
name: "H",
|
||||
rows: elsewhere,
|
||||
}],
|
||||
),
|
||||
]
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: u32, back: bool) -> Vec<Output> {
|
||||
self.pressed.push((row, back));
|
||||
match row {
|
||||
CHOICE => self.choice = (self.choice + 1) % CHOICES.len(),
|
||||
FIRST_FILLER => s.fov += 1.0,
|
||||
_ => {}
|
||||
}
|
||||
vec![CHANGED]
|
||||
}
|
||||
fn undoes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
|
||||
s.fov = before.fov;
|
||||
Vec::new()
|
||||
}
|
||||
fn slide(&mut self, _: &mut Settings, _: u32, position: f32) {
|
||||
self.slider = position;
|
||||
}
|
||||
fn wheel_step(&mut self, _: &mut Settings, row: u32, _: bool) -> Option<Vec<Output>> {
|
||||
(row == CHOICE).then(|| {
|
||||
self.choice = (self.choice + 1) % CHOICES.len();
|
||||
vec![CHANGED]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn middle_of(shell: &mut Shell<Probe>, row: u32, size: (f32, f32)) -> (f32, f32) {
|
||||
shell.point_at(&Settings::default(), size, row)
|
||||
}
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
/// What one frame asks of a screen: a draw, then the stick's scroll, an arrow key and the wheel
|
||||
/// away from any row, at the size it was drawn at.
|
||||
fn frame(shell: &mut Shell<Probe>, s: &mut Settings, atlas: &HudAtlas) -> (f32, f32) {
|
||||
let mut batch = HudBatch::new([1708, 960]);
|
||||
shell.draw(s, &mut batch, atlas, (0.0, 0.0), false, None);
|
||||
let size = (batch.width, batch.height);
|
||||
shell.scroll(s, size, -1.0);
|
||||
shell.key(s, K::ArrowDown, None, false, size);
|
||||
shell.wheel(s, size, (0.0, 0.0), 1.0);
|
||||
size
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_are_built_once_and_again_only_when_what_they_read_changes() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let built = |shell: &Shell<Probe>| shell.screen.built.get();
|
||||
let reads = |shell: &mut Shell<Probe>, s: &Settings, size, end: &str| {
|
||||
let view = shell.view(s, size);
|
||||
let mut labels = view.rows.iter().map(|r| r.label(r.value_text().as_deref()));
|
||||
labels.any(|label| label.ends_with(end))
|
||||
};
|
||||
// Each of the four built them before: now the first frame builds them once, the next none.
|
||||
let size = frame(&mut shell, &mut s, &atlas);
|
||||
assert_eq!(built(&shell), 1);
|
||||
frame(&mut shell, &mut s, &atlas);
|
||||
assert_eq!(built(&shell), 1);
|
||||
// A press changes the screen's own state: its row reads the new value.
|
||||
let at = middle_of(&mut shell, CHOICE, size);
|
||||
shell.click(&mut s, at, size, false, false, false);
|
||||
assert!(reads(&mut shell, &s, size, "Blue"));
|
||||
// A setting changed elsewhere (a key bound, a command) shows on the next frame.
|
||||
s.fov = 90.0;
|
||||
frame(&mut shell, &mut s, &atlas);
|
||||
assert!(reads(&mut shell, &s, size, "90"));
|
||||
let before = built(&shell);
|
||||
frame(&mut shell, &mut s, &atlas);
|
||||
assert_eq!(built(&shell), before);
|
||||
// Searching, and another size, build them again.
|
||||
shell.type_text("filler");
|
||||
shell.view(&s, size);
|
||||
assert_eq!(built(&shell), before + 1);
|
||||
shell.view(&s, SHORT);
|
||||
assert_eq!(built(&shell), before + 2);
|
||||
// So does another language, and coming back from it.
|
||||
lang::scoped(lang::Language::load(&[], "de_de"), || shell.view(&s, SHORT));
|
||||
assert_eq!(built(&shell), before + 3);
|
||||
shell.view(&s, SHORT);
|
||||
assert_eq!(built(&shell), before + 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_the_list_hides_part_of_scrolls_into_view_when_focused_and_then_takes_a_press() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let cols = Columns::of(SHORT.0, SHORT.1);
|
||||
// A filler far down, scrolled to just over the bottom: its middle is under the list's edge.
|
||||
let cut = FIRST_FILLER + 20;
|
||||
let view = shell.view(&s, SHORT);
|
||||
let (_, y) = shell.placed(&view).find(|(r, _)| r.id == cut).unwrap();
|
||||
shell.scroll.amount = y - cols.bottom + style::ROW / 2.0 - 1.0;
|
||||
let rect = cols.row(y - shell.scroll.amount);
|
||||
assert!(
|
||||
rect[1] + rect[3] / 2.0 >= cols.bottom,
|
||||
"the row's middle is hidden"
|
||||
);
|
||||
let moved = Hosted::reveal(&mut shell, &s, SHORT, rect).unwrap();
|
||||
let shown = [rect[0], rect[1] + moved, rect[2], rect[3]];
|
||||
assert!(moved < 0.0 && shown[1] + shown[3] <= cols.bottom);
|
||||
// All in view, it stays put.
|
||||
assert_eq!(Hosted::reveal(&mut shell, &s, SHORT, shown), None);
|
||||
let middle = (shown[0] + 4.0, shown[1] + shown[3] / 2.0);
|
||||
assert_eq!(
|
||||
shell.click(&mut s, middle, SHORT, false, false, false),
|
||||
vec![CLICK, CHANGED]
|
||||
);
|
||||
assert_eq!(shell.screen.pressed, [(cut, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_appears_after_a_change_and_puts_back_what_the_screen_opened_with() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let undo = Columns::of(SIZE.0, SIZE.1).button(1);
|
||||
let on_undo = (undo[0] + 2.0, undo[1] + 2.0);
|
||||
// Nothing changed: there is no Undo to press.
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, on_undo, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
|
||||
shell.click(&mut s, at, SIZE, false, false, false);
|
||||
assert_eq!(s.fov, 71.0);
|
||||
// Another option changed meanwhile isn't the screen's to put back.
|
||||
s.chat_opacity = 0.25;
|
||||
assert_eq!(
|
||||
shell.click(&mut s, on_undo, SIZE, false, false, false),
|
||||
vec![CLICK, CHANGED]
|
||||
);
|
||||
assert_eq!((s.fov, s.chat_opacity), (70.0, 0.25));
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, on_undo, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
// From the keyboard: Tab reaches it, Enter undoes and leaves the focus on Done.
|
||||
shell.click(&mut s, at, SIZE, false, false, false);
|
||||
let reached = (0..100).any(|_| {
|
||||
shell.key(&mut s, K::Tab, None, false, SIZE);
|
||||
shell.focus == Some(Target::Undo)
|
||||
});
|
||||
assert!(reached);
|
||||
assert_eq!(
|
||||
shell.key(&mut s, K::Enter, None, false, SIZE),
|
||||
vec![CLICK, CHANGED]
|
||||
);
|
||||
assert_eq!(s.fov, 70.0);
|
||||
assert_eq!(shell.focus, Some(Target::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_strip_says_why_a_greyed_row_cant_be_used() {
|
||||
let atlas = atlas();
|
||||
let s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let mut batch = HudBatch::new([1708, 960]);
|
||||
let size = (batch.width, batch.height);
|
||||
let strip = |batch: &mut HudBatch, shell: &mut Shell<Probe>, at| {
|
||||
shell.draw(&s, batch, &atlas, at, false, None);
|
||||
crate::widgets::end_frame(crate::widgets::generation() + 1);
|
||||
let dock = Columns::of(size.0, size.1).dock;
|
||||
let labelled = crate::widgets::last_labelled();
|
||||
let found = labelled.iter().find(|w| [w.x, w.y, w.w, w.h] == dock);
|
||||
found.map(|w| w.label.clone())
|
||||
};
|
||||
let grey = middle_of(&mut shell, GREY, size);
|
||||
assert_eq!(strip(&mut batch, &mut shell, grey).as_deref(), Some(WHY));
|
||||
let choice = middle_of(&mut shell, CHOICE, size);
|
||||
assert_eq!(
|
||||
strip(&mut batch, &mut shell, choice).as_deref(),
|
||||
Some(CHOICES[0].1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growing_the_window_keeps_the_scroll_within_the_list() {
|
||||
let s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
shell.scroll(&s, SIZE, -1000.0);
|
||||
assert!(shell.scroll.amount > 0.0);
|
||||
let tall = (SIZE.0, 4000.0);
|
||||
let view = shell.view(&s, tall);
|
||||
assert_eq!(shell.scroll.amount, 0.0);
|
||||
let first = shell.placed(&view).next().map(|(_, y)| y).unwrap();
|
||||
assert!(first >= view.cols.top);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_scroll_path_moves_the_list_for_wheel_and_stick_alike() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let rate = SCROLL_RATE;
|
||||
// The wheel away from any row, and `scroll`, which a stick and a D-pad use.
|
||||
shell.wheel(&mut s, SIZE, (0.0, 0.0), -1.0);
|
||||
assert_eq!(shell.scroll.amount, rate);
|
||||
shell.scroll(&s, SIZE, -2.0);
|
||||
assert_eq!(shell.scroll.amount, 3.0 * rate);
|
||||
shell.scroll(&s, SIZE, 100.0);
|
||||
assert_eq!(shell.scroll.amount, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_steps_a_row_only_with_the_extra_on_and_only_where_the_screen_says() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let over_choice = middle_of(&mut shell, CHOICE, SIZE);
|
||||
shell.wheel(&mut s, SIZE, over_choice, -1.0);
|
||||
assert_eq!(shell.screen.choice, 0);
|
||||
assert_eq!(shell.scroll.amount, SCROLL_RATE);
|
||||
s.extras.wheel_cycles_settings = true;
|
||||
shell.scroll.amount = 0.0;
|
||||
let outputs = shell.wheel(&mut s, SIZE, over_choice, -1.0);
|
||||
assert_eq!(outputs, vec![CHANGED]);
|
||||
assert_eq!(shell.screen.choice, 1);
|
||||
assert_eq!(shell.scroll.amount, 0.0);
|
||||
// A row the screen doesn't step scrolls, the greyed one too.
|
||||
let over_grey = middle_of(&mut shell, GREY, SIZE);
|
||||
assert!(shell.wheel(&mut s, SIZE, over_grey, -1.0).is_empty());
|
||||
assert_eq!(shell.scroll.amount, SCROLL_RATE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_says_what_its_chosen_value_does_and_is_found_by_what_it_does() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let about = |shell: &mut Shell<Probe>, s: &Settings| {
|
||||
let view = shell.view(s, SIZE);
|
||||
let row = view.rows.iter().find(|r| r.id == CHOICE).unwrap();
|
||||
row.described().to_owned()
|
||||
};
|
||||
assert_eq!(about(&mut shell, &s), "Paints it red.");
|
||||
let at = middle_of(&mut shell, CHOICE, SIZE);
|
||||
shell.click(&mut s, at, SIZE, false, false, false);
|
||||
assert_eq!(about(&mut shell, &s), "Paints it blue.");
|
||||
// The search reads the row's own description, on every page.
|
||||
shell.type_text("colour");
|
||||
let view = shell.view(&s, SIZE);
|
||||
let found: Vec<u32> = view.rows.iter().map(|r| r.id).collect();
|
||||
assert!(found.contains(&CHOICE) && !found.contains(&99));
|
||||
shell.search.set_value("elsewhere");
|
||||
shell.focus = None;
|
||||
let view = shell.view(&s, SIZE);
|
||||
assert_eq!(view.rows.iter().map(|r| r.id).collect::<Vec<_>>(), [99]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_on_the_track_slides_and_on_the_name_only_focuses() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let at = middle_of(&mut shell, SLIDER, SIZE);
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(shell.screen.slider, 0.0);
|
||||
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
|
||||
let mid = (track + width / 2.0, at.1);
|
||||
assert_eq!(
|
||||
shell.click(&mut s, mid, SIZE, false, false, false),
|
||||
vec![DRAGGED]
|
||||
);
|
||||
assert_eq!(shell.screen.slider, 0.5);
|
||||
let end = (track + width, at.1);
|
||||
assert_eq!(
|
||||
shell.click(&mut s, end, SIZE, true, false, false),
|
||||
vec![DRAGGED]
|
||||
);
|
||||
assert_eq!(shell.screen.slider, 1.0);
|
||||
assert!(shell.release().is_some());
|
||||
assert!(shell.release().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_keyboard_walks_the_live_rows_past_the_view_and_presses_them() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let mut key = |shell: &mut Shell<Probe>, k| shell.key(&mut s, k, None, false, SHORT);
|
||||
// Tab: the search, the two pages, then the first row.
|
||||
for _ in 0..4 {
|
||||
key(&mut shell, K::Tab);
|
||||
}
|
||||
assert_eq!(shell.focus, Some(Target::Row(SLIDER)));
|
||||
// A slider Tab reaches changes with the arrows, in its steps.
|
||||
key(&mut shell, K::ArrowRight);
|
||||
assert!((shell.screen.slider - 0.1).abs() < f32::EPSILON);
|
||||
// The arrows go on past the greyed row and rows out of view, which scroll in.
|
||||
key(&mut shell, K::ArrowDown);
|
||||
assert_eq!(shell.focus, Some(Target::Row(CHOICE)));
|
||||
key(&mut shell, K::ArrowDown);
|
||||
assert_eq!(shell.focus, Some(Target::Row(FIRST_FILLER)));
|
||||
for _ in 0..30 {
|
||||
key(&mut shell, K::ArrowDown);
|
||||
}
|
||||
assert_eq!(shell.focus, Some(Target::Row(FIRST_FILLER + 30)));
|
||||
assert!(shell.scroll.amount > 0.0);
|
||||
assert_eq!(key(&mut shell, K::Enter), vec![CLICK, CHANGED]);
|
||||
assert_eq!(shell.screen.pressed, [(FIRST_FILLER + 30, false)]);
|
||||
assert_eq!(key(&mut shell, K::Escape), vec![Output::Done]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_and_right_step_a_focused_choice_and_leave_other_rows() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let mut key = |shell: &mut Shell<Probe>, k| shell.key(&mut s, k, None, false, SIZE);
|
||||
shell.set_focus(Some(Target::Row(CHOICE)));
|
||||
// Right is a press, Left a Shift press: the screen's own way back, and the focus stays.
|
||||
assert_eq!(key(&mut shell, K::ArrowRight), vec![CLICK, CHANGED]);
|
||||
assert_eq!(key(&mut shell, K::ArrowLeft), vec![CLICK, CHANGED]);
|
||||
assert_eq!(shell.screen.pressed, [(CHOICE, false), (CHOICE, true)]);
|
||||
assert_eq!(shell.focus, Some(Target::Row(CHOICE)));
|
||||
assert!(shell.changed);
|
||||
// A row that isn't a choice: Left goes to the page list, as before.
|
||||
shell.set_focus(Some(Target::Row(FIRST_FILLER)));
|
||||
assert!(key(&mut shell, K::ArrowLeft).is_empty());
|
||||
assert!(matches!(shell.focus, Some(Target::Page(_))));
|
||||
assert_eq!(shell.screen.pressed.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawn_choice_tells_the_pad_its_arrows_step_it() {
|
||||
let atlas = atlas();
|
||||
let s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let mut batch = HudBatch::new([1708, 960]);
|
||||
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
|
||||
crate::widgets::end_frame(u64::MAX);
|
||||
let view = shell.view(&s, (batch.width, batch.height));
|
||||
let rect_of = |id| {
|
||||
let mut placed = shell.placed(&view);
|
||||
view.cols.row(placed.find(|(r, _)| r.id == id).unwrap().1)
|
||||
};
|
||||
assert!(crate::widgets::last_steps_value(rect_of(CHOICE)));
|
||||
assert!(!crate::widgets::last_steps_value(rect_of(SLIDER)));
|
||||
assert!(!crate::widgets::last_steps_value(rect_of(FIRST_FILLER)));
|
||||
}
|
||||
|
||||
/// A controller presses with a click, then moves its own focus: only keys show this one.
|
||||
#[test]
|
||||
fn leaving_the_search_field_gives_the_keys_back() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let view = shell.view(&s, SIZE);
|
||||
let [x, y, w, h] = view.cols.search();
|
||||
shell.click(
|
||||
&mut s,
|
||||
(x + w / 2.0, y + h / 2.0),
|
||||
SIZE,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert!(shell.typing());
|
||||
Hosted::blur_search(&mut shell);
|
||||
assert!(!shell.typing());
|
||||
// Another focus isn't dropped.
|
||||
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
|
||||
shell.click(&mut s, at, SIZE, false, false, false);
|
||||
Hosted::blur_search(&mut shell);
|
||||
shell.key(&mut s, K::ArrowDown, None, false, SIZE);
|
||||
assert!(shell.shows_focus(Target::Row(FIRST_FILLER + 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_that_moves_nothing_shows_no_focus() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
|
||||
shell.click(&mut s, at, SIZE, false, false, false);
|
||||
shell.key(&mut s, K::ShiftLeft, None, false, SIZE);
|
||||
assert!(!shell.shows_focus(Target::Row(FIRST_FILLER)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_description_says_so() {
|
||||
assert_eq!(cut_off("off, it never "), "off, it never…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_leaves_no_focus_showing_until_a_key_moves_it() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
|
||||
shell.click(&mut s, at, SIZE, false, false, false);
|
||||
assert!(!shell.shows_focus(Target::Row(FIRST_FILLER)));
|
||||
// The next key goes on from the row clicked, and shows where it went.
|
||||
shell.key(&mut s, K::ArrowDown, None, false, SIZE);
|
||||
assert!(shell.shows_focus(Target::Row(FIRST_FILLER + 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_taller_strip_takes_from_the_list_as_far_as_the_list_keeps_its_height() {
|
||||
let (wide, base) = (
|
||||
Columns::of(480.0, 270.0).with_note_lines(4),
|
||||
Columns::of(480.0, 270.0),
|
||||
);
|
||||
assert_eq!(wide.note_lines, 4);
|
||||
assert_eq!(wide.bottom, base.bottom - 2.0 * NOTE_LINE_H);
|
||||
assert_eq!(wide.dock[1] + wide.dock[3], base.dock[1] + base.dock[3]);
|
||||
assert_eq!(wide.button(0), base.button(0));
|
||||
// A 720p window at scale 4 has room for one more line, not two.
|
||||
let short = Columns::of(320.0, 180.0).with_note_lines(4);
|
||||
assert_eq!(short.note_lines, 3);
|
||||
assert!(short.bottom - short.top >= MIN_LIST_H);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_presses_a_row_backwards() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
|
||||
shell.click(&mut s, at, SIZE, false, false, true);
|
||||
// The right button is not a press.
|
||||
shell.click(&mut s, at, SIZE, false, true, false);
|
||||
assert_eq!(shell.screen.pressed, [(FIRST_FILLER, true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_a_greyed_row_does_nothing_and_done_leaves() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let at = middle_of(&mut shell, GREY, SIZE);
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(shell.screen.pressed.is_empty());
|
||||
let [x, y, w, h] = Columns::of(SIZE.0, SIZE.1).button(0);
|
||||
let at = (x + w / 2.0, y + h / 2.0);
|
||||
assert_eq!(
|
||||
shell.click(&mut s, at, SIZE, false, false, false),
|
||||
vec![CLICK, Output::Done]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_picked_shows_alone() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let [x, y, w, h] = Columns::of(SIZE.0, SIZE.1).page(1);
|
||||
let at = (x + w / 2.0, y + h / 2.0);
|
||||
assert_eq!(
|
||||
shell.click(&mut s, at, SIZE, false, false, false),
|
||||
vec![CLICK]
|
||||
);
|
||||
let view = shell.view(&s, SIZE);
|
||||
assert_eq!(view.rows.iter().map(|r| r.id).collect::<Vec<_>>(), [99]);
|
||||
}
|
||||
|
||||
/// A screen with more pages than a short view's page list has room for: a page of its own,
|
||||
/// then a section of `PAGES` under a line, one more page out of the list that the section's
|
||||
/// first page links to, and a change that waits for Apply.
|
||||
#[derive(Default)]
|
||||
struct Pages {
|
||||
pending: bool,
|
||||
applied: usize,
|
||||
}
|
||||
const PAGES: u8 = 12;
|
||||
const HOME: u8 = 0;
|
||||
const DEEP: u8 = 100;
|
||||
const LINK: u32 = 1000;
|
||||
const WAITS: u32 = 1001;
|
||||
const SECTION: &str = "Section";
|
||||
impl Screen for Pages {
|
||||
type Page = u8;
|
||||
type Row = u32;
|
||||
fn title(&self) -> &'static str {
|
||||
"Pages"
|
||||
}
|
||||
fn stamp(&self, _: &Settings) -> u64 {
|
||||
stamp_of(&self.pending)
|
||||
}
|
||||
fn sheets(&self, _: &Settings) -> Vec<Sheet<u8, u32>> {
|
||||
let page = |id: u8, name: String, rows| {
|
||||
Sheet::new(id, name, String::new(), vec![Group { name: "G", rows }])
|
||||
};
|
||||
let change = Row::new(WAITS, "Change".to_owned(), Value::Press);
|
||||
let mut sheets = vec![page(HOME, "Home".to_owned(), vec![change])];
|
||||
for i in 1..=PAGES {
|
||||
let mut rows = vec![Row::new(u32::from(i), format!("Row {i}"), Value::Press)];
|
||||
if i == 1 {
|
||||
rows.push(Row::new(LINK, "Deeper...".to_owned(), Value::Press));
|
||||
}
|
||||
let mut sheet = page(i, format!("Page {i}"), rows);
|
||||
sheet.heading = (i == 1).then(|| SECTION.to_owned());
|
||||
sheets.push(sheet);
|
||||
}
|
||||
let deep = Row::new(u32::from(DEEP), "Deep".to_owned(), Value::Press);
|
||||
let mut deeper = page(DEEP, "Deeper".to_owned(), vec![deep]);
|
||||
deeper.listed = false;
|
||||
sheets.push(deeper);
|
||||
sheets
|
||||
}
|
||||
fn press(&mut self, _: &mut Settings, row: u32, _: bool) -> Vec<Output> {
|
||||
self.pending |= row == WAITS;
|
||||
vec![CHANGED]
|
||||
}
|
||||
fn link(&self, row: u32) -> Option<u8> {
|
||||
(row == LINK).then_some(DEEP)
|
||||
}
|
||||
fn undoes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn pending(&self, _: &Settings) -> bool {
|
||||
self.pending
|
||||
}
|
||||
fn apply(&mut self, _: &mut Settings) -> Vec<Output> {
|
||||
self.pending = false;
|
||||
self.applied += 1;
|
||||
vec![CHANGED]
|
||||
}
|
||||
}
|
||||
|
||||
/// A window of 854x960 (427x480 GUI px at its automatic scale) and one of 854x480 (427x240:
|
||||
/// the page list has room for six places).
|
||||
const TALL: [u32; 2] = [854, 960];
|
||||
const SHORT_WINDOW: [u32; 2] = [854, 480];
|
||||
|
||||
/// One frame of `shell` in a window of `window`; the GUI size it was drawn at.
|
||||
fn drawn<S: Screen + Default>(shell: &mut Shell<S>, s: &Settings, window: [u32; 2]) -> (f32, f32) {
|
||||
let mut batch = HudBatch::new(window);
|
||||
shell.draw(s, &mut batch, &atlas(), (0.0, 0.0), false, None);
|
||||
crate::widgets::end_frame(crate::widgets::generation() + 1);
|
||||
(batch.width, batch.height)
|
||||
}
|
||||
|
||||
fn middle([x, y, w, h]: [f32; 4]) -> (f32, f32) {
|
||||
(x + w / 2.0, y + h / 2.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_that_fit_are_listed_and_more_collapse_into_one_row_leaving_none_out() {
|
||||
let s = Settings::default();
|
||||
let mut shell = Shell::<Pages>::default();
|
||||
// Tall enough: each page under the last, the deeper page left out as it is in no list.
|
||||
drawn(&mut shell, &s, TALL);
|
||||
let tall = shell.page_places();
|
||||
let pages: Vec<u8> = tall.iter().map(|p| p.0).collect();
|
||||
assert_eq!(pages, (HOME..=PAGES).collect::<Vec<_>>());
|
||||
assert!(tall.iter().all(|p| p.2 == Part::Tab));
|
||||
assert!(tall.windows(2).all(|p| p[1].1[1] > p[0].1[1]));
|
||||
assert_eq!(shell.strip_ends(), None);
|
||||
// Short: the first page, then the section in one row, its first page between nothing and
|
||||
// its next. Every place is inside the column, the row's on one line under the section's.
|
||||
let size = drawn(&mut shell, &s, SHORT_WINDOW);
|
||||
let short = shell.page_places();
|
||||
let parts: Vec<(u8, Part)> = short.iter().map(|p| (p.0, p.2)).collect();
|
||||
assert_eq!(
|
||||
parts,
|
||||
[(HOME, Part::Tab), (1, Part::Shown), (2, Part::Next)]
|
||||
);
|
||||
assert_eq!(shell.strip_ends(), Some((false, true)));
|
||||
let cols = Columns::of(size.0, size.1);
|
||||
for (_, [x, _, w, _], _) in &short {
|
||||
assert!(*x >= cols.side_x && x + w <= cols.side_x + style::CONTROL_W);
|
||||
}
|
||||
assert_eq!(short[1].1[1], short[2].1[1]);
|
||||
assert_eq!(short[1].1[1], cols.page(2)[1]);
|
||||
// The line naming the section stays over it.
|
||||
let labelled = crate::widgets::last_labelled();
|
||||
let line = labelled.iter().find(|w| w.label == SECTION).unwrap();
|
||||
assert!(line.y > cols.page(1)[1] && line.y < cols.page(2)[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_collapsed_rows_arrows_walk_every_page_and_its_ends_go_no_further() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Pages>::default();
|
||||
shell.show(1);
|
||||
let mut seen = vec![1];
|
||||
let size = loop {
|
||||
let size = drawn(&mut shell, &s, SHORT_WINDOW);
|
||||
let places = shell.page_places();
|
||||
let Some(&(next, rect, _)) = places.iter().find(|p| p.2 == Part::Next) else {
|
||||
break size;
|
||||
};
|
||||
assert_eq!(
|
||||
shell.click(&mut s, middle(rect), size, false, false, false),
|
||||
[CLICK]
|
||||
);
|
||||
assert_eq!(shell.open_page(), Some(next));
|
||||
seen.push(next);
|
||||
};
|
||||
assert_eq!(seen, (1..=PAGES).collect::<Vec<_>>());
|
||||
assert_eq!(shell.strip_ends(), Some((true, false)));
|
||||
// At the last page, the right of the row picks nothing: its arrow is dimmed.
|
||||
let cols = Columns::of(size.0, size.1);
|
||||
let [x, y, w, h] = cols.page(2);
|
||||
assert!(
|
||||
shell
|
||||
.click(
|
||||
&mut s,
|
||||
(x + w - 2.0, y + h / 2.0),
|
||||
size,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(shell.open_page(), Some(PAGES));
|
||||
// The page before it from the keyboard: Enter on its arrow opens it, and the focus follows
|
||||
// it to the middle of the row.
|
||||
let before = shell
|
||||
.page_places()
|
||||
.iter()
|
||||
.find(|p| p.0 == PAGES - 1)
|
||||
.unwrap()
|
||||
.2;
|
||||
assert_eq!(before, Part::Prev);
|
||||
shell.set_focus(Some(Target::Page(PAGES - 1)));
|
||||
assert_eq!(shell.key(&mut s, K::Enter, None, false, size), [CLICK]);
|
||||
drawn(&mut shell, &s, SHORT_WINDOW);
|
||||
let now = shell
|
||||
.page_places()
|
||||
.iter()
|
||||
.find(|p| p.0 == PAGES - 1)
|
||||
.unwrap()
|
||||
.2;
|
||||
assert_eq!(now, Part::Shown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn while_searching_the_collapsed_rows_arrows_skip_pages_without_matches() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Pages>::default();
|
||||
// Rows 1, 10, 11 and 12: none before the first. The first page stays, as it has no match.
|
||||
shell.set_search("row 1");
|
||||
let size = drawn(&mut shell, &s, SHORT_WINDOW);
|
||||
let pages = |shell: &Shell<Pages>| shell.page_places().iter().map(|p| p.0).collect::<Vec<_>>();
|
||||
assert_eq!(pages(&shell), [HOME, 1, 10]);
|
||||
assert_eq!(shell.strip_ends(), Some((false, true)));
|
||||
// Its arrow goes to the next match, which has the first one before it and the last after.
|
||||
let next = shell.page_places()[2].1;
|
||||
shell.click(&mut s, middle(next), size, false, false, false);
|
||||
drawn(&mut shell, &s, SHORT_WINDOW);
|
||||
assert_eq!(pages(&shell), [HOME, 1, 10, 11]);
|
||||
assert_eq!(shell.strip_ends(), Some((true, true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_controllers_bumpers_find_the_collapsed_rows_parts_as_tabs() {
|
||||
let s = Settings::default();
|
||||
let mut shell = Shell::<Pages>::default();
|
||||
shell.show(5);
|
||||
drawn(&mut shell, &s, SHORT_WINDOW);
|
||||
let tabs: Vec<(f32, bool)> = crate::widgets::last_targets()
|
||||
.into_iter()
|
||||
.filter_map(|t| match t.kind {
|
||||
crate::widgets::Kind::Tab(open) => Some((t.rect[0], open)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// The first page, the page before, the open one (lit) and the one after.
|
||||
assert_eq!(tabs.len(), 4, "{tabs:?}");
|
||||
assert_eq!(tabs.iter().filter(|t| t.1).count(), 1);
|
||||
let (before, open, after) = (tabs[1].0, tabs[2], tabs[3].0);
|
||||
assert!(open.1 && before < open.0 && open.0 < after);
|
||||
// The arrows are vanilla's Previous Page and Next Page to the narrator.
|
||||
let labels = crate::widgets::last_labelled();
|
||||
let named = |key| {
|
||||
labels
|
||||
.iter()
|
||||
.any(|w| w.label == lang::get(key) && w.enabled)
|
||||
};
|
||||
assert!(named(PREVIOUS_PAGE) && named(NEXT_PAGE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_opens_a_page_out_of_the_list_and_escape_comes_back_to_the_links_page() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Pages>::default();
|
||||
// Found by the search from another page: back is the page the link is on.
|
||||
shell.show(HOME);
|
||||
shell.set_search("deeper");
|
||||
let at = shell.point_at(&s, SIZE, LINK);
|
||||
assert_eq!(shell.click(&mut s, at, SIZE, false, false, false), [CLICK]);
|
||||
// The page opens alone, the search cleared; the list lights the page the link was on.
|
||||
assert_eq!(shell.open_page(), Some(DEEP));
|
||||
assert_eq!(shell.listed(&s, SIZE), [(DEEP, u32::from(DEEP))]);
|
||||
drawn(&mut shell, &s, TALL);
|
||||
assert_eq!(shell.lit_page(), Some(1));
|
||||
assert!(shell.key(&mut s, K::Escape, None, false, SIZE).is_empty());
|
||||
assert_eq!(shell.open_page(), Some(1));
|
||||
assert_eq!(
|
||||
shell.key(&mut s, K::Escape, None, false, SIZE),
|
||||
[Output::Done]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_shows_beside_undo_while_changes_wait_and_goes_with_them() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Pages>::default();
|
||||
let done = Columns::of(SIZE.0, SIZE.1).button(0);
|
||||
assert_eq!(shell.foot_at(&s, SIZE), [(Target::Done, done)]);
|
||||
let at = shell.point_at(&s, SIZE, WAITS);
|
||||
shell.click(&mut s, at, SIZE, false, false, false);
|
||||
let foot = shell.foot_at(&s, SIZE);
|
||||
let targets: Vec<_> = foot.iter().map(|f| f.0).collect();
|
||||
assert_eq!(targets, [Target::Undo, Target::Apply, Target::Done]);
|
||||
// Side by side over Done, as wide as it together.
|
||||
let ([ux, uy, uw, _], [ax, ay, aw, _]) = (foot[0].1, foot[1].1);
|
||||
assert_eq!((uy, ay), (foot[2].1[1] - FOOT_GAP - style::BUTTON_H, uy));
|
||||
assert!(ux + uw < ax && ax + aw == done[0] + done[2] && ux == done[0]);
|
||||
assert_eq!(
|
||||
shell.click(&mut s, middle(foot[1].1), SIZE, false, false, false),
|
||||
[CLICK, CHANGED]
|
||||
);
|
||||
assert_eq!(shell.screen.applied, 1);
|
||||
let targets: Vec<_> = shell.foot_at(&s, SIZE).iter().map(|f| f.0).collect();
|
||||
assert_eq!(targets, [Target::Undo, Target::Done]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_right_button_steps_no_choice_as_vanillas_cycle_button() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let choice = middle_of(&mut shell, CHOICE, SIZE);
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, choice, SIZE, false, true, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(shell.screen.pressed.is_empty());
|
||||
}
|
||||
@@ -11,18 +11,6 @@
|
||||
"horizon.options.gameplay_guide.basic": "Basic",
|
||||
"horizon.options.gameplay_guide.full": "Full",
|
||||
"horizon.options.gameplay_guide.off": "Off",
|
||||
"horizon.options.radial_mode": "Radial Menu",
|
||||
"horizon.options.radial_mode.about": "Hold the opener for the radial menu, or press it once to open it and again to close it.",
|
||||
"horizon.options.radial_mode.hold.about": "Hold the opener and let go to run what the stick points at.",
|
||||
"horizon.options.radial_mode.toggle.about": "Press the opener to open it; A or the opener again runs what the stick points at, B closes it.",
|
||||
"horizon.options.radial_stick": "Radial Menu Selects With",
|
||||
"horizon.options.radial_stick.about": "The stick that points at the radial menu's slots.",
|
||||
"horizon.options.radial_stick.right": "Right Stick",
|
||||
"horizon.options.radial_stick.left": "Left Stick",
|
||||
"horizon.options.radial_stick.either": "Either",
|
||||
"horizon.options.radial_stick.right.about": "The right stick points at the slots; the left still walks.",
|
||||
"horizon.options.radial_stick.left.about": "The left stick points and you stand still. D-pad Right is under that thumb: pick an opener on the Radial Menu page.",
|
||||
"horizon.options.radial_stick.either.about": "The last stick pushed points; neither walks nor turns the view. Only a bumper, a trigger or View can open the menu.",
|
||||
"horizon.controller.guide.use": "Use",
|
||||
"horizon.controller.guide.open": "Open",
|
||||
"horizon.controller.guide.ride": "Ride",
|
||||
@@ -141,82 +129,6 @@
|
||||
"horizon.pages.shader_options": "Shader Options",
|
||||
"horizon.pages.controller": "Controller Settings",
|
||||
"horizon.pages.controller_binds": "Controller Binds",
|
||||
"horizon.pages.controller_radial": "Radial Menu",
|
||||
"horizon.radial.page.general": "General",
|
||||
"horizon.radial.page.camera": "Camera",
|
||||
"horizon.radial.release_stick": "Let go of the stick",
|
||||
"horizon.radial.action.key_inventory": "Inventory",
|
||||
"horizon.radial.action.key_chat": "Chat",
|
||||
"horizon.radial.action.key_command": "Commands",
|
||||
"horizon.radial.action.key_advancements": "Advancements",
|
||||
"horizon.radial.action.key_togglePerspective": "Perspective",
|
||||
"horizon.radial.action.key_swapOffhand": "Swap Hands",
|
||||
"horizon.radial.action.key_drop": "Drop",
|
||||
"horizon.radial.action.key_pickItem": "Pick Block",
|
||||
"horizon.radial.action.key_screenshot": "Screenshot",
|
||||
"horizon.radial.action.key_toggleGui": "Hide HUD",
|
||||
"horizon.radial.action.key_smoothCamera": "Cinematic Camera",
|
||||
"horizon.radial.action.key_socialInteractions": "Social",
|
||||
"horizon.radial.action.key_emote": "Emote",
|
||||
"horizon.radial.action.pad_hotbarPrevious": "Previous Slot",
|
||||
"horizon.radial.action.pad_hotbarNext": "Next Slot",
|
||||
"horizon.radial.action.key_fullscreen": "Fullscreen",
|
||||
"horizon.radial.action.key_quickActions": "Quick Actions",
|
||||
"horizon.radial.action.key_debug_overlay": "Debug Screen",
|
||||
"horizon.radial.missing": "Missing",
|
||||
"horizon.radial.why.missing": "Unknown action",
|
||||
"horizon.radial.why.cosmetics": "Needs cosmetics on",
|
||||
"horizon.radial.why.quick_actions": "This server has no Quick Actions",
|
||||
"horizon.radial.why.social": "Only on a server",
|
||||
"horizon.radial.next": "→ %s",
|
||||
"horizon.radial.state.first_person": "First Person",
|
||||
"horizon.radial.state.third_person_back": "Third Person",
|
||||
"horizon.radial.state.third_person_front": "Front View",
|
||||
"horizon.radial.state.shown": "Shown",
|
||||
"horizon.radial.state.hidden": "Hidden",
|
||||
"horizon.radial.state.on": "On",
|
||||
"horizon.radial.state.off": "Off",
|
||||
"horizon.radial.pick_title": "Choose an Action",
|
||||
"horizon.radial.group.actions": "Actions",
|
||||
"horizon.radial.pick.slot": "%s slot of %s",
|
||||
"horizon.radial.pick.about": "Puts this action in the slot and goes back. The slot's own is marked.",
|
||||
"horizon.radial.pick.current": "Current",
|
||||
"horizon.radial.missing.about": "This game doesn't know the action %s: the slot keeps it and runs nothing. Press or Right to put another here.",
|
||||
"horizon.radial.page.new": "New Page",
|
||||
"horizon.pages.open.controller_radial": "Radial Menu...",
|
||||
"horizon.radial.group.slots": "Slots",
|
||||
"horizon.radial.group.page": "Page",
|
||||
"horizon.radial.group.all": "All Pages",
|
||||
"horizon.radial.empty": "Empty",
|
||||
"horizon.radial.page_of": "Page %s of %s. Each slot is a direction of the selecting stick while the opener is held.",
|
||||
"horizon.radial.slot.0": "Up",
|
||||
"horizon.radial.slot.1": "Up-Right",
|
||||
"horizon.radial.slot.2": "Right",
|
||||
"horizon.radial.slot.3": "Down-Right",
|
||||
"horizon.radial.slot.4": "Down",
|
||||
"horizon.radial.slot.5": "Down-Left",
|
||||
"horizon.radial.slot.6": "Left",
|
||||
"horizon.radial.slot.7": "Up-Left",
|
||||
"horizon.radial.slot.about": "The action this direction runs. Press it for the list of actions, Right for the next one, Left or Shift-click for the one before.",
|
||||
"horizon.radial.rename": "Rename Page",
|
||||
"horizon.radial.rename.about": "Give this page a name of your own.",
|
||||
"horizon.radial.name_title": "Name This Page",
|
||||
"horizon.radial.new_page.about": "Adds an empty page after the last one, and shows it.",
|
||||
"horizon.radial.new_page.full": "The menu holds at most %s pages.",
|
||||
"horizon.radial.delete_page": "Delete Page",
|
||||
"horizon.radial.delete_page.about": "Removes this page and its slots at once. Undo brings it back.",
|
||||
"horizon.radial.delete_page.last": "The menu keeps at least one page.",
|
||||
"horizon.radial.reset": "Reset All Pages",
|
||||
"horizon.radial.reset.about": "Puts back the General and Camera pages and removes the rest, at once. Undo brings them back.",
|
||||
"horizon.radial.reset.default": "The pages are the defaults already.",
|
||||
"horizon.radial.opener": "Opener",
|
||||
"horizon.radial.opener.about": "The button held to open the menu. Press, then press the controller button to use: a bumper or the D-pad, not A, B, X, Y or the right stick, which steer and cancel.",
|
||||
"horizon.radial.opener.about.left": "The button held to open the menu. Press, then press the controller button to use: a bumper, A, X or Y, not the D-pad or the left stick, which steer, nor B, which cancels.",
|
||||
"horizon.radial.opener.about.either": "The button held to open the menu. Press, then press the controller button to use: a bumper, a trigger or View; both thumbs steer, and B cancels.",
|
||||
"horizon.radial.unbound": "No button opens the menu (one under the thumb that selects is let go): press, then press a controller button.",
|
||||
"horizon.radial.conflict": "Shared with another action: in play the menu takes that button. Press to pick another.",
|
||||
"Radial Menu": "Radial Menu",
|
||||
"Named pages of eight actions, held open with a button.": "Named pages of eight actions, held open with a button.",
|
||||
"horizon.pages.extras": "Horizon Extras",
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.studio": "Replay Studio",
|
||||
@@ -1108,7 +1020,6 @@
|
||||
"Press the button for this. %s cancels.": "Press the button for this. %s cancels.",
|
||||
"This button also does: %s": "This button also does: %s",
|
||||
"Not bound: press this row, then the button that should do it.": "Not bound: press this row, then the button that should do it.",
|
||||
"horizon.pad.action.radial": "Radial Menu",
|
||||
"horizon.pad.notice.connected.press": "Press to use it",
|
||||
"horizon.recipe_transfer.missing": "Missing Items",
|
||||
"horizon.recipe_transfer.no_room": "Inventory full",
|
||||
|
||||
@@ -1005,7 +1005,7 @@ pub fn mob_to_nbt(
|
||||
);
|
||||
}
|
||||
if id == "minecraft:piglin" {
|
||||
crate::mobs::piglin::write_guarded_anger(m, map);
|
||||
write_guarded_piglin_anger(m, map);
|
||||
// InventoryCarrier.writeInventoryToTag.
|
||||
map.insert(
|
||||
"Inventory".into(),
|
||||
@@ -2232,9 +2232,6 @@ pub fn mob_from_nbt_with_random(
|
||||
) {
|
||||
m.extra.flag2 = flag(tag, "IsImmuneToZombification");
|
||||
m.extra.counter2 = number(tag.get("TimeInOverworld")).unwrap_or(0) as i32;
|
||||
if m.species() == crate::mobs::Species::Piglin {
|
||||
crate::mobs::piglin::read_guarded_anger(&mut m, tag);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Hoglin {
|
||||
m.extra.cannot_be_hunted = flag(tag, "CannotBeHunted");
|
||||
}
|
||||
@@ -5972,6 +5969,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Brain.Packed`: the guarded-block live ANGRY_AT entry. The existing
|
||||
/// anger counter supplies its remaining long TTL, not an absolute timestamp.
|
||||
fn write_guarded_piglin_anger(m: &Mob, map: &mut Compound) {
|
||||
let Some(actor) = m.extra.piglin.guarded_angry_at else {
|
||||
return;
|
||||
};
|
||||
let brain = map
|
||||
.entry("Brain".into())
|
||||
.or_insert_with(|| Tag::Compound(Compound::new()));
|
||||
if !matches!(brain, Tag::Compound(_)) {
|
||||
*brain = Tag::Compound(Compound::new());
|
||||
}
|
||||
let Tag::Compound(brain) = brain else { return };
|
||||
let memories = brain
|
||||
.entry("memories".into())
|
||||
.or_insert_with(|| Tag::Compound(Compound::new()));
|
||||
if !matches!(memories, Tag::Compound(_)) {
|
||||
*memories = Tag::Compound(Compound::new());
|
||||
}
|
||||
let Tag::Compound(memories) = memories else {
|
||||
return;
|
||||
};
|
||||
if let Some(uuid) = actor.filter(|_| m.extra.anger > 0) {
|
||||
// ExpirableValue.codec: the UUID value and optional remaining Long TTL.
|
||||
memories.insert(
|
||||
"minecraft:angry_at".into(),
|
||||
Tag::compound([
|
||||
("value", Tag::IntArray(uuid.to_vec())),
|
||||
("ttl", Tag::Long(i64::from(m.extra.anger))),
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
memories.remove(&"minecraft:angry_at".into());
|
||||
}
|
||||
}
|
||||
|
||||
/// `Brain`'s two measured Warden Unit memories, using actual existing slots.
|
||||
fn write_warden_owned_memories(m: &Mob, map: &mut Compound) {
|
||||
let brain = map
|
||||
@@ -9342,59 +9375,6 @@ mod piglin_live_anger_memory_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Piglin.readAdditionalSaveData` reads the brain's `ANGRY_AT` memory
|
||||
/// back: the actor's UUID and the ticks left (`ExpirableValue`'s TTL).
|
||||
/// Horizon wrote the memory but never read it, so a piglin that a player
|
||||
/// had angered came back calm after its chunk reloaded.
|
||||
#[test]
|
||||
fn a_saved_guarded_anger_loads_its_uuid_and_ticks() {
|
||||
// A whole saved piglin: its id, a position and the brain's memory.
|
||||
let saved = |memory: Tag| {
|
||||
Tag::compound([
|
||||
("id", Tag::String("minecraft:piglin".into())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(0.0f64.to_bits()),
|
||||
Tag::Double(64.0f64.to_bits()),
|
||||
Tag::Double(0.0f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"Brain",
|
||||
Tag::compound([("memories", Tag::compound([("minecraft:angry_at", memory)]))]),
|
||||
),
|
||||
])
|
||||
};
|
||||
let template = mob();
|
||||
let types = [(template.kind.id.clone(), template.kind.clone())].into();
|
||||
let uuid = Tag::IntArray(vec![90, 91, 92, 93]);
|
||||
let ticking = saved(Tag::compound([
|
||||
("value", uuid.clone()),
|
||||
("ttl", Tag::Long(99)),
|
||||
]));
|
||||
let loaded = mob_from_nbt(&ticking, &types, 2, None).unwrap();
|
||||
assert_eq!(loaded.extra.anger, 99);
|
||||
assert_eq!(
|
||||
loaded.extra.piglin.guarded_angry_at,
|
||||
Some(Some([90, 91, 92, 93]))
|
||||
);
|
||||
// `ExpirableValue` leaves the TTL out when it never expires, and a
|
||||
// long past the counter's range holds as long as it can: the counter
|
||||
// keeps the longest it can while the goal ticks it down.
|
||||
for memory in [
|
||||
Tag::compound([("value", uuid.clone())]),
|
||||
Tag::compound([("value", uuid.clone()), ("ttl", Tag::Long(1 << 40))]),
|
||||
] {
|
||||
let loaded = mob_from_nbt(&saved(memory), &types, 3, None).unwrap();
|
||||
assert_eq!(loaded.extra.anger, i32::MAX);
|
||||
assert_eq!(
|
||||
loaded.extra.piglin.guarded_angry_at,
|
||||
Some(Some([90, 91, 92, 93]))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_owned_memory_cannot_resurrect_stale_raw_anger() {
|
||||
let mut m = mob();
|
||||
|
||||
@@ -5832,7 +5832,7 @@ impl Game<'_> {
|
||||
self.draw_debug_overlay(&mut batch, size);
|
||||
self.frame_graph
|
||||
.draw(self.settings.horizon.overlay, &mut batch, &self.gui.atlas);
|
||||
self.draw_pad_over_hud(&mut batch);
|
||||
self.draw_pad_cursor(&mut batch);
|
||||
batch
|
||||
}
|
||||
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
@@ -5963,7 +5963,6 @@ impl Game<'_> {
|
||||
if self.camera_type.first_person()
|
||||
&& (!spectator || self.spectator_crosshair())
|
||||
&& !self.debug_crosshair()
|
||||
&& !self.overlay_holds_view()
|
||||
{
|
||||
let attack = (indicator == 1).then_some(strength);
|
||||
let full = attack.is_some_and(|a| {
|
||||
@@ -6093,7 +6092,6 @@ impl Game<'_> {
|
||||
Some(draws) => draws,
|
||||
None => self.health_hud.layout(&inputs, wi, hi),
|
||||
};
|
||||
self.keep_status_rows(&draws, wi, hi);
|
||||
// The rows draw in one pass here, so a mod's `player_health`
|
||||
// layer stands for the armor, food and air rows too.
|
||||
self.gui_layer("minecraft:player_health", &mut batch, |g, batch| {
|
||||
@@ -6193,9 +6191,7 @@ impl Game<'_> {
|
||||
if self.screen == Screen::Chat {
|
||||
self.draw_chat(&mut batch);
|
||||
} else if show_hud {
|
||||
if !self.overlay_holds_view() {
|
||||
self.gui_layer("minecraft:chat", &mut batch, |g, batch| g.draw_chat(batch));
|
||||
}
|
||||
self.gui_layer("minecraft:chat", &mut batch, |g, batch| g.draw_chat(batch));
|
||||
self.gui_layer("minecraft:tab_list", &mut batch, |g, batch| {
|
||||
g.draw_tab_list(batch);
|
||||
});
|
||||
@@ -6266,7 +6262,11 @@ impl Game<'_> {
|
||||
// after this point and so over what a mod drew.
|
||||
self.fire_screen_render_post(&mut batch);
|
||||
self.draw_saving_indicator(&mut batch);
|
||||
self.draw_every_toast(&mut batch, !show_hud);
|
||||
// ToastManager.extractRenderState: F1 hides the toasts too.
|
||||
self.draw_toasts(&mut batch, !show_hud);
|
||||
self.draw_tutorial_toasts(&mut batch, !show_hud);
|
||||
self.draw_pack_toasts(&mut batch, !show_hud);
|
||||
self.draw_music_toast(&mut batch, !show_hud);
|
||||
if self.server_packs.prompt.is_none() {
|
||||
self.remote_hud(&mut batch);
|
||||
self.draw_sign_editor(&mut batch);
|
||||
@@ -9424,7 +9424,6 @@ impl Game<'_> {
|
||||
self.camera_type.first_person(),
|
||||
self.ui.hide_gui,
|
||||
self.shows_panorama(),
|
||||
self.overlay_holds_view(),
|
||||
) {
|
||||
let scale = HudBatch::new(size).scale;
|
||||
crosshair.debug_crosshair(at, view_pitch, view_yaw, scale);
|
||||
@@ -9980,8 +9979,10 @@ impl Game<'_> {
|
||||
&mut self.toggles,
|
||||
[self.settings.toggle_crouch, self.settings.toggle_sprint],
|
||||
[
|
||||
self.pad.holds_action("key.sneak"),
|
||||
self.pad.holds_action("key.sprint"),
|
||||
self.pad
|
||||
.holds_action(&self.settings.controller, "key.sneak"),
|
||||
self.pad
|
||||
.holds_action(&self.settings.controller, "key.sprint"),
|
||||
],
|
||||
);
|
||||
self.sync_toggle(SNEAK);
|
||||
|
||||
@@ -633,26 +633,6 @@ impl Game<'_> {
|
||||
}
|
||||
ok()
|
||||
}
|
||||
// The real event, so focus loss reaches all that a window's does (the radial menu
|
||||
// cancels, held keys go).
|
||||
"pad" if words.get(1) == Some(&"focus") => {
|
||||
let focused = match arg(2)? {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => return Err("pad focus true|false".into()),
|
||||
};
|
||||
let id = self
|
||||
.window
|
||||
.as_ref()
|
||||
.map_or_else(winit::window::WindowId::dummy, |w| w.id());
|
||||
self.handle_window_event(
|
||||
event_loop,
|
||||
id,
|
||||
winit::event::WindowEvent::Focused(focused),
|
||||
super::input_trace::InputOrigin::Agent,
|
||||
);
|
||||
ok()
|
||||
}
|
||||
"pad" => Ok(Some(self.pad_command(&words)?)),
|
||||
"key" => {
|
||||
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
|
||||
@@ -665,8 +645,6 @@ impl Game<'_> {
|
||||
}
|
||||
if arg(2)? != "up" {
|
||||
self.input_keyboard_used();
|
||||
// As a window's key press: the radial menu closes, and the key goes on.
|
||||
self.pad_radial_cancel();
|
||||
}
|
||||
// The command confirmation takes the keys; none go past it.
|
||||
if self.command_prompt.is_some() {
|
||||
@@ -851,10 +829,6 @@ impl Game<'_> {
|
||||
)
|
||||
.into());
|
||||
}
|
||||
// As a window's click: the radial menu closes, and the click goes on.
|
||||
if arg(2)? != "up" {
|
||||
self.pad_radial_cancel();
|
||||
}
|
||||
match arg(2)? {
|
||||
"down" => {
|
||||
self.extras_mouse(button, true);
|
||||
@@ -1129,7 +1103,6 @@ impl Game<'_> {
|
||||
"keybinds" => Page::KeyBinds,
|
||||
"controller" => Page::Controller,
|
||||
"controller-binds" => Page::PadBinds,
|
||||
"controller-radial" => Page::ControllerRadial,
|
||||
"mouse" => Page::Mouse,
|
||||
"chat" => Page::Chat,
|
||||
"accessibility" => Page::Accessibility,
|
||||
|
||||
@@ -235,14 +235,8 @@ fn glfw_button_indices_are_glfw_indices() {
|
||||
/// F3 left on when leaving a world kept the axes on the title screen.
|
||||
#[test]
|
||||
fn the_debug_crosshair_is_gone_once_the_world_is() {
|
||||
assert!(debug_crosshair_drawn(true, true, false, false, false));
|
||||
assert!(!debug_crosshair_drawn(true, true, false, true, false));
|
||||
}
|
||||
|
||||
/// F3's 3D crosshair drew beside the radial menu's stick, which the 2D one gives way to.
|
||||
#[test]
|
||||
fn the_debug_crosshair_gives_way_to_the_radial_menu() {
|
||||
assert!(!debug_crosshair_drawn(true, true, false, false, true));
|
||||
assert!(debug_crosshair_drawn(true, true, false, false));
|
||||
assert!(!debug_crosshair_drawn(true, true, false, true));
|
||||
}
|
||||
|
||||
/// Opening a world in front of a block left its outline over Loading terrain's panorama (#12872).
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
pub(super) mod emote_wheel;
|
||||
pub(super) mod emotes;
|
||||
mod fx;
|
||||
mod settings;
|
||||
|
||||
use super::Game;
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
@@ -244,16 +245,13 @@ impl Cosmetics {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player whose UUID `uuid` gives wears, if the game shows it: the player's own model
|
||||
/// (`own`) while Show my cosmetics is on, another player unless others' cosmetics are hidden (by
|
||||
/// choice, or by owning none). Hidden, the service is never asked about them.
|
||||
/// What the player whose UUID `uuid` gives wears, if the game shows it (`shown`:
|
||||
/// [`Seat::shows`]). Hidden, the service is never asked about them.
|
||||
fn worn_of(
|
||||
client: &horizon_cosmetics::Cosmetics,
|
||||
said: &Said,
|
||||
own: bool,
|
||||
shown: bool,
|
||||
uuid: impl FnOnce() -> Option<u128>,
|
||||
) -> Option<horizon_cosmetics::Worn> {
|
||||
let shown = if own { said.mine } else { !said.others_hidden };
|
||||
if !shown {
|
||||
return None;
|
||||
}
|
||||
@@ -363,6 +361,8 @@ struct Seat {
|
||||
wanted: Vec<u64>,
|
||||
/// Companions, trails and auras (`cosmetics/fx.rs`).
|
||||
fx: fx::Fx,
|
||||
/// The player's settings (`cosmetics/settings.rs`).
|
||||
settings: settings::Settings,
|
||||
}
|
||||
|
||||
/// An item the hooks show: its sprite's id (never reused) and a mesh per bone it rides, held
|
||||
@@ -397,22 +397,10 @@ fn bone(b: horizon_cosmetics::Bone) -> Bone {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the game tells the hooks before each call, until the settings move here (spec #14091
|
||||
/// step 5a) and the player's emote with them (step 4).
|
||||
/// What the game tells the hooks before each call: the player's emote, until it moves here
|
||||
/// (spec #14091 step 4).
|
||||
#[derive(Default)]
|
||||
struct Said {
|
||||
mine: bool,
|
||||
listed: bool,
|
||||
/// Other players' cosmetics are shown (not "name tags only", not hidden).
|
||||
others_shown: bool,
|
||||
/// Other players' cosmetics are hidden (not even name tag styles).
|
||||
others_hidden: bool,
|
||||
/// Horizon's mark by Horizon players' names.
|
||||
marks: bool,
|
||||
/// Other players' trails and auras are hidden.
|
||||
hide_fx: bool,
|
||||
/// Cosmetics in single player: the signed-in account's on the player's own model.
|
||||
solo: bool,
|
||||
/// My emote pulled the camera back: when it started (the own model's age).
|
||||
pull: Option<f32>,
|
||||
/// My emote while it plays: its id and whether it loops.
|
||||
@@ -421,19 +409,6 @@ struct Said {
|
||||
first: Option<Arc<Item>>,
|
||||
}
|
||||
|
||||
impl Said {
|
||||
/// What the game's settings say.
|
||||
fn settings(&mut self, extras: &horizon_client::extras::Extras) {
|
||||
self.mine = extras.cosmetics_mine;
|
||||
self.listed = extras.horizon_listed;
|
||||
self.others_shown = extras.effective_others() == Others::Shown;
|
||||
self.others_hidden = extras.effective_others() == Others::Hidden;
|
||||
self.marks = extras.horizon_marks;
|
||||
self.hide_fx = extras.cosmetics_hide_fx;
|
||||
self.solo = extras.cosmetics_solo;
|
||||
}
|
||||
}
|
||||
|
||||
/// One [`Seat`] per splitscreen player.
|
||||
static SERVICES: Mutex<Vec<Seat>> = Mutex::new(Vec::new());
|
||||
|
||||
@@ -448,8 +423,19 @@ fn service<R>(f: impl FnOnce(&mut Seat) -> R) -> R {
|
||||
}
|
||||
|
||||
/// The cosmetics' hooks, registered when [`enabled`].
|
||||
pub(super) struct Hooks;
|
||||
pub(super) static HOOKS: Hooks = Hooks;
|
||||
struct Hooks;
|
||||
static HOOKS: Hooks = Hooks;
|
||||
|
||||
/// Registers the cosmetics' settings lines, and while they are [`enabled`] their hooks and their
|
||||
/// rows on the Extras page. The lines always: a game without cosmetics keeps the Wardrobe's.
|
||||
pub(super) fn register() -> Result<(), horizon_hooks::TooLate> {
|
||||
horizon_hooks::settings::register(&settings::STORE)?;
|
||||
if enabled() {
|
||||
horizon_hooks::register(&HOOKS)?;
|
||||
horizon_hooks::pages::register(&settings::SECTION)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl horizon_hooks::Hooks for Hooks {
|
||||
fn frame(&self, frame: &Frame, sprites: &mut dyn SpriteStore) {
|
||||
@@ -475,7 +461,7 @@ impl horizon_hooks::Hooks for Hooks {
|
||||
|
||||
fn player_mark(&self, uuid: u128) -> Option<Mark> {
|
||||
service(|s| {
|
||||
let client = s.client.as_ref().filter(|_| s.said.marks)?;
|
||||
let client = s.client.as_ref().filter(|_| s.settings.marks)?;
|
||||
client.frame().horizon(uuid).then_some(MARK)
|
||||
})
|
||||
}
|
||||
@@ -498,6 +484,22 @@ impl horizon_hooks::Hooks for Hooks {
|
||||
}
|
||||
|
||||
impl Seat {
|
||||
/// How much of other players' cosmetics shows: the player's choice, else all of it for an
|
||||
/// account that owns a cosmetic and none for any other (a vanilla game).
|
||||
fn others(&self) -> Others {
|
||||
Others::effective(self.settings.others, self.owner.owns)
|
||||
}
|
||||
|
||||
/// Whether the cosmetics of the player's own model (`own`) or of another player show at all:
|
||||
/// mine with Show my cosmetics on, others' unless hidden.
|
||||
fn shows(&self, own: bool) -> bool {
|
||||
if own {
|
||||
self.settings.mine
|
||||
} else {
|
||||
self.others() != Others::Hidden
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a frame: starts the service's client on first use, asks whether the account owns a
|
||||
/// cosmetic, and tells the service what the account shows.
|
||||
fn frame(&mut self) {
|
||||
@@ -570,7 +572,9 @@ impl Seat {
|
||||
// My emote: only while I show myself as a Horizon player (playing one says so).
|
||||
let playing = said.playing.as_ref();
|
||||
let looping = playing.is_some_and(|(_, looping)| *looping);
|
||||
let wish = playing.map(|(id, _)| id.as_str()).filter(|_| said.listed);
|
||||
let wish = playing
|
||||
.map(|(id, _)| id.as_str())
|
||||
.filter(|_| self.settings.listed);
|
||||
let o = &mut self.emote_out;
|
||||
let changed = o.sent.as_ref().map(Option::as_deref) != Some(wish);
|
||||
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
|
||||
@@ -594,10 +598,10 @@ impl Seat {
|
||||
let _ = tx.send(send_emote(&kept, wish.as_deref()));
|
||||
})
|
||||
.ok();
|
||||
} else if self.mine.due(said.mine) {
|
||||
self.mine.start(said.mine, send_mine);
|
||||
} else if self.listed.due(said.listed) {
|
||||
self.listed.start(said.listed, send_listed);
|
||||
} else if self.mine.due(self.settings.mine) {
|
||||
self.mine.start(self.settings.mine, send_mine);
|
||||
} else if self.listed.due(self.settings.listed) {
|
||||
self.listed.start(self.settings.listed, send_listed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -609,18 +613,17 @@ impl Seat {
|
||||
m: &PlayerModel,
|
||||
sprites: &dyn Sprites,
|
||||
) -> Option<NameDecoration> {
|
||||
let said = &self.said;
|
||||
let own = m.kind == Kind::Own;
|
||||
let styles = if own { said.mine } else { !said.others_hidden };
|
||||
if !(styles || said.marks) {
|
||||
let (styles, marks) = (self.shows(own), self.settings.marks);
|
||||
if !(styles || marks) {
|
||||
return None;
|
||||
}
|
||||
let uuid = self.uuid_of(m)?;
|
||||
let mut frame = self.client.as_ref()?.frame();
|
||||
let mark = (said.marks && frame.horizon(uuid)).then_some(MARK);
|
||||
let mark = (marks && frame.horizon(uuid)).then_some(MARK);
|
||||
let worn = if styles { frame.worn(uuid) } else { None };
|
||||
drop(frame);
|
||||
let others = if said.others_shown {
|
||||
let others = if self.others() == Others::Shown {
|
||||
Others::Shown
|
||||
} else {
|
||||
Others::NameTags
|
||||
@@ -634,7 +637,7 @@ impl Seat {
|
||||
/// The UUID the service knows `m` by: a player's, or a mannequin's id with local items.
|
||||
fn uuid_of(&self, m: &PlayerModel) -> Option<u128> {
|
||||
match m.kind {
|
||||
Kind::Own => own_uuid(m.uuid, self.solo, self.said.solo, || {
|
||||
Kind::Own => own_uuid(m.uuid, self.solo, self.settings.solo, || {
|
||||
horizon_client::auth::current().map(|s| s.uuid)
|
||||
}),
|
||||
Kind::Mannequin if self.local => Some(u128::from(m.id)),
|
||||
@@ -704,10 +707,9 @@ impl Seat {
|
||||
if m.invisible {
|
||||
return;
|
||||
}
|
||||
let said = &self.said;
|
||||
let own = m.kind == Kind::Own;
|
||||
// "Name tags only": other players' styles beside their names, nothing on their bodies.
|
||||
let bodies = own || said.others_shown;
|
||||
let bodies = own || self.others() == Others::Shown;
|
||||
let ticks = draw.ticks();
|
||||
// An emote's props: its time and weight while it plays.
|
||||
let emote = emotes::pose_playing(draw.body_pose()).and_then(|p| {
|
||||
@@ -715,7 +717,7 @@ impl Seat {
|
||||
(p.item.vertices > 0).then(|| (p.item.clone(), t, w))
|
||||
});
|
||||
let client = self.client.as_ref().filter(|_| bodies);
|
||||
let worn = client.and_then(|c| worn_of(c, &self.said, own, || self.uuid_of(m)));
|
||||
let worn = client.and_then(|c| worn_of(c, self.shows(own), || self.uuid_of(m)));
|
||||
// Items on one bone (a back item, a shoulder pet and a shirt ride the body) share its
|
||||
// matrix: the head, the body and the two arms at most.
|
||||
let mut bones: [Option<(Bone, [[f32; 4]; 4])>; 4] = [None; 4];
|
||||
@@ -771,7 +773,7 @@ impl Seat {
|
||||
let Some(client) = self.client.as_ref().filter(|_| !m.invisible) else {
|
||||
return;
|
||||
};
|
||||
let Some(worn) = worn_of(client, &self.said, true, || self.uuid_of(m)) else {
|
||||
let Some(worn) = worn_of(client, self.shows(true), || self.uuid_of(m)) else {
|
||||
return;
|
||||
};
|
||||
if let Some(item) = worn.iter().find(|i| i.slot == Slot::Companion) {
|
||||
@@ -795,7 +797,7 @@ impl Seat {
|
||||
}
|
||||
|
||||
fn play_others(&self, models: &mut dyn PlayerModels, view: &mut Vec<PlayerModel>) {
|
||||
let shown = self.said.others_shown;
|
||||
let shown = self.others() == Others::Shown;
|
||||
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
|
||||
// and again.
|
||||
if self.local && shown {
|
||||
@@ -860,8 +862,7 @@ impl Seat {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Hands the hooks what the game's settings and the player's emote say (allocating only when
|
||||
/// the emote changes).
|
||||
/// Hands the hooks what the player's emote says (allocating only when it changes).
|
||||
pub(super) fn cosmetics_say(&mut self) {
|
||||
if !enabled() {
|
||||
return;
|
||||
@@ -873,12 +874,10 @@ impl Game<'_> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let extras = &self.settings.extras;
|
||||
let playing = self.cosmetics.emotes.sending();
|
||||
let pull = self.cosmetics.emotes.pull();
|
||||
service(|s| {
|
||||
let said = &mut s.said;
|
||||
said.settings(extras);
|
||||
said.pull = pull;
|
||||
if said.playing.as_ref().map(|(id, l)| (id.as_str(), *l)) != playing {
|
||||
said.playing = playing.map(|(id, l)| (id.to_owned(), l));
|
||||
@@ -889,16 +888,13 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Once a frame, after the hooks: what the game reads of them (the settings' owner flag,
|
||||
/// whether items come from a folder).
|
||||
/// Once a frame, after the hooks: what the game reads of them (whether items come from a
|
||||
/// folder).
|
||||
pub(super) fn cosmetics_frame(&mut self) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
let (owns, local) = service(|s| (s.owner.owns, s.local));
|
||||
// Read every frame: the settings button shows it, and it is never saved.
|
||||
self.settings.extras.cosmetics_owner = owns;
|
||||
self.cosmetics.local = local;
|
||||
self.cosmetics.local = service(|s| s.local);
|
||||
}
|
||||
|
||||
/// Asks the service which emotes the account owns, for the wheel: when it opens, at most
|
||||
@@ -929,7 +925,6 @@ impl Game<'_> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::extras::Extras;
|
||||
use horizon_client::mobs::anim::BodyPose;
|
||||
|
||||
/// A name tag style with a 1×1 mark.
|
||||
@@ -990,31 +985,32 @@ mod tests {
|
||||
|
||||
const OTHER: u128 = 0x1234;
|
||||
|
||||
/// What `extras` tell the hooks.
|
||||
fn said_of(extras: &Extras) -> Said {
|
||||
let mut said = Said::default();
|
||||
said.settings(extras);
|
||||
said
|
||||
/// A seat whose player chose `others` (None: no choice) and whose account `owns` a cosmetic.
|
||||
fn seat_of(others: Option<Others>, owns: bool) -> Seat {
|
||||
let mut seat = Seat::default();
|
||||
seat.settings.others = others;
|
||||
seat.owner.owns = owns;
|
||||
seat
|
||||
}
|
||||
|
||||
/// What another player wears as `extras` shows it, drawn frame after frame for up to
|
||||
/// What another player wears as `seat` shows it, drawn frame after frame for up to
|
||||
/// `patience` (the service answers on its own thread), and what was asked of the service.
|
||||
fn other_player(
|
||||
extras: &Extras,
|
||||
seat: &Seat,
|
||||
patience: Duration,
|
||||
) -> (Option<horizon_cosmetics::Worn>, Vec<String>) {
|
||||
other_player_from(Service::default(), extras, patience)
|
||||
other_player_from(Service::default(), seat, patience)
|
||||
}
|
||||
|
||||
fn other_player_from(
|
||||
service: Service,
|
||||
extras: &Extras,
|
||||
seat: &Seat,
|
||||
patience: Duration,
|
||||
) -> (Option<horizon_cosmetics::Worn>, Vec<String>) {
|
||||
let client = horizon_cosmetics::Cosmetics::new(service.clone(), None);
|
||||
let deadline = Instant::now() + patience;
|
||||
let worn = loop {
|
||||
let worn = worn_of(&client, &said_of(extras), false, || Some(OTHER));
|
||||
let worn = worn_of(&client, seat.shows(false), || Some(OTHER));
|
||||
if worn.is_some() || Instant::now() >= deadline {
|
||||
break worn;
|
||||
}
|
||||
@@ -1026,18 +1022,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_player_who_owns_none_sees_and_asks_nothing_until_they_choose() {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
// No line, owns nothing: a vanilla game, and no request about the other player.
|
||||
let (short, long) = (Duration::from_millis(300), Duration::from_secs(10));
|
||||
let (worn, asked) = other_player(&Extras::default(), short);
|
||||
let (worn, asked) = other_player(&seat_of(None, false), short);
|
||||
assert!(worn.is_none());
|
||||
assert_eq!(asked, Vec::<String>::new());
|
||||
// The service says this account owns a cosmetic: the other player's style shows.
|
||||
let owner = Extras {
|
||||
cosmetics_owner: true,
|
||||
..Extras::default()
|
||||
};
|
||||
let (worn, asked) = other_player(&owner, long);
|
||||
let (worn, asked) = other_player(&seat_of(None, true), long);
|
||||
assert_eq!(
|
||||
worn.map(|w| w.iter().map(|i| i.id.clone()).collect::<Vec<_>>()),
|
||||
Some(vec!["tag".to_owned()])
|
||||
@@ -1047,16 +1038,12 @@ mod tests {
|
||||
format!("POST /v1/worn {{\"players\":[\"{OTHER:032x}\"]}}")
|
||||
);
|
||||
// A choice wins over ownership both ways.
|
||||
let chose = Extras {
|
||||
cosmetics_others: Some(Others::Shown),
|
||||
..Extras::default()
|
||||
};
|
||||
assert!(other_player(&chose, long).0.is_some());
|
||||
let hid = Extras {
|
||||
cosmetics_others: Some(Others::Hidden),
|
||||
..owner
|
||||
};
|
||||
let (worn, asked) = other_player(&hid, short);
|
||||
assert!(
|
||||
other_player(&seat_of(Some(Others::Shown), false), long)
|
||||
.0
|
||||
.is_some()
|
||||
);
|
||||
let (worn, asked) = other_player(&seat_of(Some(Others::Hidden), true), short);
|
||||
assert!(worn.is_none() && asked.is_empty());
|
||||
}
|
||||
|
||||
@@ -1068,14 +1055,12 @@ mod tests {
|
||||
let mine = |solo: bool, shown: bool, patience: Duration| {
|
||||
let service = Service::default();
|
||||
let client = horizon_cosmetics::Cosmetics::new(service.clone(), None);
|
||||
let extras = Extras {
|
||||
cosmetics_solo: shown,
|
||||
..Extras::default()
|
||||
};
|
||||
let uuid = || own_uuid(Some(OFFLINE), solo, extras.cosmetics_solo, || Some(ACCOUNT));
|
||||
let mut seat = Seat::default();
|
||||
seat.settings.solo = shown;
|
||||
let uuid = || own_uuid(Some(OFFLINE), solo, seat.settings.solo, || Some(ACCOUNT));
|
||||
let deadline = Instant::now() + patience;
|
||||
let worn = loop {
|
||||
let worn = worn_of(&client, &said_of(&extras), true, uuid);
|
||||
let worn = worn_of(&client, seat.shows(true), uuid);
|
||||
if worn.is_some() || Instant::now() >= deadline {
|
||||
break worn;
|
||||
}
|
||||
@@ -1108,7 +1093,6 @@ mod tests {
|
||||
#[test]
|
||||
fn a_tall_cosmetic_lifts_the_name_tag_only_while_it_is_drawn() {
|
||||
use super::super::names::{NAME_TAG_UNIT, cosmetic_lift};
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
// Vanilla: EntityAttachment.NAME_TAG of a standing player, 1.8 + 0.5 above the feet;
|
||||
// the tag's lowest pixel 10 text pixels under it.
|
||||
const VANILLA: f64 = 2.3;
|
||||
@@ -1118,25 +1102,17 @@ mod tests {
|
||||
// by the avatar's 0.9375.
|
||||
let star = 0.9375 * (1.501 + 20.0 / 16.0);
|
||||
let both: &'static [(&str, &str)] = &[("tag", TAG), ("crown", CROWN)];
|
||||
let shown = Extras {
|
||||
cosmetics_others: Some(Others::Shown),
|
||||
..Extras::default()
|
||||
};
|
||||
let shown = seat_of(Some(Others::Shown), false);
|
||||
let (short, long) = (Duration::from_millis(300), Duration::from_secs(10));
|
||||
let top_of = |extras: &Extras, patience| {
|
||||
let (worn, _) = other_player_from(Service(Arc::default(), both), extras, patience);
|
||||
body_top(extras.effective_others(), false, worn.as_ref(), |_| false)
|
||||
let top_of = |seat: &Seat, patience| {
|
||||
let (worn, _) = other_player_from(Service(Arc::default(), both), seat, patience);
|
||||
body_top(seat.others(), false, worn.as_ref(), |_| false)
|
||||
};
|
||||
// No cosmetics drawn on the body (a name tag style only): vanilla's height exactly.
|
||||
let (worn, _) = other_player(&shown, long);
|
||||
assert!(worn.is_some());
|
||||
assert_eq!(
|
||||
tag(body_top(
|
||||
shown.effective_others(),
|
||||
false,
|
||||
worn.as_ref(),
|
||||
|_| false
|
||||
)),
|
||||
tag(body_top(shown.others(), false, worn.as_ref(), |_| false)),
|
||||
VANILLA
|
||||
);
|
||||
// A small hat under the tag: vanilla's height too.
|
||||
@@ -1150,22 +1126,19 @@ mod tests {
|
||||
);
|
||||
// A helmet over it: vanilla's height.
|
||||
let (worn, _) = other_player_from(Service(Arc::default(), both), &shown, long);
|
||||
let helmet = body_top(shown.effective_others(), false, worn.as_ref(), |slot| {
|
||||
let helmet = body_top(shown.others(), false, worn.as_ref(), |slot| {
|
||||
slot == Slot::Head
|
||||
});
|
||||
assert_eq!(tag(helmet), VANILLA);
|
||||
// Others' cosmetics on name tags only, or hidden: nothing on the body, vanilla's height.
|
||||
for others in [Others::NameTags, Others::Hidden] {
|
||||
let extras = Extras {
|
||||
cosmetics_others: Some(others),
|
||||
..Extras::default()
|
||||
};
|
||||
let seat = seat_of(Some(others), false);
|
||||
let patience = if others == Others::Hidden {
|
||||
short
|
||||
} else {
|
||||
long
|
||||
};
|
||||
assert_eq!(tag(top_of(&extras, patience)), VANILLA, "{others:?}");
|
||||
assert_eq!(tag(top_of(&seat, patience)), VANILLA, "{others:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1202,11 +1175,10 @@ mod tests {
|
||||
let mut service = Seat {
|
||||
local: true,
|
||||
said: Said {
|
||||
others_shown: true,
|
||||
first: Some(item.clone()),
|
||||
..Said::default()
|
||||
},
|
||||
..Seat::default()
|
||||
..seat_of(Some(Others::Shown), false)
|
||||
};
|
||||
let model = |id, kind, uuid| (PlayerModel::new(id, kind, uuid, 50, false), None);
|
||||
let mut view = View(vec![
|
||||
@@ -1225,7 +1197,7 @@ mod tests {
|
||||
service.tick(&mut view);
|
||||
assert!(Arc::ptr_eq(&first, view.0[1].1.as_ref().unwrap()));
|
||||
// Others' cosmetics not shown: a new mannequin plays nothing.
|
||||
service.said.others_shown = false;
|
||||
service.settings.others = Some(Others::NameTags);
|
||||
view.0.push(model(3, Kind::Mannequin, None));
|
||||
service.tick(&mut view);
|
||||
assert!(view.0[3].1.is_none());
|
||||
@@ -1334,13 +1306,7 @@ mod tests {
|
||||
..Canvas::default()
|
||||
};
|
||||
// Others' cosmetics hidden, no service: the player's own emote still shows.
|
||||
let mut seat = Seat {
|
||||
said: Said {
|
||||
others_hidden: true,
|
||||
..Said::default()
|
||||
},
|
||||
..Seat::default()
|
||||
};
|
||||
let mut seat = seat_of(Some(Others::Hidden), false);
|
||||
// Its texture not in the atlas yet: nothing drawn, the sprite asked for.
|
||||
seat.player_layers(&player, &mut canvas);
|
||||
assert!(canvas.drawn.is_empty());
|
||||
@@ -1393,7 +1359,7 @@ mod tests {
|
||||
assert_eq!(canvas.looks.len(), 1);
|
||||
// Others' trails and auras hidden: theirs go, the player's own stays.
|
||||
canvas.base = Some(glam::Mat4::from_translation(at));
|
||||
seat.said.hide_fx = true;
|
||||
seat.settings.hide_fx = true;
|
||||
seat.fx(&other, &ring, &mut canvas);
|
||||
assert_eq!(canvas.looks.len(), 1);
|
||||
seat.fx(&own, &ring, &mut canvas);
|
||||
|
||||
@@ -93,7 +93,7 @@ impl Seat {
|
||||
/// trails and auras stay hidden with "Hide other players' trails and auras".
|
||||
pub(super) fn fx(&mut self, m: &PlayerModel, item: &Arc<Item>, draw: &mut dyn PlayerDraw) {
|
||||
let own = m.kind == Kind::Own;
|
||||
if !own && item.slot.is_effect() && self.said.hide_fx {
|
||||
if !own && item.slot.is_effect() && self.settings.hide_fx {
|
||||
return;
|
||||
}
|
||||
// Past `FAR` a companion, trail or aura is a few pixels across for the cost of a model:
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! The cosmetics' settings (spec #14091 step 5a): their lines of `horizon-extras.txt`, the ones
|
||||
//! the launcher's Wardrobe shares (`horizon_cosmetics::prefs`), through the settings store
|
||||
//! ([`STORE`]), and their rows in the Extras page's Cosmetics group ([`SECTION`]). Each
|
||||
//! splitscreen player has their own, as their own settings file: they live in the player's
|
||||
//! [`Seat`].
|
||||
|
||||
use super::{Seat, service};
|
||||
use horizon_cosmetics::prefs::{self, Others};
|
||||
use horizon_hooks::pages::{Act, Label, Page, Place, Row};
|
||||
use horizon_hooks::settings::Store;
|
||||
|
||||
/// My cosmetics in single player too.
|
||||
const SOLO: &str = "cosmeticsSolo";
|
||||
|
||||
/// The settings, each with its line in `horizon-extras.txt`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct Settings {
|
||||
/// Show my cosmetics (the account's, told to the service; `prefs::MINE`).
|
||||
pub mine: bool,
|
||||
/// How much of other players' cosmetics shows: no line until the player chooses
|
||||
/// (`Seat::others`).
|
||||
pub others: Option<Others>,
|
||||
/// Other players' trails and auras hidden.
|
||||
pub hide_fx: bool,
|
||||
/// My cosmetics in single player too: the window's own server names the player by their
|
||||
/// offline profile, which the service doesn't know, so they show only with this on.
|
||||
pub solo: bool,
|
||||
/// Horizon's mark before Horizon players' names (name tags, player list).
|
||||
pub marks: bool,
|
||||
/// The service may say this player plays Horizon.
|
||||
pub listed: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mine: true,
|
||||
others: None,
|
||||
hide_fx: false,
|
||||
solo: false,
|
||||
marks: true,
|
||||
listed: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const KEYS: [&str; 6] = [
|
||||
prefs::MINE,
|
||||
prefs::OTHERS,
|
||||
prefs::HIDE_FX,
|
||||
SOLO,
|
||||
prefs::MARKS,
|
||||
prefs::LISTED,
|
||||
];
|
||||
|
||||
impl Settings {
|
||||
/// One line; a value it can't read keeps the setting.
|
||||
fn load(&mut self, key: &str, value: &str) {
|
||||
let flag = |was: bool| match value {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => was,
|
||||
};
|
||||
match key {
|
||||
prefs::MINE => self.mine = flag(self.mine),
|
||||
// `Others::parse` reads the old two-way switch too.
|
||||
prefs::OTHERS => self.others = Others::parse(value).or(self.others),
|
||||
prefs::HIDE_FX => self.hide_fx = flag(self.hide_fx),
|
||||
SOLO => self.solo = flag(self.solo),
|
||||
prefs::MARKS => self.marks = flag(self.marks),
|
||||
prefs::LISTED => self.listed = flag(self.listed),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Their lines; others' only once chosen.
|
||||
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
|
||||
let text = |on: bool| if on { "true" } else { "false" };
|
||||
line(prefs::MINE, text(self.mine));
|
||||
if let Some(others) = self.others {
|
||||
line(prefs::OTHERS, others.name());
|
||||
}
|
||||
line(prefs::HIDE_FX, text(self.hide_fx));
|
||||
line(SOLO, text(self.solo));
|
||||
line(prefs::MARKS, text(self.marks));
|
||||
line(prefs::LISTED, text(self.listed));
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings' lines of `horizon-extras.txt`, the player's own file in splitscreen: the game
|
||||
/// loads and saves it on the player's turn, so they are that seat's.
|
||||
pub(super) static STORE: Lines = Lines;
|
||||
|
||||
pub(super) struct Lines;
|
||||
|
||||
impl Store for Lines {
|
||||
fn file(&self) -> &'static str {
|
||||
prefs::FILE
|
||||
}
|
||||
fn owns(&self, key: &str) -> bool {
|
||||
KEYS.contains(&key)
|
||||
}
|
||||
fn load(&self, key: &str, value: &str) {
|
||||
service(|s| s.settings.load(key, value));
|
||||
}
|
||||
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
|
||||
service(|s| s.settings).save(line);
|
||||
}
|
||||
fn is_default(&self) -> bool {
|
||||
service(|s| s.settings) == Settings::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows, top to bottom.
|
||||
const ROWS: [&str; 6] = [
|
||||
"horizon.extras.cosmetics_mine",
|
||||
"horizon.extras.cosmetics_solo",
|
||||
"horizon.extras.cosmetics_others",
|
||||
"horizon.extras.cosmetics_hide_fx",
|
||||
"horizon.extras.horizon_marks",
|
||||
"horizon.extras.horizon_me",
|
||||
];
|
||||
|
||||
const TOOLTIPS: [&str; 6] = [
|
||||
"horizon.extras.cosmetics_mine.tooltip",
|
||||
"horizon.extras.cosmetics_solo.tooltip",
|
||||
"horizon.extras.cosmetics_others.tooltip",
|
||||
"horizon.extras.cosmetics_hide_fx.tooltip",
|
||||
"horizon.extras.horizon_marks.tooltip",
|
||||
"horizon.extras.horizon_me.tooltip",
|
||||
];
|
||||
|
||||
/// The rows in the Extras page's Cosmetics group, while cosmetics are offered
|
||||
/// ([`super::enabled`]).
|
||||
pub(super) static SECTION: Section = Section;
|
||||
|
||||
pub(super) struct Section;
|
||||
|
||||
impl Section {
|
||||
fn rows_of(seat: &Seat, rows: &mut Vec<Row>) {
|
||||
let s = &seat.settings;
|
||||
let shown = |on: bool| {
|
||||
Label::Key(if on {
|
||||
"horizon.extras.cosmetics.shown"
|
||||
} else {
|
||||
"horizon.extras.cosmetics.hidden"
|
||||
})
|
||||
};
|
||||
let others = Label::Key(match seat.others() {
|
||||
Others::Shown => "horizon.extras.cosmetics.shown",
|
||||
Others::NameTags => "horizon.extras.cosmetics.name_tags",
|
||||
Others::Hidden => "horizon.extras.cosmetics.hidden",
|
||||
});
|
||||
let toggle = |label, on| Row::Toggle { label, on };
|
||||
let cycle = |label, value| Row::Cycle { label, value };
|
||||
rows.extend([
|
||||
cycle(ROWS[0], shown(s.mine)),
|
||||
toggle(ROWS[1], s.solo),
|
||||
cycle(ROWS[2], others),
|
||||
toggle(ROWS[3], s.hide_fx),
|
||||
toggle(ROWS[4], s.marks),
|
||||
toggle(ROWS[5], s.listed),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A press on row `row`; `back` turns the three-way row the other way.
|
||||
fn act_on(seat: &mut Seat, row: usize, back: bool) {
|
||||
let s = &mut seat.settings;
|
||||
match row {
|
||||
0 => s.mine = !s.mine,
|
||||
1 => s.solo = !s.solo,
|
||||
2 => {
|
||||
// From what the row shows; the result is the player's choice from now on.
|
||||
let o = seat.others();
|
||||
seat.settings.others = Some(if back { o.next().next() } else { o.next() });
|
||||
}
|
||||
3 => s.hide_fx = !s.hide_fx,
|
||||
4 => s.marks = !s.marks,
|
||||
5 => s.listed = !s.listed,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Page for Section {
|
||||
fn id(&self) -> &'static str {
|
||||
"horizon-cosmetics.extras"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.extras.header.cosmetics"
|
||||
}
|
||||
fn place(&self) -> Place {
|
||||
Place::Extras
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<Row>) {
|
||||
if super::enabled() {
|
||||
service(|seat| Self::rows_of(seat, rows));
|
||||
}
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
let back = match act {
|
||||
Act::Press => false,
|
||||
Act::PressBack => true,
|
||||
_ => return,
|
||||
};
|
||||
service(|seat| Self::act_on(seat, row, back));
|
||||
}
|
||||
fn tooltip(&self, row: usize) -> Option<Label> {
|
||||
TOOLTIPS.get(row).copied().map(Label::Key)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn loaded(text: &str) -> Settings {
|
||||
let mut s = Settings::default();
|
||||
for (key, value) in text.lines().filter_map(|l| l.split_once(':')) {
|
||||
s.load(key.trim(), value.trim());
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn saved(s: &Settings) -> String {
|
||||
let mut text = String::new();
|
||||
s.save(&mut |k, v| text.push_str(&format!("{k}:{v}\n")));
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lines_round_trip_and_the_wardrobe_reads_them() {
|
||||
let s = Settings {
|
||||
mine: false,
|
||||
others: Some(Others::NameTags),
|
||||
hide_fx: true,
|
||||
solo: true,
|
||||
marks: false,
|
||||
listed: false,
|
||||
};
|
||||
let text = saved(&s);
|
||||
assert_eq!(loaded(&text), s);
|
||||
// The launcher's Wardrobe reads the same lines.
|
||||
let wardrobe = prefs::Prefs::parse(&text);
|
||||
assert_eq!(
|
||||
(wardrobe.mine, wardrobe.others, wardrobe.hide_fx),
|
||||
(false, Some(Others::NameTags), true)
|
||||
);
|
||||
// No choice made: no `cosmeticsOthers` line; the old switch still reads.
|
||||
assert!(!saved(&Settings::default()).contains(prefs::OTHERS));
|
||||
assert_eq!(
|
||||
loaded("cosmeticsOthers:false\n").others,
|
||||
Some(Others::Hidden)
|
||||
);
|
||||
// A value it can't read keeps the setting.
|
||||
assert!(loaded("cosmeticsMine:maybe\n").mine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn others_cycle_three_ways_from_what_the_row_shows() {
|
||||
let mut seat = Seat::default();
|
||||
// Not chosen, nothing owned: Hidden, and forward wraps to Shown.
|
||||
assert_eq!(seat.others(), Others::Hidden);
|
||||
Section::act_on(&mut seat, 2, false);
|
||||
assert_eq!(seat.settings.others, Some(Others::Shown));
|
||||
Section::act_on(&mut seat, 2, true);
|
||||
assert_eq!(seat.settings.others, Some(Others::Hidden));
|
||||
Section::act_on(&mut seat, 2, true);
|
||||
assert_eq!(seat.settings.others, Some(Others::NameTags));
|
||||
// Owning one shows them by default, until the player chooses.
|
||||
let mut owner = Seat::default();
|
||||
owner.owner.owns = true;
|
||||
assert_eq!(owner.others(), Others::Shown);
|
||||
let mut rows = Vec::new();
|
||||
Section::rows_of(&owner, &mut rows);
|
||||
assert_eq!(
|
||||
rows[2],
|
||||
Row::Cycle {
|
||||
label: ROWS[2],
|
||||
value: Label::Key("horizon.extras.cosmetics.shown")
|
||||
}
|
||||
);
|
||||
assert_eq!(rows.len(), TOOLTIPS.len());
|
||||
}
|
||||
}
|
||||
@@ -228,7 +228,7 @@ impl Game<'_> {
|
||||
eye[2].floor() as i32,
|
||||
);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let shown = self.hands_shown();
|
||||
let shown = self.hands_to_render();
|
||||
let used_hand = self.used_hand();
|
||||
let spinning = self
|
||||
.player
|
||||
|
||||
@@ -7,9 +7,8 @@ pub(super) fn debug_crosshair_drawn(
|
||||
first_person: bool,
|
||||
hide_gui: bool,
|
||||
panorama: bool,
|
||||
overlay_holds_view: bool,
|
||||
) -> bool {
|
||||
enabled && first_person && !hide_gui && !panorama && !overlay_holds_view
|
||||
enabled && first_person && !hide_gui && !panorama
|
||||
}
|
||||
|
||||
/// GameRenderer.shouldRenderBlockOutline, and only over a level. A panorama frame (the title
|
||||
|
||||
@@ -1,27 +1,11 @@
|
||||
//! The HUD's `Room` this frame (`docs/ui-kit.md`): what vanilla's HUD shows that Horizon's own
|
||||
//! overlays keep clear of. `hud` empties it each frame and `hud_layers` fills it in before the
|
||||
//! world's HUD draws, into `ui.room`; the controller notice, splitscreen's join strip and the
|
||||
//! radial menu read it. With it, the frame's `motion::Clock` (`hud_clock`), and whether an
|
||||
//! overlay holds the view (`overlay_holds_view`), which what vanilla draws there gives way to.
|
||||
//! world's HUD draws, into `ui.room`; the controller notice and splitscreen's join strip read it.
|
||||
use super::Game;
|
||||
use horizon_client::hud::HudBatch;
|
||||
use horizon_client::hud_kit::motion::Clock;
|
||||
use horizon_client::hud_kit::room::{Keep, Room};
|
||||
use horizon_client::{boss_overlay, hud_layout, hud_overlays};
|
||||
|
||||
impl Game<'_> {
|
||||
/// The HUD's clock this frame (`hud_kit::motion`): still, so overlays show their motion
|
||||
/// settled, while the game is paused, frame-stepped (`HORIZON_STILL`, a screenshot
|
||||
/// sequence) or a repro recording or replay runs.
|
||||
pub(super) fn hud_clock(&self) -> Clock {
|
||||
let still = self.pause
|
||||
|| self.clock.still()
|
||||
|| self.screenshot_sequence.is_some()
|
||||
|| self.repro.recording_active()
|
||||
|| self.repro.replaying();
|
||||
Clock::new(std::time::Instant::now(), still)
|
||||
}
|
||||
|
||||
/// What the HUD shows on a `w` x `h` GUI px view this frame, as keep-outs: the boss bars
|
||||
/// while the HUD shows (`show_hud`: not F1, not Replay Studio), as wide as the bar or its
|
||||
/// widest name.
|
||||
@@ -52,50 +36,4 @@ impl Game<'_> {
|
||||
}
|
||||
room
|
||||
}
|
||||
|
||||
/// The status rows above the hotbar in a `w` x `h` GUI px view, from what they draw this
|
||||
/// frame (`draws`): a keep-out from their highest row's top down, as wide as the hotbar. Called
|
||||
/// as they are laid out, after `hud_room`, so the overlays drawn over the HUD see them.
|
||||
pub(super) fn keep_status_rows(&mut self, draws: &[hud_layout::GuiDraw], w: i32, h: i32) {
|
||||
if let Some(top) = hud_layout::draws_top(draws).filter(|top| *top < h) {
|
||||
let half = hud_overlays::HOTBAR_HALF_WIDTH;
|
||||
self.ui.room.add(
|
||||
Keep::StatusRows,
|
||||
[
|
||||
(w / 2 - half) as f32,
|
||||
top as f32,
|
||||
(2 * half) as f32,
|
||||
(h - top) as f32,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An overlay holds the middle of the view (the radial menu, once it shows: a quick tap of
|
||||
/// its opener draws nothing, so nothing blinks). The 2D and F3 crosshairs, the chat's lines,
|
||||
/// the toasts and the first-person hands give way to it.
|
||||
pub(super) fn overlay_holds_view(&self) -> bool {
|
||||
self.pad_radial_shown()
|
||||
}
|
||||
|
||||
/// ToastManager.extractRenderState: F1 hides the toasts (`hidden`), and so does an overlay
|
||||
/// holding the view, as the radial menu's page tabs run across the top where the toasts are.
|
||||
/// They go on timing out meanwhile, as under F1.
|
||||
pub(super) fn draw_every_toast(&mut self, batch: &mut HudBatch, hidden: bool) {
|
||||
let hidden = hidden || self.overlay_holds_view();
|
||||
self.draw_toasts(batch, hidden);
|
||||
self.draw_tutorial_toasts(batch, hidden);
|
||||
self.draw_pack_toasts(batch, hidden);
|
||||
self.draw_music_toast(batch, hidden);
|
||||
}
|
||||
|
||||
/// The hands drawn in first person this frame: none while an overlay holds the view, as the
|
||||
/// radial menu's tiles sit where they are.
|
||||
pub(super) fn hands_shown(&self) -> [bool; 2] {
|
||||
if self.overlay_holds_view() {
|
||||
[false; 2]
|
||||
} else {
|
||||
self.hands_to_render()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,11 +370,6 @@ impl Game<'_> {
|
||||
pub(super) fn has_singleplayer_server(&self) -> bool {
|
||||
self.remote.is_none() || self.integrated.is_some()
|
||||
}
|
||||
/// `Minecraft.isMultiplayerServer`: on another's server, or a world of one's own open to the
|
||||
/// network.
|
||||
pub(super) fn is_multiplayer_server(&self) -> bool {
|
||||
!self.has_singleplayer_server() || self.is_published()
|
||||
}
|
||||
/// `IntegratedServer.isPublished`: the world is open to the network.
|
||||
pub(super) fn is_published(&self) -> bool {
|
||||
self.lan.is_some()
|
||||
|
||||
@@ -21,43 +21,6 @@ use horizon_core::geometry::{Aabb, Vec3};
|
||||
use serde_json::Value;
|
||||
use std::{cell::RefCell, collections::HashMap};
|
||||
|
||||
/// The game's painter for the HUD kit's icons (`hud_kit::icon`): items by their models, the
|
||||
/// player's face from `face`, their skin's texture.
|
||||
pub(super) struct IconPainter<'g, 'a> {
|
||||
pub(super) game: &'g Game<'a>,
|
||||
pub(super) face: Option<&'g str>,
|
||||
}
|
||||
|
||||
impl horizon_client::hud_kit::icon::IconPainter for IconPainter<'_, '_> {
|
||||
fn item(&self, batch: &mut HudBatch, id: &str, [x, y, size]: [f32; 3]) -> bool {
|
||||
let start = batch.vertices.len();
|
||||
self.game
|
||||
.draw_item_model(batch, &ItemStack::new(id, 1), x, y);
|
||||
let scale = size / horizon_client::hud_kit::icon::ITEM_SIZE;
|
||||
batch.scale_since(start, x, y, scale, scale);
|
||||
batch.vertices.len() > start
|
||||
}
|
||||
fn face(&self, batch: &mut HudBatch, [x, y, size]: [f32; 3]) -> bool {
|
||||
let Some(skin) = self.face else {
|
||||
return false;
|
||||
};
|
||||
let mut draws = Vec::new();
|
||||
// `PlayerFaceExtractor`: the face, then its hat layer.
|
||||
horizon_client::tab_list::face(
|
||||
&mut draws,
|
||||
skin,
|
||||
x as i32,
|
||||
y as i32,
|
||||
size as i32,
|
||||
true,
|
||||
false,
|
||||
u32::MAX,
|
||||
);
|
||||
horizon_client::hud_layout::paint(batch, &self.game.gui.atlas, &draws);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The game's answers to what the resolver cannot read from the stack.
|
||||
pub(super) struct Env<'a, 'g> {
|
||||
game: &'a Game<'g>,
|
||||
|
||||
@@ -930,7 +930,6 @@ impl Game<'_> {
|
||||
let m = &self.mobs[i];
|
||||
let hearts = horizon_client::hud_layout::vehicle_max_hearts(m.attrs.max_health);
|
||||
let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health);
|
||||
self.keep_status_rows(&draws, w, h);
|
||||
self.gui_layer("minecraft:vehicle_health", batch, |g, batch| {
|
||||
horizon_client::hud_layout::paint(batch, &g.gui.atlas, &draws);
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@ use horizon_client::{
|
||||
keymapping::SNEAK,
|
||||
pad_glyph::{Glyph, Glyphs},
|
||||
};
|
||||
use horizon_client_input::radial;
|
||||
use serde_json::{Value, json};
|
||||
use std::time::{Duration, Instant};
|
||||
use winit::event::MouseButton;
|
||||
@@ -27,8 +26,7 @@ pub(super) const HELP: &[&str] = &[
|
||||
"pad BUTTON down|up|tap a virtual controller button (south east west north lb rb lt rt select start ls rs up down left right)",
|
||||
"pad stick left|right X Y tilt a virtual stick (-1..1, up positive); 0 0 centres it",
|
||||
"pad trigger left|right VALUE pull a virtual trigger (0..1)",
|
||||
"pad release centre and release the virtual controller (as an unplug: the radial menu cancels)",
|
||||
"pad focus true|false the window gains or loses the focus, as a window focus event does",
|
||||
"pad release centre and release the virtual controller",
|
||||
"pad status connected controllers, the active one and its state",
|
||||
"pad connection connected [FAMILY]|disconnected as if a controller (PlayStation unless FAMILY) came or went at this game's own reader",
|
||||
"pad family xbox|playstation|nintendo|auto the pad playing is that family's (its glyphs; a Nintendo one's letters)",
|
||||
@@ -88,31 +86,6 @@ pub(super) struct Pads {
|
||||
connections: Vec<Connection>,
|
||||
/// The cursor the pad drives in menus.
|
||||
pub(super) menu: super::pad_menu::MenuCursor,
|
||||
/// The radial menu while its opener is held, and the page it stays on.
|
||||
pub(super) radial: radial::RadialMenu,
|
||||
/// What the radial menu ran, for `pad status`.
|
||||
radial_runs: RadialRuns,
|
||||
/// The moments the radial menu's overlay moves from (`radial_overlay::Look`).
|
||||
pub(super) radial_look: horizon_client_render::radial_overlay::Look,
|
||||
/// The player's skin for the overlay's Emote face, read as it opens.
|
||||
radial_face: Option<String>,
|
||||
/// The D-pad's quiet after the menu closed on a D-pad opener.
|
||||
radial_settle: radial::DpadSettle,
|
||||
/// The narrator said the page shown, and the slot selected.
|
||||
radial_page_said: bool,
|
||||
radial_said: radial::Settle<(usize, usize)>,
|
||||
/// A page turn's second tick, when the pages went round an end.
|
||||
radial_rumble_due: Option<(Instant, Rumble)>,
|
||||
/// The furthest each stick leant (left, right) between the last two polls, from the pad's
|
||||
/// own events: a flick out and back between two frames still reaches the radial menu.
|
||||
peaks: [[f32; 2]; 2],
|
||||
/// With Either selecting: the stick that last leant past its select tilt (0 left, 1 right),
|
||||
/// and whether each was past it at the last poll.
|
||||
radial_either: Option<usize>,
|
||||
radial_over: [bool; 2],
|
||||
/// While the radial menu is open with Either: each stick has been at rest since the open, so
|
||||
/// it may take over. A thumb still on the walking stick can't commit for the other.
|
||||
radial_rested: [bool; 2],
|
||||
/// The last vibration asked for, for `pad status`.
|
||||
last_rumble: Option<(&'static str, gamepad::Pulse)>,
|
||||
/// The drop button, while it is held in play.
|
||||
@@ -138,37 +111,13 @@ struct Context {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
mod capture;
|
||||
mod guide;
|
||||
#[cfg(feature = "controllers")]
|
||||
mod hardware;
|
||||
#[cfg(test)]
|
||||
mod parity_tests;
|
||||
mod radial_play;
|
||||
pub(super) use hardware::{PadId, PadRouter};
|
||||
|
||||
/// How many of the radial menu's last actions `pad status` lists.
|
||||
const RADIAL_RUNS_KEPT: usize = 16;
|
||||
|
||||
/// What the radial menu ran: how many actions in all, and the last few, last one last.
|
||||
#[derive(Default)]
|
||||
struct RadialRuns {
|
||||
count: u64,
|
||||
last: std::collections::VecDeque<&'static str>,
|
||||
}
|
||||
impl RadialRuns {
|
||||
fn push(&mut self, action: &'static str) {
|
||||
self.count += 1;
|
||||
if self.last.len() == RADIAL_RUNS_KEPT {
|
||||
self.last.pop_front();
|
||||
}
|
||||
self.last.push_back(action);
|
||||
}
|
||||
}
|
||||
|
||||
/// The volume of a UI button's click (`AbstractButton.playDownSound`).
|
||||
const UI_CLICK_VOLUME: f32 = 0.25;
|
||||
|
||||
/// How long the death screen's confirm ignores cancelling (`ConfirmScreen`).
|
||||
const DEATH_CONFIRM_WAIT: Duration = Duration::from_secs(1);
|
||||
|
||||
@@ -236,9 +185,6 @@ mod hardware {
|
||||
pub(in crate::game) fn state(&self, _: PadId) -> PadState {
|
||||
PadState::default()
|
||||
}
|
||||
pub(in crate::game) fn peaks(&self, _: PadId) -> [[f32; 2]; 2] {
|
||||
[[0.0; 2]; 2]
|
||||
}
|
||||
pub(in crate::game) fn name(&self, _: PadId) -> String {
|
||||
String::new()
|
||||
}
|
||||
@@ -279,18 +225,10 @@ impl Pads {
|
||||
}
|
||||
/// Splitscreen: this player's pad state, from `source`. Another pad, or
|
||||
/// none: the old one's A going away isn't a release.
|
||||
/// `peaks`: the furthest its sticks leant since the last feed (`Pads::peaks`).
|
||||
pub(super) fn feed_from(
|
||||
&mut self,
|
||||
source: Option<PadId>,
|
||||
state: PadState,
|
||||
peaks: [[f32; 2]; 2],
|
||||
) {
|
||||
self.peaks = peaks;
|
||||
pub(super) fn feed_from(&mut self, source: Option<PadId>, state: PadState) {
|
||||
if self.feed_source != source {
|
||||
self.feed_source = source;
|
||||
self.menu.armed = None;
|
||||
self.radial.cancel();
|
||||
self.menu.stop_craft();
|
||||
}
|
||||
self.feed = Some(state);
|
||||
@@ -308,25 +246,19 @@ impl Pads {
|
||||
/// A replay ended: let go of everything it held.
|
||||
pub(super) fn clear_injected(&mut self) {
|
||||
self.menu.armed = None;
|
||||
self.radial.cancel();
|
||||
self.menu.stop_craft();
|
||||
self.injected = PadState::default();
|
||||
}
|
||||
|
||||
/// Whether a held button's press went to `action`, including releases delayed until the next
|
||||
/// simulation tick. What the press reached, not what the button is bound to now: a press the
|
||||
/// radial menu swallowed holds nothing, while a sneak held since before the menu opened is
|
||||
/// still held through it.
|
||||
pub(super) fn holds_action(&self, action: &str) -> bool {
|
||||
Button::ALL.into_iter().any(|button| {
|
||||
self.actions[button as usize].contains(&action)
|
||||
&& (self.edges.is_held(button) || self.late_releases.contains(&button))
|
||||
/// Include releases delayed until the next simulation tick.
|
||||
pub(super) fn holds_action(&self, settings: &gamepad::PadSettings, action: &str) -> bool {
|
||||
settings.binding(action).is_some_and(|button| {
|
||||
self.edges.is_held(button) || self.late_releases.contains(&button)
|
||||
})
|
||||
}
|
||||
|
||||
/// A new player entity took over (spawn, respawn, a server joined, a
|
||||
/// dimension change): its health is not compared with the old one's.
|
||||
/// The radial menu closes with it, in `Game::pad_player_replaced`.
|
||||
pub(super) fn player_replaced(&mut self) {
|
||||
self.health.forget();
|
||||
}
|
||||
@@ -359,7 +291,7 @@ impl Game<'_> {
|
||||
screen: self.key_screen(),
|
||||
focus_navigation: self.pad_navigation_mode(),
|
||||
focused: self.focused,
|
||||
osk: self.pad_keyboard_captures(),
|
||||
osk: self.pad.menu.osk.is_some(),
|
||||
dialog: self.dialog_open() || self.code_of_conduct.is_some(),
|
||||
enabled: self.settings.controller.enabled,
|
||||
}
|
||||
@@ -379,10 +311,6 @@ impl Game<'_> {
|
||||
if !self.settings.controller.enabled {
|
||||
self.pad_output_off();
|
||||
self.pad_cancel_inputs(event_loop);
|
||||
// The radial menu closed with the pad: its overlay fades out, as for a cancel.
|
||||
self.pad
|
||||
.radial_look
|
||||
.observe(None, std::time::Instant::now());
|
||||
self.pad.state = PadState::default();
|
||||
self.pad.in_use = false;
|
||||
// The keyboard's menu focus follows its widget all the same.
|
||||
@@ -436,7 +364,6 @@ impl Game<'_> {
|
||||
}
|
||||
if self.pad.blocked_sticks[i] || !self.pad_focused() {
|
||||
*stick = [0.0; 2];
|
||||
self.pad.peaks[i] = [0.0; 2];
|
||||
}
|
||||
}
|
||||
for button in std::mem::take(&mut self.pad.taps) {
|
||||
@@ -450,7 +377,6 @@ impl Game<'_> {
|
||||
self.pad.keyboard_took_over = false;
|
||||
}
|
||||
self.pad.state = state;
|
||||
self.pad_radial_frame();
|
||||
self.pad_drop_frame();
|
||||
self.pad_output_frame();
|
||||
// Damage from any source, here or on a server; nothing counts on
|
||||
@@ -464,24 +390,14 @@ impl Game<'_> {
|
||||
self.pad_rumble(Rumble::Hurt(damage));
|
||||
}
|
||||
let ticking = self.title.is_none() && self.screen == Screen::Playing && !self.pause;
|
||||
let mut due = Vec::new();
|
||||
for button in std::mem::take(&mut self.pad.late_releases) {
|
||||
if !ticking || self.pad.pressed_at[button as usize] != Some(self.ticks) {
|
||||
due.push(button);
|
||||
self.pad_button_logged(event_loop, button, false);
|
||||
} else {
|
||||
self.pad.late_releases.push(button);
|
||||
}
|
||||
}
|
||||
// The steps, in order: a press in the same poll as the opener's release, held back or
|
||||
// not, gets there first, so B still cancels.
|
||||
for step in radial_play::poll_order(due, edges, self.pad_radial_captures()) {
|
||||
let (button, down) = match step {
|
||||
radial_play::PollStep::Late(button) => {
|
||||
self.pad_button_logged(event_loop, button, false);
|
||||
continue;
|
||||
}
|
||||
radial_play::PollStep::Edge(button, down) => (button, down),
|
||||
};
|
||||
for (button, down) in edges {
|
||||
self.pad_edge(event_loop, button, down);
|
||||
let context = self.pad_context();
|
||||
if let Some(old) = self.pad.context.filter(|old| *old != context) {
|
||||
@@ -514,12 +430,10 @@ impl Game<'_> {
|
||||
|
||||
/// End held actions and captures before another context receives input.
|
||||
fn pad_cancel_inputs(&mut self, event_loop: &dyn EventLoopCtl) {
|
||||
// Before the releases below: a cancelled press must not click, a cancelled trigger
|
||||
// change the recipe book's category as it comes up, nor a cancelled radial menu
|
||||
// commit when its opener comes up.
|
||||
// Before the releases below: a cancelled press must not click, nor a cancelled trigger
|
||||
// change the recipe book's category as it comes up.
|
||||
self.pad.menu.armed = None;
|
||||
self.pad.menu.trigger = None;
|
||||
self.pad.radial.cancel();
|
||||
self.direct_craft_stop();
|
||||
let held_sticks = [
|
||||
gamepad::radial(self.pad.state.left, self.settings.controller.move_deadzone)
|
||||
@@ -587,19 +501,7 @@ impl Game<'_> {
|
||||
if down && !self.pad_focused() {
|
||||
return Ok(());
|
||||
}
|
||||
if !down {
|
||||
self.pad_radial_button_up(button);
|
||||
}
|
||||
// While the radial menu is open its presses are its own, but for the triggers'.
|
||||
if down && self.pad_radial_captures() && self.pad_radial_button(button)? {
|
||||
return Ok(());
|
||||
}
|
||||
let playing = !self.pad_in_menu();
|
||||
// A thumb rolling off a D-pad opener: its press, and so its release, reach nothing.
|
||||
if down && playing && self.pad_radial_settling(button) {
|
||||
self.pad.actions[button as usize].clear();
|
||||
return Ok(());
|
||||
}
|
||||
if down && playing && !self.captured && self.focused {
|
||||
// Like a click into the window: take the pointer.
|
||||
self.capture(true);
|
||||
@@ -615,16 +517,7 @@ impl Game<'_> {
|
||||
// Releases always reach the mappings, so nothing stays held when a
|
||||
// screen opened in between.
|
||||
let actions = if down {
|
||||
let mut actions = self.settings.controller.actions_of(button);
|
||||
// An opener sharing its button (marked in the binds list) has it to
|
||||
// itself wherever it can open, and gives it up elsewhere.
|
||||
if actions.contains(&gamepad::RADIAL) {
|
||||
if self.pad_radial_can_open() {
|
||||
actions = vec![gamepad::RADIAL];
|
||||
} else {
|
||||
actions.retain(|action| *action != gamepad::RADIAL);
|
||||
}
|
||||
}
|
||||
let actions = self.settings.controller.actions_of(button);
|
||||
self.pad.actions[button as usize] = actions.clone();
|
||||
actions
|
||||
} else {
|
||||
@@ -644,14 +537,6 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
};
|
||||
match route {
|
||||
Route::Radial => {
|
||||
if down {
|
||||
self.pad_radial_open();
|
||||
Ok(())
|
||||
} else {
|
||||
self.pad_radial_release()
|
||||
}
|
||||
}
|
||||
Route::HotbarStep { next } => {
|
||||
if down {
|
||||
self.hotbar_step(next);
|
||||
@@ -703,13 +588,6 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
// KeyboardHandler: F3 alone, let go, shows or hides the debug screen.
|
||||
Route::DebugOverlay => {
|
||||
if down {
|
||||
self.debug = !self.debug;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Route::Held => Ok(()),
|
||||
Route::Mouse(button) => {
|
||||
let playing = self.title.is_none() && self.screen == Screen::Playing;
|
||||
@@ -740,7 +618,7 @@ impl Game<'_> {
|
||||
|
||||
/// 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() || self.pad_radial_captures() {
|
||||
if self.pad_in_menu() || self.screen_over_playing() {
|
||||
return;
|
||||
}
|
||||
let interval = Duration::from_secs_f32(self.settings.controller.repeat_interval);
|
||||
@@ -795,11 +673,7 @@ impl Game<'_> {
|
||||
/// The left stick as the move vector, while playing.
|
||||
pub(super) fn pad_move(&self) -> Option<[f32; 2]> {
|
||||
let settings = &self.settings.controller;
|
||||
if !settings.enabled
|
||||
|| self.pad_in_menu()
|
||||
|| !self.pad_focused()
|
||||
|| (self.pad_radial_captures() && self.pad_radial_selects(0))
|
||||
{
|
||||
if !settings.enabled || self.pad_in_menu() || !self.pad_focused() {
|
||||
return None;
|
||||
}
|
||||
gamepad::move_vector(self.pad.state.left, settings.move_deadzone)
|
||||
@@ -808,12 +682,7 @@ impl Game<'_> {
|
||||
/// 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;
|
||||
if !settings.enabled
|
||||
|| self.pad_in_menu()
|
||||
|| self.player.is_none()
|
||||
|| !self.pad_focused()
|
||||
|| (self.pad_radial_captures() && self.pad_radial_selects(1))
|
||||
{
|
||||
if !settings.enabled || self.pad_in_menu() || self.player.is_none() || !self.pad_focused() {
|
||||
return;
|
||||
}
|
||||
// A long frame (a hitch) must not spin the view.
|
||||
@@ -917,7 +786,6 @@ impl Game<'_> {
|
||||
json!({ "title": title, "body": body })
|
||||
}),
|
||||
"menu": self.pad_menu_status(),
|
||||
"radial": self.pad_radial_status(),
|
||||
"guide": self.pad_guide_status(),
|
||||
"held": held,
|
||||
"left": s.left,
|
||||
@@ -955,7 +823,6 @@ impl Game<'_> {
|
||||
}
|
||||
"release" => {
|
||||
self.pad.menu.armed = None;
|
||||
self.pad.radial.cancel();
|
||||
self.direct_craft_stop();
|
||||
self.pad.injected = PadState::default();
|
||||
self.pad.taps.clear();
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
//! Which of the overlays over play owns the controller now, for the checks that ask about the
|
||||
//! on-screen keyboard and the radial menu. The prompts' own checks still call `prompt_open`
|
||||
//! directly (`pad.rs`, `pad_actions.rs`, `pad_menu.rs`, `pad/guide.rs`); here a prompt only
|
||||
//! outranks the radial menu.
|
||||
//!
|
||||
//! In priority order, highest first ([`PadCapture`]): the on-screen keyboard, then a prompt over
|
||||
//! the world (a server dialog, the code of conduct, a link, command or resource pack prompt),
|
||||
//! then the radial menu. Only one owns the pad at a time; the radial menu never opens under the
|
||||
//! others and closes when one comes up (`pad_radial_can_open`, `pad_radial_frame`), so the order
|
||||
//! only settles the poll in which both are up. Screens are not overlays: `pad_in_menu` says
|
||||
//! when one has the pad.
|
||||
use super::super::Game;
|
||||
|
||||
/// An overlay that owns the controller, highest priority first.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(in crate::game) enum PadCapture {
|
||||
/// The on-screen keyboard: every press types or moves on it.
|
||||
Keyboard,
|
||||
/// A prompt over the world, answered before anything behind it.
|
||||
Prompt,
|
||||
/// The radial menu, held open in play: its stick selects, its buttons page and cancel.
|
||||
Radial,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The overlay that owns the pad now, if one does.
|
||||
pub(in crate::game) fn pad_capture(&self) -> Option<PadCapture> {
|
||||
if self.pad.menu.osk.is_some() {
|
||||
Some(PadCapture::Keyboard)
|
||||
} else if self.prompt_open() {
|
||||
Some(PadCapture::Prompt)
|
||||
} else if self.pad.radial.is_open() {
|
||||
Some(PadCapture::Radial)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The on-screen keyboard owns the pad.
|
||||
pub(in crate::game) fn pad_keyboard_captures(&self) -> bool {
|
||||
self.pad_capture() == Some(PadCapture::Keyboard)
|
||||
}
|
||||
|
||||
/// The radial menu owns the pad: its stick doesn't walk or turn the view, its buttons are its
|
||||
/// own, and a bumped mouse does nothing. What is drawn gives way only once it shows
|
||||
/// (`pad_radial_shown`): a quick tap draws nothing.
|
||||
pub(in crate::game) fn pad_radial_captures(&self) -> bool {
|
||||
self.pad_capture() == Some(PadCapture::Radial)
|
||||
}
|
||||
}
|
||||
@@ -35,9 +35,6 @@ impl Game<'_> {
|
||||
&& settings.guide_detail != GuideDetail::Off
|
||||
&& self.pad.in_use
|
||||
&& !self.pad.keyboard_took_over
|
||||
// Hidden once the radial menu shows, not on the opener's press: a quick tap draws
|
||||
// nothing, so the guide doesn't blink.
|
||||
&& !self.pad_radial_shown()
|
||||
&& !self.pad_in_menu()
|
||||
&& !self.screen_over_playing()
|
||||
&& self.sleeping.is_none()
|
||||
@@ -121,7 +118,7 @@ impl Game<'_> {
|
||||
|| self.pad.keyboard_took_over
|
||||
|| self.prompt_open()
|
||||
|| self.title.is_some()
|
||||
|| self.pad_keyboard_captures()
|
||||
|| self.pad.menu.osk.is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,14 +223,8 @@ impl Pads {
|
||||
return PadState::default();
|
||||
};
|
||||
let active = self.devices.active;
|
||||
self.peaks = [[0.0; 2]; 2];
|
||||
while let Some(gilrs::Event { id, event, .. }) = gilrs.next_event() {
|
||||
use gilrs::EventType as E;
|
||||
// The furthest each stick of the active pad leant between the events, which a read
|
||||
// after them all misses (`Pads::peaks`).
|
||||
if matches!(event, E::AxisChanged(..)) && self.devices.active == Some(id) {
|
||||
keep_peaks(&mut self.peaks, sticks_of(&gilrs.gamepad(id)));
|
||||
}
|
||||
match event {
|
||||
E::Connected => {
|
||||
log::info!("Controller connected: {}", gilrs.gamepad(id).name());
|
||||
@@ -271,7 +265,6 @@ impl Pads {
|
||||
// Another pad, or none: its A going away isn't a release.
|
||||
if self.devices.active != active {
|
||||
self.menu.armed = None;
|
||||
self.radial.cancel();
|
||||
self.menu.stop_craft();
|
||||
}
|
||||
let Some(pad) = self
|
||||
@@ -291,16 +284,6 @@ impl Pads {
|
||||
}
|
||||
}
|
||||
|
||||
/// The furthest each stick leant: `sticks` (left, right) where they lean further than `peaks`.
|
||||
fn keep_peaks(peaks: &mut [[f32; 2]; 2], sticks: [[f32; 2]; 2]) {
|
||||
use horizon_client_input::radial::stick_length;
|
||||
for (peak, stick) in peaks.iter_mut().zip(sticks) {
|
||||
if stick_length(stick) > stick_length(*peak) {
|
||||
*peak = stick;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a stick or trigger has to go for its pad to become the active one.
|
||||
const TAKEOVER: f32 = 0.5;
|
||||
|
||||
@@ -346,13 +329,6 @@ fn state_of(pad: &gilrs::Gamepad) -> PadState {
|
||||
state.set(button, pad.is_pressed(named));
|
||||
}
|
||||
}
|
||||
[state.left, state.right] = sticks_of(pad);
|
||||
state.triggers = triggers::triggers(pad);
|
||||
state
|
||||
}
|
||||
|
||||
/// A pad's sticks now (left, right).
|
||||
fn sticks_of(pad: &gilrs::Gamepad) -> [[f32; 2]; 2] {
|
||||
let axis = |a| pad.value(a);
|
||||
use gilrs::Axis as A;
|
||||
let sdl_mapped = pad.mapping_source() == gilrs::MappingSource::SdlMappings;
|
||||
@@ -365,12 +341,14 @@ fn sticks_of(pad: &gilrs::Gamepad) -> [[f32; 2]; 2] {
|
||||
sdl_mapped,
|
||||
);
|
||||
let (left, right) = sdl_row::sticks(pad, left, right);
|
||||
let right = if hid_layout::right_stick_on_z(pad) {
|
||||
state.left = left;
|
||||
state.right = if hid_layout::right_stick_on_z(pad) {
|
||||
hid_layout::right_stick([axis(A::LeftZ), axis(A::RightZ)])
|
||||
} else {
|
||||
right
|
||||
};
|
||||
[left, right]
|
||||
state.triggers = triggers::triggers(pad);
|
||||
state
|
||||
}
|
||||
|
||||
/// A controller, as splitscreen tells them apart.
|
||||
@@ -384,8 +362,6 @@ pub(in crate::game) struct PadRouter {
|
||||
started: bool,
|
||||
/// Vibrations playing while several play, each until its end.
|
||||
rumbles: Vec<(gilrs::ff::Effect, Instant)>,
|
||||
/// The furthest each pad's sticks leant since the last `events` (`Pads::peaks`).
|
||||
peaks: Vec<(PadId, [[f32; 2]; 2])>,
|
||||
}
|
||||
|
||||
impl PadRouter {
|
||||
@@ -469,15 +445,7 @@ impl PadRouter {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
self.peaks.clear();
|
||||
while let Some(gilrs::Event { id, event, .. }) = gilrs.next_event() {
|
||||
if matches!(event, gilrs::EventType::AxisChanged(..)) {
|
||||
let sticks = sticks_of(&gilrs.gamepad(id));
|
||||
match self.peaks.iter_mut().find(|(pad, _)| *pad == id) {
|
||||
Some((_, peaks)) => keep_peaks(peaks, sticks),
|
||||
None => self.peaks.push((id, sticks)),
|
||||
}
|
||||
}
|
||||
match event {
|
||||
gilrs::EventType::ButtonPressed(pressed, _) => {
|
||||
let crossed = face_buttons::crossed(&gilrs.gamepad(id));
|
||||
@@ -513,14 +481,6 @@ impl PadRouter {
|
||||
.map_or([0; 16], |g| g.gamepad(id).uuid())
|
||||
}
|
||||
|
||||
/// The furthest the pad's sticks leant since the last `events`.
|
||||
pub(in crate::game) fn peaks(&self, id: PadId) -> [[f32; 2]; 2] {
|
||||
self.peaks
|
||||
.iter()
|
||||
.find(|(pad, _)| *pad == id)
|
||||
.map_or([[0.0; 2]; 2], |(_, peaks)| *peaks)
|
||||
}
|
||||
|
||||
/// The pad's state now (released when it is gone).
|
||||
pub(in crate::game) fn state(&self, id: PadId) -> PadState {
|
||||
self.gilrs
|
||||
|
||||
@@ -1,599 +0,0 @@
|
||||
//! The radial menu in play (`horizon-client-input/src/radial.rs` has its rules,
|
||||
//! `radial_overlay.rs` draws it): what opens and closes it, what the pad's presses do while it is
|
||||
//! open, and what a commit runs.
|
||||
use super::{Game, Result, UI_CLICK_VOLUME};
|
||||
use horizon_client::gamepad::MenuJob;
|
||||
use horizon_client::gamepad::{self, Button, RadialMode, RadialStick, Rumble};
|
||||
use horizon_client::pad_glyph::Glyph;
|
||||
use horizon_client::text::Text;
|
||||
use horizon_client_input::radial::{self, RadialAction, Slot};
|
||||
use horizon_client_render::radial_overlay::{self, Dial, Seen, Toggles, View};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The D-pad's buttons, which settle after the menu closes on a D-pad opener.
|
||||
const DPAD: [Button; 4] = [
|
||||
Button::DPadUp,
|
||||
Button::DPadDown,
|
||||
Button::DPadLeft,
|
||||
Button::DPadRight,
|
||||
];
|
||||
|
||||
/// One step of a poll's button changes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum PollStep {
|
||||
/// A release held back from the poll before, until a simulation tick had passed.
|
||||
Late(Button),
|
||||
Edge(Button, bool),
|
||||
}
|
||||
|
||||
/// The order a poll acts on its changes in: the releases held back first, then this poll's
|
||||
/// presses and releases. While the radial menu is open its presses come before every release, the
|
||||
/// held-back ones too: B in the same poll as the opener's release (however late) still cancels.
|
||||
pub(super) fn poll_order(
|
||||
late: Vec<Button>,
|
||||
edges: Vec<(Button, bool)>,
|
||||
radial_open: bool,
|
||||
) -> Vec<PollStep> {
|
||||
let late = late.into_iter().map(PollStep::Late);
|
||||
let edges = edges.into_iter().map(|(b, down)| PollStep::Edge(b, down));
|
||||
if !radial_open {
|
||||
return late.chain(edges).collect();
|
||||
}
|
||||
let (presses, releases): (Vec<_>, Vec<_>) =
|
||||
edges.partition(|step| matches!(step, PollStep::Edge(_, true)));
|
||||
presses.into_iter().chain(late).chain(releases).collect()
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether the radial menu selects with this stick (0 left, 1 right): while it is open that
|
||||
/// stick neither walks nor turns the view.
|
||||
pub(in crate::game) fn pad_radial_selects(&self, stick: usize) -> bool {
|
||||
match self.settings.controller.radial_stick {
|
||||
RadialStick::Right => stick == 1,
|
||||
RadialStick::Left => stick == 0,
|
||||
RadialStick::Either => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Each stick's tilts, from its own deadzone (left, right).
|
||||
fn pad_radial_tilts(&self) -> [radial::Tilt; 2] {
|
||||
let settings = &self.settings.controller;
|
||||
[settings.move_deadzone, settings.look_deadzone].map(radial::Tilt::for_deadzone)
|
||||
}
|
||||
|
||||
/// The stick that selects now (0 left, 1 right), where it is, the furthest it leant since the
|
||||
/// last poll, and its tilts. With Either, the stick that last leant past the select tilt.
|
||||
fn pad_radial_selector(&self) -> (usize, [f32; 2], [f32; 2], radial::Tilt) {
|
||||
let index = match self.settings.controller.radial_stick {
|
||||
RadialStick::Right => 1,
|
||||
RadialStick::Left => 0,
|
||||
RadialStick::Either => self.pad.radial_either.unwrap_or(1),
|
||||
};
|
||||
let stick = [self.pad.state.left, self.pad.state.right][index];
|
||||
(
|
||||
index,
|
||||
stick,
|
||||
self.pad.peaks[index],
|
||||
self.pad_radial_tilts()[index],
|
||||
)
|
||||
}
|
||||
|
||||
/// With Either: a stick that has just leant past its select tilt (or did between two polls)
|
||||
/// takes over the selecting. While the menu is open, only one that has been at rest since
|
||||
/// the open: the walking thumb easing forward on its frozen stick isn't aiming. The stick
|
||||
/// that takes over selects at once, whatever the other was waiting for.
|
||||
fn pad_radial_follow_either(&mut self) {
|
||||
let tilts = self.pad_radial_tilts();
|
||||
let sticks = [self.pad.state.left, self.pad.state.right];
|
||||
let open = self.pad.radial.is_open();
|
||||
for i in 0..2 {
|
||||
if radial::stick_length(sticks[i]) < tilts[i].rest {
|
||||
self.pad.radial_rested[i] = true;
|
||||
}
|
||||
let reach =
|
||||
radial::stick_length(sticks[i]).max(radial::stick_length(self.pad.peaks[i]));
|
||||
let over = reach >= tilts[i].select;
|
||||
let may = !open || self.pad.radial_rested[i];
|
||||
if over && !self.pad.radial_over[i] && may {
|
||||
let before = self.pad.radial_either.replace(i).unwrap_or(1);
|
||||
if open && before != i {
|
||||
self.pad.radial.took_over();
|
||||
}
|
||||
}
|
||||
self.pad.radial_over[i] = over;
|
||||
}
|
||||
}
|
||||
|
||||
/// Screens, the on-screen keyboard and the prompts over gameplay come first. It isn't a
|
||||
/// screen: the world goes on behind it, here and on a server.
|
||||
pub(in crate::game) fn pad_radial_can_open(&self) -> bool {
|
||||
!self.pad_in_menu()
|
||||
&& !self.pad_keyboard_captures()
|
||||
&& !self.screen_over_playing()
|
||||
&& self.player.is_some()
|
||||
&& self.sleeping.is_none()
|
||||
&& !self.pause
|
||||
}
|
||||
|
||||
/// The opener went down: always on the first page.
|
||||
pub(in crate::game) fn pad_radial_open(&mut self) {
|
||||
let tilts = self.pad_radial_tilts();
|
||||
let sticks = [self.pad.state.left, self.pad.state.right];
|
||||
self.pad.radial_rested =
|
||||
std::array::from_fn(|i| radial::stick_length(sticks[i]) < tilts[i].rest);
|
||||
let (index, stick, _, tilt) = self.pad_radial_selector();
|
||||
self.pad.radial.open(index, stick, tilt, Instant::now());
|
||||
self.pad.radial_face = self.pad_radial_face();
|
||||
self.pad.radial_page_said = false;
|
||||
self.pad.radial_said.reset();
|
||||
}
|
||||
|
||||
/// The opener came up: held, it commits; in toggle mode the menu stays open, until A, the
|
||||
/// opener again or B.
|
||||
pub(in crate::game) fn pad_radial_release(&mut self) -> Result<()> {
|
||||
if !self.pad.radial.is_open() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.settings.controller.radial_mode == RadialMode::Toggle && !self.pad.radial.latched()
|
||||
{
|
||||
self.pad.radial.latch();
|
||||
return Ok(());
|
||||
}
|
||||
self.pad_radial_commit(false)
|
||||
}
|
||||
|
||||
/// Whether `action` would do anything now. Only what is known here, cheaply and for certain:
|
||||
/// the emote wheel opens only with cosmetics on, Quick Actions only when the server gave a
|
||||
/// dialog for it, and Social only on a server (`Gui.handleKeybinds`). Everything else on the
|
||||
/// list always runs.
|
||||
fn pad_radial_runs(&self, action: RadialAction) -> bool {
|
||||
self.pad_radial_why(action).is_none()
|
||||
}
|
||||
|
||||
/// Why `action` wouldn't do anything now, as a language key for the overlay's hub; `None`
|
||||
/// when it runs.
|
||||
fn pad_radial_why(&self, action: RadialAction) -> Option<&'static str> {
|
||||
match action {
|
||||
RadialAction::Emote if !super::super::cosmetics::enabled() => Some(WHY_COSMETICS),
|
||||
RadialAction::QuickActions if !self.has_quick_actions() => Some(WHY_QUICK_ACTIONS),
|
||||
RadialAction::SocialInteractions if !self.is_multiplayer_server() => Some(WHY_SOCIAL),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of the page's slots would run now: the overlay dims the others. A missing action
|
||||
/// never runs.
|
||||
pub(in crate::game) fn pad_radial_available(&self) -> [bool; radial::SLOTS] {
|
||||
self.pad_radial_available_on(self.pad.radial.page())
|
||||
}
|
||||
|
||||
/// `pad_radial_available` on page `page`.
|
||||
fn pad_radial_available_on(&self, page: usize) -> [bool; radial::SLOTS] {
|
||||
let page = self.settings.controller.radial.page(page);
|
||||
page.slots().map(|slot| match slot {
|
||||
Slot::Empty => true,
|
||||
Slot::Action(action) => self.pad_radial_runs(*action),
|
||||
Slot::Missing(_) => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// A slot that holds something that would run: only those tick as the stick reaches them.
|
||||
fn pad_radial_fills(&self, slot: usize) -> bool {
|
||||
let page = self.settings.controller.radial.page(self.pad.radial.page());
|
||||
!page.slot(slot).is_empty() && self.pad_radial_available()[slot]
|
||||
}
|
||||
|
||||
/// Each poll, before its presses: the selecting stick, the D-pad's settle, a second page
|
||||
/// tick due; and while it's open, the stick, or the end of it if something took over (a bed,
|
||||
/// a death) in a way the context doesn't see.
|
||||
pub(in crate::game) fn pad_radial_frame(&mut self) {
|
||||
let now = Instant::now();
|
||||
self.pad_radial_poll(now);
|
||||
let menu = &self.pad.radial;
|
||||
let seen = menu.shown(now).then(|| Seen {
|
||||
page: menu.page(),
|
||||
selected: menu.selected(),
|
||||
waiting: menu.waiting(),
|
||||
});
|
||||
self.pad.radial_look.observe(seen, now);
|
||||
}
|
||||
|
||||
/// `pad_radial_frame`'s work, before the overlay's look sees the result.
|
||||
fn pad_radial_poll(&mut self, now: Instant) {
|
||||
self.pad
|
||||
.radial
|
||||
.sample([self.pad.state.left, self.pad.state.right], now);
|
||||
if self.settings.controller.radial_stick == RadialStick::Either {
|
||||
self.pad_radial_follow_either();
|
||||
}
|
||||
let (_, stick, peak, tilt) = self.pad_radial_selector();
|
||||
if let Some((at, rumble)) = self.pad.radial_rumble_due
|
||||
&& now >= at
|
||||
{
|
||||
self.pad.radial_rumble_due = None;
|
||||
self.pad_rumble(rumble);
|
||||
}
|
||||
if !self.pad.radial.is_open() {
|
||||
return;
|
||||
}
|
||||
if !self.pad_radial_can_open() {
|
||||
self.pad_radial_cancel();
|
||||
return;
|
||||
}
|
||||
let was = self.pad.radial.selected();
|
||||
self.pad.radial.update_with_peak(stick, peak, tilt, now);
|
||||
// A tick as the stick reaches a slot that would run, so a flick can be felt.
|
||||
let selected = self.pad.radial.selected();
|
||||
if selected != was && selected.is_some_and(|slot| self.pad_radial_fills(slot)) {
|
||||
self.pad_rumble(Rumble::RadialSlot);
|
||||
}
|
||||
self.pad_radial_narrate(now);
|
||||
}
|
||||
|
||||
/// With the narrator on: the page's name once the overlay shows and after each page turn,
|
||||
/// and the slot's caption once the selection settles.
|
||||
fn pad_radial_narrate(&mut self, now: Instant) {
|
||||
if horizon_narrator::status() == horizon_narrator::Status::Off
|
||||
|| !self.pad.radial.shown(now)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let pages = &self.settings.controller.radial;
|
||||
let page = self.pad.radial.page();
|
||||
if !std::mem::replace(&mut self.pad.radial_page_said, true) {
|
||||
let name = horizon_client_render::radial_overlay::page_label(pages.page(page));
|
||||
self.say_system_now(&Text::literal(name));
|
||||
}
|
||||
let at = self.pad.radial.selected().map(|slot| (page, slot));
|
||||
if let Some((page, slot)) = self.pad.radial_said.poll(at, now) {
|
||||
let slot = pages.page(page).slot(slot);
|
||||
let caption = horizon_client_render::radial_overlay::slot_caption(slot)
|
||||
.unwrap_or_else(|| horizon_client::lang::get("horizon.radial.empty"));
|
||||
self.say_system_now(&Text::literal(caption));
|
||||
}
|
||||
}
|
||||
|
||||
/// A press while it's open: bumpers turn the page, B and Start cancel; in toggle mode A or
|
||||
/// the opener again commits. A trigger cancels and goes on to its own action (`false`: not
|
||||
/// used up), so a fight goes on; no other press reaches a mapping.
|
||||
pub(in crate::game) fn pad_radial_button(&mut self, button: Button) -> Result<bool> {
|
||||
let settings = &self.settings.controller;
|
||||
let opener = settings.binding(gamepad::RADIAL);
|
||||
let toggle = settings.radial_mode == RadialMode::Toggle;
|
||||
let bumpers = [Button::LeftBumper, Button::RightBumper];
|
||||
match button {
|
||||
// Toggle mode, latched open: the opener again commits.
|
||||
b if Some(b) == opener => {
|
||||
if self.pad.radial.latched() {
|
||||
self.pad_radial_commit(false)?;
|
||||
}
|
||||
}
|
||||
// The thumb slid off the stick to A: what it had settled on, not what it crossed.
|
||||
Button::South if toggle => self.pad_radial_commit(true)?,
|
||||
b if bumpers.contains(&b) => {
|
||||
// With a bumper for the opener, the other turns the pages on.
|
||||
let forward = opener.is_some_and(|o| bumpers.contains(&o)) || b == bumpers[1];
|
||||
self.pad_radial_turn(forward);
|
||||
}
|
||||
Button::East | Button::Start => self.pad_radial_cancel(),
|
||||
Button::LeftTrigger | Button::RightTrigger => {
|
||||
self.pad_radial_cancel();
|
||||
return Ok(false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// A page turn: a tick, and a second one when the pages went round an end.
|
||||
fn pad_radial_turn(&mut self, forward: bool) {
|
||||
let (_, stick, _, tilt) = self.pad_radial_selector();
|
||||
let pages = self.settings.controller.radial.len();
|
||||
let Some(wrapped) = self.pad.radial.turn_page(forward, stick, tilt, pages) else {
|
||||
return;
|
||||
};
|
||||
self.pad.radial_look.turned(forward);
|
||||
self.pad_rumble(Rumble::RadialPage);
|
||||
if wrapped {
|
||||
self.pad.radial_rumble_due =
|
||||
Some((Instant::now() + radial::WRAP_TICK_GAP, Rumble::RadialWrap));
|
||||
}
|
||||
self.pad.radial_page_said = false;
|
||||
self.pad.radial_said.reset();
|
||||
}
|
||||
|
||||
/// The selected action, pressed and let go, once. Every entry acts on its press, and nothing
|
||||
/// stays held behind it. One that can't run now does nothing at all, and a release that runs
|
||||
/// nothing after something was selected gives a soft buzz.
|
||||
/// `by_a`: toggle mode's A, pressed by the thumb that was selecting (`RadialMenu::commit_settled`).
|
||||
fn pad_radial_commit(&mut self, by_a: bool) -> Result<()> {
|
||||
let page = self.pad.radial.page();
|
||||
let aimed = self.pad.radial.aimed();
|
||||
let selecting = self.pad_radial_selector().0;
|
||||
let now = Instant::now();
|
||||
let slot = if by_a {
|
||||
self.pad.radial.commit_settled(now)
|
||||
} else {
|
||||
self.pad.radial.commit(now)
|
||||
};
|
||||
self.pad_radial_closed(selecting);
|
||||
// Social, dimmed in single player, still goes through its key there: vanilla's action
|
||||
// bar says it isn't available (`Gui.handleKeybinds`).
|
||||
let action = slot
|
||||
.and_then(|slot| {
|
||||
self.settings
|
||||
.controller
|
||||
.radial
|
||||
.page(page)
|
||||
.slot(slot)
|
||||
.action()
|
||||
})
|
||||
.filter(|action| {
|
||||
self.pad_radial_runs(*action) || *action == RadialAction::SocialInteractions
|
||||
});
|
||||
let Some(action) = action else {
|
||||
if aimed {
|
||||
self.pad_rumble(Rumble::RadialNothing);
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
self.pad.radial_runs.push(action.id());
|
||||
if let Some(slot) = slot {
|
||||
self.pad.radial_look.ran(slot);
|
||||
}
|
||||
let shoots = matches!(action, RadialAction::Screenshot | RadialAction::ToggleGui);
|
||||
let action = action.action();
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, UI_CLICK_VOLUME, 1.0);
|
||||
// The release runs whatever the press did, so no key stays held.
|
||||
let pressed = self.pad_action(action, true);
|
||||
let released = self.pad_action(action, false);
|
||||
// What opened a screen, takes a screenshot or hides the HUD gets the view to itself at
|
||||
// once: no flash or fade over it.
|
||||
if shoots || self.pad_in_menu() || self.screen_over_playing() {
|
||||
self.pad.radial_look.cut();
|
||||
}
|
||||
pressed.and(released)
|
||||
}
|
||||
|
||||
/// B, Start, a trigger, a key or a click, a takeover the context doesn't see: the menu closes
|
||||
/// and nothing runs. (The window's focus is in the context: losing it cancels in
|
||||
/// `pad_cancel_inputs`.)
|
||||
pub(in crate::game) fn pad_radial_cancel(&mut self) {
|
||||
if self.pad.radial.is_open() {
|
||||
let selecting = self.pad_radial_selector().0;
|
||||
self.pad.radial.cancel();
|
||||
self.pad_radial_closed(selecting);
|
||||
}
|
||||
}
|
||||
|
||||
/// After a commit or a cancel: the stick that was selecting (`selecting`, 0 left, 1 right),
|
||||
/// and with Either both, moves the player or the view only once it has been let go; a held drop starts its wait
|
||||
/// again rather than throwing the rest of the stack the moment the menu is gone; and on a
|
||||
/// D-pad opener the D-pad settles.
|
||||
fn pad_radial_closed(&mut self, selecting: usize) {
|
||||
let now = Instant::now();
|
||||
// With Either neither stick moved anything while it was open: both wait.
|
||||
let either = self.settings.controller.radial_stick == RadialStick::Either;
|
||||
for i in 0..2 {
|
||||
if i != selecting && !either {
|
||||
continue;
|
||||
}
|
||||
let deadzone = [
|
||||
self.settings.controller.move_deadzone,
|
||||
self.settings.controller.look_deadzone,
|
||||
][i];
|
||||
let stick = [self.pad.state.left, self.pad.state.right][i];
|
||||
if gamepad::radial(stick, deadzone) != [0.0; 2] {
|
||||
self.pad.blocked_sticks[i] = true;
|
||||
self.pad.peaks[i] = [0.0; 2];
|
||||
match i {
|
||||
0 => self.pad.state.left = [0.0; 2],
|
||||
_ => self.pad.state.right = [0.0; 2],
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.pad.held_drop.is_some() {
|
||||
let delay = Duration::from_secs_f32(self.settings.controller.repeat_delay);
|
||||
self.pad.held_drop = Some(gamepad::HeldDrop::new(now, delay));
|
||||
}
|
||||
if self
|
||||
.settings
|
||||
.controller
|
||||
.binding(gamepad::RADIAL)
|
||||
.is_some_and(|b| DPAD.contains(&b))
|
||||
{
|
||||
self.pad.radial_settle.closed(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// A D-pad press in play starting just after the menu closed on a D-pad opener: a thumb
|
||||
/// rolling off it. It is ignored, and so is its release; the opener's own isn't.
|
||||
pub(in crate::game) fn pad_radial_settling(&self, button: Button) -> bool {
|
||||
let opener = self.settings.controller.binding(gamepad::RADIAL);
|
||||
let held = opener.is_some_and(|opener| self.pad.state.held(opener));
|
||||
DPAD.contains(&button)
|
||||
&& self
|
||||
.pad
|
||||
.radial_settle
|
||||
.quiet(opener == Some(button), held, Instant::now())
|
||||
}
|
||||
|
||||
/// A button came up: the opener's release starts the D-pad's settle again.
|
||||
pub(in crate::game) fn pad_radial_button_up(&mut self, button: Button) {
|
||||
if self.settings.controller.binding(gamepad::RADIAL) == Some(button) {
|
||||
self.pad.radial_settle.opener_up(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// A new player entity (spawn, respawn, a server joined, a dimension change): its health is
|
||||
/// not compared with the old one's, and the radial menu closes as for a cancel, as a commit
|
||||
/// would run on the new body (a death with immediate respawn, the End's exit).
|
||||
pub(in crate::game) fn pad_player_replaced(&mut self) {
|
||||
self.pad.player_replaced();
|
||||
self.pad_radial_cancel();
|
||||
}
|
||||
|
||||
pub(in crate::game) fn pad_radial_status(&self) -> Value {
|
||||
let menu = &self.pad.radial;
|
||||
let pages = &self.settings.controller.radial;
|
||||
let page = pages.page(menu.page());
|
||||
json!({
|
||||
"open": menu.is_open(),
|
||||
"latched": menu.latched(),
|
||||
"page": menu.page(),
|
||||
"pages": pages.len(),
|
||||
"name": horizon_client_render::radial_overlay::page_label(page),
|
||||
"slots": page.slots().map(|slot| (!slot.is_empty()).then(|| slot.id())),
|
||||
"available": self.pad_radial_available(),
|
||||
"selected": menu.selected(),
|
||||
"aimed": menu.aimed(),
|
||||
"shown": menu.shown(Instant::now()),
|
||||
"drawn": self.pad_radial_drawn(self.hud_clock()).is_some(),
|
||||
// The overlay's glyphs as the player sees them: the bumpers that turn the pages and
|
||||
// the button that cancels, by their printed labels.
|
||||
"glyphs": {
|
||||
"pages": self.pad_radial_page_keys().map(|key| key.map(|glyph| glyph.label())),
|
||||
"cancel": self.menu_glyph(MenuJob::Back).label(),
|
||||
},
|
||||
"waiting": menu.waiting(),
|
||||
"selecting": self.pad_radial_selector().0,
|
||||
"mode": self.settings.controller.radial_mode.name(),
|
||||
"stick": self.settings.controller.radial_stick.name(),
|
||||
"runs": self.pad.radial_runs.last,
|
||||
"run_count": self.pad.radial_runs.count,
|
||||
})
|
||||
}
|
||||
|
||||
/// What the pad draws over the HUD, last: the radial menu, then the pad's cursor or focus
|
||||
/// highlight and the on-screen keyboard over everything else (`draw_pad_cursor`).
|
||||
pub(in crate::game) fn draw_pad_over_hud(&self, batch: &mut horizon_client::hud::HudBatch) {
|
||||
self.draw_pad_radial(batch);
|
||||
self.draw_pad_cursor(batch);
|
||||
}
|
||||
|
||||
/// The overlay, over the HUD, in this player's view: while it shows, and while it fades out.
|
||||
fn draw_pad_radial(&self, batch: &mut horizon_client::hud::HudBatch) {
|
||||
let clock = self.hud_clock();
|
||||
let look = &self.pad.radial_look;
|
||||
let Some(seen) = self.pad_radial_drawn(clock) else {
|
||||
return;
|
||||
};
|
||||
let pages = &self.settings.controller.radial;
|
||||
let page = pages.page(seen.page);
|
||||
let why = page.slots().map(|slot| match slot {
|
||||
Slot::Action(action) => self.pad_radial_why(*action),
|
||||
_ => None,
|
||||
});
|
||||
let dial = Dial {
|
||||
pages,
|
||||
seen,
|
||||
available: self.pad_radial_available_on(seen.page),
|
||||
why,
|
||||
toggles: self.pad_radial_toggles(),
|
||||
page_keys: self.pad_radial_page_keys(),
|
||||
cancel: self.menu_glyph(MenuJob::Back),
|
||||
backing: (self.settings.text_background(radial_overlay::CHIP_BACKING) >> 24) as u8,
|
||||
room: &self.ui.room,
|
||||
};
|
||||
let painter = super::super::item_draw::IconPainter {
|
||||
game: self,
|
||||
face: self.pad.radial_face.as_deref(),
|
||||
};
|
||||
radial_overlay::draw_overlay(batch, &self.gui.atlas, &painter, &dial, look, clock);
|
||||
}
|
||||
|
||||
/// What the overlay draws at `clock`: while it shows, and while it fades out, but never a
|
||||
/// fade-out over a screen that came up meanwhile.
|
||||
fn pad_radial_drawn(&self, clock: horizon_client::hud_kit::motion::Clock) -> Option<Seen> {
|
||||
let look = &self.pad.radial_look;
|
||||
if !look.open() && (self.pad_in_menu() || self.screen_over_playing()) {
|
||||
return None;
|
||||
}
|
||||
look.showing(clock)
|
||||
}
|
||||
|
||||
/// The overlay is showing (not only fading out): it holds the view (`overlay_holds_view`).
|
||||
pub(in crate::game) fn pad_radial_shown(&self) -> bool {
|
||||
self.pad.radial_look.open()
|
||||
}
|
||||
|
||||
/// The toggles the overlay shows the state of.
|
||||
fn pad_radial_toggles(&self) -> Toggles {
|
||||
use super::super::perspective::CameraType;
|
||||
Toggles {
|
||||
view: match self.camera_type {
|
||||
CameraType::FirstPerson => View::FirstPerson,
|
||||
CameraType::ThirdPersonBack => View::ThirdPersonBack,
|
||||
CameraType::ThirdPersonFront => View::ThirdPersonFront,
|
||||
},
|
||||
hud_hidden: self.ui.hide_gui,
|
||||
cinematic: self.smooth_camera,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bumpers that turn the pages back and on, in this player's glyphs. With a bumper as the
|
||||
/// opener, only the other, which turns them on.
|
||||
fn pad_radial_page_keys(&self) -> [Option<Glyph>; 2] {
|
||||
let opener = self.settings.controller.binding(gamepad::RADIAL);
|
||||
let [back, on] = [MenuJob::PagePrev, MenuJob::PageNext];
|
||||
match opener {
|
||||
Some(Button::LeftBumper) => [None, Some(self.menu_glyph(on))],
|
||||
Some(Button::RightBumper) => [None, Some(self.menu_glyph(back))],
|
||||
_ => [Some(self.menu_glyph(back)), Some(self.menu_glyph(on))],
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's own skin, for the Emote slot's face: their entry in the player list, which
|
||||
/// single player has too (the window is its server thread's client). Before the entry or its
|
||||
/// texture has come, the default skin their id gives, as the player list draws it.
|
||||
fn pad_radial_face(&mut self) -> Option<String> {
|
||||
let me = self.remote.as_ref().map(|r| r.profile());
|
||||
let skin = match (me, self.remote_tab_players()) {
|
||||
(Some(me), Some((players, _, _))) => players
|
||||
.into_iter()
|
||||
.find(|p| p.uuid == me)
|
||||
.map(|p| (me, p.skin)),
|
||||
_ => None,
|
||||
};
|
||||
let me = me?;
|
||||
Some(match skin {
|
||||
Some((me, skin)) => self.face_skin(me, &skin),
|
||||
None => horizon_client::tab_list::default_skin(me).to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The hub's reasons a slot can't run (language keys).
|
||||
const WHY_COSMETICS: &str = "horizon.radial.why.cosmetics";
|
||||
const WHY_QUICK_ACTIONS: &str = "horizon.radial.why.quick_actions";
|
||||
const WHY_SOCIAL: &str = "horizon.radial.why.social";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_press_in_the_poll_of_a_held_back_opener_release_comes_first_while_open() {
|
||||
let late = vec![Button::DPadRight];
|
||||
let edges = vec![(Button::East, true), (Button::South, false)];
|
||||
assert_eq!(
|
||||
poll_order(late.clone(), edges.clone(), true),
|
||||
[
|
||||
PollStep::Edge(Button::East, true),
|
||||
PollStep::Late(Button::DPadRight),
|
||||
PollStep::Edge(Button::South, false),
|
||||
]
|
||||
);
|
||||
// Closed, the held-back releases go first, then the changes as they came.
|
||||
assert_eq!(
|
||||
poll_order(late, edges, false),
|
||||
[
|
||||
PollStep::Late(Button::DPadRight),
|
||||
PollStep::Edge(Button::East, true),
|
||||
PollStep::Edge(Button::South, false),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ impl Game<'_> {
|
||||
pub(super) fn pad_action_glyphs_on(&self) -> bool {
|
||||
self.pad.in_use
|
||||
&& !self.pad.menu.keyboard
|
||||
&& !self.pad_keyboard_captures()
|
||||
&& self.pad.menu.osk.is_none()
|
||||
&& self.pad.menu.pos.is_some()
|
||||
&& self.pad_in_menu()
|
||||
}
|
||||
|
||||
@@ -495,10 +495,10 @@ impl Game<'_> {
|
||||
if self.prompt_open() {
|
||||
return self.pad_prompt_first(targets).or(targets.first().copied());
|
||||
}
|
||||
let rects: Vec<Rect> = targets.iter().map(|t| t.rect).collect();
|
||||
// Back on a screen the pad left: where it was, as vanilla keeps a
|
||||
// screen's focused widget (Options, after its Done). The same widget
|
||||
// first: the rows under it can have moved.
|
||||
let rects: Vec<Rect> = targets.iter().map(|t| t.rect).collect();
|
||||
if let Some(i) = self
|
||||
.pad
|
||||
.menu
|
||||
@@ -795,16 +795,13 @@ impl Game<'_> {
|
||||
}
|
||||
// A prompt over the screen answers first; no press reaches both.
|
||||
let prompt = self.prompt_open();
|
||||
// Bindings, or the radial menu's opener row, waiting for a button: this one (Start
|
||||
// cancels).
|
||||
// Bindings waiting for a button: this one (Start cancels).
|
||||
if !prompt
|
||||
&& self.screen == Screen::Options
|
||||
&& self.ui.options.controller_open()
|
||||
&& let Some(action) = self.ui.options.selected_pad_action.take()
|
||||
{
|
||||
// Start cancels, and so does a button the action can't take (the radial menu's
|
||||
// opener under the selecting thumb or on B, `gamepad::opener_refused`): the binding
|
||||
// stays.
|
||||
if button != Button::Start && self.settings.controller.can_bind(action, button) {
|
||||
if button != Button::Start {
|
||||
self.settings.controller.bind(action, Some(button));
|
||||
}
|
||||
return self.handle_option_events(vec![Event::Changed]);
|
||||
@@ -1148,8 +1145,6 @@ impl Game<'_> {
|
||||
}
|
||||
let focus = self.pad_reveal(focus);
|
||||
self.pad.menu.focus = Some(focus);
|
||||
// A step, not a press: a radial slot's press opens its list.
|
||||
self.ui.options.pad_clicks_step(true);
|
||||
if button == Button::DPadLeft {
|
||||
self.pad_with_modifier(KeyCode::ShiftLeft, |game| {
|
||||
game.pad_click_at(event_loop, focus.center(), false)
|
||||
@@ -1157,7 +1152,6 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.pad_click_at(event_loop, focus.center(), false);
|
||||
}
|
||||
self.ui.options.pad_clicks_step(false);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1854,16 +1848,7 @@ impl Game<'_> {
|
||||
// Two frames on, as after a step that scrolled: the first may predate the scroll.
|
||||
&& !reveal_pending(self.pad.menu.revealed, widgets::generation())
|
||||
{
|
||||
// A settings page that says where its focus opens (a radial slot's list on the slot's
|
||||
// action, the slot again after it), once its row is drawn.
|
||||
let opened = (self.screen == Screen::Options)
|
||||
.then(|| self.ui.options.shell_opened_focus())
|
||||
.flatten()
|
||||
.and_then(|rect| gamepad::refind(rect, &rects, 2.0));
|
||||
if let Some(i) = opened {
|
||||
self.ui.options.clear_shell_opened_focus();
|
||||
self.pad.menu.focus = Some(targets[i]);
|
||||
} else if let Some(focus) = self.pad.menu.focus {
|
||||
if let Some(focus) = self.pad.menu.focus {
|
||||
let kind = std::mem::discriminant(&focus.kind);
|
||||
let named = self.pad_named_focus(focus, &rects);
|
||||
let followed = stick_scrolled.and_then(|s| {
|
||||
@@ -2034,7 +2019,7 @@ impl Game<'_> {
|
||||
/// button being bound, the credits, the on-screen keyboard.
|
||||
fn key_focus_blocked(&self) -> bool {
|
||||
let playing_screen = |screen| self.title.is_none() && self.screen == screen;
|
||||
self.pad_keyboard_captures()
|
||||
self.pad.menu.osk.is_some()
|
||||
|| playing_screen(Screen::Chat)
|
||||
|| self.sign_edit.is_some()
|
||||
|| matches!(self.book, Some(super::books::BookScreen::Edit { .. }))
|
||||
@@ -2247,10 +2232,8 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let takeover = match event {
|
||||
// Not while the pad holds the radial menu open: as for raw mouse motion in play
|
||||
// (`device_event`), a cursor that moved is a bumped mouse.
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
self.pad.menu.real_pointer != Some(*position) && !self.pad_radial_captures()
|
||||
self.pad.menu.real_pointer != Some(*position)
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } => event.state == ElementState::Pressed,
|
||||
WindowEvent::MouseInput { state, .. } => *state == ElementState::Pressed,
|
||||
|
||||
@@ -114,7 +114,9 @@ impl Game<'_> {
|
||||
// A pad's toggle-sneak isn't holding it: it has no release that would end it.
|
||||
Source::Keyboard => {
|
||||
!(slot == SNEAK && self.settings.controller.toggle_sneak)
|
||||
&& self.pad.holds_action(["key.sneak", "key.sprint"][slot])
|
||||
&& self
|
||||
.pad
|
||||
.holds_action(&self.settings.controller, ["key.sneak", "key.sprint"][slot])
|
||||
}
|
||||
Source::Pad => self.physical_keys.iter().any(|physical| {
|
||||
self.settings
|
||||
@@ -457,7 +459,7 @@ impl Game<'_> {
|
||||
|
||||
/// Whether a device holds the key mapping named `mapping` (`key.use`).
|
||||
pub(super) fn input_holds(&self, mapping: &str) -> bool {
|
||||
self.pad.holds_action(mapping)
|
||||
self.pad.holds_action(&self.settings.controller, mapping)
|
||||
}
|
||||
|
||||
/// Turns the view by what the devices give this frame (`Entity.turn`).
|
||||
@@ -479,7 +481,7 @@ impl Game<'_> {
|
||||
|
||||
/// A new player entity took over (spawn, respawn, a server joined, a dimension change).
|
||||
pub(super) fn input_player_replaced(&mut self) {
|
||||
self.pad_player_replaced();
|
||||
self.pad.player_replaced();
|
||||
}
|
||||
|
||||
/// Whether the drop about to happen is a device's whole-stack drop (it has no Control key to
|
||||
|
||||
@@ -146,16 +146,7 @@ impl Game<'_> {
|
||||
// an active use in the same tick.
|
||||
self.release_use_for_keybind_screen();
|
||||
}
|
||||
// key.socialInteractions (Gui.handleKeybinds): only on a server; in single player
|
||||
// the action bar says so, and the narrator.
|
||||
KeyCode::KeyP if self.screen == Screen::Playing && !self.is_multiplayer_server() => {
|
||||
let message = horizon_client::text::Text::translate(
|
||||
"multiplayer.socialInteractions.not_available",
|
||||
&[],
|
||||
);
|
||||
self.titles.set_overlay(message.clone(), false);
|
||||
self.say_system_now(&message);
|
||||
}
|
||||
// key.socialInteractions.
|
||||
KeyCode::KeyP if self.screen == Screen::Playing => {
|
||||
self.open_social(false);
|
||||
self.release_use_for_keybind_screen();
|
||||
|
||||
@@ -19,9 +19,7 @@ use std::sync::Arc;
|
||||
/// Registers the hooks of the Horizon parts still in this crate (cosmetics: spec #14091 step 5
|
||||
/// moves them out). Before the hooks are sealed.
|
||||
pub(crate) fn register() {
|
||||
if super::cosmetics::enabled()
|
||||
&& let Err(e) = horizon_hooks::register(&super::cosmetics::HOOKS)
|
||||
{
|
||||
if let Err(e) = super::cosmetics::register() {
|
||||
log::error!("Cosmetics: their hooks came too late: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,8 +917,10 @@ impl Game<'_> {
|
||||
self.drag_button = None;
|
||||
self.modifiers = Default::default();
|
||||
// A press this player's pad holds goes on, as for its keys.
|
||||
self.left = self.pad.holds_action("key.attack");
|
||||
self.right = self.pad.holds_action("key.use");
|
||||
self.left = self
|
||||
.pad
|
||||
.holds_action(&self.settings.controller, "key.attack");
|
||||
self.right = self.pad.holds_action(&self.settings.controller, "key.use");
|
||||
self.inventory_mouse_release(false);
|
||||
self.inventory_mouse_release(true);
|
||||
self.inventory_pick_release();
|
||||
|
||||
@@ -130,12 +130,6 @@ impl Split<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The furthest a player's pad leant its sticks since the last frame: nothing for a pad just
|
||||
/// joined whose buttons are still being let go.
|
||||
fn pad_peaks(&self, pad: Option<PadId>) -> [[f32; 2]; 2] {
|
||||
pad.filter(|id| !self.fresh.contains(id))
|
||||
.map_or([[0.0; 2]; 2], |id| self.pads.peaks(id))
|
||||
}
|
||||
/// The pad's state for its player: nothing while the press that gave it is still held.
|
||||
fn pad_state(&mut self, pad: Option<PadId>) -> PadState {
|
||||
let Some(id) = pad else {
|
||||
@@ -288,16 +282,14 @@ impl Split<'_> {
|
||||
self.pads.report_status(&mut self.host.pad, owner);
|
||||
}
|
||||
let state = self.pad_state(owner);
|
||||
let peaks = self.pad_peaks(owner);
|
||||
self.host.pad.feed_from(owner, state, peaks);
|
||||
self.host.pad.feed_from(owner, state);
|
||||
for i in 0..self.guests.len() {
|
||||
let pad = self.guests[i].pad;
|
||||
if self.guests[i].game.control.is_some() {
|
||||
self.pads.report_status(&mut self.guests[i].game.pad, pad);
|
||||
}
|
||||
let state = self.pad_state(pad);
|
||||
let peaks = self.pad_peaks(pad);
|
||||
self.guests[i].game.pad.feed_from(pad, state, peaks);
|
||||
self.guests[i].game.pad.feed_from(pad, state);
|
||||
}
|
||||
joins
|
||||
}
|
||||
|
||||
@@ -111,18 +111,6 @@ impl Game<'_> {
|
||||
{
|
||||
self.input_keyboard_used();
|
||||
}
|
||||
// A key or a mouse button pressed while the pad holds the radial menu open closes it,
|
||||
// and goes on to do what it does. Moving the mouse does neither (`device_event`).
|
||||
let pressed = match &event {
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
event.state == ElementState::Pressed && !event.repeat
|
||||
}
|
||||
WindowEvent::MouseInput { state, .. } => *state == ElementState::Pressed,
|
||||
_ => false,
|
||||
};
|
||||
if pressed && !self.pad.menu.synthetic {
|
||||
self.pad_radial_cancel();
|
||||
}
|
||||
if let WindowEvent::MouseInput { state, button, .. } = &event {
|
||||
if *state == ElementState::Pressed {
|
||||
self.physical_buttons.insert(*button);
|
||||
@@ -363,13 +351,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
&& self.screen == Screen::Playing
|
||||
&& let DeviceEvent::MouseMotion { delta: (x, y) } = event
|
||||
{
|
||||
// While the pad holds the radial menu open the mouse does nothing, as the right stick
|
||||
// doesn't turn the view: it neither cancels the menu nor takes control back, nor
|
||||
// turns the view. A thumb is on the opener, so the motion is a bumped desk.
|
||||
if self.pad_radial_captures() {
|
||||
return;
|
||||
}
|
||||
// Moving the mouse hands control back from the pad.
|
||||
if x != 0.0 || y != 0.0 {
|
||||
self.pad_yield_input(event_loop);
|
||||
}
|
||||
|
||||
@@ -33,8 +33,6 @@ pub(crate) fn give() {
|
||||
rule: dark.rule,
|
||||
accent: dark.accent,
|
||||
on_accent: dark.on_accent,
|
||||
lit: theme::night::NIGHT.accent,
|
||||
lit_edge: theme::night::NIGHT.accent_hi,
|
||||
},
|
||||
sizes: Sizes {
|
||||
caption: theme::CAPTION.px,
|
||||
|
||||
@@ -2205,67 +2205,6 @@ pub fn hoglin_anim(m: &Mob, s: &State, p: &mut Poses) {
|
||||
p.xrot("left_hind_leg", pos.mcos() * 1.2 * sp);
|
||||
}
|
||||
|
||||
/// `Brain.Packed`: the guarded-block live ANGRY_AT entry. The existing
|
||||
/// anger counter supplies its remaining long TTL, not an absolute timestamp.
|
||||
pub fn write_guarded_anger(m: &Mob, map: &mut horizon_world::nbt::Compound) {
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
let Some(actor) = m.extra.piglin.guarded_angry_at else {
|
||||
return;
|
||||
};
|
||||
let brain = map
|
||||
.entry("Brain".into())
|
||||
.or_insert_with(|| Tag::Compound(Compound::new()));
|
||||
if !matches!(brain, Tag::Compound(_)) {
|
||||
*brain = Tag::Compound(Compound::new());
|
||||
}
|
||||
let Tag::Compound(brain) = brain else { return };
|
||||
let memories = brain
|
||||
.entry("memories".into())
|
||||
.or_insert_with(|| Tag::Compound(Compound::new()));
|
||||
if !matches!(memories, Tag::Compound(_)) {
|
||||
*memories = Tag::Compound(Compound::new());
|
||||
}
|
||||
let Tag::Compound(memories) = memories else {
|
||||
return;
|
||||
};
|
||||
if let Some(uuid) = actor.filter(|_| m.extra.anger > 0) {
|
||||
// ExpirableValue.codec: the UUID value and optional remaining Long TTL.
|
||||
memories.insert(
|
||||
"minecraft:angry_at".into(),
|
||||
Tag::compound([
|
||||
("value", Tag::IntArray(uuid.to_vec())),
|
||||
("ttl", Tag::Long(i64::from(m.extra.anger))),
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
memories.remove(&"minecraft:angry_at".into());
|
||||
}
|
||||
}
|
||||
|
||||
/// `Piglin.readAdditionalSaveData` through `Brain.Packed`: the guarded-block
|
||||
/// `ANGRY_AT` entry is an entity UUID with the ticks left as its TTL. Vanilla
|
||||
/// reads the memory with the brain, and an expired one no longer angers the
|
||||
/// piglin; the counter is what Horizon's goals ask.
|
||||
pub fn read_guarded_anger(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
|
||||
use horizon_world::nbt::Tag;
|
||||
let memory = tag
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
.and_then(|b| b.get("minecraft:angry_at"));
|
||||
let Some(memory) = memory else { return };
|
||||
let Some(uuid) = crate::save::uuid_from_tag(memory.get("value")) else {
|
||||
return;
|
||||
};
|
||||
// The TTL is a long: one past the counter's range holds as long as it can.
|
||||
let ttl = match memory.get("ttl") {
|
||||
Some(Tag::Long(v)) => (*v).clamp(0, i64::from(i32::MAX)) as i32,
|
||||
Some(t) => crate::nbt_path::int_value(t).unwrap_or(i32::MAX),
|
||||
None => i32::MAX,
|
||||
};
|
||||
m.extra.anger = ttl.max(0);
|
||||
m.extra.piglin.guarded_angry_at = Some(Some(uuid));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod brute_sound_tests {
|
||||
use super::*;
|
||||
|
||||
+10
-190
@@ -64,13 +64,11 @@ once per loop iteration next to winit's events.
|
||||
| Change camera | View | Create / Share | `key.togglePerspective` |
|
||||
| Pick block | D-pad left | D-pad left | `key.pickItem` |
|
||||
| Chat | D-pad up | D-pad up | `key.chat` |
|
||||
| Radial menu (hold) | D-pad right | D-pad right | Horizon `pad.radial` |
|
||||
| Pause / back | Menu | Options | Escape (fixed) |
|
||||
|
||||
Advancements has no button of its own: it is on the radial menu's first page,
|
||||
and bindable, as the player list is. A settings file from before these defaults
|
||||
keeps its buttons where they were and leaves the menu unbound (Controller Settings > Bindings
|
||||
offers it).
|
||||
Advancements and the player list have no button of their own, and are
|
||||
bindable. A settings file from before these defaults keeps its buttons where
|
||||
they were.
|
||||
|
||||
Also bindable, with no default button: fullscreen, player social, quick actions, the save and load
|
||||
toolbar activators and hotbar slots 1 to 9. They act in play only: in a menu or a prompt the button
|
||||
@@ -92,179 +90,6 @@ awkward, which is why Bedrock does the same) and acts like vanilla's toggle
|
||||
crouch: a screen stands you up while it is open and closing it crouches you
|
||||
again. The controls options can make it hold-to-sneak.
|
||||
|
||||
## Radial menu
|
||||
|
||||
Hold the opener and the right stick points at one of eight slots, clockwise
|
||||
from the top; letting the opener go runs the selected action, once. It isn't a
|
||||
screen: the world goes on behind it, and neither the selecting stick nor the mouse
|
||||
turns the view while it is open (the stick not until it has been let go). With
|
||||
the default opener, D-pad Right, the left thumb is on the D-pad, so the player
|
||||
stands still while picking; with the opener on a bumper, a trigger or View the
|
||||
stick that doesn't select still walks or turns the view.
|
||||
Every hold opens on the first page, so a flick aimed blind always lands on the
|
||||
same slot.
|
||||
|
||||
Two settings in Controller Settings > General, under Radial Menu, change how it
|
||||
is held and aimed, saved as `radialMode` and `radialStick` in
|
||||
`horizon-controller.txt`:
|
||||
|
||||
- Radial Menu: Hold (the default) or Toggle (`radialMode:hold|toggle`). In
|
||||
Toggle a press opens it, shown at once, and it stays open with the opener up:
|
||||
A or a second press of the opener runs what is selected, B cancels, and with
|
||||
nothing selected nothing runs.
|
||||
- Radial Menu Selects With: Right Stick (the default), Left Stick or Either
|
||||
(`radialStick:right|left|either`). The selecting stick neither walks nor
|
||||
turns the view while the menu is open; with Either, the last stick to lean
|
||||
past the select tilt selects, and neither moves anything. While the menu is
|
||||
open a stick takes over only once it has been at rest since the open, so a
|
||||
thumb left on the walking stick can't commit for the other. Its tilts follow
|
||||
that stick's own deadzone.
|
||||
|
||||
- The overlay shows once the opener has been held 120 ms (`DRAW_DELAY`); the
|
||||
stick counts from the press, so a quick flick-and-release works without it,
|
||||
and a tap that selected nothing shows nothing and does nothing. A flick
|
||||
between two frames still counts: the pad's own events give the furthest the
|
||||
stick leant since the last poll.
|
||||
- The slots are eight tiles (vanilla's game mode switcher slots, each with its
|
||||
action's icon from the player's own resources) on a ring at true 45 degree
|
||||
steps, where the stick selects them; the selected sector lights up behind
|
||||
its tile. The hub in the middle names the selection; for Perspective, Hide
|
||||
HUD and Cinematic Camera it gives the state and what a press turns it to,
|
||||
and for a slot that can't run, why (`Needs cosmetics on`). An empty slot is
|
||||
a pip, one that can't run is dimmed and slashed, and a missing action (an id
|
||||
this game doesn't know) says Missing. Along the top, the pages are tabs
|
||||
between the bumpers' glyphs, the current one white; on a narrow view they
|
||||
collapse to the name between arrows, with the bumpers naming the pages they
|
||||
turn to. With a bumper for the opener only the other shows, and with one page
|
||||
only its name. B's Cancel sits at the lower right when the view has room. The
|
||||
header stays below vanilla's boss bars and the ring above the status rows
|
||||
over the hotbar (armour, health with absorption, food, a mount's hearts);
|
||||
while the menu shows, the chat lines, the toasts, the crosshair, the guide
|
||||
and the first-person hand give way (none of them blink on a quick tap,
|
||||
which draws nothing). Sizes follow the
|
||||
view's height (`radial_overlay::Layout`), so 1280x720, 854x480 and a quarter
|
||||
of a large window look the same, and smaller views drop the state chips and
|
||||
the Cancel hint first. Its motion is quiet and never selects: a quick fade in,
|
||||
the lit sector easing to a new slot, the tiles sliding in on a page turn, the
|
||||
chosen tile flashing on a commit (`hud_kit::motion`, settled while the game
|
||||
is paused or recording).
|
||||
- Bumpers change the page, around the ends, and clear the selection; with a
|
||||
bumper for the opener, the other one turns the pages on. A stick held through
|
||||
the page turn selects nothing until it moves. One that had leant past rest
|
||||
for 90 ms before the opener went down (`LEAN_BEFORE_OPEN`, a stick turning the
|
||||
camera) selects nothing until it has been at rest, page turns included; one
|
||||
that moved only just before is a flick aimed at the menu, and selects.
|
||||
Meanwhile the sector stays dark and the hub says "Let go of the stick".
|
||||
- A selected slot stays selected a little past its wedge and under the tilt, so
|
||||
a light hold or a stick on a boundary doesn't flicker. The tilts are tuned at
|
||||
the default look deadzone and keep their share of the travel past a raised
|
||||
one (`radial::Tilt`): a selection always lets go above the deadzone, so a
|
||||
drifting stick never keeps one, and no slot ever needs more than a 0.93 lean,
|
||||
which a worn stick still reaches.
|
||||
- With vibration on: reaching a slot that would run gives a tick (45 ms on the
|
||||
light motor), a page turn a shorter one, twice when the pages go round an
|
||||
end, and a release that runs nothing after something was selected a soft
|
||||
buzz. A commit that runs an action plays the button click.
|
||||
- With the narrator on, it says the page's name as the overlay shows and after
|
||||
each page turn, and a slot's caption once the selection has stayed 150 ms
|
||||
(`NARRATE_SETTLE`), so a sweep across slots says only where it stops.
|
||||
- A stick let go keeps its selection for 250 ms (`NEUTRAL_RETENTION`), so it can
|
||||
spring back before the opener comes up; then the selection clears. Latched
|
||||
open in Toggle the selection stays with the stick let go, so the thumb can
|
||||
move to A: only another slot, a page turn or a cancel changes it. A slot the
|
||||
thumb crosses on its way to A, selected less than 90 ms before A goes down
|
||||
(`A_SETTLE`), doesn't count: A runs the one it had settled on.
|
||||
- A release with nothing selected, or on an empty slot, does nothing. An empty
|
||||
slot is a faint outline with no caption; only the selected action takes the
|
||||
accent.
|
||||
- An action that can't run now is drawn dimmed. It can be selected, but its
|
||||
release does nothing: no action, no click. The game decides which: Emote
|
||||
opens the emote wheel only with cosmetics on, Quick Actions only when the
|
||||
server gave a dialog for it, and Social only on a server. Social is the one
|
||||
exception to doing nothing: in single player its release goes through its key,
|
||||
which shows vanilla's "not available" message on the action bar, as
|
||||
`Gui.handleKeybinds` does (pressing its key does the same). A Missing action
|
||||
never runs. The others always run.
|
||||
- B, Start, a disconnect, losing the window's focus (with Play in Background on
|
||||
too), the player being replaced (a death with immediate respawn, the End's
|
||||
exit) or anything that opens over gameplay cancels, also during those 250 ms.
|
||||
RT or LT pressed while it is open cancels it and goes on to attack or use in
|
||||
the same poll; so does any key or mouse button, which isn't swallowed. No
|
||||
other press made while it is open reaches a mapping, and a press in the same
|
||||
poll as the opener's release, held back to the next tick or not, comes first,
|
||||
so B still cancels. Buttons held since before it opened keep their action and
|
||||
release it as usual, so a held sneak stays held. Moving the mouse neither
|
||||
cancels it nor takes control back from the pad, nor turns the view.
|
||||
- After it closes on a D-pad opener, a press of another D-pad button that starts
|
||||
within 100 ms reaches nothing, and nor does its release (`DPAD_SETTLE`): a
|
||||
thumb rolling off the opener doesn't drop or open the chat. The opener itself
|
||||
opens the menu again at once. A drop held through it starts
|
||||
its wait again as it closes, rather than throwing the rest of the stack. F3's
|
||||
3D crosshair gives way to it, as the 2D one does.
|
||||
- Menus, the on-screen keyboard and prompts come first: there the opener's
|
||||
button does what the screen says (or its other action, if it shares one).
|
||||
- The opener is never under a thumb that selects, as holding it and steering
|
||||
are two jobs for one thumb, nor on B, which cancels (`gamepad::opener_refused`):
|
||||
selecting with the right stick, not A, X, Y or R3; with the left, not the D-pad
|
||||
or L3 (A, X and Y are free); with either, neither set, leaving the bumpers,
|
||||
the triggers and View. In the binds list a refused button cancels the capture
|
||||
as Start does, and a file with one reads as if it had no opener saved (D-pad
|
||||
Right, unless another action is there or the stick refuses it). Changing the
|
||||
stick so that the opener is under the selecting thumb unbinds it, and the
|
||||
opener's row says no button opens the menu and which ones can; back on the
|
||||
right stick an unbound opener takes D-pad Right again if it is free. A bumper
|
||||
can be the opener; the other bumper then turns the pages on, round the end.
|
||||
|
||||
The default pages hold only actions with no button of their own that always
|
||||
run, so the menu adds to the pad instead of repeating it, on the straight
|
||||
directions: General has Advancements (up), Commands (right) and Social (down);
|
||||
Camera has Screenshot (up), Hide HUD (right) and Cinematic Camera (down). The
|
||||
rest start empty. Slots hold actions from a fixed list
|
||||
(`radial::RadialAction`), those that do everything on their press (Fullscreen,
|
||||
Quick Actions and the F3 debug screen besides those of the default pages), each shown
|
||||
by a short caption (`horizon.radial.action.*`). Its rules are in
|
||||
`horizon-client-input/src/radial.rs`, the overlay in
|
||||
`horizon-client-render/src/radial_overlay.rs`.
|
||||
|
||||
Pages live in `horizon-controller.txt`, written once they differ from the
|
||||
defaults, so each splitscreen profile has its own, after `radialLayout:1` (the
|
||||
version of the default page list they were written against). A page the player
|
||||
hasn't changed is written as its preset, `radial_<page>_preset:general` or
|
||||
`camera`, and reads back as that default page as it is now: changing one slot
|
||||
of General leaves Camera following the defaults. Setting a slot or a name to
|
||||
what it already is changes nothing, and a page edited back to its default (or
|
||||
read from its slots as that) is its preset again; an edited page drops a preset
|
||||
id this game doesn't know, which would otherwise replace it. A page of the player's own is
|
||||
`radial_<page>_slots` (eight ids, by commas) and its name as
|
||||
`radial_<page>_name` (a name Horizon gave it, by language key) or
|
||||
`radial_<page>_label` (the player's own text, kept as typed);
|
||||
`radial_<page>_kind:hotbar` is read and kept for a hotbar page, which this
|
||||
version shows with empty slots. Slot ids are vanilla key mappings by name
|
||||
(`key.chat`) and Horizon's own in its namespace (`horizon:emote`,
|
||||
`horizon:hotbar_next`, `horizon:hotbar_previous`); the ids they had before
|
||||
(`key.emote`, `pad.hotbarNext`, `pad.hotbarPrevious`) read through an alias
|
||||
table. An id this game doesn't know (a newer Horizon's, a mod's) stays in its
|
||||
slot, drawn as Missing and dimmed, and never runs, so a slot can't run a
|
||||
command. Fields and kinds it doesn't read stay too. A file can hold up to 16
|
||||
pages (`PARSE_MAX`), and the lines of any past them are kept; the menu and the
|
||||
editor show the first six, and a page deleted there brings the next forward.
|
||||
Removing an action empties its slot and moves no other.
|
||||
|
||||
Controller Settings > Bindings > Radial Menu edits them, on the settings shell
|
||||
(`horizon-client-render/src/radial_editor.rs`): the page list is the pages, by
|
||||
name, and each page has its eight slots by direction (A or a click opens the list
|
||||
of every action and Empty for that slot, which names the slot, marks what it holds
|
||||
and opens on it; Right steps a slot to the next action, Empty between the last and
|
||||
the first, and Left or Shift-click back, as on every choice of the settings shell),
|
||||
then Rename Page, New Page
|
||||
and Delete Page, then Reset All Pages and the opener's button, drawn with its
|
||||
glyph. A press on the opener's row waits there for a controller button, as a key
|
||||
row does: A, B, X, Y and the right stick's click are refused, Start cancels, and
|
||||
the row's description says when no button opens the menu or another action
|
||||
shares it. Delete and Reset act at once: Undo, there once something changed, puts
|
||||
the pages and the opener back as the screen found them. Rename opens a page with
|
||||
a name field (A on it opens the on-screen keyboard), Done and Cancel.
|
||||
|
||||
A Nintendo pad's face buttons can go by their places, as the table and SDL's
|
||||
layout have them (B, at the bottom, jumps and confirms), or by their letters,
|
||||
as a Switch's own menus and keyboard do: A (right) jumps and confirms, B goes
|
||||
@@ -644,9 +469,9 @@ The picture's texture is 32 pixels tall, drawn 16 GUI pixels tall with a clear e
|
||||
glyph shows 12 tall, as a text glyph does: a square picture 12 wide, a bumper or trigger 20. A declared
|
||||
button's label leaves room for it.
|
||||
|
||||
Art shows on declared buttons and a focused field's A, in notices, the gameplay guide, the radial
|
||||
menu's hints and splitscreen's strips. What runs inside a line of text stays text: the recipe tooltip,
|
||||
the on-screen keyboard's hint line and the buttons on the Bindings page. Vanilla's own messages that name a key (tutorial hints) keep the keyboard's names.
|
||||
Art shows on declared buttons and a focused field's A, in notices, the gameplay guide and splitscreen's
|
||||
strips. What runs inside a line of text stays text: the recipe tooltip, the on-screen keyboard's hint line and the buttons on
|
||||
the Bindings page. Vanilla's own messages that name a key (tutorial hints) keep the keyboard's names.
|
||||
|
||||
The inventory crosshair is `crosshair_inv` in the same folder, drawn at 16 GUI pixels on the point it
|
||||
clicks (`pad_cursor.rs`).
|
||||
@@ -666,8 +491,7 @@ Extras and Graphics, with a search field and General, Bindings and Device pages.
|
||||
describes the pointed or focused row, and what a choice's value does. Left and Right step a choice both
|
||||
ways, as its `<` `>` show. The search gives the keys back as soon as the focus leaves it.
|
||||
|
||||
Bindings starts with the Radial Menu row, which opens the radial menu's pages (described above), then
|
||||
Actions, a list like the key binds screen. Each action's row shows its button's art in the player's
|
||||
Bindings is a list like the key binds screen. Each action's row shows its button's art in the player's
|
||||
glyphs. Press the row and then a button; Start, a click or a key cancels, and the keyboard's Escape
|
||||
unbinds. Meanwhile the row reads "> A <" and the strip names the button that cancels. A button that does
|
||||
more than one action is marked "[ ]" in yellow, as vanilla marks conflicts, and the strip lists what else
|
||||
@@ -701,13 +525,11 @@ bind_key.jump:south
|
||||
craftOnServers:false
|
||||
gameplayGuide:full
|
||||
bind_pad.hotbarNext:rb
|
||||
bindLayout:2
|
||||
bindLayout:1
|
||||
```
|
||||
|
||||
Only bindings that differ from the defaults are written. A file without `bindLayout` comes from before the
|
||||
current defaults and keeps its buttons where they were. A file below `bindLayout:2` comes from before the radial
|
||||
menu's opener: on its first load the opener stays off D-pad Right if the player had put another action there.
|
||||
That happens once; from layout 2 on, a conflict the player makes in the binds list stays as they made it.
|
||||
current defaults and keeps its buttons where they were.
|
||||
|
||||
Deadzones are radial (the stick's length, so diagonals aren't clipped) and the
|
||||
remaining range is rescaled to 0..1. Look uses a squared curve for precise
|
||||
@@ -730,9 +552,7 @@ button, has no row. The mouse or keyboard taking over hides them, as do a menu,
|
||||
Gameplay Guide option. They read what a click would do from the client's own answers
|
||||
(`remote_block_reacts`, `remote_mob_takes`), and a use only a fluid ray or the server can tell (an
|
||||
empty bucket) shows nothing. The rows step around what the HUD drew (effect icons, boss bars, the
|
||||
sidebar, chat, toasts, splitscreen's badge), and in a small view the last rows drop. The left
|
||||
column's last row names the radial menu's opener, as nothing else in play says it's there (for a
|
||||
spectator too, who can open it).
|
||||
sidebar, chat, toasts, splitscreen's badge), and in a small view the last rows drop.
|
||||
|
||||
An inventory or creative screen the pad drives has rows of its own, from its bottom corners, under
|
||||
the same option, and what's under the crosshair decides them: a slot, by whether it holds anything
|
||||
|
||||
+3
-72
@@ -14,7 +14,7 @@ The kit grows a piece at a time, each with something on screen that needs it:
|
||||
|
||||
| Piece | What it gives |
|
||||
|---|---|
|
||||
| `hud_kit::room` | `Room`: what of a view an overlay may use, clear of vanilla's boss bars and status rows |
|
||||
| `hud_kit::room` | `Room`: what of a view an overlay may use, clear of vanilla's boss bars |
|
||||
| `hud_kit::plate` | `Plate`: the box behind a notice's text, or a guide row's backing |
|
||||
| `hud_kit::shapes` | triangles, fans, arcs, discs and radial gradients for `HudBatch`; `alpha_since`, `translate_since` |
|
||||
| `hud_kit::motion` | `Clock` and `Tween`: quiet motion as pure functions of the frame's time |
|
||||
@@ -32,12 +32,11 @@ All sizes are GUI px from the view's top left, and an area is `[x, y, w, h]`.
|
||||
### Room
|
||||
|
||||
A `Room` is a view's size plus the keep-outs its HUD shows this frame. Each keep-out is a `Keep`
|
||||
and an area. There are two kinds:
|
||||
and an area. Today there is one kind:
|
||||
|
||||
| `Keep` | Where it comes from |
|
||||
|---|---|
|
||||
| `BossBars` | `hud_overlays::boss_bars_bottom(h, n)`: vanilla's `BossHealthOverlay` layout, y from 12 in steps of 19 until a third of the view's height; as wide as the 182 px bar or its widest name |
|
||||
| `StatusRows` | the survival rows above the hotbar (health with its absorption rows, armour, food or the mount's hearts, air), from the highest row's top to the view's bottom, as `hud_layout::draws_top` reports them; `room.bottom()` is the lowest y an overlay may reach |
|
||||
|
||||
`Game::hud_room` (`crates/horizon-client/src/game/hud_room.rs`) gathers what is showing.
|
||||
Nothing shows while F1 or Replay Studio hides the HUD, or off a world. `hud` empties `ui.room` at
|
||||
@@ -65,23 +64,6 @@ The other HUD boxes (the minimap, the PvP & HUD widgets, mods' corner panels) do
|
||||
yet. Chat, the hotbar, effect icons and subtitles aren't keep-outs yet either. #10125 asks for
|
||||
these push-aside points for mods.
|
||||
|
||||
### Overlays that hold the view
|
||||
|
||||
Some overlays take the middle of the view for themselves, as the radial menu does once it shows.
|
||||
What vanilla draws there gives way to them through one query, `Game::overlay_holds_view()`
|
||||
(`crates/horizon-client/src/game/hud_room.rs`), not a check per overlay in each piece:
|
||||
|
||||
| Gives way | Where |
|
||||
|---|---|
|
||||
| the 2D crosshair and F3's crosshair | `hud_layers`, `debug_crosshair_drawn` (`game.rs`) |
|
||||
| the chat's lines (an open chat still draws) | `hud_layers` (`game.rs`) |
|
||||
| every toast: advancements, recipes, tutorials, packs, music | `Game::draw_every_toast` (`hud_room.rs`); they go on timing out, as under F1 |
|
||||
| the first-person hands | `Game::hands_shown` (`hud_room.rs`), which `hand_geometry` reads (`game/frame_geometry.rs`) |
|
||||
|
||||
A new overlay that holds the view adds its own condition to `overlay_holds_view`. Ask from the moment
|
||||
it shows, not from when it owns the pad ([Pad capture](#pad-capture)): a quick tap of the radial
|
||||
menu's opener owns the pad for a frame and draws nothing, so nothing should blink.
|
||||
|
||||
### Plates
|
||||
|
||||
`hud_kit::plate::draw(batch, atlas, plate, area)` fills an area with a plate's look, then draws
|
||||
@@ -225,30 +207,6 @@ keeps the list, the arrows walk every page, search skips pages without matches,
|
||||
targets), and `tools/pad_menu_e2e.py --only shader_screens` (RB through every screen of a
|
||||
twelve-screen pack in a real game).
|
||||
|
||||
## Pad capture
|
||||
|
||||
Several overlays over play take the controller for themselves. `Game::pad_capture`
|
||||
(`crates/horizon-client/src/game/pad/capture.rs`) is the one place that says which owns it now,
|
||||
in one priority order:
|
||||
|
||||
| `PadCapture` | Owns the pad while |
|
||||
|---|---|
|
||||
| `Keyboard` | the on-screen keyboard is up |
|
||||
| `Prompt` | a prompt is over the world (a server dialog, the code of conduct, a link, command or resource pack prompt) |
|
||||
| `Radial` | the radial menu is held open |
|
||||
|
||||
Code that routes the pad's presses and sticks (`pad.rs`, `pad_actions.rs`, `pad_menu.rs`,
|
||||
`window_handler.rs`), and the controller guide, which gives way to the keyboard, ask
|
||||
`pad_radial_captures()` or `pad_keyboard_captures()` rather than the overlays themselves. A new
|
||||
overlay that takes the pad adds its variant here, in its place in the order, and a check that asks
|
||||
about it gets a `pad_<overlay>_captures()` beside these. What vanilla draws under an overlay gives
|
||||
way through `overlay_holds_view` instead ([Overlays that hold the view](#overlays-that-hold-the-view)).
|
||||
Screens are not overlays: `pad_in_menu` says when one has the pad.
|
||||
|
||||
One change came with it: a prompt that arrives while the radial menu is open owns the pad from that
|
||||
poll, so a B in the same poll answers the prompt instead of cancelling the menu (which the prompt
|
||||
closes anyway). Before, the menu took that B.
|
||||
|
||||
## Glyphs
|
||||
|
||||
Code asks for a glyph by what the button does, never by a button. The chain runs from the job to the art:
|
||||
@@ -424,9 +382,7 @@ pages read from a shader pack, rows that link to pages out of the page list, and
|
||||
included. **Apply**, for changes that wait: `pending` shows it beside Undo, and `apply` does them.
|
||||
6. **Host it**: a `Shell` of it on `OptionsUi`, named in `OptionsUi::shell` and `shell_mut`, and opened
|
||||
at its page with `Shell::show`. Every input goes through those two, so a screen can't take the wheel
|
||||
and miss the stick. They live in `options_ui/shell_pages.rs` with the rest of the hosting glue
|
||||
(`shell_events`, `pad_clicks_step`, `shell_opened_focus`), so hosting a screen touches that file,
|
||||
not `options_ui.rs`.
|
||||
and miss the stick.
|
||||
7. **Delete** the screen's own drawing, scrolling, focus, search and click code.
|
||||
|
||||
What the old screen's tests checked moves to tests on the `Shell` of the new one: press a row with
|
||||
@@ -446,11 +402,6 @@ or what else its button does, and draws the row's reset.
|
||||
- `shared`: what else the same button does, which the strip lists and the row marks "[ ]" in yellow.
|
||||
- `reset`: `Reset::None` (no button), `Reset::Default` (greyed) or `Reset::Changed` (live) for a reset
|
||||
button at the row's end; pressed (a click, or Right then Enter or A), it calls `Screen::reset`.
|
||||
- The strip says the binding's note (unbound, or what else its button does) unless the row has a
|
||||
`chosen` of its own: that outranks the note except while the host waits, so a binding can say why it
|
||||
has no button or shares one (the radial menu's opener says what that means for opening the menu).
|
||||
- A `Capture::Pad` row waits on any screen of the shell, not only Controller Settings: the game binds
|
||||
the next button for whatever `selected_pad_action` names.
|
||||
|
||||
A press gives `Output::Bind(capture)`; the host's `shell_events` starts the wait, and passes what it waits
|
||||
on back to `Hosted::draw`.
|
||||
@@ -474,26 +425,6 @@ let binding = Binding {
|
||||
Row::new(Item::Bind(action), name, Value::Binding(binding))
|
||||
```
|
||||
|
||||
### Lists, steps and where the focus opens
|
||||
|
||||
A screen whose choice has too many values to step through can open a list of them instead, as the
|
||||
radial menu's editor does for a slot's action:
|
||||
|
||||
- `Screen::choose(s, row)`: a press on a `Choice` row (A, Enter or a click; not Left, Right or a
|
||||
Shift press). `None`, the default, steps it as Right does; outputs (a `Push` of the list's page) open
|
||||
the list instead. Left and Right still step it.
|
||||
- `Hosted::clicks_step(on)`: the host marks the clicks that are a controller's D-pad Left or Right
|
||||
(`OptionsUi::pad_clicks_step`, from `game/pad_menu.rs`), so on such a choice they step rather than
|
||||
open the list.
|
||||
- `Shell::focus_soon(row)`: put the focus on a row at the next draw, scrolled into view, as the
|
||||
keyboard would. The host reads where it went (`Hosted::opened_focus`, `OptionsUi::shell_opened_focus`)
|
||||
so a controller's focus lands there too: the list opens on the slot's current action, and back on
|
||||
the slot after a pick.
|
||||
- `Shell::changed_elsewhere()`: a page pushed over the screen (the list) changed what its Undo puts
|
||||
back.
|
||||
- A screen whose pages are the player's (`Sheet`s that come and go) can delete the open one: the shell
|
||||
then shows the first page.
|
||||
|
||||
### The page list
|
||||
|
||||
A screen of two pages or more has a page list down the left, a page under the last. A `Sheet` with
|
||||
|
||||
@@ -493,12 +493,9 @@ mod input_trace {
|
||||
pub enum InputOrigin { User }
|
||||
pub enum TraceKind { Motion { x: f64, y: f64 } }
|
||||
}
|
||||
struct Radial(bool);
|
||||
impl Radial { fn is_open(&self) -> bool { self.0 } }
|
||||
struct Pad { radial: Radial }
|
||||
struct Game {
|
||||
pending_save: Option<()>, captured: bool, focused: bool, keyboard_mouse: bool, split_window: bool,
|
||||
options: Options, screen: Screen, mouse: MouseLook, last_input: Instant, pad: Pad, scripted: bool,
|
||||
options: Options, screen: Screen, mouse: MouseLook, last_input: Instant, scripted: bool,
|
||||
}
|
||||
impl Game {
|
||||
fn scripted_run(&self) -> bool { self.scripted }
|
||||
@@ -506,30 +503,26 @@ impl Game {
|
||||
fn note_input(&mut self, _: input_trace::InputOrigin, _: input_trace::TraceKind) {}
|
||||
// Handing input back from the controller does not change the turn either.
|
||||
fn pad_yield_input(&mut self, _: &ActiveEventLoop) {}
|
||||
// `game/pad/capture.rs`: the radial menu owns the pad while it is open.
|
||||
fn pad_radial_captures(&self) -> bool { self.pad.radial.is_open() }
|
||||
METHOD
|
||||
}
|
||||
fn main() {
|
||||
// (offscreen renderer, compositor, keyboard/mouse owner, active, gameplay, saving, the pad's
|
||||
// radial menu open, a scripted run).
|
||||
for (offscreen, split, owner, active, playing, saving, radial, scripted, turns) in [
|
||||
(false, false, true, true, true, false, false, false, true),
|
||||
(true, true, true, true, true, false, false, false, true),
|
||||
(true, true, false, true, true, false, false, false, false),
|
||||
(true, false, true, true, true, false, false, false, false),
|
||||
(true, true, true, false, true, false, false, false, false),
|
||||
(true, true, true, true, false, false, false, false, false),
|
||||
(true, true, true, true, true, true, false, false, false),
|
||||
(false, false, true, true, true, false, true, false, false),
|
||||
(false, false, true, true, true, false, false, true, false),
|
||||
// (offscreen renderer, compositor, keyboard/mouse owner, active, gameplay, saving,
|
||||
// a scripted run).
|
||||
for (offscreen, split, owner, active, playing, saving, scripted, turns) in [
|
||||
(false, false, true, true, true, false, false, true),
|
||||
(true, true, true, true, true, false, false, true),
|
||||
(true, true, false, true, true, false, false, false),
|
||||
(true, false, true, true, true, false, false, false),
|
||||
(true, true, true, false, true, false, false, false),
|
||||
(true, true, true, true, false, false, false, false),
|
||||
(true, true, true, true, true, true, false, false),
|
||||
(false, false, true, true, true, false, true, false),
|
||||
] {
|
||||
let mut game = Game {
|
||||
pending_save: saving.then_some(()), captured: true, focused: active,
|
||||
keyboard_mouse: owner, split_window: split, options: Options { offscreen },
|
||||
screen: if playing { Screen::Playing } else { Screen::Paused },
|
||||
mouse: MouseLook::default(), last_input: Instant::now(), pad: Pad { radial: Radial(radial) },
|
||||
scripted,
|
||||
mouse: MouseLook::default(), last_input: Instant::now(), scripted,
|
||||
};
|
||||
game.device_event(&ActiveEventLoop, DeviceId, DeviceEvent::MouseMotion { delta: (0.0, 0.0) });
|
||||
game.device_event(&ActiveEventLoop, DeviceId, DeviceEvent::MouseMotion { delta: (20.0, -10.0) });
|
||||
@@ -539,7 +532,7 @@ fn main() {
|
||||
rotation.turn(x, y);
|
||||
let want = if turns { (3.0, -1.5) } else { (0.0, 0.0) };
|
||||
assert!((rotation.y_rot - want.0).abs() < 0.00001 && (rotation.x_rot - want.1).abs() < 0.00001,
|
||||
"rotation=({}, {}), offscreen={offscreen}, split={split}, owner={owner}, active={active}, playing={playing}, saving={saving}, radial={radial}, scripted={scripted}", rotation.y_rot, rotation.x_rot);
|
||||
"rotation=({}, {}), offscreen={offscreen}, split={split}, owner={owner}, active={active}, playing={playing}, saving={saving}, scripted={scripted}", rotation.y_rot, rotation.x_rot);
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
"horizon-client-lib -> horizon-shader-api",
|
||||
"horizon-client-lib -> horizon-shaders",
|
||||
"horizon-client-lib -> horizon-telemetry",
|
||||
"horizon-client-render -> horizon-cosmetics",
|
||||
"horizon-client-render -> horizon-hdr",
|
||||
"horizon-client-render -> horizon-optifine",
|
||||
"horizon-client-render -> horizon-polytone",
|
||||
|
||||
+3
-1118
File diff suppressed because it is too large.
Load diff
@@ -25,7 +25,7 @@ AbstractPiglin.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.r
|
||||
AbstractPiglin.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt reads immunity, time in overworld and the pick-up flag
|
||||
AbstractPiglin.customServerAiStep port crates/horizon-client/src/mobs/piglin.rs::ai_step gap: minor: zombification runs outside the Nether by the nether flag, not the PIGLINS_ZOMBIFY attribute
|
||||
AbstractPiglin.finishConversion port crates/horizon-client/src/mobs/piglin.rs::ai_step spawns the zombified piglin with nausea and removes the old one
|
||||
Piglin.readAdditionalSaveData port crates/horizon-client/src/mobs/piglin.rs::read_guarded_anger baby and inventory are read in entity_nbt::mob_from_nbt; the brain's minecraft:angry_at UUID and TTL come back through read_guarded_anger (CannotHunt still not read)
|
||||
Piglin.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt gap: minor: CannotHunt is not read, so hunting ignores it; baby and inventory are read
|
||||
Piglin.getSlot missing item commands stop at the open container: a piglin's own inventory slots (300 and up) are not addressable
|
||||
Piglin.defineSynchedData skip:java synched-data registration; Horizon keeps baby, crossbow and dancing state in Mob fields
|
||||
Piglin.createAttributes skip:data attribute defaults (health 16, speed 0.35, attack 5) live in data tables
|
||||
|
||||
|
@@ -108,7 +108,7 @@ FirstPersonHandsAndItems.extractRenderState inline crates/horizon-client/src/gam
|
||||
FirstPersonHandsAndItems.shouldInstantlyReplaceVisibleItem port crates/horizon-client/src/game/hand.rs::shows_held_stack the same stack object, or an equal one, which plays no swap animation; the slot a replaced hand changed is the exception
|
||||
FirstPersonHandsAndItems.extractMapRenderState inline crates/horizon-client/src/game/maps.rs::Game::held_map the MAP_ID component picks the map data drawn in the hand, its decorations included, and decides the checkerboard background
|
||||
FirstPersonHandsAndItems.isChargedCrossbow inline crates/horizon-client/src/first_person.rs::hands_to_render
|
||||
ItemActivation.tick port crates/horizon-client/src/item_activation.rs::ItemActivation::tick the count down to 0, where the item goes; game/totem.rs::tick_item_activation runs it, called from the game tick and the remote tick
|
||||
ItemActivation.tick port crates/horizon-client/src/item_activation.rs::ItemActivation::tick the count down to 0, where the item goes; game.rs::tick_item_activation runs it
|
||||
ItemActivation.activate port crates/horizon-client/src/item_activation.rs::ItemActivation::activate a copy of the stack, the 40 ticks and two offsets in [-1, 1); game/totem.rs sets it from findTotem
|
||||
KeyboardInput.calculateImpulse inline crates/horizon-client-input/src/input.rs::keyboard_direction
|
||||
LocalPlayer.<init> inline crates/horizon-client/src/ambience.rs::Ambience::new gap: minor: the three ambient handlers are the Ambience loops; no connection set-up
|
||||
|
||||
|
Reference in new issue
Block a user