mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 17:19:15 -10:00
Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
911e23fb55 | ||
|
|
ffaaa63836 | ||
|
|
537539fe58 | ||
|
|
cbbd362eed | ||
|
|
1430b9990a | ||
|
|
a84f637dd6 | ||
|
|
efab4d1bbf | ||
|
|
7b50bae250 | ||
|
|
3780c215a7 | ||
|
|
3c853b46eb | ||
|
|
e64af82d00 | ||
|
|
37bf47f477 | ||
|
|
03d8150bdb | ||
|
|
0b8d44458a | ||
|
|
c21f627431 | ||
|
|
62e62c21e6 | ||
|
|
a6350aa8f9 | ||
|
|
c817ed7fff | ||
|
|
b020ab26b9 | ||
|
|
35f095bd41 | ||
|
|
4e22c1ad9a | ||
|
|
e1aa435188 | ||
|
|
128575e4e5 | ||
|
|
cb870cf80d | ||
|
|
e138304d2e | ||
|
|
5385996153 | ||
|
|
ee60188af7 | ||
|
|
f518ef4c07 | ||
|
|
2719b22258 | ||
|
|
3da374ef18 | ||
|
|
4be9343eca | ||
|
|
03fd6705a1 | ||
|
|
a5a60598e4 | ||
|
|
0fd4e22139 | ||
|
|
ec19b6320a | ||
|
|
16e29a2774 | ||
|
|
d016678e24 | ||
|
|
3d9df204e9 | ||
|
|
984b7d8635 | ||
|
|
afa60454b2 | ||
|
|
aa8592fbf9 | ||
|
|
cef35e64e7 | ||
|
|
3a9b05d21d | ||
|
|
8aed306751 | ||
|
|
bb19f898e4 | ||
|
|
bbc17333bd | ||
|
|
8630bbed74 | ||
|
|
2c5baa6a60 | ||
|
|
ed35d477be | ||
|
|
7a236ba054 | ||
|
|
bf125a1a69 | ||
|
|
385dc78afc | ||
|
|
661a253ec4 | ||
|
|
7bfa76577f | ||
|
|
8ac6a79035 | ||
|
|
153e0d20a4 | ||
|
|
01343707af | ||
|
|
c873af989a | ||
|
|
84baabff3c | ||
|
|
6622e459b2 | ||
|
|
600a863354 | ||
|
|
b77e4c4924 | ||
|
|
96aa9791e6 | ||
|
|
aa060c5fe1 | ||
|
|
07ff96c50c | ||
|
|
19c23a96c6 | ||
|
|
29d8c49bc2 | ||
|
|
c66936f747 | ||
|
|
1d52dd41ba | ||
|
|
0a6874877d | ||
|
|
e2ad02a58a | ||
|
|
95a3721df1 | ||
|
|
4a296fffb2 | ||
|
|
300d65c317 | ||
|
|
18c8cc5eb9 | ||
|
|
f9baaaed27 | ||
|
|
72352e0006 | ||
|
|
d4da822ac2 | ||
|
|
26f8689238 |
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 = []
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! 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,
|
||||
}
|
||||
|
||||
/// `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,
|
||||
},
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
//! sections are immutable behind `Arc` and edited through `Arc::make_mut`, so
|
||||
//! a weak reference tells an edited or replaced section from the one the list
|
||||
//! was built from (see `random_tick_sections`), without keeping its blocks.
|
||||
use crate::{chunk::Section, fast_hash::FxHashMap};
|
||||
use crate::{
|
||||
chunk::{Chunk, Section},
|
||||
fast_hash::FxHashMap,
|
||||
};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
/// A section's POIs: the block index in the section (`y << 8 | z << 4 | x`)
|
||||
@@ -18,13 +21,16 @@ struct Cached<T> {
|
||||
section: Weak<Section>,
|
||||
pois: SectionPois<T>,
|
||||
}
|
||||
/// Cached POI lists by section, `(chunk x, section index, chunk z)`.
|
||||
/// Cached POI lists by chunk column `(chunk x, chunk z)`, each by section
|
||||
/// index: a search through a column looks it up once.
|
||||
pub struct PoiSections<T> {
|
||||
sections: Mutex<Lists<T>>,
|
||||
}
|
||||
struct Lists<T> {
|
||||
by_section: FxHashMap<(i32, i32, i32), Cached<T>>,
|
||||
/// Size above which lists of sections no longer resident are dropped.
|
||||
by_column: FxHashMap<(i32, i32), Vec<Option<Cached<T>>>>,
|
||||
/// How many section lists are kept, and the count above which the lists
|
||||
/// of sections no longer resident are dropped.
|
||||
len: usize,
|
||||
prune_at: usize,
|
||||
}
|
||||
const MIN_PRUNE: usize = 4096;
|
||||
@@ -32,27 +38,61 @@ impl<T> Default for PoiSections<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sections: Mutex::new(Lists {
|
||||
by_section: FxHashMap::default(),
|
||||
by_column: FxHashMap::default(),
|
||||
len: 0,
|
||||
prune_at: MIN_PRUNE,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: Copy> PoiSections<T> {
|
||||
/// The POIs of `section`, whose block states `kind` classifies. `kind`
|
||||
/// must give the same answer for a state on every call.
|
||||
pub fn get(
|
||||
&self,
|
||||
key: (i32, i32, i32),
|
||||
section: &Arc<Section>,
|
||||
mut kind: impl FnMut(u32) -> Option<T>,
|
||||
) -> SectionPois<T> {
|
||||
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(cached) = lists.by_section.get(&key)
|
||||
&& cached.section.as_ptr() == Arc::as_ptr(section)
|
||||
{
|
||||
return cached.pois.clone();
|
||||
impl<T: Copy> Lists<T> {
|
||||
/// The column's lists, for sections up to `last`, after dropping the
|
||||
/// lists of sections no longer resident if there are too many.
|
||||
fn column(
|
||||
&mut self,
|
||||
column: (i32, i32),
|
||||
last: usize,
|
||||
) -> (&mut [Option<Cached<T>>], &mut usize) {
|
||||
if self.len >= self.prune_at {
|
||||
self.prune();
|
||||
}
|
||||
let lists = self.by_column.entry(column).or_default();
|
||||
if lists.len() <= last {
|
||||
lists.resize_with(last + 1, || None);
|
||||
}
|
||||
(lists, &mut self.len)
|
||||
}
|
||||
|
||||
/// Drops the lists of sections no longer resident.
|
||||
fn prune(&mut self) {
|
||||
let mut len = 0;
|
||||
self.by_column.retain(|_, lists| {
|
||||
for c in lists.iter_mut() {
|
||||
if c.as_ref().is_some_and(|c| c.section.strong_count() == 0) {
|
||||
*c = None;
|
||||
}
|
||||
}
|
||||
let kept = lists.iter().filter(|c| c.is_some()).count();
|
||||
len += kept;
|
||||
kept > 0
|
||||
});
|
||||
self.len = len;
|
||||
self.prune_at = (len * 2).max(MIN_PRUNE);
|
||||
}
|
||||
}
|
||||
|
||||
/// The POIs of `section` from `slot`, listed again (and kept there) unless
|
||||
/// `slot` was listed from this very section; `len` counts the slots filled.
|
||||
fn list<'a, T: Copy>(
|
||||
slot: &'a mut Option<Cached<T>>,
|
||||
len: &mut usize,
|
||||
section: &Arc<Section>,
|
||||
kind: &mut impl FnMut(u32) -> Option<T>,
|
||||
) -> &'a SectionPois<T> {
|
||||
let fresh = slot
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.section.as_ptr() == Arc::as_ptr(section));
|
||||
if !fresh {
|
||||
let pois: SectionPois<T> = if section.blocks.maybe_has(|v| kind(v).is_some()) {
|
||||
let mut states = [0u32; 4096];
|
||||
section.unpack(&mut states);
|
||||
@@ -64,18 +104,56 @@ impl<T: Copy> PoiSections<T> {
|
||||
} else {
|
||||
Arc::from([])
|
||||
};
|
||||
if lists.by_section.len() >= lists.prune_at {
|
||||
lists.by_section.retain(|_, c| c.section.strong_count() > 0);
|
||||
lists.prune_at = (lists.by_section.len() * 2).max(MIN_PRUNE);
|
||||
*len += usize::from(slot.is_none());
|
||||
*slot = Some(Cached {
|
||||
section: Arc::downgrade(section),
|
||||
pois,
|
||||
});
|
||||
}
|
||||
&slot.as_ref().expect("just listed").pois
|
||||
}
|
||||
|
||||
impl<T: Copy> PoiSections<T> {
|
||||
/// The POIs of `section`, the `key.1`-th section of the column
|
||||
/// `(key.0, key.2)`, whose block states `kind` classifies. `kind` must give
|
||||
/// the same answer for a state on every call.
|
||||
pub fn get(
|
||||
&self,
|
||||
key: (i32, i32, i32),
|
||||
section: &Arc<Section>,
|
||||
mut kind: impl FnMut(u32) -> Option<T>,
|
||||
) -> SectionPois<T> {
|
||||
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let s = usize::try_from(key.1).expect("a section index");
|
||||
let (column, len) = lists.column((key.0, key.2), s);
|
||||
list(&mut column[s], len, section, &mut kind).clone()
|
||||
}
|
||||
|
||||
/// The POIs of `chunk` from block Y `y0` to `y1`, its first section at
|
||||
/// `min_y`, as [`Self::get`] gives them: each section that has any, with
|
||||
/// the Y of its lowest blocks, in order, put in `out`. One lookup of the
|
||||
/// column.
|
||||
pub fn column(
|
||||
&self,
|
||||
chunk: &Chunk,
|
||||
min_y: i32,
|
||||
(y0, y1): (i32, i32),
|
||||
mut kind: impl FnMut(u32) -> Option<T>,
|
||||
out: &mut Vec<(i32, SectionPois<T>)>,
|
||||
) {
|
||||
out.clear();
|
||||
let (first, last) = ((y0 - min_y) >> 4, (y1 - min_y) >> 4);
|
||||
if first > last {
|
||||
return;
|
||||
}
|
||||
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (slots, len) = lists.column((chunk.x, chunk.z), last as usize);
|
||||
for s in first as usize..=last as usize {
|
||||
let pois = list(&mut slots[s], len, &chunk.sections[s], &mut kind);
|
||||
if !pois.is_empty() {
|
||||
out.push((min_y + s as i32 * 16, pois.clone()));
|
||||
}
|
||||
}
|
||||
lists.by_section.insert(
|
||||
key,
|
||||
Cached {
|
||||
section: Arc::downgrade(section),
|
||||
pois: pois.clone(),
|
||||
},
|
||||
);
|
||||
pois
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +221,48 @@ mod tests {
|
||||
assert!(index.get((0, 0, 0), &chunk.sections[0], kind).is_empty());
|
||||
}
|
||||
|
||||
/// A column gives each section's list as `get` does, the sections
|
||||
/// without any left out, and keeps them until a section changes.
|
||||
#[test]
|
||||
fn a_column_gives_what_each_section_gives() {
|
||||
let index = PoiSections::<u8>::default();
|
||||
let kind = |v: u32| (v == 5).then_some(5);
|
||||
let mut chunk = Arc::new(Chunk::empty(2, -3));
|
||||
for (x, y, z) in [(1, MIN_Y + 2, 3), (4, MIN_Y + 40, 0), (0, MIN_Y + 41, 15)] {
|
||||
Arc::make_mut(&mut chunk).set(x, y, z, 5);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
index.column(&chunk, MIN_Y, (MIN_Y, MIN_Y + 63), kind, &mut out);
|
||||
let bases: Vec<i32> = out.iter().map(|(base, _)| *base).collect();
|
||||
assert_eq!(bases, [MIN_Y, MIN_Y + 32]);
|
||||
for (base, pois) in &out {
|
||||
let s = (base - MIN_Y) / 16;
|
||||
let one = index.get((2, s, -3), &chunk.sections[s as usize], kind);
|
||||
assert!(Arc::ptr_eq(pois, &one));
|
||||
}
|
||||
// Kept: no rescan.
|
||||
index.column(
|
||||
&chunk,
|
||||
MIN_Y,
|
||||
(MIN_Y, MIN_Y + 63),
|
||||
|_| panic!("rescanned"),
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(out.len(), 2);
|
||||
// An edit lists that section again.
|
||||
Arc::make_mut(&mut chunk).set(4, MIN_Y + 40, 0, 1);
|
||||
let mut rescans = 0;
|
||||
let counted = |v: u32| {
|
||||
rescans += 1;
|
||||
kind(v)
|
||||
};
|
||||
index.column(&chunk, MIN_Y, (MIN_Y + 20, MIN_Y + 63), counted, &mut out);
|
||||
assert!(rescans > 0);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].0, MIN_Y + 32);
|
||||
assert_eq!(&*out[0].1, &[(9 << 8 | 15 << 4, 5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloaded_sections_are_dropped() {
|
||||
let index = PoiSections::<u8>::default();
|
||||
@@ -153,7 +273,7 @@ mod tests {
|
||||
index.get((1, i, 0), &Arc::new(Section::default()), kind);
|
||||
}
|
||||
let lists = index.sections.lock().unwrap();
|
||||
assert!(lists.by_section.len() <= 2);
|
||||
assert!(lists.by_section.contains_key(&(0, 0, 0)));
|
||||
assert!(lists.len <= 2);
|
||||
assert!(lists.by_column[&(0, 0)][0].is_some());
|
||||
}
|
||||
}
|
||||
@@ -237,17 +237,17 @@ impl<P: Copy, S: Copy> Lines<P, S> {
|
||||
pub(crate) fn backdrop(batch: &mut HudBatch, atlas: &HudAtlas, in_world: bool) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
style::fill(batch, atlas, [0.0, 0.0, w, h], style::VEIL);
|
||||
style::fill(batch, atlas, [0.0, 0.0, w, h], style::veil());
|
||||
}
|
||||
|
||||
/// The mark and the screen's name over the search field.
|
||||
pub(crate) fn mark(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, title: &str) {
|
||||
let (x, y) = (cols.side_x, cols.side_y);
|
||||
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::ACCENT);
|
||||
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::accent());
|
||||
let horizon = Text::literal("Horizon");
|
||||
batch.text_styled(atlas, &horizon, x + 10.0, y, style::WHITE, true);
|
||||
let title = Text::literal(title);
|
||||
batch.text_styled(atlas, &title, x, y + 10.0, style::MUTED, true);
|
||||
batch.text_styled(atlas, &title, x, y + 10.0, style::muted(), true);
|
||||
}
|
||||
|
||||
/// A page in the page list: the open page on a plate with the accent's bar beside it, the
|
||||
@@ -273,13 +273,13 @@ pub(crate) fn page_tab(
|
||||
crate::widgets::target_as(rect, crate::widgets::Kind::Tab(open));
|
||||
}
|
||||
if open {
|
||||
style::fill(batch, atlas, rect, style::OPEN);
|
||||
style::fill(batch, atlas, [px, py, 2.0, ph], style::ACCENT);
|
||||
style::fill(batch, atlas, rect, style::open());
|
||||
style::fill(batch, atlas, [px, py, 2.0, ph], style::accent());
|
||||
}
|
||||
let color = match (open || hover, has) {
|
||||
(true, _) => style::WHITE,
|
||||
(false, true) => style::SOFT,
|
||||
(false, false) => style::MUTED,
|
||||
(false, true) => style::soft(),
|
||||
(false, false) => style::muted(),
|
||||
};
|
||||
let ty = py + ((ph - 8.0) / 2.0).floor();
|
||||
let name = Text::literal(style::fit(atlas, name, pw - 12.0));
|
||||
@@ -312,12 +312,12 @@ pub(crate) fn search_field(
|
||||
search.draw(batch, atlas, None);
|
||||
if search.value.is_empty() && !focused {
|
||||
let hint = Text::literal(search.message.clone());
|
||||
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::MUTED, true);
|
||||
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::muted(), true);
|
||||
}
|
||||
if !search.value.is_empty() {
|
||||
let hover = ui::hovered(cursor, clear[0], clear[1], clear[2], clear[3]);
|
||||
crate::widgets::widget_cursor(hover, true);
|
||||
let color = if hover { style::WHITE } else { style::MUTED };
|
||||
let color = if hover { style::WHITE } else { style::muted() };
|
||||
let cy = y + ((h - 8.0) / 2.0).floor();
|
||||
ui::centered_text(
|
||||
batch,
|
||||
@@ -344,7 +344,7 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
|
||||
batch,
|
||||
atlas,
|
||||
[px + 8.0, py + 24.0, pw - 16.0, 1.0],
|
||||
style::HEAD_RULE,
|
||||
style::head_rule(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -383,9 +383,9 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
}
|
||||
let Some(setting) = line.setting else {
|
||||
let (name, color, dy) = match line.group {
|
||||
Some(("", _)) => (page_name(line.page), style::MUTED, 3.0),
|
||||
Some((name, _)) => (lang::get(name), style::MUTED, 3.0),
|
||||
None => (page_name(line.page), style::ACCENT, 4.0),
|
||||
Some(("", _)) => (page_name(line.page), style::muted(), 3.0),
|
||||
Some((name, _)) => (lang::get(name), style::muted(), 3.0),
|
||||
None => (page_name(line.page), style::accent(), 4.0),
|
||||
};
|
||||
let name = Text::literal(name);
|
||||
batch.text_styled(atlas, &name, cols.list_x + 2.0, y + dy, color, true);
|
||||
@@ -393,7 +393,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
};
|
||||
if !after_name {
|
||||
let rule = [cols.list_x + 8.0, y, cols.list_w - 16.0, 1.0];
|
||||
style::fill(batch, atlas, rule, style::ROW_RULE);
|
||||
style::fill(batch, atlas, rule, style::row_rule());
|
||||
}
|
||||
row(batch, setting, y);
|
||||
}
|
||||
@@ -404,7 +404,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
&none,
|
||||
cols.list_x + 2.0,
|
||||
cols.top + 4.0,
|
||||
style::MUTED,
|
||||
style::muted(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -499,7 +499,7 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
|
||||
let active = live && (hover || focused || held);
|
||||
if active {
|
||||
let lit = [rect[0] + 2.0, y + 1.0, rect[2] - 4.0, style::ROW - 2.0];
|
||||
style::fill(batch, atlas, lit, style::HOVER);
|
||||
style::fill(batch, atlas, lit, style::hover());
|
||||
}
|
||||
crate::widgets::widget_cursor(hover, live);
|
||||
// The control socket's widget tree, the narrator and a controller's focus.
|
||||
@@ -523,8 +523,8 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
|
||||
if live {
|
||||
crate::widgets::target_as(rect, kind);
|
||||
}
|
||||
let color = if live { style::WHITE } else { style::MUTED };
|
||||
let soft = if live { style::SOFT } else { style::MUTED };
|
||||
let color = if live { style::WHITE } else { style::muted() };
|
||||
let soft = if live { style::soft() } else { style::muted() };
|
||||
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
|
||||
let lit = live && (hover || held);
|
||||
let left = rect[0] + style::INSET;
|
||||
@@ -611,7 +611,7 @@ fn lit(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, _]: [f32; 4]) {
|
||||
batch,
|
||||
atlas,
|
||||
[x + 2.0, y + 1.0, w - 4.0, style::ROW - 2.0],
|
||||
style::HOVER,
|
||||
style::hover(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -644,7 +644,7 @@ fn read_row(
|
||||
let room = rect[2] - 2.0 * style::INSET - name_w - 8.0;
|
||||
let value = style::fit(atlas, value, room);
|
||||
let vx = rect[0] + rect[2] - style::INSET - atlas.font.width(&value);
|
||||
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::SOFT, true);
|
||||
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::soft(), true);
|
||||
if focused {
|
||||
style::ring(
|
||||
batch,
|
||||
@@ -749,7 +749,7 @@ fn pad_row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32, view:
|
||||
batch.text_styled(atlas, &yellow(close), right.ceil(), ty, style::WHITE, true);
|
||||
}
|
||||
let left = rect[0] + style::INSET;
|
||||
let color = if live { style::WHITE } else { style::MUTED };
|
||||
let color = if live { style::WHITE } else { style::muted() };
|
||||
let shown = Text::literal(style::fit(atlas, name, chip[0] - 6.0 - left));
|
||||
batch.text_styled(atlas, &shown, left, ty, color, true);
|
||||
if focused {
|
||||
@@ -799,7 +799,7 @@ fn side_lines(
|
||||
];
|
||||
crate::widgets::record(line, rect, false);
|
||||
let shown = Text::literal(style::fit(atlas, line, rect[2]));
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
|
||||
}
|
||||
}
|
||||
/// A line under the page list: its inset, and its height.
|
||||
@@ -881,7 +881,7 @@ fn heading(batch: &mut HudBatch, atlas: &HudAtlas, text: &str, [x, y, w, _]: [f3
|
||||
let rect = [x + SIDE_INSET, y + HEADING_Y, w - 2.0 * SIDE_INSET, 8.0];
|
||||
crate::widgets::record(text, rect, false);
|
||||
let shown = Text::literal(style::fit(atlas, text, rect[2]));
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
|
||||
}
|
||||
|
||||
/// Vanilla's scroller, inside the panel's right edge, sized as `ui::Scroll` drags it.
|
||||
@@ -1896,8 +1896,8 @@ impl<S: Screen> Shell<S> {
|
||||
// The row's page is open: lit as the list's open page is.
|
||||
if list.lit == Some(*shown) {
|
||||
let row @ [x, y, _, h] = strip.area;
|
||||
style::fill(batch, atlas, row, style::OPEN);
|
||||
style::fill(batch, atlas, [x, y, 2.0, h], style::ACCENT);
|
||||
style::fill(batch, atlas, row, style::open());
|
||||
style::fill(batch, atlas, [x, y, 2.0, h], style::accent());
|
||||
}
|
||||
tabs::draw(batch, atlas, strip);
|
||||
}
|
||||
@@ -2134,7 +2134,14 @@ impl<S: Screen> Shell<S> {
|
||||
crate::widgets::narratable(|| crate::widgets::Narratable::text(row.name.clone(), rect));
|
||||
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
|
||||
let name = Text::literal(style::fit(atlas, &row.name, rect[2] - 2.0 * style::INSET));
|
||||
batch.text_styled(atlas, &name, rect[0] + style::INSET, ty, style::MUTED, true);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&name,
|
||||
rect[0] + style::INSET,
|
||||
ty,
|
||||
style::muted(),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let over = |[x, y, w, h]: [f32; 4]| pointer.is_some_and(|at| ui::hovered(at, x, y, w, h));
|
||||
@@ -2240,7 +2247,7 @@ impl<S: Screen> Shell<S> {
|
||||
// Labelled for the widget tree: what the strip says is what a test or an agent reads.
|
||||
crate::widgets::record(about, cols.dock, false);
|
||||
let color = if why.is_some() {
|
||||
style::MUTED
|
||||
style::muted()
|
||||
} else {
|
||||
style::GREY
|
||||
};
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -23,6 +24,13 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
horizon_client::telemetry::mark_start();
|
||||
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
|
||||
crash::on_report(horizon_client::telemetry::keep_crash);
|
||||
// Crates' text in the player's language (`horizon_hooks::tr`).
|
||||
horizon_hooks::engine::set_language(|key| {
|
||||
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
|
||||
});
|
||||
// Play with Friends' host side, until it moves to horizon-friends (spec #14167).
|
||||
#[cfg(feature = "friends")]
|
||||
game::register_friends();
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`),
|
||||
// then the Horizon parts still in this crate; none registers after this point.
|
||||
game::player_hooks::register();
|
||||
|
||||
@@ -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;
|
||||
@@ -357,6 +356,7 @@ mod pad;
|
||||
mod pad_actions;
|
||||
mod pad_craft;
|
||||
mod pad_menu;
|
||||
mod page_screen;
|
||||
mod paintings;
|
||||
mod pandas;
|
||||
mod particle_culling;
|
||||
@@ -423,6 +423,7 @@ mod server_links;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
mod shader_packs;
|
||||
mod share_view;
|
||||
mod sheep_dye;
|
||||
mod shelf;
|
||||
mod shield;
|
||||
@@ -599,6 +600,8 @@ use resource_loader::{
|
||||
EntityModels, GameCpuResources, Loaded, LoaderMsg, PendingWorldEntry, WorldRestLoader,
|
||||
run_loading, write_report,
|
||||
};
|
||||
#[cfg(feature = "friends")]
|
||||
pub(crate) use share_view::register_friends;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, HashSet},
|
||||
@@ -652,7 +655,7 @@ enum Screen {
|
||||
/// WorldOptionsScreen (game/lan_screen.rs).
|
||||
WorldOptions,
|
||||
/// Play with Friends, from the pause menu's Friends button
|
||||
/// (game/friends.rs, game/friends/screen.rs).
|
||||
/// (the page at `Place::PauseFriends`, game/page_screen.rs).
|
||||
Friends,
|
||||
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
|
||||
Social,
|
||||
@@ -1499,7 +1502,7 @@ impl LevelAccess for GameLevel<'_> {
|
||||
};
|
||||
}
|
||||
if let Some(signal) = books::lectern_signal(self.world, pos) {
|
||||
return signal;
|
||||
return signal * i32::from(books::has_book_state(self.registry, state));
|
||||
}
|
||||
if let Some(signal) =
|
||||
machines::shelf_signal(self.world, self.registry, state, pos, direction)
|
||||
@@ -2895,9 +2898,6 @@ struct Game<'a> {
|
||||
level_shapes: horizon_client::chunk::LevelShapes,
|
||||
/// This game is that server thread's (game/integrated.rs).
|
||||
serving: Option<integrated::Serving>,
|
||||
/// The open world is shared behind an invite code (game/friends.rs).
|
||||
#[cfg(feature = "friends")]
|
||||
friends: Option<friends::Sharing>,
|
||||
/// The Social Interactions screen and the players whose chat is hidden (game/social.rs).
|
||||
social: social::Social,
|
||||
world_options: Option<lan_screen::WorldOptions>,
|
||||
@@ -5729,7 +5729,6 @@ impl Game<'_> {
|
||||
if let Some(log) = self.world.block_log.as_mut() {
|
||||
log.extend(held);
|
||||
}
|
||||
self.tick_friends();
|
||||
horizon_client::spans::time("tick_hand", || self.tick_hand());
|
||||
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
|
||||
self.announce_sleep_status();
|
||||
@@ -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);
|
||||
@@ -11253,8 +11252,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();
|
||||
@@ -11262,6 +11260,7 @@ impl Game<'_> {
|
||||
// the world's own ticks.
|
||||
horizon_client::social::set_presence_status(self.presence_status());
|
||||
horizon_client::social::tick();
|
||||
self.share_tick();
|
||||
// Minecraft.tick: ClientTickEvent.Pre before any of it.
|
||||
self.native_client_tick_pre();
|
||||
if self.title.is_none() && !self.pause {
|
||||
@@ -12015,8 +12014,6 @@ impl<'a> Game<'a> {
|
||||
test_defs_snapshot: false,
|
||||
test_highlights: HashMap::new(),
|
||||
game_tests: game_tests::GameTests::default(),
|
||||
#[cfg(feature = "friends")]
|
||||
friends: None,
|
||||
social: Default::default(),
|
||||
debug_keys: Default::default(),
|
||||
debug_charts: Default::default(),
|
||||
@@ -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.
|
||||
|
||||
@@ -1608,6 +1608,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LecternBlock.getAnalogOutputSignal`'s `HAS_BOOK` check.
|
||||
pub(super) fn has_book_state(
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
) -> bool {
|
||||
registry
|
||||
.state(state)
|
||||
.is_some_and(|s| s.properties.get("has_book").is_some_and(|v| v == "true"))
|
||||
}
|
||||
|
||||
/// `LecternBlockEntity.getRedstoneSignal`.
|
||||
pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos) -> Option<i32> {
|
||||
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
|
||||
@@ -1721,6 +1731,7 @@ mod lectern_signal_tests {
|
||||
items::ItemStack,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::level::LevelAccess;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
fn signal(game: &mut super::Game<'_>, book: Option<ItemStack>, page: i32) -> i32 {
|
||||
@@ -1738,6 +1749,55 @@ mod lectern_signal_tests {
|
||||
lectern_signal(&game.world, BlockPos::new(key.0, key.1, key.2)).unwrap()
|
||||
}
|
||||
|
||||
/// `LecternBlock.getAnalogOutputSignal` reads the block entity only while
|
||||
/// the state's `HAS_BOOK` is true.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_lectern_without_the_has_book_state_feeds_no_comparator() {
|
||||
let mut game = server_game();
|
||||
let key = (8, 64, 8);
|
||||
let state = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:lectern")
|
||||
.unwrap();
|
||||
game.world.set(key.0, key.1, key.2, state).unwrap();
|
||||
game.world.block_entities.insert(
|
||||
key,
|
||||
BlockEntity::Lectern(Lectern {
|
||||
book: Some(ItemStack::new("minecraft:writable_book", 1)),
|
||||
page: 0,
|
||||
}),
|
||||
);
|
||||
let signal = |game: &mut super::Game<'_>, state| {
|
||||
game.with_level(|_, level, _| {
|
||||
level.block_entity_analog_signal(
|
||||
BlockPos::new(key.0, key.1, key.2),
|
||||
state,
|
||||
horizon_core::geometry::Direction::North,
|
||||
)
|
||||
})
|
||||
};
|
||||
assert_eq!(
|
||||
signal(&mut game, state),
|
||||
0,
|
||||
"the block state says the lectern holds no book"
|
||||
);
|
||||
let registry = game.pack.assets.registry;
|
||||
let mut with_book = registry.state(state).unwrap().clone();
|
||||
with_book
|
||||
.properties
|
||||
.insert("has_book".into(), "true".into());
|
||||
let with_book = registry.id(&with_book).unwrap();
|
||||
game.world.set(key.0, key.1, key.2, with_book).unwrap();
|
||||
assert_eq!(
|
||||
signal(&mut game, with_book),
|
||||
15,
|
||||
"with HAS_BOOK the book's page signal"
|
||||
);
|
||||
}
|
||||
|
||||
/// `LecternBlockEntity.getRedstoneSignal`: `floor(page / (pageCount - 1)
|
||||
/// * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a book-content
|
||||
/// component, so a stack that only sits in the lectern (a non-book item a
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Play with friends, host side: "Start Sharing" on the Play with Friends
|
||||
//! screen (`screen`, the pause menu's Friends button) opens the world to LAN if
|
||||
//! page (`PAGE`, what the pause menu's Friends icon opens) opens the world to LAN if
|
||||
//! it isn't (with Open to LAN's defaults) and shares that port behind an
|
||||
//! invite code (horizon-friends). The code holds this computer's addresses
|
||||
//! and the key, so friends join with no server of ours; a relay the player
|
||||
@@ -7,18 +7,32 @@
|
||||
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
|
||||
//! loopback address. The joining side is horizon-friends' (`game`), registered by
|
||||
//! horizon-official.
|
||||
use super::{Game, lan::LanSettings};
|
||||
use horizon_client::{Result, hud::HudBatch, lang, text::Text};
|
||||
//!
|
||||
//! It sees the game only through `horizon_hooks` (the `ShareView` its `Hooks::share` tick
|
||||
//! gets, `Hooks::lan_closed`, its page, `tr`) and keeps what it shares in its own statics, so
|
||||
//! it can leave the engine for horizon-friends (spec #14167). The `horizon-ctl friends` command
|
||||
//! below still reaches it from `Game` until then.
|
||||
use super::Game;
|
||||
use horizon_client::Result;
|
||||
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
|
||||
use horizon_hooks::{
|
||||
Hooks,
|
||||
pages::{Act, Page, Place, Row},
|
||||
share::{LanOptions, ShareView},
|
||||
};
|
||||
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Mutex, MutexGuard, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
mod screen;
|
||||
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
|
||||
pub const TITLE: &str = "Play with Friends";
|
||||
/// The title in the current language.
|
||||
pub fn title() -> String {
|
||||
tr("horizon.friends.title", TITLE)
|
||||
}
|
||||
|
||||
use screen::{self as friends_screen, Action, FriendsView};
|
||||
pub use screen::{TITLE, title};
|
||||
|
||||
pub(super) struct Sharing {
|
||||
struct Sharing {
|
||||
host: Host,
|
||||
/// Sharing opened the world to LAN, so stopping closes it again.
|
||||
opened_lan: bool,
|
||||
@@ -31,12 +45,112 @@ pub(super) struct Sharing {
|
||||
paths: (u32, u32),
|
||||
}
|
||||
|
||||
/// The world shared, if one is. The game has one window player who can share (the engine
|
||||
/// calls the hooks for it only), so one is all there is.
|
||||
static SHARING: Mutex<Option<Sharing>> = Mutex::new(None);
|
||||
/// Whether [`SHARING`] holds one: a tick with nothing shared is this one load.
|
||||
static ON: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// What the page left for the next [`Hooks::share`] tick, which has the game's view.
|
||||
#[derive(Default)]
|
||||
struct PageState {
|
||||
/// Chat lines (new codes, the relay stopping), said in order.
|
||||
chat: Vec<String>,
|
||||
/// A button pressed on the page.
|
||||
pressed: Option<Action>,
|
||||
/// The page was drawn since the last tick: its guests are read again.
|
||||
open: bool,
|
||||
/// The other players, as the last tick while the page was open read them.
|
||||
guests: Vec<String>,
|
||||
/// The action of each row the page last showed.
|
||||
acts: Vec<Option<Action>>,
|
||||
}
|
||||
static PAGE_STATE: Mutex<PageState> = Mutex::new(PageState {
|
||||
chat: Vec::new(),
|
||||
pressed: None,
|
||||
open: false,
|
||||
guests: Vec::new(),
|
||||
acts: Vec::new(),
|
||||
});
|
||||
/// Whether [`PAGE_STATE`] has something for the tick.
|
||||
static WORK: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn page() -> MutexGuard<'static, PageState> {
|
||||
PAGE_STATE.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// A button of the page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Action {
|
||||
Share,
|
||||
Copy,
|
||||
Stop,
|
||||
/// Open the troubleshooting page.
|
||||
Help,
|
||||
}
|
||||
|
||||
fn sharing() -> MutexGuard<'static, Option<Sharing>> {
|
||||
SHARING.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Ends the sharing, if there is one. The lock is let go before the caller touches the
|
||||
/// game: closing LAN calls [`Hooks::lan_closed`], which takes it again.
|
||||
fn take() -> Option<Sharing> {
|
||||
let taken = sharing().take();
|
||||
ON.store(false, Ordering::Relaxed);
|
||||
taken
|
||||
}
|
||||
|
||||
/// Registered by the engine's entry while this module is part of it.
|
||||
pub struct Friends;
|
||||
pub static HOOKS: Friends = Friends;
|
||||
|
||||
impl Hooks for Friends {
|
||||
/// What the page left for the game: chat lines, a button's press, the guests while it is
|
||||
/// open. With nothing left, one relaxed load.
|
||||
fn share(&self, world: &mut dyn ShareView) {
|
||||
if !WORK.swap(false, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let (lines, pressed, open) = {
|
||||
let mut page = page();
|
||||
let open = std::mem::take(&mut page.open);
|
||||
(std::mem::take(&mut page.chat), page.pressed.take(), open)
|
||||
};
|
||||
for line in lines {
|
||||
world.chat(&line);
|
||||
}
|
||||
if let Some(action) = pressed {
|
||||
press(action, world);
|
||||
}
|
||||
if open {
|
||||
let mut guests = Vec::new();
|
||||
world.players(&mut |name| guests.push(name.to_owned()));
|
||||
page().guests = guests;
|
||||
}
|
||||
}
|
||||
|
||||
/// The LAN server is closing: the code goes with it.
|
||||
fn lan_closed(&self) {
|
||||
if ON.load(Ordering::Relaxed)
|
||||
&& let Some(mut sharing) = take()
|
||||
{
|
||||
sharing.host.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const HELP: &[&str] = &[
|
||||
"friends host share the open world behind an invite code",
|
||||
"friends stop stop sharing",
|
||||
"friends status the code, how friends reach this host, the guests",
|
||||
];
|
||||
|
||||
/// A string in the player's language, or the English one.
|
||||
fn tr(key: &str, english: &str) -> String {
|
||||
horizon_hooks::tr(key).unwrap_or_else(|| english.to_owned())
|
||||
}
|
||||
|
||||
/// `HORIZON_STUN_SERVERS`, else `friendsStun` in horizon.txt, else the defaults.
|
||||
fn stun_servers() -> Vec<String> {
|
||||
if std::env::var_os("HORIZON_STUN_SERVERS").is_some() {
|
||||
@@ -53,101 +167,98 @@ fn env_flag(name: &str, default: bool) -> bool {
|
||||
std::env::var(name).map_or(default, |v| v != "0")
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Start sharing the open world.
|
||||
pub(super) fn friends_start(&mut self) -> Result<()> {
|
||||
if self.friends.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// Single player on its server thread shares that server, as Open to
|
||||
// LAN publishes it; another server can't be shared.
|
||||
if self.title.is_some() || (self.remote.is_some() && self.integrated.is_none()) {
|
||||
return Err("open a singleplayer world first".into());
|
||||
}
|
||||
let opened_lan = self.lan_port().is_none();
|
||||
if opened_lan {
|
||||
// ShareToLanScreen's defaults, as the world options screen offers them.
|
||||
self.lan_open(LanSettings {
|
||||
port: horizon_client::lan::available_port(),
|
||||
guest_commands: false,
|
||||
force_game_mode: true,
|
||||
})?;
|
||||
}
|
||||
let port = self.lan_port().ok_or("the world isn't open to LAN")?;
|
||||
let host = match Host::start(HostConfig {
|
||||
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
|
||||
protocol: PROTOCOL_VERSION,
|
||||
game: GAME_VERSION.to_owned(),
|
||||
relay: horizon_client::net::friends_relay_setting(),
|
||||
path: PathMode::from_env(),
|
||||
port: std::env::var("HORIZON_FRIENDS_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(DEFAULT_PORT),
|
||||
reach: reach::Options {
|
||||
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
|
||||
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
|
||||
stun: stun_servers(),
|
||||
// Both ends on one machine (tests).
|
||||
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
|
||||
},
|
||||
}) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
if opened_lan {
|
||||
self.lan_close(true);
|
||||
}
|
||||
return Err(e.into());
|
||||
/// Start sharing the open world.
|
||||
fn start(world: &mut dyn ShareView) -> std::result::Result<(), String> {
|
||||
if ON.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
if !world.can_share() {
|
||||
return Err("open a singleplayer world first".into());
|
||||
}
|
||||
let (port, opened_lan) = match world.lan_port() {
|
||||
Some(port) => (port, false),
|
||||
// ShareToLanScreen's defaults, as the world options screen offers them.
|
||||
None => (world.open_lan(LanOptions::default())?, true),
|
||||
};
|
||||
let host = Host::start(HostConfig {
|
||||
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
|
||||
protocol: PROTOCOL_VERSION,
|
||||
game: GAME_VERSION.to_owned(),
|
||||
relay: horizon_client::net::friends_relay_setting(),
|
||||
path: PathMode::from_env(),
|
||||
port: std::env::var("HORIZON_FRIENDS_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(DEFAULT_PORT),
|
||||
reach: reach::Options {
|
||||
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
|
||||
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
|
||||
stun: stun_servers(),
|
||||
// Both ends on one machine (tests).
|
||||
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
|
||||
},
|
||||
});
|
||||
let host = match host {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
if opened_lan {
|
||||
world.close_lan();
|
||||
}
|
||||
};
|
||||
self.friends = Some(Sharing {
|
||||
host,
|
||||
opened_lan,
|
||||
code: None,
|
||||
wrong: 0,
|
||||
notice: None,
|
||||
copied: None,
|
||||
paths: (0, 0),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
*sharing() = Some(Sharing {
|
||||
host,
|
||||
opened_lan,
|
||||
code: None,
|
||||
wrong: 0,
|
||||
notice: None,
|
||||
copied: None,
|
||||
paths: (0, 0),
|
||||
});
|
||||
ON.store(true, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop sharing: the code ends (direct port closed, router mapping
|
||||
/// deleted); the world closes to LAN again if sharing opened it.
|
||||
pub(super) fn friends_stop(&mut self) {
|
||||
let Some(mut sharing) = self.friends.take() else {
|
||||
/// Stop sharing: the code ends (direct port closed, router mapping
|
||||
/// deleted); the world closes to LAN again if sharing opened it.
|
||||
fn stop(world: &mut dyn ShareView) {
|
||||
if !world.can_share() {
|
||||
return;
|
||||
}
|
||||
let Some(mut sharing) = take() else {
|
||||
return;
|
||||
};
|
||||
sharing.host.stop();
|
||||
if sharing.opened_lan {
|
||||
world.close_lan();
|
||||
} else {
|
||||
let text = tr(
|
||||
"horizon.friends.stopped",
|
||||
"Stopped sharing the invite code.",
|
||||
);
|
||||
world.chat(&text);
|
||||
}
|
||||
}
|
||||
|
||||
/// New codes and sessions, while the page is drawn and when `friends status` asks. Its chat
|
||||
/// lines wait for the next tick ([`Hooks::share`]), or for [`say`].
|
||||
fn poll() {
|
||||
if !ON.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let mut announce = false;
|
||||
let mut stopped = None;
|
||||
{
|
||||
let mut guard = sharing();
|
||||
let Some(sharing) = guard.as_mut() else {
|
||||
return;
|
||||
};
|
||||
sharing.host.stop();
|
||||
if sharing.opened_lan {
|
||||
self.lan_close(true);
|
||||
} else {
|
||||
self.system_message(Text::literal(lang::get_or(
|
||||
"horizon.friends.stopped",
|
||||
"Stopped sharing the invite code.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The LAN server is closing: the code goes with it.
|
||||
pub(super) fn friends_lan_closed(&mut self) {
|
||||
if let Some(mut sharing) = self.friends.take() {
|
||||
sharing.host.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// New codes and sessions, once a tick and while the pause menu is drawn.
|
||||
pub(super) fn tick_friends(&mut self) {
|
||||
let Some(sharing) = self.friends.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let mut announce = false;
|
||||
let mut stopped = None;
|
||||
for event in sharing.host.poll() {
|
||||
match event {
|
||||
HostEvent::Code(code) => {
|
||||
if sharing.code.is_some() && sharing.wrong >= horizon_friends::host::MAX_WRONG {
|
||||
sharing.notice = Some(lang::get_or(
|
||||
sharing.notice = Some(tr(
|
||||
"horizon.friends.short_code_changed",
|
||||
"Someone kept trying a wrong short code, so it changed.",
|
||||
));
|
||||
@@ -158,7 +269,7 @@ impl Game<'_> {
|
||||
}
|
||||
HostEvent::WrongCode { count } => {
|
||||
sharing.wrong = count;
|
||||
sharing.notice = Some(lang::get_or(
|
||||
sharing.notice = Some(tr(
|
||||
"horizon.friends.wrong_code",
|
||||
"Someone tried a wrong code.",
|
||||
));
|
||||
@@ -167,7 +278,7 @@ impl Game<'_> {
|
||||
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
|
||||
HostEvent::Disconnected { .. } => {}
|
||||
HostEvent::RelayStopped => {
|
||||
let text = lang::get_or(
|
||||
let text = tr(
|
||||
"horizon.friends.relay_stopped",
|
||||
"Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
|
||||
);
|
||||
@@ -176,164 +287,484 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(text) = stopped {
|
||||
self.system_message(Text::literal(text));
|
||||
}
|
||||
if announce {
|
||||
self.system_message(Text::literal(lang::get_or(
|
||||
"horizon.friends.code_ready",
|
||||
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut page = page();
|
||||
page.chat.extend(stopped);
|
||||
if announce {
|
||||
page.chat.push(tr(
|
||||
"horizon.friends.code_ready",
|
||||
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
|
||||
));
|
||||
}
|
||||
if !page.chat.is_empty() {
|
||||
WORK.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn friends_copy(&mut self) {
|
||||
let Some(code) = self.friends.as_ref().and_then(|s| s.code.clone()) else {
|
||||
return;
|
||||
};
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&code);
|
||||
if let Some(s) = self.friends.as_mut() {
|
||||
s.copied = Some(Instant::now());
|
||||
}
|
||||
/// The chat lines waiting, said now.
|
||||
fn say(world: &mut dyn ShareView) {
|
||||
let lines = std::mem::take(&mut page().chat);
|
||||
for line in lines {
|
||||
world.chat(&line);
|
||||
}
|
||||
}
|
||||
|
||||
/// Play with Friends. The world is paused under it in singleplayer, so
|
||||
/// new codes and sessions are read while it is drawn.
|
||||
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
|
||||
self.tick_friends();
|
||||
let view = self.friends_view();
|
||||
horizon_client::widgets::begin_screen(|| {
|
||||
horizon_client::widgets::ScreenInfo::new("friends", friends_screen::TITLE)
|
||||
});
|
||||
friends_screen::draw(batch, &self.gui.atlas, self.cursor, &view, true);
|
||||
}
|
||||
pub(super) fn friends_screen_click(&mut self) {
|
||||
let view = self.friends_view();
|
||||
let Some(action) =
|
||||
friends_screen::click(&self.gui.atlas, self.gui_size(), self.cursor, &view)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.audio.play_button_click();
|
||||
match action {
|
||||
Action::Share => {
|
||||
if let Err(e) = self.friends_start() {
|
||||
self.system_message(Text::literal(
|
||||
lang::get_or(
|
||||
"horizon.friends.share_failed",
|
||||
"Couldn't share the world: %s",
|
||||
)
|
||||
.replacen("%s", &e.to_string(), 1),
|
||||
));
|
||||
}
|
||||
/// A button of the page, on the tick after its press.
|
||||
fn press(action: Action, world: &mut dyn ShareView) {
|
||||
match action {
|
||||
Action::Share => {
|
||||
if let Err(e) = start(world) {
|
||||
let text = tr(
|
||||
"horizon.friends.share_failed",
|
||||
"Couldn't share the world: %s",
|
||||
)
|
||||
.replacen("%s", &e, 1);
|
||||
world.chat(&text);
|
||||
}
|
||||
Action::Copy => self.friends_copy(),
|
||||
Action::Stop => self.friends_stop(),
|
||||
// The system browser, as the folder buttons open their folders.
|
||||
Action::Help => {
|
||||
if let Some(url) = &view.help {
|
||||
super::packs::open_uri(url);
|
||||
}
|
||||
}
|
||||
Action::Done => self.screen = super::Screen::Paused,
|
||||
}
|
||||
Action::Copy => copy(world),
|
||||
Action::Stop => stop(world),
|
||||
// The system browser, as the folder buttons open their folders.
|
||||
Action::Help => world.open_url(reach::HELP_URL),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pause menu offers Play with Friends (a singleplayer world).
|
||||
pub(super) fn friends_shown(&self) -> bool {
|
||||
self.friends_view().shown
|
||||
fn copy(world: &mut dyn ShareView) {
|
||||
if !world.can_share() {
|
||||
return;
|
||||
}
|
||||
|
||||
/// What Play with Friends shows.
|
||||
pub(super) fn friends_view(&self) -> FriendsView {
|
||||
let guests = match &self.lan {
|
||||
Some(h) => h.hub.guests.iter().map(|g| g.name.clone()).collect(),
|
||||
// On the server thread: the other players the server lists.
|
||||
None if self.integrated.is_some() => {
|
||||
let me = self.options.player_name();
|
||||
let mut names = self.remote_online_names().unwrap_or_default();
|
||||
names.retain(|n| *n != me);
|
||||
names
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
let Some(s) = &self.friends else {
|
||||
return FriendsView {
|
||||
shown: self.remote.is_none() || self.integrated.is_some(),
|
||||
..FriendsView::default()
|
||||
};
|
||||
};
|
||||
let state = s.host.state();
|
||||
let mut status = if s.code.is_none() {
|
||||
lang::get_or(
|
||||
"horizon.friends.finding_route",
|
||||
"Finding how friends can reach you…",
|
||||
)
|
||||
} else {
|
||||
state.summary.clone()
|
||||
};
|
||||
if let Some(short) = &state.short {
|
||||
status.push_str(&format!(" Short code for the relay: {short}."));
|
||||
}
|
||||
if let Some(problem) = &state.relay_problem {
|
||||
status.push(' ');
|
||||
status.push_str(problem);
|
||||
}
|
||||
let reachable = matches!(
|
||||
state.verdict,
|
||||
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
|
||||
) || state.short.is_some();
|
||||
FriendsView {
|
||||
shown: true,
|
||||
sharing: true,
|
||||
code: s.code.clone(),
|
||||
status,
|
||||
reachable,
|
||||
help: (!reachable && s.code.is_some()).then(|| reach::HELP_URL.to_owned()),
|
||||
guests,
|
||||
notice: s.notice.clone(),
|
||||
copied: s
|
||||
.copied
|
||||
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
|
||||
}
|
||||
let Some(code) = sharing().as_ref().and_then(|s| s.code.clone()) else {
|
||||
return;
|
||||
};
|
||||
world.copy(&code);
|
||||
if let Some(s) = sharing().as_mut() {
|
||||
s.copied = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizon-ctl friends ...`.
|
||||
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
|
||||
use serde_json::json;
|
||||
if words[0] != "friends" {
|
||||
return Ok(None);
|
||||
}
|
||||
match words.get(1).copied() {
|
||||
Some("host") => {
|
||||
self.friends_start()?;
|
||||
Ok(Some(json!({ "ok": true, "lan_port": self.lan_port() })))
|
||||
}
|
||||
Some("stop") => {
|
||||
self.friends_stop();
|
||||
Ok(Some(json!({ "ok": true })))
|
||||
}
|
||||
Some("status") => {
|
||||
self.tick_friends();
|
||||
let view = self.friends_view();
|
||||
let state = self.friends.as_ref().map(|s| s.host.state());
|
||||
Ok(Some(json!({
|
||||
"sharing": view.sharing,
|
||||
"code": view.code,
|
||||
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
|
||||
"summary": view.status,
|
||||
"reachable": view.reachable,
|
||||
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
|
||||
"direct_port": state.as_ref().map(|s| s.port),
|
||||
"sessions": self.friends.as_ref().map_or(0, |s| s.host.sessions()),
|
||||
"direct": self.friends.as_ref().map_or(0, |s| s.paths.0),
|
||||
"relayed": self.friends.as_ref().map_or(0, |s| s.paths.1),
|
||||
"guests": view.guests,
|
||||
"lan_port": self.lan_port(),
|
||||
})))
|
||||
}
|
||||
_ => Err("friends host|stop|status".into()),
|
||||
/// What Play with Friends shows.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct View {
|
||||
sharing: bool,
|
||||
code: Option<String>,
|
||||
/// Who can join and what to do when friends on the internet can't (or,
|
||||
/// before the code, what the game is doing).
|
||||
status: String,
|
||||
/// Friends on the internet can reach the host.
|
||||
reachable: bool,
|
||||
/// The players in the world besides the host.
|
||||
guests: Vec<String>,
|
||||
notice: Option<String>,
|
||||
/// The code was just copied.
|
||||
copied: bool,
|
||||
}
|
||||
|
||||
/// What Play with Friends shows; `guests` is asked only while sharing.
|
||||
fn view(guests: impl FnOnce() -> Vec<String>) -> View {
|
||||
let guard = sharing();
|
||||
let Some(s) = guard.as_ref() else {
|
||||
return View::default();
|
||||
};
|
||||
let state = s.host.state();
|
||||
let mut status = if s.code.is_none() {
|
||||
tr(
|
||||
"horizon.friends.finding_route",
|
||||
"Finding how friends can reach you…",
|
||||
)
|
||||
} else {
|
||||
state.summary.clone()
|
||||
};
|
||||
if let Some(short) = &state.short {
|
||||
status.push_str(&format!(" Short code for the relay: {short}."));
|
||||
}
|
||||
if let Some(problem) = &state.relay_problem {
|
||||
status.push(' ');
|
||||
status.push_str(problem);
|
||||
}
|
||||
let reachable = matches!(
|
||||
state.verdict,
|
||||
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
|
||||
) || state.short.is_some();
|
||||
View {
|
||||
sharing: true,
|
||||
code: s.code.clone(),
|
||||
status,
|
||||
reachable,
|
||||
guests: guests(),
|
||||
notice: s.notice.clone(),
|
||||
copied: s
|
||||
.copied
|
||||
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's rows for `view`, each with the button's action: the layout today's Friends
|
||||
/// screen had (the engine draws it, `game/page_screen.rs`).
|
||||
fn rows(view: &View, rows: &mut Vec<Row>, acts: &mut Vec<Option<Action>>) {
|
||||
let mut row = |row: Row, act: Option<Action>| {
|
||||
rows.push(row);
|
||||
acts.push(act);
|
||||
};
|
||||
let hint = || {
|
||||
tr(
|
||||
"horizon.friends.hint",
|
||||
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
|
||||
)
|
||||
};
|
||||
if !view.sharing {
|
||||
row(
|
||||
Row::text(tr(
|
||||
"horizon.friends.share_intro",
|
||||
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
)),
|
||||
None,
|
||||
);
|
||||
row(Row::Space(4), None);
|
||||
row(Row::text(hint()).quiet(), None);
|
||||
row(Row::Space(8), None);
|
||||
row(
|
||||
Row::button("horizon.friends.start_sharing"),
|
||||
Some(Action::Share),
|
||||
);
|
||||
return;
|
||||
}
|
||||
row(
|
||||
Row::text(tr("horizon.multiplayer.invite_code", "Invite Code")).quiet(),
|
||||
None,
|
||||
);
|
||||
row(Row::Space(2), None);
|
||||
match &view.code {
|
||||
Some(code) => row(Row::text(code.clone()).groups(), None),
|
||||
None => row(Row::text(view.status.clone()), None),
|
||||
}
|
||||
row(Row::Space(6), None);
|
||||
let copy = if view.copied {
|
||||
"horizon.friends.copied"
|
||||
} else {
|
||||
"horizon.friends.copy_code"
|
||||
};
|
||||
row(
|
||||
Row::button(copy).half().active(view.code.is_some()),
|
||||
Some(Action::Copy),
|
||||
);
|
||||
row(
|
||||
Row::button("horizon.friends.stop_sharing").half(),
|
||||
Some(Action::Stop),
|
||||
);
|
||||
if view.code.is_some() {
|
||||
// Who can join: grey when friends anywhere can, white (it asks
|
||||
// for something) when only the LAN and VPNs can.
|
||||
let status = Row::text(view.status.clone());
|
||||
row(
|
||||
if view.reachable {
|
||||
status.quiet()
|
||||
} else {
|
||||
status
|
||||
},
|
||||
None,
|
||||
);
|
||||
row(Row::Space(4), None);
|
||||
}
|
||||
let here = if view.guests.is_empty() {
|
||||
tr("horizon.friends.none_yet", "No friends here yet.")
|
||||
} else {
|
||||
tr("horizon.friends.here_now", "Here now: %s").replacen("%s", &view.guests.join(", "), 1)
|
||||
};
|
||||
row(Row::text(here).quiet(), None);
|
||||
if let Some(notice) = &view.notice {
|
||||
row(Row::text(notice.clone()), None);
|
||||
}
|
||||
// Where friends paste the code, while it fits above the footer.
|
||||
row(Row::text(hint()).quiet(), None);
|
||||
// Troubleshooting beside Done when friends on the internet can't reach the host.
|
||||
if !view.reachable && view.code.is_some() {
|
||||
row(
|
||||
Row::button("horizon.friends.troubleshooting").footer(),
|
||||
Some(Action::Help),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Play with Friends' page: what the pause menu's Friends icon opens. Its rows are read when
|
||||
/// it is drawn; a press waits for the next tick, which has the game's view.
|
||||
pub struct FriendsPage;
|
||||
pub static PAGE: FriendsPage = FriendsPage;
|
||||
|
||||
impl Page for FriendsPage {
|
||||
fn id(&self) -> &'static str {
|
||||
"horizon_friends.share"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.friends.title"
|
||||
}
|
||||
fn place(&self) -> Place {
|
||||
Place::PauseFriends
|
||||
}
|
||||
fn rows(&self, out: &mut Vec<Row>) {
|
||||
// The world is paused under the page in singleplayer, so new codes and sessions are
|
||||
// read while it is drawn.
|
||||
poll();
|
||||
let guests = std::mem::take(&mut page().guests);
|
||||
let view = view(|| guests);
|
||||
let mut acts = Vec::new();
|
||||
rows(&view, out, &mut acts);
|
||||
let mut page = page();
|
||||
page.guests = view.guests;
|
||||
page.acts = acts;
|
||||
page.open = true;
|
||||
WORK.store(true, Ordering::Relaxed);
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
let mut page = page();
|
||||
if act == Act::Press
|
||||
&& let Some(Some(action)) = page.acts.get(row).copied()
|
||||
{
|
||||
page.pressed = Some(action);
|
||||
WORK.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizon-ctl friends host|stop|status`.
|
||||
fn control(
|
||||
word: Option<&str>,
|
||||
world: &mut dyn ShareView,
|
||||
) -> std::result::Result<serde_json::Value, String> {
|
||||
use serde_json::json;
|
||||
match word {
|
||||
Some("host") => {
|
||||
start(world)?;
|
||||
Ok(json!({ "ok": true, "lan_port": world.lan_port() }))
|
||||
}
|
||||
Some("stop") => {
|
||||
stop(world);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
Some("status") => {
|
||||
poll();
|
||||
say(world);
|
||||
let view = if world.can_share() {
|
||||
view(|| {
|
||||
let mut guests = Vec::new();
|
||||
world.players(&mut |name| guests.push(name.to_owned()));
|
||||
guests
|
||||
})
|
||||
} else {
|
||||
View::default()
|
||||
};
|
||||
let guard = sharing();
|
||||
let sharing = guard.as_ref().filter(|_| view.sharing);
|
||||
let state = sharing.map(|s| s.host.state());
|
||||
Ok(json!({
|
||||
"sharing": view.sharing,
|
||||
"code": view.code,
|
||||
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
|
||||
"summary": view.status,
|
||||
"reachable": view.reachable,
|
||||
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
|
||||
"direct_port": state.as_ref().map(|s| s.port),
|
||||
"sessions": sharing.map_or(0, |s| s.host.sessions()),
|
||||
"direct": sharing.map_or(0, |s| s.paths.0),
|
||||
"relayed": sharing.map_or(0, |s| s.paths.1),
|
||||
"guests": view.guests,
|
||||
"lan_port": world.lan_port(),
|
||||
}))
|
||||
}
|
||||
_ => Err("friends host|stop|status".into()),
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `horizon-ctl friends ...`.
|
||||
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
|
||||
if words[0] != "friends" {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(control(
|
||||
words.get(1).copied(),
|
||||
&mut self.share_view(),
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A world as the engine shows it, recording what the crate did to it.
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
can_share: bool,
|
||||
lan: Option<u16>,
|
||||
refuse_lan: bool,
|
||||
did: Vec<String>,
|
||||
}
|
||||
|
||||
impl ShareView for Fake {
|
||||
fn can_share(&self) -> bool {
|
||||
self.can_share
|
||||
}
|
||||
fn lan_port(&self) -> Option<u16> {
|
||||
self.lan
|
||||
}
|
||||
fn open_lan(&mut self, options: LanOptions) -> std::result::Result<u16, String> {
|
||||
self.did.push(format!("open_lan {options:?}"));
|
||||
if self.refuse_lan {
|
||||
return Err("no free port".into());
|
||||
}
|
||||
self.lan = Some(25565);
|
||||
Ok(25565)
|
||||
}
|
||||
fn close_lan(&mut self) {
|
||||
self.did.push("close_lan".into());
|
||||
self.lan = None;
|
||||
}
|
||||
fn players(&self, each: &mut dyn FnMut(&str)) {
|
||||
each("Alex");
|
||||
}
|
||||
fn chat(&mut self, text: &str) {
|
||||
self.did.push(format!("chat {text}"));
|
||||
}
|
||||
fn copy(&mut self, text: &str) {
|
||||
self.did.push(format!("copy {text}"));
|
||||
}
|
||||
fn open_url(&mut self, url: &str) {
|
||||
self.did.push(format!("open_url {url}"));
|
||||
}
|
||||
fn tr(&self, _key: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_world_that_cannot_be_shared_is_left_alone() {
|
||||
// Another server, or a splitscreen guest in P1's world.
|
||||
let mut guest = Fake {
|
||||
lan: Some(25565),
|
||||
..Fake::default()
|
||||
};
|
||||
assert_eq!(
|
||||
control(Some("host"), &mut guest).unwrap_err(),
|
||||
"open a singleplayer world first"
|
||||
);
|
||||
stop(&mut guest);
|
||||
copy(&mut guest);
|
||||
assert!(guest.did.is_empty(), "{:?}", guest.did);
|
||||
assert!(!ON.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_opens_lan_as_open_to_lan_does_and_a_refusal_shares_nothing() {
|
||||
let mut world = Fake {
|
||||
can_share: true,
|
||||
refuse_lan: true,
|
||||
..Fake::default()
|
||||
};
|
||||
assert_eq!(start(&mut world).unwrap_err(), "no free port");
|
||||
assert_eq!(
|
||||
world.did,
|
||||
["open_lan LanOptions { guest_commands: false, force_game_mode: true }"]
|
||||
);
|
||||
// Offered, not shared: the guests aren't asked for.
|
||||
let view = view(|| unreachable!("the guests of a world not shared"));
|
||||
assert!(!view.sharing && view.guests.is_empty());
|
||||
assert!(!ON.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_page_shows_todays_screen_and_a_press_runs_on_the_next_tick() {
|
||||
// Sharing, with a code friends on the internet can't reach yet.
|
||||
let shared = View {
|
||||
sharing: true,
|
||||
code: Some("K7QM2-AAAAA".into()),
|
||||
status: "Friends on your network can join.".into(),
|
||||
..View::default()
|
||||
};
|
||||
let (mut out, mut acts) = (Vec::new(), Vec::new());
|
||||
rows(&shared, &mut out, &mut acts);
|
||||
let buttons: Vec<_> = out
|
||||
.iter()
|
||||
.zip(&acts)
|
||||
.filter_map(|(row, act)| match row {
|
||||
Row::Button {
|
||||
label,
|
||||
active,
|
||||
half,
|
||||
footer,
|
||||
..
|
||||
} => Some((*label, *active, *half, *footer, *act)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
buttons,
|
||||
[
|
||||
(
|
||||
"horizon.friends.copy_code",
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
Some(Action::Copy)
|
||||
),
|
||||
(
|
||||
"horizon.friends.stop_sharing",
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
Some(Action::Stop)
|
||||
),
|
||||
(
|
||||
"horizon.friends.troubleshooting",
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
Some(Action::Help)
|
||||
),
|
||||
]
|
||||
);
|
||||
assert!(out.iter().any(
|
||||
|row| matches!(row, Row::Text { text, groups: true, .. } if text == "K7QM2-AAAAA")
|
||||
));
|
||||
// Before the code there is nothing to copy.
|
||||
let (mut out, mut acts) = (Vec::new(), Vec::new());
|
||||
rows(
|
||||
&View {
|
||||
code: None,
|
||||
..shared
|
||||
},
|
||||
&mut out,
|
||||
&mut acts,
|
||||
);
|
||||
assert!(out.iter().any(|row| matches!(
|
||||
row,
|
||||
Row::Button {
|
||||
label: "horizon.friends.copy_code",
|
||||
active: false,
|
||||
..
|
||||
}
|
||||
)));
|
||||
|
||||
// Not sharing: Start Sharing. Its press waits for the tick, which has the world.
|
||||
let (mut out, mut acts) = (Vec::new(), Vec::new());
|
||||
rows(&View::default(), &mut out, &mut acts);
|
||||
let share = acts.iter().position(|a| *a == Some(Action::Share)).unwrap();
|
||||
page().acts = acts;
|
||||
PAGE.act(share, Act::Press);
|
||||
let mut world = Fake {
|
||||
can_share: true,
|
||||
refuse_lan: true,
|
||||
..Fake::default()
|
||||
};
|
||||
HOOKS.share(&mut world);
|
||||
assert_eq!(
|
||||
world.did,
|
||||
[
|
||||
"open_lan LanOptions { guest_commands: false, force_game_mode: true }",
|
||||
"chat Couldn't share the world: no free port",
|
||||
]
|
||||
);
|
||||
// Pressed once, run once.
|
||||
HOOKS.share(&mut world);
|
||||
assert_eq!(world.did.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
//! Play with Friends: Horizon's own sharing of the open world behind an
|
||||
//! invite code (`game/friends.rs`), with the rest of Play with Friends' game glue. The pause menu's Friends button, the icon
|
||||
//! vanilla's PauseScreen already has, opens it. It is drawn the way a mod adds
|
||||
//! a screen to vanilla: the menu background, a centred title and text,
|
||||
//! vanilla's buttons and Done in the footer.
|
||||
use horizon_client::{
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
multiplayer::wrap,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
|
||||
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
|
||||
pub const TITLE: &str = "Play with Friends";
|
||||
/// A string of this screen: the language table's, or the English text.
|
||||
fn tr(key: &str, english: &str) -> String {
|
||||
horizon_client::lang::get_or(key, english)
|
||||
}
|
||||
/// The title in the current language.
|
||||
pub fn title() -> String {
|
||||
tr("horizon.friends.title", TITLE)
|
||||
}
|
||||
/// What the screen shows (game/friends.rs fills it).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FriendsView {
|
||||
/// A singleplayer world (not a server): sharing is offered.
|
||||
pub shown: bool,
|
||||
pub sharing: bool,
|
||||
pub code: Option<String>,
|
||||
/// Who can join and what to do when friends on the internet can't (or,
|
||||
/// before the code, what the game is doing).
|
||||
pub status: String,
|
||||
/// Friends on the internet can reach the host.
|
||||
pub reachable: bool,
|
||||
/// Where the troubleshooting is, when they can't.
|
||||
pub help: Option<String>,
|
||||
/// The players in the world besides the host.
|
||||
pub guests: Vec<String>,
|
||||
pub notice: Option<String>,
|
||||
/// The code was just copied.
|
||||
pub copied: bool,
|
||||
}
|
||||
|
||||
/// A button's action.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Share,
|
||||
Copy,
|
||||
Stop,
|
||||
/// Open the troubleshooting page.
|
||||
Help,
|
||||
Done,
|
||||
}
|
||||
|
||||
fn grey() -> u32 {
|
||||
rgba(160, 160, 160, 255)
|
||||
}
|
||||
const WHITE: u32 = u32::MAX;
|
||||
fn hint() -> String {
|
||||
tr(
|
||||
"horizon.friends.hint",
|
||||
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
|
||||
)
|
||||
}
|
||||
|
||||
struct Button {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
label: String,
|
||||
action: Action,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
/// The centred lines (text, colour, y) and the buttons.
|
||||
fn layout(
|
||||
atlas: &HudAtlas,
|
||||
(w, h): (f32, f32),
|
||||
view: &FriendsView,
|
||||
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
|
||||
let width = (w - 40.0).min(310.0);
|
||||
let cx = (w / 2.0).floor();
|
||||
let mut lines = Vec::new();
|
||||
let mut y = 40.0;
|
||||
let add = |lines: &mut Vec<(String, u32, f32)>, text: &str, color: u32, y: &mut f32| {
|
||||
for line in wrap(text, width, atlas) {
|
||||
lines.push((line, color, *y));
|
||||
*y += 10.0;
|
||||
}
|
||||
};
|
||||
let mut buttons = Vec::new();
|
||||
if view.sharing {
|
||||
lines.push((
|
||||
tr("horizon.multiplayer.invite_code", "Invite Code"),
|
||||
grey(),
|
||||
y,
|
||||
));
|
||||
y += 12.0;
|
||||
match &view.code {
|
||||
Some(code) => {
|
||||
for line in code_lines(atlas, code, width) {
|
||||
lines.push((line, WHITE, y));
|
||||
y += 10.0;
|
||||
}
|
||||
}
|
||||
None => add(&mut lines, &view.status, WHITE, &mut y),
|
||||
}
|
||||
y += 6.0;
|
||||
buttons.push(Button {
|
||||
x: cx - 154.0,
|
||||
y,
|
||||
w: 150.0,
|
||||
label: if view.copied {
|
||||
tr("horizon.friends.copied", "Copied")
|
||||
} else {
|
||||
tr("horizon.friends.copy_code", "Copy Code")
|
||||
},
|
||||
action: Action::Copy,
|
||||
active: view.code.is_some(),
|
||||
});
|
||||
buttons.push(Button {
|
||||
x: cx + 4.0,
|
||||
y,
|
||||
w: 150.0,
|
||||
label: tr("horizon.friends.stop_sharing", "Stop Sharing"),
|
||||
action: Action::Stop,
|
||||
active: true,
|
||||
});
|
||||
y += 30.0;
|
||||
if view.code.is_some() {
|
||||
// Who can join: grey when friends anywhere can, white (it asks
|
||||
// for something) when only the LAN and VPNs can.
|
||||
let tint = if view.reachable { grey() } else { WHITE };
|
||||
add(&mut lines, &view.status, tint, &mut y);
|
||||
y += 4.0;
|
||||
}
|
||||
let here = if view.guests.is_empty() {
|
||||
tr("horizon.friends.none_yet", "No friends here yet.")
|
||||
} else {
|
||||
tr("horizon.friends.here_now", "Here now: %s").replacen(
|
||||
"%s",
|
||||
&view.guests.join(", "),
|
||||
1,
|
||||
)
|
||||
};
|
||||
add(&mut lines, &here, grey(), &mut y);
|
||||
if let Some(notice) = &view.notice {
|
||||
add(&mut lines, notice, WHITE, &mut y);
|
||||
}
|
||||
// Where friends paste the code, while it fits above the footer.
|
||||
let hint = hint();
|
||||
if y + 10.0 * wrap(&hint, width, atlas).len() as f32 <= h - 33.0 {
|
||||
add(&mut lines, &hint, grey(), &mut y);
|
||||
}
|
||||
} else {
|
||||
add(
|
||||
&mut lines,
|
||||
&tr(
|
||||
"horizon.friends.share_intro",
|
||||
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
),
|
||||
WHITE,
|
||||
&mut y,
|
||||
);
|
||||
y += 4.0;
|
||||
add(&mut lines, &hint(), grey(), &mut y);
|
||||
buttons.push(Button {
|
||||
x: cx - 100.0,
|
||||
y: y + 8.0,
|
||||
w: 200.0,
|
||||
label: tr("horizon.friends.start_sharing", "Start Sharing"),
|
||||
action: Action::Share,
|
||||
active: view.shown,
|
||||
});
|
||||
}
|
||||
// HeaderAndFooterLayout's footer: Done, beside Troubleshooting when
|
||||
// friends on the internet can't reach the host.
|
||||
if view.sharing && view.help.is_some() {
|
||||
buttons.push(Button {
|
||||
x: cx - 154.0,
|
||||
y: h - 27.0,
|
||||
w: 150.0,
|
||||
label: tr("horizon.friends.troubleshooting", "Troubleshooting..."),
|
||||
action: Action::Help,
|
||||
active: true,
|
||||
});
|
||||
buttons.push(Button {
|
||||
x: cx + 4.0,
|
||||
y: h - 27.0,
|
||||
w: 150.0,
|
||||
label: "gui.done".to_owned(),
|
||||
action: Action::Done,
|
||||
active: true,
|
||||
});
|
||||
} else {
|
||||
buttons.push(Button {
|
||||
x: cx - 100.0,
|
||||
y: h - 27.0,
|
||||
w: 200.0,
|
||||
label: "gui.done".to_owned(),
|
||||
action: Action::Done,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
(lines, buttons)
|
||||
}
|
||||
/// The code's groups packed into lines of at most `width`.
|
||||
fn code_lines(atlas: &HudAtlas, code: &str, width: f32) -> Vec<String> {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for group in code.split('-') {
|
||||
match lines.last_mut() {
|
||||
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
|
||||
l.push('-');
|
||||
l.push_str(group);
|
||||
}
|
||||
_ => lines.push(group.to_owned()),
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
fn label(key: &str) -> Text {
|
||||
if key.starts_with("gui.") {
|
||||
Text::translate(key, &[])
|
||||
} else {
|
||||
Text::literal(key)
|
||||
}
|
||||
}
|
||||
pub fn draw(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
view: &FriendsView,
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
let title = Text::literal(title());
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
|
||||
let (lines, buttons) = layout(atlas, (w, h), view);
|
||||
for (line, color, y) in lines {
|
||||
let lw = HudBatch::text_width(atlas, &line);
|
||||
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
|
||||
}
|
||||
for b in buttons {
|
||||
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
|
||||
if matches!(b.action, Action::Done) {
|
||||
horizon_client::widgets::declare_action(
|
||||
horizon_client::widgets::Action::Done,
|
||||
[b.x, b.y, b.w, 20.0],
|
||||
b.active,
|
||||
);
|
||||
}
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
b.x,
|
||||
b.y,
|
||||
b.w,
|
||||
20.0,
|
||||
&label(&b.label),
|
||||
b.active,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// The button under a click, if it is active.
|
||||
pub fn click(
|
||||
atlas: &HudAtlas,
|
||||
size: (f32, f32),
|
||||
cursor: (f32, f32),
|
||||
view: &FriendsView,
|
||||
) -> Option<Action> {
|
||||
layout(atlas, size, view)
|
||||
.1
|
||||
.into_iter()
|
||||
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
|
||||
.map(|b| b.action)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_then_copy_and_stop() {
|
||||
let atlas = atlas();
|
||||
let size = (427.0, 240.0);
|
||||
let idle = FriendsView {
|
||||
shown: true,
|
||||
..FriendsView::default()
|
||||
};
|
||||
let (_, buttons) = layout(&atlas, size, &idle);
|
||||
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
|
||||
assert_eq!(
|
||||
click(&atlas, size, (share.x + 1.0, share.y + 1.0), &idle),
|
||||
Some(Action::Share)
|
||||
);
|
||||
let sharing = FriendsView {
|
||||
shown: true,
|
||||
sharing: true,
|
||||
status: "Finding how friends can reach you…".into(),
|
||||
..FriendsView::default()
|
||||
};
|
||||
let (_, buttons) = layout(&atlas, size, &sharing);
|
||||
let copy = buttons.iter().find(|b| b.action == Action::Copy).unwrap();
|
||||
assert!(!copy.active, "nothing to copy before the code");
|
||||
let with_code = FriendsView {
|
||||
code: Some("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0".into()),
|
||||
status: "Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address).".into(),
|
||||
help: Some("https://example.invalid/help".into()),
|
||||
..sharing
|
||||
};
|
||||
let (lines, buttons) = layout(&atlas, size, &with_code);
|
||||
assert!(
|
||||
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
|
||||
"the text stays above the footer"
|
||||
);
|
||||
for action in [Action::Copy, Action::Stop, Action::Help, Action::Done] {
|
||||
let b = buttons.iter().find(|b| b.action == action).unwrap();
|
||||
assert_eq!(
|
||||
click(&atlas, size, (b.x + 1.0, b.y + 1.0), &with_code),
|
||||
Some(action)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_offers_no_sharing() {
|
||||
let atlas = atlas();
|
||||
let view = FriendsView::default();
|
||||
let (_, buttons) = layout(&atlas, (427.0, 240.0), &view);
|
||||
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
|
||||
assert!(!share.active);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Play with Friends left out of the build (the `friends` feature off, as in the server build):
|
||||
//! no world is shared, the pause menu has no Friends button (`friends_shown` is false, as on
|
||||
//! another server) and `horizon-ctl friends` says it isn't there. game/friends.rs is the real one.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{Result, hud::HudBatch};
|
||||
use super::Game;
|
||||
use horizon_client::Result;
|
||||
|
||||
pub const HELP: &[&str] = &[];
|
||||
|
||||
@@ -13,21 +13,6 @@ pub fn title() -> String {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn friends_lan_closed(&mut self) {}
|
||||
|
||||
pub(super) fn tick_friends(&mut self) {}
|
||||
|
||||
/// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
|
||||
pub(super) fn draw_friends_screen(&mut self, _batch: &mut HudBatch) {
|
||||
self.screen = Screen::Paused;
|
||||
}
|
||||
|
||||
pub(super) fn friends_screen_click(&mut self) {}
|
||||
|
||||
pub(super) fn friends_shown(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// `horizon-ctl friends ...`.
|
||||
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
|
||||
if words[0] != "friends" {
|
||||
|
||||
@@ -1489,7 +1489,7 @@ impl Game<'_> {
|
||||
None => self.unpublish(),
|
||||
};
|
||||
if closed {
|
||||
self.friends_lan_closed();
|
||||
self.lan_closed_hooks();
|
||||
}
|
||||
if closed && announce && self.integrated.is_some() {
|
||||
self.system_message(Text::translate(
|
||||
@@ -1499,7 +1499,7 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.friends_lan_closed();
|
||||
self.lan_closed_hooks();
|
||||
self.native_players_leaving();
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
//! A page drawn as its own screen (`horizon_hooks::pages::Place::PauseFriends`, what the pause
|
||||
//! menu's vanilla Friends icon opens), the way a mod adds a screen to vanilla: the menu
|
||||
//! background over the paused world, the title centred at the top, the rows centred down the
|
||||
//! middle, footer buttons beside Done at the bottom (`HeaderAndFooterLayout`).
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
multiplayer::wrap,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_hooks::pages::{self, Act, Page, Place, Row};
|
||||
|
||||
/// What a click on the screen hit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Hit {
|
||||
/// The page's row with this index.
|
||||
Row(usize),
|
||||
Done,
|
||||
}
|
||||
|
||||
struct Button {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
label: &'static str,
|
||||
hit: Hit,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
fn grey() -> u32 {
|
||||
rgba(160, 160, 160, 255)
|
||||
}
|
||||
const WHITE: u32 = u32::MAX;
|
||||
|
||||
/// The centred lines (text, colour, y) and the buttons.
|
||||
fn layout(
|
||||
atlas: &HudAtlas,
|
||||
(w, h): (f32, f32),
|
||||
rows: &[Row],
|
||||
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
|
||||
let width = (w - 40.0).min(310.0);
|
||||
let cx = (w / 2.0).floor();
|
||||
let mut lines = Vec::new();
|
||||
let mut buttons = Vec::new();
|
||||
let mut footer = Vec::new();
|
||||
let mut y = 40.0;
|
||||
// A half-width button waiting for the next to sit beside it.
|
||||
let mut half_open = false;
|
||||
for (r, row) in rows.iter().enumerate() {
|
||||
let beside = std::mem::take(&mut half_open);
|
||||
let (text, quiet, groups) = match row {
|
||||
Row::Text {
|
||||
text,
|
||||
quiet,
|
||||
groups,
|
||||
..
|
||||
} => (text.clone(), *quiet, *groups),
|
||||
Row::Note(text) => (text.clone(), true, false),
|
||||
Row::Heading(key) => (horizon_client::lang::get(key), true, false),
|
||||
Row::Space(px) => {
|
||||
y += f32::from(*px);
|
||||
continue;
|
||||
}
|
||||
Row::Button {
|
||||
label,
|
||||
active,
|
||||
footer: true,
|
||||
..
|
||||
} => {
|
||||
footer.push((r, *label, *active));
|
||||
continue;
|
||||
}
|
||||
Row::Button {
|
||||
label,
|
||||
active,
|
||||
half,
|
||||
..
|
||||
} => {
|
||||
let (x, w) = match (*half, beside) {
|
||||
(true, true) => {
|
||||
y -= 30.0;
|
||||
(cx + 4.0, 150.0)
|
||||
}
|
||||
(true, false) => {
|
||||
half_open = true;
|
||||
(cx - 154.0, 150.0)
|
||||
}
|
||||
(false, _) => (cx - 100.0, 200.0),
|
||||
};
|
||||
buttons.push(Button {
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
label,
|
||||
hit: Hit::Row(r),
|
||||
active: *active,
|
||||
});
|
||||
y += 30.0;
|
||||
continue;
|
||||
}
|
||||
// Settings rows belong in the options shell.
|
||||
_ => continue,
|
||||
};
|
||||
let wrapped = if groups {
|
||||
groups_lines(atlas, &text, width)
|
||||
} else {
|
||||
wrap(&text, width, atlas)
|
||||
};
|
||||
// Text that would run into the footer is left out.
|
||||
if y + 10.0 * wrapped.len() as f32 > h - 33.0 {
|
||||
continue;
|
||||
}
|
||||
let color = if quiet { grey() } else { WHITE };
|
||||
for line in wrapped {
|
||||
lines.push((line, color, y));
|
||||
y += 10.0;
|
||||
}
|
||||
}
|
||||
// The footer: the page's buttons, then Done; Done alone is wider.
|
||||
footer.push((usize::MAX, "gui.done", true));
|
||||
let (each, gap) = if footer.len() == 1 {
|
||||
(200.0, 0.0)
|
||||
} else {
|
||||
(150.0, 8.0)
|
||||
};
|
||||
let total = footer.len() as f32 * (each + gap) - gap;
|
||||
let mut x = cx - total / 2.0;
|
||||
for (r, label, active) in footer {
|
||||
let hit = if r == usize::MAX {
|
||||
Hit::Done
|
||||
} else {
|
||||
Hit::Row(r)
|
||||
};
|
||||
buttons.push(Button {
|
||||
x,
|
||||
y: h - 27.0,
|
||||
w: each,
|
||||
label,
|
||||
hit,
|
||||
active,
|
||||
});
|
||||
x += each + gap;
|
||||
}
|
||||
(lines, buttons)
|
||||
}
|
||||
|
||||
/// `-`-separated groups packed into lines of at most `width`, never broken inside a group.
|
||||
fn groups_lines(atlas: &HudAtlas, text: &str, width: f32) -> Vec<String> {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for group in text.split('-') {
|
||||
match lines.last_mut() {
|
||||
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
|
||||
l.push('-');
|
||||
l.push_str(group);
|
||||
}
|
||||
_ => lines.push(group.to_owned()),
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn draw(batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32), title: &str, rows: &[Row]) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let title = Text::translate(title, &[]);
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
|
||||
let (lines, buttons) = layout(atlas, (w, h), rows);
|
||||
for (line, color, y) in lines {
|
||||
let lw = HudBatch::text_width(atlas, &line);
|
||||
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
|
||||
}
|
||||
for b in buttons {
|
||||
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
|
||||
if b.hit == Hit::Done {
|
||||
horizon_client::widgets::declare_action(
|
||||
horizon_client::widgets::Action::Done,
|
||||
[b.x, b.y, b.w, 20.0],
|
||||
b.active,
|
||||
);
|
||||
}
|
||||
let label = Text::translate(b.label, &[]);
|
||||
ui::button(batch, atlas, b.x, b.y, b.w, 20.0, &label, b.active, hover);
|
||||
}
|
||||
}
|
||||
|
||||
/// The active button under a click.
|
||||
fn click(atlas: &HudAtlas, size: (f32, f32), cursor: (f32, f32), rows: &[Row]) -> Option<Hit> {
|
||||
layout(atlas, size, rows)
|
||||
.1
|
||||
.into_iter()
|
||||
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
|
||||
.map(|b| b.hit)
|
||||
}
|
||||
|
||||
/// The page the Friends icon opens: the first registered there.
|
||||
fn friends_page() -> Option<&'static dyn Page> {
|
||||
pages::engine::all()
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|page| page.place() == Place::PauseFriends)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether the pause menu's Friends icon is active: a page is registered for it and this
|
||||
/// window plays a singleplayer world (another server's world can't be shared).
|
||||
pub(super) fn friends_shown(&self) -> bool {
|
||||
self.can_share() && friends_page().is_some()
|
||||
}
|
||||
|
||||
/// The Friends icon's page. Its rows are asked each time it is drawn.
|
||||
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
|
||||
let Some(page) = friends_page() else {
|
||||
// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
|
||||
self.screen = Screen::Paused;
|
||||
return;
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
page.rows(&mut rows);
|
||||
horizon_client::widgets::begin_screen(|| {
|
||||
horizon_client::widgets::ScreenInfo::new("friends", super::friends::TITLE)
|
||||
});
|
||||
draw(batch, &self.gui.atlas, self.cursor, page.title(), &rows);
|
||||
}
|
||||
|
||||
pub(super) fn friends_screen_click(&mut self) {
|
||||
let Some(page) = friends_page() else {
|
||||
return;
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
page.rows(&mut rows);
|
||||
let Some(hit) = click(&self.gui.atlas, self.gui_size(), self.cursor, &rows) else {
|
||||
return;
|
||||
};
|
||||
self.audio.play_button_click();
|
||||
match hit {
|
||||
Hit::Row(r) => page.act(r, Act::Press),
|
||||
Hit::Done => self.screen = Screen::Paused,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
fn at(buttons: &[Button], hit: Hit) -> &Button {
|
||||
buttons.iter().find(|b| b.hit == hit).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halves_sit_side_by_side_and_footer_buttons_beside_done() {
|
||||
let atlas = atlas();
|
||||
let size = (427.0, 240.0);
|
||||
let rows = [
|
||||
Row::text("Invite Code").quiet(),
|
||||
Row::Space(2),
|
||||
Row::text("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0").groups(),
|
||||
Row::Space(6),
|
||||
Row::button("copy").half().active(false),
|
||||
Row::button("stop").half(),
|
||||
Row::text("Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address)."),
|
||||
Row::button("help").footer(),
|
||||
];
|
||||
let (lines, buttons) = layout(&atlas, size, &rows);
|
||||
let (copy, stop) = (at(&buttons, Hit::Row(4)), at(&buttons, Hit::Row(5)));
|
||||
// 213 is the middle: two 150-wide halves 8 apart around it, as vanilla's pairs.
|
||||
assert_eq!(
|
||||
(copy.x, copy.w, stop.x, stop.w),
|
||||
(59.0, 150.0, 217.0, 150.0)
|
||||
);
|
||||
assert_eq!(copy.y, stop.y);
|
||||
let (help, done) = (at(&buttons, Hit::Row(7)), at(&buttons, Hit::Done));
|
||||
assert_eq!(
|
||||
(help.x, done.x, help.y, done.y),
|
||||
(59.0, 217.0, 213.0, 213.0)
|
||||
);
|
||||
// The code breaks between its groups only.
|
||||
let code: Vec<_> = lines
|
||||
.iter()
|
||||
.filter(|(_, c, _)| *c == WHITE)
|
||||
.map(|(l, ..)| l)
|
||||
.collect();
|
||||
assert!(code[0].starts_with("0B33K-") && code.iter().all(|l| !l.ends_with('-')));
|
||||
assert!(
|
||||
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
|
||||
"above the footer"
|
||||
);
|
||||
// An inactive button takes no click; Done alone is 200 wide.
|
||||
assert_eq!(
|
||||
click(&atlas, size, (copy.x + 1.0, copy.y + 1.0), &rows),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
click(&atlas, size, (stop.x + 1.0, stop.y + 1.0), &rows),
|
||||
Some(Hit::Row(5))
|
||||
);
|
||||
let (_, alone) = layout(&atlas, size, &[Row::button("share")]);
|
||||
assert_eq!(at(&alone, Hit::Done).w, 200.0);
|
||||
assert_eq!(at(&alone, Hit::Row(0)).x, 113.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_would_reach_the_footer_is_left_out() {
|
||||
let atlas = atlas();
|
||||
let rows = [
|
||||
Row::Space(150),
|
||||
Row::text("first"),
|
||||
Row::text("too low").quiet(),
|
||||
];
|
||||
let (lines, _) = layout(&atlas, (427.0, 240.0), &rows);
|
||||
// 40 + 150 = 190: one line fits above 207, the next (200..210) doesn't.
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert_eq!(lines[0].0, "first");
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -738,14 +738,11 @@ impl Game<'_> {
|
||||
});
|
||||
self.set_pushed_on_ground(target, limited, resolved);
|
||||
|
||||
// Entity.move updates ground before its tiny-collision return. This
|
||||
// native slice covers only unoccupied server NoAI horizontal moves.
|
||||
if let Target::Entity(EntityHandle::Mob(i)) = target {
|
||||
// shortcut: unoccupied server mobs only, expand when controller/passenger authority is bound.
|
||||
let mob = if let Target::Entity(EntityHandle::Mob(i)) = target {
|
||||
let m = &self.mobs[i];
|
||||
let eligible = !self.options.execution_role.is_client()
|
||||
(!self.options.execution_role.is_client()
|
||||
&& self.remote.is_none()
|
||||
&& limited.y == 0.0
|
||||
&& m.no_ai
|
||||
&& !m.extra.passenger
|
||||
&& m.extra.mount.is_none()
|
||||
&& m.rider.is_none()
|
||||
@@ -757,16 +754,22 @@ impl Game<'_> {
|
||||
.guests
|
||||
.iter()
|
||||
.any(|g| host.hub.here(g) && g.vehicle == Some(m.id))
|
||||
});
|
||||
if eligible {
|
||||
self.mobs[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
|
||||
}
|
||||
}))
|
||||
.then_some(i)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Entity.move updates ground before its tiny-collision return.
|
||||
if let Some(i) = mob
|
||||
&& self.mobs[i].no_ai
|
||||
{
|
||||
self.mobs[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
|
||||
}
|
||||
let tnt_after_move =
|
||||
tnt.is_some() && !self.options.execution_role.is_client() && self.remote.is_none();
|
||||
if mob.is_none() && !tnt_after_move {
|
||||
self.restitute_piston(target, limited, resolved);
|
||||
}
|
||||
// `Entity.move(MoverType.PISTON, …)` restitution, which runs whether or
|
||||
// not the step is applied: so a primed TNT in a chunk that does not tick
|
||||
// entities still loses its velocity into walls and ceilings and
|
||||
// bounces on slime (#12022).
|
||||
self.restitute_piston(target, limited, resolved);
|
||||
let moved = resolved.length_squared() > 1.0e-7
|
||||
|| limited.length_squared() - resolved.length_squared() < 1.0e-7;
|
||||
if moved {
|
||||
@@ -775,6 +778,12 @@ impl Game<'_> {
|
||||
// A move the collisions cut to nothing still ends in the check.
|
||||
self.release_after_block_move(target);
|
||||
}
|
||||
// Restitution reads the new landing block even when the push was blocked.
|
||||
if let Some(i) = mob {
|
||||
self.mobs[i].restitute_movement(&self.world, &self.motion.blocks, limited, resolved);
|
||||
} else if tnt_after_move {
|
||||
self.restitute_piston(target, limited, resolved);
|
||||
}
|
||||
if let Some((i, from, ..)) = tnt {
|
||||
self.finish_tnt_piston_move(i, from, resolved);
|
||||
}
|
||||
@@ -1053,6 +1062,7 @@ mod tests {
|
||||
mod order_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_core::{geometry::Direction, position::BlockPos};
|
||||
use horizon_world::level::pistons::MovingPiston;
|
||||
use std::sync::Arc;
|
||||
@@ -1151,8 +1161,9 @@ mod order_tests {
|
||||
}
|
||||
// The near stone's box reaches x 5.5 into a TNT from x 5.01: 0.49 plus
|
||||
// PUSH_OFFSET; the far stone's old box stops it at x 6.
|
||||
// Vanilla's TNT width is 0.98F, not the exact decimal 0.98.
|
||||
assert!(
|
||||
(moved[0] - 0.5).abs() < 1e-9,
|
||||
(moved[0] - 0.500000009536743).abs() < 1e-9,
|
||||
"far stone gone first: {}",
|
||||
moved[0]
|
||||
);
|
||||
@@ -1251,7 +1262,7 @@ mod order_tests {
|
||||
}
|
||||
|
||||
/// A primed TNT pushed down by a piston onto a slime block bounces up,
|
||||
/// matching vanilla's slime restitution (~+0.64) even in a lazy chunk (#12022).
|
||||
/// matching vanilla's restitution even in a lazy chunk (#12022).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_pushed_tnt_bounces_on_slime() {
|
||||
@@ -1312,32 +1323,36 @@ mod order_tests {
|
||||
death_ticks: 0,
|
||||
id: 1,
|
||||
};
|
||||
game.block_ticks.moving_pistons = vec![(BlockPos::new(5, 200, 5), piston)];
|
||||
game.sync_moving_pistons();
|
||||
game.tnts.clear();
|
||||
assert!(game.run_command("summon tnt 5.5 200.5 5.5 {fuse:80,Motion:[0.0d,-0.4d,0.0d]}"));
|
||||
assert!(
|
||||
(game.tnts[0].velocity.y - (-0.4)).abs() < 1.0e-9,
|
||||
"TNT starts with vy -0.4, got {:?}",
|
||||
game.tnts[0].velocity
|
||||
);
|
||||
// Piston advances from progress 0 to 0.5 and pushes the TNT down.
|
||||
let pushes: Vec<_> = game
|
||||
.block_ticks
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.map(|&(pos, old)| (pos, old, old.progress + 0.5))
|
||||
.collect();
|
||||
for (_, piston) in &mut game.block_ticks.moving_pistons {
|
||||
piston.progress += 0.5;
|
||||
// Pinned 26.3 PistonMovingBlockEntity.moveCollidedEntities, without entity ticks.
|
||||
for (floor, y, vy, final_y, final_vy) in [
|
||||
(slime, 200.5, -0.4, 200.0, 0.3510000138133766_f64),
|
||||
(stone, 200.5, -0.4, 200.0, 0.0),
|
||||
(slime, 200.0, -0.4, 200.0, 0.3200000047683716),
|
||||
(slime, 200.00001, -0.4, 200.00001, 0.32000064476813395),
|
||||
(slime, 200.5, 0.3, 200.0, 0.0),
|
||||
] {
|
||||
game.world.set(5, 199, 5, floor).unwrap();
|
||||
game.block_ticks.moving_pistons = vec![(BlockPos::new(5, 200, 5), piston)];
|
||||
game.sync_moving_pistons();
|
||||
game.tnts.clear();
|
||||
assert!(game.run_command(&format!(
|
||||
"summon tnt 5.5 {y} 5.5 {{fuse:80,Motion:[0.0d,{vy}d,0.0d]}}"
|
||||
)));
|
||||
assert_eq!(game.tnts[0].velocity.y, vy);
|
||||
let pushes: Vec<_> = game
|
||||
.block_ticks
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.map(|&(pos, old)| (pos, old, old.progress + 0.5))
|
||||
.collect();
|
||||
for (_, piston) in &mut game.block_ticks.moving_pistons {
|
||||
piston.progress += 0.5;
|
||||
}
|
||||
game.push_piston_entities(pushes);
|
||||
assert_eq!(game.tnts[0].position, Vec3::new(5.5, final_y, 5.5));
|
||||
assert_eq!(game.tnts[0].velocity.y.to_bits(), final_vy.to_bits());
|
||||
assert!(game.tnts[0].on_ground);
|
||||
}
|
||||
game.push_piston_entities(pushes);
|
||||
// The slime block at y=199 bounces the TNT up; vertical velocity must be positive.
|
||||
assert!(
|
||||
game.tnts[0].velocity.y > 0.0,
|
||||
"TNT should bounce up on slime, got vy {:?}",
|
||||
game.tnts[0].velocity
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1602,6 +1617,54 @@ mod h255_mob_piston_ground_tests {
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h255_added_tnt_authority_and_exhausted_budget() {
|
||||
use crate::game::execution_role::ExecutionRole;
|
||||
for role in [
|
||||
ExecutionRole::Client,
|
||||
ExecutionRole::IntegratedServer,
|
||||
ExecutionRole::DedicatedServer,
|
||||
] {
|
||||
with_role_game(role, |game| {
|
||||
assert!(game.run_command("setblock 5 199 5 slime_block"));
|
||||
assert!(
|
||||
game.run_command("summon tnt 5.5 200.5 5.5 {fuse:80,Motion:[0d,-0.4d,0d]}")
|
||||
);
|
||||
let mut budget = std::collections::HashMap::new();
|
||||
let push = |game: &mut Game<'_>,
|
||||
budget: &mut std::collections::HashMap<_, _>,
|
||||
movement| {
|
||||
let tnt = &game.tnts[0];
|
||||
game.move_pushed(
|
||||
Target::Entity(EntityHandle::Tnt(0)),
|
||||
tnt.bounds(),
|
||||
[tnt.position.x, tnt.position.z],
|
||||
0.0,
|
||||
tnt.on_ground,
|
||||
movement,
|
||||
Direction::Down,
|
||||
budget,
|
||||
);
|
||||
};
|
||||
push(game, &mut budget, Vec3::ZERO);
|
||||
assert_eq!(game.tnts[0].position.y, 200.5);
|
||||
assert_eq!(game.tnts[0].velocity.y, -0.4);
|
||||
push(game, &mut budget, Vec3::new(0.0, -0.51, 0.0));
|
||||
let expected = if role.is_client() {
|
||||
0.0_f64
|
||||
} else {
|
||||
0.3510000138133766
|
||||
};
|
||||
assert_eq!(game.tnts[0].velocity.y.to_bits(), expected.to_bits());
|
||||
game.tnts[0].position.y = 200.5;
|
||||
game.tnts[0].velocity.y = -0.4;
|
||||
push(game, &mut budget, Vec3::new(0.0, -0.51, 0.0));
|
||||
assert_eq!(game.tnts[0].position.y, 200.5);
|
||||
assert_eq!(game.tnts[0].velocity.y, -0.4);
|
||||
});
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h255_added_no_request_and_exhausted_budget_keep_state() {
|
||||
with_game(|game| {
|
||||
let i = added_mob(game, 20, "horse", true, 8.5);
|
||||
@@ -1613,6 +1676,16 @@ mod h255_mob_piston_ground_tests {
|
||||
added_push(game, i, Vec3::new(0.51, 0.0, 0.0));
|
||||
assert!(game.mobs[i].on_ground, "occupied path remains excluded");
|
||||
assert_eq!(game.mobs[i].position, Vec3::new(9.01, 202.0, 4.5));
|
||||
assert!(game.run_command("setblock 9 201 4 slime_block"));
|
||||
game.mobs[i].position.y = 202.25;
|
||||
game.mobs[i].velocity = Vec3::new(0.0, -1.0, 0.0);
|
||||
game.mobs[i].sync_position = false;
|
||||
added_push(game, i, Vec3::new(0.0, -0.5, 0.0));
|
||||
assert_eq!(
|
||||
game.mobs[i].velocity.y, 0.0,
|
||||
"occupied restitution stays outside this slice"
|
||||
);
|
||||
assert!(!game.mobs[i].sync_position);
|
||||
assert!(game.run_command("ride @s dismount"));
|
||||
assert!(game.riding.is_none());
|
||||
assert!(game.mobs[i].rider.is_none());
|
||||
@@ -1655,23 +1728,46 @@ mod h255_mob_piston_ground_tests {
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h255_added_client_role_ai_and_vertical_paths_keep_native_state() {
|
||||
fn h255_added_client_role_ai_and_vertical_ground_state() {
|
||||
use crate::game::execution_role::ExecutionRole as Role;
|
||||
for (role, no_ai, movement) in [
|
||||
(Role::Client, true, Vec3::new(0.25, 0.0, 0.0)),
|
||||
(Role::IntegratedServer, false, Vec3::new(0.25, 0.0, 0.0)),
|
||||
(Role::IntegratedServer, true, Vec3::new(0.0, 0.25, 0.0)),
|
||||
for (role, no_ai, movement, on_ground) in [
|
||||
(Role::Client, true, Vec3::new(0.25, 0.0, 0.0), true),
|
||||
(
|
||||
Role::IntegratedServer,
|
||||
false,
|
||||
Vec3::new(0.25, 0.0, 0.0),
|
||||
true,
|
||||
),
|
||||
(
|
||||
Role::IntegratedServer,
|
||||
true,
|
||||
Vec3::new(0.0, 0.25, 0.0),
|
||||
false,
|
||||
),
|
||||
] {
|
||||
with_role_game(role, |game| {
|
||||
let i = added_mob(game, 22, "husk", no_ai, 8.5);
|
||||
let before = game.mobs[i].position;
|
||||
added_push(game, i, movement);
|
||||
assert_eq!(game.mobs[i].position, before.add_vector(movement));
|
||||
assert!(
|
||||
game.mobs[i].on_ground,
|
||||
"excluded native path remains unchanged; not Java parity"
|
||||
);
|
||||
assert_eq!(game.mobs[i].on_ground, on_ground);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h255_added_partial_push_reads_slime_after_the_position_changes() {
|
||||
with_game(|game| {
|
||||
assert!(game.run_command("setblock 8 201 4 slime_block"));
|
||||
let i = added_mob(game, 23, "iron_golem", true, 8.5);
|
||||
game.mobs[i].position.y = 202.25;
|
||||
game.mobs[i].velocity = Vec3::new(0.0, -1.0, 0.0);
|
||||
game.mobs[i].sync_position = false;
|
||||
added_push(game, i, Vec3::new(0.0, -0.5, 0.0));
|
||||
assert_eq!(game.mobs[i].position, Vec3::new(8.5, 202.0, 4.5));
|
||||
assert!(game.mobs[i].velocity.y > 0.9);
|
||||
assert!(game.mobs[i].sync_position);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1569,7 +1569,7 @@ impl Game<'_> {
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
// The shared world is closing: its invite code ends with it.
|
||||
self.friends_lan_closed();
|
||||
self.lan_closed_hooks();
|
||||
// Single player's server thread saves the world before the title shows.
|
||||
let single_player = match self.integrated.take() {
|
||||
Some(server) => {
|
||||
@@ -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)?;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! The window's world as `horizon_hooks::share` shows it to the crates that share it past the
|
||||
//! LAN (Play with Friends), and where the engine calls `Hooks::share` and `Hooks::lan_closed`.
|
||||
use super::{Game, lan::LanSettings};
|
||||
use horizon_client::text::Text;
|
||||
use horizon_hooks::share::{LanOptions, ShareView};
|
||||
|
||||
/// The window's own player: not the integrated server's thread, not a splitscreen guest (it
|
||||
/// plays P1's world and can't share it; its leaving doesn't close it).
|
||||
fn shares(game: &Game<'_>) -> bool {
|
||||
!game.serving() && !game.options.split_guest
|
||||
}
|
||||
|
||||
/// Play with Friends' host side (game/friends.rs) hears the hooks, until it moves to
|
||||
/// horizon-friends (spec #14167). Before the hooks are sealed.
|
||||
#[cfg(feature = "friends")]
|
||||
pub(crate) fn register_friends() {
|
||||
if horizon_hooks::register(&super::friends::HOOKS).is_err()
|
||||
|| horizon_hooks::pages::register(&super::friends::PAGE).is_err()
|
||||
{
|
||||
log::warn!("Play with Friends registered after the game started: sharing is off");
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Hooks::share`, once a client tick (`Minecraft.runTick`, so also while paused).
|
||||
pub(super) fn share_tick(&mut self) {
|
||||
if shares(self) {
|
||||
horizon_hooks::engine::share(&mut World(self));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Hooks::lan_closed`: the world stopped being open to LAN, or the player left it.
|
||||
pub(super) fn lan_closed_hooks(&self) {
|
||||
if shares(self) {
|
||||
horizon_hooks::engine::lan_closed();
|
||||
}
|
||||
}
|
||||
|
||||
/// A singleplayer world runs on this window's server thread, so it can be shared, as Open
|
||||
/// to LAN publishes it; another server can't be.
|
||||
pub(super) fn can_share(&self) -> bool {
|
||||
self.title.is_none() && (self.remote.is_none() || self.integrated.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'g> Game<'g> {
|
||||
/// The view of this window's world, for the engine's own Friends screen and command until
|
||||
/// they move to the crate.
|
||||
#[cfg(feature = "friends")]
|
||||
pub(super) fn share_view(&mut self) -> World<'_, 'g> {
|
||||
World(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct World<'a, 'g>(&'a mut Game<'g>);
|
||||
|
||||
impl ShareView for World<'_, '_> {
|
||||
fn can_share(&self) -> bool {
|
||||
self.0.can_share()
|
||||
}
|
||||
|
||||
fn lan_port(&self) -> Option<u16> {
|
||||
self.0.lan_port()
|
||||
}
|
||||
|
||||
fn open_lan(&mut self, options: LanOptions) -> Result<u16, String> {
|
||||
self.0
|
||||
.lan_open(LanSettings {
|
||||
port: horizon_client::lan::available_port(),
|
||||
guest_commands: options.guest_commands,
|
||||
force_game_mode: options.force_game_mode,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.0
|
||||
.lan_port()
|
||||
.ok_or_else(|| "the world isn't open to LAN".to_owned())
|
||||
}
|
||||
|
||||
fn close_lan(&mut self) {
|
||||
self.0.lan_close(true);
|
||||
}
|
||||
|
||||
fn players(&self, each: &mut dyn FnMut(&str)) {
|
||||
let g = &self.0;
|
||||
if let Some(host) = &g.lan {
|
||||
for guest in &host.hub.guests {
|
||||
each(&guest.name);
|
||||
}
|
||||
} else if g.integrated.is_some() {
|
||||
// On the server thread: the other players the server lists.
|
||||
let me = g.options.player_name();
|
||||
for name in g.remote_online_names().unwrap_or_default() {
|
||||
if name != me {
|
||||
each(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn chat(&mut self, text: &str) {
|
||||
self.0.system_message(Text::literal(text));
|
||||
}
|
||||
|
||||
fn copy(&mut self, text: &str) {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.0.clipboard.set(text);
|
||||
}
|
||||
|
||||
fn open_url(&mut self, url: &str) {
|
||||
// The system browser, as the folder buttons open their folders.
|
||||
super::packs::open_uri(url);
|
||||
}
|
||||
|
||||
fn tr(&self, key: &str) -> Option<String> {
|
||||
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -273,9 +273,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(
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! 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,
|
||||
},
|
||||
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)]
|
||||
|
||||
@@ -849,7 +849,6 @@ impl Mob {
|
||||
self.horizontal_collision = x_hit || z_hit;
|
||||
// A server's mobs are authoritative (`isLocalInstanceAuthoritative`):
|
||||
// the vertical flags update even without vertical movement.
|
||||
let moved_vertically = wanted.y.abs() > 0.0;
|
||||
let vertical = wanted.y != moved.y;
|
||||
self.vertical_collision_below = vertical && wanted.y < 0.0;
|
||||
self.on_ground = self.vertical_collision_below;
|
||||
@@ -857,42 +856,51 @@ impl Mob {
|
||||
// checkFallDamage reads the landing block (getOnPosLegacy).
|
||||
self.supporting = self.supporting_block(w, moved);
|
||||
self.species_check_fall_damage(w, moved, events);
|
||||
// Entity.restituteMovementAfterCollisions: the colliding components
|
||||
// bounce back by the entity's bounciness or the block's (a slime
|
||||
// block, a bed), whichever is larger. A mob never sneaks, so nothing
|
||||
// suppresses its bounce there.
|
||||
if moved_vertically && vertical || self.horizontal_collision {
|
||||
// Entity.getOnPos(0.2F): the block whose bounce this move sees.
|
||||
self.restitute_movement(w.world, w.movement, wanted, moved);
|
||||
self.movement_emission(w, moved, events);
|
||||
// Entity.move's last step: soul sand, honey and the like slow the
|
||||
// horizontal speed of whatever the mob stands in or on.
|
||||
let factor = self.block_speed_factor(w);
|
||||
self.velocity = horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
|
||||
}
|
||||
/// `Entity.restituteMovementAfterCollisions`, shared by travel and piston pushes.
|
||||
pub fn restitute_movement(
|
||||
&mut self,
|
||||
world: &crate::world::World,
|
||||
movement: &horizon_world::movement_properties::BoundMovementProperties,
|
||||
wanted: Vec3,
|
||||
moved: Vec3,
|
||||
) {
|
||||
let differs = |a: f64, b: f64| (b - a).abs() >= f64::from(1.0e-5_f32);
|
||||
let x_hit = differs(wanted.x, moved.x);
|
||||
let z_hit = differs(wanted.z, moved.z);
|
||||
let vertical = wanted.y != moved.y;
|
||||
if wanted.y.abs() > 0.0 && vertical || x_hit || z_hit {
|
||||
let on = (
|
||||
self.position.x.floor() as i32,
|
||||
(self.position.y - f64::from(0.2_f32)).floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
);
|
||||
let input = RestitutionInput {
|
||||
block: w.world.block(on.0, on.1, on.2),
|
||||
block: world.block(on.0, on.1, on.2),
|
||||
collided_x: x_hit,
|
||||
collided_z: z_hit,
|
||||
vertical_collision: vertical,
|
||||
collision_below: self.vertical_collision_below,
|
||||
collision_below: vertical && wanted.y < 0.0,
|
||||
living: true,
|
||||
resolved_movement: moved,
|
||||
fall_distance: self.fall_distance,
|
||||
};
|
||||
// The port's phases cannot fail for a mob: a block outside the
|
||||
// registry reads as one with no bounce, never as an error.
|
||||
// Unknown block states have no bounce; the provider cannot fail.
|
||||
let _ = restitute_movement(
|
||||
&mut MobRestitution {
|
||||
mob: self,
|
||||
world: w,
|
||||
world,
|
||||
movement,
|
||||
},
|
||||
input,
|
||||
);
|
||||
}
|
||||
self.movement_emission(w, moved, events);
|
||||
// Entity.move's last step: soul sand, honey and the like slow the
|
||||
// horizontal speed of whatever the mob stands in or on.
|
||||
let factor = self.block_speed_factor(w);
|
||||
self.velocity = horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
|
||||
}
|
||||
/// `Entity.getBlockSpeedFactor` at the mob's feet.
|
||||
fn block_speed_factor(&self, w: &MobWorld) -> f32 {
|
||||
@@ -2103,20 +2111,21 @@ pub fn client_water_splash(mob: &mut Mob, out: &mut Vec<MobEvent>) {
|
||||
/// mob that never sneaks. The phases themselves are
|
||||
/// `movement_restitution::restitute_movement`, the same port the player and
|
||||
/// dropped items use.
|
||||
struct MobRestitution<'a, 'w> {
|
||||
struct MobRestitution<'a> {
|
||||
mob: &'a mut Mob,
|
||||
world: &'a MobWorld<'w>,
|
||||
world: &'a crate::world::World,
|
||||
movement: &'a horizon_world::movement_properties::BoundMovementProperties,
|
||||
}
|
||||
impl<'a, 'w> MobRestitution<'a, 'w> {
|
||||
impl<'a> MobRestitution<'a> {
|
||||
/// The block's movement data: its coefficients and its bounce tag.
|
||||
fn movement(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
) -> Result<horizon_world::movement_properties::MovementBlock, ()> {
|
||||
self.world.movement.get(state).map_err(|_| ())
|
||||
self.movement.get(state).map_err(|_| ())
|
||||
}
|
||||
}
|
||||
impl RestitutionWorld for MobRestitution<'_, '_> {
|
||||
impl RestitutionWorld for MobRestitution<'_> {
|
||||
// The block data cannot fail here: a state the registry does not know
|
||||
// reads as a block with no bounce, never as an error.
|
||||
type Error = ();
|
||||
@@ -2162,7 +2171,7 @@ impl RestitutionWorld for MobRestitution<'_, '_> {
|
||||
position.z.floor() as i32,
|
||||
));
|
||||
};
|
||||
let state = self.world.world.block(support[0], support[1], support[2]);
|
||||
let state = self.world.block(support[0], support[1], support[2]);
|
||||
let keeps_column = self
|
||||
.movement(state)
|
||||
.is_ok_and(|b| b.wall || b.coefficients.fence_gate);
|
||||
|
||||
@@ -928,12 +928,12 @@ fn chunk_pois(
|
||||
return;
|
||||
};
|
||||
let kind = |state: u32| poi_of_state(w, horizon_world::registry::BlockStateId(state));
|
||||
let min = w.world.bottom_y();
|
||||
for s in (y0 - min) >> 4..=(y1 - min) >> 4 {
|
||||
let section = &chunk.sections[s as usize];
|
||||
for &(i, poi) in w.world.pois.get((qx, s, qz), section, kind).iter() {
|
||||
let (min, mut lists) = (w.world.bottom_y(), Vec::new());
|
||||
w.world.pois.column(chunk, min, (y0, y1), kind, &mut lists);
|
||||
for (base, pois) in &lists {
|
||||
for &(i, poi) in pois.iter() {
|
||||
let i = i32::from(i);
|
||||
let y = min + s * 16 + (i >> 8);
|
||||
let y = base + (i >> 8);
|
||||
if (y0..=y1).contains(&y) {
|
||||
visit(poi, [qx * 16 + (i & 15), y, qz * 16 + (i >> 4 & 15)]);
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
@@ -22,6 +22,7 @@ pub mod pages;
|
||||
pub mod players;
|
||||
mod registry;
|
||||
pub mod settings;
|
||||
pub mod share;
|
||||
pub mod tunnel;
|
||||
|
||||
use registry::Registry;
|
||||
@@ -77,12 +78,87 @@ 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
|
||||
}
|
||||
|
||||
/// Once a client tick (20 a second, also while the game is paused or on the title screen),
|
||||
/// with the window's world ([`share`]). Only the window's own player calls it.
|
||||
fn share(&self, _world: &mut dyn share::ShareView) {}
|
||||
|
||||
/// The window's world stopped being open to LAN: Open to LAN's stop, or the player left the
|
||||
/// world. Called at that moment, by the window's own player only.
|
||||
fn lan_closed(&self) {}
|
||||
|
||||
/// 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.
|
||||
@@ -123,6 +199,15 @@ pub fn register(hooks: &'static dyn Hooks) -> Result<(), TooLate> {
|
||||
}
|
||||
|
||||
/// The registered hooks, sealing the list on the first call.
|
||||
/// The game's translations, which it installs at start ([`engine::set_language`]).
|
||||
static LANGUAGE: std::sync::OnceLock<fn(&str) -> Option<String>> = std::sync::OnceLock::new();
|
||||
|
||||
/// The text of a translation key in the player's language, if the game has it. For text a crate
|
||||
/// makes outside a view, such as a page's rows. None before the game installs its languages.
|
||||
pub fn tr(key: &str) -> Option<String> {
|
||||
LANGUAGE.get().and_then(|language| language(key))
|
||||
}
|
||||
|
||||
fn all() -> &'static [&'static dyn Hooks] {
|
||||
HOOKS.all()
|
||||
}
|
||||
@@ -157,6 +242,11 @@ pub mod engine {
|
||||
crate::hud::engine::all();
|
||||
}
|
||||
|
||||
/// Where [`crate::tr`] reads translations from; the first call wins.
|
||||
pub fn set_language(language: fn(&str) -> Option<String>) {
|
||||
let _ = LANGUAGE.set(language);
|
||||
}
|
||||
|
||||
/// [`Hooks::started`].
|
||||
pub fn started(game: &Started<'_>) {
|
||||
for hooks in all() {
|
||||
@@ -178,6 +268,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() {
|
||||
@@ -185,6 +301,20 @@ pub mod engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::share`].
|
||||
pub fn share(world: &mut dyn share::ShareView) {
|
||||
for hooks in all() {
|
||||
hooks.share(world);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::lan_closed`].
|
||||
pub fn lan_closed() {
|
||||
for hooks in all() {
|
||||
hooks.lan_closed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any hook is registered: without one the engine builds no view to hand them.
|
||||
pub fn any() -> bool {
|
||||
!all().is_empty()
|
||||
@@ -217,6 +347,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 +392,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 +425,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 +509,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 +520,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 +542,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 +553,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 +575,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 +613,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 +624,8 @@ mod tests {
|
||||
"started 0.2.31",
|
||||
"published true",
|
||||
"published false",
|
||||
"Menus",
|
||||
"World { play: Singleplayer, dimension: Nether, mode: Creative }",
|
||||
"frame 4"
|
||||
]
|
||||
);
|
||||
|
||||
@@ -19,6 +19,12 @@ pub enum Place {
|
||||
/// start a group of their own after the page's. A [`Row::Heading`] starts another group. A
|
||||
/// [`Row::Key`] shows its key there, without changing it: give a page of its own to a key.
|
||||
Extras,
|
||||
/// What the pause menu's Friends icon opens (vanilla's `CommonButtons.friends`): the icon is
|
||||
/// active while a page is registered here and the world can be shared (singleplayer). The
|
||||
/// first page registered here is drawn as its own screen over the paused world, as a mod's
|
||||
/// screen is: the title at the top, the rows centred down the middle, footer buttons beside
|
||||
/// Done at the bottom. Text that would reach the footer is left out.
|
||||
PauseFriends,
|
||||
}
|
||||
|
||||
/// One page. Its values live in the crate (statics, often its settings [`crate::settings::Store`]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Sharing the open world past the LAN (Play with Friends' host side): what the engine lets a
|
||||
//! crate do with the world a window plays. The engine implements [`ShareView`] and hands it to
|
||||
//! [`Hooks::share`](crate::Hooks::share) once a client tick; [`Hooks::lan_closed`](crate::Hooks::lan_closed)
|
||||
//! says the world stopped being open to LAN.
|
||||
//!
|
||||
//! Only the window's own player calls them (a splitscreen guest can't share P1's world, and
|
||||
//! its leaving doesn't close it), never the integrated server's thread or a dedicated server.
|
||||
|
||||
/// How [`ShareView::open_lan`] opens the world, as Open to LAN's screen does. The default is
|
||||
/// what `ShareToLanScreen` offers: no commands for guests, the world's game mode forced on them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct LanOptions {
|
||||
/// Guests may run commands (`ShareToLanScreen`'s "Allow Cheats").
|
||||
pub guest_commands: bool,
|
||||
/// Guests play in the world's game mode.
|
||||
pub force_game_mode: bool,
|
||||
}
|
||||
|
||||
impl Default for LanOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
guest_commands: false,
|
||||
force_game_mode: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's world, as a crate sharing it sees it. Nothing here allocates per call unless a
|
||||
/// method says so.
|
||||
pub trait ShareView {
|
||||
/// A singleplayer world runs on this window's server thread, so it can be shared. False on
|
||||
/// the title screen and on another server.
|
||||
fn can_share(&self) -> bool;
|
||||
/// The port the world is open to LAN on, if it is.
|
||||
fn lan_port(&self) -> Option<u16>;
|
||||
/// Opens the world to LAN on a free port, as Open to LAN does; the port, or why not.
|
||||
fn open_lan(&mut self, options: LanOptions) -> Result<u16, String>;
|
||||
/// Closes the world to LAN, saying so in chat as Open to LAN's stop does.
|
||||
fn close_lan(&mut self);
|
||||
/// The names of the other players in the world (not this one's). For a screen or a command,
|
||||
/// not every tick.
|
||||
fn players(&self, each: &mut dyn FnMut(&str));
|
||||
/// A line in this player's chat, as the game's own system messages; `text` is already in
|
||||
/// the player's language ([`ShareView::tr`]).
|
||||
fn chat(&mut self, text: &str);
|
||||
/// Puts `text` on the system clipboard.
|
||||
fn copy(&mut self, text: &str);
|
||||
/// Opens `url` in the system's browser.
|
||||
fn open_url(&mut self, url: &str);
|
||||
/// The translation of `key` in the player's language, if the game has one.
|
||||
fn tr(&self, key: &str) -> Option<String>;
|
||||
}
|
||||
@@ -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,
|
||||
share, tr, 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
|
||||
|
||||
+4
-4
@@ -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 |
|
||||
| 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)) |
|
||||
| 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` (its page, drawn by `game/page_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.
|
||||
|
||||
+9
-4
@@ -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
|
||||
@@ -287,7 +287,9 @@ build pays nothing.
|
||||
|
||||
The hooks today: `started` (once, when someone plays: not a dedicated server, a benchmark or an automated run),
|
||||
`published` (the world opened to or closed from LAN or friends, on change), `shutdown` (the window closed, the
|
||||
world saved) and `tunnel` (the resolver of `friend:` addresses, Play with Friends'). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
|
||||
world saved), `tunnel` (the resolver of `friend:` addresses, Play with Friends'), `share` (each client tick, the window's world
|
||||
through `share::ShareView`: open it to LAN, its players, chat, the clipboard) and `lan_closed` (it stopped being open
|
||||
to LAN). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
|
||||
`horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data or a type
|
||||
the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a draw
|
||||
context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension point
|
||||
@@ -363,7 +365,7 @@ pub trait Store: Sync {
|
||||
A page lives in the options screen's shell, and its entry sits on one of the game's menus.
|
||||
|
||||
```rust
|
||||
pub enum Place { HorizonSettings, Title, Pause }
|
||||
pub enum Place { HorizonSettings, Title, Pause, Extras, PauseFriends }
|
||||
pub trait Page: Sync {
|
||||
fn id(&self) -> &'static str; // stable, for horizon-ctl and the page stack
|
||||
fn title(&self) -> &'static str; // translation key
|
||||
@@ -391,6 +393,9 @@ pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
|
||||
button on the title screen or the pause menu that opens the page. These buttons exist only when something
|
||||
registered, so the vanilla menus keep vanilla's look. The page is drawn with the same widgets, tooltips,
|
||||
narration, controller focus and `horizon-ctl` ids as the engine's pages.
|
||||
- `PauseFriends`: what the pause menu's vanilla Friends icon opens. The icon is active while a page is registered
|
||||
there and the world can be shared; the page is drawn as its own screen over the paused world (title, rows down the
|
||||
middle, footer buttons beside Done), the way Play with Friends' screen always looked.
|
||||
- `Button` and `Text` are built with `Row::button` and `Row::text` and their options: `.active(false)` greys a
|
||||
button, `.half()` puts two side by side, `.footer()` sets one beside Done on a page drawn as its own screen,
|
||||
`.quiet()` greys text, `.groups()` breaks it only between `-`-separated groups (an invite code). In the options
|
||||
|
||||
+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
|
||||
|
||||
@@ -26,7 +26,7 @@ that finds the port takes the session.
|
||||
## How it works
|
||||
|
||||
**Hosting.** The pause menu's Friends button (vanilla's icon, beside Player Reporting) opens **Play
|
||||
with Friends** (`game/friends/screen.rs`); its **Start Sharing** opens the world to LAN if it isn't (Open to
|
||||
with Friends** (game/friends.rs's page at `Place::PauseFriends`, drawn by `game/page_screen.rs`); its **Start Sharing** opens the world to LAN if it isn't (Open to
|
||||
LAN's defaults), then the host's game, on its own:
|
||||
|
||||
1. listens on its **direct port**, TCP 25580 by default (the same port every time, so a port forwarded
|
||||
@@ -54,9 +54,12 @@ router that opened the port, or with a forwarded port, that's it: no server took
|
||||
reads codes and opens the tunnel, which the join (`net.rs`) then reaches on a loopback port like any server.
|
||||
horizon-friends' `game` side offers it, and the official executable (`horizon-official`) registers it;
|
||||
horizon-client's own executable has none, so there a `friend:` address is refused. Hosting needs no hook: the
|
||||
host end connects to the world's LAN port over loopback, so tunnelled guests are LAN guests. It still lives in
|
||||
horizon-client (`game/friends.rs`, `game/friends/screen.rs`, the `friends` feature, which comes with
|
||||
`horizon-extras`) until the pause menu and `horizon-ctl` offer entries to crates above the engine.
|
||||
host end connects to the world's LAN port over loopback, so tunnelled guests are LAN guests. Hosting sees the
|
||||
game only through `horizon_hooks`: `Hooks::share` each client tick with a `share::ShareView` (open the world to
|
||||
LAN, its players, chat, the clipboard, a link), `Hooks::lan_closed`, its page (`pages::Place::PauseFriends`, what
|
||||
the pause menu's Friends icon opens; a press runs on the next tick) and `tr`. It keeps what it shares in its own
|
||||
statics. It still lives in horizon-client (`game/friends.rs`, the `friends` feature, which comes with
|
||||
`horizon-extras`) until `horizon-ctl` offers commands to crates above the engine (#14167).
|
||||
|
||||
**Codes.** Crockford base32 (`0-9 A-Z` without `I L O U`; input ignores case, spaces and dashes and reads
|
||||
`O` as `0`, `I`/`L` as `1`) in groups of five, with a 16-bit checksum so a typo is caught before
|
||||
|
||||
+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`
|
||||
|
||||
@@ -6291,6 +6291,14 @@ def moving_piston_presentation_diagnostic():
|
||||
return moving_piston_presentation(cmd, setup)
|
||||
|
||||
|
||||
def piston_mob_bounce():
|
||||
try:
|
||||
from .piston_bounce_corpus import piston_bounce
|
||||
except ImportError:
|
||||
from piston_bounce_corpus import piston_bounce
|
||||
return piston_bounce(cmd, setup)
|
||||
|
||||
|
||||
def piston_entity_tick_order():
|
||||
"""#9337: entities travel before moving pistons push them, not afterward."""
|
||||
ops = []
|
||||
@@ -7481,6 +7489,7 @@ def groups():
|
||||
('wither spawn', wither_spawn()),
|
||||
('riding teleport', riding_teleport()),
|
||||
('piston entity tick order', piston_entity_tick_order()),
|
||||
('piston mob bounce', piston_mob_bounce()),
|
||||
('primed tnt box', primed_tnt_box()),
|
||||
('primed tnt fall', primed_tnt_fall()),
|
||||
('command block last output', command_block_last_output()),
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Actual downward piston pushes onto slime, including the blocked move (#12091)."""
|
||||
|
||||
|
||||
def piston_bounce(cmd, setup):
|
||||
ops = setup('/tick freeze', '/gamerule spawn_mobs false',
|
||||
'/gamerule advance_time false', '/forceload add 0 0')
|
||||
ops.append({'op': 'settle', 'required_chunks': [[0, 0]]})
|
||||
ops += setup('/tp @s 1.5 200 1.5', '/gamemode creative @s',
|
||||
'/kill @e[type=!player]', '/fill 0 199 0 15 199 15 stone',
|
||||
'/fill 0 200 0 15 208 15 air',
|
||||
'/scoreboard objectives add piston_bounce dummy',
|
||||
'/execute store result score start piston_bounce run time query gametime')
|
||||
cases = [('slime_block', 'slime_block', False),
|
||||
('slime_block', 'stone', False),
|
||||
('stone', 'slime_block', False),
|
||||
('slime_block', 'slime_block', True)]
|
||||
for index, (floor, top, no_ai) in enumerate(cases):
|
||||
x, z = 4 + index % 2 * 6, 4 + index // 2 * 6
|
||||
selector = f'@e[tag=piston_bounce_{index},limit=1]'
|
||||
for command in (f'/setblock {x} 200 {z} {floor}',
|
||||
f'/setblock {x} 204 {z} {top}',
|
||||
f'/setblock {x} 205 {z} piston[facing=down]',
|
||||
f'/summon wither_skeleton {x}.5 201 {z}.5 '
|
||||
'{PersistenceRequired:1b,Invulnerable:1b,Motion:[0d,0d,0d],'
|
||||
f'NoAI:{int(no_ai)}b,Tags:["piston_bounce_{index}"]' + '}'):
|
||||
ops.append(cmd(command, expect_result=1))
|
||||
ops += setup(f'/attribute {selector} movement_speed base set 0')
|
||||
fields = (f'Pos:[{x}.5d,201d,{z}.5d],Motion:[0d,0d,0d],'
|
||||
'Health:20f' + (',NoAI:1b' if no_ai else ''))
|
||||
ops.append(cmd(f'/execute if entity @e[tag=piston_bounce_{index},limit=1,type=wither_skeleton,nbt={{' + fields + '}]',
|
||||
expect_result=1))
|
||||
ops.append(cmd(f'/execute unless entity @e[tag=piston_bounce_{index},nbt={{NoGravity:1b}}]',
|
||||
expect_result=1))
|
||||
if not no_ai:
|
||||
ops.append(cmd(f'/execute unless entity @e[tag=piston_bounce_{index},nbt={{NoAI:1b}}]',
|
||||
expect_result=1))
|
||||
for tick in range(7):
|
||||
if tick == 1:
|
||||
for x, z in [(4, 4), (10, 4), (4, 10), (10, 10)]:
|
||||
ops.append(cmd(f'/setblock {x + 1} 205 {z} redstone_block', expect_result=1))
|
||||
if tick == 3:
|
||||
# Remove the ceiling so the bounce becomes observable travel.
|
||||
for x, z in [(4, 4), (10, 4), (4, 10), (10, 10)]:
|
||||
ops += setup(f'/fill {x} 203 {z} {x} 205 {z} air')
|
||||
for y in (203, 204, 205):
|
||||
ops.append(cmd(f'/execute if block {x} {y} {z} air', expect_result=1))
|
||||
if tick:
|
||||
ops += setup('/tick step 1')
|
||||
ops.append({'op': 'tick', 'n': 1})
|
||||
ops += setup('/execute store result score now piston_bounce run time query gametime',
|
||||
'/scoreboard players operation now piston_bounce -= start piston_bounce')
|
||||
ops.append(cmd('/scoreboard players get now piston_bounce', expect_result=tick))
|
||||
for index in range(len(cases)):
|
||||
selector = f'@e[tag=piston_bounce_{index},limit=1]'
|
||||
for field in ('Pos', 'Motion', 'OnGround'):
|
||||
ops.append(cmd(f'/data get entity {selector} {field}'))
|
||||
if tick in (1, 2):
|
||||
x, z = 4 + index % 2 * 6, 4 + index // 2 * 6
|
||||
ops.append(cmd(f'/execute if block {x} 205 {z} piston[facing=down,extended=true]',
|
||||
expect_result=1))
|
||||
ops.append(cmd(f'/data get block {x} 204 {z} progress'))
|
||||
return ops + setup('/kill @e[type=!player]', '/fill 0 200 0 15 208 15 air',
|
||||
'/scoreboard objectives remove piston_bounce', '/forceload remove 0 0',
|
||||
'/tick unfreeze', '/tp @s 8.5 230 8.5 0 0')
|
||||
Reference in new issue
Block a user