mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 17:18:25 -10:00
Compare commits
114
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8642db5c99 | ||
|
|
1453e0e614 | ||
|
|
9b4258fecc | ||
|
|
2d610e9482 | ||
|
|
e6eccbd3b9 | ||
|
|
537539fe58 | ||
|
|
cbbd362eed | ||
|
|
1430b9990a | ||
|
|
64a6b82dd0 | ||
|
|
a84f637dd6 | ||
|
|
f2d50d4a75 | ||
|
|
efab4d1bbf | ||
|
|
604fb90bf1 | ||
|
|
b2d72cea9e | ||
|
|
7b50bae250 | ||
|
|
3780c215a7 | ||
|
|
3c853b46eb | ||
|
|
0b8d44458a | ||
|
|
c21f627431 | ||
|
|
62e62c21e6 | ||
|
|
a6350aa8f9 | ||
|
|
c817ed7fff | ||
|
|
4e22c1ad9a | ||
|
|
e1aa435188 | ||
|
|
0341ad3d3a | ||
|
|
128575e4e5 | ||
|
|
6cc30fbed4 | ||
|
|
cb870cf80d | ||
|
|
e138304d2e | ||
|
|
5385996153 | ||
|
|
83a9845928 | ||
|
|
3563a7f986 | ||
|
|
cc4933eaed | ||
|
|
4be9343eca | ||
|
|
03fd6705a1 | ||
|
|
a5a60598e4 | ||
|
|
0fd4e22139 | ||
|
|
ec19b6320a | ||
|
|
16e29a2774 | ||
|
|
d016678e24 | ||
|
|
3d9df204e9 | ||
|
|
984b7d8635 | ||
|
|
afa60454b2 | ||
|
|
aa8592fbf9 | ||
|
|
7a4aedf231 | ||
|
|
8e45334760 | ||
|
|
cef35e64e7 | ||
|
|
3a9b05d21d | ||
|
|
8aed306751 | ||
|
|
da800dff55 | ||
|
|
67f61f1c24 | ||
|
|
bb19f898e4 | ||
|
|
bbc17333bd | ||
|
|
8630bbed74 | ||
|
|
2c5baa6a60 | ||
|
|
ed35d477be | ||
|
|
7a236ba054 | ||
|
|
bf125a1a69 | ||
|
|
661a253ec4 | ||
|
|
d56b769df5 | ||
|
|
7bfa76577f | ||
|
|
0022d3f42d | ||
|
|
ac1f560622 | ||
|
|
6a4172369c | ||
|
|
06130ae40d | ||
|
|
8ac6a79035 | ||
|
|
153e0d20a4 | ||
|
|
01343707af | ||
|
|
c873af989a | ||
|
|
84baabff3c | ||
|
|
6622e459b2 | ||
|
|
600a863354 | ||
|
|
b77e4c4924 | ||
|
|
96aa9791e6 | ||
|
|
aa060c5fe1 | ||
|
|
07ff96c50c | ||
|
|
19c23a96c6 | ||
|
|
29d8c49bc2 | ||
|
|
c66936f747 | ||
|
|
0a6874877d | ||
|
|
e2ad02a58a | ||
|
|
fb23266b6e | ||
|
|
d7d1e72afc | ||
|
|
2a5ec67594 | ||
|
|
97ab5f100a | ||
|
|
f3e4420699 | ||
|
|
db3f211cfa | ||
|
|
dc2aaacea3 | ||
|
|
9a43f998c1 | ||
|
|
2cdde29154 | ||
|
|
7816e1adbb | ||
|
|
d94ceb2eb9 | ||
|
|
4843a2b975 | ||
|
|
0d7c032320 | ||
|
|
932fea900e | ||
|
|
a53de4943f | ||
|
|
2aee551905 | ||
|
|
599ef2acd3 | ||
|
|
9969321db4 | ||
|
|
acfa08d9e7 | ||
|
|
91d32f40bc | ||
|
|
1691d8d65b | ||
|
|
408b3dc47d | ||
|
|
d55b4b0a78 | ||
|
|
1a1e4c6f46 | ||
|
|
7008bc32ba | ||
|
|
2ccc6f7ffe | ||
|
|
a14242eb15 | ||
|
|
d747ae6c26 | ||
|
|
a9e60a6782 | ||
|
|
7a92b5064c | ||
|
|
87346bb92f | ||
|
|
d16c9137f9 | ||
|
|
0e4163f67f |
No files matched your search
Generated
+10
@@ -1650,6 +1650,15 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-discord"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-dualsense"
|
||||
version = "0.1.0"
|
||||
@@ -1793,6 +1802,7 @@ dependencies = [
|
||||
name = "horizon-official"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-discord",
|
||||
"horizon-friends",
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
|
||||
@@ -34,6 +34,7 @@ members = [
|
||||
"crates/horizon-game",
|
||||
"crates/horizon-official",
|
||||
"crates/horizon-heartbeat",
|
||||
"crates/horizon-discord",
|
||||
"crates/horizon-official-servers",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
|
||||
@@ -9,11 +9,13 @@ description = "The client's input layer: key bindings and their names, mouse, co
|
||||
[dependencies]
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
winit = "0.30.13"
|
||||
# The window icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
png = "0.18"
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# The window icon test decodes Horizon's icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
|
||||
[features]
|
||||
controllers = []
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ actions! {
|
||||
Emote, "key.emote", Some(KeyCode::KeyB);
|
||||
HotbarPrevious, "pad.hotbarPrevious", None;
|
||||
HotbarNext, "pad.hotbarNext", None;
|
||||
Radial, "pad.radial", None;
|
||||
}
|
||||
|
||||
impl Action {
|
||||
|
||||
@@ -253,6 +253,17 @@ 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
|
||||
@@ -271,6 +282,7 @@ 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),
|
||||
@@ -303,11 +315,14 @@ 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,
|
||||
@@ -320,10 +335,12 @@ 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);
|
||||
@@ -334,12 +351,16 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -654,6 +675,21 @@ 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 {
|
||||
@@ -680,6 +716,12 @@ 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -706,6 +748,7 @@ 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;
|
||||
@@ -754,22 +797,85 @@ 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 => {
|
||||
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);
|
||||
}
|
||||
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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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:")) {
|
||||
// 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);
|
||||
for (action, button) in [
|
||||
("key.drop", Button::East),
|
||||
("key.togglePerspective", Button::DPadUp),
|
||||
@@ -778,10 +884,7 @@ impl PadSettings {
|
||||
("key.advancements", Button::DPadDown),
|
||||
] {
|
||||
// A button saved by hand stays, even one that's now this action's default.
|
||||
if !text
|
||||
.lines()
|
||||
.any(|line| line.starts_with(&format!("bind_{action}:")))
|
||||
{
|
||||
if !saved(action) {
|
||||
s.bindings.entry(action).or_insert(Some(button));
|
||||
}
|
||||
}
|
||||
@@ -822,7 +925,25 @@ impl PadSettings {
|
||||
"playInBackground:{}\n",
|
||||
bool_text(self.play_in_background)
|
||||
));
|
||||
text.push_str("bindLayout:1\n");
|
||||
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
|
||||
}
|
||||
pub fn default_of(action: &str) -> Option<Button> {
|
||||
@@ -835,11 +956,15 @@ impl PadSettings {
|
||||
None => Self::default_of(action),
|
||||
}
|
||||
}
|
||||
/// Bind an action (`None` unbinds); unknown actions are ignored.
|
||||
/// Put `action` on `button`, or unbind it. An unknown action, or a button it can't take
|
||||
/// (`can_bind`), changes nothing.
|
||||
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 {
|
||||
@@ -1440,6 +1565,14 @@ 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 {
|
||||
@@ -1459,6 +1592,7 @@ 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 {
|
||||
@@ -1466,6 +1600,10 @@ 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.
|
||||
@@ -1492,6 +1630,9 @@ 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,
|
||||
@@ -1513,6 +1654,10 @@ 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)]
|
||||
@@ -1873,7 +2018,55 @@ 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, PadSettings::default());
|
||||
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"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! 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,8 +141,13 @@ 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: Vec::new(),
|
||||
left: self
|
||||
.left
|
||||
.into_iter()
|
||||
.filter(|row| row.caption == crate::gamepad::RADIAL_CAPTION)
|
||||
.collect(),
|
||||
right: self.right,
|
||||
},
|
||||
GuideDetail::Off => Guide {
|
||||
@@ -178,6 +183,8 @@ 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()
|
||||
@@ -566,6 +573,7 @@ mod tests {
|
||||
(Button::North, "key.inventory"),
|
||||
(Button::DPadDown, "horizon.controller.guide.drop"),
|
||||
(Button::West, "horizon.controller.guide.swap"),
|
||||
(Button::DPadRight, crate::gamepad::RADIAL_CAPTION),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -736,12 +744,12 @@ mod tests {
|
||||
gliding: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
["key.inventory"]
|
||||
["key.inventory", crate::gamepad::RADIAL_CAPTION]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spectator_only_flies_opens_spectates_and_has_its_menu() {
|
||||
fn a_spectator_only_flies_opens_spectates_and_has_its_menus() {
|
||||
let facts = Facts {
|
||||
spectator: true,
|
||||
flying: true,
|
||||
@@ -765,7 +773,8 @@ mod tests {
|
||||
captions(&guide.left),
|
||||
[
|
||||
"horizon.controller.guide.fly_up",
|
||||
"horizon.controller.guide.fly_down"
|
||||
"horizon.controller.guide.fly_down",
|
||||
crate::gamepad::RADIAL_CAPTION
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@ 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
@@ -638,10 +638,30 @@ pub fn fullscreen_request(
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
|
||||
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
|
||||
/// The PNG the windows show as their icon, given once at startup by the layer that brands the
|
||||
/// game (the engine has none of its own).
|
||||
static ICON_PNG: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new();
|
||||
|
||||
/// Give the windows their icon, a PNG: call it before the first window opens. A second call
|
||||
/// keeps the first icon.
|
||||
pub fn set_icon_png(png: &'static [u8]) {
|
||||
let _ = ICON_PNG.set(png);
|
||||
}
|
||||
|
||||
/// The icon given to [`set_icon_png`], if any.
|
||||
pub fn icon_png() -> Option<&'static [u8]> {
|
||||
ICON_PNG.get().copied()
|
||||
}
|
||||
|
||||
/// The icon for the window's title bar and the task bar (X11 and Windows; Wayland takes it from
|
||||
/// the `.desktop` entry, macOS from the app bundle). `None` with no icon given, or one that
|
||||
/// can't be read.
|
||||
pub fn icon() -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
|
||||
decode_icon(icon_png()?)
|
||||
}
|
||||
|
||||
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
@@ -654,14 +674,15 @@ pub fn icon() -> Option<winit::window::Icon> {
|
||||
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
|
||||
}
|
||||
|
||||
/// Apply Horizon's icon to every window. Windows has a separate taskbar icon slot from the small
|
||||
/// window icon; requiring the decoded icon there prevents a generic taskbar icon from appearing.
|
||||
pub fn with_horizon_icon(
|
||||
attributes: winit::window::WindowAttributes,
|
||||
) -> winit::window::WindowAttributes {
|
||||
/// Apply the icon given to [`set_icon_png`] to a window. Windows has a separate taskbar icon slot
|
||||
/// from the small window icon; requiring a given icon to decode there prevents a generic taskbar
|
||||
/// icon from appearing.
|
||||
pub fn with_icon(attributes: winit::window::WindowAttributes) -> winit::window::WindowAttributes {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let icon = icon();
|
||||
#[cfg(target_os = "windows")]
|
||||
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
|
||||
let icon =
|
||||
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
|
||||
|
||||
let attributes = attributes.with_window_icon(icon.clone());
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -739,7 +760,8 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn the_window_icon_decodes() {
|
||||
assert!(icon().is_some());
|
||||
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
|
||||
assert!(decode_icon(b"not a png").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -70,7 +70,6 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
|
||||
mio = { version = "1.2.4", features = ["os-poll", "net"] }
|
||||
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
|
||||
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
@@ -109,16 +108,12 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers", "horizon-extras"]
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
|
||||
# Horizon's own parts still in this crate (docs/forking.md, docs/layers.md): the features below.
|
||||
horizon-extras = ["discord"]
|
||||
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
|
||||
discord = []
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
@@ -130,6 +125,8 @@ windows-segment-heap = []
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# ui_theme's unit tests draw text in the design system's fonts.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
|
||||
@@ -29,7 +29,6 @@ horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
ab_glyph = "0.2.32"
|
||||
@@ -85,5 +84,7 @@ horizon-extras = []
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "glsl-out", "msl-out", "spv-out"] }
|
||||
@@ -57,6 +57,8 @@ 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),
|
||||
}
|
||||
|
||||
@@ -106,6 +108,15 @@ 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,
|
||||
})
|
||||
}
|
||||
@@ -123,6 +134,11 @@ 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.";
|
||||
|
||||
@@ -201,6 +217,13 @@ 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![
|
||||
@@ -231,6 +254,13 @@ 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",
|
||||
@@ -437,6 +467,7 @@ 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)
|
||||
@@ -496,6 +527,7 @@ 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(),
|
||||
}
|
||||
}
|
||||
@@ -533,15 +565,21 @@ impl Screen for ControllerScreen {
|
||||
fn undoes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// This player's controller settings, bindings included, as the screen found them.
|
||||
/// 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.
|
||||
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.
|
||||
/// 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.
|
||||
fn differs(&self, s: &Settings, before: &Settings) -> Option<bool> {
|
||||
Some(s.controller != before.controller)
|
||||
let mut now = s.controller.clone();
|
||||
now.radial = before.controller.radial.clone();
|
||||
Some(now != before.controller)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -793,6 +831,48 @@ 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();
|
||||
@@ -816,8 +896,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, then the first action.
|
||||
for _ in 0..Page::ALL.len() + 2 {
|
||||
// Search, the three pages, the radial menu's row, then the first action.
|
||||
for _ in 0..Page::ALL.len() + 3 {
|
||||
key(&mut ui, &mut s, K::Tab);
|
||||
}
|
||||
assert_eq!(ui.focused(), Some(Target::Row(Item::Bind(JUMP))));
|
||||
@@ -939,6 +1019,8 @@ 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();
|
||||
@@ -972,6 +1054,8 @@ mod tests {
|
||||
Opt::PadNintendoButtons,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadLightbar,
|
||||
Opt::PadRadialMode,
|
||||
Opt::PadRadialStick,
|
||||
] {
|
||||
for _ in 0..4 {
|
||||
lang::get(choice_description(opt, &settings.controller).unwrap());
|
||||
@@ -990,6 +1074,7 @@ 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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
|
||||
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
|
||||
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
|
||||
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
|
||||
//! (No AI, Agent Control) are kept here too.
|
||||
use std::{collections::VecDeque, fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-extras.txt";
|
||||
@@ -145,8 +145,6 @@ pub struct Extras {
|
||||
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
|
||||
/// default: a recording copies the world).
|
||||
pub repro_keys: bool,
|
||||
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
|
||||
pub discord_presence: bool,
|
||||
/// Another controller can join P1's world (`splitscreen`, off by default).
|
||||
pub splitscreen: bool,
|
||||
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
|
||||
@@ -233,7 +231,6 @@ impl Default for Extras {
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
repro_keys: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
splitscreen_two: 0,
|
||||
splitscreen_three: 0,
|
||||
@@ -338,7 +335,6 @@ impl Extras {
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"reproKeys" => e.repro_keys = flag(e.repro_keys),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
"splitscreen" => e.splitscreen = flag(e.splitscreen),
|
||||
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
|
||||
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
|
||||
@@ -376,7 +372,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:{}\ndiscordPresence:{}\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:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
|
||||
bool_text(self.pack_browser),
|
||||
bool_text(self.pregen),
|
||||
bool_text(self.fullbright),
|
||||
@@ -418,7 +414,6 @@ impl Extras {
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
bool_text(self.repro_keys),
|
||||
bool_text(self.discord_presence),
|
||||
bool_text(self.splitscreen),
|
||||
self.splitscreen_two,
|
||||
self.splitscreen_three,
|
||||
@@ -613,8 +608,6 @@ pub enum Item {
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
AgentControl,
|
||||
/// On the Horizon Settings page.
|
||||
DiscordPresence,
|
||||
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
|
||||
ReproKeys,
|
||||
/// On the Splitscreen settings page.
|
||||
@@ -691,7 +684,6 @@ impl Item {
|
||||
Item::NoAi => "horizon.extras.no_ai",
|
||||
Item::AgentControl => "horizon.extras.agent_control",
|
||||
Item::ReproKeys => "horizon.extras.repro_keys",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator",
|
||||
@@ -724,7 +716,6 @@ impl Item {
|
||||
Item::NoAi => "horizon.extras.no_ai.tooltip",
|
||||
Item::AgentControl => "horizon.extras.agent_control.tooltip",
|
||||
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
|
||||
@@ -768,7 +759,6 @@ impl Item {
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::ReproKeys => "Recording shortcuts",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
Item::Splitscreen => "Splitscreen",
|
||||
Item::WheelCyclesSettings => "Scroll wheel changes settings",
|
||||
Item::PackBrowser => "Resource pack browser",
|
||||
@@ -792,10 +782,9 @@ impl Item {
|
||||
ChatIndicator => ChatAnimation,
|
||||
Pvp(o) => return o.parent().map(Pvp),
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
|
||||
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
|
||||
| Pregen => {
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
|
||||
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
|
||||
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -844,9 +833,6 @@ impl Item {
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
Item::DiscordPresence => {
|
||||
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
|
||||
}
|
||||
Item::Splitscreen => {
|
||||
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
|
||||
}
|
||||
@@ -927,7 +913,6 @@ impl Item {
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::ReproKeys => e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings,
|
||||
Item::PackBrowser => e.pack_browser,
|
||||
@@ -986,7 +971,6 @@ impl Item {
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::ReproKeys => e.repro_keys = !e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen = !e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
|
||||
Item::PackBrowser => e.pack_browser = !e.pack_browser,
|
||||
@@ -1104,7 +1088,7 @@ mod tests {
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
|
||||
assert!(!d.hide_block_particles && !d.pregen);
|
||||
assert!(!d.music_page && d.music_off.is_empty());
|
||||
assert!(d.discord_presence && !d.wheel_cycles_settings);
|
||||
assert!(!d.wheel_cycles_settings);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
let mut e = d.clone();
|
||||
@@ -1143,7 +1127,6 @@ mod tests {
|
||||
e.sort_key = "key.keyboard.r".into();
|
||||
e.chat_animation = true;
|
||||
e.chat_indicator = false;
|
||||
e.discord_presence = false;
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
|
||||
|
||||
@@ -74,6 +74,7 @@ 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.
|
||||
@@ -189,6 +190,7 @@ mod cull;
|
||||
mod dx12_compiler;
|
||||
mod end_sky;
|
||||
pub mod entity_skin;
|
||||
pub mod external;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod gpu_culling;
|
||||
@@ -1418,8 +1420,10 @@ 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 (`pack_post::ShadowView`).
|
||||
/// The sections a shader pack's shadow camera sees (`external::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
|
||||
@@ -5290,6 +5294,7 @@ 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(),
|
||||
@@ -5673,6 +5678,9 @@ 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 {
|
||||
@@ -7289,12 +7297,19 @@ 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);
|
||||
}
|
||||
@@ -8629,6 +8644,13 @@ 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()
|
||||
@@ -8640,6 +8662,12 @@ 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);
|
||||
@@ -12771,61 +12799,6 @@ 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>(
|
||||
@@ -12848,17 +12821,6 @@ 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 {
|
||||
@@ -12982,21 +12944,6 @@ 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
|
||||
@@ -13023,68 +12970,6 @@ 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
|
||||
@@ -15383,7 +15268,7 @@ mod shadow_face_tests {
|
||||
}
|
||||
|
||||
/// The layers (faces) of the quads the shadow pass draws of `solid`.
|
||||
fn drawn(view: &pack_post::ShadowView, origin: [f32; 3]) -> Vec<u16> {
|
||||
fn drawn(view: &external::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();
|
||||
@@ -15396,8 +15281,8 @@ mod shadow_face_tests {
|
||||
layers
|
||||
}
|
||||
|
||||
fn view(every_face: bool) -> pack_post::ShadowView {
|
||||
pack_post::ShadowView {
|
||||
fn view(every_face: bool) -> external::ShadowView {
|
||||
external::ShadowView {
|
||||
cull: Mat4::IDENTITY,
|
||||
light: Vec3::Y,
|
||||
distance: None,
|
||||
@@ -15611,7 +15496,7 @@ mod shadow_caster_tests {
|
||||
..horizon_shaders::Shadow::default()
|
||||
};
|
||||
let camera = DVec3::new(13.5, y + 1.62, -13.5);
|
||||
let view = pack_post::ShadowView {
|
||||
let view = external::ShadowView {
|
||||
cull: pack_uniforms::shadow_culling_at(Vec3::Y, camera, &shadow),
|
||||
light: Vec3::Y,
|
||||
distance: None,
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! 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]
|
||||
}
|
||||
@@ -1001,12 +1001,14 @@ impl Hdr {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's icon (horizon-theme) as a 1-bit mask: its white strokes, one colour with hard
|
||||
/// edges, are the logo; everything else is not.
|
||||
/// The windows' icon (`window::set_icon_png`: Horizon's in the official game) as a 1-bit mask:
|
||||
/// its white strokes, one colour with hard edges, are the logo; everything else is not. No icon
|
||||
/// given: an empty mask.
|
||||
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
use wgpu::util::DeviceExt;
|
||||
let (w, h, rgba) =
|
||||
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
|
||||
let (w, h, rgba) = horizon_client_input::window::icon_png()
|
||||
.and_then(|png| crate::hud::decode_bytes(png).ok())
|
||||
.unwrap_or((1, 1, vec![0; 4]));
|
||||
let mask: Vec<u8> = rgba
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
|
||||
@@ -1201,31 +1201,7 @@ pub(super) type TerrainDraw<'a> = dyn FnMut(
|
||||
Option<(u32, &wgpu::BindGroupLayout)>,
|
||||
) + 'a;
|
||||
|
||||
/// 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,
|
||||
}
|
||||
pub(super) use super::external::ShadowView;
|
||||
|
||||
/// An arena page's key, native vertices and optional per-quad source records.
|
||||
pub(super) type TerrainPage = (u32, wgpu::Buffer, Option<wgpu::Buffer>);
|
||||
|
||||
@@ -2,16 +2,21 @@
|
||||
//! colours and the controls it draws with. The screen (`graphics_ui`) only
|
||||
//! lays out rows and handles input; a menu theme replaces this module.
|
||||
//!
|
||||
//! It takes the design system's Blocks palette (`horizon_theme::BLOCKS_DARK`,
|
||||
//! `docs/design-system.md`) in a lighter form: square panels of dark glass
|
||||
//! with a thin edge, so the world shows through, flat controls, and the violet
|
||||
//! face on the one main button, in vanilla's font.
|
||||
//! Its colours come from `menu_style`, given at startup (in the official game the design
|
||||
//! system's Blocks palette, `docs/design-system.md`), in a lighter form: square panels of
|
||||
//! dark glass with a thin edge, so the world shows through, flat controls, and the face on
|
||||
//! the one main button, in vanilla's font.
|
||||
use crate::menu_style::{self, SettingsColours};
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_theme::BLOCKS_DARK as B;
|
||||
|
||||
/// The settings pages' colours (`menu_style`, given at startup).
|
||||
fn b() -> &'static SettingsColours {
|
||||
&menu_style::get().settings
|
||||
}
|
||||
|
||||
/// What a setting costs in frame time, shown with its description.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -36,54 +41,91 @@ impl Cost {
|
||||
/// `0xRRGGBB`: the palette's success, warn and danger.
|
||||
pub fn rgb(self) -> u32 {
|
||||
match self {
|
||||
Cost::None => B.ink_soft,
|
||||
Cost::Low | Cost::Saves => B.success,
|
||||
Cost::Medium => B.warn,
|
||||
Cost::High => B.danger,
|
||||
Cost::None => b().ink_soft,
|
||||
Cost::Low | Cost::Saves => b().success,
|
||||
Cost::Medium => b().warn,
|
||||
Cost::High => b().danger,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `0xRRGGBB` and an alpha in the HUD's colour encoding.
|
||||
const fn hex(c: u32, a: u8) -> u32 {
|
||||
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
|
||||
}
|
||||
use menu_style::hud_colour as hex;
|
||||
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
|
||||
const GLASS: u32 = hex(B.page, 118);
|
||||
fn glass() -> u32 {
|
||||
hex(b().page, 118)
|
||||
}
|
||||
/// Their 1 px edge.
|
||||
const EDGE: u32 = hex(B.ink, 26);
|
||||
pub fn edge() -> u32 {
|
||||
hex(b().ink, 26)
|
||||
}
|
||||
/// A group's panel, a little lighter than the plate it sits on.
|
||||
const GROUP: u32 = hex(B.ink, 11);
|
||||
fn group_fill() -> u32 {
|
||||
hex(b().ink, 11)
|
||||
}
|
||||
/// A hovered or focused row, lit on its group's panel.
|
||||
pub const HOVER: u32 = hex(B.ink, 18);
|
||||
pub fn hover() -> u32 {
|
||||
hex(b().ink, 18)
|
||||
}
|
||||
/// The open page in the page list.
|
||||
pub const OPEN: u32 = hex(B.ink, 26);
|
||||
pub fn open() -> u32 {
|
||||
hex(b().ink, 26)
|
||||
}
|
||||
/// The rule under the plate's heading, and between two rows of a group.
|
||||
pub const HEAD_RULE: u32 = hex(B.ink, 31);
|
||||
pub const ROW_RULE: u32 = hex(B.ink, 15);
|
||||
pub fn head_rule() -> u32 {
|
||||
hex(b().ink, 31)
|
||||
}
|
||||
pub fn row_rule() -> u32 {
|
||||
hex(b().ink, 15)
|
||||
}
|
||||
/// Fields, boxes and the value stepper: sunk, see-through.
|
||||
const WELL: u32 = hex(B.outline, 102);
|
||||
fn well_fill() -> u32 {
|
||||
hex(b().outline, 102)
|
||||
}
|
||||
/// A button, a slider's track, the scroller's thumb.
|
||||
const BUTTON: u32 = hex(B.ink, 20);
|
||||
const BUTTON_HOVER: u32 = hex(B.ink, 41);
|
||||
const TRACK: u32 = hex(B.ink, 64);
|
||||
const HANDLE: u32 = hex(B.ink, 255);
|
||||
fn button_fill() -> u32 {
|
||||
hex(b().ink, 20)
|
||||
}
|
||||
fn button_hover() -> u32 {
|
||||
hex(b().ink, 41)
|
||||
}
|
||||
fn track_fill() -> u32 {
|
||||
hex(b().ink, 64)
|
||||
}
|
||||
fn handle_fill() -> u32 {
|
||||
hex(b().ink, 255)
|
||||
}
|
||||
/// The one main button.
|
||||
const FACE: u32 = hex(B.face, 255);
|
||||
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
|
||||
pub fn face() -> u32 {
|
||||
hex(b().face, 255)
|
||||
}
|
||||
fn face_hover() -> u32 {
|
||||
hex(b().face_hover, 255)
|
||||
}
|
||||
/// A tick box's edge when off.
|
||||
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
|
||||
const OUTLINE: u32 = hex(B.outline, 255);
|
||||
pub const ACCENT: u32 = hex(B.accent, 255);
|
||||
fn box_edge() -> u32 {
|
||||
hex(b().ink_soft, 89)
|
||||
}
|
||||
fn outline() -> u32 {
|
||||
hex(b().outline, 255)
|
||||
}
|
||||
pub fn accent() -> u32 {
|
||||
hex(b().accent, 255)
|
||||
}
|
||||
/// Vanilla's white text, and its grey for what can't be used now.
|
||||
pub const WHITE: u32 = u32::MAX;
|
||||
pub const MUTED: u32 = hex(B.muted, 255);
|
||||
pub fn muted() -> u32 {
|
||||
hex(b().muted, 255)
|
||||
}
|
||||
/// A page that isn't open, a value at rest.
|
||||
pub const SOFT: u32 = hex(B.ink_soft, 255);
|
||||
pub fn soft() -> u32 {
|
||||
hex(b().ink_soft, 255)
|
||||
}
|
||||
/// Vanilla's grey for a tooltip's lines under its first (`ChatFormatting.GRAY`).
|
||||
pub const GREY: u32 = hex(0xaaaaaa, 255);
|
||||
/// A light veil over the backdrop, under the panels.
|
||||
pub const VEIL: u32 = hex(B.page, 77);
|
||||
pub fn veil() -> u32 {
|
||||
hex(b().page, 77)
|
||||
}
|
||||
|
||||
/// Sizes in GUI pixels.
|
||||
/// Shorter screens than this (a 720p window at scale 3) keep a narrower margin on top.
|
||||
@@ -120,21 +162,21 @@ pub fn ring(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], colo
|
||||
}
|
||||
/// The screen's plate, and the strip under it: dark glass with a thin edge.
|
||||
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, GLASS);
|
||||
ring(batch, atlas, rect, EDGE);
|
||||
fill(batch, atlas, rect, glass());
|
||||
ring(batch, atlas, rect, edge());
|
||||
}
|
||||
/// A group's panel on the plate, its rows on it.
|
||||
pub fn group(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, GROUP);
|
||||
fill(batch, atlas, rect, group_fill());
|
||||
}
|
||||
/// A sunk well: the search field, the value stepper.
|
||||
pub fn well(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, WELL);
|
||||
fill(batch, atlas, rect, well_fill());
|
||||
}
|
||||
/// The scroller: a faint track and its thumb.
|
||||
pub fn scroller(batch: &mut HudBatch, atlas: &HudAtlas, track: [f32; 4], thumb: [f32; 4]) {
|
||||
fill(batch, atlas, track, ROW_RULE);
|
||||
fill(batch, atlas, thumb, TRACK);
|
||||
fill(batch, atlas, track, row_rule());
|
||||
fill(batch, atlas, thumb, track_fill());
|
||||
}
|
||||
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control: the HUD
|
||||
/// kit's `fit::ellipsize`.
|
||||
@@ -142,11 +184,11 @@ pub use crate::hud_kit::fit::ellipsize as fit;
|
||||
/// A 12 px tick box ending at `right`, centred in the row at `y`: its edge in the accent when on.
|
||||
pub fn checkbox(batch: &mut HudBatch, atlas: &HudAtlas, right: f32, y: f32, on: bool, live: bool) {
|
||||
let rect = [right - 12.0, y + 4.0, 12.0, 12.0];
|
||||
fill(batch, atlas, rect, WELL);
|
||||
fill(batch, atlas, rect, well_fill());
|
||||
let edge = match (on, live) {
|
||||
(true, true) => ACCENT,
|
||||
(true, false) => MUTED,
|
||||
(false, _) => BOX_EDGE,
|
||||
(true, true) => accent(),
|
||||
(true, false) => muted(),
|
||||
(false, _) => box_edge(),
|
||||
};
|
||||
ring(batch, atlas, rect, edge);
|
||||
if on {
|
||||
@@ -169,7 +211,7 @@ fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
|
||||
for (i, c) in row.chars().enumerate() {
|
||||
if c == '#' {
|
||||
let (px, py) = (x + i as f32, y + j as f32);
|
||||
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], OUTLINE);
|
||||
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], outline());
|
||||
fill(batch, atlas, [px, py, 1.0, 1.0], color);
|
||||
}
|
||||
}
|
||||
@@ -198,17 +240,17 @@ pub fn button_with_room(
|
||||
) {
|
||||
let [x, y, w, h] = rect;
|
||||
let face = match (live, primary, hover) {
|
||||
(false, _, _) => WELL,
|
||||
(true, true, false) => FACE,
|
||||
(true, true, true) => FACE_HOVER,
|
||||
(true, false, false) => BUTTON,
|
||||
(true, false, true) => BUTTON_HOVER,
|
||||
(false, _, _) => well_fill(),
|
||||
(true, true, false) => face(),
|
||||
(true, true, true) => face_hover(),
|
||||
(true, false, false) => button_fill(),
|
||||
(true, false, true) => button_hover(),
|
||||
};
|
||||
fill(batch, atlas, rect, face);
|
||||
if hover && live {
|
||||
ring(batch, atlas, rect, WHITE);
|
||||
}
|
||||
let color = if live { WHITE } else { MUTED };
|
||||
let color = if live { WHITE } else { muted() };
|
||||
let ty = y + ((h - 8.0) / 2.0).floor();
|
||||
let left = x + 2.0 + room;
|
||||
scrolling_label(
|
||||
@@ -252,7 +294,7 @@ pub fn slider(
|
||||
(hover, live): (bool, bool),
|
||||
) {
|
||||
let [x, y, w, h] = rect;
|
||||
let color = if live { WHITE } else { MUTED };
|
||||
let color = if live { WHITE } else { muted() };
|
||||
scrolling_label(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -263,17 +305,22 @@ pub fn slider(
|
||||
);
|
||||
let line = y + h - 3.0;
|
||||
let at = x + (position.clamp(0.0, 1.0) * (w - 4.0)).round();
|
||||
fill(batch, atlas, [x, line, w, 2.0], TRACK);
|
||||
fill(batch, atlas, [x, line, w, 2.0], track_fill());
|
||||
fill(
|
||||
batch,
|
||||
atlas,
|
||||
[x, line, at - x, 2.0],
|
||||
if live { ACCENT } else { MUTED },
|
||||
if live { accent() } else { muted() },
|
||||
);
|
||||
let handle = [at, line - 3.0, 4.0, 8.0];
|
||||
fill(batch, atlas, handle, if live { HANDLE } else { MUTED });
|
||||
fill(
|
||||
batch,
|
||||
atlas,
|
||||
handle,
|
||||
if live { handle_fill() } else { muted() },
|
||||
);
|
||||
if hover && live {
|
||||
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], ACCENT);
|
||||
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], accent());
|
||||
}
|
||||
}
|
||||
/// A value that a press steps through: in a well, between the arrows that say so.
|
||||
@@ -290,13 +337,13 @@ pub fn cycler(
|
||||
ring(batch, atlas, rect, WHITE);
|
||||
}
|
||||
let ty = y + ((h - 8.0) / 2.0).floor();
|
||||
let arrows = if hover && live { WHITE } else { MUTED };
|
||||
let arrows = if hover && live { WHITE } else { muted() };
|
||||
batch.text_styled(atlas, &Text::literal("<"), x + 3.0, ty, arrows, true);
|
||||
batch.text_styled(atlas, &Text::literal(">"), x + w - 8.0, ty, arrows, true);
|
||||
let color = match (live, hover) {
|
||||
(false, _) => MUTED,
|
||||
(false, _) => muted(),
|
||||
(true, true) => WHITE,
|
||||
(true, false) => SOFT,
|
||||
(true, false) => soft(),
|
||||
};
|
||||
scrolling_label(batch, atlas, value, [x + 9.0, ty, w - 18.0], (y, h), color);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! where a tween is at the frame's [`Clock`]; nothing here holds state or runs on its own, and
|
||||
//! no motion ever decides what input does.
|
||||
//!
|
||||
//! The curves are the design system's (`horizon_theme::QUICK`, `MOVE`): a [`Tween`] takes one
|
||||
//! of them, at the theme's duration or a shorter one of its own.
|
||||
//! A [`Tween`] takes a duration and a CSS `cubic-bezier` curve: Horizon's overlays use the design
|
||||
//! system's (`horizon_theme::QUICK`, `MOVE`), at the theme's duration or a shorter one of their
|
||||
//! own.
|
||||
//!
|
||||
//! The game builds the clock once a frame (`Clock::new(now, still)`). A still clock (the game is
|
||||
//! paused, frame-stepped by `HORIZON_STILL` or a screenshot sequence, or a repro recording or
|
||||
@@ -16,7 +17,7 @@
|
||||
//! use std::time::{Duration, Instant};
|
||||
//!
|
||||
//! let opened = Instant::now();
|
||||
//! let open = Tween::new(Duration::from_millis(90), horizon_theme::QUICK);
|
||||
//! let open = Tween::new(Duration::from_millis(90), [0.4, 0.0, 0.2, 1.0]);
|
||||
//! // Half way through: partly faded in, partly grown from 94%.
|
||||
//! let clock = Clock::new(opened + Duration::from_millis(45), false);
|
||||
//! let alpha = motion::fade(open, clock, opened);
|
||||
@@ -26,7 +27,6 @@
|
||||
//! let still = Clock::new(opened, true);
|
||||
//! assert_eq!(motion::fade(open, still, opened), 1.0);
|
||||
//! ```
|
||||
use horizon_theme::Motion;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The time a frame shows, built once a frame by the game.
|
||||
@@ -50,28 +50,20 @@ impl Clock {
|
||||
}
|
||||
}
|
||||
|
||||
/// A duration and one of the design system's easing curves.
|
||||
/// A duration and an easing curve.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tween {
|
||||
/// How long it runs.
|
||||
pub duration: Duration,
|
||||
/// The CSS `cubic-bezier` control points of its curve (a `horizon_theme::Motion`'s).
|
||||
/// The CSS `cubic-bezier` control points of its curve.
|
||||
pub curve: [f32; 4],
|
||||
}
|
||||
|
||||
impl Tween {
|
||||
/// `motion`'s curve over `duration`: the dial's motions are shorter than the launcher's, on
|
||||
/// the same curves.
|
||||
pub const fn new(duration: Duration, motion: Motion) -> Self {
|
||||
Self {
|
||||
duration,
|
||||
curve: motion.curve,
|
||||
}
|
||||
}
|
||||
|
||||
/// A theme motion as it is: its own duration and curve.
|
||||
pub const fn of(motion: Motion) -> Self {
|
||||
Self::new(motion.duration(), motion)
|
||||
/// `curve` over `duration`: the dial's motions are shorter than the launcher's, on the same
|
||||
/// curves.
|
||||
pub const fn new(duration: Duration, curve: [f32; 4]) -> Self {
|
||||
Self { duration, curve }
|
||||
}
|
||||
|
||||
/// The linear progress, 0 to 1, `since` a start at this clock; 1 on a still clock.
|
||||
@@ -84,7 +76,7 @@ impl Tween {
|
||||
|
||||
/// The eased progress, 0 to 1, on the tween's curve.
|
||||
pub fn progress(self, clock: Clock, since: Instant) -> f32 {
|
||||
horizon_theme::bezier(self.curve, self.linear(clock, since))
|
||||
bezier(self.curve, self.linear(clock, since))
|
||||
}
|
||||
|
||||
/// Whether it has finished (always, on a still clock).
|
||||
@@ -98,6 +90,29 @@ impl Tween {
|
||||
}
|
||||
}
|
||||
|
||||
/// A CSS `cubic-bezier(x1, y1, x2, y2)` at `t` in 0..1: solves x for the curve parameter,
|
||||
/// returns y.
|
||||
pub fn bezier([x1, y1, x2, y2]: [f32; 4], t: f32) -> f32 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if t == 0.0 || t == 1.0 {
|
||||
return t;
|
||||
}
|
||||
let at = |a: f32, b: f32, s: f32| {
|
||||
let u = 1.0 - s;
|
||||
3.0 * u * u * s * a + 3.0 * u * s * s * b + s * s * s
|
||||
};
|
||||
let (mut lo, mut hi) = (0f32, 1f32);
|
||||
for _ in 0..24 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if at(x1, x2, mid) < t {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
at(y1, y2, (lo + hi) / 2.0)
|
||||
}
|
||||
|
||||
/// An alpha from 0 to 1 as something fades in.
|
||||
pub fn fade(tween: Tween, clock: Clock, since: Instant) -> f32 {
|
||||
tween.progress(clock, since)
|
||||
@@ -145,7 +160,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_tween_runs_from_its_start_to_its_end_and_holds() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE);
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
|
||||
assert_eq!(tween.progress(at(t0, 0), t0), 0.0);
|
||||
assert_eq!(tween.progress(at(t0, 80), t0), 1.0);
|
||||
assert_eq!(tween.progress(at(t0, 500), t0), 1.0);
|
||||
@@ -160,8 +175,7 @@ mod tests {
|
||||
fn a_still_clock_shows_every_motion_settled() {
|
||||
let t0 = Instant::now();
|
||||
let still = Clock::new(t0, true);
|
||||
let tween = Tween::of(horizon_theme::QUICK);
|
||||
assert_eq!(tween.duration, horizon_theme::QUICK.duration());
|
||||
let tween = Tween::new(horizon_theme::QUICK.duration(), horizon_theme::QUICK.curve);
|
||||
assert_eq!(fade(tween, still, t0), 1.0);
|
||||
assert_eq!(fade_out(tween, still, t0), 0.0);
|
||||
assert_eq!(scale_in(tween, still, t0, 0.94), 1.0);
|
||||
@@ -173,7 +187,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_helpers_start_where_they_say() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(100), horizon_theme::QUICK);
|
||||
let tween = Tween::new(MS(100), horizon_theme::QUICK.curve);
|
||||
let c = at(t0, 0);
|
||||
assert_eq!(fade(tween, c, t0), 0.0);
|
||||
assert_eq!(fade_out(tween, c, t0), 1.0);
|
||||
@@ -184,10 +198,25 @@ mod tests {
|
||||
assert!((flash(tween, at(t0, 25), t0) - 0.75).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_is_the_design_systems() {
|
||||
for motion in horizon_theme::MOTION {
|
||||
for i in 0..=20 {
|
||||
let t = i as f32 / 20.0;
|
||||
assert_eq!(
|
||||
bezier(motion.curve, t),
|
||||
motion.ease(t),
|
||||
"{} at {t}",
|
||||
motion.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turn_takes_the_short_way_round() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE);
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
|
||||
// From up-left (7/8 of a turn) to up-right: through up, not round the bottom.
|
||||
let from = 7.0 * FRAC_PI_4;
|
||||
let half = turn(tween, at(t0, 1), t0, from, FRAC_PI_4);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 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. Today the only keep-out is the boss bars at the top centre.
|
||||
//! 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.
|
||||
//!
|
||||
//! 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.
|
||||
@@ -34,6 +35,16 @@ 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.
|
||||
@@ -90,10 +101,20 @@ impl Room {
|
||||
/// bars' bottom, or 0 with none.
|
||||
pub fn top(&self) -> f32 {
|
||||
self.keeps()
|
||||
.map(|(Keep::BossBars, [_, y, _, h])| y + h)
|
||||
.filter(|(keep, _)| keep.at_top())
|
||||
.map(|(_, [_, 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.
|
||||
@@ -134,4 +155,15 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,12 @@
|
||||
//! ```
|
||||
use crate::hud::{HudAtlas, HudBatch};
|
||||
use crate::hud_kit::{colour, fit};
|
||||
use crate::menu_style::{self, OverlayColours};
|
||||
|
||||
/// The overlays' colours (`menu_style`, given at startup).
|
||||
fn overlay() -> &'static OverlayColours {
|
||||
&menu_style::get().overlay
|
||||
}
|
||||
|
||||
/// A pip under the strip: its size and the step from one to the next, in GUI px.
|
||||
pub const PIP: [f32; 2] = [3.0, 2.0];
|
||||
@@ -45,8 +51,12 @@ pub const PIP_STEP: f32 = 5.0;
|
||||
/// How far, in GUI px, the arrows sit from the name.
|
||||
pub const ARROW_GAP: f32 = 4.0;
|
||||
/// An arrow with a page that way, and one at an end, with none.
|
||||
pub const ARROW: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
|
||||
pub const ARROW_OFF: u32 = colour(horizon_theme::BACKDROP_MUTED, 77);
|
||||
pub fn arrow_colour() -> u32 {
|
||||
colour(overlay().backdrop_muted, 255)
|
||||
}
|
||||
pub fn arrow_off_colour() -> u32 {
|
||||
colour(overlay().backdrop_muted, 77)
|
||||
}
|
||||
/// The pips (the selected one is white).
|
||||
pub const PIP_OFF: u32 = colour(0xFFFFFF, 89);
|
||||
pub const PIP_ON: u32 = colour(0xFFFFFF, 255);
|
||||
@@ -65,9 +75,15 @@ pub const EDGE_FADE: f32 = 12.0;
|
||||
pub const FADE: [f32; 5] = [1.0, 0.7, 0.35, 0.15, 0.1];
|
||||
/// The selected tab's fill and text, and the others'.
|
||||
pub const SELECTED_FILL: u32 = colour(0xFFFFFF, 242);
|
||||
pub const SELECTED_TEXT: u32 = colour(horizon_theme::DARK.page, 255);
|
||||
pub const TAB_FILL: u32 = colour(horizon_theme::DARK.page, 191);
|
||||
pub const TAB_TEXT: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
|
||||
pub fn selected_text() -> u32 {
|
||||
colour(overlay().page, 255)
|
||||
}
|
||||
pub fn tab_fill() -> u32 {
|
||||
colour(overlay().page, 191)
|
||||
}
|
||||
pub fn tab_text() -> u32 {
|
||||
colour(overlay().backdrop_muted, 255)
|
||||
}
|
||||
/// The line of text's height in GUI px (vanilla's font, without its shadow).
|
||||
const LINE: f32 = 8.0;
|
||||
|
||||
@@ -144,7 +160,11 @@ pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
|
||||
arrow,
|
||||
ax,
|
||||
ay,
|
||||
if on { ARROW } else { ARROW_OFF },
|
||||
if on {
|
||||
arrow_colour()
|
||||
} else {
|
||||
arrow_off_colour()
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -282,9 +302,9 @@ pub fn draw_tabs(batch: &mut HudBatch, atlas: &HudAtlas, strip: &TabStrip, alpha
|
||||
for tab in &row.tabs {
|
||||
let [tx, ty, tw, th] = tab.area;
|
||||
let (fill, text) = if tab.selected {
|
||||
(SELECTED_FILL, SELECTED_TEXT)
|
||||
(SELECTED_FILL, selected_text())
|
||||
} else {
|
||||
(TAB_FILL, TAB_TEXT)
|
||||
(tab_fill(), tab_text())
|
||||
};
|
||||
let tab_start = batch.vertices.len();
|
||||
// Cut where the edges' fades start, so each fade runs over its own piece.
|
||||
@@ -380,15 +400,16 @@ mod tests {
|
||||
let [ax, _] = strip.arrows[i];
|
||||
(batch.vertices.iter())
|
||||
.filter(|v| {
|
||||
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 0x00FF_FFFF
|
||||
(v.pos[0] - ax).abs() < 4.0
|
||||
&& v.color & 0x00FF_FFFF == arrow_colour() & 0x00FF_FFFF
|
||||
})
|
||||
.map(|v| v.color >> 24)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let first = collapsed(&atlas, &pages, 0, AREA);
|
||||
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
|
||||
assert_eq!(alpha(&first, 1), ARROW >> 24);
|
||||
assert_eq!(alpha(&first, 0), arrow_off_colour() >> 24);
|
||||
assert_eq!(alpha(&first, 1), arrow_colour() >> 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -131,6 +131,23 @@ 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,6 +583,9 @@ 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,
|
||||
|
||||
@@ -74,6 +74,7 @@ pub mod light;
|
||||
pub mod lod;
|
||||
pub mod logging;
|
||||
pub mod menu_blur;
|
||||
pub mod menu_style;
|
||||
pub mod mesher;
|
||||
pub mod minimap;
|
||||
pub mod mipmap;
|
||||
@@ -109,6 +110,8 @@ 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;
|
||||
@@ -169,6 +172,7 @@ 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;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! The look of the screens vanilla doesn't have that the engine still draws: the settings pages
|
||||
//! (`graphics_style`), the HUD kit's tab strip, and the overlays drawn with `ui_theme` (the
|
||||
//! on-screen keyboard, the boot screen, the missing-resources window). Vanilla's screens never
|
||||
//! read it.
|
||||
//!
|
||||
//! The layer above gives it once at startup ([`install`]), before the first frame; the
|
||||
//! official game gives Horizon's design system (`docs/design-system.md`). With none given, the
|
||||
//! screens draw in [`NEUTRAL`]: vanilla's greys and chat colours, and no text where they would
|
||||
//! use their own fonts. A read is one atomic load, and only those screens make it.
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Colours as `0xRRGGBB`, sizes in dp (the launcher's logical pixels).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MenuStyle {
|
||||
pub settings: SettingsColours,
|
||||
pub overlay: OverlayColours,
|
||||
pub sizes: Sizes,
|
||||
/// The overlays' TrueType faces; none: they draw no text.
|
||||
pub fonts: Option<Fonts>,
|
||||
}
|
||||
|
||||
/// The settings pages: square panels of dark glass over the world, flat controls, one face.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SettingsColours {
|
||||
/// The plate behind everything, drawn see-through.
|
||||
pub page: u32,
|
||||
/// Text, and the light the panels, rules, hovers and controls are drawn in.
|
||||
pub ink: u32,
|
||||
/// Secondary text and values.
|
||||
pub ink_soft: u32,
|
||||
/// Labels, descriptions, what can't be used.
|
||||
pub muted: u32,
|
||||
/// Wells and the dark edge of small marks.
|
||||
pub outline: u32,
|
||||
/// Focus, a checked box, a slider's fill.
|
||||
pub accent: u32,
|
||||
/// The one main button, and while hovered.
|
||||
pub face: u32,
|
||||
pub face_hover: u32,
|
||||
pub success: u32,
|
||||
pub warn: u32,
|
||||
pub danger: u32,
|
||||
}
|
||||
|
||||
/// Overlays drawn over the game (the HUD kit's tab strip, `ui_theme`'s overlays).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OverlayColours {
|
||||
/// The dark page under text and tabs.
|
||||
pub page: u32,
|
||||
/// Muted text over a veiled backdrop.
|
||||
pub backdrop_muted: u32,
|
||||
/// Text and the light hover and pressed plates are drawn in.
|
||||
pub ink: u32,
|
||||
/// Secondary text and values.
|
||||
pub ink_soft: u32,
|
||||
/// One-pixel rules between rows.
|
||||
pub rule: u32,
|
||||
/// 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.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Sizes {
|
||||
pub caption: f32,
|
||||
pub label: f32,
|
||||
pub ui: f32,
|
||||
pub body: f32,
|
||||
pub title: f32,
|
||||
/// The boot screen's wordmark at its smallest, and its console lines.
|
||||
pub display: f32,
|
||||
pub micro: f32,
|
||||
/// Fields; plates (hover rows, selection); a large control's height.
|
||||
pub radius: f32,
|
||||
pub radius_plate: f32,
|
||||
pub control_large: f32,
|
||||
}
|
||||
|
||||
/// TrueType faces (`ui_theme::Face`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Fonts {
|
||||
pub regular: &'static [u8],
|
||||
pub medium: &'static [u8],
|
||||
pub semibold: &'static [u8],
|
||||
pub mono: &'static [u8],
|
||||
}
|
||||
|
||||
/// The look with nothing given: vanilla's greys and chat colours.
|
||||
pub const NEUTRAL: MenuStyle = MenuStyle {
|
||||
settings: SettingsColours {
|
||||
page: 0x101010,
|
||||
ink: 0xffffff,
|
||||
ink_soft: 0xe0e0e0,
|
||||
muted: 0xa0a0a0,
|
||||
outline: 0x000000,
|
||||
accent: 0xffffff,
|
||||
face: 0x555555,
|
||||
face_hover: 0x6f6f6f,
|
||||
success: 0x55ff55,
|
||||
warn: 0xffff55,
|
||||
danger: 0xff5555,
|
||||
},
|
||||
overlay: OverlayColours {
|
||||
page: 0x101010,
|
||||
backdrop_muted: 0xa0a0a0,
|
||||
ink: 0xffffff,
|
||||
ink_soft: 0xe0e0e0,
|
||||
rule: 0x303030,
|
||||
accent: 0xffffff,
|
||||
on_accent: 0x000000,
|
||||
lit: 0xffffff,
|
||||
lit_edge: 0xffffff,
|
||||
},
|
||||
sizes: Sizes {
|
||||
caption: 12.0,
|
||||
label: 13.0,
|
||||
ui: 14.0,
|
||||
body: 15.0,
|
||||
title: 28.0,
|
||||
display: 44.0,
|
||||
micro: 11.0,
|
||||
radius: 0.0,
|
||||
radius_plate: 0.0,
|
||||
control_large: 40.0,
|
||||
},
|
||||
fonts: None,
|
||||
};
|
||||
|
||||
static STYLE: OnceLock<MenuStyle> = OnceLock::new();
|
||||
|
||||
/// Give the menus their look: call it once at startup. A second call keeps the first look and
|
||||
/// returns false.
|
||||
pub fn install(style: MenuStyle) -> bool {
|
||||
STYLE.set(style).is_ok()
|
||||
}
|
||||
|
||||
/// The look given to [`install`], or [`NEUTRAL`].
|
||||
#[inline]
|
||||
pub fn get() -> &'static MenuStyle {
|
||||
STYLE.get().unwrap_or(&NEUTRAL)
|
||||
}
|
||||
|
||||
/// `0xRRGGBB` and an alpha in the HUD's colour encoding (`0xAABBGGRR`).
|
||||
pub const fn hud_colour(rgb: u32, alpha: u8) -> u32 {
|
||||
(alpha as u32) << 24 | (rgb & 0xff) << 16 | (rgb >> 8 & 0xff) << 8 | (rgb >> 16 & 0xff)
|
||||
}
|
||||
@@ -18,6 +18,8 @@ use crate::{
|
||||
ui,
|
||||
};
|
||||
|
||||
mod shell_pages;
|
||||
|
||||
fn tr(key: &str) -> Text {
|
||||
Text::translate(key, &[])
|
||||
}
|
||||
@@ -322,6 +324,8 @@ 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",
|
||||
@@ -880,6 +884,7 @@ 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(),
|
||||
}
|
||||
@@ -1201,6 +1206,7 @@ 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() {
|
||||
@@ -1352,6 +1358,11 @@ 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,
|
||||
@@ -1683,6 +1694,10 @@ 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
|
||||
@@ -1767,6 +1782,8 @@ 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(),
|
||||
@@ -2112,6 +2129,7 @@ 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(),
|
||||
@@ -2244,95 +2262,16 @@ 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 {
|
||||
@@ -2369,6 +2308,9 @@ 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",
|
||||
@@ -3602,6 +3544,9 @@ 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.
|
||||
@@ -3953,6 +3898,9 @@ 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() {
|
||||
@@ -4648,6 +4596,8 @@ 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);
|
||||
@@ -4683,6 +4633,9 @@ 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);
|
||||
}
|
||||
@@ -4786,6 +4739,9 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
//! 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))]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,21 +124,15 @@ impl OptionsUi {
|
||||
Some(open("horizon.pages.open.studio", Page::Studio)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::DiscordPresence),
|
||||
Some(if s.extras.no_ai {
|
||||
self.extra_button(s, crate::extras::Item::NoAi)
|
||||
} else {
|
||||
open("horizon.pages.open.agent", Page::Agent)
|
||||
}),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(
|
||||
(!s.extras.no_ai)
|
||||
.then(|| Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)),
|
||||
)
|
||||
.collect();
|
||||
if s.extras.no_ai {
|
||||
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
|
||||
} else {
|
||||
Row::Buttons(
|
||||
open("horizon.pages.open.agent", Page::Agent),
|
||||
Some(self.extra_button(s, crate::extras::Item::NoAi)),
|
||||
)
|
||||
},
|
||||
];
|
||||
self.added_entries(&mut rows);
|
||||
rows
|
||||
}
|
||||
|
||||
@@ -0,0 +1,844 @@
|
||||
//! 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,6 +869,10 @@ 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
@@ -0,0 +1,815 @@
|
||||
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());
|
||||
}
|
||||
@@ -304,19 +304,19 @@ impl StudioUi {
|
||||
|
||||
// Card background & highlight
|
||||
if is_selected {
|
||||
style::fill(batch, atlas, rect, style::OPEN);
|
||||
style::fill(batch, atlas, rect, style::open());
|
||||
} else if is_hovered {
|
||||
style::fill(batch, atlas, rect, style::HOVER);
|
||||
style::fill(batch, atlas, rect, style::hover());
|
||||
} else {
|
||||
style::well(batch, atlas, rect);
|
||||
}
|
||||
|
||||
let border_color = if is_selected {
|
||||
style::ACCENT
|
||||
style::accent()
|
||||
} else if is_hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
style::ROW_RULE
|
||||
style::row_rule()
|
||||
};
|
||||
style::ring(batch, atlas, rect, border_color);
|
||||
|
||||
|
||||
@@ -119,14 +119,11 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
|
||||
# cosmetics and emotes, the "Horizon player" presence, the official server added to
|
||||
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends, and ray
|
||||
# tracing on request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# the server list, the Horizon Settings and Mods pages and Play with Friends, and ray tracing on
|
||||
# request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
|
||||
# client is best effort: CI builds only the default.
|
||||
horizon-extras = ["horizon-client-lib/horizon-extras", "horizon-client-render/horizon-extras", "discord", "friends"]
|
||||
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
|
||||
# on Discord (game/discord.rs).
|
||||
discord = ["horizon-client-lib/discord"]
|
||||
horizon-extras = ["horizon-client-render/horizon-extras", "friends"]
|
||||
# Play with Friends: sharing a world behind an invite code (game/friends.rs). Joining one is
|
||||
# horizon-friends' `game` side, which horizon-official registers (`horizon_hooks::tunnel`).
|
||||
friends = ["dep:horizon-friends"]
|
||||
|
||||
@@ -11,6 +11,18 @@
|
||||
"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",
|
||||
@@ -129,6 +141,82 @@
|
||||
"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",
|
||||
@@ -1020,6 +1108,7 @@
|
||||
"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",
|
||||
|
||||
@@ -17,8 +17,8 @@ pub use horizon_client_render::{
|
||||
containers, controller_ui, crash, custom_sky, datapack, dynamic_light, effects, environment,
|
||||
extras, fast_hash, fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui,
|
||||
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
|
||||
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
|
||||
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
|
||||
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, menu_style, mesher,
|
||||
minimap, mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
|
||||
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
|
||||
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
|
||||
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
|
||||
@@ -58,8 +58,6 @@ pub mod debug_overlay;
|
||||
pub mod debugworld;
|
||||
pub mod dialog;
|
||||
pub mod dialog_screen;
|
||||
#[cfg(feature = "discord")]
|
||||
pub mod discord_presence;
|
||||
pub mod end_crystal;
|
||||
pub mod end_gateway;
|
||||
pub mod entities;
|
||||
|
||||
@@ -1005,7 +1005,7 @@ pub fn mob_to_nbt(
|
||||
);
|
||||
}
|
||||
if id == "minecraft:piglin" {
|
||||
write_guarded_piglin_anger(m, map);
|
||||
crate::mobs::piglin::write_guarded_anger(m, map);
|
||||
// InventoryCarrier.writeInventoryToTag.
|
||||
map.insert(
|
||||
"Inventory".into(),
|
||||
@@ -2232,6 +2232,9 @@ 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");
|
||||
}
|
||||
@@ -5969,42 +5972,6 @@ 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
|
||||
@@ -9375,6 +9342,59 @@ 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();
|
||||
|
||||
@@ -13,6 +13,7 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
log::warn!("Trait mods already installed; ignoring the second list");
|
||||
}
|
||||
horizon_threads::configure_allocator();
|
||||
crate::horizon_look::give();
|
||||
// OptiFine's and Polytone's pack formats (platform, docs/layers.md), before any pack loads.
|
||||
horizon_client::pack_formats::install();
|
||||
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
|
||||
|
||||
@@ -49,6 +49,7 @@ fn pack_files(world_tables: &Option<WorldTables>) -> horizon_world::loot::PackOv
|
||||
}
|
||||
}
|
||||
|
||||
mod activity;
|
||||
mod advancements;
|
||||
mod advancements_screen;
|
||||
mod agent;
|
||||
@@ -128,8 +129,6 @@ mod decorated_pot;
|
||||
mod dedicated;
|
||||
mod deflect;
|
||||
mod dialogs;
|
||||
#[cfg(feature = "discord")]
|
||||
mod discord;
|
||||
mod dispense_fixture;
|
||||
mod dispenser;
|
||||
mod dragon;
|
||||
@@ -5833,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_cursor(&mut batch);
|
||||
self.draw_pad_over_hud(&mut batch);
|
||||
batch
|
||||
}
|
||||
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
@@ -5964,6 +5963,7 @@ 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,6 +6093,7 @@ 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| {
|
||||
@@ -6192,7 +6193,9 @@ impl Game<'_> {
|
||||
if self.screen == Screen::Chat {
|
||||
self.draw_chat(&mut batch);
|
||||
} else if show_hud {
|
||||
self.gui_layer("minecraft:chat", &mut batch, |g, batch| g.draw_chat(batch));
|
||||
if !self.overlay_holds_view() {
|
||||
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);
|
||||
});
|
||||
@@ -6263,11 +6266,7 @@ 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);
|
||||
// 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);
|
||||
self.draw_every_toast(&mut batch, !show_hud);
|
||||
if self.server_packs.prompt.is_none() {
|
||||
self.remote_hud(&mut batch);
|
||||
self.draw_sign_editor(&mut batch);
|
||||
@@ -6717,8 +6716,8 @@ impl Game<'_> {
|
||||
.extend(out.drain(player_start..));
|
||||
}
|
||||
self.mark_motion(out.len(), 0);
|
||||
// Companions in first person, trails and auras (game/cosmetics/fx.rs).
|
||||
self.cosmetics_fx_geometry(camera, alpha, &mut out);
|
||||
// What crates draw around players: first person, see-through (game/player_hooks.rs).
|
||||
self.players_after_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:evoker_fangs");
|
||||
self.fangs_geometry(camera, alpha, &mut out);
|
||||
self.block_item_display_geometry(camera, alpha, &mut out);
|
||||
@@ -9425,6 +9424,7 @@ 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,10 +9980,8 @@ impl Game<'_> {
|
||||
&mut self.toggles,
|
||||
[self.settings.toggle_crouch, self.settings.toggle_sprint],
|
||||
[
|
||||
self.pad
|
||||
.holds_action(&self.settings.controller, "key.sneak"),
|
||||
self.pad
|
||||
.holds_action(&self.settings.controller, "key.sprint"),
|
||||
self.pad.holds_action("key.sneak"),
|
||||
self.pad.holds_action("key.sprint"),
|
||||
],
|
||||
);
|
||||
self.sync_toggle(SNEAK);
|
||||
@@ -11253,8 +11251,7 @@ impl Game<'_> {
|
||||
self.heal_hold_latches();
|
||||
// For the hooks above the engine (the "I'm playing" ping): said when it changes.
|
||||
horizon_hooks::engine::published(self.is_published());
|
||||
#[cfg(feature = "discord")]
|
||||
self.update_discord_presence();
|
||||
self.report_activity();
|
||||
let steps = self.clock.advance(elapsed);
|
||||
for _ in 0..steps {
|
||||
let started = Instant::now();
|
||||
@@ -12529,15 +12526,8 @@ pub fn run_with() -> Result<()> {
|
||||
if options.execution_role.is_client() {
|
||||
narrator::start(&early_settings);
|
||||
}
|
||||
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
|
||||
#[cfg(feature = "discord")]
|
||||
if options.dedicated.is_none() {
|
||||
let automated = options.benchmark.is_some()
|
||||
|| options.offscreen
|
||||
|| options.frames.is_some()
|
||||
|| options.world_frames.is_some();
|
||||
horizon_client::discord_presence::start(early_settings.extras.discord_presence, automated);
|
||||
}
|
||||
// A game window's hooks (Discord's presence): never for a server.
|
||||
activity::client_started(&options);
|
||||
// Snapshots of derived tables live in the game folder, never beside the
|
||||
// shared resources.
|
||||
Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache"));
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! What the player is doing, for the hooks above the engine (`Hooks::activity`: the Horizon
|
||||
//! layer's Discord presence shows it).
|
||||
use super::*;
|
||||
use horizon_client::command::args::GameMode;
|
||||
use horizon_hooks::{Activity, Client, Dimension, Play};
|
||||
|
||||
/// At startup, for a game window (never a dedicated server's): the player's first activity, then
|
||||
/// `Hooks::client_started`.
|
||||
pub(super) fn client_started(options: &Options) {
|
||||
if options.dedicated.is_some() {
|
||||
return;
|
||||
}
|
||||
let automated = options.benchmark.is_some()
|
||||
|| options.offscreen
|
||||
|| options.frames.is_some()
|
||||
|| options.world_frames.is_some();
|
||||
horizon_hooks::engine::activity(Activity::Menus);
|
||||
horizon_hooks::engine::client_started(&Client::new(automated));
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Each frame: only a change reaches the hooks.
|
||||
pub(super) fn report_activity(&self) {
|
||||
// One status per computer: P1's, not a splitscreen player's.
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
horizon_hooks::engine::activity(self.activity());
|
||||
}
|
||||
fn activity(&self) -> Activity {
|
||||
if self.title.is_some() {
|
||||
return Activity::Menus;
|
||||
}
|
||||
let max = horizon_client::lan::MAX_PLAYERS;
|
||||
let play = if let Some(host) = &self.lan {
|
||||
Play::Hosting {
|
||||
players: 1 + host.hub.guests.len(),
|
||||
max,
|
||||
}
|
||||
} else if let Some(server) = &self.integrated {
|
||||
match (server.published(), &self.remote) {
|
||||
(Some(_), Some(remote)) => Play::Hosting {
|
||||
players: remote.player_count(),
|
||||
max,
|
||||
},
|
||||
_ => Play::Singleplayer,
|
||||
}
|
||||
} else if self.remote.is_some() {
|
||||
Play::Multiplayer
|
||||
} else {
|
||||
Play::Singleplayer
|
||||
};
|
||||
Activity::World {
|
||||
play,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => Dimension::Overworld,
|
||||
Dim::Nether => Dimension::Nether,
|
||||
Dim::End => Dimension::End,
|
||||
},
|
||||
mode: match self.mode {
|
||||
GameMode::Survival => horizon_hooks::GameMode::Survival,
|
||||
GameMode::Creative => horizon_hooks::GameMode::Creative,
|
||||
GameMode::Adventure => horizon_hooks::GameMode::Adventure,
|
||||
GameMode::Spectator => horizon_hooks::GameMode::Spectator,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1278,10 +1278,21 @@ impl Game<'_> {
|
||||
.raw
|
||||
.get(&id)
|
||||
.is_some_and(|tag| horizon_client::entity_nbt::flag(tag, "Invulnerable"));
|
||||
// Entity.isInvulnerableTo: the flag, then EnchantmentHelper.isImmuneToDamage.
|
||||
let a = &self.armor_stands[i];
|
||||
if (invulnerable && !creative) || a.invisible || a.marker {
|
||||
if (invulnerable && !creative)
|
||||
|| a.invisible
|
||||
|| a.marker
|
||||
|| self.stand_immune_to_damage(i, damage_type, causing)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// ArmorStand.hurtServer attributes its game events to the source
|
||||
// entity: the player, a LAN guest or the mob.
|
||||
let by = match causing {
|
||||
Some(Target::Player | Target::Guest(_) | Target::Mob(_)) => causing,
|
||||
_ => None,
|
||||
};
|
||||
let (explosion, ignites, burns) = (
|
||||
tagged("minecraft:is_explosion"),
|
||||
tagged("minecraft:ignites_armor_stands"),
|
||||
@@ -1292,21 +1303,26 @@ impl Game<'_> {
|
||||
tagged("minecraft:always_kills_armor_stands"),
|
||||
);
|
||||
if explosion {
|
||||
self.break_armor_stand_by_anything(i);
|
||||
self.break_armor_stand_with_source(i, by);
|
||||
return false;
|
||||
}
|
||||
if ignites {
|
||||
if self.armor_stands[i].fire_ticks > 0 {
|
||||
self.cause_stand_damage(i, 0.15);
|
||||
self.cause_stand_damage(i, 0.15, by);
|
||||
} else {
|
||||
// igniteForSeconds(5.0f): at least 100 ticks.
|
||||
// igniteForSeconds(5.0f): at least 100 ticks, and
|
||||
// Entity.igniteForTicks calls clearFreeze.
|
||||
let a = &mut self.armor_stands[i];
|
||||
a.fire_ticks = a.fire_ticks.max(100);
|
||||
if let Some(horizon_world::nbt::Tag::Compound(map)) = self.persist.raw.get_mut(&id)
|
||||
{
|
||||
map.remove(&"TicksFrozen".into());
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if burns && self.armor_stands[i].health > 0.5 {
|
||||
self.cause_stand_damage(i, 4.0);
|
||||
self.cause_stand_damage(i, 4.0, by);
|
||||
return false;
|
||||
}
|
||||
if !breaks && !always_kills {
|
||||
@@ -1321,22 +1337,85 @@ impl Game<'_> {
|
||||
if !may_build {
|
||||
return false;
|
||||
}
|
||||
// ArmorStand.hurtServer attributes its game events to the source
|
||||
// entity: the player, a LAN guest or the mob.
|
||||
let by = match causing {
|
||||
Some(Target::Player | Target::Guest(_) | Target::Mob(_)) => causing,
|
||||
_ => None,
|
||||
};
|
||||
self.break_armor_stand_by_hit(i, creative, always_kills, by)
|
||||
}
|
||||
/// `EnchantmentHelper.isImmuneToDamage` for the stand at `i`: whether an
|
||||
/// enchantment on an active equipment slot has a `minecraft:damage_immunity`
|
||||
/// effect whose requirements hold for `damage_type` and `causing`.
|
||||
fn stand_immune_to_damage(
|
||||
&mut self,
|
||||
i: usize,
|
||||
damage_type: &str,
|
||||
causing: Option<super::commands::Target>,
|
||||
) -> bool {
|
||||
use super::commands::Target;
|
||||
let mut checks = Vec::new();
|
||||
let equipment = &self.armor_stands[i].equipment;
|
||||
for (stack, slot) in equipment.iter().zip(ArmorStand::SLOTS) {
|
||||
let Some(stack) = stack else { continue };
|
||||
let armor = matches!(slot, "head" | "chest" | "legs" | "feet");
|
||||
let hand = matches!(slot, "mainhand" | "offhand");
|
||||
for (id, level) in stack.enchantments() {
|
||||
let Some(def) = self.res.stations().enchantments.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if !def.slots.iter().any(|s| {
|
||||
s == slot || s == "any" || (s == "armor" && armor) || (s == "hand" && hand)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let immunity = &def.effects["minecraft:damage_immunity"];
|
||||
for entry in immunity.as_array().into_iter().flatten() {
|
||||
checks.push((entry.get("requirements").cloned(), *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
if checks.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let attacker = match causing {
|
||||
Some(Target::Player | Target::Guest(_)) => Some("minecraft:player".to_owned()),
|
||||
Some(Target::Mob(m_id)) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == m_id)
|
||||
.map(|m| m.kind.id.clone()),
|
||||
Some(Target::Stand(_)) => Some("minecraft:armor_stand".to_owned()),
|
||||
_ => None,
|
||||
};
|
||||
let canonical = if damage_type.contains(':') {
|
||||
damage_type.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{damage_type}")
|
||||
};
|
||||
let ctx = horizon_client::enchant::EffectContext {
|
||||
items: Some(self.res.items()),
|
||||
damage_type: Some(&canonical),
|
||||
is_direct: attacker.is_some(),
|
||||
this: Some("minecraft:armor_stand"),
|
||||
attacker: attacker.as_deref(),
|
||||
direct_attacker: attacker.as_deref(),
|
||||
..Default::default()
|
||||
};
|
||||
let ench = &self.res.stations().enchantments;
|
||||
let rng = &mut self.world.random;
|
||||
for (req, level) in checks {
|
||||
if req.is_none_or(|r| ench.condition(&r, level, &ctx, rng)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `ArmorStand.causeDamage`: the health less `amount`, broken by anything
|
||||
/// at 0.5 or less.
|
||||
fn cause_stand_damage(&mut self, i: usize, amount: f32) {
|
||||
/// at 0.5 or less, else `gameEvent(ENTITY_DAMAGE, damageSource.getEntity())`.
|
||||
fn cause_stand_damage(&mut self, i: usize, amount: f32, by: Option<super::commands::Target>) {
|
||||
let health = self.armor_stands[i].health - amount;
|
||||
if health <= 0.5 {
|
||||
self.break_armor_stand_by_anything(i);
|
||||
self.break_armor_stand_with_source(i, by);
|
||||
} else {
|
||||
let at = self.armor_stands[i].position;
|
||||
self.armor_stands[i].health = health;
|
||||
self.stand_game_event("minecraft:entity_damage", [at.x, at.y, at.z], by);
|
||||
}
|
||||
}
|
||||
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
|
||||
|
||||
@@ -633,6 +633,26 @@ 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]))?;
|
||||
@@ -645,6 +665,8 @@ 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() {
|
||||
@@ -829,6 +851,10 @@ 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);
|
||||
@@ -1103,6 +1129,7 @@ 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,8 +235,14 @@ 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));
|
||||
assert!(!debug_crosshair_drawn(true, true, false, true));
|
||||
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));
|
||||
}
|
||||
|
||||
/// Opening a world in front of a block left its outline over Loading terrain's panorama (#12872).
|
||||
|
||||
@@ -13,18 +13,12 @@ mod fx;
|
||||
|
||||
use super::Game;
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::mesher::Vertex;
|
||||
use horizon_client::mobs::anim::Poses;
|
||||
use horizon_client::mobs::{Mob, Part, Polygon};
|
||||
use horizon_cosmetics::follow::Follow;
|
||||
use horizon_cosmetics::prefs::{self, Others};
|
||||
use horizon_cosmetics::trail::{Budget, Emitter};
|
||||
use horizon_cosmetics::{Item, Slot};
|
||||
use horizon_hooks::players::{
|
||||
Badge, CameraOverride, CameraView, Frame, Gear, Kind, Mark, NameDecoration, PlayerModel,
|
||||
PlayerModels, SpriteStore, Sprites,
|
||||
Badge, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Look, Mark, Mesh, NameDecoration,
|
||||
Pass, PlayerDraw, PlayerModel, PlayerModels, Quad, SpriteStore, Sprites,
|
||||
};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -32,46 +26,10 @@ use std::time::{Duration, Instant};
|
||||
/// Horizon's mark before a Horizon player's name, over their name tag and in the player list.
|
||||
const MARK: Mark = &horizon_cosmetics::mark::MARK;
|
||||
|
||||
/// An item as the renderer takes it.
|
||||
struct Baked {
|
||||
item: Arc<Item>,
|
||||
/// A model per bone it rides (a shirt's sleeves are on the arms).
|
||||
models: Vec<(horizon_cosmetics::Bone, Arc<Part>)>,
|
||||
/// Its texture's sprite (a 64×64 skin slot of the atlas).
|
||||
location: String,
|
||||
/// The frame it was last marked drawn in (its slot is kept).
|
||||
marked: Cell<u32>,
|
||||
}
|
||||
|
||||
impl Baked {
|
||||
fn new(item: &Arc<Item>, frame: u32) -> Self {
|
||||
Self {
|
||||
item: item.clone(),
|
||||
models: item
|
||||
.roots()
|
||||
.map(|(bone, root)| (bone, Arc::new(part(root))))
|
||||
.collect(),
|
||||
location: format!("cosmetics/{:x}", Arc::as_ptr(item) as usize),
|
||||
marked: Cell::new(frame.wrapping_sub(1)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Cosmetics {
|
||||
/// The hooks' client (`Seat`), taken once it started.
|
||||
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
|
||||
/// By the item's address; each entry holds its item, so the address isn't reused.
|
||||
baked: RefCell<FxHashMap<usize, Baked>>,
|
||||
/// Items drawn whose texture isn't in the atlas yet.
|
||||
uploads: RefCell<Vec<Arc<Item>>>,
|
||||
frame: Cell<u32>,
|
||||
/// Kept between players so a frame of avatars allocates none: the wearer's body, an item.
|
||||
poses: RefCell<(Poses, Poses)>,
|
||||
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
|
||||
local: bool,
|
||||
/// Companions, trails and auras.
|
||||
fx: RefCell<Fx>,
|
||||
/// Emotes: those the wheel offers and the one playing (`cosmetics/emotes.rs`).
|
||||
emotes: emotes::Emotes,
|
||||
/// The emote wheel (`cosmetics/emote_wheel.rs`).
|
||||
@@ -130,49 +88,6 @@ fn load_owned_emotes(
|
||||
)
|
||||
}
|
||||
|
||||
/// The key of the player's own model in `Fx`'s maps (others go by entity id).
|
||||
const OWN: u32 = u32::MAX;
|
||||
/// Full light, for what glows.
|
||||
const FULL_LIGHT: u32 = 240 | 240 << 8;
|
||||
|
||||
/// What companions and trails keep between frames, per wearer (marked with the frame they were
|
||||
/// last drawn in; the others are dropped), and the frame's trails and auras, drawn after the
|
||||
/// players (`cosmetics_fx_geometry`).
|
||||
#[derive(Default)]
|
||||
struct Fx {
|
||||
companions: FxHashMap<u32, (Follow, u32)>,
|
||||
trails: FxHashMap<u32, (Emitter, u32)>,
|
||||
budget: Budget,
|
||||
clock: Option<(Instant, Instant)>,
|
||||
/// Ticks since the first frame, and the last frame's length in seconds.
|
||||
now: f64,
|
||||
dt: f32,
|
||||
/// Trails and auras: see-through.
|
||||
clear: Vec<Vertex>,
|
||||
}
|
||||
|
||||
impl Fx {
|
||||
/// A new frame: the clock moves on and what no frame drew since the last one goes.
|
||||
fn frame(&mut self, frame: u32) {
|
||||
let now = Instant::now();
|
||||
let (start, last) = self.clock.get_or_insert((now, now));
|
||||
self.dt = now.duration_since(*last).as_secs_f32().min(0.25);
|
||||
self.now = now.duration_since(*start).as_secs_f64() * 20.0;
|
||||
*last = now;
|
||||
let seen = |mark: u32| frame.wrapping_sub(mark) <= 1;
|
||||
self.companions.retain(|_, (_, mark)| seen(*mark));
|
||||
let budget = &mut self.budget;
|
||||
self.trails.retain(|_, (e, mark)| {
|
||||
let keep = seen(*mark);
|
||||
if !keep {
|
||||
e.clear(budget);
|
||||
}
|
||||
keep
|
||||
});
|
||||
self.clear.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the signed-in account owns a cosmetic: the answer kept from the last run first
|
||||
/// (`prefs::owned`), then the service's, asked once a run and retried after a failure like Show my
|
||||
/// cosmetics. Not known yet, or no session: not owning.
|
||||
@@ -334,15 +249,11 @@ impl Cosmetics {
|
||||
/// choice, or by owning none). Hidden, the service is never asked about them.
|
||||
fn worn_of(
|
||||
client: &horizon_cosmetics::Cosmetics,
|
||||
extras: &horizon_client::extras::Extras,
|
||||
said: &Said,
|
||||
own: bool,
|
||||
uuid: impl FnOnce() -> Option<u128>,
|
||||
) -> Option<horizon_cosmetics::Worn> {
|
||||
let shown = if own {
|
||||
extras.cosmetics_mine
|
||||
} else {
|
||||
extras.effective_others() != Others::Hidden
|
||||
};
|
||||
let shown = if own { said.mine } else { !said.others_hidden };
|
||||
if !shown {
|
||||
return None;
|
||||
}
|
||||
@@ -369,24 +280,6 @@ fn own_uuid(
|
||||
/// How often a looping emote is said again (the service keeps one a minute).
|
||||
const LOOP_AGAIN: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The game's model of an item's part.
|
||||
fn part(p: &horizon_cosmetics::Part) -> Part {
|
||||
Part {
|
||||
name: p.name.clone(),
|
||||
pose: p.pose,
|
||||
polygons: p
|
||||
.faces
|
||||
.iter()
|
||||
.map(|f| Polygon {
|
||||
normal: f.normal,
|
||||
vertices: f.vertices,
|
||||
cull_back: false,
|
||||
})
|
||||
.collect(),
|
||||
children: p.children.iter().map(part).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The texture in the top-left corner of a skin slot's 64×64 RGBA.
|
||||
fn slot_image(item: &Item) -> Vec<u8> {
|
||||
let t = &item.texture;
|
||||
@@ -463,12 +356,45 @@ struct Seat {
|
||||
models: Vec<PlayerModel>,
|
||||
/// The world is the window's own single-player one (`Frame::solo`).
|
||||
solo: bool,
|
||||
/// Name tag badges' sprites, by the item's address: their id (never reused) and the item,
|
||||
/// held so the address isn't reused while it is there.
|
||||
sprites: FxHashMap<usize, (u64, Arc<Item>)>,
|
||||
/// The items the hooks show, by the item's address.
|
||||
items: FxHashMap<usize, Kept>,
|
||||
last_sprite: u64,
|
||||
/// Sprites drawing missed, put in the atlas next frame.
|
||||
wanted: Vec<u64>,
|
||||
/// Companions, trails and auras (`cosmetics/fx.rs`).
|
||||
fx: fx::Fx,
|
||||
}
|
||||
|
||||
/// An item the hooks show: its sprite's id (never reused) and a mesh per bone it rides, held
|
||||
/// with the item so its address isn't reused while it is here.
|
||||
struct Kept {
|
||||
sprite: u64,
|
||||
item: Arc<Item>,
|
||||
meshes: Vec<(Bone, Arc<Mesh>)>,
|
||||
}
|
||||
|
||||
/// The hooks' model of an item's part.
|
||||
fn mesh(p: &horizon_cosmetics::Part) -> Mesh {
|
||||
let quads = p.faces.iter().map(|f| Quad::new(f.normal, f.vertices));
|
||||
Mesh::new(
|
||||
p.name.clone(),
|
||||
p.pose,
|
||||
quads.collect(),
|
||||
p.children.iter().map(mesh).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The hooks' name of a bone.
|
||||
fn bone(b: horizon_cosmetics::Bone) -> Bone {
|
||||
use horizon_cosmetics::Bone as B;
|
||||
match b {
|
||||
B::Head => Bone::Head,
|
||||
B::Body => Bone::Body,
|
||||
B::LeftArm => Bone::LeftArm,
|
||||
B::RightArm => Bone::RightArm,
|
||||
B::LeftLeg => Bone::LeftLeg,
|
||||
B::RightLeg => Bone::RightLeg,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the game tells the hooks before each call, until the settings move here (spec #14091
|
||||
@@ -483,6 +409,8 @@ struct Said {
|
||||
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).
|
||||
@@ -493,6 +421,19 @@ 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());
|
||||
|
||||
@@ -515,6 +456,7 @@ impl horizon_hooks::Hooks for Hooks {
|
||||
service(|s| {
|
||||
s.solo = frame.solo;
|
||||
s.frame();
|
||||
s.fx.frame();
|
||||
s.put_sprites(sprites);
|
||||
});
|
||||
}
|
||||
@@ -539,6 +481,15 @@ impl horizon_hooks::Hooks for Hooks {
|
||||
}
|
||||
|
||||
/// An emote pulls the camera back: it eases out over the emote's first quarter second.
|
||||
fn player_layers(&self, model: &PlayerModel, draw: &mut dyn PlayerDraw) {
|
||||
service(|s| s.player_layers(model, draw));
|
||||
}
|
||||
|
||||
/// The player's own companion in first person.
|
||||
fn first_person(&self, own: &PlayerModel, draw: &mut dyn PlayerDraw) {
|
||||
service(|s| s.first_person(own, draw));
|
||||
}
|
||||
|
||||
fn camera(&self, own: &CameraView) -> Option<CameraOverride> {
|
||||
let start = service(|s| s.said.pull)?;
|
||||
let t = ((own.age - start) / 5.0).clamp(0.0, 1.0);
|
||||
@@ -664,14 +615,7 @@ impl Seat {
|
||||
if !(styles || said.marks) {
|
||||
return None;
|
||||
}
|
||||
let uuid = match m.kind {
|
||||
Kind::Own => own_uuid(m.uuid, self.solo, said.solo, || {
|
||||
horizon_client::auth::current().map(|s| s.uuid)
|
||||
}),
|
||||
Kind::Mannequin if self.local => Some(u128::from(m.id)),
|
||||
Kind::Player => m.uuid,
|
||||
_ => 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 worn = if styles { frame.worn(uuid) } else { None };
|
||||
@@ -687,42 +631,152 @@ impl Seat {
|
||||
Some(NameDecoration::new(mark, badge, top))
|
||||
}
|
||||
|
||||
/// The name tag style in `worn`: its badge's place in the atlas and its line's colour. None
|
||||
/// until the badge is in the atlas (it is put there next frame).
|
||||
fn badge(&mut self, worn: &horizon_cosmetics::Worn, sprites: &dyn Sprites) -> Option<Badge> {
|
||||
let item = worn.iter().find(|i| i.slot == Slot::NameTag)?;
|
||||
let [r, g, b] = item.tag?.line;
|
||||
/// 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, || {
|
||||
horizon_client::auth::current().map(|s| s.uuid)
|
||||
}),
|
||||
Kind::Mannequin if self.local => Some(u128::from(m.id)),
|
||||
Kind::Player => m.uuid,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `item` as the hooks show it, kept from its first sight.
|
||||
fn kept(&mut self, item: &Arc<Item>) -> &Kept {
|
||||
let next = self.last_sprite + 1;
|
||||
let (id, _) = *self
|
||||
.sprites
|
||||
let kept = self
|
||||
.items
|
||||
.entry(Arc::as_ptr(item) as usize)
|
||||
.or_insert_with(|| (next, item.clone()));
|
||||
self.last_sprite = self.last_sprite.max(id);
|
||||
let Some([u0, v0, u1, v1]) = sprites.get(id) else {
|
||||
.or_insert_with(|| Kept {
|
||||
sprite: next,
|
||||
item: item.clone(),
|
||||
meshes: item
|
||||
.roots()
|
||||
.map(|(b, root)| (bone(b), Arc::new(mesh(root))))
|
||||
.collect(),
|
||||
});
|
||||
self.last_sprite = self.last_sprite.max(kept.sprite);
|
||||
kept
|
||||
}
|
||||
|
||||
/// Where `item`'s texture is in the atlas (`get` reads the crate's sprites): the top left
|
||||
/// corner of its slot. None until it is there (it is put there next frame).
|
||||
fn uv(&mut self, item: &Arc<Item>, get: impl Fn(u64) -> Option<[f32; 4]>) -> Option<[f32; 4]> {
|
||||
let id = self.kept(item).sprite;
|
||||
let Some([u0, v0, u1, v1]) = get(id) else {
|
||||
if !self.wanted.contains(&id) {
|
||||
self.wanted.push(id);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
// The mark sits in the slot's top left corner.
|
||||
let u1 = u0 + (u1 - u0) * item.texture.width as f32 / 64.0;
|
||||
let v1 = v0 + (v1 - v0) * item.texture.height as f32 / 64.0;
|
||||
Some(Badge::new(
|
||||
[u0, v0, u1, v1],
|
||||
u32::from_be_bytes([0, r, g, b]),
|
||||
))
|
||||
let (w, h) = (
|
||||
item.texture.width as f32 / 64.0,
|
||||
item.texture.height as f32 / 64.0,
|
||||
);
|
||||
Some([u0, v0, u0 + (u1 - u0) * w, v0 + (v1 - v0) * h])
|
||||
}
|
||||
|
||||
/// Puts the sprites drawing missed in the atlas; forgets those of items no one wears.
|
||||
/// The name tag style in `worn`: its badge and its line's colour. None until the badge is in
|
||||
/// the atlas.
|
||||
fn badge(&mut self, worn: &horizon_cosmetics::Worn, sprites: &dyn Sprites) -> Option<Badge> {
|
||||
let item = worn.iter().find(|i| i.slot == Slot::NameTag)?;
|
||||
let [r, g, b] = item.tag?.line;
|
||||
let uv = self.uv(item, |id| sprites.get(id))?;
|
||||
Some(Badge::new(uv, u32::from_be_bytes([0, r, g, b])))
|
||||
}
|
||||
|
||||
/// Puts the sprites drawing missed in the atlas; forgets the items no one wears.
|
||||
fn put_sprites(&mut self, store: &mut dyn SpriteStore) {
|
||||
for id in self.wanted.drain(..) {
|
||||
let item = self.sprites.values().find(|(i, _)| *i == id);
|
||||
if let Some((_, item)) = item.filter(|_| !store.has(id)) {
|
||||
store.put(id, slot_image(item));
|
||||
let kept = self.items.values().find(|k| k.sprite == id);
|
||||
if let Some(kept) = kept.filter(|_| !store.has(id)) {
|
||||
store.put(id, slot_image(&kept.item));
|
||||
}
|
||||
}
|
||||
self.sprites
|
||||
.retain(|_, (_, item)| Arc::strong_count(item) > 1);
|
||||
self.items.retain(|_, k| Arc::strong_count(&k.item) > 1);
|
||||
}
|
||||
|
||||
/// The cosmetics `m` wears, on its bones and around it in the world (companions, trails,
|
||||
/// auras: `cosmetics/fx.rs`), and an emote's props.
|
||||
fn player_layers(&mut self, m: &PlayerModel, draw: &mut dyn PlayerDraw) {
|
||||
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 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| {
|
||||
let (t, w) = p.at(ticks)?;
|
||||
(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)));
|
||||
// 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];
|
||||
let worn_items = worn.iter().flat_map(|w| w.iter()).map(|i| (i, None));
|
||||
let emote_items = emote.iter().map(|(i, t, w)| (i, Some((*t, *w))));
|
||||
for (item, emoting) in worn_items.chain(emote_items) {
|
||||
// On no bone, around the model in the world.
|
||||
if let (Slot::Companion | Slot::Trail | Slot::Aura, None) = (item.slot, emoting) {
|
||||
if draw.pass() == Pass::World {
|
||||
self.fx(m, item, draw);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Covered by gear, or drawn by no bone (a name tag style).
|
||||
if emoting.is_none() && covered(m.gear, item.slot) {
|
||||
continue;
|
||||
}
|
||||
let Some(uv) = self.uv(item, |id| draw.sprite(id)) else {
|
||||
continue;
|
||||
};
|
||||
for i in 0..self.kept(item).meshes.len() {
|
||||
let (bone_of, mesh) = self.kept(item).meshes[i].clone();
|
||||
let known = bones.iter().flatten().find(|(b, _)| *b == bone_of);
|
||||
let matrix = match known {
|
||||
Some(&(_, matrix)) => matrix,
|
||||
None => {
|
||||
let Some(matrix) = draw.bone(bone_of) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(free) = bones.iter_mut().find(|b| b.is_none()) {
|
||||
*free = Some((bone_of, matrix));
|
||||
}
|
||||
matrix
|
||||
}
|
||||
};
|
||||
draw.draw(&mesh, matrix, Look::new(uv), &mut |p| {
|
||||
item.animate(ticks, |name, pose| *p.pose(name) = pose);
|
||||
if let (Some((t, w)), Some(e)) = (emoting, &item.emote) {
|
||||
e.pose_props(t, |n| item.rest(n), |n, pose| *p.pose(n) = pose);
|
||||
// Props grow in and shrink out with the emote's blend.
|
||||
let root = p.pose(&mesh.name);
|
||||
for s in &mut root[6..9] {
|
||||
*s *= w;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's own companion while its body is out of view.
|
||||
fn first_person(&mut self, m: &PlayerModel, draw: &mut dyn PlayerDraw) {
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
if let Some(item) = worn.iter().find(|i| i.slot == Slot::Companion) {
|
||||
self.fx(m, item, draw);
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
|
||||
@@ -824,12 +878,7 @@ impl Game<'_> {
|
||||
let pull = self.cosmetics.emotes.pull();
|
||||
service(|s| {
|
||||
let said = &mut s.said;
|
||||
said.mine = extras.cosmetics_mine;
|
||||
said.listed = extras.horizon_listed;
|
||||
said.others_shown = extras.effective_others() == Others::Shown;
|
||||
said.others_hidden = extras.effective_others() == Others::Hidden;
|
||||
said.marks = extras.horizon_marks;
|
||||
said.solo = extras.cosmetics_solo;
|
||||
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));
|
||||
@@ -840,36 +889,16 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Once a frame, after the hooks: takes the service's client for drawing and puts the
|
||||
/// textures the last frame wanted into the atlas.
|
||||
/// Once a frame, after the hooks: what the game reads of them (the settings' owner flag,
|
||||
/// whether items come from a folder).
|
||||
pub(super) fn cosmetics_frame(&mut self) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
let c = &mut self.cosmetics;
|
||||
let owns = service(|s| {
|
||||
if c.client.is_none() {
|
||||
c.client = s.client.clone();
|
||||
c.local = s.local;
|
||||
}
|
||||
s.owner.owns
|
||||
});
|
||||
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;
|
||||
c.frame.set(c.frame.get().wrapping_add(1));
|
||||
c.fx.get_mut().frame(c.frame.get());
|
||||
let uploads = std::mem::take(&mut *c.uploads.borrow_mut());
|
||||
for item in uploads {
|
||||
let location = format!("cosmetics/{:x}", Arc::as_ptr(&item) as usize);
|
||||
if !self.gui.atlas.sprites.contains_key(&location) {
|
||||
self.store_skin(location, slot_image(&item));
|
||||
}
|
||||
}
|
||||
// Items no player wears any more.
|
||||
self.cosmetics
|
||||
.baked
|
||||
.borrow_mut()
|
||||
.retain(|_, b| Arc::strong_count(&b.item) > 1);
|
||||
self.cosmetics.local = local;
|
||||
}
|
||||
|
||||
/// Asks the service which emotes the account owns, for the wheel: when it opens, at most
|
||||
@@ -895,210 +924,6 @@ impl Game<'_> {
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// What `m` wears, if cosmetics are shown for it: `own` for the player's own model.
|
||||
fn worn(&self, m: &Mob, own: bool) -> Option<horizon_cosmetics::Worn> {
|
||||
let client = self.cosmetics.client.as_ref()?;
|
||||
worn_of(client, &self.settings.extras, own, || {
|
||||
self.cosmetics_uuid(m, own)
|
||||
})
|
||||
}
|
||||
|
||||
/// The UUID the service knows `m` by: a player's, or a mannequin's id with local items.
|
||||
fn cosmetics_uuid(&self, m: &Mob, own: bool) -> Option<u128> {
|
||||
if own {
|
||||
own_uuid(
|
||||
self.remote.as_ref().map(|r| r.profile()),
|
||||
self.integrated.is_some(),
|
||||
self.settings.extras.cosmetics_solo,
|
||||
|| horizon_client::auth::current().map(|s| s.uuid),
|
||||
)
|
||||
} else if &*m.kind.id == "minecraft:player" {
|
||||
self.remote_player_uuid(m.id)
|
||||
} else if self.cosmetics.local && &*m.kind.id == "minecraft:mannequin" {
|
||||
Some(u128::from(m.id))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The cosmetics `m` wears, on its bones. `gpu`: posed on the GPU (the mob pass's skin
|
||||
/// frame, begun); the bounds of what went there come back, for culling. Else the vertices
|
||||
/// go to `out`. `camera`: where the world view's camera is; None in a picture of the player
|
||||
/// (the inventory), which shows no companion, trail or aura.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn cosmetics_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
own: bool,
|
||||
base: glam::Mat4,
|
||||
camera: Option<glam::DVec3>,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
gpu: bool,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) -> Option<(glam::Vec3, glam::Vec3)> {
|
||||
use horizon_client::mobs::{anim, render};
|
||||
let worn = self.worn(m, own);
|
||||
// An emote's props: its time and weight while it plays.
|
||||
let ticks = m.age as f32 + alpha;
|
||||
let emote = emotes::playing(m).and_then(|p| {
|
||||
let (t, w) = p.at(ticks)?;
|
||||
(p.item.vertices > 0).then_some((&p.item, t, w))
|
||||
});
|
||||
if worn.is_none() && emote.is_none() || horizon_client::mobs::trader::is_invisible(m) {
|
||||
return None;
|
||||
}
|
||||
// The avatar's body, posed as it is drawn.
|
||||
let (key, _) = render::body(m);
|
||||
let body = m.kind.layer(&key).unwrap_or(&m.kind.model);
|
||||
let mut scratch = self.cosmetics.poses.borrow_mut();
|
||||
let (body_poses, poses) = &mut *scratch;
|
||||
body_poses.reset(body);
|
||||
let data = self.res.mob_data();
|
||||
anim::setup(m, &anim::state(m, alpha), body_poses, &data.animations);
|
||||
let mut bounds = None;
|
||||
// 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<(horizon_cosmetics::Bone, glam::Mat4)>; 4] = [None; 4];
|
||||
// "Name tags only": other players' styles beside their names, nothing on their bodies.
|
||||
let bodies = own || self.settings.extras.effective_others() == Others::Shown;
|
||||
let worn_items = worn.iter().flat_map(|w| w.iter()).map(|i| (i, None));
|
||||
let emote_items = emote.map(|(i, t, w)| (i, Some((t, w))));
|
||||
for (item, emoting) in worn_items.chain(emote_items) {
|
||||
if emoting.is_none() && !bodies {
|
||||
continue;
|
||||
}
|
||||
if let (Some(camera), Slot::Companion | Slot::Trail | Slot::Aura) = (camera, item.slot)
|
||||
{
|
||||
// The player's own in first person: only its companion, drawn after the drain
|
||||
// of the first-person model (`cosmetics_fx_geometry`).
|
||||
if !(own && !self.own_body_in_view()) {
|
||||
let at = fx::Wearer {
|
||||
m,
|
||||
own,
|
||||
key: if own { OWN } else { m.id },
|
||||
base,
|
||||
camera,
|
||||
alpha,
|
||||
light,
|
||||
};
|
||||
bounds = self.cosmetics_fx(&at, item, gpu, bounds, poses, out);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if emoting.is_none() && covered(super::player_hooks::gear(m), item.slot) {
|
||||
continue;
|
||||
}
|
||||
let Some((models, uv)) = self.cosmetics_baked(item) else {
|
||||
continue;
|
||||
};
|
||||
for (bone_of, model) in &models {
|
||||
let known = bones.iter().flatten().find(|(b, _)| b == bone_of);
|
||||
let bone = match known {
|
||||
Some(&(_, matrix)) => matrix,
|
||||
None => {
|
||||
let Some(matrix) =
|
||||
render::part_matrix(body, base, body_poses, bone_of.part(), 0.0)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(free) = bones.iter_mut().find(|b| b.is_none()) {
|
||||
*free = Some((*bone_of, matrix));
|
||||
}
|
||||
matrix
|
||||
}
|
||||
};
|
||||
poses.reset(model);
|
||||
item.animate(ticks, |name, pose| *poses.get(name) = pose);
|
||||
if let (Some((t, w)), Some(e)) = (emoting, &item.emote) {
|
||||
e.pose_props(t, |n| item.rest(n), |n, pose| *poses.get(n) = pose);
|
||||
// Props grow in and shrink out with the emote's blend.
|
||||
let root = poses.get(&model.name);
|
||||
for s in &mut root[6..9] {
|
||||
*s *= w;
|
||||
}
|
||||
}
|
||||
bounds = self.cosmetics_model(model, bone, poses, uv, light, gpu, bounds, out);
|
||||
}
|
||||
}
|
||||
bounds
|
||||
}
|
||||
|
||||
/// An item's models and its texture's corners in the atlas, once its texture is there (it is
|
||||
/// asked for meanwhile).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn cosmetics_baked(
|
||||
&self,
|
||||
item: &Arc<Item>,
|
||||
) -> Option<(Vec<(horizon_cosmetics::Bone, Arc<Part>)>, [f32; 4])> {
|
||||
let frame = self.cosmetics.frame.get();
|
||||
let mut baked = self.cosmetics.baked.borrow_mut();
|
||||
let b = baked
|
||||
.entry(Arc::as_ptr(item) as usize)
|
||||
.or_insert_with(|| Baked::new(item, frame));
|
||||
let Some(rect) = self.gui.atlas.sprites.get(&b.location) else {
|
||||
self.cosmetics.uploads.borrow_mut().push(item.clone());
|
||||
return None;
|
||||
};
|
||||
if b.marked.replace(frame) != frame {
|
||||
self.skin_downloads.borrow_mut().drawn(&b.location);
|
||||
}
|
||||
// The texture fills the top-left of its 64×64 slot.
|
||||
let (w, h) = (
|
||||
item.texture.width as f32 / 64.0,
|
||||
item.texture.height as f32 / 64.0,
|
||||
);
|
||||
let uv = [
|
||||
rect.u0,
|
||||
rect.v0,
|
||||
rect.u0 + (rect.u1 - rect.u0) * w,
|
||||
rect.v0 + (rect.v1 - rect.v0) * h,
|
||||
];
|
||||
Some((b.models.clone(), uv))
|
||||
}
|
||||
|
||||
/// One model at `matrix`, posed by `poses`: on the GPU's skin path when `gpu` (the bounds
|
||||
/// grow to hold it), else its vertices go to `out`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn cosmetics_model(
|
||||
&self,
|
||||
model: &Arc<Part>,
|
||||
matrix: glam::Mat4,
|
||||
poses: &horizon_client::mobs::anim::Poses,
|
||||
uv: [f32; 4],
|
||||
light: u32,
|
||||
gpu: bool,
|
||||
bounds: Option<(glam::Vec3, glam::Vec3)>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> Option<(glam::Vec3, glam::Vec3)> {
|
||||
if gpu {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
let mut frame = self.skin_frame.borrow_mut();
|
||||
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
|
||||
skin.bounds = bounds;
|
||||
let paint = horizon_client::mobs::skin::Paint {
|
||||
uv,
|
||||
scroll: 0.0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
overlay: 0,
|
||||
};
|
||||
if skin.draw(model, matrix, poses, paint) {
|
||||
return skin.bounds;
|
||||
}
|
||||
}
|
||||
horizon_client::mobs::render_model(
|
||||
model,
|
||||
matrix,
|
||||
&|p: &Part| poses.pose(p),
|
||||
uv,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
bounds
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1165,6 +990,13 @@ 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
|
||||
}
|
||||
|
||||
/// What another player wears as `extras` 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(
|
||||
@@ -1182,7 +1014,7 @@ mod tests {
|
||||
let client = horizon_cosmetics::Cosmetics::new(service.clone(), None);
|
||||
let deadline = Instant::now() + patience;
|
||||
let worn = loop {
|
||||
let worn = worn_of(&client, extras, false, || Some(OTHER));
|
||||
let worn = worn_of(&client, &said_of(extras), false, || Some(OTHER));
|
||||
if worn.is_some() || Instant::now() >= deadline {
|
||||
break worn;
|
||||
}
|
||||
@@ -1243,7 +1075,7 @@ mod tests {
|
||||
let uuid = || own_uuid(Some(OFFLINE), solo, extras.cosmetics_solo, || Some(ACCOUNT));
|
||||
let deadline = Instant::now() + patience;
|
||||
let worn = loop {
|
||||
let worn = worn_of(&client, &extras, true, uuid);
|
||||
let worn = worn_of(&client, &said_of(&extras), true, uuid);
|
||||
if worn.is_some() || Instant::now() >= deadline {
|
||||
break worn;
|
||||
}
|
||||
@@ -1398,4 +1230,173 @@ mod tests {
|
||||
service.tick(&mut view);
|
||||
assert!(view.0[3].1.is_none());
|
||||
}
|
||||
|
||||
/// A player model as `player_layers` draws on it: one bone, the sprites it is given; logs
|
||||
/// each draw (mesh, matrix, uv, its root's scale once posed).
|
||||
#[derive(Default)]
|
||||
struct Canvas {
|
||||
pose: Option<Arc<dyn BodyPose>>,
|
||||
sprite: Option<[f32; 4]>,
|
||||
drawn: Vec<(String, [[f32; 4]; 4], [f32; 4], [f32; 3])>,
|
||||
/// The model's matrix (`LEFT_ARM` if None) and how it moves.
|
||||
base: Option<glam::Mat4>,
|
||||
motion: horizon_hooks::players::Motion,
|
||||
/// Each draw's look: see-through alpha and light.
|
||||
looks: Vec<(Option<f32>, horizon_hooks::players::Light)>,
|
||||
}
|
||||
|
||||
const LEFT_ARM: [[f32; 4]; 4] = [[2.0, 0.0, 0.0, 0.0]; 4];
|
||||
|
||||
impl PlayerDraw for Canvas {
|
||||
fn pass(&self) -> horizon_hooks::players::Pass {
|
||||
horizon_hooks::players::Pass::World
|
||||
}
|
||||
fn ticks(&self) -> f32 {
|
||||
102.0
|
||||
}
|
||||
fn base(&self) -> [[f32; 4]; 4] {
|
||||
self.base.map_or(LEFT_ARM, |b| b.to_cols_array_2d())
|
||||
}
|
||||
fn motion(&self) -> horizon_hooks::players::Motion {
|
||||
self.motion
|
||||
}
|
||||
fn view(&self) -> horizon_hooks::players::View {
|
||||
Default::default()
|
||||
}
|
||||
fn particles(
|
||||
&mut self,
|
||||
_: [f32; 4],
|
||||
_: &[horizon_hooks::players::Particle],
|
||||
_: horizon_hooks::players::Light,
|
||||
) {
|
||||
}
|
||||
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
|
||||
(bone == Bone::LeftArm).then_some(LEFT_ARM)
|
||||
}
|
||||
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>> {
|
||||
self.pose.as_ref()
|
||||
}
|
||||
fn sprite(&self, _: u64) -> Option<[f32; 4]> {
|
||||
self.sprite
|
||||
}
|
||||
fn draw(
|
||||
&mut self,
|
||||
mesh: &Arc<Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
look: Look,
|
||||
pose: &mut dyn FnMut(&mut dyn horizon_hooks::players::PartPoses),
|
||||
) {
|
||||
struct Rest(std::collections::HashMap<String, [f32; 9]>);
|
||||
impl horizon_hooks::players::PartPoses for Rest {
|
||||
fn pose(&mut self, name: &str) -> &mut [f32; 9] {
|
||||
self.0.entry(name.to_owned()).or_insert([0.0; 9])
|
||||
}
|
||||
}
|
||||
let mut rest = Rest([(mesh.name.clone(), mesh.pose)].into());
|
||||
pose(&mut rest);
|
||||
let root = rest.0[&mesh.name];
|
||||
self.looks.push((look.see_through, look.light));
|
||||
self.drawn.push((
|
||||
mesh.name.clone(),
|
||||
matrix,
|
||||
look.uv,
|
||||
[root[6], root[7], root[8]],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_emotes_prop_goes_on_its_bone_once_its_texture_is_in_scaled_by_the_blend() {
|
||||
let pop = CROWN
|
||||
.replace(
|
||||
r#""slot": "head", "bone": "head""#,
|
||||
r#""slot": "emote", "bone": "left_arm""#,
|
||||
)
|
||||
.replace(
|
||||
r#""uv": [0, 0]}]}]}"#,
|
||||
r#""uv": [0, 0]}]}],
|
||||
"emote": {"length": 40, "blend": 4, "tracks": [
|
||||
{"part": "right_arm", "keys": [{"t": 0}, {"t": 40}]}]}}"#,
|
||||
);
|
||||
let item = Arc::new(Item::parse(pop.as_bytes()).unwrap());
|
||||
let playing = horizon_cosmetics::Playing {
|
||||
item: item.clone(),
|
||||
start: 100.0,
|
||||
stop: None,
|
||||
};
|
||||
let (_, weight) = playing.at(102.0).unwrap();
|
||||
assert!(weight > 0.0 && weight < 1.0, "{weight}");
|
||||
let player = PlayerModel::new(9, Kind::Player, Some(5), 102, false);
|
||||
let mut view = View(vec![(player, None)]);
|
||||
emotes::set_model_playing(&mut view, 9, Some(playing));
|
||||
let mut canvas = Canvas {
|
||||
pose: view.0[0].1.clone(),
|
||||
..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()
|
||||
};
|
||||
// Its texture not in the atlas yet: nothing drawn, the sprite asked for.
|
||||
seat.player_layers(&player, &mut canvas);
|
||||
assert!(canvas.drawn.is_empty());
|
||||
assert_eq!(seat.wanted.len(), 1);
|
||||
canvas.sprite = Some([0.0, 0.0, 0.5, 0.5]);
|
||||
seat.player_layers(&player, &mut canvas);
|
||||
// A 16×16 texture: the slot's top left quarter of it.
|
||||
let quarter = 0.5 * 16.0 / 64.0;
|
||||
assert_eq!(canvas.drawn.len(), 1);
|
||||
let (_, matrix, uv, scale) = &canvas.drawn[0];
|
||||
assert_eq!((matrix, uv), (&LEFT_ARM, &[0.0, 0.0, quarter, quarter]));
|
||||
for s in scale {
|
||||
assert!((s - weight).abs() < 1e-6, "{scale:?} by {weight}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_aura_goes_see_through_at_the_feet_unless_swimming_far_or_hidden() {
|
||||
use horizon_hooks::players::{Light, Motion};
|
||||
let ring = CROWN
|
||||
.replace(r#""slot": "head", "bone": "head""#, r#""slot": "aura""#)
|
||||
// At the feet: an aura reaches at most 6 pixels above them.
|
||||
.replace(r#""attach": {"offset": [0, -18, 0]},"#, "")
|
||||
.replace(
|
||||
r#""uv": [0, 0]}]}]}"#,
|
||||
r#""uv": [0, 0]}]}], "aura": {"at": "feet", "alpha": 0.5}}"#,
|
||||
);
|
||||
let ring = Arc::new(Item::parse(ring.as_bytes()).unwrap());
|
||||
let at = glam::Vec3::new(3.0, 0.0, 4.0);
|
||||
let mut canvas = Canvas {
|
||||
sprite: Some([0.0, 0.0, 0.5, 0.5]),
|
||||
base: Some(glam::Mat4::from_translation(at)),
|
||||
..Canvas::default()
|
||||
};
|
||||
let other = PlayerModel::new(9, Kind::Player, Some(5), 102, false);
|
||||
let own = PlayerModel::new(1, Kind::Own, None, 102, false);
|
||||
let mut seat = Seat::default();
|
||||
seat.fx(&other, &ring, &mut canvas);
|
||||
// Half see-through, lit as the wearer, 24 pixels under the neck.
|
||||
assert_eq!(canvas.looks, [(Some(0.5), Light::Model)]);
|
||||
let feet = glam::Mat4::from_cols_array_2d(&canvas.drawn[0].1).w_axis;
|
||||
assert_eq!(feet.truncate(), at + glam::Vec3::new(0.0, 1.5, 0.0));
|
||||
// Swimming, it would tip over with the body: none.
|
||||
canvas.motion = Motion::default().poses(false, false, false, true);
|
||||
seat.fx(&other, &ring, &mut canvas);
|
||||
canvas.motion = Motion::default();
|
||||
// Past 48 blocks: none.
|
||||
canvas.base = Some(glam::Mat4::from_translation(glam::Vec3::X * 49.0));
|
||||
seat.fx(&other, &ring, &mut canvas);
|
||||
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.fx(&other, &ring, &mut canvas);
|
||||
assert_eq!(canvas.looks.len(), 1);
|
||||
seat.fx(&own, &ring, &mut canvas);
|
||||
assert_eq!(canvas.looks.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -71,11 +71,6 @@ pub(in crate::game) fn pose_playing(pose: Option<&Arc<dyn BodyPose>>) -> Option<
|
||||
any.downcast_ref::<EmotePose>().map(|e| &e.0)
|
||||
}
|
||||
|
||||
/// The emote `m` plays, if any.
|
||||
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
|
||||
pose_playing(m.emote.as_ref())
|
||||
}
|
||||
|
||||
/// The pose that plays `next` where `now` is laid, unless it plays that already (None: keep
|
||||
/// it; a new one allocates).
|
||||
fn replaced(
|
||||
|
||||
@@ -1,47 +1,78 @@
|
||||
//! Companions, trails and auras ([docs/cosmetics.md](../../../../../docs/cosmetics.md)): worn
|
||||
//! like the other cosmetics but on no bone. A companion floats beside its wearer and follows with
|
||||
//! a lag (`horizon_cosmetics::follow`), drawn with the wearer; a trail's particles
|
||||
//! (`horizon_cosmetics::trail`) and an aura blend, so they go after every player in one
|
||||
//! see-through span (`cosmetics_fx_geometry`). Nothing of them is an entity, collides or is sent
|
||||
//! to a server.
|
||||
//! (`horizon_cosmetics::trail`) and an aura blend, so they are drawn see-through after every
|
||||
//! player (`PlayerDraw::particles`, `Look::see_through`). Nothing of them is an entity, collides
|
||||
//! or is sent to a server.
|
||||
|
||||
use super::{FULL_LIGHT, OWN};
|
||||
use crate::game::Game;
|
||||
use super::Seat;
|
||||
use glam::{DVec3, Mat4, Vec3};
|
||||
use horizon_client::mesher::Vertex;
|
||||
use horizon_client::mobs::anim::Poses;
|
||||
use horizon_client::mobs::{Mob, Part};
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_cosmetics::follow::{self, Follow};
|
||||
use horizon_cosmetics::trail::Emitter;
|
||||
use horizon_cosmetics::trail::{Budget, Emitter};
|
||||
use horizon_cosmetics::{Item, Slot};
|
||||
use horizon_hooks::players::{Kind, Light, Look, Particle, Pass, PlayerDraw, PlayerModel};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
/// How far from the camera (blocks) a wearer's companion, trail and aura are drawn.
|
||||
const FAR: f32 = 48.0;
|
||||
|
||||
/// Who wears it, as drawn this frame.
|
||||
pub(super) struct Wearer<'a> {
|
||||
pub m: &'a Mob,
|
||||
/// The player's own model.
|
||||
pub own: bool,
|
||||
/// `OWN`, or the entity id.
|
||||
pub key: u32,
|
||||
/// The wearer's model matrix (`render::base_transform`), relative to `camera`.
|
||||
pub base: Mat4,
|
||||
pub camera: DVec3,
|
||||
pub alpha: f32,
|
||||
pub light: u32,
|
||||
/// The key of the player's own model in `Fx`'s maps (others go by entity id).
|
||||
const OWN: u32 = u32::MAX;
|
||||
|
||||
/// What companions and trails keep between frames, per wearer (marked with the frame they were
|
||||
/// last drawn in; the others are dropped).
|
||||
#[derive(Default)]
|
||||
pub(super) struct Fx {
|
||||
companions: FxHashMap<u32, (Follow, u32)>,
|
||||
trails: FxHashMap<u32, (Emitter, u32)>,
|
||||
budget: Budget,
|
||||
clock: Option<(Instant, Instant)>,
|
||||
/// Ticks since the first frame, and the last frame's length in seconds.
|
||||
now: f64,
|
||||
dt: f32,
|
||||
frame: u32,
|
||||
/// Kept so a frame of trails allocates none.
|
||||
particles: Vec<Particle>,
|
||||
}
|
||||
|
||||
/// `m`'s head yaw this frame (degrees).
|
||||
fn head_yaw(m: &Mob, alpha: f32) -> f32 {
|
||||
let turn = horizon_client::mobs::living::wrap(m.head_yaw - m.previous_head_yaw);
|
||||
m.previous_head_yaw + turn * alpha
|
||||
}
|
||||
impl Fx {
|
||||
/// A new frame: the clock moves on and what no frame drew since the last one goes.
|
||||
pub(super) fn frame(&mut self) {
|
||||
self.frame = self.frame.wrapping_add(1);
|
||||
let frame = self.frame;
|
||||
let now = Instant::now();
|
||||
let (start, last) = self.clock.get_or_insert((now, now));
|
||||
self.dt = now.duration_since(*last).as_secs_f32().min(0.25);
|
||||
self.now = now.duration_since(*start).as_secs_f64() * 20.0;
|
||||
*last = now;
|
||||
let seen = |mark: u32| frame.wrapping_sub(mark) <= 1;
|
||||
self.companions.retain(|_, (_, mark)| seen(*mark));
|
||||
let budget = &mut self.budget;
|
||||
self.trails.retain(|_, (e, mark)| {
|
||||
let keep = seen(*mark);
|
||||
if !keep {
|
||||
e.clear(budget);
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether `m` lies in a bed.
|
||||
fn asleep(m: &Mob) -> bool {
|
||||
m.extra.avatar_pose == horizon_client::pose::Pose::Sleeping
|
||||
/// The companion of wearer `key` moved toward `target` (once a frame, however many times it
|
||||
/// is drawn).
|
||||
fn follow(&mut self, key: u32, target: DVec3, yaw: f32, lag: f32) -> Follow {
|
||||
let (frame, dt) = (self.frame, self.dt);
|
||||
let (f, mark) = self
|
||||
.companions
|
||||
.entry(key)
|
||||
.or_insert((Follow::at(target, yaw), frame));
|
||||
if *mark != frame {
|
||||
f.step(target, yaw, lag, dt);
|
||||
*mark = frame;
|
||||
}
|
||||
*f
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a companion floats: `at` (relative to the camera) facing `yaw`, at the wearer's scale,
|
||||
@@ -52,270 +83,144 @@ fn companion_matrix(at: Vec3, yaw: f32, scale: f32) -> Mat4 {
|
||||
* Mat4::from_scale(Vec3::new(-scale, -scale, scale))
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A companion, trail or aura `at` wears: a companion goes where the wearer's other items go
|
||||
/// (`gpu` and `out`, `bounds` grown), trails and auras to the frame's see-through vertices.
|
||||
/// Other players' trails and auras stay hidden with "Hide other players' trails and auras".
|
||||
pub(super) fn cosmetics_fx(
|
||||
&self,
|
||||
at: &Wearer,
|
||||
item: &Arc<Item>,
|
||||
gpu: bool,
|
||||
bounds: Option<(Vec3, Vec3)>,
|
||||
poses: &mut Poses,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> Option<(Vec3, Vec3)> {
|
||||
if !at.own && item.slot.is_effect() && self.settings.extras.cosmetics_hide_fx {
|
||||
return bounds;
|
||||
/// Full light for what glows, else `otherwise`.
|
||||
fn glowing(glow: bool, otherwise: Light) -> Light {
|
||||
if glow { Light::Full } else { otherwise }
|
||||
}
|
||||
|
||||
impl Seat {
|
||||
/// A companion, trail or aura `m` wears, around its model as `draw` draws it. Other players'
|
||||
/// 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 {
|
||||
return;
|
||||
}
|
||||
// Past `FAR` a companion, trail or aura is a few pixels across for the cost of a model:
|
||||
// not drawn, and a trail that isn't drawn doesn't spawn (its particles go back to the
|
||||
// budget).
|
||||
if at.base.w_axis.truncate().length_squared() > FAR * FAR {
|
||||
return bounds;
|
||||
let base = Mat4::from_cols_array_2d(&draw.base());
|
||||
if base.w_axis.truncate().length_squared() > FAR * FAR {
|
||||
return;
|
||||
}
|
||||
let Some(uv) = self.uv(item, |id| draw.sprite(id)) else {
|
||||
return;
|
||||
};
|
||||
let key = if own { OWN } else { m.id };
|
||||
match item.slot {
|
||||
Slot::Companion => {
|
||||
let Some((model, uv, matrix)) = self.companion_at(at, item, None) else {
|
||||
return bounds;
|
||||
};
|
||||
self.pose_companion(item, at.m, at.alpha, poses, &model);
|
||||
let glow = item.companion.as_ref().is_some_and(|c| c.glow);
|
||||
let light = if glow { FULL_LIGHT } else { at.light };
|
||||
self.cosmetics_model(&model, matrix, poses, uv, light, gpu, bounds, out)
|
||||
}
|
||||
Slot::Trail => {
|
||||
self.trail(at, item);
|
||||
bounds
|
||||
}
|
||||
Slot::Aura => {
|
||||
self.aura(at, item, poses);
|
||||
bounds
|
||||
}
|
||||
_ => bounds,
|
||||
Slot::Companion => self.companion(key, item, uv, base, draw),
|
||||
Slot::Trail => self.trail(key, item, uv, draw),
|
||||
Slot::Aura => self.aura(item, uv, base, draw),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The companion's model, texture and matrix this frame, its follow moved on once a frame.
|
||||
/// `first_person`: the camera's forward and left, for the player's own in first person: the
|
||||
/// companion keeps out of the middle of the view.
|
||||
fn companion_at(
|
||||
&self,
|
||||
at: &Wearer,
|
||||
/// The companion where it floats, its follow moved on once a frame. In first person it keeps
|
||||
/// out of the middle of the view, lit where it floats.
|
||||
fn companion(
|
||||
&mut self,
|
||||
key: u32,
|
||||
item: &Arc<Item>,
|
||||
first_person: Option<(Vec3, Vec3)>,
|
||||
) -> Option<(Arc<Part>, [f32; 4], Mat4)> {
|
||||
let c = item.companion.as_ref()?;
|
||||
let (models, uv) = self.cosmetics_baked(item)?;
|
||||
let model = models.first()?.1.clone();
|
||||
let scale = at.base.x_axis.truncate().length();
|
||||
let target = at.base.transform_point3(Vec3::from(c.anchor) / 16.0);
|
||||
let head = at
|
||||
.base
|
||||
.transform_point3(Vec3::from(horizon_cosmetics::HEAD) / 16.0);
|
||||
let yaw = head_yaw(at.m, at.alpha);
|
||||
let follow = self.companion_follow(at.key, at.camera + target.as_dvec3(), yaw, c.lag);
|
||||
uv: [f32; 4],
|
||||
base: Mat4,
|
||||
draw: &mut dyn PlayerDraw,
|
||||
) {
|
||||
let Some(c) = item.companion.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some((_, mesh)) = self.kept(item).meshes.first().cloned() else {
|
||||
return;
|
||||
};
|
||||
let first_person = draw.pass() == Pass::FirstPerson;
|
||||
let (motion, view) = (draw.motion(), draw.view());
|
||||
let camera = DVec3::from(view.camera);
|
||||
let scale = base.x_axis.truncate().length();
|
||||
let target = base.transform_point3(Vec3::from(c.anchor) / 16.0);
|
||||
let head = base.transform_point3(Vec3::from(horizon_cosmetics::HEAD) / 16.0);
|
||||
let follow = self
|
||||
.fx
|
||||
.follow(key, camera + target.as_dvec3(), motion.head_yaw, c.lag);
|
||||
let clearance = (horizon_cosmetics::HEAD_CLEARANCE + c.reach) / 16.0 * scale;
|
||||
let mut rel = (follow.pos - at.camera).as_vec3();
|
||||
if let Some((forward, left)) = first_person {
|
||||
rel = follow::out_of_view(rel, forward, left);
|
||||
let mut rel = (follow.pos - camera).as_vec3();
|
||||
if first_person {
|
||||
rel = follow::out_of_view(rel, Vec3::from(view.forward), Vec3::from(view.left));
|
||||
}
|
||||
rel = follow::clear_of(rel, head, clearance);
|
||||
Some((model, uv, companion_matrix(rel, follow.yaw, scale)))
|
||||
}
|
||||
|
||||
/// The companion of wearer `key` moved toward `target` (once a frame, however many times it
|
||||
/// is drawn).
|
||||
fn companion_follow(&self, key: u32, target: DVec3, yaw: f32, lag: f32) -> Follow {
|
||||
let frame = self.cosmetics.frame.get();
|
||||
let mut fx = self.cosmetics.fx.borrow_mut();
|
||||
let dt = fx.dt;
|
||||
let (f, mark) = fx
|
||||
.companions
|
||||
.entry(key)
|
||||
.or_insert((Follow::at(target, yaw), frame));
|
||||
if *mark != frame {
|
||||
f.step(target, yaw, lag, dt);
|
||||
*mark = frame;
|
||||
}
|
||||
*f
|
||||
}
|
||||
|
||||
/// The companion's animations, and its `look` part turned with the wearer's pitch.
|
||||
fn pose_companion(&self, item: &Item, m: &Mob, alpha: f32, poses: &mut Poses, model: &Part) {
|
||||
poses.reset(model);
|
||||
item.animate(m.age as f32 + alpha, |name, pose| *poses.get(name) = pose);
|
||||
if let Some(look) = item.companion.as_ref().and_then(|c| c.look.as_deref()) {
|
||||
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * alpha;
|
||||
poses.get(look)[3] += (pitch * 0.6).clamp(-30.0, 30.0).to_radians();
|
||||
}
|
||||
let matrix = companion_matrix(rel, follow.yaw, scale).to_cols_array_2d();
|
||||
let near = if first_person {
|
||||
Light::AtPlace
|
||||
} else {
|
||||
Light::Model
|
||||
};
|
||||
let look = Look::new(uv).lit(glowing(c.glow, near));
|
||||
let ticks = draw.ticks();
|
||||
// Its animations, and its `look` part turned with the wearer's pitch.
|
||||
let pitch = (motion.head_pitch * 0.6).clamp(-30.0, 30.0).to_radians();
|
||||
draw.draw(&mesh, matrix, look, &mut |p| {
|
||||
item.animate(ticks, |name, pose| *p.pose(name) = pose);
|
||||
if let Some(part) = c.look.as_deref() {
|
||||
p.pose(part)[3] += pitch;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The trail's particles: spawned once a frame by the distance walked (not while sneaking or
|
||||
/// asleep), facing the camera.
|
||||
fn trail(&self, at: &Wearer, item: &Arc<Item>) {
|
||||
fn trail(&mut self, key: u32, item: &Arc<Item>, uv: [f32; 4], draw: &mut dyn PlayerDraw) {
|
||||
let Some(t) = &item.trail else {
|
||||
return;
|
||||
};
|
||||
let Some((_, uv)) = self.cosmetics_baked(item) else {
|
||||
return;
|
||||
};
|
||||
let m = at.m;
|
||||
let a = f64::from(at.alpha);
|
||||
let feet = DVec3::new(
|
||||
m.previous.x + (m.position.x - m.previous.x) * a,
|
||||
m.previous.y + (m.position.y - m.previous.y) * a,
|
||||
m.previous.z + (m.position.z - m.previous.z) * a,
|
||||
);
|
||||
let moves = !m.extra.crouching && !asleep(m);
|
||||
let q = self.camera_orientation();
|
||||
let (right, up) = (q * Vec3::X, q * Vec3::Y);
|
||||
let light = if t.glow { FULL_LIGHT } else { at.light };
|
||||
let frame = self.cosmetics.frame.get();
|
||||
let mut fx = self.cosmetics.fx.borrow_mut();
|
||||
let super::Fx {
|
||||
let (motion, view) = (draw.motion(), draw.view());
|
||||
let moves = !motion.crouching && !motion.asleep;
|
||||
let Fx {
|
||||
trails,
|
||||
budget,
|
||||
now,
|
||||
clear,
|
||||
frame,
|
||||
particles,
|
||||
..
|
||||
} = &mut *fx;
|
||||
} = &mut self.fx;
|
||||
let (e, mark) = trails
|
||||
.entry(at.key)
|
||||
.entry(key)
|
||||
.or_insert_with(|| (Emitter::default(), frame.wrapping_sub(1)));
|
||||
if *mark != frame {
|
||||
e.step(t, feet, moves, *now, budget);
|
||||
*mark = frame;
|
||||
}
|
||||
for quad in e.quads(t, *now, at.camera, right, up) {
|
||||
let color = ((quad.alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
for (corner, [u, v]) in quad.corners.iter().zip(quad.uv) {
|
||||
clear.push(Vertex {
|
||||
pos: corner.to_array(),
|
||||
uv: [uv[0] + (uv[2] - uv[0]) * u, uv[1] + (uv[3] - uv[1]) * v],
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
// Unlit: a particle keeps its colour whichever way it faces.
|
||||
normal: [0, 0, 0, 0],
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
if *mark != *frame {
|
||||
e.step(t, DVec3::from(motion.feet), moves, *now, budget);
|
||||
*mark = *frame;
|
||||
}
|
||||
let (camera, right, up) = (
|
||||
DVec3::from(view.camera),
|
||||
Vec3::from(view.right),
|
||||
Vec3::from(view.up),
|
||||
);
|
||||
particles.clear();
|
||||
particles.extend(
|
||||
e.quads(t, *now, camera, right, up)
|
||||
.map(|q| Particle::new(q.corners.map(|c| c.to_array()), q.uv, q.alpha)),
|
||||
);
|
||||
draw.particles(uv, particles, glowing(t.glow, Light::Model));
|
||||
}
|
||||
|
||||
/// The aura at the wearer's feet, see-through. None while they fly, swim or sleep: it would
|
||||
/// tip over with the body.
|
||||
fn aura(&self, at: &Wearer, item: &Arc<Item>, poses: &mut Poses) {
|
||||
let (m, Some(a)) = (at.m, &item.aura) else {
|
||||
fn aura(&mut self, item: &Arc<Item>, uv: [f32; 4], base: Mat4, draw: &mut dyn PlayerDraw) {
|
||||
let Some(a) = &item.aura else {
|
||||
return;
|
||||
};
|
||||
if m.extra.fall_flying || asleep(m) || m.extra.swim.amount(at.alpha) > 0.0 {
|
||||
let motion = draw.motion();
|
||||
if motion.flying || motion.asleep || motion.swimming {
|
||||
return;
|
||||
}
|
||||
let Some((models, uv)) = self.cosmetics_baked(item) else {
|
||||
return;
|
||||
};
|
||||
let Some((_, model)) = models.first() else {
|
||||
let Some((_, mesh)) = self.kept(item).meshes.first().cloned() else {
|
||||
return;
|
||||
};
|
||||
// The model's origin is the middle of the feet: 24 pixels under the neck.
|
||||
let matrix = at.base * Mat4::from_translation(Vec3::new(0.0, 24.0 / 16.0, 0.0));
|
||||
poses.reset(model);
|
||||
item.animate(m.age as f32 + at.alpha, |name, pose| {
|
||||
*poses.get(name) = pose
|
||||
let matrix = base * Mat4::from_translation(Vec3::new(0.0, 24.0 / 16.0, 0.0));
|
||||
let look = Look::new(uv)
|
||||
.lit(glowing(a.glow, Light::Model))
|
||||
.see_through(a.alpha);
|
||||
let ticks = draw.ticks();
|
||||
draw.draw(&mesh, matrix.to_cols_array_2d(), look, &mut |p| {
|
||||
item.animate(ticks, |name, pose| *p.pose(name) = pose)
|
||||
});
|
||||
let light = if a.glow { FULL_LIGHT } else { at.light };
|
||||
let mut fx = self.cosmetics.fx.borrow_mut();
|
||||
let start = fx.clear.len();
|
||||
horizon_client::mobs::render_model(
|
||||
model,
|
||||
matrix,
|
||||
&|p: &Part| poses.pose(p),
|
||||
uv,
|
||||
light,
|
||||
0,
|
||||
&mut fx.clear,
|
||||
);
|
||||
let color = ((a.alpha * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
for v in &mut fx.clear[start..] {
|
||||
v.color = color;
|
||||
// Seen through, a face turned away would add to the near one (and a card's two
|
||||
// faces would double it): `entity.wgsl` drops back faces, so it is `alpha` at most.
|
||||
v.normal[3] = horizon_client::mesher::SHADED_CULL_W;
|
||||
}
|
||||
}
|
||||
|
||||
/// After every player: the player's own companion in first person (out of the middle of the
|
||||
/// view, after the first-person model went to the shadow pass only), then the frame's trails
|
||||
/// and auras in one see-through span.
|
||||
pub(in crate::game) fn cosmetics_fx_geometry(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if !super::enabled() {
|
||||
return;
|
||||
}
|
||||
if !self.own_body_in_view()
|
||||
&& let Some(m) = self.player_model.as_ref()
|
||||
{
|
||||
self.mark_entity(out.len(), "minecraft:player");
|
||||
self.own_companion_first_person(m, camera, alpha as f32, out);
|
||||
}
|
||||
let fx = self.cosmetics.fx.borrow();
|
||||
if !fx.clear.is_empty() {
|
||||
self.mark_translucent_entity(out.len(), "minecraft:player");
|
||||
out.extend_from_slice(&fx.clear);
|
||||
}
|
||||
}
|
||||
|
||||
fn own_companion_first_person(
|
||||
&self,
|
||||
m: &Mob,
|
||||
camera: DVec3,
|
||||
alpha: f32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if horizon_client::mobs::trader::is_invisible(m) || self.own_invisible() {
|
||||
return;
|
||||
}
|
||||
let Some(worn) = self.worn(m, true) else {
|
||||
return;
|
||||
};
|
||||
let Some(item) = worn.iter().find(|i| i.slot == Slot::Companion) else {
|
||||
return;
|
||||
};
|
||||
let at = Wearer {
|
||||
m,
|
||||
own: true,
|
||||
key: OWN,
|
||||
base: horizon_client::mobs::render::base_transform(m, alpha, camera),
|
||||
camera,
|
||||
alpha,
|
||||
light: FULL_LIGHT,
|
||||
};
|
||||
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
|
||||
let forward = Vec3::new(
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
);
|
||||
let left = Vec3::new(yaw.cos(), 0.0, yaw.sin());
|
||||
let Some((model, uv, matrix)) = self.companion_at(&at, item, Some((forward, left))) else {
|
||||
return;
|
||||
};
|
||||
// Lit where it floats.
|
||||
let p = (camera + matrix.w_axis.truncate().as_dvec3()).floor();
|
||||
let light = self.world.light(p.x as i32, p.y as i32, p.z as i32);
|
||||
let mut light = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
if item.companion.as_ref().is_some_and(|c| c.glow) {
|
||||
light = FULL_LIGHT;
|
||||
}
|
||||
let mut poses = self.cosmetics.poses.borrow_mut();
|
||||
let poses = &mut poses.1;
|
||||
self.pose_companion(item, m, alpha, poses, &model);
|
||||
self.cosmetics_model(&model, matrix, poses, uv, light, false, None, out);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//! What the player is doing, for Discord Rich Presence (`horizon_client::discord_presence`).
|
||||
use super::*;
|
||||
use horizon_client::discord_presence::{self as presence, Dimension, Play, Status};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Each frame: only a change reaches the presence thread.
|
||||
pub(super) fn update_discord_presence(&self) {
|
||||
// One Discord status per computer: P1's, not a splitscreen player's.
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
presence::set(self.discord_status());
|
||||
}
|
||||
fn discord_status(&self) -> Status {
|
||||
if !self.settings.extras.discord_presence {
|
||||
return Status::Off;
|
||||
}
|
||||
if self.title.is_some() {
|
||||
return Status::Menus;
|
||||
}
|
||||
let play = if let Some(host) = &self.lan {
|
||||
Play::Hosting {
|
||||
players: 1 + host.hub.guests.len(),
|
||||
max: horizon_client::lan::MAX_PLAYERS,
|
||||
}
|
||||
} else if let Some(server) = &self.integrated {
|
||||
match (server.published(), &self.remote) {
|
||||
(Some(_), Some(remote)) => Play::Hosting {
|
||||
players: remote.player_count(),
|
||||
max: horizon_client::lan::MAX_PLAYERS,
|
||||
},
|
||||
_ => Play::Singleplayer,
|
||||
}
|
||||
} else if self.remote.is_some() {
|
||||
Play::Multiplayer
|
||||
} else {
|
||||
Play::Singleplayer
|
||||
};
|
||||
Status::World {
|
||||
play,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => Dimension::Overworld,
|
||||
Dim::Nether => Dimension::Nether,
|
||||
Dim::End => Dimension::End,
|
||||
},
|
||||
mode: self.mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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_to_render();
|
||||
let shown = self.hands_shown();
|
||||
let used_hand = self.used_hand();
|
||||
let spinning = self
|
||||
.player
|
||||
|
||||
@@ -7,8 +7,9 @@ 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
|
||||
enabled && first_person && !hide_gui && !panorama && !overlay_holds_view
|
||||
}
|
||||
|
||||
/// GameRenderer.shouldRenderBlockOutline, and only over a level. A panorama frame (the title
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
//! 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 and splitscreen's join strip read it.
|
||||
//! 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.
|
||||
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.
|
||||
@@ -36,4 +52,50 @@ 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,6 +370,11 @@ 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,6 +21,43 @@ 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>,
|
||||
|
||||
@@ -34,8 +34,6 @@ pub(super) fn run_event_loop(mut game: Game<'_>) -> Result<()> {
|
||||
narrator::stop();
|
||||
leave_fullscreen_after(&result, &mut game.settings);
|
||||
result?;
|
||||
#[cfg(feature = "discord")]
|
||||
horizon_client::discord_presence::stop();
|
||||
write_report(&game)?;
|
||||
if let Some(e) = game.error.take() {
|
||||
return Err(e);
|
||||
@@ -98,9 +96,8 @@ impl Loading {
|
||||
.map_or([1280, 720], |o| o.window_size)
|
||||
.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
);
|
||||
let attributes =
|
||||
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
|
||||
// Wayland names the window by its app id, which the horizon.desktop entry matches.
|
||||
#[cfg(target_os = "linux")]
|
||||
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
|
||||
@@ -130,22 +127,25 @@ impl Loading {
|
||||
// redraw the same design when they start. GDI is unsafe:
|
||||
// horizon-threads holds it (this crate forbids unsafe code).
|
||||
#[cfg(target_os = "windows")]
|
||||
let boot_paint_stop = hwnd(&window).map(|hwnd| {
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let repaint_stop = stop.clone();
|
||||
std::thread::spawn(move || {
|
||||
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
horizon_threads::boot_paint::paint(
|
||||
hwnd,
|
||||
horizon_theme::fonts::GEIST_SEMIBOLD,
|
||||
horizon_theme::fonts::GEIST_MONO,
|
||||
&horizon_client::logging::recent_lines(),
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
// In the fonts given to `menu_style`; with none, the window stays as it is.
|
||||
let boot_paint_stop = hwnd(&window)
|
||||
.zip(horizon_client::menu_style::get().fonts)
|
||||
.map(|(hwnd, fonts)| {
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let repaint_stop = stop.clone();
|
||||
std::thread::spawn(move || {
|
||||
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
horizon_threads::boot_paint::paint(
|
||||
hwnd,
|
||||
fonts.semibold,
|
||||
fonts.mono,
|
||||
&horizon_client::logging::recent_lines(),
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
});
|
||||
stop
|
||||
});
|
||||
stop
|
||||
});
|
||||
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
|
||||
let boot_step = startup::step("renderer.boot");
|
||||
match loading_renderer(
|
||||
|
||||
@@ -930,6 +930,7 @@ 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);
|
||||
});
|
||||
|
||||
@@ -93,10 +93,9 @@ impl ApplicationHandler for Missing {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
)
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
|
||||
let attributes =
|
||||
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"))
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
|
||||
let made = event_loop
|
||||
.create_window(attributes)
|
||||
.map_err(Into::into)
|
||||
|
||||
@@ -17,6 +17,7 @@ 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;
|
||||
@@ -26,7 +27,8 @@ 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",
|
||||
"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 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)",
|
||||
@@ -86,6 +88,31 @@ 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.
|
||||
@@ -111,13 +138,37 @@ 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);
|
||||
|
||||
@@ -185,6 +236,9 @@ 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()
|
||||
}
|
||||
@@ -225,10 +279,18 @@ 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.
|
||||
pub(super) fn feed_from(&mut self, source: Option<PadId>, state: PadState) {
|
||||
/// `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;
|
||||
if self.feed_source != source {
|
||||
self.feed_source = source;
|
||||
self.menu.armed = None;
|
||||
self.radial.cancel();
|
||||
self.menu.stop_craft();
|
||||
}
|
||||
self.feed = Some(state);
|
||||
@@ -246,19 +308,25 @@ 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();
|
||||
}
|
||||
|
||||
/// 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)
|
||||
/// 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))
|
||||
})
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
@@ -291,7 +359,7 @@ impl Game<'_> {
|
||||
screen: self.key_screen(),
|
||||
focus_navigation: self.pad_navigation_mode(),
|
||||
focused: self.focused,
|
||||
osk: self.pad.menu.osk.is_some(),
|
||||
osk: self.pad_keyboard_captures(),
|
||||
dialog: self.dialog_open() || self.code_of_conduct.is_some(),
|
||||
enabled: self.settings.controller.enabled,
|
||||
}
|
||||
@@ -311,6 +379,10 @@ 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.
|
||||
@@ -364,6 +436,7 @@ 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) {
|
||||
@@ -377,6 +450,7 @@ 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
|
||||
@@ -390,14 +464,24 @@ 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) {
|
||||
self.pad_button_logged(event_loop, button, false);
|
||||
due.push(button);
|
||||
} else {
|
||||
self.pad.late_releases.push(button);
|
||||
}
|
||||
}
|
||||
for (button, down) in edges {
|
||||
// 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),
|
||||
};
|
||||
self.pad_edge(event_loop, button, down);
|
||||
let context = self.pad_context();
|
||||
if let Some(old) = self.pad.context.filter(|old| *old != context) {
|
||||
@@ -430,10 +514,12 @@ 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, nor a cancelled trigger
|
||||
// change the recipe book's category as it comes up.
|
||||
// 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.
|
||||
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)
|
||||
@@ -501,7 +587,19 @@ 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);
|
||||
@@ -517,7 +615,16 @@ impl Game<'_> {
|
||||
// Releases always reach the mappings, so nothing stays held when a
|
||||
// screen opened in between.
|
||||
let actions = if down {
|
||||
let actions = self.settings.controller.actions_of(button);
|
||||
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);
|
||||
}
|
||||
}
|
||||
self.pad.actions[button as usize] = actions.clone();
|
||||
actions
|
||||
} else {
|
||||
@@ -537,6 +644,14 @@ 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);
|
||||
@@ -588,6 +703,13 @@ 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;
|
||||
@@ -618,7 +740,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() {
|
||||
if self.pad_in_menu() || self.screen_over_playing() || self.pad_radial_captures() {
|
||||
return;
|
||||
}
|
||||
let interval = Duration::from_secs_f32(self.settings.controller.repeat_interval);
|
||||
@@ -673,7 +795,11 @@ 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() {
|
||||
if !settings.enabled
|
||||
|| self.pad_in_menu()
|
||||
|| !self.pad_focused()
|
||||
|| (self.pad_radial_captures() && self.pad_radial_selects(0))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
gamepad::move_vector(self.pad.state.left, settings.move_deadzone)
|
||||
@@ -682,7 +808,12 @@ 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() {
|
||||
if !settings.enabled
|
||||
|| self.pad_in_menu()
|
||||
|| self.player.is_none()
|
||||
|| !self.pad_focused()
|
||||
|| (self.pad_radial_captures() && self.pad_radial_selects(1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// A long frame (a hitch) must not spin the view.
|
||||
@@ -786,6 +917,7 @@ 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,
|
||||
@@ -823,6 +955,7 @@ 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();
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! 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,6 +35,9 @@ 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()
|
||||
@@ -118,7 +121,7 @@ impl Game<'_> {
|
||||
|| self.pad.keyboard_took_over
|
||||
|| self.prompt_open()
|
||||
|| self.title.is_some()
|
||||
|| self.pad.menu.osk.is_some()
|
||||
|| self.pad_keyboard_captures()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,8 +223,14 @@ 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());
|
||||
@@ -265,6 +271,7 @@ 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
|
||||
@@ -284,6 +291,16 @@ 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;
|
||||
|
||||
@@ -329,6 +346,13 @@ 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;
|
||||
@@ -341,14 +365,12 @@ fn state_of(pad: &gilrs::Gamepad) -> PadState {
|
||||
sdl_mapped,
|
||||
);
|
||||
let (left, right) = sdl_row::sticks(pad, left, right);
|
||||
state.left = left;
|
||||
state.right = if hid_layout::right_stick_on_z(pad) {
|
||||
let right = if hid_layout::right_stick_on_z(pad) {
|
||||
hid_layout::right_stick([axis(A::LeftZ), axis(A::RightZ)])
|
||||
} else {
|
||||
right
|
||||
};
|
||||
state.triggers = triggers::triggers(pad);
|
||||
state
|
||||
[left, right]
|
||||
}
|
||||
|
||||
/// A controller, as splitscreen tells them apart.
|
||||
@@ -362,6 +384,8 @@ 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 {
|
||||
@@ -445,7 +469,15 @@ 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));
|
||||
@@ -481,6 +513,14 @@ 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
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
//! 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.menu.osk.is_none()
|
||||
&& !self.pad_keyboard_captures()
|
||||
&& 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,13 +795,16 @@ impl Game<'_> {
|
||||
}
|
||||
// A prompt over the screen answers first; no press reaches both.
|
||||
let prompt = self.prompt_open();
|
||||
// Bindings waiting for a button: this one (Start cancels).
|
||||
// Bindings, or the radial menu's opener row, 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()
|
||||
{
|
||||
if button != Button::Start {
|
||||
// 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) {
|
||||
self.settings.controller.bind(action, Some(button));
|
||||
}
|
||||
return self.handle_option_events(vec![Event::Changed]);
|
||||
@@ -1145,6 +1148,8 @@ 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)
|
||||
@@ -1152,6 +1157,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.pad_click_at(event_loop, focus.center(), false);
|
||||
}
|
||||
self.ui.options.pad_clicks_step(false);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1848,7 +1854,16 @@ 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())
|
||||
{
|
||||
if let Some(focus) = self.pad.menu.focus {
|
||||
// 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 {
|
||||
let kind = std::mem::discriminant(&focus.kind);
|
||||
let named = self.pad_named_focus(focus, &rects);
|
||||
let followed = stick_scrolled.and_then(|s| {
|
||||
@@ -2019,7 +2034,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.menu.osk.is_some()
|
||||
self.pad_keyboard_captures()
|
||||
|| playing_screen(Screen::Chat)
|
||||
|| self.sign_edit.is_some()
|
||||
|| matches!(self.book, Some(super::books::BookScreen::Edit { .. }))
|
||||
@@ -2232,8 +2247,10 @@ 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.menu.real_pointer != Some(*position) && !self.pad_radial_captures()
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } => event.state == ElementState::Pressed,
|
||||
WindowEvent::MouseInput { state, .. } => *state == ElementState::Pressed,
|
||||
|
||||
@@ -801,19 +801,8 @@ impl Game<'_> {
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
if super::cosmetics::enabled() {
|
||||
let base = horizon_client::mobs::render::base_transform(m, alpha as f32, camera);
|
||||
self.cosmetics_geometry(
|
||||
m,
|
||||
true,
|
||||
base,
|
||||
Some(camera),
|
||||
alpha as f32,
|
||||
packed,
|
||||
false,
|
||||
out,
|
||||
);
|
||||
}
|
||||
let world = horizon_hooks::players::Pass::World;
|
||||
self.own_layers_geometry(m, world, camera, alpha as f32, packed, out);
|
||||
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
|
||||
@@ -927,10 +916,8 @@ impl Game<'_> {
|
||||
240 | 240 << 8,
|
||||
&mut verts,
|
||||
);
|
||||
if super::cosmetics::enabled() {
|
||||
let base = horizon_client::mobs::render::base_transform(&m, 1.0, camera);
|
||||
self.cosmetics_geometry(&m, true, base, None, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
}
|
||||
let picture = horizon_hooks::players::Pass::Picture;
|
||||
self.own_layers_geometry(&m, picture, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
// `WingsLayer` draws through `RenderTypes.armorCutoutNoCull`: the
|
||||
// wings' faces show from both sides (the elytra texture is empty on
|
||||
// the face that looks at the body).
|
||||
|
||||
@@ -114,9 +114,7 @@ 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(&self.settings.controller, ["key.sneak", "key.sprint"][slot])
|
||||
&& self.pad.holds_action(["key.sneak", "key.sprint"][slot])
|
||||
}
|
||||
Source::Pad => self.physical_keys.iter().any(|physical| {
|
||||
self.settings
|
||||
@@ -459,7 +457,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(&self.settings.controller, mapping)
|
||||
self.pad.holds_action(mapping)
|
||||
}
|
||||
|
||||
/// Turns the view by what the devices give this frame (`Entity.turn`).
|
||||
@@ -481,7 +479,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,7 +146,16 @@ impl Game<'_> {
|
||||
// an active use in the same tick.
|
||||
self.release_use_for_keybind_screen();
|
||||
}
|
||||
// key.socialInteractions.
|
||||
// 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);
|
||||
}
|
||||
KeyCode::KeyP if self.screen == Screen::Playing => {
|
||||
self.open_social(false);
|
||||
self.release_use_for_keybind_screen();
|
||||
|
||||
@@ -11,8 +11,8 @@ use horizon_client::mobs::anim::{self, BodyPose, PooledPoses, Poses};
|
||||
use horizon_client::mobs::{Mob, Part, Polygon, render};
|
||||
use horizon_hooks::engine;
|
||||
use horizon_hooks::players::{
|
||||
Bone, CameraView, Frame, Gear, Kind, Mesh, PartPoses, Pass, PlayerDraw, PlayerModel,
|
||||
PlayerModels, SpriteStore, Sprites,
|
||||
Bone, CameraView, Frame, Gear, Kind, Light, Look, Mesh, Motion, PartPoses, Particle, Pass,
|
||||
PlayerDraw, PlayerModel, PlayerModels, SpriteStore, Sprites, View,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -182,6 +182,14 @@ fn part_of(mesh: &Arc<Mesh>) -> Arc<Part> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Full light, packed as the vertices carry it.
|
||||
const FULL: u32 = 240 | 240 << 8;
|
||||
|
||||
thread_local! {
|
||||
/// What crates draw see-through this frame, added after every player.
|
||||
static SEE_THROUGH: std::cell::RefCell<Vec<Vertex>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Forgets the meshes no crate holds any more (once a frame).
|
||||
fn forget_meshes() {
|
||||
let _ = MESHES.try_with(|m| {
|
||||
@@ -199,7 +207,7 @@ impl PartPoses for PosesOf<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A player model drawn in the world pass, as a crate draws on it: posed on the GPU's skin path
|
||||
/// A player model drawn, as a crate draws on it: posed on the GPU's skin path
|
||||
/// when `gpu` (the mob pass's skin frame, begun; the bounds of what went there grow, for
|
||||
/// culling), else its vertices go to `out`.
|
||||
struct DrawPlayer<'a, 'g> {
|
||||
@@ -208,6 +216,7 @@ struct DrawPlayer<'a, 'g> {
|
||||
pass: Pass,
|
||||
/// The model's matrix (`render::base_transform`).
|
||||
base: glam::Mat4,
|
||||
camera: glam::DVec3,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
gpu: bool,
|
||||
@@ -223,6 +232,19 @@ impl DrawPlayer<'_, '_> {
|
||||
let (key, _) = render::body(self.m);
|
||||
self.m.kind.layer(&key).unwrap_or(&self.m.kind.model)
|
||||
}
|
||||
|
||||
/// `light` packed; `at`: the place `Light::AtPlace` reads, relative to the camera.
|
||||
fn packed(&self, light: Light, at: glam::Vec3) -> u32 {
|
||||
match light {
|
||||
Light::Full => FULL,
|
||||
Light::AtPlace => {
|
||||
let p = (self.camera + at.as_dvec3()).floor();
|
||||
let l = self.game.world.light(p.x as i32, p.y as i32, p.z as i32);
|
||||
(u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8)
|
||||
}
|
||||
_ => self.light,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerDraw for DrawPlayer<'_, '_> {
|
||||
@@ -234,7 +256,52 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
|
||||
self.m.age as f32 + self.alpha
|
||||
}
|
||||
|
||||
fn base(&self) -> [[f32; 4]; 4] {
|
||||
self.base.to_cols_array_2d()
|
||||
}
|
||||
|
||||
fn motion(&self) -> Motion {
|
||||
let (m, a) = (self.m, self.alpha);
|
||||
let t = f64::from(a);
|
||||
let (p, n) = (m.previous, m.position);
|
||||
let feet = [
|
||||
p.x + (n.x - p.x) * t,
|
||||
p.y + (n.y - p.y) * t,
|
||||
p.z + (n.z - p.z) * t,
|
||||
];
|
||||
let turn = horizon_client::mobs::living::wrap(m.head_yaw - m.previous_head_yaw);
|
||||
let yaw = m.previous_head_yaw + turn * a;
|
||||
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * a;
|
||||
let asleep = m.extra.avatar_pose == horizon_client::pose::Pose::Sleeping;
|
||||
let swimming = m.extra.swim.amount(a) > 0.0;
|
||||
Motion::new(feet, yaw, pitch).poses(
|
||||
m.extra.crouching,
|
||||
asleep,
|
||||
m.extra.fall_flying,
|
||||
swimming,
|
||||
)
|
||||
}
|
||||
|
||||
fn view(&self) -> View {
|
||||
let q = self.game.camera_orientation();
|
||||
let (right, up) = (
|
||||
(q * glam::Vec3::X).to_array(),
|
||||
(q * glam::Vec3::Y).to_array(),
|
||||
);
|
||||
let (yaw, pitch) = (self.game.yaw.to_radians(), self.game.pitch.to_radians());
|
||||
let forward = [
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
];
|
||||
let left = [yaw.cos(), 0.0, yaw.sin()];
|
||||
View::new(self.camera.to_array(), right, up).looking(forward, left)
|
||||
}
|
||||
|
||||
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
|
||||
if self.pass == Pass::FirstPerson {
|
||||
return None;
|
||||
}
|
||||
if self.body.is_none() {
|
||||
let mut poses = Poses::pooled();
|
||||
poses.reset(self.body());
|
||||
@@ -260,13 +327,30 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
|
||||
&mut self,
|
||||
mesh: &Arc<Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
uv: [f32; 4],
|
||||
look: Look,
|
||||
pose: &mut dyn FnMut(&mut dyn PartPoses),
|
||||
) {
|
||||
let model = part_of(mesh);
|
||||
let matrix = glam::Mat4::from_cols_array_2d(&matrix);
|
||||
let (uv, light) = (look.uv, self.packed(look.light, matrix.w_axis.truncate()));
|
||||
self.poses.reset(&model);
|
||||
pose(&mut PosesOf(&mut self.poses));
|
||||
let poses = &self.poses;
|
||||
if let Some(alpha) = look.see_through {
|
||||
let color = ((alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
let pose_of = |p: &Part| poses.pose(p);
|
||||
SEE_THROUGH.with_borrow_mut(|out| {
|
||||
let start = out.len();
|
||||
horizon_client::mobs::render_model(&model, matrix, &pose_of, uv, light, 0, out);
|
||||
for v in &mut out[start..] {
|
||||
v.color = color;
|
||||
// Seen through, a face turned away would add to the near one: `entity.wgsl`
|
||||
// drops back faces, so it is `alpha` at most.
|
||||
v.normal[3] = horizon_client::mesher::SHADED_CULL_W;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if self.gpu {
|
||||
let mut models = self.game.skin_models.borrow_mut();
|
||||
let mut frame = self.game.skin_frame.borrow_mut();
|
||||
@@ -276,30 +360,43 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
|
||||
uv,
|
||||
scroll: 0.0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: self.light,
|
||||
light,
|
||||
overlay: 0,
|
||||
};
|
||||
if skin.draw(&model, matrix, &self.poses, paint) {
|
||||
if skin.draw(&model, matrix, poses, paint) {
|
||||
self.bounds = skin.bounds;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let poses = &self.poses;
|
||||
horizon_client::mobs::render_model(
|
||||
&model,
|
||||
matrix,
|
||||
&|p: &Part| poses.pose(p),
|
||||
uv,
|
||||
self.light,
|
||||
0,
|
||||
self.out,
|
||||
);
|
||||
let pose_of = |p: &Part| poses.pose(p);
|
||||
horizon_client::mobs::render_model(&model, matrix, &pose_of, uv, light, 0, self.out);
|
||||
}
|
||||
|
||||
fn particles(&mut self, uv: [f32; 4], particles: &[Particle], light: Light) {
|
||||
let light = self.packed(light, glam::Vec3::ZERO);
|
||||
SEE_THROUGH.with_borrow_mut(|out| {
|
||||
for q in particles {
|
||||
let color = ((q.alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
for (corner, [u, v]) in q.corners.iter().zip(q.uv) {
|
||||
out.push(Vertex {
|
||||
pos: *corner,
|
||||
uv: [uv[0] + (uv[2] - uv[0]) * u, uv[1] + (uv[3] - uv[1]) * v],
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
// Unlit: a particle keeps its colour whichever way it faces.
|
||||
normal: [0, 0, 0, 0],
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// What crates draw on player model `m` in the world pass (`creatures.rs`), then the
|
||||
/// cosmetics still drawn here: see [`DrawPlayer`]. The bounds of what went to the GPU.
|
||||
/// What crates draw on player model `m` in the world pass (`creatures.rs`): see
|
||||
/// [`DrawPlayer`]. The bounds of what went to the GPU.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn player_layers_geometry(
|
||||
&self,
|
||||
@@ -315,33 +412,102 @@ impl Game<'_> {
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
let kind = kind(m)?;
|
||||
let mut bounds = None;
|
||||
if engine::any() {
|
||||
let model = model(m, kind, self.remote_player_uuid(m.id));
|
||||
let kind = kind(m).filter(|_| engine::any())?;
|
||||
let model = model(m, kind, self.remote_player_uuid(m.id));
|
||||
let mut draw = DrawPlayer {
|
||||
game: self,
|
||||
m,
|
||||
pass: Pass::World,
|
||||
base,
|
||||
camera,
|
||||
alpha,
|
||||
light,
|
||||
gpu,
|
||||
out,
|
||||
bounds: None,
|
||||
body: None,
|
||||
poses: Poses::pooled(),
|
||||
};
|
||||
engine::player_layers(&model, &mut draw);
|
||||
draw.bounds
|
||||
}
|
||||
|
||||
/// What crates draw on the window's own model `m` (its vertices to `out`): in the world in
|
||||
/// third person (`Pass::World`), or in the inventory's picture of it (`Pass::Picture`).
|
||||
/// `camera`: the world view's camera, or the picture's.
|
||||
pub(super) fn own_layers_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
pass: Pass,
|
||||
camera: glam::DVec3,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
if !engine::any() {
|
||||
return;
|
||||
}
|
||||
let model = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
|
||||
let mut draw = DrawPlayer {
|
||||
game: self,
|
||||
m,
|
||||
pass,
|
||||
base: render::base_transform(m, alpha, camera),
|
||||
camera,
|
||||
alpha,
|
||||
light,
|
||||
gpu: false,
|
||||
out,
|
||||
bounds: None,
|
||||
body: None,
|
||||
poses: Poses::pooled(),
|
||||
};
|
||||
engine::player_layers(&model, &mut draw);
|
||||
}
|
||||
|
||||
/// After every player (`game.rs`): what crates draw around the window's own model in first
|
||||
/// person, then what was drawn see-through, in one span.
|
||||
pub(super) fn players_after_geometry(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if engine::any()
|
||||
&& !self.own_body_in_view()
|
||||
&& let Some(m) = self.player_model.as_ref()
|
||||
{
|
||||
let own = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
|
||||
let own = own.looks(own.gear, own.invisible || self.own_invisible());
|
||||
self.mark_entity(out.len(), "minecraft:player");
|
||||
let mut draw = DrawPlayer {
|
||||
game: self,
|
||||
m,
|
||||
pass: Pass::World,
|
||||
base,
|
||||
alpha,
|
||||
light,
|
||||
gpu,
|
||||
pass: Pass::FirstPerson,
|
||||
base: render::base_transform(m, alpha as f32, camera),
|
||||
camera,
|
||||
alpha: alpha as f32,
|
||||
light: FULL,
|
||||
gpu: false,
|
||||
out: &mut *out,
|
||||
bounds: None,
|
||||
body: None,
|
||||
poses: Poses::pooled(),
|
||||
};
|
||||
engine::player_layers(&model, &mut draw);
|
||||
bounds = draw.bounds;
|
||||
}
|
||||
let worn = super::cosmetics::enabled()
|
||||
.then(|| self.cosmetics_geometry(m, false, base, Some(camera), alpha, light, gpu, out))
|
||||
.flatten();
|
||||
match (bounds, worn) {
|
||||
(Some((l, h)), Some((lo, hi))) => Some((l.min(lo), h.max(hi))),
|
||||
(b, w) => b.or(w),
|
||||
engine::first_person(&own, &mut draw);
|
||||
}
|
||||
let _ = SEE_THROUGH.try_with(|v| {
|
||||
let mut v = v.borrow_mut();
|
||||
if !v.is_empty() {
|
||||
self.mark_translucent_entity(out.len(), "minecraft:player");
|
||||
out.extend_from_slice(&v);
|
||||
v.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Once a frame, before drawing.
|
||||
@@ -358,6 +524,7 @@ impl Game<'_> {
|
||||
let frame = Frame::new(connection, self.integrated.is_some());
|
||||
engine::frame(&frame, &mut HookStore(self));
|
||||
forget_meshes();
|
||||
let _ = SEE_THROUGH.try_with(|v| v.borrow_mut().clear());
|
||||
self.cosmetics_frame();
|
||||
}
|
||||
|
||||
|
||||
@@ -11463,8 +11463,7 @@ impl Remote {
|
||||
pub(super) fn listed_player_count(&self) -> usize {
|
||||
self.listed.len()
|
||||
}
|
||||
/// Players in the server's player list, this one included (for Discord).
|
||||
#[cfg(feature = "discord")]
|
||||
/// Players in the server's player list, this one included (for the activity hooks).
|
||||
pub(super) fn player_count(&self) -> usize {
|
||||
self.players.len()
|
||||
}
|
||||
|
||||
@@ -306,8 +306,6 @@ pub(super) fn run_loading(
|
||||
App::Loading(loading) => leave_fullscreen_after(&result, &mut loading.settings),
|
||||
}
|
||||
result?;
|
||||
#[cfg(feature = "discord")]
|
||||
horizon_client::discord_presence::stop();
|
||||
match app {
|
||||
App::Ready(mut split) => {
|
||||
write_report(&split.host)?;
|
||||
|
||||
@@ -917,10 +917,8 @@ 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(&self.settings.controller, "key.attack");
|
||||
self.right = self.pad.holds_action(&self.settings.controller, "key.use");
|
||||
self.left = self.pad.holds_action("key.attack");
|
||||
self.right = self.pad.holds_action("key.use");
|
||||
self.inventory_mouse_release(false);
|
||||
self.inventory_mouse_release(true);
|
||||
self.inventory_pick_release();
|
||||
|
||||
@@ -130,6 +130,12 @@ 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 {
|
||||
@@ -282,14 +288,16 @@ impl Split<'_> {
|
||||
self.pads.report_status(&mut self.host.pad, owner);
|
||||
}
|
||||
let state = self.pad_state(owner);
|
||||
self.host.pad.feed_from(owner, state);
|
||||
let peaks = self.pad_peaks(owner);
|
||||
self.host.pad.feed_from(owner, state, peaks);
|
||||
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);
|
||||
self.guests[i].game.pad.feed_from(pad, state);
|
||||
let peaks = self.pad_peaks(pad);
|
||||
self.guests[i].game.pad.feed_from(pad, state, peaks);
|
||||
}
|
||||
joins
|
||||
}
|
||||
|
||||
@@ -516,10 +516,9 @@ impl<'a> Split<'a> {
|
||||
continue;
|
||||
}
|
||||
// Opened without the focus: whoever types in the game's window keeps the keyboard.
|
||||
let attributes =
|
||||
horizon_client::window::with_horizon_icon(Window::default_attributes())
|
||||
.with_active(false)
|
||||
.with_visible(window_shown(true, self.offscreen));
|
||||
let attributes = horizon_client::window::with_icon(Window::default_attributes())
|
||||
.with_active(false)
|
||||
.with_visible(window_shown(true, self.offscreen));
|
||||
// A monitor's window covers it, borderless. On macOS not the system's fullscreen, a
|
||||
// Space of its own that hides the other windows as it slides in.
|
||||
let placement = self.placement(*key).cloned();
|
||||
|
||||
@@ -113,6 +113,117 @@ fn fire_from_damage_lights_or_burns_the_stand_though_the_command_fails() {
|
||||
assert!(health.ends_with(": 16.0f"), "{health}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn igniting_a_frozen_stand_clears_its_freeze_while_burning_keeps_it() {
|
||||
let mut game = game();
|
||||
summon(&mut game, ",TicksFrozen:140");
|
||||
let (_, frozen) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
|
||||
assert!(frozen.ends_with(": 140"), "{frozen}");
|
||||
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
|
||||
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
|
||||
assert!(fire.ends_with(": 100s"), "{fire}");
|
||||
let (ok, frozen_after) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
|
||||
assert!(!ok, "{frozen_after}");
|
||||
|
||||
// When already burning, in_fire calls causeDamage(0.15) instead of igniteForSeconds.
|
||||
assert!(game.run_command(&format!("data merge entity {STAND} {{TicksFrozen:140}}")));
|
||||
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
|
||||
let (ok, still_frozen) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
|
||||
assert!(ok && still_frozen.ends_with(": 140"), "{still_frozen}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn damage_immunity_on_worn_boots_refuses_campfire_but_not_in_fire_or_wrong_slot() {
|
||||
let mut game = game();
|
||||
summon(
|
||||
&mut game,
|
||||
r#",equipment:{feet:{id:"minecraft:diamond_boots",count:1,components:{"minecraft:enchantments":{"minecraft:frost_walker":1}}}}"#,
|
||||
);
|
||||
// campfire is in both #ignites_armor_stands and #burn_from_stepping.
|
||||
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:campfire")));
|
||||
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
|
||||
assert!(fire.ends_with(": 0s"), "{fire}");
|
||||
// in_fire is not in #burn_from_stepping, so it still ignites the stand.
|
||||
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
|
||||
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
|
||||
assert!(fire.ends_with(": 100s"), "{fire}");
|
||||
|
||||
// Frost Walker on a helmet (head) is not in an active slot for the enchantment.
|
||||
let mut helmeted = self::game();
|
||||
summon(
|
||||
&mut helmeted,
|
||||
r#",equipment:{head:{id:"minecraft:diamond_helmet",count:1,components:{"minecraft:enchantments":{"minecraft:frost_walker":1}}}}"#,
|
||||
);
|
||||
assert!(!helmeted.run_command(&format!("damage {STAND} 1 minecraft:campfire")));
|
||||
let (_, fire) = say(&mut helmeted, &format!("data get entity {STAND} Fire"));
|
||||
assert!(fire.ends_with(": 100s"), "{fire}");
|
||||
}
|
||||
|
||||
fn capture() {
|
||||
super::sculk::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
|
||||
}
|
||||
|
||||
fn take_events() -> Vec<(&'static str, [f64; 3], Option<horizon_client::mobs::Who>)> {
|
||||
super::sculk::TEST_POSTED_EVENTS.with(|events| events.borrow_mut().take().unwrap_or_default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn cause_damage_and_explosion_post_attributed_game_events() {
|
||||
use horizon_client::mobs::Who;
|
||||
let mut game = game();
|
||||
summon(&mut game, ",Fire:200s");
|
||||
assert!(game.run_command("summon pig 14.5 100 2.5 {NoAI:1b}"));
|
||||
let pig_id = game.mobs[0].id;
|
||||
let at = [2.5, 100.0, 8.5];
|
||||
|
||||
// Burning stand hurt by in_fire (0.15) posts entity_damage attributed to the pig.
|
||||
capture();
|
||||
assert!(!game.run_command(&format!(
|
||||
"damage {STAND} 1 minecraft:in_fire by @e[type=pig,limit=1]"
|
||||
)));
|
||||
assert_eq!(
|
||||
take_events(),
|
||||
vec![("minecraft:entity_damage", at, Some(Who::Mob(pig_id)))]
|
||||
);
|
||||
|
||||
// Burning stand hurt by on_fire (4.0) while surviving posts entity_damage.
|
||||
capture();
|
||||
assert!(!game.run_command(&format!(
|
||||
"damage {STAND} 1 minecraft:on_fire by @e[type=pig,limit=1]"
|
||||
)));
|
||||
assert_eq!(
|
||||
take_events(),
|
||||
vec![("minecraft:entity_damage", at, Some(Who::Mob(pig_id)))]
|
||||
);
|
||||
|
||||
// Burning stand broken by on_fire (health <= 0.5) posts entity_die attributed to the pig.
|
||||
assert!(game.run_command(&format!("data merge entity {STAND} {{Health:4f}}")));
|
||||
capture();
|
||||
assert!(!game.run_command(&format!(
|
||||
"damage {STAND} 1 minecraft:on_fire by @e[type=pig,limit=1]"
|
||||
)));
|
||||
assert!(!game.run_command(&format!("execute if entity {STAND}")));
|
||||
assert_eq!(
|
||||
take_events(),
|
||||
vec![("minecraft:entity_die", at, Some(Who::Mob(pig_id)))]
|
||||
);
|
||||
|
||||
// Stand broken by explosion posts entity_die attributed to the pig.
|
||||
summon(&mut game, "");
|
||||
capture();
|
||||
assert!(!game.run_command(&format!(
|
||||
"damage {STAND} 1 minecraft:explosion by @e[type=pig,limit=1]"
|
||||
)));
|
||||
assert!(!game.run_command(&format!("execute if entity {STAND}")));
|
||||
assert_eq!(
|
||||
take_events(),
|
||||
vec![("minecraft:entity_die", at, Some(Who::Mob(pig_id)))]
|
||||
);
|
||||
}
|
||||
|
||||
// Controls added after the baseline: the guards before the acting sources,
|
||||
// and a stand broken once however often it is hurt.
|
||||
|
||||
|
||||
@@ -111,6 +111,18 @@ 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);
|
||||
@@ -273,9 +285,8 @@ impl ApplicationHandler for Game<'_> {
|
||||
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
|
||||
let size = self.options.window_size.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
);
|
||||
let attributes =
|
||||
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
|
||||
// Wayland names the window by its app id, which the horizon.desktop entry matches.
|
||||
#[cfg(target_os = "linux")]
|
||||
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
|
||||
@@ -352,6 +363,13 @@ 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Horizon's look, given to the engine's extension points at startup (docs/layers.md): the
|
||||
//! windows' icon and the colours, sizes and fonts of the menus vanilla doesn't have
|
||||
//! (`docs/design-system.md`).
|
||||
//! Its own module so it can leave the engine crate as a pure file move, for the official
|
||||
//! executable to call.
|
||||
use horizon_client::menu_style::{Fonts, MenuStyle, OverlayColours, SettingsColours, Sizes};
|
||||
use horizon_theme as theme;
|
||||
|
||||
/// Give the engine Horizon's look; call it once, before the first window opens.
|
||||
pub(crate) fn give() {
|
||||
horizon_client::window::set_icon_png(theme::ICON_PNG);
|
||||
let (blocks, dark) = (theme::BLOCKS_DARK, theme::DARK);
|
||||
horizon_client::menu_style::install(MenuStyle {
|
||||
settings: SettingsColours {
|
||||
page: blocks.page,
|
||||
ink: blocks.ink,
|
||||
ink_soft: blocks.ink_soft,
|
||||
muted: blocks.muted,
|
||||
outline: blocks.outline,
|
||||
accent: blocks.accent,
|
||||
face: blocks.face,
|
||||
// The face's hover, lighter than `face`: the settings screen's own shade.
|
||||
face_hover: 0x6a50e6,
|
||||
success: blocks.success,
|
||||
warn: blocks.warn,
|
||||
danger: blocks.danger,
|
||||
},
|
||||
overlay: OverlayColours {
|
||||
page: dark.page,
|
||||
backdrop_muted: theme::BACKDROP_MUTED,
|
||||
ink: dark.ink,
|
||||
ink_soft: dark.ink_soft,
|
||||
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,
|
||||
label: theme::LABEL.px,
|
||||
ui: theme::UI.px,
|
||||
body: theme::BODY.px,
|
||||
title: theme::TITLE.px,
|
||||
display: theme::DISPLAY.px,
|
||||
micro: theme::MICRO.px,
|
||||
radius: theme::RADIUS,
|
||||
radius_plate: theme::RADIUS_PLATE,
|
||||
control_large: theme::CONTROL_LARGE,
|
||||
},
|
||||
fonts: Some(Fonts {
|
||||
regular: theme::fonts::GEIST_REGULAR,
|
||||
medium: theme::fonts::GEIST_MEDIUM,
|
||||
semibold: theme::fonts::GEIST_SEMIBOLD,
|
||||
mono: theme::fonts::GEIST_MONO,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ extern crate self as horizon_client;
|
||||
pub use horizon_client_lib::*;
|
||||
pub mod entry;
|
||||
pub mod game;
|
||||
mod horizon_look;
|
||||
pub mod mod_api;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2205,6 +2205,67 @@ 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::*;
|
||||
|
||||
@@ -46,11 +46,11 @@ impl NoResources {
|
||||
let x = ((ui.w - width) / 2.0).round();
|
||||
let lines: Vec<Vec<String>> = PARAGRAPHS
|
||||
.iter()
|
||||
.map(|p| wrap(ui, Face::Regular, BODY, p, width))
|
||||
.map(|p| wrap(ui, Face::Regular, body(), p, width))
|
||||
.collect();
|
||||
let text_h = lines.iter().map(|l| l.len() as f32 * LINE).sum::<f32>()
|
||||
+ (lines.len() - 1) as f32 * PARAGRAPH_GAP;
|
||||
let height = TITLE_LINE + 16.0 + text_h + 28.0 + CONTROL + 20.0 + SMALL_LINE;
|
||||
let height = TITLE_LINE + 16.0 + text_h + 28.0 + control() + 20.0 + SMALL_LINE;
|
||||
let y = ((ui.h - height) / 2.0).max(24.0).round();
|
||||
let buttons_y = y + TITLE_LINE + 16.0 + text_h + 28.0;
|
||||
let mut buttons = Vec::new();
|
||||
@@ -59,7 +59,7 @@ impl NoResources {
|
||||
("Download the launcher", Action::Download),
|
||||
("Quit", Action::Quit),
|
||||
] {
|
||||
let w = (ui.measure(Face::Medium, BUTTON_TEXT, label) + 40.0).round();
|
||||
let w = (ui.measure(Face::Medium, button_text(), label) + 40.0).round();
|
||||
buttons.push(Button {
|
||||
label,
|
||||
action,
|
||||
@@ -80,21 +80,29 @@ impl NoResources {
|
||||
|
||||
pub fn draw(&self, ui: &mut Ui) {
|
||||
let layout = self.layout(ui);
|
||||
ui.rounded(0.0, 0.0, ui.w, ui.h, 0.0, color::BG);
|
||||
ui.rounded(0.0, 0.0, ui.w, ui.h, 0.0, color::bg());
|
||||
let mut y = layout.y;
|
||||
ui.text_line(
|
||||
Face::Medium,
|
||||
TITLE_SIZE,
|
||||
title_size(),
|
||||
TITLE_LINE,
|
||||
layout.x,
|
||||
y,
|
||||
color::TEXT,
|
||||
color::text(),
|
||||
TITLE,
|
||||
);
|
||||
y += TITLE_LINE + 16.0;
|
||||
for paragraph in &layout.lines {
|
||||
for line in paragraph {
|
||||
ui.text_line(Face::Regular, BODY, LINE, layout.x, y, color::TEXT, line);
|
||||
ui.text_line(
|
||||
Face::Regular,
|
||||
body(),
|
||||
LINE,
|
||||
layout.x,
|
||||
y,
|
||||
color::text(),
|
||||
line,
|
||||
);
|
||||
y += LINE;
|
||||
}
|
||||
y += PARAGRAPH_GAP;
|
||||
@@ -102,20 +110,20 @@ impl NoResources {
|
||||
for button in &layout.buttons {
|
||||
let hovered = button.contains(ui.cursor);
|
||||
let (plate, ink) = match (button.action, hovered) {
|
||||
(Action::Download, false) => (color::ACCENT, color::ON_ACCENT),
|
||||
(Action::Download, false) => (color::accent(), color::on_accent()),
|
||||
(Action::Download, true) => {
|
||||
(color::with_alpha(color::ACCENT, 0.85), color::ON_ACCENT)
|
||||
(color::with_alpha(color::accent(), 0.85), color::on_accent())
|
||||
}
|
||||
(Action::Quit, false) => (color::RAISED, color::TEXT),
|
||||
(Action::Quit, true) => (color::HOVER, color::TEXT),
|
||||
(Action::Quit, false) => (color::raised(), color::text()),
|
||||
(Action::Quit, true) => (color::hover(), color::text()),
|
||||
};
|
||||
let h = CONTROL;
|
||||
let h = control();
|
||||
ui.rounded(button.x, button.y, button.w, h, h / 2.0, plate);
|
||||
let lw = ui.measure(Face::Medium, BUTTON_TEXT, button.label);
|
||||
let lw = ui.measure(Face::Medium, button_text(), button.label);
|
||||
let lx = button.x + ((button.w - lw) / 2.0).round();
|
||||
ui.text_line(
|
||||
Face::Medium,
|
||||
BUTTON_TEXT,
|
||||
button_text(),
|
||||
h,
|
||||
lx,
|
||||
button.y,
|
||||
@@ -123,11 +131,11 @@ impl NoResources {
|
||||
button.label,
|
||||
);
|
||||
}
|
||||
let small_y = layout.buttons[0].y + CONTROL + 20.0;
|
||||
let small_y = layout.buttons[0].y + control() + 20.0;
|
||||
for (i, line) in wrap(
|
||||
ui,
|
||||
Face::Regular,
|
||||
ui_theme::SMALL,
|
||||
ui_theme::small(),
|
||||
&self.looked_in,
|
||||
layout.width,
|
||||
)
|
||||
@@ -137,11 +145,11 @@ impl NoResources {
|
||||
let line_y = small_y + i as f32 * SMALL_LINE;
|
||||
ui.text_line(
|
||||
Face::Regular,
|
||||
ui_theme::SMALL,
|
||||
ui_theme::small(),
|
||||
SMALL_LINE,
|
||||
layout.x,
|
||||
line_y,
|
||||
color::MUTED,
|
||||
color::muted(),
|
||||
line,
|
||||
);
|
||||
}
|
||||
@@ -157,14 +165,22 @@ impl NoResources {
|
||||
}
|
||||
}
|
||||
|
||||
const TITLE_SIZE: f32 = horizon_theme::TITLE.px;
|
||||
fn title_size() -> f32 {
|
||||
ui_theme::sizes().title
|
||||
}
|
||||
const TITLE_LINE: f32 = 40.0;
|
||||
const BODY: f32 = horizon_theme::BODY.px;
|
||||
fn body() -> f32 {
|
||||
ui_theme::sizes().body
|
||||
}
|
||||
const LINE: f32 = 24.0;
|
||||
const PARAGRAPH_GAP: f32 = 10.0;
|
||||
const BUTTON_TEXT: f32 = horizon_theme::UI.px;
|
||||
fn button_text() -> f32 {
|
||||
ui_theme::sizes().ui
|
||||
}
|
||||
const SMALL_LINE: f32 = 18.0;
|
||||
const CONTROL: f32 = horizon_theme::CONTROL_LARGE;
|
||||
fn control() -> f32 {
|
||||
ui_theme::sizes().control_large
|
||||
}
|
||||
|
||||
struct Layout {
|
||||
x: f32,
|
||||
@@ -184,7 +200,7 @@ struct Button {
|
||||
|
||||
impl Button {
|
||||
fn contains(&self, (cx, cy): (f32, f32)) -> bool {
|
||||
cx >= self.x && cx < self.x + self.w && cy >= self.y && cy < self.y + CONTROL
|
||||
cx >= self.x && cx < self.x + self.w && cy >= self.y && cy < self.y + control()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +248,7 @@ mod tests {
|
||||
for size in [[1280, 720], [800, 600], [2560, 1440]] {
|
||||
let (download, quit, density) = with_ui(size, (0.0, 0.0), |ui| {
|
||||
let layout = screen.layout(ui);
|
||||
let center = |b: &Button| (b.x + b.w / 2.0, b.y + CONTROL / 2.0);
|
||||
let center = |b: &Button| (b.x + b.w / 2.0, b.y + control() / 2.0);
|
||||
(
|
||||
center(&layout.buttons[0]),
|
||||
center(&layout.buttons[1]),
|
||||
@@ -255,7 +271,7 @@ mod tests {
|
||||
let layout = screen.layout(ui);
|
||||
let last = layout.buttons.last().unwrap();
|
||||
assert!(
|
||||
last.x + last.w <= ui.w && last.y + CONTROL <= ui.h,
|
||||
last.x + last.w <= ui.w && last.y + control() <= ui.h,
|
||||
"{size:?}"
|
||||
);
|
||||
});
|
||||
@@ -266,10 +282,10 @@ mod tests {
|
||||
fn text_wraps_within_the_column_and_keeps_every_word() {
|
||||
with_ui([1280, 720], (0.0, 0.0), |ui| {
|
||||
for paragraph in PARAGRAPHS {
|
||||
let lines = wrap(ui, Face::Regular, BODY, paragraph, 300.0);
|
||||
let lines = wrap(ui, Face::Regular, body(), paragraph, 300.0);
|
||||
assert!(lines.len() > 1, "{paragraph}");
|
||||
for line in &lines {
|
||||
assert!(ui.measure(Face::Regular, BODY, line) <= 300.0, "{line}");
|
||||
assert!(ui.measure(Face::Regular, body(), line) <= 300.0, "{line}");
|
||||
}
|
||||
assert_eq!(lines.join(" "), paragraph);
|
||||
}
|
||||
|
||||
@@ -293,16 +293,16 @@ impl Keyboard {
|
||||
y0 - 12.0,
|
||||
width + 24.0,
|
||||
height,
|
||||
ui_theme::RADIUS_PLATE,
|
||||
color::with_alpha(color::BG, 0.94),
|
||||
ui_theme::radius_plate(),
|
||||
color::with_alpha(color::bg(), 0.94),
|
||||
);
|
||||
ui.border(
|
||||
x0 - 12.0,
|
||||
y0 - 12.0,
|
||||
width + 24.0,
|
||||
height,
|
||||
ui_theme::RADIUS_PLATE,
|
||||
color::BORDER,
|
||||
ui_theme::radius_plate(),
|
||||
color::border(),
|
||||
);
|
||||
for (r, row) in self.rows().iter().enumerate() {
|
||||
let y = y0 + r as f32 * (KEY_H + GAP);
|
||||
@@ -314,13 +314,13 @@ impl Keyboard {
|
||||
let lit = matches!(key, Key::Shift) && (self.shift || self.caps)
|
||||
|| matches!(key, Key::Layer) && self.symbols;
|
||||
let (plate, ink) = if focused {
|
||||
(color::ACCENT, color::ON_ACCENT)
|
||||
(color::accent(), color::on_accent())
|
||||
} else if lit {
|
||||
(color::HOVER, color::TEXT)
|
||||
(color::hover(), color::text())
|
||||
} else {
|
||||
(color::RAISED, color::TEXT)
|
||||
(color::raised(), color::text())
|
||||
};
|
||||
ui.rounded(x, y, w, KEY_H, ui_theme::RADIUS, plate);
|
||||
ui.rounded(x, y, w, KEY_H, ui_theme::radius(), plate);
|
||||
let label = match key {
|
||||
Key::Char(ch) => self.letter(*ch),
|
||||
Key::Shift if self.caps => "CAPS".into(),
|
||||
@@ -331,10 +331,10 @@ impl Keyboard {
|
||||
Key::Backspace => tr("horizon.osk.delete", "Delete"),
|
||||
Key::Done => tr("gui.done", "Done"),
|
||||
};
|
||||
let lw = ui.measure(Face::Medium, ui_theme::BODY, &label);
|
||||
let lw = ui.measure(Face::Medium, ui_theme::body(), &label);
|
||||
ui.text_line(
|
||||
Face::Medium,
|
||||
ui_theme::BODY,
|
||||
ui_theme::body(),
|
||||
KEY_H,
|
||||
x + ((w - lw) / 2.0).round(),
|
||||
y,
|
||||
@@ -359,10 +359,10 @@ impl Keyboard {
|
||||
.collect();
|
||||
ui.text_centered(
|
||||
Face::Regular,
|
||||
ui_theme::SMALL,
|
||||
ui_theme::small(),
|
||||
x0 + width / 2.0,
|
||||
y0 + 5.0 * (KEY_H + GAP) + 2.0,
|
||||
color::MUTED,
|
||||
color::muted(),
|
||||
&line.join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ impl TimelineUi {
|
||||
let tx = start_x + sec * px_per_sec;
|
||||
if tx <= start_x + w {
|
||||
// Major tick line
|
||||
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::HEAD_RULE);
|
||||
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::head_rule());
|
||||
// Label every 2 or 5 seconds depending on width
|
||||
let is_labeled = if total_secs > 60.0 {
|
||||
(sec as u32).is_multiple_of(10)
|
||||
@@ -424,7 +424,7 @@ impl TimelineUi {
|
||||
"horizon.studio.timeline.seconds",
|
||||
&[Text::literal(seconds)],
|
||||
);
|
||||
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::SOFT, true);
|
||||
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::soft(), true);
|
||||
}
|
||||
}
|
||||
sec += sec_step;
|
||||
@@ -459,7 +459,7 @@ impl TimelineUi {
|
||||
// Base lane well
|
||||
let lane_rect = [start_x, y, track_w, bar_h];
|
||||
style::fill(batch, atlas, lane_rect, 0xFF14_171F);
|
||||
style::ring(batch, atlas, lane_rect, style::ROW_RULE);
|
||||
style::ring(batch, atlas, lane_rect, style::row_rule());
|
||||
|
||||
let total_dur = self.duration_ms.max(1) as f32;
|
||||
|
||||
@@ -514,7 +514,7 @@ impl TimelineUi {
|
||||
&scene_title,
|
||||
seg_x + 5.0,
|
||||
y + 5.0,
|
||||
style::MUTED,
|
||||
style::muted(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -577,7 +577,7 @@ impl TimelineUi {
|
||||
let px = start_x + norm * track_w;
|
||||
|
||||
// Scrubber line from ruler through bottom of tracks
|
||||
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::ACCENT);
|
||||
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::accent());
|
||||
|
||||
// Inverted triangle marker at ruler top
|
||||
let tri_color = style::WHITE;
|
||||
|
||||
@@ -178,7 +178,7 @@ impl TopBarUi {
|
||||
);
|
||||
}
|
||||
let color = if open {
|
||||
style::ACCENT
|
||||
style::accent()
|
||||
} else if hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
@@ -220,7 +220,7 @@ impl TopBarUi {
|
||||
if pause_hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
style::MUTED
|
||||
style::muted()
|
||||
},
|
||||
true,
|
||||
);
|
||||
@@ -339,7 +339,11 @@ impl TopBarUi {
|
||||
&Text::translate(key, &[]),
|
||||
x + 8.0,
|
||||
y + 6.0,
|
||||
if hovered { style::WHITE } else { style::MUTED },
|
||||
if hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
style::muted()
|
||||
},
|
||||
true,
|
||||
);
|
||||
if !shortcut.is_empty() {
|
||||
@@ -350,7 +354,7 @@ impl TopBarUi {
|
||||
&shortcut,
|
||||
x + menu_width - shortcut_width - 8.0,
|
||||
y + 6.0,
|
||||
style::SOFT,
|
||||
style::soft(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The design system's widgets drawn in the game (`crates/horizon-theme`:
|
||||
//! palette, type scale, radii, control sizes, motion and the bundled fonts;
|
||||
//! `docs/design-system.md`). Every menu is vanilla's (`ui`); what is left
|
||||
//! The design system's widgets drawn in the game: palette, type scale, radii,
|
||||
//! control sizes and fonts from `menu_style`, given at startup (in the official
|
||||
//! game `crates/horizon-theme`, `docs/design-system.md`). Every menu is vanilla's (`ui`); what is left
|
||||
//! here draws Horizon's own overlays that vanilla has no screen for: the
|
||||
//! on-screen keyboard a controller types with (`osk`), plus the
|
||||
//! `horizon.txt` preferences.
|
||||
@@ -39,44 +39,76 @@ pub(crate) fn set_preference(dir: &Path, key: &str, value: Option<&str>) {
|
||||
let _ = std::fs::write(dir.join("horizon.txt"), out);
|
||||
}
|
||||
|
||||
/// The dark palette (`horizon_theme::DARK`) in the HUD's colour encoding, plus the
|
||||
/// The overlays' palette (`menu_style`) in the HUD's colour encoding, plus the
|
||||
/// translucent plates the overlays draw over the game.
|
||||
pub(crate) mod color {
|
||||
use horizon_theme::DARK as P;
|
||||
pub const fn hex(c: u32, a: u8) -> u32 {
|
||||
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
|
||||
use horizon_client_render::menu_style::{self, OverlayColours};
|
||||
pub use menu_style::hud_colour as hex;
|
||||
fn p() -> &'static OverlayColours {
|
||||
&menu_style::get().overlay
|
||||
}
|
||||
/// The page, under every veil.
|
||||
pub const BG: u32 = hex(P.page, 255);
|
||||
pub fn bg() -> u32 {
|
||||
hex(p().page, 255)
|
||||
}
|
||||
/// Hover plates, secondary buttons, fields, the segmented track: the
|
||||
/// dark palette's soft plate, as ink over the veil so the plates read the
|
||||
/// same over any backdrop (over the page colour they are `plate_soft`).
|
||||
pub const RAISED: u32 = hex(P.ink, 20);
|
||||
pub fn raised() -> u32 {
|
||||
hex(p().ink, 20)
|
||||
}
|
||||
/// Pressed plates and a switch that is off (`plate`, the same way).
|
||||
pub const HOVER: u32 = hex(P.ink, 36);
|
||||
pub fn hover() -> u32 {
|
||||
hex(p().ink, 36)
|
||||
}
|
||||
/// One-pixel rules between rows, slider tracks.
|
||||
pub const BORDER: u32 = hex(P.rule, 255);
|
||||
pub fn border() -> u32 {
|
||||
hex(p().rule, 255)
|
||||
}
|
||||
/// Text and selection (`ink`).
|
||||
pub const TEXT: u32 = hex(P.ink, 255);
|
||||
pub fn text() -> u32 {
|
||||
hex(p().ink, 255)
|
||||
}
|
||||
/// Values and secondary text (`ink_soft`).
|
||||
pub const MUTED: u32 = hex(P.ink_soft, 255);
|
||||
pub const ACCENT: u32 = hex(P.accent, 255);
|
||||
pub const ON_ACCENT: u32 = hex(P.on_accent, 255);
|
||||
pub fn muted() -> u32 {
|
||||
hex(p().ink_soft, 255)
|
||||
}
|
||||
pub fn accent() -> u32 {
|
||||
hex(p().accent, 255)
|
||||
}
|
||||
pub fn on_accent() -> u32 {
|
||||
hex(p().on_accent, 255)
|
||||
}
|
||||
pub(crate) fn with_alpha(c: u32, a: f32) -> u32 {
|
||||
let base = (c >> 24) as f32;
|
||||
(c & 0x00ff_ffff) | ((base * a.clamp(0.0, 1.0)).round() as u32) << 24
|
||||
}
|
||||
}
|
||||
|
||||
/// The launcher's type scale and shapes (`horizon_theme`), in dp.
|
||||
pub(crate) const SMALL: f32 = horizon_theme::CAPTION.px;
|
||||
pub(crate) const BODY: f32 = horizon_theme::LABEL.px;
|
||||
/// The boot screen's wordmark (`DISPLAY`) and console lines (`MICRO`).
|
||||
pub(crate) const DISPLAY: f32 = horizon_theme::DISPLAY.px;
|
||||
pub(crate) const MICRO: f32 = horizon_theme::MICRO.px;
|
||||
/// Fields; plates (hover rows, selection); pills (buttons, segments).
|
||||
pub(crate) const RADIUS: f32 = horizon_theme::RADIUS;
|
||||
pub(crate) const RADIUS_PLATE: f32 = horizon_theme::RADIUS_PLATE;
|
||||
/// The type scale and shapes (`menu_style`), in dp.
|
||||
pub(crate) fn sizes() -> &'static horizon_client_render::menu_style::Sizes {
|
||||
&horizon_client_render::menu_style::get().sizes
|
||||
}
|
||||
pub(crate) fn small() -> f32 {
|
||||
sizes().caption
|
||||
}
|
||||
pub(crate) fn body() -> f32 {
|
||||
sizes().label
|
||||
}
|
||||
/// The boot screen's wordmark (`display`) and console lines (`micro`).
|
||||
fn display() -> f32 {
|
||||
sizes().display
|
||||
}
|
||||
fn micro() -> f32 {
|
||||
sizes().micro
|
||||
}
|
||||
/// Fields; plates (hover rows, selection).
|
||||
pub(crate) fn radius() -> f32 {
|
||||
sizes().radius
|
||||
}
|
||||
pub(crate) fn radius_plate() -> f32 {
|
||||
sizes().radius_plate
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum Face {
|
||||
@@ -87,20 +119,34 @@ pub(crate) enum Face {
|
||||
/// Counts, timestamps and the boot screen's console lines.
|
||||
Mono,
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
const TEST_FONTS: Option<horizon_client_render::menu_style::Fonts> = None;
|
||||
#[cfg(test)]
|
||||
const TEST_FONTS: Option<horizon_client_render::menu_style::Fonts> =
|
||||
Some(horizon_client_render::menu_style::Fonts {
|
||||
regular: horizon_theme::fonts::GEIST_REGULAR,
|
||||
medium: horizon_theme::fonts::GEIST_MEDIUM,
|
||||
semibold: horizon_theme::fonts::GEIST_SEMIBOLD,
|
||||
mono: horizon_theme::fonts::GEIST_MONO,
|
||||
});
|
||||
struct Fonts {
|
||||
faces: [FontRef<'static>; 4],
|
||||
}
|
||||
impl Fonts {
|
||||
fn load() -> Self {
|
||||
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("bundled font");
|
||||
Self {
|
||||
/// The faces given in `menu_style`; none given, none (unit tests take the design system's).
|
||||
fn load() -> Option<Self> {
|
||||
let given = horizon_client_render::menu_style::get()
|
||||
.fonts
|
||||
.or(TEST_FONTS)?;
|
||||
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("given font");
|
||||
Some(Self {
|
||||
faces: [
|
||||
f(horizon_theme::fonts::GEIST_REGULAR),
|
||||
f(horizon_theme::fonts::GEIST_MEDIUM),
|
||||
f(horizon_theme::fonts::GEIST_SEMIBOLD),
|
||||
f(horizon_theme::fonts::GEIST_MONO),
|
||||
f(given.regular),
|
||||
f(given.medium),
|
||||
f(given.semibold),
|
||||
f(given.mono),
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
fn face(&self, f: Face) -> &FontRef<'static> {
|
||||
&self.faces[f as usize]
|
||||
@@ -142,7 +188,8 @@ struct Cached {
|
||||
}
|
||||
/// Glyphs and corner masks rasterized into the atlas region `horizon/ui-text`.
|
||||
struct Cache {
|
||||
fonts: Fonts,
|
||||
/// None: no fonts were given, and text draws nothing.
|
||||
fonts: Option<Fonts>,
|
||||
entries: HashMap<Key, Cached>,
|
||||
shelf: (u32, u32, u32),
|
||||
pending: Vec<crate::font::Patch>,
|
||||
@@ -230,9 +277,16 @@ impl Cache {
|
||||
if let Some(c) = self.entries.get(&key) {
|
||||
return *c;
|
||||
}
|
||||
let scale = self.fonts.scale(face, f32::from(size) / 4.0);
|
||||
let Some(fonts) = &self.fonts else {
|
||||
return Cached {
|
||||
rect: None,
|
||||
left: 0.0,
|
||||
top: 0.0,
|
||||
};
|
||||
};
|
||||
let scale = fonts.scale(face, f32::from(size) / 4.0);
|
||||
let glyph = id.with_scale_and_position(scale, ab_glyph::point(0.0, 0.0));
|
||||
let made = match self.fonts.face(face).outline_glyph(glyph) {
|
||||
let made = match fonts.face(face).outline_glyph(glyph) {
|
||||
Some(outline) => {
|
||||
let b = outline.px_bounds();
|
||||
let (w, h) = (b.width() as u32, b.height() as u32);
|
||||
@@ -459,14 +513,17 @@ impl<'a> Ui<'a> {
|
||||
let atlas = self.atlas;
|
||||
let gui = self.scale;
|
||||
with_cache(atlas, |c| {
|
||||
let scale = c.fonts.scale(face, px);
|
||||
let Some(fonts) = &c.fonts else {
|
||||
return (0.0, 0.0, 0.0, Vec::new());
|
||||
};
|
||||
let scale = fonts.scale(face, px);
|
||||
let (ascent, descent) = {
|
||||
let f = c.fonts.face(face).as_scaled(scale);
|
||||
let f = fonts.face(face).as_scaled(scale);
|
||||
(f.ascent(), f.descent())
|
||||
};
|
||||
let chars: Vec<(char, Option<crate::font::Glyph>)> = unformatted(text)
|
||||
.map(|ch| {
|
||||
let pack = (!ch.is_whitespace() && c.fonts.face(face).glyph_id(ch).0 == 0)
|
||||
let pack = (!ch.is_whitespace() && fonts.face(face).glyph_id(ch).0 == 0)
|
||||
.then(|| atlas.font.glyph(ch))
|
||||
// The pack lacks it too (its missing glyph): Geist's box, like vanilla's.
|
||||
.filter(|g| g.rect.is_some() || g.right <= 0.0);
|
||||
@@ -491,7 +548,10 @@ impl<'a> Ui<'a> {
|
||||
continue;
|
||||
}
|
||||
let (id, advance, kern) = {
|
||||
let font = c.fonts.face(face);
|
||||
let Some(fonts) = &c.fonts else {
|
||||
break;
|
||||
};
|
||||
let font = fonts.face(face);
|
||||
let id = font.glyph_id(ch);
|
||||
let f = font.as_scaled(scale);
|
||||
(id, f.h_advance(id), prev.map_or(0.0, |p| f.kern(p, id)))
|
||||
@@ -606,8 +666,8 @@ pub fn draw_boot(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
|
||||
/// The wordmark with its tagline, centred.
|
||||
fn wordmark(atlas: &HudAtlas, batch: &mut HudBatch) {
|
||||
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
|
||||
let size = (ui.w / 16.0).clamp(DISPLAY, 96.0);
|
||||
let small = (size * 0.25).clamp(MICRO, 18.0);
|
||||
let size = (ui.w / 16.0).clamp(display(), 96.0);
|
||||
let small = (size * 0.25).clamp(micro(), 18.0);
|
||||
let block = size * 1.45 + small * 0.6 + small * 1.5;
|
||||
let top = (ui.h - block) / 2.0;
|
||||
ui.text_centered(Face::SemiBold, size, ui.w / 2.0, top, BOOT_TEXT, "Horizon");
|
||||
@@ -629,7 +689,7 @@ pub fn draw_boot_log(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
|
||||
return;
|
||||
}
|
||||
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
|
||||
let (size, line, pad) = (MICRO, MICRO * 1.5, 16.0);
|
||||
let (size, line, pad) = (micro(), micro() * 1.5, 16.0);
|
||||
let max_w = ui.w / 2.0;
|
||||
let max_rows = (((ui.h - pad) / line) as usize).clamp(1, 24);
|
||||
let mut y = ui.h - pad - line;
|
||||
@@ -696,7 +756,12 @@ mod tests {
|
||||
let atlas = HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
reset_atlas();
|
||||
let draw = |cache: &mut Cache| {
|
||||
let glyph = cache.fonts.face(Face::Regular).glyph_id('A');
|
||||
let glyph = cache
|
||||
.fonts
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.face(Face::Regular)
|
||||
.glyph_id('A');
|
||||
assert!(
|
||||
cache
|
||||
.glyph(&atlas, Face::Regular, glyph, 16.0)
|
||||
@@ -782,7 +847,7 @@ mod tests {
|
||||
let lines = wrapping_lines(
|
||||
&ui,
|
||||
Face::Mono,
|
||||
MICRO,
|
||||
micro(),
|
||||
"the quick brown fox jumps over the lazy dog",
|
||||
90.0,
|
||||
);
|
||||
@@ -790,7 +855,7 @@ mod tests {
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.all(|l| ui.measure(Face::Mono, MICRO, l) <= 90.0),
|
||||
.all(|l| ui.measure(Face::Mono, micro(), l) <= 90.0),
|
||||
"every wrapped line fits"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -812,7 +877,7 @@ mod tests {
|
||||
fn drawn(atlas: &HudAtlas, text: &str) -> (f32, Vec<([f32; 2], [f32; 2])>) {
|
||||
let mut batch = HudBatch::new([1280, 720]);
|
||||
let mut ui = Ui::new(atlas, &mut batch, (0.0, 0.0));
|
||||
let w = ui.text_line(Face::Medium, BODY, 20.0, 10.0, 10.0, color::TEXT, text);
|
||||
let w = ui.text_line(Face::Medium, body(), 20.0, 10.0, 10.0, color::text(), text);
|
||||
let quads = batch
|
||||
.vertices
|
||||
.iter()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "horizon-discord"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Discord Rich Presence: \"Playing Horizon\" on the player's Discord profile (docs/native-client.md)"
|
||||
|
||||
[dependencies]
|
||||
horizon-platform = { path = "../horizon-platform" }
|
||||
log = "0.4"
|
||||
serde_json = "1.0.151"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
+189
-29
@@ -1,21 +1,30 @@
|
||||
//! Discord Rich Presence: "Playing Horizon" on the player's Discord profile, with what they are
|
||||
//! doing (docs/native-client.md). A thread of its own talks to the Discord app on this computer
|
||||
//! through its local IPC socket (Discord's RPC docs: `discord-ipc-N`, frames of opcode u32 LE +
|
||||
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread,
|
||||
//! length u32 LE + JSON). The game says what the player does on each change ([`set`]), which wakes the thread,
|
||||
//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's
|
||||
//! update limit and clears the activity on quit. Nothing is sent with the setting off,
|
||||
//! or with `HORIZON_NO_DISCORD=1`. It uses nothing else of the game (docs/layers.md: Horizon layer):
|
||||
//! `game/discord.rs` says what the player does.
|
||||
//! or with `HORIZON_NO_DISCORD=1`.
|
||||
//!
|
||||
//! A Horizon crate (docs/layers.md): the game tells it what the player does through the
|
||||
//! platform's hooks ([`HOOKS`]: `client_started`, `activity`, `shutdown`), which the official
|
||||
//! executable (`horizon-official`) registers with the switch: its line of `horizon-extras.txt`
|
||||
//! ([`SETTING`]) and its row in Extras' Multiplayer group ([`SECTION`]). horizon-client's own
|
||||
//! executable has no presence.
|
||||
#![forbid(unsafe_code)]
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Condvar, Mutex, OnceLock};
|
||||
use std::thread::Thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use horizon_command::args::GameMode;
|
||||
use horizon_platform::hooks::{self, Activity, Dimension, GameMode, Hooks, Play};
|
||||
use horizon_platform::pages::{Act, Label, Page, Place, Row};
|
||||
use horizon_platform::settings::Store;
|
||||
|
||||
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
|
||||
/// Rich Presence assets: `horizon`, the logo.
|
||||
@@ -40,24 +49,141 @@ const CLOSE: u32 = 2;
|
||||
const PING: u32 = 3;
|
||||
const PONG: u32 = 4;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Dimension {
|
||||
Overworld,
|
||||
Nether,
|
||||
End,
|
||||
/// The presence's hooks, for `horizon_platform::hooks::register`.
|
||||
pub static HOOKS: Presence = Presence;
|
||||
|
||||
pub struct Presence;
|
||||
|
||||
impl Hooks for Presence {
|
||||
/// Starts the presence thread with the game's window (never for a dedicated server).
|
||||
fn client_started(&self, client: &hooks::Client) {
|
||||
start(client.automated);
|
||||
}
|
||||
|
||||
fn activity(&self, now: Activity) {
|
||||
set(match now {
|
||||
Activity::Menus => Status::Menus,
|
||||
Activity::World {
|
||||
play,
|
||||
dimension,
|
||||
mode,
|
||||
} => Status::World {
|
||||
play,
|
||||
dimension,
|
||||
mode,
|
||||
},
|
||||
// Anything a later game says that this crate doesn't know yet.
|
||||
_ => Status::Off,
|
||||
});
|
||||
}
|
||||
|
||||
/// On quit: the activity is cleared.
|
||||
fn shutdown(&self) {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Play {
|
||||
Singleplayer,
|
||||
/// A server or someone else's LAN world. Its address and name are never shown.
|
||||
Multiplayer,
|
||||
/// This world is open to LAN or friends; `players` counts the host, `max` is how many
|
||||
/// may join.
|
||||
Hosting {
|
||||
players: usize,
|
||||
max: usize,
|
||||
},
|
||||
/// The switch's line in `horizon-extras.txt`, where the engine's Extras kept it before.
|
||||
const KEY: &str = "discordPresence";
|
||||
/// The switch's row, and what it says on hover.
|
||||
const LABEL: &str = "horizon.extras.discord_presence";
|
||||
const ABOUT: &str = "horizon.extras.discord_presence.tooltip";
|
||||
|
||||
/// The switch (on by default). One presence per computer: the first splitscreen player's
|
||||
/// switch, in their settings, on their Extras page; the other players have none.
|
||||
static ON: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
/// Whether the player whose turn it is owns the switch: the first one.
|
||||
fn first_player() -> bool {
|
||||
hooks::seat() == 0
|
||||
}
|
||||
|
||||
/// Turns the presence on or off; off clears the activity at once.
|
||||
pub fn set_enabled(on: bool) {
|
||||
ON.store(on, Ordering::Relaxed);
|
||||
let mut shared = lock();
|
||||
let status = shown(shared.game);
|
||||
let changed = shared.wanted.update(status, unix_now());
|
||||
drop(shared);
|
||||
if let Some(thread) = THREAD.get().filter(|_| changed) {
|
||||
thread.unpark();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enabled() -> bool {
|
||||
ON.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// What Discord is told for the game's `status`: nothing with the switch off.
|
||||
fn shown(status: Status) -> Status {
|
||||
if enabled() { status } else { Status::Off }
|
||||
}
|
||||
|
||||
/// The switch's line of `horizon-extras.txt`, for `horizon_platform::settings::register`.
|
||||
pub static SETTING: Setting = Setting;
|
||||
|
||||
pub struct Setting;
|
||||
|
||||
impl Store for Setting {
|
||||
fn file(&self) -> &'static str {
|
||||
"horizon-extras.txt"
|
||||
}
|
||||
fn owns(&self, key: &str) -> bool {
|
||||
key == KEY
|
||||
}
|
||||
fn load(&self, _key: &str, value: &str) {
|
||||
if !first_player() {
|
||||
return;
|
||||
}
|
||||
match value {
|
||||
"true" => set_enabled(true),
|
||||
"false" => set_enabled(false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
|
||||
if first_player() {
|
||||
line(KEY, if enabled() { "true" } else { "false" });
|
||||
}
|
||||
}
|
||||
fn is_default(&self) -> bool {
|
||||
enabled() || !first_player()
|
||||
}
|
||||
}
|
||||
|
||||
/// The switch's row in the Extras page's Multiplayer group, for
|
||||
/// `horizon_platform::pages::register`.
|
||||
pub static SECTION: Section = Section;
|
||||
|
||||
pub struct Section;
|
||||
|
||||
impl Page for Section {
|
||||
fn id(&self) -> &'static str {
|
||||
"horizon-discord.extras"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.extras.header.multiplayer"
|
||||
}
|
||||
fn place(&self) -> Place {
|
||||
Place::Extras
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<Row>) {
|
||||
if !first_player() {
|
||||
return;
|
||||
}
|
||||
rows.push(Row::Toggle {
|
||||
label: LABEL,
|
||||
on: enabled(),
|
||||
});
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
if row == 0 && first_player() && matches!(act, Act::Press | Act::PressBack) {
|
||||
set_enabled(!enabled());
|
||||
}
|
||||
}
|
||||
fn tooltip(&self, _row: usize) -> Option<Label> {
|
||||
Some(Label::Key(ABOUT))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -97,11 +223,15 @@ impl Wanted {
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
/// What the game said last.
|
||||
game: Status,
|
||||
/// What Discord is to show: the game's, or nothing with the switch off.
|
||||
wanted: Wanted,
|
||||
stop: bool,
|
||||
stopped: bool,
|
||||
}
|
||||
static SHARED: Mutex<Shared> = Mutex::new(Shared {
|
||||
game: Status::Menus,
|
||||
wanted: Wanted {
|
||||
status: Status::Menus,
|
||||
since: None,
|
||||
@@ -114,7 +244,8 @@ static THREAD: OnceLock<Thread> = OnceLock::new();
|
||||
|
||||
/// Starts the presence thread, once: when the game starts, never for a dedicated server.
|
||||
/// `automated` runs (offscreen, benchmarks) only talk to a test's fake Discord.
|
||||
pub fn start(enabled: bool, automated: bool) {
|
||||
/// The status [`set`] gave before, the switch included, is the first one sent.
|
||||
pub fn start(automated: bool) {
|
||||
let fake_discord = std::env::var_os(IPC_DIR_ENV).is_some();
|
||||
if THREAD.get().is_some()
|
||||
|| std::env::var_os("HORIZON_NO_DISCORD").is_some_and(|v| v != "0")
|
||||
@@ -122,9 +253,6 @@ pub fn start(enabled: bool, automated: bool) {
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !enabled {
|
||||
lock().wanted.status = Status::Off;
|
||||
}
|
||||
match std::thread::Builder::new()
|
||||
.name("Discord presence".into())
|
||||
.spawn(run)
|
||||
@@ -136,12 +264,13 @@ pub fn start(enabled: bool, automated: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player does now; only a change wakes the thread. Free without [`start`].
|
||||
/// What the player does now; only a change wakes the thread. Kept for [`start`] before it.
|
||||
pub fn set(status: Status) {
|
||||
let Some(thread) = THREAD.get() else {
|
||||
return;
|
||||
};
|
||||
if lock().wanted.update(status, unix_now()) {
|
||||
let mut shared = lock();
|
||||
shared.game = status;
|
||||
let changed = shared.wanted.update(shown(status), unix_now());
|
||||
drop(shared);
|
||||
if let Some(thread) = THREAD.get().filter(|_| changed) {
|
||||
thread.unpark();
|
||||
}
|
||||
}
|
||||
@@ -582,6 +711,37 @@ mod tests {
|
||||
|
||||
/// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping.
|
||||
#[cfg(unix)]
|
||||
/// The switch as the player's settings and the Extras page see it. The only test that
|
||||
/// touches the presence's statics.
|
||||
#[test]
|
||||
fn the_switch_clears_the_activity_at_once() {
|
||||
let wanted = || lock().wanted.status;
|
||||
Setting.load(KEY, "false");
|
||||
set(Status::Menus);
|
||||
assert!(!enabled());
|
||||
assert_eq!(wanted(), Status::Off);
|
||||
let mut rows = Vec::new();
|
||||
SECTION.rows(&mut rows);
|
||||
assert_eq!(
|
||||
rows,
|
||||
[Row::Toggle {
|
||||
label: LABEL,
|
||||
on: false
|
||||
}]
|
||||
);
|
||||
let mut lines = Vec::new();
|
||||
Setting.save(&mut |k, v| lines.push(format!("{k}:{v}")));
|
||||
assert_eq!(lines, ["discordPresence:false"]);
|
||||
assert!(!Setting.is_default());
|
||||
// On from the page: what the game said last is shown at once.
|
||||
SECTION.act(0, Act::Press);
|
||||
assert_eq!(wanted(), Status::Menus);
|
||||
assert!(Setting.is_default());
|
||||
SECTION.act(0, Act::Press);
|
||||
assert_eq!(wanted(), Status::Off);
|
||||
SECTION.act(0, Act::Press);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn talks_to_discord_over_its_socket() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-discord-{}", std::process::id()));
|
||||
@@ -77,12 +77,79 @@ pub trait Hooks: Sync {
|
||||
/// Draws on a player model, after its vanilla layers, each time it is drawn.
|
||||
fn player_layers(&self, _model: &players::PlayerModel, _draw: &mut dyn players::PlayerDraw) {}
|
||||
|
||||
/// Draws around the window's own model while its body is out of view (first person), once a
|
||||
/// frame after the first-person model: [`players::Pass::FirstPerson`].
|
||||
fn first_person(&self, _own: &players::PlayerModel, _draw: &mut dyn players::PlayerDraw) {}
|
||||
|
||||
/// The resolver of `friend:` addresses this crate offers ([`tunnel`]); the first hook that
|
||||
/// offers one is used. Asked on the network thread when joining and on the game thread when
|
||||
/// an address is shown, never per frame.
|
||||
fn tunnel(&self) -> Option<&dyn tunnel::Resolver> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A game window opened: once, before the first frame, for every run that has a window,
|
||||
/// automated ones included ([`Client::automated`]); never for a dedicated server.
|
||||
fn client_started(&self, _client: &Client) {}
|
||||
|
||||
/// What the player is doing, for a status shown outside the game (Discord's): called when it
|
||||
/// changes, not every frame. Splitscreen: the first player's only.
|
||||
fn activity(&self, _now: Activity) {}
|
||||
}
|
||||
|
||||
/// What [`Hooks::client_started`] gets.
|
||||
#[non_exhaustive]
|
||||
pub struct Client {
|
||||
/// A run nobody plays: offscreen, a benchmark, a run that quits by itself after some frames.
|
||||
pub automated: bool,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new(automated: bool) -> Self {
|
||||
Self { automated }
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player is doing ([`Hooks::activity`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Activity {
|
||||
/// On the title screen or another menu outside a world.
|
||||
Menus,
|
||||
World {
|
||||
play: Play,
|
||||
dimension: Dimension,
|
||||
mode: GameMode,
|
||||
},
|
||||
}
|
||||
|
||||
/// How the player is in a world.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Play {
|
||||
Singleplayer,
|
||||
/// A server or someone else's LAN world.
|
||||
Multiplayer,
|
||||
/// Their world is open to LAN or friends; `players` counts the host, `max` is how many may
|
||||
/// join.
|
||||
Hosting {
|
||||
players: usize,
|
||||
max: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Dimension {
|
||||
Overworld,
|
||||
Nether,
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GameMode {
|
||||
Survival,
|
||||
Creative,
|
||||
Adventure,
|
||||
Spectator,
|
||||
}
|
||||
|
||||
/// What [`Hooks::started`] gets.
|
||||
@@ -178,6 +245,32 @@ pub mod engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::client_started`].
|
||||
pub fn client_started(client: &Client) {
|
||||
for hooks in all() {
|
||||
hooks.client_started(client);
|
||||
}
|
||||
}
|
||||
|
||||
/// The activity at the last call (`None` before the first).
|
||||
static ACTIVITY: std::sync::Mutex<Option<Activity>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// [`Hooks::activity`], when `now` differs from the last call's. Cheap enough to call every
|
||||
/// frame: with no hook registered it only loads the empty list.
|
||||
pub fn activity(now: Activity) {
|
||||
let hooks = all();
|
||||
if hooks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut last = ACTIVITY.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.replace(now) != Some(now) {
|
||||
drop(last);
|
||||
for h in hooks {
|
||||
h.activity(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::shutdown`].
|
||||
pub fn shutdown() {
|
||||
for hooks in all() {
|
||||
@@ -217,6 +310,13 @@ pub mod engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::first_person`].
|
||||
pub fn first_person(own: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
|
||||
for hooks in all() {
|
||||
hooks.first_person(own, draw);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::camera`].
|
||||
pub fn camera(own: &players::CameraView) -> Option<players::CameraOverride> {
|
||||
all().iter().find_map(|h| h.camera(own))
|
||||
@@ -255,6 +355,9 @@ mod tests {
|
||||
fn published(&self, open: bool) {
|
||||
self.0.lock().unwrap().push(format!("published {open}"));
|
||||
}
|
||||
fn activity(&self, now: Activity) {
|
||||
self.0.lock().unwrap().push(format!("{now:?}"));
|
||||
}
|
||||
fn frame(&self, frame: &players::Frame, sprites: &mut dyn players::SpriteStore) {
|
||||
self.0
|
||||
.lock()
|
||||
@@ -285,7 +388,14 @@ mod tests {
|
||||
return;
|
||||
};
|
||||
let turn = draw.ticks() / 100.0;
|
||||
draw.draw(&HAT, head, uv, &mut |p| p.pose("brim")[4] = turn);
|
||||
let look = players::Look::new(uv);
|
||||
draw.draw(&HAT, head, look, &mut |p| p.pose("brim")[4] = turn);
|
||||
}
|
||||
/// A spark at the eyes, as bright as the head turns.
|
||||
fn first_person(&self, _: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
|
||||
let alpha = draw.motion().head_yaw / 90.0;
|
||||
let spark = players::Particle::new([[0.0; 3]; 4], [[0.0; 2]; 4], alpha);
|
||||
draw.particles([0.0; 4], &[spark], players::Light::Full);
|
||||
}
|
||||
fn tick(&self, models: &mut dyn players::PlayerModels) {
|
||||
let mut ids = Vec::new();
|
||||
@@ -362,9 +472,10 @@ mod tests {
|
||||
))
|
||||
});
|
||||
|
||||
/// A player drawn at age 50, its head where the camera is; logs each draw.
|
||||
/// A player drawn at age 50, its head where the camera is, turned 45 degrees; logs each draw
|
||||
/// and its particles' alphas.
|
||||
#[derive(Default)]
|
||||
struct Canvas(Vec<(String, [[f32; 4]; 4], [f32; 4], f32)>);
|
||||
struct Canvas(Vec<(String, [[f32; 4]; 4], [f32; 4], f32)>, Vec<f32>);
|
||||
impl players::PlayerDraw for Canvas {
|
||||
fn pass(&self) -> players::Pass {
|
||||
players::Pass::World
|
||||
@@ -372,6 +483,15 @@ mod tests {
|
||||
fn ticks(&self) -> f32 {
|
||||
50.0
|
||||
}
|
||||
fn base(&self) -> [[f32; 4]; 4] {
|
||||
[[1.0, 0.0, 0.0, 0.0]; 4]
|
||||
}
|
||||
fn motion(&self) -> players::Motion {
|
||||
players::Motion::new([0.0; 3], 45.0, 0.0)
|
||||
}
|
||||
fn view(&self) -> players::View {
|
||||
players::View::default()
|
||||
}
|
||||
fn bone(&mut self, bone: players::Bone) -> Option<[[f32; 4]; 4]> {
|
||||
(bone == players::Bone::Head).then_some([[1.0, 0.0, 0.0, 0.0]; 4])
|
||||
}
|
||||
@@ -385,7 +505,7 @@ mod tests {
|
||||
&mut self,
|
||||
mesh: &std::sync::Arc<players::Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
uv: [f32; 4],
|
||||
look: players::Look,
|
||||
pose: &mut dyn FnMut(&mut dyn players::PartPoses),
|
||||
) {
|
||||
struct Rest([f32; 9]);
|
||||
@@ -396,7 +516,10 @@ mod tests {
|
||||
}
|
||||
let mut brim = Rest(mesh.children[0].pose);
|
||||
pose(&mut brim);
|
||||
self.0.push((mesh.name.clone(), matrix, uv, brim.0[4]));
|
||||
self.0.push((mesh.name.clone(), matrix, look.uv, brim.0[4]));
|
||||
}
|
||||
fn particles(&mut self, _: [f32; 4], particles: &[players::Particle], _: players::Light) {
|
||||
self.1.extend(particles.iter().map(|p| p.alpha));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,6 +538,14 @@ mod tests {
|
||||
for open in [false, true, true, true, false, false] {
|
||||
engine::published(open);
|
||||
}
|
||||
let world = Activity::World {
|
||||
play: Play::Singleplayer,
|
||||
dimension: Dimension::Nether,
|
||||
mode: GameMode::Creative,
|
||||
};
|
||||
for now in [Activity::Menus, Activity::Menus, world, world] {
|
||||
engine::activity(now);
|
||||
}
|
||||
engine::shutdown();
|
||||
assert!(engine::any());
|
||||
let mut atlas = Atlas::default();
|
||||
@@ -445,6 +576,8 @@ mod tests {
|
||||
0.5
|
||||
)]
|
||||
);
|
||||
engine::first_person(&model(players::Kind::Own), &mut canvas);
|
||||
assert_eq!(canvas.1, [0.5]);
|
||||
let mut models = Two(Default::default());
|
||||
engine::tick(&mut models);
|
||||
assert!(models.body_pose(3).is_some() && models.body_pose(7).is_some());
|
||||
@@ -454,6 +587,8 @@ mod tests {
|
||||
"started 0.2.31",
|
||||
"published true",
|
||||
"published false",
|
||||
"Menus",
|
||||
"World { play: Singleplayer, dimension: Nether, mode: Creative }",
|
||||
"frame 4"
|
||||
]
|
||||
);
|
||||
|
||||
@@ -274,8 +274,149 @@ impl Bone {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Pass {
|
||||
/// In the world, seen by the camera.
|
||||
/// In the world, seen by the camera (the window's own model too, in third person).
|
||||
World,
|
||||
/// The inventory's picture of the window's own model.
|
||||
Picture,
|
||||
/// The window's own model while its body is out of view (first person): no bone, only what
|
||||
/// floats around it ([`crate::Hooks::first_person`]).
|
||||
FirstPerson,
|
||||
}
|
||||
|
||||
/// How a player model moves this frame ([`PlayerDraw::motion`]), with the frame's part of a tick.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct Motion {
|
||||
/// Where its feet are, in the world.
|
||||
pub feet: [f64; 3],
|
||||
/// Where its head turns (degrees, the game's yaw and pitch).
|
||||
pub head_yaw: f32,
|
||||
pub head_pitch: f32,
|
||||
pub crouching: bool,
|
||||
/// Lies in a bed.
|
||||
pub asleep: bool,
|
||||
/// Flies with an elytra.
|
||||
pub flying: bool,
|
||||
/// Swims (any part of the swimming pose).
|
||||
pub swimming: bool,
|
||||
}
|
||||
|
||||
impl Motion {
|
||||
pub fn new(feet: [f64; 3], head_yaw: f32, head_pitch: f32) -> Self {
|
||||
Self {
|
||||
feet,
|
||||
head_yaw,
|
||||
head_pitch,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// With its poses: crouching, asleep, flying, swimming.
|
||||
pub fn poses(self, crouching: bool, asleep: bool, flying: bool, swimming: bool) -> Self {
|
||||
Self {
|
||||
crouching,
|
||||
asleep,
|
||||
flying,
|
||||
swimming,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera a player model is drawn for ([`PlayerDraw::view`]).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct View {
|
||||
/// Where it is, in the world: matrices and [`Particle`] corners are relative to it.
|
||||
pub camera: [f64; 3],
|
||||
/// Its right and up, for what faces it.
|
||||
pub right: [f32; 3],
|
||||
pub up: [f32; 3],
|
||||
/// Where the window's player looks, and their level left (first person).
|
||||
pub forward: [f32; 3],
|
||||
pub left: [f32; 3],
|
||||
}
|
||||
|
||||
impl View {
|
||||
pub fn new(camera: [f64; 3], right: [f32; 3], up: [f32; 3]) -> Self {
|
||||
Self {
|
||||
camera,
|
||||
right,
|
||||
up,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// With where the window's player looks and their level left.
|
||||
pub fn looking(self, forward: [f32; 3], left: [f32; 3]) -> Self {
|
||||
Self {
|
||||
forward,
|
||||
left,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a mesh or particles are lit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Light {
|
||||
/// As the player model is.
|
||||
Model,
|
||||
/// Fully lit: it glows.
|
||||
Full,
|
||||
/// By the world where the mesh's origin is (as particles: like [`Light::Model`]).
|
||||
AtPlace,
|
||||
}
|
||||
|
||||
/// How [`PlayerDraw::draw`] draws a mesh.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Look {
|
||||
/// The sprite part it is textured by (u0, v0, u1, v1).
|
||||
pub uv: [f32; 4],
|
||||
pub light: Light,
|
||||
/// Drawn see-through at this alpha, after every player, its back faces dropped.
|
||||
pub see_through: Option<f32>,
|
||||
}
|
||||
|
||||
impl Look {
|
||||
/// Opaque, lit as the model.
|
||||
pub fn new(uv: [f32; 4]) -> Self {
|
||||
Self {
|
||||
uv,
|
||||
light: Light::Model,
|
||||
see_through: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lit(self, light: Light) -> Self {
|
||||
Self { light, ..self }
|
||||
}
|
||||
|
||||
pub fn see_through(self, alpha: f32) -> Self {
|
||||
Self {
|
||||
see_through: Some(alpha),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A quad that faces the camera ([`PlayerDraw::particles`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Particle {
|
||||
/// Its corners, relative to the camera.
|
||||
pub corners: [[f32; 3]; 4],
|
||||
/// Each corner's place across the sprite part (0 to 1).
|
||||
pub uv: [[f32; 2]; 4],
|
||||
pub alpha: f32,
|
||||
}
|
||||
|
||||
impl Particle {
|
||||
pub fn new(corners: [[f32; 3]; 4], uv: [[f32; 2]; 4], alpha: f32) -> Self {
|
||||
Self { corners, uv, alpha }
|
||||
}
|
||||
}
|
||||
|
||||
/// The poses of a [`Mesh`]'s parts for one [`PlayerDraw::draw`], each at rest to begin with.
|
||||
@@ -291,19 +432,25 @@ pub trait PlayerDraw {
|
||||
fn pass(&self) -> Pass;
|
||||
/// The model's age in ticks, with the frame's part of a tick.
|
||||
fn ticks(&self) -> f32;
|
||||
/// Where `bone` is as the body is drawn this frame; None if the model has no such part.
|
||||
/// The model's own matrix, its origin at the neck (y down, 16 units a block).
|
||||
fn base(&self) -> [[f32; 4]; 4];
|
||||
fn motion(&self) -> Motion;
|
||||
fn view(&self) -> View;
|
||||
/// Where `bone` is as the body is drawn this frame; None if the model has no such part (or
|
||||
/// no body is drawn: [`Pass::FirstPerson`]).
|
||||
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]>;
|
||||
/// The pose laid over the model's body ([`PlayerModels::set_body_pose`]), if any.
|
||||
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>>;
|
||||
/// As [`Sprites::get`].
|
||||
fn sprite(&self, id: u64) -> Option<[f32; 4]>;
|
||||
/// Draws `mesh` at `matrix`, textured by the sprite part `uv` (u0, v0, u1, v1) and lit as the
|
||||
/// model is; `pose` poses its parts first.
|
||||
/// Draws `mesh` at `matrix` as `look` says; `pose` poses its parts first.
|
||||
fn draw(
|
||||
&mut self,
|
||||
mesh: &Arc<Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
uv: [f32; 4],
|
||||
look: Look,
|
||||
pose: &mut dyn FnMut(&mut dyn PartPoses),
|
||||
);
|
||||
/// Draws `particles`, textured by the sprite part `uv`, see-through after every player.
|
||||
fn particles(&mut self, uv: [f32; 4], particles: &[Particle], light: Light);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ horizon-platform = { path = "../horizon-platform" }
|
||||
horizon-heartbeat = { path = "../horizon-heartbeat" }
|
||||
horizon-friends = { path = "../horizon-friends", features = ["game"] }
|
||||
horizon-official-servers = { path = "../horizon-official-servers" }
|
||||
horizon-discord = { path = "../horizon-discord" }
|
||||
log = "0.4"
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -4,27 +4,36 @@
|
||||
//! (`game-build.yml`); horizon-client keeps its own plain executable.
|
||||
//!
|
||||
//! Each Horizon crate that runs in the game gives a `&'static` value implementing
|
||||
//! `horizon_platform::hooks::Hooks`, and [`register`] adds it. Nothing here depends on an engine
|
||||
//! crate.
|
||||
//! `horizon_platform::hooks::Hooks`, and its settings store and pages if it has any, and
|
||||
//! [`register`] adds them. Nothing here depends on an engine crate.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use horizon_platform::hooks::{self, Hooks};
|
||||
use horizon_platform::{pages, settings};
|
||||
|
||||
/// Every Horizon part in the game, in the order its hooks run.
|
||||
const PARTS: [&dyn Hooks; 3] = [
|
||||
const PARTS: [&dyn Hooks; 4] = [
|
||||
// The anonymous "I'm playing" ping.
|
||||
&horizon_heartbeat::HOOKS,
|
||||
// Play with Friends: `friend:` addresses join through its tunnel.
|
||||
&horizon_friends::game::HOOKS,
|
||||
// The official server in the player's server list.
|
||||
&horizon_official_servers::HOOKS,
|
||||
// "Playing Horizon" on the player's Discord profile.
|
||||
&horizon_discord::HOOKS,
|
||||
];
|
||||
/// Their settings, in the player's settings files.
|
||||
const STORES: [&dyn settings::Store; 1] = [&horizon_discord::SETTING];
|
||||
/// Their rows and pages in the options screens.
|
||||
const PAGES: [&dyn pages::Page; 1] = [&horizon_discord::SECTION];
|
||||
|
||||
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
|
||||
/// game, and so does the launcher's generated executable for native mods.
|
||||
pub fn register() {
|
||||
for part in PARTS {
|
||||
if let Err(e) = hooks::register(part) {
|
||||
let late = PARTS.into_iter().map(hooks::register);
|
||||
let late = late.chain(STORES.into_iter().map(settings::register));
|
||||
for result in late.chain(PAGES.into_iter().map(pages::register)) {
|
||||
if let Err(e) = result {
|
||||
// The executable registered after starting the game: a bug, not a player's problem.
|
||||
log::warn!("Horizon: an in-game part did not register: {e}");
|
||||
}
|
||||
|
||||
@@ -50,11 +50,14 @@ pub mod hooks {
|
||||
/// [`Hooks::tick`] get, the pose a crate lays over a player's body, what goes by their names,
|
||||
/// the crate's sprites, the camera, and what a crate draws on them.
|
||||
pub use horizon_hooks::players::{
|
||||
BODY_PARTS, Badge, BodyPose, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Mark,
|
||||
Mesh, NameDecoration, PartPoses, Pass, PlayerDraw, PlayerModel, PlayerModels, Quad,
|
||||
SpriteStore, Sprites,
|
||||
BODY_PARTS, Badge, BodyPose, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Light,
|
||||
Look, Mark, Mesh, Motion, NameDecoration, PartPoses, Particle, Pass, PlayerDraw,
|
||||
PlayerModel, PlayerModels, Quad, SpriteStore, Sprites, View,
|
||||
};
|
||||
pub use horizon_hooks::{
|
||||
Activity, Client, Dimension, GameMode, Hooks, Play, Started, TooLate, register, seat,
|
||||
tunnel,
|
||||
};
|
||||
pub use horizon_hooks::{Hooks, Started, TooLate, register, seat, tunnel};
|
||||
}
|
||||
|
||||
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
|
||||
|
||||
+190
-10
@@ -64,11 +64,13 @@ 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 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.
|
||||
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).
|
||||
|
||||
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
|
||||
@@ -90,6 +92,179 @@ 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
|
||||
@@ -469,9 +644,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 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, 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.
|
||||
|
||||
The inventory crosshair is `crosshair_inv` in the same folder, drawn at 16 GUI pixels on the point it
|
||||
clicks (`pad_cursor.rs`).
|
||||
@@ -491,7 +666,8 @@ 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 is a list like the key binds screen. Each action's row shows its button's art in the player's
|
||||
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
|
||||
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
|
||||
@@ -525,11 +701,13 @@ bind_key.jump:south
|
||||
craftOnServers:false
|
||||
gameplayGuide:full
|
||||
bind_pad.hotbarNext:rb
|
||||
bindLayout:1
|
||||
bindLayout:2
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -552,7 +730,9 @@ 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.
|
||||
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).
|
||||
|
||||
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
-3
@@ -23,14 +23,14 @@ What changes in that build:
|
||||
|---|---|---|
|
||||
| Cosmetics, emotes and the emote wheel, "Show me as a Horizon player" | `game/cosmetics.rs` (+ `cosmetics/fx.rs`, `cosmetics/emotes.rs`, `cosmetics/emote_wheel.rs`), crate `horizon-cosmetics` | `game::cosmetics::enabled()`: the service is never asked, nothing is drawn, B opens no wheel |
|
||||
| The official server added to the server list, the withdrawn partner server taken out | crate `horizon-official-servers` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
|
||||
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent, Discord) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
|
||||
| Discord Rich Presence | `discord_presence.rs`, `game/discord.rs` | the `discord` feature, which `horizon-extras` turns on |
|
||||
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
|
||||
| Discord Rich Presence and its switch | crate `horizon-discord` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
|
||||
| Play with Friends | hosting: `game/friends.rs`, `game/friends/screen.rs`; joining: crate `horizon-friends` (`game`) | hosting: the `friends` feature (`game/friends_off.rs` stands in); joining: only the official executable registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
|
||||
| The anonymous "I'm playing" ping | crate `horizon-heartbeat` | not in horizon-client at all: only the official executable (`horizon-official`) registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
|
||||
|
||||
Credits & Attribution then says where the official build is (`horizon.plain.official`).
|
||||
|
||||
`grep -rn horizon-extras crates/` lists every gate. Apart from the three features above, the gates
|
||||
`grep -rn horizon-extras crates/` lists every gate. Apart from the `friends` feature above, the gates
|
||||
are `cfg!(feature = "horizon-extras")`: both sides compile in every build, so no stub has to follow
|
||||
the code it replaces. To remove an extra for good, delete its files from the table and the lines its
|
||||
gate guards.
|
||||
|
||||
+2
-2
@@ -156,8 +156,8 @@ Horizon's own extras and its product, and every built-in address of an outside s
|
||||
benchmark uploads, releases and updates, the hosting supervisor, the launcher.
|
||||
- **Examples:** `horizon-addresses`, `horizon-cosmetics`, `horizon-friends`, `horizon-relay`, `horizon-telemetry`,
|
||||
`horizon-bench`, `horizon-release-sign`, `horizon-server`, `horizon-mod-registry`, `horizon-heartbeat`,
|
||||
`horizon-official`, `horizon-official-servers`, `launcher/`. Still in engine crates today: `game/cosmetics.rs`, `game/emotes.rs`,
|
||||
`game/discord.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_ui.rs`, `pvp.rs`.
|
||||
`horizon-official`, `horizon-official-servers`, `horizon-discord`, `launcher/`. Still in engine crates today:
|
||||
`game/cosmetics.rs`, `game/emotes.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_ui.rs`, `pvp.rs`.
|
||||
- **Rules:**
|
||||
- It uses the platform's public API only, never the engine.
|
||||
- Until it moves, every Horizon feature in the game sits behind the `horizon-extras` feature
|
||||
|
||||
+11
-6
@@ -423,13 +423,18 @@ While Discord runs on the same computer, the player's Discord profile shows
|
||||
"Playing Horizon" and what they are doing: "In the menus", "Singleplayer",
|
||||
"Multiplayer" or "Hosting a world" (with the party size, of 8), the dimension
|
||||
("In the Nether") and the time since they entered the world. It never shows a
|
||||
world's name or a server's address. Horizon Settings has the switch
|
||||
(**Discord Rich Presence**, on by default; `discordPresence` in
|
||||
`horizon-extras.txt`), and `HORIZON_NO_DISCORD=1` turns it off for one run.
|
||||
world's name or a server's address. The switch is on the Extras page, in its
|
||||
Multiplayer group (**Discord Rich Presence**, on by default; `discordPresence`
|
||||
in `horizon-extras.txt`; in splitscreen only the first player has it), and
|
||||
`HORIZON_NO_DISCORD=1` turns it off for one run.
|
||||
|
||||
`crates/horizon-client/src/discord_presence.rs` speaks Discord's local IPC
|
||||
itself (the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its
|
||||
own: the frame only hands it a status when it changes. It retries every 15 s
|
||||
The crate `horizon-discord` (Horizon layer) speaks Discord's local IPC itself
|
||||
(the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its own.
|
||||
The game hands it a status only when it changes, through the platform's
|
||||
`activity` hook (`game/activity.rs` says what the player does), and keeps the
|
||||
switch itself: its settings store and its Extras row (the platform's settings
|
||||
and pages registries). Only the official executable (`horizon-official`)
|
||||
registers it: horizon-client's own executable shows no presence and no switch. It retries every 15 s
|
||||
while Discord isn't running and stays within Discord's 5 updates per 20 s.
|
||||
Dedicated servers never start it, and automated runs (offscreen, benchmarks)
|
||||
only talk to a fake Discord: `lab.game_env` sets `HORIZON_NO_DISCORD=1`, and
|
||||
|
||||
+2
-2
@@ -427,8 +427,8 @@ game built in `Game::new`, the integrated server on its own thread. On that base
|
||||
**Binaries.** The release archives hold `horizon-client`, `horizon-ctl`, `horizon-prepare` and
|
||||
`horizon-server`. On Linux the release also has `horizon-server-linux-x86_64.tar.gz`: `horizon-server`,
|
||||
`horizon-prepare` and `horizon-engine`, the game built with `--no-default-features` (no sound, no
|
||||
controllers, no Discord presence or Play with Friends: the `discord` and `friends` features; nor the "I'm
|
||||
playing" ping, which only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
|
||||
controllers, no Play with Friends: the `friends` feature; nor Discord presence or the "I'm playing" ping, which
|
||||
only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
|
||||
client archive (`xtask package-server`) and fails if `tools/check_imports.py --server` finds either library;
|
||||
CI lints the same build. `horizon-server` runs `horizon-engine` when it is beside it or on the PATH, else
|
||||
`horizon-client`. Build it yourself with `cargo build --release -p horizon-client --no-default-features`
|
||||
|
||||
+72
-3
@@ -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 |
|
||||
| `hud_kit::room` | `Room`: what of a view an overlay may use, clear of vanilla's boss bars and status rows |
|
||||
| `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,11 +32,12 @@ 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. Today there is one kind:
|
||||
and an area. There are two kinds:
|
||||
|
||||
| `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
|
||||
@@ -64,6 +65,23 @@ 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
|
||||
@@ -207,6 +225,30 @@ 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:
|
||||
@@ -382,7 +424,9 @@ 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.
|
||||
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`.
|
||||
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
|
||||
@@ -402,6 +446,11 @@ 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`.
|
||||
@@ -425,6 +474,26 @@ 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,9 +493,12 @@ 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, scripted: bool,
|
||||
options: Options, screen: Screen, mouse: MouseLook, last_input: Instant, pad: Pad, scripted: bool,
|
||||
}
|
||||
impl Game {
|
||||
fn scripted_run(&self) -> bool { self.scripted }
|
||||
@@ -503,26 +506,30 @@ 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,
|
||||
// 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),
|
||||
// (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),
|
||||
] {
|
||||
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(), scripted,
|
||||
mouse: MouseLook::default(), last_input: Instant::now(), pad: Pad { radial: Radial(radial) },
|
||||
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) });
|
||||
@@ -532,7 +539,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}, scripted={scripted}", rotation.y_rot, rotation.x_rot);
|
||||
"rotation=({}, {}), offscreen={offscreen}, split={split}, owner={owner}, active={active}, playing={playing}, saving={saving}, radial={radial}, scripted={scripted}", rotation.y_rot, rotation.x_rot);
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
@@ -9,7 +9,7 @@ The game goes title -> world -> Nether -> quit through horizon-ctl, and the acti
|
||||
single single player on its server thread
|
||||
lan a host opens its world to LAN and a guest joins: the guest shows "Multiplayer",
|
||||
the host "Hosting a world" with a party of 2 of 8
|
||||
setting off in horizon-extras.txt: nothing reaches Discord; the Horizon Settings switch
|
||||
setting off in horizon-extras.txt: nothing reaches Discord; the Extras switch
|
||||
turns it on (the activity appears) and off (it is cleared)
|
||||
late Discord starts after the game: found within the 15 s retry
|
||||
|
||||
@@ -136,7 +136,7 @@ class Game:
|
||||
self.discord.start()
|
||||
extra = {'HORIZON_HOME': self.home, 'HORIZON_TELEMETRY_URL': 'off', 'HORIZON_DISCORD_IPC_DIR': self.discord.dir}
|
||||
self.env = lab.game_env(**extra)
|
||||
self.ctl_run('launch', '--offscreen', '--game', str(bins / 'horizon-client'), '--resources',
|
||||
self.ctl_run('launch', '--offscreen', '--game', str(bins / 'horizon-official'), '--resources',
|
||||
str(lab.RESOURCES.resolve()), *args)
|
||||
|
||||
def ctl_run(self, *words, timeout=600):
|
||||
@@ -207,8 +207,21 @@ def lan(bins):
|
||||
g.close()
|
||||
|
||||
|
||||
def extras_row(game, label):
|
||||
"""The Extras page's row whose label starts with `label`, scrolling the list down to it: only
|
||||
the rows on screen are widgets."""
|
||||
first = game.ctl_run('ui')['widgets'][0]['bounds']
|
||||
for _ in range(20):
|
||||
found = [w for w in game.ctl_run('ui')['widgets'] if (w.get('label') or '').startswith(label)]
|
||||
if found:
|
||||
return found[0]
|
||||
game.ctl_run('scroll-at', str(first[0] + 5), str(first[1] + 5), '-5')
|
||||
time.sleep(0.3)
|
||||
raise AssertionError(f'no {label!r} row on the Extras page')
|
||||
|
||||
|
||||
def setting(bins):
|
||||
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Horizon Settings: the
|
||||
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Extras: the
|
||||
activity appears; off again: it is cleared."""
|
||||
game = Game(bins, 'setting', extras='discordPresence:false\n')
|
||||
d = game.discord
|
||||
@@ -216,8 +229,8 @@ def setting(bins):
|
||||
game.ctl_run('wait-for', 'screen', 'title')
|
||||
time.sleep(3)
|
||||
assert not d.handshakes and not d.activities, (d.handshakes, d.activities)
|
||||
game.ctl_run('options', 'horizon')
|
||||
switch = next(w for w in game.ctl_run('ui')['widgets'] if w.get('label', '').startswith('Discord Rich'))
|
||||
game.ctl_run('options', 'extras')
|
||||
switch = extras_row(game, 'Discord Rich')
|
||||
assert switch['label'] == 'Discord Rich Presence: Off', switch
|
||||
game.ctl_run('click', switch['id'])
|
||||
d.wait('turned on', shows('In the menus'))
|
||||
@@ -251,14 +264,14 @@ CHECKS = {'single': single, 'lan': lan, 'setting': setting, 'late': late}
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--check', choices=[*CHECKS, 'all'], default='all')
|
||||
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
|
||||
parser.add_argument('--bin-dir', help='prebuilt horizon-official and horizon-ctl (default: build with the fast profile)')
|
||||
args = parser.parse_args()
|
||||
if sys.platform == 'win32':
|
||||
sys.exit('the fake Discord is a Unix socket: macOS or Linux only')
|
||||
if args.bin_dir:
|
||||
bins = Path(args.bin_dir).resolve()
|
||||
else:
|
||||
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-p', 'horizon-client-lib', '--bins'],
|
||||
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-official', '-p', 'horizon-client-lib', '--bins'],
|
||||
cwd=ROOT, check=True)
|
||||
meta = json.loads(subprocess.run(['cargo', 'metadata', '--format-version', '1', '--no-deps'], cwd=ROOT,
|
||||
capture_output=True, text=True, check=True).stdout)
|
||||
|
||||
+2
-4
@@ -51,6 +51,7 @@
|
||||
"horizon-addresses": "horizon",
|
||||
"horizon-bench": "horizon",
|
||||
"horizon-cosmetics": "horizon",
|
||||
"horizon-discord": "horizon",
|
||||
"horizon-friends": "horizon",
|
||||
"horizon-heartbeat": "horizon",
|
||||
"horizon-launcher": "horizon",
|
||||
@@ -95,7 +96,6 @@
|
||||
"horizon-client -> horizon-shaders",
|
||||
"horizon-client -> horizon-telemetry",
|
||||
"horizon-client -> horizon-theme",
|
||||
"horizon-client-input -> horizon-theme",
|
||||
"horizon-client-lib -> horizon-anticheat",
|
||||
"horizon-client-lib -> horizon-bench",
|
||||
"horizon-client-lib -> horizon-chat-filter",
|
||||
@@ -106,13 +106,11 @@
|
||||
"horizon-client-lib -> horizon-shader-api",
|
||||
"horizon-client-lib -> horizon-shaders",
|
||||
"horizon-client-lib -> horizon-telemetry",
|
||||
"horizon-client-lib -> horizon-theme",
|
||||
"horizon-client-render -> horizon-cosmetics",
|
||||
"horizon-client-render -> horizon-hdr",
|
||||
"horizon-client-render -> horizon-optifine",
|
||||
"horizon-client-render -> horizon-polytone",
|
||||
"horizon-client-render -> horizon-shader-api",
|
||||
"horizon-client-render -> horizon-shaders",
|
||||
"horizon-client-render -> horizon-theme"
|
||||
"horizon-client-render -> horizon-shaders"
|
||||
]
|
||||
}
|
||||
+1118
-3
File diff suppressed because it is too large.
Load diff
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user