Compare commits

..
Author SHA1 Message Date
Ludovic Marie 128575e4e5 Merge pull request #14029 from horizon-rust/layers/client-lib-style
🤖 Agent: ui_theme's palette, sizes and Geist fonts come from menu_style; with no fonts given (NEUTRAL) the Horizon overlays measure and draw no text instead of panicking.
2026-11-09 21:15:21 +01:00
Ludovic Marie cb870cf80d Merge pull request #14027 from horizon-rust/layers/render-theme-out
🤖 Agent: hud_kit's tween curve is pinned to horizon_theme::MOTION by a dev-dependency test, and the HDR logo reads the icon the window hook was given.
2026-11-09 21:11:27 +01:00
Horizon agentandClaude Opus 5.5 8aed306751 ui_theme: main's text_line test reads the body size and ink from the style
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:24:26 +01:00
Horizon agent bb19f898e4 Merge branch 'layers/render-theme-out' into layers/client-lib-style 2026-11-09 19:17:07 +01:00
Horizon agent bbc17333bd Merge branch 'layers/render-style' into layers/render-theme-out 2026-11-09 19:17:06 +01:00
Horizon agentandClaude Opus 5.5 8630bbed74 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:17:03 +01:00
Horizon agent 2c5baa6a60 Merge branch 'layers/render-theme-out' into layers/client-lib-style
# Conflicts:
#	crates/horizon-client/src/ui_theme.rs
2026-11-09 19:12:40 +01:00
Horizon agent ed35d477be Merge branch 'layers/render-style' into layers/render-theme-out
# Conflicts:
#	tools/layers.json
2026-11-09 19:10:41 +01:00
Horizon agent 7a236ba054 Merge branch 'layers/window-icon-hook' into layers/render-style
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-09 19:10:23 +01:00
Horizon agent bf125a1a69 Merge remote-tracking branch 'origin/main' into layers/window-icon-hook
# Conflicts:
#	crates/horizon-client/src/entry.rs
2026-11-09 19:09:55 +01:00
Horizon agentandClaude Opus 5.5 c873af989a Menu style: ui_theme's overlays take their palette, sizes and fonts from it
horizon-client-lib no longer depends on horizon-theme outside its tests
(layer plan M6). menu_style gains the overlays' colours, ui_theme's type
scale and shapes, and its fonts (none given: the overlays draw no text);
ui_theme, the on-screen keyboard, the missing-resources window and the
Windows boot paint read them. horizon_look gives Horizon's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:55:10 +01:00
Horizon agentandClaude Opus 5.5 84baabff3c Renderer: the HDR logo and the HUD kit's motion no longer read horizon-theme
horizon-client-render no longer depends on horizon-theme outside its tests
(layer plan M6):
- hud_kit::motion: a Tween takes a cubic-bezier curve; the solver lives
  here, tested against the design system's curves.
- The HDR calibration logo is the window icon given at startup.
- The font tests take Geist Mono as a dev-dependency.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:55:10 +01:00
Horizon agentandClaude Opus 5.5 6622e459b2 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:58 +01:00
Horizon agentandClaude Opus 5.5 600a863354 Menu style: the settings pages and the tab strip take their colours from the layer above
menu_style (horizon-client-render): the colours of the settings pages
(graphics_style) and the HUD kit's tab strip, given once at startup;
vanilla's neutral greys when none is given. graphics_style's and tabs'
palette consts become functions reading it. horizon_look gives Horizon's
(layer plan M6; render -> theme goes in the next PR).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:58 +01:00
Horizon agentandClaude Opus 5.5 b77e4c4924 horizon_look.rs: the module the previous commit declares
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:56 +01:00
Horizon agentandClaude Opus 5.5 aa060c5fe1 Horizon's look is given from its own module
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 07ff96c50c rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 19c23a96c6 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 29d8c49bc2 Window icon: the engine takes it from the layer above
horizon-client-input's window code no longer embeds Horizon's icon: the
startup gives it (window::set_icon_png), and with_horizon_icon becomes
with_icon. horizon-client-input no longer depends on horizon-theme outside
its tests (layer plan M6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
152 changed files with 3307 additions and 6241 deletions

No files matched your search

-8
View File
@@ -13,14 +13,6 @@ else (videos, Discord and other community material) lives elsewhere.
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
**Mod-related pull requests go to [arismoko/horizon](https://github.com/arismoko/horizon)**, the fork that carries the
mod API and reviews and merges that work. That covers the mod API, native hooks and events for mods, native mods,
content mods (`.hmod`), mixins, wasm mods, `cargo horizon-mod`, `horizon-mods`, mod builds and the registry, and
their docs ([mods](docs/mods.md), [native mods](docs/native-mods.md)). Open the pull request there from your usual
branch. Mod-related pull requests opened here are closed with a pointer to it. Engine extension points that Horizon's
own crates register (the [layer split](docs/layers.md)), resource and shader pack formats, and gameplay fixes stay
here.
## Build it
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
Generated
+7 -23
View File
@@ -1418,7 +1418,6 @@ dependencies = [
name = "horizon-chat-filter"
version = "0.1.0"
dependencies = [
"horizon-server-files",
"serde",
"serde_json",
"toml",
@@ -1529,6 +1528,7 @@ dependencies = [
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
@@ -1592,6 +1592,7 @@ dependencies = [
"horizon-optifine",
"horizon-polytone",
"horizon-protocol",
"horizon-render-pipeline",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
@@ -1650,15 +1651,6 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-discord"
version = "0.1.0"
dependencies = [
"horizon-platform",
"log",
"serde_json",
]
[[package]]
name = "horizon-dualsense"
version = "0.1.0"
@@ -1676,7 +1668,6 @@ dependencies = [
"getrandom 0.3.4",
"hkdf",
"horizon-addresses",
"horizon-platform",
"if-addrs",
"serde",
"serde_json",
@@ -1802,25 +1793,13 @@ dependencies = [
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-discord",
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
"horizon-official-servers",
"horizon-platform",
"log",
"winresource",
]
[[package]]
name = "horizon-official-servers"
version = "0.1.0"
dependencies = [
"horizon-addresses",
"horizon-platform",
"log",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
@@ -1912,6 +1891,10 @@ dependencies = [
"ring",
]
[[package]]
name = "horizon-render-pipeline"
version = "0.1.0"
[[package]]
name = "horizon-server"
version = "0.1.0"
@@ -1974,6 +1957,7 @@ version = "0.1.0"
dependencies = [
"half",
"horizon-core",
"horizon-render-pipeline",
"naga",
"png",
"zip",
+1 -2
View File
@@ -34,8 +34,6 @@ members = [
"crates/horizon-game",
"crates/horizon-official",
"crates/horizon-heartbeat",
"crates/horizon-discord",
"crates/horizon-official-servers",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
@@ -45,6 +43,7 @@ members = [
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-render-pipeline",
"crates/horizon-optifine",
"crates/horizon-polytone",
"crates/horizon-shader-api",
-4
View File
@@ -15,10 +15,6 @@ pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
/// `official_server_previous` in `launcher/links.json`).
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
/// Afri-Network, a partner server the game added once below the official server (#9299) until
/// the partnership ended: kept so the game can take back out the entry it added.
pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
/// (the telemetry server's status checks).
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
-2
View File
@@ -7,8 +7,6 @@ rust-version.workspace = true
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
# The moderation durations, shared with `/tempban`.
horizon-server-files = { path = "../horizon-server-files" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.1"
+55 -3
View File
@@ -10,9 +10,6 @@ use std::path::{Path, PathBuf};
mod normalise;
pub use horizon_server_files::duration::{
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
};
pub use normalise::{Run, Text};
/// The list built into the game, used when `words_file` is empty.
@@ -477,6 +474,61 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
writeln!(f, "{line}")
}
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
/// `w` (`10m`, `2h`, `1d`).
pub fn parse_duration(text: &str) -> Option<i64> {
let unit = match text.chars().last()? {
's' => 1_000,
'm' => MINUTE,
'h' => 60 * MINUTE,
'd' => 24 * 60 * MINUTE,
'w' => 7 * 24 * 60 * MINUTE,
_ => return None,
};
let count: i64 = text[..text.len() - 1].parse().ok()?;
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// The unit used when presenting a moderation duration to a player.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DurationUnit {
Minute,
Hour,
Day,
}
/// A duration's rounded count and unit, shared by logs and translated replies.
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
let (unit, kind) = if millis <= 60 * MINUTE {
(MINUTE, DurationUnit::Minute)
} else if millis <= 48 * 60 * MINUTE {
(60 * MINUTE, DurationUnit::Hour)
} else {
(24 * 60 * MINUTE, DurationUnit::Day)
};
(
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
kind,
)
}
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let (count, unit) = duration_parts(millis);
let unit = match unit {
DurationUnit::Minute => "minute",
DurationUnit::Hour => "hour",
DurationUnit::Day => "day",
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
pub fn utc(millis: i64) -> String {
let secs = millis.div_euclid(1000);
+4 -2
View File
@@ -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 = []
+32 -43
View File
@@ -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")]
@@ -670,39 +691,6 @@ pub fn with_horizon_icon(
attributes
}
/// The screen a window is on: its size in physical pixels, its refresh rate where the OS gives
/// it, and its scale. For `horizon-ctl status` and the performance statistics' `system.display`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Display {
pub width: u32,
pub height: u32,
pub refresh_hz: Option<u32>,
pub scale: f64,
}
/// The screen `window` is on, else the primary one, else the only one: right after the window
/// is made, Wayland doesn't say yet which screen it is on.
pub fn monitor(window: &winit::window::Window) -> Option<winit::monitor::MonitorHandle> {
let only = || {
let mut all = window.available_monitors();
all.next().filter(|_| all.next().is_none())
};
window
.current_monitor()
.or_else(|| window.primary_monitor())
.or_else(only)
}
/// [`monitor`]'s size, refresh rate and scale.
pub fn display(window: &winit::window::Window) -> Option<Display> {
monitor(window).map(|m| Display {
width: m.size().width,
height: m.size().height,
refresh_hz: m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
scale: m.scale_factor(),
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -739,7 +727,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]
+9 -2
View File
@@ -42,6 +42,8 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
# Play with Friends: invite codes and their tunnel; the `friends` feature.
horizon-friends = { path = "../horizon-friends", optional = true }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
@@ -70,7 +72,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,12 +110,16 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers"]
default = ["audio", "controllers", "discord", "friends"]
# 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"]
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
discord = []
# Play with Friends, joining side (`net.rs`): without it no invite code joins.
friends = ["dep:horizon-friends"]
# 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.
@@ -126,6 +131,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).
+3 -1
View File
@@ -26,10 +26,10 @@ horizon-narrator = { path = "../horizon-narrator" }
horizon-optifine = { path = "../horizon-optifine" }
horizon-polytone = { path = "../horizon-polytone" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-render-pipeline = { path = "../horizon-render-pipeline" }
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"
@@ -81,5 +81,7 @@ test-support = []
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"] }
+23 -6
View File
@@ -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) are kept here too.
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
@@ -145,6 +145,8 @@ 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,
@@ -231,6 +233,7 @@ impl Default for Extras {
no_ai: false,
agent_control: false,
repro_keys: false,
discord_presence: true,
splitscreen: false,
splitscreen_two: 0,
splitscreen_three: 0,
@@ -335,6 +338,7 @@ 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),
@@ -372,7 +376,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\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),
@@ -414,6 +418,7 @@ 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,
@@ -608,6 +613,8 @@ 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.
@@ -684,6 +691,7 @@ 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",
@@ -716,6 +724,7 @@ 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",
@@ -759,6 +768,7 @@ 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",
@@ -782,9 +792,10 @@ impl Item {
ChatIndicator => ChatAnimation,
Pvp(o) => return o.parent().map(Pvp),
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
| Pregen => {
return None;
}
})
@@ -833,6 +844,9 @@ 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."
}
@@ -913,6 +927,7 @@ 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,
@@ -971,6 +986,7 @@ 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,
@@ -1088,7 +1104,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.wheel_cycles_settings);
assert!(d.discord_presence && !d.wheel_cycles_settings);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -1127,6 +1143,7 @@ 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));
+8 -288
View File
@@ -1,6 +1,5 @@
//! The Horizon Extras page, on the settings shell (`settings_shell`): which rows it has and what
//! pressing them does. Each one's value and what it does stay in `extras::Item`; the sections crates
//! add (`horizon_hooks::pages::Place::Extras`) keep theirs.
//! pressing them does. Each one's value and what it does stay in `extras::Item`.
use crate::{
extras::{self, Item},
lang,
@@ -8,7 +7,6 @@ use crate::{
settings::Settings,
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
};
use horizon_hooks::pages::{self as added, Act, Label};
const TITLE: &str = "horizon.pages.extras";
const ABOUT: &str = "horizon.pages.extras.tooltip";
@@ -33,121 +31,6 @@ pub(crate) enum ExtraRow {
/// It isn't installed: where to get it. A note, not a button: Options > Mods already has the
/// one way into the Mods page (`every_setting_and_page_is_reached_from_one_place`).
ZoomInstall,
/// Row `.1` of the section registered page `.0` adds (`Place::Extras`).
Added(usize, usize),
}
#[cfg(test)]
thread_local! {
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
const { std::cell::Cell::new(None) };
}
/// The pages crates registered, all places.
fn pages() -> &'static [&'static dyn added::Page] {
#[cfg(test)]
if let Some(pages) = TEST_PAGES.get() {
return pages;
}
added::engine::all()
}
/// The sections of this page, by their index among the registered pages.
fn sections() -> impl Iterator<Item = (usize, &'static dyn added::Page)> {
let pages = pages().iter().copied().enumerate();
pages.filter(|(_, page)| page.place() == added::Place::Extras)
}
fn label_text(label: Label) -> String {
match label {
Label::Key(key) => lang::get(key),
Label::Value(text) => text,
}
}
/// The page's group named `name`, made at the end if it has none.
fn group_named<'a>(
groups: &'a mut Vec<Group<ExtraRow>>,
name: &'static str,
) -> &'a mut Group<ExtraRow> {
let at = groups.iter().position(|group| group.name == name);
let at = at.unwrap_or_else(|| {
groups.push(Group {
name,
rows: Vec::new(),
});
groups.len() - 1
});
&mut groups[at]
}
/// The sections' rows, in the groups their titles and headings name.
fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
for (page, section) in sections() {
let mut rows = Vec::new();
section.rows(&mut rows);
let mut name = section.title();
for (r, row) in rows.into_iter().enumerate() {
let id = ExtraRow::Added(page, r);
let row = match row {
added::Row::Heading(key) => {
name = key;
continue;
}
added::Row::Note(text) => Row::new(id, text, Value::Note).live(false),
added::Row::Toggle { label, on } => {
Row::new(id, lang::get(label), Value::Switch(on))
}
added::Row::Cycle { label, value } => {
Row::new(id, lang::get(label), Value::Choice(label_text(value)))
}
added::Row::Slider { label, value, text } => {
let position = value.clamp(0.0, 1.0);
let text = label_text(text);
let value = Value::Slider {
position,
steps: None,
text,
};
Row::new(id, lang::get(label), value)
}
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
added::Row::Key { label, key } => {
let key = crate::keybinds::display_name(&key).plain();
Row::new(id, lang::get(label), Value::Read(key))
}
// A row kind newer than this engine.
_ => continue,
};
let row = match section.tooltip(r) {
Some(about) => row.about(label_text(about)),
None => row,
};
group_named(groups, name).rows.push(row);
}
}
}
/// Row `r` of registered page `page` was acted on: the options screen saves when it closes.
fn act(page: usize, r: usize, act: Act) -> Vec<Output> {
let Some(section) = pages().get(page) else {
return Vec::new();
};
section.act(r, act);
vec![Output::Event(Event::Changed)]
}
/// Whether row `r` of registered page `page` steps (a switch or a cycle).
fn steps(page: usize, r: usize) -> bool {
let mut rows = Vec::new();
if let Some(section) = pages().get(page) {
section.rows(&mut rows);
}
matches!(
rows.get(r),
Some(added::Row::Toggle { .. } | added::Row::Cycle { .. })
)
}
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
@@ -202,14 +85,9 @@ impl Screen for ExtrasScreen {
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
/// The rows read the extras, the mods and whether cosmetics are offered.
fn stamp(&self, s: &Settings) -> u64 {
let mut added = Vec::new();
for (_, section) in sections() {
section.rows(&mut added);
}
let cosmetics = horizon_cosmetics::enabled();
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
shell::stamp_of(&(&s.extras, &s.mods, horizon_cosmetics::enabled()))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
let mut groups = vec![Group {
@@ -229,7 +107,6 @@ impl Screen for ExtrasScreen {
}
}
}
add_sections(&mut groups);
vec![Sheet::new((), lang::get(TITLE), lang::get(ABOUT), groups)]
}
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
@@ -248,29 +125,16 @@ impl Screen for ExtrasScreen {
Output::Event(Event::NativeSettings(i)),
],
ExtraRow::ZoomInstall => Vec::new(),
ExtraRow::Added(page, r) => {
act(page, r, if back { Act::PressBack } else { Act::Press })
}
}
}
fn slide(&mut self, _s: &mut Settings, row: ExtraRow, position: f32) {
if let ExtraRow::Added(page, r) = row {
act(page, r, Act::Slide(position));
}
}
/// With the wheel extra on, the wheel over an item changes it, without a click; the mod's rows
/// let the list scroll.
fn wheel_step(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Option<Vec<Output>> {
match row {
ExtraRow::Item(item) => {
item.cycle(&mut s.extras, back);
Some(vec![Output::Event(Event::Changed)])
}
ExtraRow::Added(page, r) if steps(page, r) => {
Some(act(page, r, if back { Act::PressBack } else { Act::Press }))
}
_ => None,
}
let ExtraRow::Item(item) = row else {
return None;
};
item.cycle(&mut s.extras, back);
Some(vec![Output::Event(Event::Changed)])
}
}
@@ -428,148 +292,4 @@ mod tests {
shell.key(&mut s, K::ArrowDown, None, false, size);
assert_eq!(shell.focused_row(), Some(first));
}
/// A section with a switch in the page's Multiplayer group, then a heading of its own with a
/// number that steps either way, a note and a volume.
struct Section {
on: std::sync::Mutex<bool>,
step: std::sync::Mutex<i32>,
volume: std::sync::Mutex<f32>,
}
impl added::Page for Section {
fn id(&self) -> &'static str {
"test.section"
}
fn title(&self) -> &'static str {
"horizon.extras.header.multiplayer"
}
fn place(&self) -> added::Place {
added::Place::Extras
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::Toggle {
label: "test.section.switch",
on: *self.on.lock().unwrap(),
});
rows.push(added::Row::Heading("test.section.heading"));
rows.push(added::Row::Cycle {
label: "test.section.step",
value: Label::Value(self.step.lock().unwrap().to_string()),
});
rows.push(added::Row::Note("a note".into()));
rows.push(added::Row::Slider {
label: "test.section.volume",
value: *self.volume.lock().unwrap(),
text: Label::Value(String::new()),
});
}
fn act(&self, row: usize, act: Act) {
match (row, act) {
(0, Act::Press | Act::PressBack) => {
let mut on = self.on.lock().unwrap();
*on = !*on;
}
(2, Act::Press) => *self.step.lock().unwrap() += 1,
(2, Act::PressBack) => *self.step.lock().unwrap() -= 1,
(4, Act::Slide(v)) => *self.volume.lock().unwrap() = v,
_ => {}
}
}
}
/// A page for Horizon Settings: never on Extras.
struct Elsewhere;
impl added::Page for Elsewhere {
fn id(&self) -> &'static str {
"test.elsewhere"
}
fn title(&self) -> &'static str {
"test.elsewhere"
}
fn place(&self) -> added::Place {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::Button {
label: "test.elsewhere.button",
});
}
fn act(&self, _: usize, _: Act) {}
}
fn with_section() -> &'static Section {
let section: &'static Section = Box::leak(Box::new(Section {
on: false.into(),
step: 0.into(),
volume: 0.5.into(),
}));
let pages: &'static [&'static dyn added::Page] = Box::leak(Box::new([
&Elsewhere as &dyn added::Page,
section as &dyn added::Page,
]));
TEST_PAGES.set(Some(pages));
section
}
/// The section's first row joins the page's own Multiplayer group, after its rows; its heading
/// starts a group after the page's; a page for elsewhere adds nothing.
#[test]
fn a_section_joins_the_group_its_title_names_and_heads_its_own() {
with_section();
let s = Settings::default();
let sheets = ExtrasScreen.sheets(&s);
let groups = &sheets[0].groups;
let ids = |name: &str| -> Vec<ExtraRow> {
let group = groups.iter().find(|g| g.name == name).unwrap();
group.rows.iter().map(|r| r.id).collect()
};
let multiplayer = ids("horizon.extras.header.multiplayer");
assert_eq!(multiplayer.last(), Some(&ExtraRow::Added(1, 0)));
assert_eq!(groups.last().unwrap().name, "test.section.heading");
let own = [
ExtraRow::Added(1, 2),
ExtraRow::Added(1, 3),
ExtraRow::Added(1, 4),
];
assert_eq!(ids("test.section.heading"), own);
let all = rows_of(&s);
assert!(!all.iter().any(|r| matches!(r.id, ExtraRow::Added(0, _))));
}
/// A click flips the switch, a Shift click steps the number back, the wheel steps it, and the
/// note takes no click; the rows show what the section holds after each.
#[test]
fn a_sections_rows_act_on_it() {
let section = with_section();
let mut s = Settings::default();
let mut shell = Shell::<ExtrasScreen>::default();
let switch = ExtraRow::Added(1, 0);
let at = shell.point_at(&s, SIZE, switch);
assert!(
!shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert!(*section.on.lock().unwrap());
let value = |s: &Settings, id| rows_of(s).into_iter().find(|r| r.id == id).unwrap().value;
assert_eq!(value(&s, switch), Value::Switch(true));
let step = ExtraRow::Added(1, 2);
let mut screen = ExtrasScreen;
screen.press(&mut s, step, true);
assert_eq!(value(&s, step), Value::Choice("-1".into()));
assert!(screen.wheel_step(&mut s, step, false).is_some());
assert_eq!(*section.step.lock().unwrap(), 0);
assert_eq!(
screen.wheel_step(&mut s, ExtraRow::Added(1, 3), false),
None
);
let note = shell.point_at(&s, SIZE, ExtraRow::Added(1, 3));
assert!(
shell
.click(&mut s, note, SIZE, false, false, false)
.is_empty()
);
screen.slide(&mut s, ExtraRow::Added(1, 4), 0.25);
assert_eq!(*section.volume.lock().unwrap(), 0.25);
}
}
+58 -8
View File
@@ -239,7 +239,6 @@ mod reuse;
mod stars;
mod surface_setup;
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
mod off_page;
pub mod submit;
mod sunrise_fan;
mod timing;
@@ -251,7 +250,6 @@ mod wgsl_prefix;
mod world_post;
pub mod world_primitives;
mod world_warp;
use off_page::move_off_page;
/// A section's instance: its camera-relative origin and, in `w`, the step of
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
@@ -728,6 +726,62 @@ impl Part {
}
}
}
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
/// section arena, GPU culling's records (`records`) along. False when the other
/// pages have no room left.
fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
mut records: Option<&mut gpu_culling::Records>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
budget: &mut u64,
) -> bool {
debug_assert!(
records.is_none() || matches!(part, Part::Section { .. }),
"records follow the section arena's ranges"
);
let mut moved = false;
for s in meshes {
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
// range of page 0); in the other arenas, one without translucent quads.
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
continue;
};
let bytes = vertices * arena.stride();
if moved && bytes > *budget {
break;
}
let Some(to) = arena.alloc_moved(vertices) else {
return false;
};
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
let key = match part {
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
_ => to.0,
};
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
// Its solid quads start the range in either layout.
if let (Part::Section { .. }, Some(records), Some(id)) =
(part, records.as_deref_mut(), s.gpu_id)
{
records.relocate(id, to.0, u64::from(s.solid_base()));
}
*budget = budget.saturating_sub(bytes);
moved = true;
}
true
}
/// Starts moving a page of a large arena far larger than its ranges in use
/// (`Arena::oversized`) into a new page of the size they get (`Arena::open_page`),
/// for `Renderer::shrink_translucent`: the page closes and `move_off_page` empties
@@ -10062,11 +10116,7 @@ impl Renderer {
let pack_reads_world = pack_post || native_post;
laps.lap("r_uploads");
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
// Post effects (a resource pack's chains, `use_depth_buffer`) read the world's depth
// after the world pass, as `GameRenderer.render` hands them the main target's.
let post_reads_depth = !frame.title && !frame.post_effects.effects.is_empty();
let keep_depth = pack_reads_world
|| post_reads_depth
|| (taa && self.world_post.taa)
|| oit_on
|| native_render::render_needs().world_depth
@@ -15668,11 +15718,11 @@ mod shadow_caster_tests {
#[test]
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
let shadow = horizon_shaders::Shadow {
let shadow = horizon_render_pipeline::Shadow {
distance: 128.0,
near,
far,
..horizon_shaders::Shadow::default()
..horizon_render_pipeline::Shadow::default()
};
let camera = DVec3::new(13.5, y + 1.62, -13.5);
let view = pack_post::ShadowView {
+6 -4
View File
@@ -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
@@ -338,7 +338,7 @@ pub struct NativeFrame {
reflection_sampler: wgpu::Sampler,
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
pub shadow_map: super::native_shadow::ShadowMap,
shadow_camera: Option<horizon_shaders::Shadow>,
shadow_camera: Option<horizon_render_pipeline::Shadow>,
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
/// is drawn and `has_reflection` stays 0.
pub reflects: bool,
@@ -1497,7 +1497,7 @@ pub fn frame_inputs(
size: [u32; 2],
seconds: f32,
index: u32,
shadow: Option<&horizon_shaders::Shadow>,
shadow: Option<&horizon_render_pipeline::Shadow>,
) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
@@ -6,8 +6,8 @@
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_render_pipeline::Shadow;
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
use super::wgsl_prefix::{TERRAIN, prefixed, section};
@@ -1,59 +0,0 @@
//! `move_off_page`, moved out of `gfx.rs` unchanged.
use super::*;
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
/// section arena, GPU culling's records (`records`) along. False when the other
/// pages have no room left.
pub(super) fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
mut records: Option<&mut gpu_culling::Records>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
budget: &mut u64,
) -> bool {
debug_assert!(
records.is_none() || matches!(part, Part::Section { .. }),
"records follow the section arena's ranges"
);
let mut moved = false;
for s in meshes {
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
// range of page 0); in the other arenas, one without translucent quads.
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
continue;
};
let bytes = vertices * arena.stride();
if moved && bytes > *budget {
break;
}
let Some(to) = arena.alloc_moved(vertices) else {
return false;
};
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
let key = match part {
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
_ => to.0,
};
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
// Its solid quads start the range in either layout.
if let (Part::Section { .. }, Some(records), Some(id)) =
(part, records.as_deref_mut(), s.gpu_id)
{
records.relocate(id, to.0, u64::from(s.solid_base()));
}
*budget = budget.saturating_sub(bytes);
moved = true;
}
true
}
@@ -4,7 +4,8 @@
use std::collections::BTreeMap;
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
use horizon_render_pipeline::{Format, Kind};
use horizon_shaders::{BufferObject, CustomImage};
pub(super) struct PackImage {
pub(super) def: CustomImage,
@@ -18,7 +18,8 @@
use std::collections::HashMap;
use glam::{DMat4, DVec3};
use horizon_shaders::{BUFFERS, Format, Group, Kind, Post, SHADOW_COLORS};
use horizon_render_pipeline::{BUFFERS, Format, Kind, SHADOW_COLORS};
use horizon_shaders::{Group, Post};
use smallvec::SmallVec;
use super::pack_draw::DrawUniforms;
@@ -892,7 +893,7 @@ fn depth_linear(interface: &horizon_shaders::Interface, linear: u8) -> [f32; 4]
}
fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool) -> Source {
let shadow_color = horizon_shaders::shadow_color(name);
let shadow_color = horizon_render_pipeline::shadow_color(name);
let shadow_depth = match name {
"shadowtex0" | "watershadow" | "shadowtex0HW" => Some(0),
"shadowtex1" | "shadowtex1HW" => Some(1),
@@ -911,7 +912,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
};
}
}
if shadow_space && horizon_shaders::buffer_index(name).is_some() {
if shadow_space && horizon_render_pipeline::buffer_index(name).is_some() {
// colortex buffers are not this pass's.
return Source::Dummy;
}
@@ -925,7 +926,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
"normals" => Source::Normals,
"specular" => Source::Specular,
horizon_shaders::WATER_MASK => Source::WaterMask,
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
n => horizon_render_pipeline::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1582,7 +1583,7 @@ fn bindings(
!unbound
&& (t.stage != pack_textures::stage(group)
|| released.0 != t.stage
|| horizon_shaders::buffer_index(&t.name)
|| horizon_render_pipeline::buffer_index(&t.name)
.is_none_or(|b| released.1 & 1 << b == 0))
});
let image = pack
@@ -2432,7 +2433,7 @@ impl PackPost {
let shadow_config = post.config.shadow.clone();
let noise_size = post.config.noise_resolution.unwrap_or(256);
let half_lives = post.config.half_lives;
for (name, ticks) in horizon_shaders::HALF_LIVES.iter().zip(half_lives) {
for (name, ticks) in horizon_render_pipeline::HALF_LIVES.iter().zip(half_lives) {
if let Some(t) = ticks {
notes.push(format!("`{name}` is {t} ticks"));
}
@@ -10,7 +10,7 @@
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
use horizon_shaders::{Format, Kind};
use horizon_render_pipeline::{Format, Kind};
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
@@ -3,7 +3,7 @@
//! with two sides that `shadowcomp` programs flip as composite programs flip colortex (6.1.6).
//! Square, `shadowMapResolution` pixels.
use horizon_shaders::SHADOW_COLORS;
use horizon_render_pipeline::SHADOW_COLORS;
type Ops<T> = wgpu::Operations<T>;
type Color = wgpu::Color;
@@ -97,7 +97,11 @@ pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
}
impl ShadowTargets {
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
pub fn reset_history(
&mut self,
device: &wgpu::Device,
shadow: &horizon_render_pipeline::Shadow,
) {
let mut colors = 0;
let mut doubled = 0;
let formats = std::array::from_fn(|n| match &self.color[n] {
@@ -125,7 +129,7 @@ impl ShadowTargets {
/// resolution is kept within the device's limit.
pub fn new(
device: &wgpu::Device,
shadow: &horizon_shaders::Shadow,
shadow: &horizon_render_pipeline::Shadow,
formats: [wgpu::TextureFormat; SHADOW_COLORS],
colors: u16,
doubled: u16,
@@ -1,7 +1,8 @@
//! A shader pack's custom textures (`texture.<stage>.<name>`, `texture.noise`) on the GPU, and
//! which of them a program's sampler reads.
use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
use horizon_render_pipeline::Kind;
use horizon_shaders::{CustomTexture, Group, Texels, Texture, TextureDim, TextureSource};
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
pub(super) enum Image {
@@ -118,12 +119,12 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
} else {
wgpu::TextureViewDimension::D2
};
let buffer = horizon_shaders::buffer_index(name);
let buffer = horizon_render_pipeline::buffer_index(name);
let staged = textures.iter().position(|t| {
t.stage == stage(group)
&& t.dimension == dimension
&& (t.name == name
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
|| buffer.is_some() && horizon_render_pipeline::buffer_index(&t.name) == buffer)
});
staged.or_else(|| {
textures.iter().position(|t| {
@@ -28,7 +28,7 @@
//! [-w, w], and the depth buffers packs read hold GL window depth.
use glam::{DMat4, DVec3, Mat4, Vec3, Vec4};
use horizon_shaders::Shadow;
use horizon_render_pipeline::Shadow;
use super::Frame;
@@ -440,7 +440,7 @@ pub struct FrameUniforms {
center_depth: Option<f32>,
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
/// The pack's smoothing half-lives in ticks (`horizon_shaders::HALF_LIVES`).
/// The pack's smoothing half-lives in ticks (`horizon_render_pipeline::HALF_LIVES`).
pub half_lives: [Option<f32>; 4],
}
@@ -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]
+1
View File
@@ -75,6 +75,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)
}
+65 -23
View File
@@ -339,7 +339,6 @@ impl Opt {
Opt::SharePresence => "options.sharePresence",
Opt::DirectionalAudio => "options.directionalAudio",
Opt::MusicToast => "options.musicToast",
Opt::Page(page, row) => pages::slider_caption(page, row),
}
}
pub(crate) fn kind(self, s: &Settings) -> Kind {
@@ -403,7 +402,6 @@ impl Opt {
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
Opt::PadTriggerThreshold => unit(s.controller.trigger_threshold, 0.1, 0.9),
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
Opt::Page(page, row) => pages::slider_kind(page, row),
_ => Kind::Cycle,
}
}
@@ -880,7 +878,6 @@ impl Opt {
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
}),
Opt::Page(page, row) => pages::slider_text(page, row),
_ => Text::empty(),
}
}
@@ -955,7 +952,6 @@ impl Opt {
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
Opt::PadTriggerThreshold => s.controller.trigger_threshold = step(10.0, 90.0) / 100.0,
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
Opt::Page(page, row) => pages::slide(page, row, v),
_ => {}
}
if self.affects_preset() {
@@ -1335,8 +1331,6 @@ pub enum Page {
ModProject,
/// A native mod's settings page: its index in `mods.installed`.
NativeMod(usize),
/// A page a crate registered (`horizon_hooks::pages`): its index in `OptionsUi::pages`.
Added(usize),
/// Horizon's shader packs, either kind (`shader_pack_list`).
ShaderPacks,
/// The picked pack's options: the shader screen (`shader_ui`) at the pack's main screen.
@@ -1396,8 +1390,6 @@ enum Act {
/// A native mod's key (mod, key) waits for a key; a button of its page.
NativeKey(usize, usize),
NativeButton(usize, usize),
/// A registered page's row: (page, row).
PageRow(usize, usize),
OpenModFolder,
/// Browse mods: the page of listing `.0` of `mod_browser`.
ModProject(usize),
@@ -1625,8 +1617,7 @@ pub struct OptionsUi {
/// Key Binds: the mapping waiting for a key ([`NATIVE_KEY`]: a native
/// mod's, `selected_native_key`).
pub selected_key: Option<&'static str>,
/// The native mod's key waiting: (mod, key) in `mods.installed`, or a registered page's
/// (page, row) ([`pages::PAGE_KEY`]).
/// The native mod's key waiting: (mod, key) in `mods.installed`.
selected_native_key: Option<(usize, usize)>,
/// Bindings: the action waiting for a pad button.
pub selected_pad_action: Option<&'static str>,
@@ -1870,6 +1861,25 @@ fn wide_extras(s: &Settings, width: impl Fn(&str) -> f32) -> Vec<crate::extras::
})
.collect()
}
/// What a button opening one of Horizon's pages says on hover.
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Some(match page {
Page::Horizon => "horizon.pages.settings.tooltip",
Page::Studio => "horizon.pages.studio.tooltip",
Page::Graphics => "horizon.pages.graphics.tooltip",
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
Page::Controller => "horizon.pages.controller.tooltip",
Page::Extras => "horizon.pages.extras.tooltip",
Page::Pvp => "horizon.pages.pvp.tooltip",
Page::Agent => "horizon.pages.agent.tooltip",
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
Page::Music => "horizon.pages.music.tooltip",
_ => return None,
})
}
/// Rows of an OptionsList: (big?, options) or a header.
enum Row {
Big(Opt),
@@ -1889,17 +1899,14 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
return None;
};
let key = opt.tooltip_key(s);
let Opt::Page(page, row) = opt else {
return lang::has(&key).then(|| tr(&key));
};
return pages::tooltip(*page, *row);
return lang::has(&key).then(|| tr(&key));
};
let mut tip = None;
if let Act::Extra(item) = act {
tip = Some(Text::literal(item.description_text()));
}
if let Act::Push(page) = act
&& let Some(text) = pages::horizon_page_tooltip(*page)
&& let Some(text) = horizon_page_tooltip(*page)
{
tip = Some(tr(text));
}
@@ -1919,7 +1926,6 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
}
match act {
Act::Cycle(opt) if !opt.available() => Some(tr("horizon.options.unavailable")),
Act::PageRow(page, row) => pages::tooltip(*page, *row),
Act::ToggleMod(i) => match s.mods.installed.get(*i) {
Some(installed) => {
let description = installed
@@ -2361,7 +2367,6 @@ impl OptionsUi {
Page::ModBrowser => "horizon.mods.browse.title",
Page::ModProject => "horizon.mods.browse.project",
Page::NativeMod(_) => "Mod Settings",
Page::Added(i) => pages::title(i),
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::ShaderOptions => "horizon.pages.shader_options",
Page::PackOptions => "horizon.pack.options.title",
@@ -2642,7 +2647,47 @@ impl OptionsUi {
flush(&mut rows, &mut section);
rows
}
Page::Horizon => self.horizon_rows(s),
Page::Horizon => {
// Two to a row, as vanilla's OptionsList.addSmall lays out
// buttons; each opens one of Horizon's settings pages. Each page has
// one way in: graphics and shader packs are Video Settings' (the graphics
// screen), the controller is Controls', mods are the Options screen's.
let open =
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
vec![
Row::Buttons(
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.pvp", Page::Pvp)),
),
Row::Buttons(
open(
"horizon.pages.open.split_setup",
if self.split_guest {
Page::SplitProfiles
} else {
Page::SplitSetup
},
),
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()
}
Page::Agent => {
let mut rows = vec![Row::Buttons(
self.extra_button(s, crate::extras::Item::AgentControl),
@@ -2925,7 +2970,6 @@ impl OptionsUi {
Page::ModBrowser => self.mod_browser_rows(),
Page::ModProject => self.mod_project_rows(s),
Page::NativeMod(i) => self.native_rows(s, i),
Page::Added(i) => self.added_rows(i),
_ => {
let _ = s;
Vec::new()
@@ -4194,7 +4238,6 @@ impl OptionsUi {
}
Act::NativeKey(i, k) => self.wait_native_key(i, k),
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::PageRow(p, r) => events.extend(self.page_act(p, r, self.shift)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::ModProject(i) => {
if let Some(listing) = self.mod_browser.listings.get(i) {
@@ -4698,8 +4741,8 @@ impl OptionsUi {
let Some(name) = self.selected_key.take() else {
return Vec::new();
};
if name == NATIVE_KEY || name == pages::PAGE_KEY {
return self.bind_native_key(s, name, value);
if name == NATIVE_KEY {
return self.bind_native_key(s, value);
}
if !s.extras.set_key(name, value.clone()) {
s.keys.set(name, &value);
@@ -5083,7 +5126,6 @@ impl OptionsUi {
}
Act::NativeKey(i, k) => self.wait_native_key(i, k),
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::PageRow(p, r) => events.extend(self.page_act(p, r, shift)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::ModProject(i) => {
if let Some(listing) = self.mod_browser.listings.get(i) {
@@ -1,224 +1,8 @@
//! The pages crates add to the options screens: a native mod's settings page (its keys and
//! buttons), and the pages crates above the engine register (`horizon_hooks::pages`, docs/layers.md
//! "Platform registries") with their entries on Horizon Settings. Kept out of options_ui.rs, which
//! may not grow (tools/check_file_size.py).
//! buttons). Kept out of options_ui.rs, which may not grow (tools/check_file_size.py).
use super::*;
use horizon_hooks::pages as added;
/// `selected_key` while a registered page's key waits (`selected_native_key`: page, row).
pub(super) const PAGE_KEY: &str = "horizon.pageKey";
#[cfg(test)]
thread_local! {
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
const { std::cell::Cell::new(None) };
}
/// The pages crates registered.
fn registered() -> &'static [&'static dyn added::Page] {
#[cfg(test)]
if let Some(pages) = TEST_PAGES.get() {
return pages;
}
added::engine::all()
}
/// A registered page's title (a translation key).
pub(super) fn title(page: usize) -> &'static str {
registered().get(page).map_or("", |p| p.title())
}
/// Row `r` of a registered page, as it is now.
fn row(page: usize, r: usize) -> Option<added::Row> {
let mut rows = Vec::new();
registered().get(page)?.rows(&mut rows);
rows.into_iter().nth(r)
}
/// A registered page's slider ([`Opt::Page`]): its caption (a translation key).
pub(super) fn slider_caption(page: usize, r: usize) -> &'static str {
match row(page, r) {
Some(added::Row::Slider { label, .. }) => label,
_ => "",
}
}
/// A registered page's slider: its position.
pub(super) fn slider_kind(page: usize, r: usize) -> Kind {
match row(page, r) {
Some(added::Row::Slider { value, .. }) => Kind::Slider(value.clamp(0.0, 1.0)),
_ => Kind::Cycle,
}
}
/// A registered page's slider: the value its message shows after the caption.
pub(super) fn slider_text(page: usize, r: usize) -> Text {
match row(page, r) {
Some(added::Row::Slider { text, .. }) => label_text(text),
_ => Text::empty(),
}
}
/// A registered page's slider moved.
pub(super) fn slide(page: usize, r: usize, value: f32) {
if let Some(registered) = registered().get(page) {
registered.act(r, added::Act::Slide(value));
}
}
/// What a registered page's row says on hover.
pub(super) fn tooltip(page: usize, r: usize) -> Option<Text> {
registered().get(page)?.tooltip(r).map(label_text)
}
/// What a button opening one of Horizon's pages says on hover.
pub(super) fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Some(match page {
Page::Horizon => "horizon.pages.settings.tooltip",
Page::Studio => "horizon.pages.studio.tooltip",
Page::Graphics => "horizon.pages.graphics.tooltip",
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
Page::Controller => "horizon.pages.controller.tooltip",
Page::Extras => "horizon.pages.extras.tooltip",
Page::Pvp => "horizon.pages.pvp.tooltip",
Page::Agent => "horizon.pages.agent.tooltip",
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
Page::Music => "horizon.pages.music.tooltip",
_ => return None,
})
}
fn label_text(label: added::Label) -> Text {
match label {
added::Label::Key(key) => tr(key),
added::Label::Value(value) => Text::literal(value),
}
}
impl OptionsUi {
/// Horizon Settings: its pages, then the pages crates registered for it.
pub(super) fn horizon_rows(&self, s: &Settings) -> Vec<Row> {
// Two to a row, as vanilla's OptionsList.addSmall lays out
// buttons; each opens one of Horizon's settings pages. Each page has
// one way in: graphics and shader packs are Video Settings' (the graphics
// screen), the controller is Controls', mods are the Options screen's.
let open = |key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
let mut rows: Vec<Row> = vec![
Row::Buttons(
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.pvp", Page::Pvp)),
),
Row::Buttons(
open(
"horizon.pages.open.split_setup",
if self.split_guest {
Page::SplitProfiles
} else {
Page::SplitSetup
},
),
Some(open("horizon.pages.open.studio", Page::Studio)),
),
// No AI hides the AI Agent page and everything AI.
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
}
/// The entries of the pages registered for Horizon Settings, two to a row like the engine's.
fn added_entries(&self, rows: &mut Vec<Row>) {
let mut entries = registered()
.iter()
.enumerate()
.filter(|(_, page)| page.place() == added::Place::HorizonSettings)
.map(|(i, page)| button(0.0, 0.0, 150.0, tr(page.title()), Act::Push(Page::Added(i))));
while let Some(first) = entries.next() {
rows.push(Row::Buttons(first, entries.next()));
}
}
/// A registered page's rows, asked of it each time the page is drawn.
pub(super) fn added_rows(&self, page: usize) -> Vec<Row> {
let Some(registered) = registered().get(page) else {
return Vec::new();
};
let mut rows = Vec::new();
registered.rows(&mut rows);
rows.into_iter()
.enumerate()
.map(|(r, row)| self.added_row(page, r, row))
.collect()
}
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
let act = Act::PageRow(page, r);
let wide = |label: Text| Row::Buttons(button(0.0, 0.0, 310.0, label, act.clone()), None);
match row {
added::Row::Heading(key) => Row::Heading(key.to_owned()),
added::Row::Note(text) => Row::Note(text),
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
added::Row::Button { label } => wide(tr(label)),
added::Row::Key { label, key } => {
let mut message = keybinds::display_name(&key);
if self.selected_key == Some(PAGE_KEY)
&& self.selected_native_key == Some((page, r))
{
message = Text::literal("> ")
.append(message.with_color(text::WHITE))
.append(" <")
.with_color(text::YELLOW);
}
let caption = button(0.0, 0.0, 150.0, tr(label), Act::None);
let change = button(0.0, 0.0, 150.0, message, act);
Row::Buttons(inactive(caption), Some(change))
}
// A row kind newer than this engine: shown empty.
_ => Row::Note(String::new()),
}
}
/// A registered page's row was pressed: a key row waits for a key, any other row hears it.
pub(super) fn page_act(&mut self, page: usize, r: usize, shift: bool) -> Vec<Event> {
let Some(registered) = registered().get(page) else {
return Vec::new();
};
let mut rows = Vec::new();
registered.rows(&mut rows);
match rows.get(r) {
Some(added::Row::Key { .. }) => {
self.selected_key = Some(PAGE_KEY);
self.selected_native_key = Some((page, r));
Vec::new()
}
Some(_) => {
registered.act(
r,
if shift {
added::Act::PressBack
} else {
added::Act::Press
},
);
vec![Event::Changed]
}
None => Vec::new(),
}
}
/// A native mod's page: its name, its keys as Key Binds rows (the
/// player's choice, else the default), then its buttons.
pub(super) fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
@@ -262,21 +46,9 @@ impl OptionsUi {
self.selected_native_key = Some((i, k));
}
/// The key pressed for the native mod's key, or the registered page's ([`PAGE_KEY`]), that
/// waited for one.
pub(super) fn bind_native_key(
&mut self,
s: &mut Settings,
name: &str,
value: String,
) -> Vec<Event> {
/// The key pressed for the native mod's key that waited for one.
pub(super) fn bind_native_key(&mut self, s: &mut Settings, value: String) -> Vec<Event> {
let (i, k) = self.selected_native_key.take().unwrap_or_default();
if name == PAGE_KEY {
if let Some(page) = registered().get(i) {
page.act(k, added::Act::Bind(value));
}
return vec![Event::Changed];
}
let Some(m) = s.mods.installed.get(i) else {
return Vec::new();
};
@@ -288,171 +60,3 @@ impl OptionsUi {
vec![Event::Changed]
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// A page with a heading, a switch, a number that steps either way, a key and a volume.
struct Sample {
on: Mutex<bool>,
step: Mutex<i32>,
key: Mutex<String>,
volume: Mutex<f32>,
}
impl added::Page for Sample {
fn id(&self) -> &'static str {
"test.sample"
}
fn title(&self) -> &'static str {
"test.sample.title"
}
fn place(&self) -> added::Place {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::Heading("test.sample.heading"));
rows.push(added::Row::Toggle {
label: "test.sample.switch",
on: *self.on.lock().unwrap(),
});
rows.push(added::Row::Cycle {
label: "test.sample.step",
value: added::Label::Value(self.step.lock().unwrap().to_string()),
});
rows.push(added::Row::Key {
label: "test.sample.key",
key: self.key.lock().unwrap().clone(),
});
let volume = *self.volume.lock().unwrap();
rows.push(added::Row::Slider {
label: "test.sample.volume",
value: volume,
text: added::Label::Value(format!("{}%", (volume * 100.0).round())),
});
}
fn act(&self, row: usize, act: added::Act) {
match (row, act) {
(1, added::Act::Press) => {
let mut on = self.on.lock().unwrap();
*on = !*on;
}
(2, added::Act::Press) => *self.step.lock().unwrap() += 1,
(2, added::Act::PressBack) => *self.step.lock().unwrap() -= 1,
(3, added::Act::Bind(key)) => *self.key.lock().unwrap() = key,
(4, added::Act::Slide(v)) => *self.volume.lock().unwrap() = v,
other => panic!("row and act the page doesn't expect: {other:?}"),
}
}
fn tooltip(&self, row: usize) -> Option<added::Label> {
(row == 1).then_some(added::Label::Key("test.sample.switch.tooltip"))
}
}
static SAMPLE: Sample = Sample {
on: Mutex::new(false),
step: Mutex::new(0),
key: Mutex::new(String::new()),
volume: Mutex::new(0.0),
};
static PAGES: [&dyn added::Page; 1] = [&SAMPLE];
/// Clicks the active button that does `act`, with Shift held or not.
fn press(ui: &mut OptionsUi, s: &mut Settings, act: Act, shift: bool) -> Vec<Event> {
let size = (480.0, 240.0);
let (widgets, ..) = ui.layout(s, size.0, size.1);
let (x, y) = widgets
.iter()
.find_map(|w| match &w.kind {
WKind::Button {
act: a,
active: true,
..
} if *a == act => Some((w.x + 1.0, w.y + 1.0)),
_ => None,
})
.unwrap_or_else(|| panic!("no button does {act:?}"));
ui.shift = shift;
let atlas = super::super::tests::atlas();
ui.click(s, &atlas, x, y, size, [480, 240], false, false)
}
#[test]
fn a_registered_page_opens_from_horizon_settings_and_hears_its_rows() {
let mut s = Settings::default();
TEST_PAGES.set(Some(&PAGES));
let mut ui = OptionsUi::default();
ui.show(Page::Horizon);
press(&mut ui, &mut s, Act::Push(Page::Added(0)), false);
assert_eq!(ui.page(), Page::Added(0));
// Rows: the heading (0), the switch (1), the step (2), the key (3).
let events = press(&mut ui, &mut s, Act::PageRow(0, 1), false);
assert_eq!(
events.last(),
Some(&Event::Changed),
"the settings get saved"
);
assert!(*SAMPLE.on.lock().unwrap());
press(&mut ui, &mut s, Act::PageRow(0, 2), false);
press(&mut ui, &mut s, Act::PageRow(0, 2), true);
press(&mut ui, &mut s, Act::PageRow(0, 2), true);
assert_eq!(*SAMPLE.step.lock().unwrap(), -1, "Shift steps back");
// A key row waits for the next key, as on Key Binds, and the page gets its name.
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
let atlas = super::super::tests::atlas();
let events = ui.key(&mut s, &atlas, winit::keyboard::KeyCode::KeyZ, None);
assert_eq!(events, [Event::Changed]);
assert_eq!(*SAMPLE.key.lock().unwrap(), "key.keyboard.z");
assert_eq!(ui.selected_key, None);
// The volume is one of the screen's own sliders: dragged to its middle, then let go.
let size = (480.0, 240.0);
let (widgets, ..) = ui.layout(&s, size.0, size.1);
let slider = widgets
.iter()
.find(|w| {
matches!(
w.kind,
WKind::Slider {
opt: Opt::Page(0, 4),
..
}
)
})
.expect("the volume's slider");
let (x, y) = (slider.x + slider.w / 2.0, slider.y + 1.0);
ui.click(&mut s, &atlas, x, y, size, [480, 240], false, false);
let volume = *SAMPLE.volume.lock().unwrap();
assert!((0.45..0.55).contains(&volume), "{volume}");
assert!(ui.release().contains(&Event::Changed));
// The slider shows the page's value once let go.
let (widgets, ..) = ui.layout(&s, size.0, size.1);
let shown = widgets.iter().find_map(|w| match w.kind {
WKind::Slider {
opt: Opt::Page(0, 4),
value,
..
} => Some(value),
_ => None,
});
assert_eq!(shown, Some(volume));
// The page's tooltip for its switch.
let switch = widgets
.iter()
.find(|w| {
matches!(
&w.kind,
WKind::Button {
act: Act::PageRow(0, 1),
..
}
)
})
.expect("the switch");
assert_eq!(
tooltip_text(&s, switch).map(|t| t.plain()),
Some(tr("test.sample.switch.tooltip").plain())
);
}
}
+67 -1
View File
@@ -31,7 +31,6 @@ mod native;
pub mod polytone;
mod polytone_gpu;
mod polytone_model;
mod spawn;
pub use native::{ParticleController, ParticleUpdate};
/// `ParticleOptions` with the parameters of the option classes used here.
@@ -2027,6 +2026,73 @@ impl ParticleEngine {
(eye[0] - pos[0]).powi(2) + (eye[1] - pos[1]).powi(2) + (eye[2] - pos[2]).powi(2) <= 9.0
})
}
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
pub fn send_particles(
&mut self,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
) {
if count == 0 {
let v = [speed * spread[0], speed * spread[1], speed * spread[2]];
self.add(options, at, v);
return;
}
for _ in 0..count {
let r = &mut self.random;
let p = [
at[0] + r.next_gaussian() * spread[0],
at[1] + r.next_gaussian() * spread[1],
at[2] + r.next_gaussian() * spread[2],
];
let v = [
r.next_gaussian() * speed,
r.next_gaussian() * speed,
r.next_gaussian() * speed,
];
self.add(options, p, v);
}
}
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
if let Some(requests) = &mut self.created_requests {
requests.push((options.clone(), p, v));
return;
}
let seed = self.seeds.next_long();
if let Some(mut particle) = self.make(options, p, v, seed) {
*self.made.entry(options.ty.name()).or_default() += 1;
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
if let Some(registry) = &self.polytone
&& registry.modifies_particles()
{
let id = match &options.mod_particle {
Some(name) => name.clone(),
None => format!("minecraft:{}", options.ty.name()),
};
polytone::modify(registry, &id, options.source.as_deref(), &mut particle);
}
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
// shortcut: counts live plus queued particles by scanning instead of
// vanilla's counted map; use a counter if spore spawning shows in profiles.
if particle.ty == ParticleType::SporeBlossomAir
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
&& self
.particles
.iter()
.chain(self.pending.iter())
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
.count()
>= SPORE_BLOSSOM_LIMIT
{
return;
}
self.pending.push(particle);
}
}
/// How many particles of each type were made so far.
pub fn made(&self) -> &std::collections::BTreeMap<&'static str, u64> {
&self.made
@@ -1,73 +0,0 @@
//! Where particles come into the engine: a server's particle packet and a single particle made
//! where the game asks for one.
use super::*;
impl ParticleEngine {
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
pub fn send_particles(
&mut self,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
) {
if count == 0 {
let v = [speed * spread[0], speed * spread[1], speed * spread[2]];
self.add(options, at, v);
return;
}
for _ in 0..count {
let r = &mut self.random;
let p = [
at[0] + r.next_gaussian() * spread[0],
at[1] + r.next_gaussian() * spread[1],
at[2] + r.next_gaussian() * spread[2],
];
let v = [
r.next_gaussian() * speed,
r.next_gaussian() * speed,
r.next_gaussian() * speed,
];
self.add(options, p, v);
}
}
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
if let Some(requests) = &mut self.created_requests {
requests.push((options.clone(), p, v));
return;
}
let seed = self.seeds.next_long();
if let Some(mut particle) = self.make(options, p, v, seed) {
*self.made.entry(options.ty.name()).or_default() += 1;
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
if let Some(registry) = &self.polytone
&& registry.modifies_particles()
{
let id = match &options.mod_particle {
Some(name) => name.clone(),
None => format!("minecraft:{}", options.ty.name()),
};
polytone::modify(registry, &id, options.source.as_deref(), &mut particle);
}
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
// shortcut: counts live plus queued particles by scanning instead of
// vanilla's counted map; use a counter if spore spawning shows in profiles.
if particle.ty == ParticleType::SporeBlossomAir
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
&& self
.particles
.iter()
.chain(self.pending.iter())
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
.count()
>= SPORE_BLOSSOM_LIMIT
{
return;
}
self.pending.push(particle);
}
}
}
@@ -864,8 +864,6 @@ pub enum Opt {
PadGameplayGuide,
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
ModelPart(usize),
/// A slider of a page a crate registered (`horizon_hooks::pages`): (page, row).
Page(usize, usize),
/// 3D skin layers: the player's outer layer as extruded slabs.
SkinLayers3d,
MainHand,
@@ -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);
+7 -5
View File
@@ -119,13 +119,15 @@ 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 and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. 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 = ["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"]
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"]
# Play with Friends: sharing a world behind an invite code (game/friends.rs) and joining one.
friends = ["horizon-client-lib/friends", "dep:horizon-friends"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
+5 -2
View File
@@ -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_colors, polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit,
@@ -58,6 +58,8 @@ 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;
@@ -68,6 +70,7 @@ pub mod falling_block;
pub mod fire_render;
pub mod first_person;
pub mod flatgen;
pub mod friends_screen;
pub mod furnace;
pub mod game_mode_switcher;
pub mod game_rules_screen;
@@ -1,30 +1,21 @@
//! 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 says what the player does on each change ([`set`]), which wakes the thread,
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change 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`.
//!
//! 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)]
//! 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.
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_platform::hooks::{self, Activity, Dimension, GameMode, Hooks, Play};
use horizon_platform::pages::{Act, Label, Page, Place, Row};
use horizon_platform::settings::Store;
use horizon_command::args::GameMode;
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
/// Rich Presence assets: `horizon`, the logo.
@@ -49,134 +40,24 @@ const CLOSE: u32 = 2;
const PING: u32 = 3;
const PONG: u32 = 4;
/// 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 Dimension {
Overworld,
Nether,
End,
}
/// 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): the player's own settings' line, the game's first player's. A
/// splitscreen player's settings file loads after the first player's and keeps it.
static ON: AtomicBool = AtomicBool::new(true);
/// Whether the player's settings were loaded once: the later loads are splitscreen players'.
static LOADED: AtomicBool = AtomicBool::new(false);
/// 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) {
// The first player's settings load first; the first load is theirs.
if LOADED.swap(true, Ordering::Relaxed) {
return;
}
match value {
"true" => set_enabled(true),
"false" => set_enabled(false),
_ => {}
}
}
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
line(KEY, if enabled() { "true" } else { "false" });
}
fn is_default(&self) -> bool {
enabled()
}
}
/// 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>) {
rows.push(Row::Toggle {
label: LABEL,
on: enabled(),
});
}
fn act(&self, row: usize, act: Act) {
if row == 0 && 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)]
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,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -216,15 +97,11 @@ 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,
@@ -237,8 +114,7 @@ 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.
/// The status [`set`] gave before, the switch included, is the first one sent.
pub fn start(automated: bool) {
pub fn start(enabled: bool, 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")
@@ -246,6 +122,9 @@ pub fn start(automated: bool) {
{
return;
}
if !enabled {
lock().wanted.status = Status::Off;
}
match std::thread::Builder::new()
.name("Discord presence".into())
.spawn(run)
@@ -257,13 +136,12 @@ pub fn start(automated: bool) {
}
}
/// What the player does now; only a change wakes the thread. Kept for [`start`] before it.
/// What the player does now; only a change wakes the thread. Free without [`start`].
pub fn set(status: Status) {
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) {
let Some(thread) = THREAD.get() else {
return;
};
if lock().wanted.update(status, unix_now()) {
thread.unpark();
}
}
@@ -704,39 +582,6 @@ 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_and_keeps_the_first_players_line() {
let wanted = || lock().wanted.status;
Setting.load(KEY, "false");
// A splitscreen player's settings file loads later and keeps the first player's.
Setting.load(KEY, "true");
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()));
+1 -1
View File
@@ -84,7 +84,7 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
mod fluids;
mod item_fall;
mod item_no_physics;
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
pub use fluids::{fluid_flow, update_entity_fluids, water_clip};
#[cfg(test)]
mod drop_motion_tests {
@@ -45,30 +45,6 @@ pub fn fluid_flow(world: &World, fluids: &FluidResources, pos: [i32; 3]) -> [f64
let flow = flow_at(&mut scene, state, at).unwrap_or(Vec3::ZERO);
[flow.x, flow.y, flow.z]
}
/// `Entity.move`'s fall-reset ray along a move from `from`, up to 8 blocks:
/// `ClipContext.Fluid.WATER` (the water's shape) and
/// `ClipContext.Block.FALLDAMAGE_RESETTING` (a block `tagged` says is a reset
/// block clips as a full block). Both shapes are queried for every visited
/// block, the water's first (its first query fixes the shared cached shape);
/// either one hitting ends the ray.
pub fn fall_reset_hit(
world: &World,
fluids: &FluidResources,
tagged: impl Fn(BlockPos) -> bool,
from: Vec3,
moved: Vec3,
) -> bool {
let to = from.add_vector(moved.normalize().scale(moved.length().min(8.0)));
let hit = horizon_core::geometry::traverse_blocks(from, to, 1_000_000, |p| {
let water = water_clip(world, fluids, from, to, p);
let reset = tagged(p)
&& horizon_core::geometry::VoxelShape::block()
.clip(from, to, p)
.is_some();
(water || reset).then_some(())
});
matches!(hit, Ok(Some(())))
}
/// `ClipContext.Fluid.WATER` in a fall-reset ray through `p`: the water's
/// shape from the shared `FlowingFluid.shapes` cache (the first query of a
/// fluid state fixes its height), as the player's own ray reads it.
+2 -2
View File
@@ -233,7 +233,7 @@ fn is_animal(id: &str) -> bool {
use crate::mobs::persistent_anger;
/// The single player's UUID (`offline_uuid("Player")`): the entity the
/// neutral mobs are angry at.
pub(crate) fn player_uuid() -> [i32; 4] {
fn player_uuid() -> [i32; 4] {
let u = horizon_protocol::crypto::offline_uuid("Player");
[
(u >> 96) as u32 as i32,
@@ -1297,7 +1297,7 @@ pub fn mob_to_nbt(
crate::mobs::nautilus::to_nbt(m, map);
}
"minecraft:sulfur_cube" => crate::mobs::sulfur_cube::to_nbt(m, map),
"minecraft:bee" => crate::mobs::bee::to_nbt(m, map, &mut uuid_of),
"minecraft:bee" => crate::mobs::bee::to_nbt(m, map),
"minecraft:sniffer" => crate::mobs::sniffer::to_nbt(m, map),
"minecraft:wither" => {
map.insert("Invul".into(), Tag::Int(m.extra.counter));
+1
View File
@@ -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.
+1 -119
View File
@@ -1,11 +1,9 @@
//! HUD boxes in the corners: the minimap's place and mods' panels, small
//! boxes in GUI pixels stacked per corner (`docs/extras.md`), and the layers crates above the
//! engine register (`horizon_hooks::hud`).
//! boxes in GUI pixels stacked per corner (`docs/extras.md`).
use crate::{
extras::Corner,
hud::{HudAtlas, HudBatch, Rect, rgba},
};
use horizon_hooks::hud;
/// The gap between an overlay and the screen edge or the next overlay.
const MARGIN: f32 = 2.0;
@@ -127,68 +125,6 @@ pub fn draw_lines(
}
}
/// Draws the HUD layers crates registered, after the extras' and mods' boxes, which they stack
/// with. `shown`: whether each corner of `Corner::ALL` shows (the debug screen covers some).
pub fn draw_layers(
layers: &[&dyn hud::Layer],
batch: &mut HudBatch,
atlas: &HudAtlas,
corners: &mut Corners,
shown: [bool; 4],
) {
let mut canvas = Canvas {
batch,
atlas,
corners,
shown,
};
for layer in layers {
layer.draw(&mut canvas);
}
}
struct Canvas<'a> {
batch: &'a mut HudBatch,
atlas: &'a HudAtlas,
corners: &'a mut Corners,
shown: [bool; 4],
}
/// `0xAARRGGBB` as the batch's colour.
fn argb_color(argb: u32) -> u32 {
let [a, r, g, b] = argb.to_be_bytes();
rgba(r, g, b, a)
}
impl hud::Canvas for Canvas<'_> {
fn size(&self) -> (f32, f32) {
(self.batch.width, self.batch.height)
}
fn tr(&self, key: &str) -> String {
crate::lang::get(key)
}
fn text_width(&self, text: &str) -> f32 {
HudBatch::text_width(self.atlas, text)
}
fn text(&mut self, x: f32, y: f32, text: &str, argb: u32, shadow: bool) {
self.batch
.text(self.atlas, text, x, y, argb_color(argb), shadow);
}
fn fill(&mut self, x: f32, y: f32, w: f32, h: f32, argb: u32) {
self.batch.fill(self.atlas, x, y, w, h, argb_color(argb));
}
fn place(&mut self, corner: hud::Corner, w: f32, h: f32) -> Option<(f32, f32)> {
let corner = match corner {
hud::Corner::TopLeft => Corner::TopLeft,
hud::Corner::TopRight => Corner::TopRight,
hud::Corner::BottomLeft => Corner::BottomLeft,
hud::Corner::BottomRight => Corner::BottomRight,
};
let i = Corner::ALL.iter().position(|c| *c == corner)?;
self.shown[i].then(|| self.corners.place(corner, w, h))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -204,58 +140,4 @@ mod tests {
assert_eq!(c.place(Corner::BottomRight, 50.0, 10.0), (348.0, 274.0));
assert_eq!(c.place(Corner::BottomLeft, 10.0, 10.0), (2.0, 288.0));
}
/// Draws a box of one line in a corner and keeps where it went.
struct Panel {
corner: hud::Corner,
at: std::sync::Mutex<Vec<Option<(f32, f32)>>>,
}
impl hud::Layer for Panel {
fn draw(&self, canvas: &mut dyn hud::Canvas) {
let w = canvas.text_width("12 CPS") + 4.0;
let at = canvas.place(self.corner, w, 12.0);
if let Some((x, y)) = at {
canvas.fill(x, y, w, 12.0, 0x7800_0000);
canvas.text(x + 2.0, y + 2.0, "12 CPS", 0xFFFF_FFFF, true);
}
self.at.lock().unwrap().push(at);
}
}
#[test]
fn layers_stack_with_the_extras_and_skip_hidden_corners() {
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
let mut batch = HudBatch::new([800, 600]);
let (width, height) = (batch.width, batch.height);
let mut corners = Corners::new(width, height, 0.0);
// The minimap already took the top right.
corners.place(Corner::TopRight, 64.0, 64.0);
let right = Panel {
corner: hud::Corner::TopRight,
at: Default::default(),
};
let left = Panel {
corner: hud::Corner::TopLeft,
at: Default::default(),
};
let layers: [&dyn hud::Layer; 2] = [&right, &left];
let empty = batch.vertices.len();
draw_layers(
&layers,
&mut batch,
&atlas,
&mut corners,
[false, true, true, true],
);
let (_, y) = right.at.lock().unwrap()[0].expect("the top right shows");
assert_eq!(y, 2.0 + 64.0 + 2.0, "under the minimap");
assert_eq!(
*left.at.lock().unwrap(),
[None],
"the debug screen covers the top left"
);
assert!(batch.vertices.len() > empty, "the shown box is drawn");
}
}
@@ -1,9 +1,9 @@
//! 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
//! invite code (`game/friends.rs`). 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::{
use crate::{
hud::{HudAtlas, HudBatch, rgba},
multiplayer::wrap,
text::Text,
@@ -14,7 +14,7 @@ use horizon_client::{
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)
crate::lang::get_or(key, english)
}
/// The title in the current language.
pub fn title() -> String {
@@ -245,8 +245,8 @@ pub fn draw(
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,
crate::widgets::declare_action(
crate::widgets::Action::Done,
[b.x, b.y, b.w, 20.0],
b.active,
);
+79 -7
View File
@@ -49,7 +49,6 @@ fn pack_files(world_tables: &Option<WorldTables>) -> horizon_world::loot::PackOv
}
}
mod activity;
mod advancements;
mod advancements_screen;
mod agent;
@@ -129,6 +128,8 @@ mod decorated_pot;
mod dedicated;
mod deflect;
mod dialogs;
#[cfg(feature = "discord")]
mod discord;
mod dispense_fixture;
mod dispenser;
mod dragon;
@@ -649,7 +650,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).
/// (`friends_screen`, game/friends.rs).
Friends,
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
Social,
@@ -5489,6 +5490,34 @@ impl Game<'_> {
);
cancel
}
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
/// moves only while the world runs.
pub(super) fn visual_ticks(&self) -> u64 {
if self.clock.still() {
self.world.game_time.max(0) as u64
} else {
self.ticks
}
}
/// The clock of the animated textures: `Minecraft.runTick` ticks the `TextureManager`
/// only while the level runs normally. Under `HORIZON_STILL` it is the world's ticks.
pub(super) fn texture_clock(&self) -> u64 {
if self.clock.still() {
self.visual_ticks()
} else {
self.texture_ticks
}
}
/// `Util.getMillis` for what the frame shows (the world border's scroll, the hearts' blink,
/// the glint's under the switch): under `HORIZON_STILL`, the world's ticks in milliseconds.
pub(super) fn visual_millis(&self) -> i64 {
if self.clock.still() {
self.visual_ticks() as i64 * 50
} else {
self.started.elapsed().as_millis() as i64
}
}
/// `PresenceHandler.getPresenceStatus`: what an all-sharing player is doing, for
/// the friends service's `updatePresence`.
fn presence_status(&self) -> horizon_client::social::PresenceStatus {
@@ -6474,6 +6503,18 @@ impl Game<'_> {
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
/// changes hands: a screen that opens or closes starts with its own
/// state, and a press whose release never reached the screen -- the
/// pointer left the window, or the screen closed first -- would
/// otherwise leave every later press on every later screen swallowed.
pub(super) fn reset_inventory_clicks(&mut self) {
self.inv_clicks.cancel_drag();
self.inv_drag = None;
self.last_inv_click = None;
}
pub(super) fn close_container(&mut self) {
// `Gui.setScreen` clears the old screen's focus before it goes: the
// key that closed the container (Tab bound to the inventory) has
@@ -6517,6 +6558,20 @@ impl Game<'_> {
}
self.close_ender_chest();
}
/// A sound a menu click made the player play (a bundle's insert or
/// remove): `Entity.playSound` at the player.
pub(super) fn player_menu_sound(&mut self, sound: &str) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
(1.0, 1.0)
} else {
(0.8, 0.8 + self.world.random.next_float() * 0.4)
};
self.audio.play(sound, Some(at), volume, pitch);
}
/// `Camera.setRotation`: the camera's orientation, turned round in
/// third-person front view.
pub(super) fn camera_orientation(&self) -> glam::Quat {
@@ -11403,7 +11458,8 @@ 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());
self.report_activity();
#[cfg(feature = "discord")]
self.update_discord_presence();
let steps = self.clock.advance(elapsed);
for _ in 0..steps {
let started = Instant::now();
@@ -12663,8 +12719,17 @@ pub fn run_with() -> Result<()> {
// horizon-ctl drives: those start in a source checkout and wrote the list there.
if !options.offscreen && !synchronous_startup(&options) {
let version = horizon_client::telemetry::version();
let game_dir = horizon_client::logging::player_dir();
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version, &game_dir));
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
let player_dir = horizon_client::logging::player_dir();
// horizon-extras: the official server is Horizon's (docs/forking.md).
if cfg!(feature = "horizon-extras")
&& let Err(e) = horizon_client::multiplayer::add_official_server(&player_dir)
{
log::warn!("could not add the official server to servers.dat: {e}");
}
if let Err(e) = horizon_client::multiplayer::withdraw_partner_servers(&player_dir) {
log::warn!("could not take the withdrawn partner server out of servers.dat: {e}");
}
}
mods::register_components(&vanilla_root)?;
mods::register_standalone_models();
@@ -12692,8 +12757,15 @@ pub fn run_with() -> Result<()> {
if options.execution_role.is_client() {
narrator::start(&early_settings);
}
// A game window's hooks (Discord's presence): never for a server.
activity::client_started(&options);
// "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);
}
// 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"));
@@ -1,68 +0,0 @@
//! 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,
},
}
}
}
+16 -8
View File
@@ -686,15 +686,23 @@ impl Game<'_> {
if fall == 0.0 || length < 1.0 {
return fall;
}
let to = from.add_vector(moved.normalize().scale(moved.length().min(8.0)));
let reset = &self.res.motion().fall_reset;
let hit = horizon_client::entities::fall_reset_hit(
&self.world,
&self.res.motion().fluids,
|p| reset.contains(&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name),
from,
moved,
);
if hit { 0.0 } else { fall }
let fluids = &self.res.motion().fluids;
let hit = horizon_core::geometry::traverse_blocks(from, to, 1_000_000, |p| {
// A tagged block clips as a full block, not its collider.
let name = &self.res.facts().get(self.world.block(p.x, p.y, p.z)).name;
if reset.contains(&**name) && VoxelShape::block().clip(from, to, p).is_some() {
return Some(());
}
// The water's shape, through the shared fluid shape cache.
horizon_client::entities::water_clip(&self.world, fluids, from, to, p).then_some(())
});
if matches!(hit, Ok(Some(()))) {
0.0
} else {
fall
}
}
/// `AbstractBoat.checkFallDamage` after a move that went `moved_y` (with
/// `b` already where it landed): on the ground the fall resets; a move
+3 -63
View File
@@ -70,23 +70,6 @@ pub(super) enum BookScreen {
lectern: Option<(i32, i32, i32)>,
},
}
impl BookScreen {
/// `BookSignScreen`'s constructor: its title box opens with the title
/// kept from the last time, which Cancel stored (`titleValue`).
fn begin_signing(&mut self) {
if let Self::Edit {
signing,
title,
title_edit,
..
} = self
{
*signing = true;
title_edit.set_value(title.as_str());
title_edit.reset_blink();
}
}
}
/// The pages a book screen reads: a written book's components (so their
/// click events stay), else a writable book's text.
fn view_pages(data: Option<&StackData>) -> Vec<Text> {
@@ -1005,7 +988,7 @@ impl Game<'_> {
title,
..
}) if !title.trim().is_empty() => self.save_book(true),
Some(book @ BookScreen::Edit { .. }) => book.begin_signing(),
Some(BookScreen::Edit { signing, .. }) => *signing = true,
_ => return Err("not a book and quill".into()),
},
Some("done") => match self.book.as_mut() {
@@ -1207,8 +1190,8 @@ impl Game<'_> {
self.turn_page(false);
} else if inside(w / 2.0 - 100.0, 196.0, 98.0, 20.0) {
click(self);
if let Some(book) = self.book.as_mut() {
book.begin_signing();
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
*signing = true;
}
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
click(self);
@@ -1826,46 +1809,3 @@ mod lectern_reach_tests {
);
}
}
#[cfg(test)]
mod sign_tests {
use super::{BookScreen, book_page_box, book_title_box};
/// `BookSignScreen`'s constructor sets its title box to `titleValue`, what
/// Cancel kept; reopening the sign screen shows the title typed before,
/// not an empty box beside a stale `title`.
#[test]
fn the_sign_screen_opens_with_the_title_it_kept() {
let mut book = BookScreen::Edit {
slot: 0,
pages: vec!["page".into()],
page: 0,
signing: false,
title: "My Book".into(),
title_edit: book_title_box(),
page_edit: book_page_box(),
};
assert_eq!(book_title_value(&book), "");
book.begin_signing();
let BookScreen::Edit {
signing,
title,
title_edit,
..
} = &book
else {
panic!("the book stays open");
};
assert!(signing);
assert_eq!(title.as_str(), "My Book");
assert_eq!(title_edit.value, "My Book");
assert_eq!(title_edit.cursor, 7);
}
fn book_title_value(book: &BookScreen) -> String {
match book {
BookScreen::Edit { title_edit, .. } => title_edit.value.clone(),
BookScreen::View { .. } => String::new(),
}
}
}
+14 -16
View File
@@ -42,8 +42,8 @@ impl Reply {
}
pub(super) fn duration(millis: i64) -> Text {
use horizon_server_files::duration::{DurationUnit, duration_parts};
let (count, unit) = duration_parts(millis);
use horizon_chat_filter::DurationUnit;
let (count, unit) = horizon_chat_filter::duration_parts(millis);
let key = match (unit, count == 1) {
(DurationUnit::Minute, true) => "horizon.moderation.duration.minute",
(DurationUnit::Minute, false) => "horizon.moderation.duration.minutes",
@@ -93,7 +93,7 @@ pub(super) const MUTE_USAGES: [&str; 4] = [
fn mute_time(words: &[&str]) -> (Option<i64>, usize) {
match words
.get(2)
.and_then(|w| horizon_server_files::duration::parse_duration(w))
.and_then(|w| horizon_chat_filter::parse_duration(w))
{
Some(ms) => (Some(ms), 3),
None => (None, 2),
@@ -133,7 +133,7 @@ impl Game<'_> {
count: u32,
action: Action,
) {
let now = horizon_server_files::duration::now_millis();
let now = horizon_chat_filter::now_millis();
let summary = format!(
"[chat-filter] Refused {name}'s {} (hit {count}: {})",
place.name(),
@@ -281,7 +281,7 @@ impl Game<'_> {
let reason = |from: usize| (words.len() > from).then(|| words[from..].join(" "));
match words {
["mutelist"] => {
let now = horizon_server_files::duration::now_millis();
let now = horizon_chat_filter::now_millis();
let mutes = host.hub.mutes();
if mutes.is_empty() {
return vec![Reply::translated("horizon.moderation.mutelist.empty", &[])];
@@ -319,15 +319,13 @@ impl Game<'_> {
let (time, from) = mute_time(words);
vec![self.mute(host, name, time, reason(from), by)]
}
["tempmute", name, time, ..] => {
match horizon_server_files::duration::parse_duration(time) {
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
None => vec![Reply::translated(
"horizon.moderation.invalid_duration",
&[Text::literal(*time)],
)],
}
}
["tempmute", name, time, ..] => match horizon_chat_filter::parse_duration(time) {
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
None => vec![Reply::translated(
"horizon.moderation.invalid_duration",
&[Text::literal(*time)],
)],
},
["mute", ..] => vec![Reply::translated("horizon.moderation.usage.mute", &[])],
["tempmute", ..] => vec![Reply::translated("horizon.moderation.usage.tempmute", &[])],
["unmute", ..] => vec![Reply::translated("horizon.moderation.usage.unmute", &[])],
@@ -355,10 +353,10 @@ impl Game<'_> {
let Some((uuid, name)) = user else {
return Reply::no_player();
};
let until = time.map(|ms| horizon_server_files::duration::now_millis() + ms);
let until = time.map(|ms| horizon_chat_filter::now_millis() + ms);
let what = time.map_or_else(
|| "until unmuted".into(),
|ms| format!("for {}", horizon_server_files::duration::duration_text(ms)),
|ms| format!("for {}", horizon_chat_filter::duration_text(ms)),
);
let why = reason
.as_deref()
+5 -5
View File
@@ -1567,11 +1567,11 @@ impl Game<'_> {
"streaming": self.streaming.stages().into_iter().map(|(k, v)| (k.to_string(), v.into())).collect::<serde_json::Map<_, _>>(),
"dirty_light": self.world.dirty_light.len(),
"renderer": renderer,
"display": self.window.as_deref().and_then(horizon_client::window::display).map(|d| json!({
"width": d.width,
"height": d.height,
"scale": d.scale,
"refresh_hz": d.refresh_hz,
"display": self.window.as_deref().and_then(horizon_client::telemetry::monitor).map(|m| json!({
"width": m.size().width,
"height": m.size().height,
"scale": m.scale_factor(),
"refresh_hz": m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
})),
"uptime_seconds": self.started.elapsed().as_secs_f64(),
"server": self.remote_status(),
@@ -623,6 +623,3 @@ fn stack_drop_requires_ctrl_even_with_sprint_latched() {
}
}
}
#[path = "play_ping_tests.rs"]
mod play_ping_tests;
+783 -2
View File
@@ -2,10 +2,8 @@
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
mod mob_geometry;
mod projectile_world;
mod pufferfish_player;
mod spawn_eggs;
use super::{
Dim, Game, Hand, InteractTarget, entity_culling::EntityCullDecision,
@@ -7452,6 +7450,171 @@ impl Game<'_> {
}
Ok(true)
}
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
/// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby
/// at the mob's feet, taking the parent's variant as `getBreedOffspring`
/// does, and one egg used. False (PASS) when the mob has no baby form.
fn spawn_egg_offspring(&mut self, index: usize) -> bool {
let Some(stack) = self.held().cloned() else {
return false;
};
let parent = &self.mobs[index];
if !parent.alive()
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|| !horizon_client::mobs::species::can_be_a_baby(parent.species())
// WanderingTrader.getBreedOffspring returns null: no offspring, the
// click carries on to trading.
|| parent.species() == Species::WanderingTrader
{
return false;
}
let (kind, at) = (
parent.kind.id.clone(),
[parent.position.x, parent.position.y, parent.position.z],
);
let (species, variant, color) =
(parent.species(), parent.extra.variant, parent.extra.color);
let target = self.mob_entity(&self.mobs[index]);
// SpawnEggItem.spawnOffspringFromSpawnEgg: `type.create(level, SPAWN_ITEM_USE)`
// creates the baby without finalizeSpawn, and `snapTo(pos, 0, 0)` faces
// it south without a level-random draw.
let Some(id) =
self.spawn_mob_internal(&kind, at, false, false, false, false, SpawnYaw::Fixed(0.0))
else {
return false;
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
// `Mob.checkAndHandleImportantInteractions` then calls
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
// who used the egg (a fox egg used on a block trusts nobody).
if species == Species::Fox {
let spawner = self.acting_player_uuid();
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
}
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
true
}
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
pub(super) fn spawn_egg_fluid_hit(&self) -> Option<super::Hit> {
let hit = self.bucket_hit()?;
matches!(
&*self.block_name(hit.pos),
"minecraft:water" | "minecraft:lava"
)
.then_some(hit)
}
/// Shared peaceful-mode eligibility for spawn eggs.
pub(super) fn spawn_egg_allowed(&self, item: &str) -> bool {
let kind = item.trim_end_matches("_spawn_egg");
self.ui.difficulty != 0
|| self
.res
.mob_types()
.get(kind)
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
}
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
let fluid = matches!(self.guest_use, Some(super::GuestUse::Item(_)));
let Some(hit) = (if fluid {
self.spawn_egg_fluid_hit()
} else {
self.pick()
}) else {
return Ok(false);
};
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
if !self.spawn_egg_allowed(&stack.item) {
self.use_failed = true;
return Ok(true);
}
if fluid
&& !self.may_build()
&& !self.can_place_on_in_adventure([
hit.pos[0] - hit.face[0],
hit.pos[1] - hit.face[1],
hit.pos[2] - hit.face[2],
])
{
self.use_failed = true;
return Ok(true);
}
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
let (spawner_id, data) = horizon_client::block_entity_item::spawn_egg_on_spawner(
stack,
self.acting_player_is_op(),
);
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &spawner_id, data.as_ref()) {
if done {
self.swing_arm();
self.award_stat("used", &stack.item, 1);
self.consume_held();
}
return Ok(true);
}
// useOn: a clicked block without a collision shape (vines, grass)
// takes the mob itself, any other the one on the clicked face.
let p = if fluid || self.collision_empty(hit.pos) {
hit.pos
} else {
[
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
]
};
let at = [
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
];
// SpawnEggItem.spawnMob: `EntityType.spawn`.
let Some(id) = self.spawn_mob_spawned(&kind, at, SpawnYaw::Created) else {
return Ok(false);
};
// SpawnEggItem.spawnMob: `EntityType.create` finalizes the mob, then
// `PostSpawnProcessor` puts the egg's components on it.
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::entity_nbt::apply_item_components(m, stack);
}
self.apply_spawn_egg_entity_data(id, stack);
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
if !self.creative
&& let Some(s) = self.held_mut().as_mut()
{
s.count -= 1;
if s.count == 0 {
*self.held_mut() = None;
}
}
Ok(true)
}
/// `EntityType.updateCustomEntityTag`: the egg's `minecraft:entity_data` on
/// the mob it spawned, once the guards let it through, as
/// `TypedEntityData.loadInto`: the mob's own compound, the data merged over
/// it, loaded back into the same mob (`/data merge entity`'s loader).
pub(super) fn apply_spawn_egg_entity_data(&mut self, id: u32, stack: &ItemStack) {
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
return;
};
let kind = self.mobs[i].kind.id.clone();
let op = self.acting_player_is_op();
let Some(data) = horizon_client::block_entity_item::spawn_egg_entity_data(stack, &kind, op)
else {
return;
};
let own = self.mob_tree_nbt(i);
let tag = horizon_client::data_cmd::merged(&own, data);
// Nothing to report: the mob's own compound always loads back.
let _ = self.apply_entity_nbt(super::commands::Target::Mob(id), tag);
}
/// A beam target's centre (half its height), interpolated.
fn beam_target(&self, who: Option<Who>, alpha: f64) -> Option<glam::DVec3> {
match who? {
@@ -7622,6 +7785,624 @@ impl Game<'_> {
}
}
}
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
// The coarse mob bounds pay off only in dense async scenes; otherwise,
// keep the existing exact mesh-culling path.
if self.settings.horizon.entity_culling == EntityCullingMode::Asynchronous
&& self
.async_occlusion_culler
.as_ref()
.is_some_and(|culler| culler.camera_is_stationary())
&& self.settings.horizon.entity_culling_precheck != 0
&& self.mobs.len() >= self.settings.horizon.entity_culling_precheck as usize
&& !self.pack_shadow_casters.0
&& horizon_client::mod_api::mods().is_empty()
{
self.mob_geometry_inner::<true>(camera, alpha, out);
} else {
self.mob_geometry_inner::<false>(camera, alpha, out);
}
}
fn mob_geometry_inner<const PRECHECK: bool>(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
let skinning = self.skinning();
// What custom entity model animations read of the frame.
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
time: self.world.game_time as f64 + alpha,
day_time: self.world.day_time as f64 + alpha,
dimension: match self.dim {
Dim::Overworld => 0.0,
Dim::Nether => -1.0,
Dim::End => 1.0,
},
player: self.player.as_ref().map_or(camera.to_array(), |p| {
[p.position.x, p.position.y, p.position.z]
}),
player_rot: [self.pitch, self.yaw],
paused: self.pause,
first_person: self.camera_type.first_person(),
third_person_reversed: self.camera_type
== super::perspective::CameraType::ThirdPersonFront,
..Default::default()
});
for mob in &self.mobs {
// LevelRenderer: the entity the view looks out of is not drawn
// in first person.
if self.hides_as_camera(mob.id) {
continue;
}
let is_player = &*mob.kind.id == "minecraft:player";
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the view scale. RemotePlayer's box counts
// ten times.
let (w, h, _) = mob.dims();
let edge = (w + h + w) / 3.0 * if is_player { 10.0 } else { 1.0 };
let range = edge * 64.0 * scale;
if d.length_squared() >= range * range {
continue;
}
let beam = horizon_client::mobs::guardian::beam(mob, alpha as f32);
// EntityRenderer.shouldRender: the renderer's interpolated culling box,
// grown by 0.5 (GuardianRenderer also draws while its beam is out).
if let Some(view) = &self.view_frustum
&& beam.is_none()
{
let (lo, hi) = super::entity_culling::cull_box(mob, alpha);
let (lo, hi) = (
(lo - camera - glam::DVec3::splat(0.5)).as_vec3(),
(hi - camera + glam::DVec3::splat(0.5)).as_vec3(),
);
if !view.intersects(lo, hi) {
continue;
}
}
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
// A player keeps its armour, elytra and held items
// (`LivingEntityRenderer.submit` skips only the body).
let layers_only = mob.invisible && outline.is_none() && is_player;
if mob.invisible && outline.is_none() && !layers_only {
continue;
}
let culling = if PRECHECK
&& outline.is_none()
&& !is_player
&& mob.kind.id.starts_with("minecraft:")
&& [
&mob.extra.head,
&mob.extra.chest,
&mob.extra.legs,
&mob.extra.feet,
&mob.extra.main_hand,
&mob.extra.off_hand,
]
.iter()
.all(|item| item.is_none())
&& beam.is_none()
{
self.entity_cull_decision(mob, alpha)
} else {
EntityCullDecision::CheckVertices
};
if culling.hides_entity() {
continue;
}
let entity_start = out.len();
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
self.mark_entity(entity_start, &mob.kind.id);
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
// `EntityRenderer.getPackedLightCoords`: the block the mob's light
// probe (its eye) is in, at the position this frame draws rather
// than this tick's.
let probe = mob.light_probe(alpha);
let light = self.world.light(probe[0], probe[1], probe[2]);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
// renderers' overrides; the sky light stays the level's.
if mob.fire_ticks > 0
|| matches!(
mob.species(),
Species::Allay
| Species::Blaze
| Species::MagmaCube
| Species::Vex
| Species::Wither
)
{
packed = (packed & 0xFF) | ((15 * 16) << 8);
}
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
// dark ticks run out (the block light is the second byte).
if mob.species() == Species::GlowSquid {
let lit = (packed >> 12) & 15;
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
packed = (packed & 0xFF) | ((block * 16) << 8);
}
// A native mod's own model in place of the vanilla one
// (`native_mob_submit`), before any of the vanilla model's parts
// (a sulfur cube's swallowed block) are drawn.
// A player's own hook (`native_player_submit`) is asked for the
// other players in view.
let who = is_player
.then(|| self.remote_player_name(mob.id))
.flatten()
.map(|name| NativePlayerRender { name, local: false });
let modded = if layers_only {
false
} else if let Some(who) = who {
self.mod_player_geometry(mob, who, alpha as f32, camera, packed, out)
} else {
!is_player && self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
};
if modded {
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
if outline.is_none() {
self.cull_entity_vertices(out, entity_start);
}
continue;
}
if mob.species() == Species::SulfurCube
&& !horizon_client::mobs::trader::is_invisible(mob)
{
self.sulfur_cube_body_geometry(
mob,
alpha as f32,
horizon_client::mobs::render::base_transform(mob, alpha as f32, camera),
packed,
out,
);
}
// Other players on a server: PlayerRenderer's HumanoidArmorLayer.
let armor = if is_player {
self.worn_armor_layers(mob)
} else {
Vec::new()
};
let body = out.len();
// The GPU path: an ordinary mob, no layers the CPU path changes after drawing them
// (a player's, the HDR tiers below, the sulfur cube's shell). A glowing mob's
// silhouette is posed too (`SkinFrame::outline_since`), unless it is invisible:
// then only the outline draws.
let silhouette_on_gpu = outline.is_none() || !mob.invisible;
let posable = skinning
&& !is_player
&& !layers_only
&& silhouette_on_gpu
&& armor.is_empty()
&& !matches!(
mob.species(),
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
);
let mut skinned = None;
if posable {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mark = frame.mark();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
horizon_client::mobs::render::render_skinned(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
out,
&mut skin,
);
skinned = Some((mark, skin.bounds));
} else {
let render = if layers_only {
horizon_client::mobs::render::render_layers
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
}
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
// fs_hdr_mark); a tier's window lifts only their bright texels.
if let Some(tier) = match mob.species() {
Species::Blaze => Some(horizon_client::mesher::BRIGHT),
Species::MagmaCube | Species::GlowSquid => Some(horizon_client::mesher::LOW),
_ => None,
} {
for v in &mut out[body..] {
v.layer |= u32::from(tier) << 16;
}
}
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
let layers = horizon_client::mobs::render::layers_shown(mob);
if is_player && layers {
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
// and SpinAttackEffectLayer. Only the cape hides with the body;
// the others have no `isInvisible` check.
let chest = mob.extra.chest.as_deref();
if !layers_only {
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
}
if let Some((_, name)) = self
.remote_player_names()
.into_iter()
.find(|(id, _)| *id == mob.id)
{
self.player_ears_geometry(mob, &name, camera, alpha as f32, packed, out);
}
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(mob, camera, alpha as f32, packed, out);
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
// with it.
if !layers_only
&& super::cosmetics::enabled()
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
{
let mark = self.skin_frame.borrow().mark();
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
let gpu = skinning;
if let Some((lo, hi)) = self.cosmetics_geometry(
mob,
false,
base,
Some(camera),
alpha as f32,
packed,
gpu,
out,
) {
skinned = Some(match skinned {
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
Some((first, None)) => (first, Some((lo, hi))),
None => (mark, Some((lo, hi))),
});
}
}
// CustomHeadLayer (a pumpkin, skull or head item) included.
if layers {
// Held items join the GPU path's draws, their silhouette too.
let skin = (skinning && silhouette_on_gpu).then_some(&mut skinned);
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
}
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if let Some(((first, _, _), _)) = skinned {
let color = horizon_client::mobs::render::abgr(rgb);
self.skin_frame.borrow_mut().outline_since(first, color);
}
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
// Glowing outlines and a guardian's beam remain visible independently
// of whether the body is hidden behind terrain.
if outline.is_none() && culling.checks_vertices() && beam.is_none() {
// What the GPU draws of the mob counts by its bounds' corners.
self.cull_skinned(out, entity_start, skinned);
}
// GuardianRenderer: the laser to the locked target.
if let Some((time, scale)) = beam
&& let Some(target) = self.beam_target(mob.ai.target, alpha)
&& let Some(uv) = lookup("entity/guardian/guardian_beam")
{
let a = alpha;
let eye = glam::DVec3::new(
mob.previous.x + (mob.position.x - mob.previous.x) * a,
mob.previous.y + (mob.position.y - mob.previous.y) * a + mob.eye_height(),
mob.previous.z + (mob.position.z - mob.previous.z) * a,
) - camera;
horizon_client::mobs::guardian::beam_geometry(
eye.as_vec3(),
(target - camera).as_vec3(),
time,
scale,
uv,
out,
);
}
}
// What follows isn't a tracked mob: close the last one's span.
self.mark_motion(out.len(), 0);
self.elder_apparition_geometry(alpha, out);
self.spawner_geometry(camera, alpha, out);
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
let (right, up) = thrown_item_axes(self.camera_type, self.yaw, self.pitch);
let live = self.creatures.projectiles.iter().map(|a| {
let at = [
a.previous.x + (a.position.x - a.previous.x) * alpha,
a.previous.y + (a.position.y - a.previous.y) * alpha,
a.previous.z + (a.position.z - a.previous.z) * alpha,
];
(a, at)
});
// ItemPickupParticle: a picked-up arrow flying to its collector.
let taken = self.pickups.live.iter().filter_map(|p| match &p.taken {
super::pickups::Taken::Arrow(a) => Some((&**a, super::pickups::position(p, alpha))),
_ => None,
});
// A glowing projectile's outline: what it drew up to the next one.
let mut glowing = None;
for (a, at) in live.chain(taken) {
if let Some((start, rgb)) = glowing.take() {
self.mark_outline(&out[start..], rgb);
}
if a.id != 0
&& let Some(rgb) = self.thing_outline(a.id, a.kind.id())
{
glowing = Some((out.len(), rgb));
}
let p = glam::DVec3::from_array(at) - camera;
let c = p.as_vec3();
// EntityRenderer.getPackedLightCoords: the block its light probe
// (the eye) is in, at the position this frame draws rather than
// this tick's, and not the one under its feet.
let light = self.world.light(
at[0].floor() as i32,
a.light_probe_y(alpha).floor() as i32,
at[2].floor() as i32,
);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a shulker bullet and anything
// on fire are drawn at 15 block light (the fireball renderers'
// own override); the sky light stays the level's.
if let Some(block) = a.block_light_override() {
packed = (packed & 0xFF) | (u32::from(block) * 16) << 8;
}
if a.is_arrow() {
let Some(model) = self.arrow_model.as_ref() else {
continue;
};
// SpectralArrowRenderer, and TippableArrowRenderer's `getColor() > 0`.
let texture = if a.is_spectral_arrow() {
"entity/projectiles/arrow_spectral"
} else if a.arrow.as_ref().is_some_and(|s| s.effect_color > 0) {
"entity/projectiles/arrow_tipped"
} else {
"entity/projectiles/arrow"
};
let Some(r) = atlas.sprites.get(texture).copied() else {
continue;
};
// ArrowRenderer: `Mth.lerp(partialTick, state.yRotO, state.yRot)`
// and the same for the pitch, so a turning arrow is drawn
// between the two ticks it turned over.
let alpha32 = alpha as f32;
let yaw = a.previous_yaw + alpha32 * (a.yaw - a.previous_yaw);
let pitch = a.previous_pitch + alpha32 * (a.pitch - a.previous_pitch);
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z(pitch.to_radians());
// ArrowRenderer: `state.shake = shakeTime - partialTicks`; ArrowModel.setupAnim
// rolls the root while the shake timer runs.
let shake = a
.arrow
.as_ref()
.map_or(0.0, |s| s.shake as f32 - alpha as f32);
let roll = if shake > 0.0 {
-(shake * 3.0).msin() * shake * (std::f32::consts::PI / 180.0)
} else {
0.0
};
let pose_for = |part: &horizon_client::mobs::Part| {
let mut pose = part.pose;
if std::ptr::eq(part, model) {
pose[5] += roll;
}
pose
};
horizon_client::mobs::render_model(
model,
m,
&pose_for,
[r.u0, r.v0, r.u1, r.v1],
packed,
0,
out,
);
if a.arrow.as_ref().is_some_and(|s| s.on_fire()) {
flame_geometry(atlas, c, 0.5, 0.5, self.yaw, light, out);
}
continue;
}
// ThrownTridentRenderer.
if matches!(a.kind, ProjectileKind::Trident) {
let Some(model) = self.trident_model.as_ref() else {
continue;
};
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
continue;
};
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
let poses = horizon_client::mobs::anim::Poses::new(model);
horizon_client::mobs::render::draw(
model,
m,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
continue;
}
// WindChargeRenderer.
if matches!(
a.kind,
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
) {
self.wind_charge_geometry(a, c, packed, alpha as f32, out);
continue;
}
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
let model = &self.entity_models.shulker_bullet;
if let (Some(model), Some(r)) =
(model, atlas.sprites.get("entity/shulker/spark").copied())
{
use horizon_core::geometry::trig;
let tc = a.age as f32 + alpha as f32;
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("main", a.yaw.to_radians());
poses.xrot("main", a.pitch.to_radians());
let light = (packed & 0xFF) | ((15 * 16) << 8);
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
* glam::Mat4::from_rotation_y(
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_x(
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_z(
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
)
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
let uv = [r.u0, r.v0, r.u1, r.v1];
horizon_client::mobs::render::draw(
model,
base,
&poses,
uv,
0xFFFF_FFFF,
light,
0,
out,
);
// The translucent shell blends over the opaque core
// (`RenderTypes.entityTranslucent`).
let shell = out.len();
horizon_client::mobs::render::draw(
model,
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
&poses,
uv,
0x26FF_FFFF,
light,
0,
out,
);
horizon_client::gfx::mark_oit(&mut out[shell..]);
}
continue;
}
// WitherSkullRenderer: the skull model facing its flight.
if let Some(dangerous) = super::wither::skull_kind(a) {
let model = &self.entity_models.wither_skull;
let tex = if dangerous {
"entity/wither/wither_invulnerable"
} else {
"entity/wither/wither"
};
if let (Some(model), Some(r)) = (model, atlas.sprites.get(tex).copied()) {
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("head", a.yaw.to_radians());
poses.xrot("head", a.pitch.to_radians());
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0));
horizon_client::mobs::render::draw(
model,
base,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
}
continue;
}
if let Some(stack) = a.drawn_item() {
let owner = super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation());
self.submit_item_world(&state, pose, packed, out);
continue;
}
// `Entity.displayFireAnimation`: a thrown item that flew through a
// fire block burns (`ThrowableProjectile.tick`'s
// `applyEffectsFromBlocks`), and ThrownItemRenderer draws the flame
// over its box as any other entity renderer does.
if a.on_fire() {
let size = a.kind.size() as f32;
flame_geometry(atlas, c, size, size, self.yaw, light, out);
}
// `ThrownItemRenderer` for a fireball: the item `Fireball.getItem`
// returns (a fire charge until `setItem` gave it another), its model
// at the renderer's scale for the kind. `packed` is already 15, the
// renderer's full bright.
if let Some(stack) = a.item.as_ref() {
let owner = super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation())
* glam::Mat4::from_scale(glam::Vec3::splat(a.sprite_scale()));
self.submit_item_world(&state, pose, packed, out);
continue;
}
let Some(name) = a.item_sprite() else {
continue;
};
let Some(r) = atlas.sprites.get(name).copied() else {
continue;
};
let lift = is_item_billboard(&a.kind);
let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift);
for (v, uv) in quad
.iter()
.zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]])
{
out.push(Vertex {
normal: [0; 4],
pos: v.to_array(),
uv,
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
if let Some((start, rgb)) = glowing {
self.mark_outline(&out[start..], rgb);
}
}
}
/// `ThrownItemRenderer.submit` turns the item by `CameraRenderState.orientation`
/// (`Camera.setRotation`): the billboard's right and up axes are the camera's.
@@ -1,629 +0,0 @@
//! Drawing the mobs, players and projectiles the client sees, each frame (`mob_geometry`).
use super::*;
impl Game<'_> {
pub(in crate::game) fn mob_geometry(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
// The coarse mob bounds pay off only in dense async scenes; otherwise,
// keep the existing exact mesh-culling path.
if self.settings.horizon.entity_culling == EntityCullingMode::Asynchronous
&& self
.async_occlusion_culler
.as_ref()
.is_some_and(|culler| culler.camera_is_stationary())
&& self.settings.horizon.entity_culling_precheck != 0
&& self.mobs.len() >= self.settings.horizon.entity_culling_precheck as usize
&& !self.pack_shadow_casters.0
&& horizon_client::mod_api::mods().is_empty()
{
self.mob_geometry_inner::<true>(camera, alpha, out);
} else {
self.mob_geometry_inner::<false>(camera, alpha, out);
}
}
fn mob_geometry_inner<const PRECHECK: bool>(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
let skinning = self.skinning();
// What custom entity model animations read of the frame.
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
time: self.world.game_time as f64 + alpha,
day_time: self.world.day_time as f64 + alpha,
dimension: match self.dim {
Dim::Overworld => 0.0,
Dim::Nether => -1.0,
Dim::End => 1.0,
},
player: self.player.as_ref().map_or(camera.to_array(), |p| {
[p.position.x, p.position.y, p.position.z]
}),
player_rot: [self.pitch, self.yaw],
paused: self.pause,
first_person: self.camera_type.first_person(),
third_person_reversed: self.camera_type
== super::super::perspective::CameraType::ThirdPersonFront,
..Default::default()
});
for mob in &self.mobs {
// LevelRenderer: the entity the view looks out of is not drawn
// in first person.
if self.hides_as_camera(mob.id) {
continue;
}
let is_player = &*mob.kind.id == "minecraft:player";
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the view scale. RemotePlayer's box counts
// ten times.
let (w, h, _) = mob.dims();
let edge = (w + h + w) / 3.0 * if is_player { 10.0 } else { 1.0 };
let range = edge * 64.0 * scale;
if d.length_squared() >= range * range {
continue;
}
let beam = horizon_client::mobs::guardian::beam(mob, alpha as f32);
// EntityRenderer.shouldRender: the renderer's interpolated culling box,
// grown by 0.5 (GuardianRenderer also draws while its beam is out).
if let Some(view) = &self.view_frustum
&& beam.is_none()
{
let (lo, hi) = super::super::entity_culling::cull_box(mob, alpha);
let (lo, hi) = (
(lo - camera - glam::DVec3::splat(0.5)).as_vec3(),
(hi - camera + glam::DVec3::splat(0.5)).as_vec3(),
);
if !view.intersects(lo, hi) {
continue;
}
}
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
// A player keeps its armour, elytra and held items
// (`LivingEntityRenderer.submit` skips only the body).
let layers_only = mob.invisible && outline.is_none() && is_player;
if mob.invisible && outline.is_none() && !layers_only {
continue;
}
let culling = if PRECHECK
&& outline.is_none()
&& !is_player
&& mob.kind.id.starts_with("minecraft:")
&& [
&mob.extra.head,
&mob.extra.chest,
&mob.extra.legs,
&mob.extra.feet,
&mob.extra.main_hand,
&mob.extra.off_hand,
]
.iter()
.all(|item| item.is_none())
&& beam.is_none()
{
self.entity_cull_decision(mob, alpha)
} else {
EntityCullDecision::CheckVertices
};
if culling.hides_entity() {
continue;
}
let entity_start = out.len();
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
self.mark_entity(entity_start, &mob.kind.id);
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
// `EntityRenderer.getPackedLightCoords`: the block the mob's light
// probe (its eye) is in, at the position this frame draws rather
// than this tick's.
let probe = mob.light_probe(alpha);
let light = self.world.light(probe[0], probe[1], probe[2]);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
// renderers' overrides; the sky light stays the level's.
if mob.fire_ticks > 0
|| matches!(
mob.species(),
Species::Allay
| Species::Blaze
| Species::MagmaCube
| Species::Vex
| Species::Wither
)
{
packed = (packed & 0xFF) | ((15 * 16) << 8);
}
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
// dark ticks run out (the block light is the second byte).
if mob.species() == Species::GlowSquid {
let lit = (packed >> 12) & 15;
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
packed = (packed & 0xFF) | ((block * 16) << 8);
}
// A native mod's own model in place of the vanilla one
// (`native_mob_submit`), before any of the vanilla model's parts
// (a sulfur cube's swallowed block) are drawn.
// A player's own hook (`native_player_submit`) is asked for the
// other players in view.
let who = is_player
.then(|| self.remote_player_name(mob.id))
.flatten()
.map(|name| NativePlayerRender { name, local: false });
let modded = if layers_only {
false
} else if let Some(who) = who {
self.mod_player_geometry(mob, who, alpha as f32, camera, packed, out)
} else {
!is_player && self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
};
if modded {
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
if outline.is_none() {
self.cull_entity_vertices(out, entity_start);
}
continue;
}
if mob.species() == Species::SulfurCube
&& !horizon_client::mobs::trader::is_invisible(mob)
{
self.sulfur_cube_body_geometry(
mob,
alpha as f32,
horizon_client::mobs::render::base_transform(mob, alpha as f32, camera),
packed,
out,
);
}
// Other players on a server: PlayerRenderer's HumanoidArmorLayer.
let armor = if is_player {
self.worn_armor_layers(mob)
} else {
Vec::new()
};
let body = out.len();
// The GPU path: an ordinary mob, no layers the CPU path changes after drawing them
// (a player's, the HDR tiers below, the sulfur cube's shell). A glowing mob's
// silhouette is posed too (`SkinFrame::outline_since`), unless it is invisible:
// then only the outline draws.
let silhouette_on_gpu = outline.is_none() || !mob.invisible;
let posable = skinning
&& !is_player
&& !layers_only
&& silhouette_on_gpu
&& armor.is_empty()
&& !matches!(
mob.species(),
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
);
let mut skinned = None;
if posable {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mark = frame.mark();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
horizon_client::mobs::render::render_skinned(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
out,
&mut skin,
);
skinned = Some((mark, skin.bounds));
} else {
let render = if layers_only {
horizon_client::mobs::render::render_layers
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
}
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
// fs_hdr_mark); a tier's window lifts only their bright texels.
if let Some(tier) = match mob.species() {
Species::Blaze => Some(horizon_client::mesher::BRIGHT),
Species::MagmaCube | Species::GlowSquid => Some(horizon_client::mesher::LOW),
_ => None,
} {
for v in &mut out[body..] {
v.layer |= u32::from(tier) << 16;
}
}
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
let layers = horizon_client::mobs::render::layers_shown(mob);
if is_player && layers {
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
// and SpinAttackEffectLayer. Only the cape hides with the body;
// the others have no `isInvisible` check.
let chest = mob.extra.chest.as_deref();
if !layers_only {
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
}
if let Some((_, name)) = self
.remote_player_names()
.into_iter()
.find(|(id, _)| *id == mob.id)
{
self.player_ears_geometry(mob, &name, camera, alpha as f32, packed, out);
}
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(mob, camera, alpha as f32, packed, out);
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
// with it.
if !layers_only
&& super::super::cosmetics::enabled()
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
{
let mark = self.skin_frame.borrow().mark();
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
let gpu = skinning;
if let Some((lo, hi)) = self.cosmetics_geometry(
mob,
false,
base,
Some(camera),
alpha as f32,
packed,
gpu,
out,
) {
skinned = Some(match skinned {
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
Some((first, None)) => (first, Some((lo, hi))),
None => (mark, Some((lo, hi))),
});
}
}
// CustomHeadLayer (a pumpkin, skull or head item) included.
if layers {
// Held items join the GPU path's draws, their silhouette too.
let skin = (skinning && silhouette_on_gpu).then_some(&mut skinned);
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
}
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if let Some(((first, _, _), _)) = skinned {
let color = horizon_client::mobs::render::abgr(rgb);
self.skin_frame.borrow_mut().outline_since(first, color);
}
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
// Glowing outlines and a guardian's beam remain visible independently
// of whether the body is hidden behind terrain.
if outline.is_none() && culling.checks_vertices() && beam.is_none() {
// What the GPU draws of the mob counts by its bounds' corners.
self.cull_skinned(out, entity_start, skinned);
}
// GuardianRenderer: the laser to the locked target.
if let Some((time, scale)) = beam
&& let Some(target) = self.beam_target(mob.ai.target, alpha)
&& let Some(uv) = lookup("entity/guardian/guardian_beam")
{
let a = alpha;
let eye = glam::DVec3::new(
mob.previous.x + (mob.position.x - mob.previous.x) * a,
mob.previous.y + (mob.position.y - mob.previous.y) * a + mob.eye_height(),
mob.previous.z + (mob.position.z - mob.previous.z) * a,
) - camera;
horizon_client::mobs::guardian::beam_geometry(
eye.as_vec3(),
(target - camera).as_vec3(),
time,
scale,
uv,
out,
);
}
}
// What follows isn't a tracked mob: close the last one's span.
self.mark_motion(out.len(), 0);
self.elder_apparition_geometry(alpha, out);
self.spawner_geometry(camera, alpha, out);
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
let (right, up) = thrown_item_axes(self.camera_type, self.yaw, self.pitch);
let live = self.creatures.projectiles.iter().map(|a| {
let at = [
a.previous.x + (a.position.x - a.previous.x) * alpha,
a.previous.y + (a.position.y - a.previous.y) * alpha,
a.previous.z + (a.position.z - a.previous.z) * alpha,
];
(a, at)
});
// ItemPickupParticle: a picked-up arrow flying to its collector.
let taken = self.pickups.live.iter().filter_map(|p| match &p.taken {
super::super::pickups::Taken::Arrow(a) => {
Some((&**a, super::super::pickups::position(p, alpha)))
}
_ => None,
});
// A glowing projectile's outline: what it drew up to the next one.
let mut glowing = None;
for (a, at) in live.chain(taken) {
if let Some((start, rgb)) = glowing.take() {
self.mark_outline(&out[start..], rgb);
}
if a.id != 0
&& let Some(rgb) = self.thing_outline(a.id, a.kind.id())
{
glowing = Some((out.len(), rgb));
}
let p = glam::DVec3::from_array(at) - camera;
let c = p.as_vec3();
// EntityRenderer.getPackedLightCoords: the block its light probe
// (the eye) is in, at the position this frame draws rather than
// this tick's, and not the one under its feet.
let light = self.world.light(
at[0].floor() as i32,
a.light_probe_y(alpha).floor() as i32,
at[2].floor() as i32,
);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a shulker bullet and anything
// on fire are drawn at 15 block light (the fireball renderers'
// own override); the sky light stays the level's.
if let Some(block) = a.block_light_override() {
packed = (packed & 0xFF) | (u32::from(block) * 16) << 8;
}
if a.is_arrow() {
let Some(model) = self.arrow_model.as_ref() else {
continue;
};
// SpectralArrowRenderer, and TippableArrowRenderer's `getColor() > 0`.
let texture = if a.is_spectral_arrow() {
"entity/projectiles/arrow_spectral"
} else if a.arrow.as_ref().is_some_and(|s| s.effect_color > 0) {
"entity/projectiles/arrow_tipped"
} else {
"entity/projectiles/arrow"
};
let Some(r) = atlas.sprites.get(texture).copied() else {
continue;
};
// ArrowRenderer: `Mth.lerp(partialTick, state.yRotO, state.yRot)`
// and the same for the pitch, so a turning arrow is drawn
// between the two ticks it turned over.
let alpha32 = alpha as f32;
let yaw = a.previous_yaw + alpha32 * (a.yaw - a.previous_yaw);
let pitch = a.previous_pitch + alpha32 * (a.pitch - a.previous_pitch);
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z(pitch.to_radians());
// ArrowRenderer: `state.shake = shakeTime - partialTicks`; ArrowModel.setupAnim
// rolls the root while the shake timer runs.
let shake = a
.arrow
.as_ref()
.map_or(0.0, |s| s.shake as f32 - alpha as f32);
let roll = if shake > 0.0 {
-(shake * 3.0).msin() * shake * (std::f32::consts::PI / 180.0)
} else {
0.0
};
let pose_for = |part: &horizon_client::mobs::Part| {
let mut pose = part.pose;
if std::ptr::eq(part, model) {
pose[5] += roll;
}
pose
};
horizon_client::mobs::render_model(
model,
m,
&pose_for,
[r.u0, r.v0, r.u1, r.v1],
packed,
0,
out,
);
if a.arrow.as_ref().is_some_and(|s| s.on_fire()) {
flame_geometry(atlas, c, 0.5, 0.5, self.yaw, light, out);
}
continue;
}
// ThrownTridentRenderer.
if matches!(a.kind, ProjectileKind::Trident) {
let Some(model) = self.trident_model.as_ref() else {
continue;
};
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
continue;
};
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
let poses = horizon_client::mobs::anim::Poses::new(model);
horizon_client::mobs::render::draw(
model,
m,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
continue;
}
// WindChargeRenderer.
if matches!(
a.kind,
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
) {
self.wind_charge_geometry(a, c, packed, alpha as f32, out);
continue;
}
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
let model = &self.entity_models.shulker_bullet;
if let (Some(model), Some(r)) =
(model, atlas.sprites.get("entity/shulker/spark").copied())
{
use horizon_core::geometry::trig;
let tc = a.age as f32 + alpha as f32;
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("main", a.yaw.to_radians());
poses.xrot("main", a.pitch.to_radians());
let light = (packed & 0xFF) | ((15 * 16) << 8);
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
* glam::Mat4::from_rotation_y(
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_x(
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_z(
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
)
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
let uv = [r.u0, r.v0, r.u1, r.v1];
horizon_client::mobs::render::draw(
model,
base,
&poses,
uv,
0xFFFF_FFFF,
light,
0,
out,
);
// The translucent shell blends over the opaque core
// (`RenderTypes.entityTranslucent`).
let shell = out.len();
horizon_client::mobs::render::draw(
model,
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
&poses,
uv,
0x26FF_FFFF,
light,
0,
out,
);
horizon_client::gfx::mark_oit(&mut out[shell..]);
}
continue;
}
// WitherSkullRenderer: the skull model facing its flight.
if let Some(dangerous) = super::super::wither::skull_kind(a) {
let model = &self.entity_models.wither_skull;
let tex = if dangerous {
"entity/wither/wither_invulnerable"
} else {
"entity/wither/wither"
};
if let (Some(model), Some(r)) = (model, atlas.sprites.get(tex).copied()) {
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("head", a.yaw.to_radians());
poses.xrot("head", a.pitch.to_radians());
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0));
horizon_client::mobs::render::draw(
model,
base,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
}
continue;
}
if let Some(stack) = a.drawn_item() {
let owner = super::super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation());
self.submit_item_world(&state, pose, packed, out);
continue;
}
// `Entity.displayFireAnimation`: a thrown item that flew through a
// fire block burns (`ThrowableProjectile.tick`'s
// `applyEffectsFromBlocks`), and ThrownItemRenderer draws the flame
// over its box as any other entity renderer does.
if a.on_fire() {
let size = a.kind.size() as f32;
flame_geometry(atlas, c, size, size, self.yaw, light, out);
}
// `ThrownItemRenderer` for a fireball: the item `Fireball.getItem`
// returns (a fire charge until `setItem` gave it another), its model
// at the renderer's scale for the kind. `packed` is already 15, the
// renderer's full bright.
if let Some(stack) = a.item.as_ref() {
let owner = super::super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation())
* glam::Mat4::from_scale(glam::Vec3::splat(a.sprite_scale()));
self.submit_item_world(&state, pose, packed, out);
continue;
}
let Some(name) = a.item_sprite() else {
continue;
};
let Some(r) = atlas.sprites.get(name).copied() else {
continue;
};
let lift = is_item_billboard(&a.kind);
let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift);
for (v, uv) in quad
.iter()
.zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]])
{
out.push(Vertex {
normal: [0; 4],
pos: v.to_array(),
uv,
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
if let Some((start, rgb)) = glowing {
self.mark_outline(&out[start..], rgb);
}
}
}
@@ -1,171 +0,0 @@
//! Spawn eggs: a baby from an egg used on its own kind, an egg used on a block or a fluid, and the
//! egg's entity data on the mob it spawned.
use super::*;
impl Game<'_> {
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
/// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby
/// at the mob's feet, taking the parent's variant as `getBreedOffspring`
/// does, and one egg used. False (PASS) when the mob has no baby form.
pub(super) fn spawn_egg_offspring(&mut self, index: usize) -> bool {
let Some(stack) = self.held().cloned() else {
return false;
};
let parent = &self.mobs[index];
if !parent.alive()
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|| !horizon_client::mobs::species::can_be_a_baby(parent.species())
// WanderingTrader.getBreedOffspring returns null: no offspring, the
// click carries on to trading.
|| parent.species() == Species::WanderingTrader
{
return false;
}
let (kind, at) = (
parent.kind.id.clone(),
[parent.position.x, parent.position.y, parent.position.z],
);
let (species, variant, color) =
(parent.species(), parent.extra.variant, parent.extra.color);
let target = self.mob_entity(&self.mobs[index]);
// SpawnEggItem.spawnOffspringFromSpawnEgg: `type.create(level, SPAWN_ITEM_USE)`
// creates the baby without finalizeSpawn, and `snapTo(pos, 0, 0)` faces
// it south without a level-random draw.
let Some(id) =
self.spawn_mob_internal(&kind, at, false, false, false, false, SpawnYaw::Fixed(0.0))
else {
return false;
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
// `Mob.checkAndHandleImportantInteractions` then calls
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
// who used the egg (a fox egg used on a block trusts nobody).
if species == Species::Fox {
let spawner = self.acting_player_uuid();
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
}
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
true
}
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
pub(in crate::game) fn spawn_egg_fluid_hit(&self) -> Option<super::super::Hit> {
let hit = self.bucket_hit()?;
matches!(
&*self.block_name(hit.pos),
"minecraft:water" | "minecraft:lava"
)
.then_some(hit)
}
/// Shared peaceful-mode eligibility for spawn eggs.
pub(in crate::game) fn spawn_egg_allowed(&self, item: &str) -> bool {
let kind = item.trim_end_matches("_spawn_egg");
self.ui.difficulty != 0
|| self
.res
.mob_types()
.get(kind)
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
}
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
pub(super) fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
let fluid = matches!(self.guest_use, Some(super::super::GuestUse::Item(_)));
let Some(hit) = (if fluid {
self.spawn_egg_fluid_hit()
} else {
self.pick()
}) else {
return Ok(false);
};
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
if !self.spawn_egg_allowed(&stack.item) {
self.use_failed = true;
return Ok(true);
}
if fluid
&& !self.may_build()
&& !self.can_place_on_in_adventure([
hit.pos[0] - hit.face[0],
hit.pos[1] - hit.face[1],
hit.pos[2] - hit.face[2],
])
{
self.use_failed = true;
return Ok(true);
}
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
let (spawner_id, data) = horizon_client::block_entity_item::spawn_egg_on_spawner(
stack,
self.acting_player_is_op(),
);
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &spawner_id, data.as_ref()) {
if done {
self.swing_arm();
self.award_stat("used", &stack.item, 1);
self.consume_held();
}
return Ok(true);
}
// useOn: a clicked block without a collision shape (vines, grass)
// takes the mob itself, any other the one on the clicked face.
let p = if fluid || self.collision_empty(hit.pos) {
hit.pos
} else {
[
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
]
};
let at = [
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
];
// SpawnEggItem.spawnMob: `EntityType.spawn`.
let Some(id) = self.spawn_mob_spawned(&kind, at, SpawnYaw::Created) else {
return Ok(false);
};
// SpawnEggItem.spawnMob: `EntityType.create` finalizes the mob, then
// `PostSpawnProcessor` puts the egg's components on it.
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::entity_nbt::apply_item_components(m, stack);
}
self.apply_spawn_egg_entity_data(id, stack);
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
if !self.creative
&& let Some(s) = self.held_mut().as_mut()
{
s.count -= 1;
if s.count == 0 {
*self.held_mut() = None;
}
}
Ok(true)
}
/// `EntityType.updateCustomEntityTag`: the egg's `minecraft:entity_data` on
/// the mob it spawned, once the guards let it through, as
/// `TypedEntityData.loadInto`: the mob's own compound, the data merged over
/// it, loaded back into the same mob (`/data merge entity`'s loader).
pub(in crate::game) fn apply_spawn_egg_entity_data(&mut self, id: u32, stack: &ItemStack) {
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
return;
};
let kind = self.mobs[i].kind.id.clone();
let op = self.acting_player_is_op();
let Some(data) = horizon_client::block_entity_item::spawn_egg_entity_data(stack, &kind, op)
else {
return;
};
let own = self.mob_tree_nbt(i);
let tag = horizon_client::data_cmd::merged(&own, data);
// Nothing to report: the mob's own compound always loads back.
let _ = self.apply_entity_nbt(super::super::commands::Target::Mob(id), tag);
}
}
+1 -1
View File
@@ -2671,7 +2671,7 @@ mod tests {
#[test]
fn a_temporarily_banned_player_is_refused_with_the_end_until_it_has_passed() {
use super::{BanEntry, BanList, Lists, NameAndId};
use horizon_server_files::duration::now_millis;
use horizon_chat_filter::now_millis;
let dir = std::env::temp_dir().join(format!("horizon-can-login-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let mut lists = Lists {
+49
View File
@@ -0,0 +1,49 @@
//! 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,
}
}
}
-6
View File
@@ -273,12 +273,6 @@ impl Game<'_> {
}
self.draw_pvp_hud(batch, &mut corners);
self.native_hud(batch, &mut corners);
// The layers crates registered (`horizon_hooks::hud`): none, no canvas.
let layers = horizon_hooks::hud::engine::all();
if !layers.is_empty() {
let shown = Corner::ALL.map(|corner| self.native_corner_shown(corner));
extras_hud::draw_layers(layers, batch, &self.gui.atlas, &mut corners, shown);
}
}
/// The height the status effect icons take at the top right
/// (`Hud.extractEffects`: beneficial at y 1, harmful at y 26).
+9 -14
View File
@@ -1,23 +1,23 @@
//! 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
//! screen (`friends_screen`, the pause menu's Friends button) 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
//! configured adds a short code and a fallback. The integrated server is the
//! 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.
//! loopback address.
use super::{Game, lan::LanSettings};
use horizon_client::{Result, hud::HudBatch, lang, text::Text};
use horizon_client::{
Result,
friends_screen::{self, Action, FriendsView},
hud::HudBatch,
lang,
text::Text,
};
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
use std::time::{Duration, Instant};
mod screen;
use screen::{self as friends_screen, Action, FriendsView};
pub use screen::{TITLE, title};
pub(super) struct Sharing {
host: Host,
/// Sharing opened the world to LAN, so stopping closes it again.
@@ -240,11 +240,6 @@ impl Game<'_> {
}
}
/// Whether the pause menu offers Play with Friends (a singleplayer world).
pub(super) fn friends_shown(&self) -> bool {
self.friends_view().shown
}
/// What Play with Friends shows.
pub(super) fn friends_view(&self) -> FriendsView {
let guests = match &self.lan {
+4 -10
View File
@@ -1,17 +1,11 @@
//! 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
//! no world is shared, the pause menu has no Friends button (`FriendsView::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 horizon_client::{Result, friends_screen::FriendsView, hud::HudBatch};
pub const HELP: &[&str] = &[];
/// The pause menu's Friends icon is vanilla's, drawn inactive: its name for the widget tree.
pub const TITLE: &str = "Play with Friends";
pub fn title() -> String {
horizon_client::lang::get_or("horizon.friends.title", TITLE)
}
impl Game<'_> {
pub(super) fn friends_lan_closed(&mut self) {}
@@ -24,8 +18,8 @@ impl Game<'_> {
pub(super) fn friends_screen_click(&mut self) {}
pub(super) fn friends_shown(&self) -> bool {
false
pub(super) fn friends_view(&self) -> FriendsView {
FriendsView::default()
}
/// `horizon-ctl friends ...`.
@@ -34,6 +34,8 @@ 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);
@@ -96,9 +98,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(
@@ -128,22 +129,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(
@@ -899,32 +899,4 @@ impl Game<'_> {
f.input_clicked(world_recipes!(self), before);
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
/// changes hands: a screen that opens or closes starts with its own
/// state, and a press whose release never reached the screen -- the
/// pointer left the window, or the screen closed first -- would
/// otherwise leave every later press on every later screen swallowed.
pub(super) fn reset_inventory_clicks(&mut self) {
self.inv_clicks.cancel_drag();
self.inv_drag = None;
self.last_inv_click = None;
}
/// A sound a menu click made the player play (a bundle's insert or
/// remove): `Entity.playSound` at the player.
pub(super) fn player_menu_sound(&mut self, sound: &str) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
(1.0, 1.0)
} else {
(0.8, 0.8 + self.world.random.next_float() * 0.4)
};
self.audio.play(sound, Some(at), volume, pitch);
}
}
+267 -4
View File
@@ -28,10 +28,6 @@ use horizon_core::{
};
use horizon_world::registry::BlockStateId;
use std::{collections::HashMap, sync::Arc};
mod index;
mod nbt;
pub(super) use index::CartIndex;
use index::cart_at;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(super) enum CartKind {
@@ -191,6 +187,102 @@ pub(super) const TNT_PRIME_EVENT: i8 = 70;
mod display;
use display::{cart_render_bounds, display_offset, shown_unless_air};
pub(super) use display::{default_display_offset, forget_unreadable_display};
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
/// every mob of the level. Both answer in slot order, as the scans did.
///
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
/// rebuilt once the list's length changed (a cart gone or added), and dropped
/// after a command, which can move or replace any cart
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
/// unseen, so with mods loaded both scans stay.
#[derive(Default)]
pub(super) struct CartIndex {
carts: Option<(SeenGrid, Vec<u32>)>,
/// The grid, and the carts a mob rides.
mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
near: Vec<u32>,
}
impl CartIndex {
fn usable() -> bool {
horizon_client::mod_api::mods().is_empty()
}
/// The cart in slot `i` has these bounds now.
fn moved(&mut self, i: usize, bounds: Aabb) {
if let Some((grid, ids)) = &mut self.carts
&& i < ids.len()
{
grid.update(i, bounds);
}
}
fn forget_carts(&mut self) {
self.carts = None;
}
/// The slots of the carts whose box may meet `area`, for the cart in slot
/// `i` (just moved); false: scan them all.
fn carts_near(&mut self, carts: &[Minecart], i: usize, area: Aabb, out: &mut Vec<u32>) -> bool {
if !Self::usable() {
return false;
}
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
self.carts = Some((
SeenGrid::new(carts.iter().map(Minecart::bounds)),
carts.iter().map(|c| c.id).collect(),
));
}
let Some((grid, ids)) = &mut self.carts else {
return false;
};
grid.update(i, carts[i].bounds());
if !grid.candidates(area, out) {
return false;
}
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
// A cart was replaced in place: start over next time.
self.carts = None;
return false;
}
true
}
/// The slots of the mobs whose box may meet `area` into `out`, and
/// whether a mob rides cart `cart`; None: scan them all.
fn mobs_near(
&mut self,
mobs: &JoinList<Mob>,
cart: u32,
area: Aabb,
out: &mut Vec<u32>,
) -> Option<bool> {
if !Self::usable() {
return None;
}
if !self
.mobs
.as_ref()
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
{
self.mobs = Some((
mobs.stamp(),
SeenGrid::new(mobs.iter().map(Mob::bounds)),
mobs.iter().filter_map(|m| m.extra.mount).collect(),
));
}
let (_, grid, mounted) = self.mobs.as_ref()?;
grid.candidates(area, out).then(|| mounted.contains(&cart))
}
}
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
/// the usual case while carts tick in order) or found by a scan. A world
/// with hundreds of mineshaft chest carts asked this of every cart several
/// times a tick.
fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
if carts.get(hint).is_some_and(|c| c.id == id) {
return Some(hint);
}
carts.iter().position(|c| c.id == id)
}
/// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`:
/// the push the cart loses (the entity gets a quarter of it back).
pub(super) fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
@@ -2892,6 +2984,177 @@ impl Game<'_> {
.render_state(&scene, state.0, m, light, out);
}
}
/// Saving and loading.
impl Minecart {
pub fn to_nbt(
&self,
uuid: [i32; 4],
raw: Option<&horizon_world::nbt::Tag>,
) -> horizon_world::nbt::Tag {
use horizon_world::nbt::{JavaString, Tag};
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
self.kind.id(),
self.position,
self.velocity,
[self.yaw, self.pitch],
(self.on_ground, self.fall_distance),
uuid,
raw,
);
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
if let Tag::Compound(map) = &mut tag {
// ContainerEntity.addChestVehicleSaveData belongs only to the
// container subclasses, never to a plain or specialized cart.
if self.kind.slots() == 0 {
map.remove(&JavaString::from("Items"));
}
if self.kind == CartKind::CommandBlock {
for key in ["LastOutput", "LastExecution"] {
map.remove(&JavaString::from(key));
}
}
map.remove(&JavaString::from("DisplayOffset"));
map.remove(&JavaString::from("DisplayState"));
let mut put = |k: &str, v: Tag| {
map.insert(JavaString::from(k), v);
};
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
put("Fire", Tag::Short(0));
// addAdditionalSaveData: the offset only off its default, the state only when set.
if let Some(offset) = self.custom_offset
&& f64::from(offset) != default_display_offset(self.kind)
{
put("DisplayOffset", Tag::Int(offset));
}
if let Some(state) = &self.custom_display {
put("DisplayState", state.clone());
}
match self.kind {
CartKind::Chest | CartKind::Hopper => {
put("Items", horizon_client::save::encode_items(&self.items));
if self.kind == CartKind::Hopper {
put("Enabled", Tag::Byte(i8::from(self.enabled)));
}
}
CartKind::Furnace => {
put("PushX", Tag::Double(self.push.x.to_bits()));
put("PushZ", Tag::Double(self.push.z.to_bits()));
put("Fuel", Tag::Short(self.fuel as i16));
}
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
// BaseCommandBlock.save (its CustomName shares the entity's key).
CartKind::CommandBlock => {
for (key, value) in self.command.save() {
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
put(key, value);
}
}
}
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
CartKind::Spawner => {
if let Some(spawner) = &self.spawner {
for (key, value) in
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
{
put(key, value);
}
}
}
CartKind::Rideable => {}
}
// A loaded loot table goes back while the items are still its roll;
// otherwise the items replace it, as vanilla's unpackLootTable does.
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
{
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
}
} else {
map.remove(&JavaString::from("LootTable"));
map.remove(&JavaString::from("LootTableSeed"));
}
}
tag
}
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let mut c = Self::new(id, kind, position);
c.load(tag)?;
Some(c)
}
/// `Entity.load` on this cart: the saved fields are read over the live
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
/// `None`, with nothing changed, when the compound has no position.
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
use horizon_world::nbt::Tag;
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let kind = self.kind;
let c = self;
c.position = position;
c.previous = position;
c.velocity = velocity;
c.yaw = yaw;
c.previous_yaw = yaw;
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
c.previous_pitch = c.pitch;
// ValueInput's numeric getters take any NumericTag: getIntOr is its
// intValue, getShortOr the low 16 bits of it, getBooleanOr
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
let int = |k: &str| {
tag.get(k)
.and_then(horizon_client_core::nbt_path::int_value)
};
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
let double = |k: &str| match tag.get(k) {
Some(Tag::Byte(v)) => f64::from(*v),
Some(Tag::Short(v)) => f64::from(*v),
Some(Tag::Int(v)) => f64::from(*v),
Some(Tag::Long(v)) => *v as f64,
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
Some(Tag::Double(v)) => f64::from_bits(*v),
_ => 0.0,
};
// Entity.load: OnGround and fall_distance, as saved.
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
c.flipped = flag("FlippedRotation", false);
c.first_tick = flag("HasTicked", false);
if kind.slots() > 0 {
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
}
c.enabled = flag("Enabled", true);
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
// readAdditionalSaveData: the saved push has no y.
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
c.fuse = int("fuse").unwrap_or(-1);
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
c.custom_display = tag.get("DisplayState").cloned();
c.custom_offset = int("DisplayOffset");
if kind == CartKind::CommandBlock {
c.command = horizon_client::command_block::CommandBlock::load(tag);
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
if c.synced_command != c.command.command {
c.synced_command.clone_from(&c.command.command);
c.command.set_command(&c.synced_command);
}
c.synced_output = c.command.last_output_string();
}
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
if kind == CartKind::Spawner {
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
tag,
)));
}
Some(())
}
}
/// `VehicleEntity.hurtServer`'s destroy test: a creative player, damage past
/// 40, or (`MinecartTNT.shouldSourceDestroy`) fire or an explosion hitting a
/// TNT cart.
@@ -1,105 +0,0 @@
//! The carts' and mobs' boxes for a tick's cart pushes ([`CartIndex`]).
use super::*;
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
/// every mob of the level. Both answer in slot order, as the scans did.
///
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
/// rebuilt once the list's length changed (a cart gone or added), and dropped
/// after a command, which can move or replace any cart
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
/// unseen, so with mods loaded both scans stay.
#[derive(Default)]
pub(in crate::game) struct CartIndex {
pub(super) carts: Option<(SeenGrid, Vec<u32>)>,
/// The grid, and the carts a mob rides.
pub(super) mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
pub(super) near: Vec<u32>,
}
impl CartIndex {
pub(super) fn usable() -> bool {
horizon_client::mod_api::mods().is_empty()
}
/// The cart in slot `i` has these bounds now.
pub(super) fn moved(&mut self, i: usize, bounds: Aabb) {
if let Some((grid, ids)) = &mut self.carts
&& i < ids.len()
{
grid.update(i, bounds);
}
}
pub(super) fn forget_carts(&mut self) {
self.carts = None;
}
/// The slots of the carts whose box may meet `area`, for the cart in slot
/// `i` (just moved); false: scan them all.
pub(super) fn carts_near(
&mut self,
carts: &[Minecart],
i: usize,
area: Aabb,
out: &mut Vec<u32>,
) -> bool {
if !Self::usable() {
return false;
}
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
self.carts = Some((
SeenGrid::new(carts.iter().map(Minecart::bounds)),
carts.iter().map(|c| c.id).collect(),
));
}
let Some((grid, ids)) = &mut self.carts else {
return false;
};
grid.update(i, carts[i].bounds());
if !grid.candidates(area, out) {
return false;
}
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
// A cart was replaced in place: start over next time.
self.carts = None;
return false;
}
true
}
/// The slots of the mobs whose box may meet `area` into `out`, and
/// whether a mob rides cart `cart`; None: scan them all.
pub(super) fn mobs_near(
&mut self,
mobs: &JoinList<Mob>,
cart: u32,
area: Aabb,
out: &mut Vec<u32>,
) -> Option<bool> {
if !Self::usable() {
return None;
}
if !self
.mobs
.as_ref()
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
{
self.mobs = Some((
mobs.stamp(),
SeenGrid::new(mobs.iter().map(Mob::bounds)),
mobs.iter().filter_map(|m| m.extra.mount).collect(),
));
}
let (_, grid, mounted) = self.mobs.as_ref()?;
grid.candidates(area, out).then(|| mounted.contains(&cart))
}
}
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
/// the usual case while carts tick in order) or found by a scan. A world
/// with hundreds of mineshaft chest carts asked this of every cart several
/// times a tick.
pub(super) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
if carts.get(hint).is_some_and(|c| c.id == id) {
return Some(hint);
}
carts.iter().position(|c| c.id == id)
}
@@ -1,173 +0,0 @@
//! Saving and loading a minecart.
use super::*;
/// Saving and loading.
impl Minecart {
pub fn to_nbt(
&self,
uuid: [i32; 4],
raw: Option<&horizon_world::nbt::Tag>,
) -> horizon_world::nbt::Tag {
use horizon_world::nbt::{JavaString, Tag};
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
self.kind.id(),
self.position,
self.velocity,
[self.yaw, self.pitch],
(self.on_ground, self.fall_distance),
uuid,
raw,
);
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
if let Tag::Compound(map) = &mut tag {
// ContainerEntity.addChestVehicleSaveData belongs only to the
// container subclasses, never to a plain or specialized cart.
if self.kind.slots() == 0 {
map.remove(&JavaString::from("Items"));
}
if self.kind == CartKind::CommandBlock {
for key in ["LastOutput", "LastExecution"] {
map.remove(&JavaString::from(key));
}
}
map.remove(&JavaString::from("DisplayOffset"));
map.remove(&JavaString::from("DisplayState"));
let mut put = |k: &str, v: Tag| {
map.insert(JavaString::from(k), v);
};
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
put("Fire", Tag::Short(0));
// addAdditionalSaveData: the offset only off its default, the state only when set.
if let Some(offset) = self.custom_offset
&& f64::from(offset) != default_display_offset(self.kind)
{
put("DisplayOffset", Tag::Int(offset));
}
if let Some(state) = &self.custom_display {
put("DisplayState", state.clone());
}
match self.kind {
CartKind::Chest | CartKind::Hopper => {
put("Items", horizon_client::save::encode_items(&self.items));
if self.kind == CartKind::Hopper {
put("Enabled", Tag::Byte(i8::from(self.enabled)));
}
}
CartKind::Furnace => {
put("PushX", Tag::Double(self.push.x.to_bits()));
put("PushZ", Tag::Double(self.push.z.to_bits()));
put("Fuel", Tag::Short(self.fuel as i16));
}
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
// BaseCommandBlock.save (its CustomName shares the entity's key).
CartKind::CommandBlock => {
for (key, value) in self.command.save() {
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
put(key, value);
}
}
}
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
CartKind::Spawner => {
if let Some(spawner) = &self.spawner {
for (key, value) in
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
{
put(key, value);
}
}
}
CartKind::Rideable => {}
}
// A loaded loot table goes back while the items are still its roll;
// otherwise the items replace it, as vanilla's unpackLootTable does.
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
{
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
}
} else {
map.remove(&JavaString::from("LootTable"));
map.remove(&JavaString::from("LootTableSeed"));
}
}
tag
}
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let mut c = Self::new(id, kind, position);
c.load(tag)?;
Some(c)
}
/// `Entity.load` on this cart: the saved fields are read over the live
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
/// `None`, with nothing changed, when the compound has no position.
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
use horizon_world::nbt::Tag;
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let kind = self.kind;
let c = self;
c.position = position;
c.previous = position;
c.velocity = velocity;
c.yaw = yaw;
c.previous_yaw = yaw;
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
c.previous_pitch = c.pitch;
// ValueInput's numeric getters take any NumericTag: getIntOr is its
// intValue, getShortOr the low 16 bits of it, getBooleanOr
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
let int = |k: &str| {
tag.get(k)
.and_then(horizon_client_core::nbt_path::int_value)
};
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
let double = |k: &str| match tag.get(k) {
Some(Tag::Byte(v)) => f64::from(*v),
Some(Tag::Short(v)) => f64::from(*v),
Some(Tag::Int(v)) => f64::from(*v),
Some(Tag::Long(v)) => *v as f64,
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
Some(Tag::Double(v)) => f64::from_bits(*v),
_ => 0.0,
};
// Entity.load: OnGround and fall_distance, as saved.
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
c.flipped = flag("FlippedRotation", false);
c.first_tick = flag("HasTicked", false);
if kind.slots() > 0 {
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
}
c.enabled = flag("Enabled", true);
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
// readAdditionalSaveData: the saved push has no y.
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
c.fuse = int("fuse").unwrap_or(-1);
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
c.custom_display = tag.get("DisplayState").cloned();
c.custom_offset = int("DisplayOffset");
if kind == CartKind::CommandBlock {
c.command = horizon_client::command_block::CommandBlock::load(tag);
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
if c.synced_command != c.command.command {
c.synced_command.clone_from(&c.command.command);
c.command.set_command(&c.synced_command);
}
c.synced_output = c.command.last_output_string();
}
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
if kind == CartKind::Spawner {
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
tag,
)));
}
Some(())
}
}
+10 -6
View File
@@ -157,7 +157,7 @@ impl Game<'_> {
/// The numbers `Mob.compareArmor` and `compareWeapons` read off `stack`
/// in `slot` for the mob `m`.
pub(super) fn stack_facts(&self, m: &Mob, stack: &ItemStack, slot: Slot) -> Facts {
let (armor, toughness, attack) = self.attributes(m, stack, slot);
let (armor, toughness, attack) = self.attributes(m, &stack.item, slot);
Facts {
armor,
armor_toughness: toughness,
@@ -173,18 +173,22 @@ impl Game<'_> {
/// `Mob.getApproximateAttributeWith` for the item `id` in `slot`: each
/// attribute starts from the mob's own value. (armour, toughness, damage)
fn attributes(&self, m: &Mob, stack: &ItemStack, slot: Slot) -> (f64, f64, f64) {
let modifiers = self.items().attribute_modifiers(stack);
fn attributes(&self, m: &Mob, id: &str, slot: Slot) -> (f64, f64, f64) {
let empty: &[(String, String, f64, String, String)] = &[];
let modifiers = self
.items()
.get(id)
.map_or(empty, |i| i.modifiers.as_slice());
(
equip::attribute_with(&modifiers, "minecraft:armor", f64::from(m.kind.armor), slot),
equip::attribute_with(modifiers, "minecraft:armor", f64::from(m.kind.armor), slot),
equip::attribute_with(
&modifiers,
modifiers,
"minecraft:armor_toughness",
f64::from(m.kind.armor_toughness),
slot,
),
equip::attribute_with(
&modifiers,
modifiers,
"minecraft:attack_damage",
f64::from(m.attrs.attack_damage),
slot,
@@ -10,7 +10,11 @@ use horizon_client::{
net::FRIEND,
};
use horizon_core::geometry::Vec3;
#[cfg(feature = "friends")]
use horizon_friends::invite::{self, Parsed};
use std::borrow::Cow;
#[cfg(feature = "friends")]
use std::net::SocketAddr;
use winit::keyboard::KeyCode;
/// HUD and first-person hand visibility. Other hand-render restrictions still apply.
@@ -105,11 +109,28 @@ impl NativeWorldId {
}
}
/// The part of a friend's invite anyone may see (`horizon_hooks::tunnel::Resolver::public_part`).
/// A host's code is fresh each time it shares, so a friend's world is known for that sharing only.
/// Without Play with Friends no invite is read: a `friend:` world shows no part of it.
/// The part of a friend's invite anyone may see. A host's code is fresh each
/// time it shares, so a friend's world is known for that sharing only. The
/// relay is kept beside the addresses: two hosts on other networks can share
/// a private address and the fixed direct port.
#[cfg(feature = "friends")]
fn invite_public_part(code: &str) -> String {
horizon_hooks::engine::tunnel().map_or_else(String::new, |r| r.public_part(code))
match invite::parse(code) {
Some(Parsed::Short(code)) => code.nameplate,
Some(Parsed::Long(invite)) => {
let relay = invite
.relay
.map(|r| format!("{}/{}", r.address, r.code.nameplate));
let endpoints = invite.endpoints.iter().map(SocketAddr::to_string);
endpoints.chain(relay).collect::<Vec<_>>().join(",")
}
None => String::new(),
}
}
/// Without Play with Friends no invite is read: a `friend:` world shows no part of it.
#[cfg(not(feature = "friends"))]
fn invite_public_part(_code: &str) -> String {
String::new()
}
/// A living entity known to the client, before interpolation. No authoritative writes.
@@ -537,6 +558,8 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "friends")]
use horizon_friends::code::Code;
#[test]
fn a_world_is_its_save_in_single_player_and_its_address_on_a_server() {
@@ -550,6 +573,52 @@ mod tests {
);
}
#[test]
#[cfg(feature = "friends")]
fn friends_worlds_are_told_apart_by_their_hosts_never_by_their_secrets() {
const PORT: u16 = 50000;
const RELAY: &str = "relay.example.com:443";
// A short code: its nameplate, then its secret.
let short = |nameplate: &str, secret: &str| Code {
nameplate: nameplate.into(),
secret: secret.into(),
};
let invite = |secret: u8, host: &str, relay: Option<Code>| {
let code = invite::Invite {
secret: [secret; invite::SECRET],
port: PORT,
endpoints: vec![format!("{host}:{PORT}").parse().unwrap()],
relay: relay.map(|code| invite::RelayRef {
address: RELAY.into(),
code,
}),
}
.encode();
NativeWorldId::of(None, &format!("{FRIEND}{code}"))
};
assert_ne!(
invite(1, "192.168.1.5", None),
invite(1, "192.168.1.6", None),
"two friends"
);
assert_ne!(
invite(1, "192.168.1.5", Some(short("ABCD", "WXYZ"))),
invite(1, "192.168.1.5", Some(short("EFGH", "WXYZ"))),
"two friends on other networks behind the same private address"
);
assert_eq!(
invite(1, "192.168.1.5", Some(short("ABCD", "WXYZ"))),
invite(2, "192.168.1.5", Some(short("ABCD", "QRST"))),
"the invite's and the relay code's secrets take no part"
);
let typed = |code: Code| NativeWorldId::of(None, &format!("{FRIEND}{code}"));
assert_eq!(typed(short("ABCD", "WXYZ")), typed(short("ABCD", "QRST")));
assert_eq!(
typed(short("ABCD", "WXYZ")),
NativeWorldId::Server(format!("{FRIEND}ABCD"))
);
}
#[test]
fn a_hud_tells_chat_and_death_from_other_screens() {
assert_eq!(
+1 -9
View File
@@ -102,16 +102,8 @@ impl Game<'_> {
if food && m.health < m.attrs.max_health {
// feed(player, hand, stack, 2, 1).
let sound = nautilus::eat_sound(m);
// TamableAnimal.feed: heals 2 x the food's nutrition, 1 for an
// item with no food component.
let nutrition = self
.res
.items()
.get(item)
.and_then(|i| i.food.as_ref())
.map(|f| f.0 as i32);
let m = &mut self.mobs[index];
m.heal(nautilus::feed_heal(nutrition));
m.heal(2.0);
self.use_nautilus_food(item);
self.nautilus_sound(index, sound);
return true;
@@ -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)
+23 -102
View File
@@ -226,25 +226,6 @@ impl Game<'_> {
});
}
/// `Entity.collide`'s `getEntityCollisions` for a body a piston or a
/// shulker lid pushes: the entities it can collide with
/// (`canCollideWith`, which for every mover but a vehicle is
/// `canBeCollidedWith`): a boat, a living shulker, a happy ghast held
/// still. A pushed boat or minecart collides by `canVehicleCollide`
/// instead, which this doesn't cover, and the player moves itself.
fn pushed_entity_colliders(&self, target: Target) -> Vec<Aabb> {
match target {
Target::Entity(EntityHandle::Boat(_) | EntityHandle::Cart(_)) => Vec::new(),
Target::Entity(handle) => {
let own = with_entity!(self, handle, entity => Entity::bounds(entity));
let mut boxes = self.item_entity_collisions();
boxes.retain(|b| *b != own);
boxes
}
_ => Vec::new(),
}
}
/// The collision view of the moving piston at `pos` takes the progress
/// its tick gave it. One no longer moving keeps its entry until
/// `sync_moving_pistons`.
@@ -303,7 +284,6 @@ impl Game<'_> {
self.shulker_push_player(movement);
return;
}
let entities = self.pushed_entity_colliders(target);
let Ok(moved) = pushed_movement(
SceneCollisions {
world: &self.world,
@@ -318,7 +298,6 @@ impl Game<'_> {
body.step_height,
body.on_ground,
None,
&entities,
) else {
return;
};
@@ -686,7 +665,6 @@ impl Game<'_> {
self.projectile_piston_block_effects(i, at);
}
self.piston_block_triggers(before, before);
self.release_after_block_move(target);
return;
}
// `Entity.move`: a projectile a cobweb or powder snow holds stops dead
@@ -717,7 +695,6 @@ impl Game<'_> {
}
_ => None,
};
let entities = self.pushed_entity_colliders(target);
let resolved = with_piston_noclip(piston_direction, || {
pushed_movement(
SceneCollisions {
@@ -732,7 +709,6 @@ impl Game<'_> {
step_height,
on_ground,
tnt.as_ref().map(|t| t.3),
&entities,
)
.unwrap_or(Vec3::ZERO)
});
@@ -771,9 +747,6 @@ impl Game<'_> {
|| limited.length_squared() - resolved.length_squared() < 1.0e-7;
if moved {
self.apply_push(target, resolved);
} else {
// A move the collisions cut to nothing still ends in the check.
self.release_after_block_move(target);
}
if let Some((i, from, ..)) = tnt {
self.finish_tnt_piston_move(i, from, resolved);
@@ -891,37 +864,26 @@ impl Game<'_> {
}
Target::Entity(handle) => {
with_entity_mut!(self, handle, entity => entity.move_by_piston(movement));
self.release_after_block_move(target);
let Some(bounds) =
with_entity!(self, handle, entity => entity.piston_release_bounds())
else {
return;
};
let no_block_collision = {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
no_collision_empty(&mut scene, bounds)
};
with_entity_mut!(self, handle, entity => {
entity.release_after_piston(no_block_collision)
});
}
}
}
/// `AbstractArrow.move` with a mover other than the arrow itself (a
/// piston, a shulker lid): after `Entity.move`, whether or not it went
/// anywhere, a stuck arrow or trident with nothing to collide with within
/// 0.06 starts falling (`shouldFall`, `startFalling`). Its own tick only
/// checks once the block at its position changes, a tick later.
fn release_after_block_move(&mut self, target: Target) {
let Target::Entity(handle) = target else {
return;
};
let Some(bounds) = with_entity!(self, handle, entity => entity.piston_release_bounds())
else {
return;
};
let no_block_collision = {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
no_collision_empty(&mut scene, bounds)
};
with_entity_mut!(self, handle, entity => {
entity.release_after_piston(no_block_collision)
});
}
/// `causeBounce`: a slime block sets the velocity along the movement axis.
fn bounce(&mut self, target: Target, movement: Direction) {
match target {
@@ -977,16 +939,19 @@ fn pushed_movement<W>(
step_height: f32,
on_ground: bool,
context: Option<CollisionContext<'_>>,
entities: &[Aabb],
) -> horizon_client::Result<Vec3>
where
W: BlockCollisionWorld<Error = std::io::Error> + EntityMovementWorld<Error = std::io::Error>,
{
// Without the entity's own context, the one `resolve` reads at its feet.
let context = context.unwrap_or_else(|| CollisionContext::position(before.min_position().y));
let mut collisions = EntityCollisions::with_border(blocks, border, at);
collisions.entities = entities;
collisions.resolve_with_context(before, movement, step_height, on_ground, context)
EntityCollisions::with_border(blocks, border, at).resolve_with_context(
before,
movement,
step_height,
on_ground,
context,
)
}
#[cfg(test)]
@@ -1015,7 +980,6 @@ mod tests {
0.0,
false,
None,
&[],
)
.expect("the push resolves");
// The wall stops the body 0.4 blocks short of the whole push.
@@ -1039,7 +1003,6 @@ mod tests {
0.0,
false,
None,
&[],
)
.expect("the push resolves");
assert!(
@@ -1558,48 +1521,6 @@ mod h255_mob_piston_ground_tests {
&mut std::collections::HashMap::new(),
);
}
/// `AbstractArrow.move` after a push the collisions cut to nothing: a stuck
/// arrow with nothing within 0.06 of it starts falling all the same.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_stuck_arrow_whose_push_a_wall_stops_falls_when_nothing_holds_it() {
with_game(|game| {
assert!(game.run_command("setblock 6 202 4 minecraft:stone"));
game.creatures.projectiles.clear();
assert!(game.run_command("summon minecraft:arrow 5.75 202.5 4.5 {NoGravity:0b}"));
let i = game
.creatures
.projectiles
.iter()
.position(|p| p.arrow.is_some())
.expect("the arrow");
{
let p = &mut game.creatures.projectiles[i];
p.velocity = Vec3::ZERO;
p.stuck = true;
p.arrow.as_mut().unwrap().in_ground = true;
}
let p = &game.creatures.projectiles[i];
let (bounds, at) = (p.bounds(), [p.position.x, p.position.z]);
let before = p.position;
game.move_pushed(
Target::Entity(EntityHandle::Projectile(i)),
bounds,
at,
0.0,
false,
Vec3::new(0.3, 0.0, 0.0),
Direction::East,
&mut std::collections::HashMap::new(),
);
let p = &game.creatures.projectiles[i];
assert_eq!(p.position, before, "the stone stops the push");
assert!(
!p.arrow.as_ref().unwrap().in_ground,
"nothing holds it, so it falls"
);
});
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_added_no_request_and_exhausted_budget_keep_state() {
@@ -1,334 +0,0 @@
//! Registered play-Ping regression with the production sender-flush completion boundary.
use crate::game::{Game, lan::LanSettings};
use horizon_client::lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig};
use horizon_protocol::{
buf::Reader,
packets::{
configuration::ClientInformation,
play::{Clientbound as C, Serverbound as S},
status,
},
};
use std::sync::{Arc, atomic::AtomicI32, mpsc};
fn decode(packet: S) -> (S, usize) {
let bytes = packet.encode();
let mut reader = Reader::new(&bytes);
let id = reader.varint().unwrap();
(S::decode(id, reader.rest()).unwrap(), bytes.len())
}
fn output(rx: &mpsc::Receiver<Out>) -> Vec<C> {
rx.try_iter()
.flat_map(|out| match out {
Out::Packet(body) => vec![body],
Out::Packets(bodies) => bodies,
Out::Disconnect(reason) => panic!("unexpected disconnect: {reason:?}"),
})
.map(|body| {
println!("actual outbound play body={body:02x?}");
let mut reader = Reader::new(&body);
let id = reader.varint().unwrap();
C::decode(id, reader.rest()).unwrap()
})
.collect()
}
fn pass(game: &mut Game<'_>, conn: u32, packet: S) {
let (packet, bytes) = decode(packet);
if let S::PingRequest(p) = &packet {
println!(
"actual decoded play PingRequest conn={conn} value={} bits={:016x}",
p.time, p.time as u64
);
}
let hub = &game.lan.as_ref().unwrap().hub;
assert_eq!(hub.guests.len(), 2);
assert_ne!(hub.guests[0].uuid, hub.guests[1].uuid);
assert_ne!(hub.guests[0].conn, hub.guests[1].conn);
let registered = hub.guest(conn).expect("origin connection registered");
assert_eq!(registered.uuid, u128::from(conn));
assert!(!registered.fake && !registered.sender.is_fake());
assert!(registered.left_reason.is_empty());
assert_eq!(registered.dimension, "minecraft:overworld");
let guest = game.lan.as_mut().unwrap().hub.guest_mut(conn).unwrap();
guest.inbox.push_back((packet, bytes));
guest.inbox_bytes += bytes;
// Already registered connections: this is a second level pass, without polling sockets.
game.level_pass.first = false;
game.level_pass.packets_drained = false;
let clock_before = game.world.game_time;
game.tick_lan_packets();
// tick_lan_packets has ended its Holding scope; production flushes these senders later.
horizon_client::lan::flush_all(
game.lan
.as_ref()
.unwrap()
.hub
.guests
.iter()
.map(|g| &g.sender),
);
assert_eq!(
game.world.game_time, clock_before,
"sender completion adds no world tick"
);
println!("actual sender completion conn={conn} world_clock={clock_before}");
assert!(
game.level_pass.packets_drained,
"real Game packet phase completed"
);
assert!(
game.lan
.as_ref()
.unwrap()
.hub
.guest(conn)
.unwrap()
.inbox
.is_empty(),
"decoded request was drained by the actual Hub"
);
}
fn fixture() -> (Game<'static>, [mpsc::Receiver<Out>; 2]) {
let mut game = crate::game::native_machines::tests::server_game();
game.build_registries();
game.lan_host(
LanSettings {
port: 0,
guest_commands: false,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "play-ping-test".into(), 2)
},
HubConfig {
default_game_mode: 0,
guest_commands: false,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 2,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let receivers = [1, 2].map(|conn| {
let (tx, rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn,
uuid: u128::from(conn),
name: format!("PingGuest{conn}"),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
conn as i32,
[0.5, 80.0, 0.5],
2,
);
guest.placed = true;
guest.loaded = true;
guest.loading = false;
guest.load_timeout = 0;
guest.last_action_tick = 7;
game.lan.as_mut().unwrap().hub.guests.push(guest);
rx
});
game.lan.as_mut().unwrap().hub.ticks = 123;
(game, receivers)
}
#[test]
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
fn registered_play_ping_echoes_exact_long_to_only_its_connection() {
let (mut game, receivers) = fixture();
for (turn, time) in [
1,
0,
-1,
i64::MIN,
i64::MAX,
0x0123_4567_89ab_cdef,
0xfedc_ba98_7654_3210_u64 as i64,
]
.into_iter()
.enumerate()
{
let index = turn % 2;
let conn = index as u32 + 1;
pass(&mut game, conn, S::PingRequest(status::Ping { time }));
let packets = output(&receivers[index]);
let echoed: Vec<_> = packets
.iter()
.filter_map(|p| match p {
C::PongResponse(p) => Some(p.time),
_ => None,
})
.collect();
assert_eq!(
echoed,
vec![time],
"missing or incorrect registered Game play-Ping reply"
);
assert_eq!(packets.len(), 1, "one request produces exactly one reply");
assert_eq!(
game.lan
.as_ref()
.unwrap()
.hub
.guest(conn)
.unwrap()
.last_action_tick,
7,
"transport ping does not count as a player action"
);
assert!(
output(&receivers[1 - index]).is_empty(),
"no reply to the other connection"
);
}
}
#[test]
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
fn dead_guest_play_ping_is_transport_not_an_action() {
let (mut game, receivers) = fixture();
game.lan.as_mut().unwrap().hub.guest_mut(1).unwrap().dead = true;
pass(
&mut game,
1,
S::PingRequest(status::Ping { time: i64::MIN }),
);
let packets = output(&receivers[0]);
assert!(matches!(packets.as_slice(), [C::PongResponse(p)] if p.time == i64::MIN));
assert!(output(&receivers[1]).is_empty());
assert_eq!(
game.lan
.as_ref()
.unwrap()
.hub
.guest(1)
.unwrap()
.last_action_tick,
7
);
}
#[test]
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
fn server_ping_client_pong_keeps_the_existing_prediction_route() {
let (mut game, receivers) = fixture();
let hub = &mut game.lan.as_mut().unwrap().hub;
let mut config = horizon_anticheat::Config::default();
config.log.clear();
config.strikes.clear();
config.prediction.enabled = true;
hub.set_anticheat(config);
hub.ticks = 123;
let holding = horizon_client::lan::Holding::start();
hub.anticheat_ping();
drop(holding);
// Ending Holding is not delivery: independently admit the existing flush boundary first.
for receiver in &receivers {
assert!(
output(receiver).is_empty(),
"actual server Ping is unavailable before flush"
);
}
println!("actual reverse Ping unavailable after Holding drop, before flush");
horizon_client::lan::flush_all(hub.guests.iter().map(|g| &g.sender));
for receiver in &receivers {
assert!(matches!(output(receiver).as_slice(), [C::Ping(p)] if p.id == 123));
}
pass(
&mut game,
1,
S::Pong(horizon_protocol::packets::configuration::Ping { id: 123 }),
);
let record = game
.lan
.as_mut()
.unwrap()
.hub
.guest_mut(1)
.unwrap()
.anticheat
.record
.as_deref_mut()
.expect("existing client Pong updates prediction");
assert_eq!(record.tick_end(0, false).seen, Some(123));
for receiver in &receivers {
assert!(output(receiver).is_empty());
}
}
#[test]
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
fn held_guest_play_ping_survives_the_acknowledgement_filter() {
let (mut game, receivers) = fixture();
let guest = game.lan.as_mut().unwrap().hub.guest_mut(1).unwrap();
guest.placed = false;
guest.held = true;
pass(&mut game, 1, S::PingRequest(status::Ping { time: -1 }));
assert!(matches!(output(&receivers[0]).as_slice(), [C::PongResponse(p)] if p.time == -1));
assert!(output(&receivers[1]).is_empty());
assert_eq!(
game.lan
.as_ref()
.unwrap()
.hub
.guest(1)
.unwrap()
.last_action_tick,
7
);
}
#[test]
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
fn handover_guest_play_ping_survives_the_acknowledgement_filter() {
let (mut game, receivers) = fixture();
let handover = horizon_client::lan::arrivals::Handover {
id: 0x1234,
since: 123,
record: serde_json::json!({"fixture": "handover-state-sentinel"}),
};
game.lan
.as_mut()
.unwrap()
.hub
.guest_mut(1)
.unwrap()
.handover = Some(handover.clone());
pass(
&mut game,
1,
S::PingRequest(status::Ping {
time: 0xfedc_ba98_7654_3210_u64 as i64,
}),
);
let packets = output(&receivers[0]);
assert!(
matches!(packets.as_slice(), [C::PongResponse(p)]
if p.time == 0xfedc_ba98_7654_3210_u64 as i64),
"missing matching handover play-Ping reply"
);
assert!(output(&receivers[1]).is_empty());
let guest = game.lan.as_ref().unwrap().hub.guest(1).unwrap();
assert_eq!(guest.last_action_tick, 7);
assert_eq!(guest.handover.as_ref(), Some(&handover));
}
+2 -1
View File
@@ -11496,7 +11496,8 @@ impl Remote {
pub(super) fn listed_player_count(&self) -> usize {
self.listed.len()
}
/// Players in the server's player list, this one included (for the activity hooks).
/// Players in the server's player list, this one included (for Discord).
#[cfg(feature = "discord")]
pub(super) fn player_count(&self) -> usize {
self.players.len()
}
@@ -726,37 +726,6 @@ impl Game<'_> {
}
}
}
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
/// moves only while the world runs.
pub(super) fn visual_ticks(&self) -> u64 {
if self.clock.still() {
self.world.game_time.max(0) as u64
} else {
self.ticks
}
}
/// The clock of the animated textures: `Minecraft.runTick` ticks the `TextureManager`
/// only while the level runs normally. Under `HORIZON_STILL` it is the world's ticks.
pub(super) fn texture_clock(&self) -> u64 {
if self.clock.still() {
self.visual_ticks()
} else {
self.texture_ticks
}
}
/// `Util.getMillis` for what the frame shows (the world border's scroll, the hearts' blink,
/// the glint's under the switch): under `HORIZON_STILL`, the world's ticks in milliseconds.
pub(super) fn visual_millis(&self) -> i64 {
if self.clock.still() {
self.visual_ticks() as i64 * 50
} else {
self.started.elapsed().as_millis() as i64
}
}
}
impl Game<'_> {
/// What the packs' Polytone animation modes read this tick (wiki "Variant Textures"): the
@@ -306,6 +306,8 @@ 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)?;
+52 -91
View File
@@ -141,12 +141,11 @@ fn pause_buttons(
remote: bool,
links: bool,
additions: bool,
rows: &[(&'static str, u8)],
split: bool,
) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
let row3 = links || additions;
let extra = u8::from(row3) + rows.len() as u8;
let (x0, y0) = pause_grid(w, h, extra);
let down = 24.0 * f32::from(extra);
let (x0, y0) = pause_grid(w, h, u8::from(row3) + u8::from(split));
let down = 24.0 * f32::from(u8::from(row3) + u8::from(split));
let mut v = vec![
(x0 + 4.0, y0 + 50.0, 204.0, "menu.returnToGame", 0, true),
(x0 + 4.0, y0 + 74.0, 98.0, "gui.advancements", 4, true),
@@ -160,9 +159,9 @@ fn pause_buttons(
// CommonDialogData.computeExternalTitle of Dialogs.SERVER_LINKS.
v.push((x0 + 4.0, y0 + 122.0, 204.0, "menu.server_links", 11, true));
}
for (k, &(key, id)) in rows.iter().enumerate() {
let y = y0 + 122.0 + 24.0 * (f32::from(u8::from(row3)) + k as f32);
v.push((x0 + 4.0, y, 204.0, key, id, true));
if split {
let y = y0 + 122.0 + if row3 { 24.0 } else { 0.0 };
v.push((x0 + 4.0, y, 204.0, SPLIT_SETTINGS, 30, true));
}
v.extend([
(x0 + 4.0, y0 + 122.0 + down, 98.0, "menu.options", 1, true),
@@ -229,17 +228,14 @@ fn pause_icon_focus_ids(friends: bool) -> Vec<u8> {
.collect()
}
const SPLIT_SETTINGS: &str = "horizon.pages.split_settings";
/// The pause menu's first id for the pages crates registered there: id `ADDED_PAGES + i` opens
/// registered page `i`.
const ADDED_PAGES: u8 = 40;
fn pause_icons(
w: f32,
h: f32,
links: bool,
additions: bool,
rows: &[(&'static str, u8)],
split: bool,
) -> [(f32, f32, &'static str); 4] {
let (x0, y0) = pause_grid(w, h, u8::from(links || additions) + rows.len() as u8);
let (x0, y0) = pause_grid(w, h, u8::from(links || additions) + u8::from(split));
let (x0, y) = (x0 + 60.0, y0 + 98.0);
[
(x0, y, "gui/sprites/pause_menu/bug"),
@@ -311,7 +307,7 @@ impl Game<'_> {
self.remote.is_some() && self.integrated.is_none()
}
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
let friends = self.friends_shown();
let friends = self.friends_view().shown;
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
@@ -335,7 +331,7 @@ impl Game<'_> {
self.external_server(),
links,
additions.is_some(),
&self.pause_rows(),
self.options.split_guest || self.split_hud.menu,
) {
let is_focused = self.pause_focus == Some(id);
let hover = active && (ui::hovered(self.cursor, x, y, bw, 20.0) || is_focused);
@@ -362,9 +358,15 @@ impl Game<'_> {
tooltip = Some(Text::translate("menu.custom_options.tooltip", &[]));
}
}
for (i, (x, y, sprite)) in pause_icons(w, h, links, additions.is_some(), &self.pause_rows())
.into_iter()
.enumerate()
for (i, (x, y, sprite)) in pause_icons(
w,
h,
links,
additions.is_some(),
self.options.split_guest || self.split_hud.menu,
)
.into_iter()
.enumerate()
{
let active = icon_active(i, friends);
let is_focused = (self.pause_focus == Some(10) && i == FRIENDS_ICON)
@@ -373,7 +375,11 @@ impl Game<'_> {
// The icon has no label: name it for controller focus and the
// control socket's widget tree.
if i == FRIENDS_ICON {
horizon_client::widgets::record(super::friends::TITLE, [x, y, 20.0, 20.0], active);
horizon_client::widgets::record(
horizon_client::friends_screen::TITLE,
[x, y, 20.0, 20.0],
active,
);
}
ui::button(
batch,
@@ -410,7 +416,7 @@ impl Game<'_> {
batch.sprite_scaled(atlas, sprite, x + 3.0, y + 3.0, 15.0, 15.0, u32::MAX);
if hover {
tooltip = Some(if i == FRIENDS_ICON {
Text::literal(super::friends::title())
Text::literal(horizon_client::friends_screen::title())
} else {
Text::translate(ICON_LABELS[i], &[])
});
@@ -450,10 +456,6 @@ impl Game<'_> {
// PauseScreen: `new SocialInteractionsScreen(this)`.
15 => self.open_social(true),
30 => self.open_split_settings(),
id @ ADDED_PAGES.. => {
let page = usize::from(id - ADDED_PAGES);
self.open_options_at(false, horizon_client::options_ui::Page::Added(page));
}
// addCustomDialogButtons: DialogConnectionAccess.openDialog of the
// additions dialog, from the pause menu.
14 => {
@@ -469,23 +471,6 @@ impl Game<'_> {
}
Ok(())
}
/// The pause menu's full-width rows above Options: Splitscreen's settings, then the pages
/// crates registered for the pause menu (`horizon_hooks::pages`), by their title.
fn pause_rows(&self) -> Vec<(&'static str, u8)> {
let split = self.options.split_guest || self.split_hud.menu;
let pages = horizon_hooks::pages::engine::all().iter().enumerate();
let added = pages
.filter(|(i, page)| {
page.place() == horizon_hooks::pages::Place::Pause
&& *i < usize::from(u8::MAX - ADDED_PAGES)
})
.map(|(i, page)| (page.title(), ADDED_PAGES + i as u8));
split
.then_some((SPLIT_SETTINGS, 30))
.into_iter()
.chain(added)
.collect()
}
pub(super) fn pause_click(&mut self) -> Result<()> {
let (w, h) = {
let size = self
@@ -497,7 +482,13 @@ impl Game<'_> {
};
let links = !self.server_links().is_empty();
let additions = self.pause_screen_additions();
let icons = pause_icons(w, h, links, additions.is_some(), &self.pause_rows());
let icons = pause_icons(
w,
h,
links,
additions.is_some(),
self.options.split_guest || self.split_hud.menu,
);
let friends = icons[FRIENDS_ICON];
let hit = pause_buttons(
w,
@@ -505,7 +496,7 @@ impl Game<'_> {
self.external_server(),
links,
additions.is_some(),
&self.pause_rows(),
self.options.split_guest || self.split_hud.menu,
)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
@@ -518,8 +509,9 @@ impl Game<'_> {
.map(|i| 12 + i as u8)
})
.or_else(|| {
(self.friends_shown() && ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
.then_some(10)
(self.friends_view().shown
&& ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
.then_some(10)
});
if let Some(id) = hit {
self.pause_focus = Some(id);
@@ -544,10 +536,10 @@ impl Game<'_> {
self.external_server(),
links,
self.pause_screen_additions().is_some(),
&self.pause_rows(),
self.options.split_guest || self.split_hud.menu,
);
let mut active_ids: Vec<u8> = buttons.into_iter().filter(|b| b.5).map(|b| b.4).collect();
let friends = self.friends_shown();
let friends = self.friends_view().shown;
active_ids.extend(pause_icon_focus_ids(friends));
if active_ids.is_empty() {
return Ok(());
@@ -592,20 +584,20 @@ impl Game<'_> {
self.external_server(),
links,
self.pause_screen_additions().is_some(),
&self.pause_rows(),
self.options.split_guest || self.split_hud.menu,
);
let mut rects: Vec<(u8, f32, f32, f32)> = buttons
.into_iter()
.filter(|b| b.5)
.map(|b| (b.4, b.0, b.1, b.2))
.collect();
let friends = self.friends_shown();
let friends = self.friends_view().shown;
for (i, (x, y, _)) in pause_icons(
w,
h,
links,
self.pause_screen_additions().is_some(),
&self.pause_rows(),
self.options.split_guest || self.split_hud.menu,
)
.into_iter()
.enumerate()
@@ -923,10 +915,7 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::{
ADDED_PAGES, SPLIT_SETTINGS, Screen, death_buttons, pause_buttons, pause_icon_focus_ids,
pause_icons,
};
use super::{Screen, death_buttons, pause_buttons, pause_icon_focus_ids, pause_icons};
#[test]
fn death_buttons_sit_on_whole_gui_pixels() {
@@ -1009,7 +998,7 @@ mod tests {
#[test]
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
let buttons = pause_buttons(427.0, 240.0, remote, false, false, &[]);
let buttons = pause_buttons(427.0, 240.0, remote, false, false, false);
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
assert_eq!(quit.3, label);
assert!(quit.5);
@@ -1034,14 +1023,14 @@ mod tests {
#[test]
fn server_links_adds_a_full_width_row_above_options() {
let with = pause_buttons(427.0, 240.0, true, true, false, &[]);
let without = pause_buttons(427.0, 240.0, true, false, false, &[]);
let with = pause_buttons(427.0, 240.0, true, true, false, false);
let without = pause_buttons(427.0, 240.0, true, false, false, false);
let links = with.iter().find(|b| b.4 == 11).unwrap();
assert_eq!(links.2, 204.0);
assert_eq!(links.3, "menu.server_links");
assert!(links.5);
// The icon row sits above it; Options and Disconnect move down 24.
let icons = pause_icons(427.0, 240.0, true, false, &[]);
let icons = pause_icons(427.0, 240.0, true, false, false);
let y0_icons = ((240.0f32 - 190.0) * 0.25).floor() + 98.0;
assert_eq!(icons[0].1, y0_icons);
assert!(links.1 > icons[0].1);
@@ -1057,8 +1046,8 @@ mod tests {
fn pause_screen_additions_takes_the_server_links_row() {
// getCustomAdditions: one additions button in row 3, never next to the
// links button, so the grid keeps one extra row either way.
let with = pause_buttons(427.0, 240.0, true, true, true, &[]);
let links = pause_buttons(427.0, 240.0, true, true, false, &[]);
let with = pause_buttons(427.0, 240.0, true, true, true, false);
let links = pause_buttons(427.0, 240.0, true, true, false, false);
assert!(with.iter().all(|b| b.4 != 11));
let button = with.iter().find(|b| b.4 == 14).unwrap();
assert_eq!(
@@ -1071,42 +1060,14 @@ mod tests {
assert_eq!(add.1, link.1);
}
assert_eq!(
pause_icons(427.0, 240.0, true, true, &[])[0].1,
pause_icons(427.0, 240.0, true, false, &[])[0].1
pause_icons(427.0, 240.0, true, true, false)[0].1,
pause_icons(427.0, 240.0, true, false, false)[0].1
);
}
#[test]
fn registered_pages_stack_above_options_in_the_pause_menu() {
let rows = [
(SPLIT_SETTINGS, 30),
("test.page.a", ADDED_PAGES),
("test.page.b", ADDED_PAGES + 2),
];
let buttons = pause_buttons(427.0, 300.0, true, false, false, &rows);
let y = |id| buttons.iter().find(|b| b.4 == id).unwrap().1;
assert_eq!(y(ADDED_PAGES), y(30) + 24.0);
assert_eq!(y(ADDED_PAGES + 2), y(ADDED_PAGES) + 24.0);
assert_eq!(
y(1),
y(ADDED_PAGES + 2) + 24.0,
"Options under the last one"
);
// The grid stays centred: three more rows start it higher.
let without = pause_buttons(427.0, 300.0, true, false, false, &[]);
let back = |b: &[(f32, f32, f32, &str, u8, bool)]| b.iter().find(|b| b.4 == 0).unwrap().1;
assert!(back(&buttons) < back(&without));
}
#[test]
fn split_settings_preserves_dialog_rows_and_icon_actions() {
for (links, additions) in [(false, false), (true, false), (false, true), (true, true)] {
let buttons = pause_buttons(
427.0,
300.0,
true,
links,
additions,
&[(SPLIT_SETTINGS, 30)],
);
let buttons = pause_buttons(427.0, 300.0, true, links, additions, true);
let split = buttons.iter().find(|b| b.4 == 30).unwrap();
let options = buttons.iter().find(|b| b.4 == 1).unwrap();
assert_eq!(options.1, split.1 + 24.0);
@@ -1119,7 +1080,7 @@ mod tests {
}
assert!(buttons.iter().all(|b| b.4 != 12 && b.4 != 13));
assert!(
!pause_buttons(427.0, 300.0, true, links, additions, &[])
!pause_buttons(427.0, 300.0, true, links, additions, false)
.iter()
.any(|b| b.4 == 30)
);
@@ -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();
+1 -1
View File
@@ -6,10 +6,10 @@
//! the time left. Vanilla has no such commands: they reach operators' command trees (completions included)
//! as a native mod's commands do (`moderation_commands`), and the game runs them itself.
use super::{Game, chat_filter::Reply, dedicated, lan::LanHost};
use horizon_chat_filter::{duration_text, now_millis, parse_duration};
use horizon_client::{mod_api::NativeCommand, text::Text};
use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_protocol::text::translatable;
use horizon_server_files::duration::{duration_text, now_millis, parse_duration};
use horizon_server_files::user_lists::{BanEntry, BanList, NameAndId, format_date};
/// The operator level `/tempban` needs, as `/ban`'s.
@@ -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(
+56
View File
@@ -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,
}),
});
}
+1 -171
View File
@@ -504,69 +504,6 @@ impl ServerAdvancements {
w.f32(r["experience"].as_f64().unwrap_or(0.0) as f32);
(cooking_category(kind, r), vec![&r["ingredient"]])
}
// DyeRecipe.display: the target, then the dyes kept to those with
// the DYE component, shown as the demo of dyeing that target.
// Its placement is the target and the dye.
"minecraft:crafting_dye" => {
let Some(dye_component) = sync.id("minecraft:data_component_type", "minecraft:dye")
else {
return Vec::new();
};
let (target, dye) = (&r["target"], &r["dye"]);
w.varint(
sync.id("minecraft:recipe_display", "minecraft:crafting_shapeless")
.unwrap_or(0),
);
w.varint(2);
w.varint(slot("minecraft:tag"));
holder_set(&mut w, target, &item);
w.varint(slot("minecraft:only_with_component"));
w.varint(slot("minecraft:tag"));
holder_set(&mut w, dye, &item);
w.varint(dye_component);
w.varint(slot("minecraft:dyed"));
w.varint(slot("minecraft:only_with_component"));
w.varint(slot("minecraft:tag"));
holder_set(&mut w, dye, &item);
w.varint(dye_component);
w.varint(slot("minecraft:tag"));
holder_set(&mut w, target, &item);
station(&mut w, "minecraft:crafting_table");
(
crafting_category(r["category"].as_str().unwrap_or("")).name(),
vec![target, dye],
)
}
// ImbueRecipe.display: four materials, the source kept to any
// potion, four materials again, and the potion-wrapped result.
// Its placement names the source in the middle cell.
"minecraft:crafting_imbue" => {
let (source, material) = (&r["source"], &r["material"]);
w.varint(
sync.id("minecraft:recipe_display", "minecraft:crafting_shaped")
.unwrap_or(0),
);
w.varint(3);
w.varint(3);
w.varint(9);
for cell in 0..9 {
if cell == 4 {
w.varint(slot("minecraft:with_any_potion"));
}
w.varint(slot("minecraft:tag"));
holder_set(&mut w, if cell == 4 { source } else { material }, &item);
}
w.varint(slot("minecraft:with_any_potion"));
result(&mut w);
station(&mut w, "minecraft:crafting_table");
(
crafting_category(r["category"].as_str().unwrap_or("")).name(),
vec![
material, material, material, material, source, material, material,
material, material,
],
)
}
_ => return Vec::new(),
};
self.entry_tail(&mut w, r, sync, category, &placed, flags);
@@ -819,15 +756,7 @@ mod tests {
"minecraft:redstone_ore": {"protocol_id": 2},
"minecraft:redstone": {"protocol_id": 3},
"minecraft:furnace": {"protocol_id": 4},
"minecraft:blast_furnace": {"protocol_id": 5},
"minecraft:leather_helmet": {"protocol_id": 6},
"minecraft:crafting_table": {"protocol_id": 7},
"minecraft:arrow": {"protocol_id": 8},
"minecraft:lingering_potion": {"protocol_id": 9},
"minecraft:tipped_arrow": {"protocol_id": 10}
}},
"minecraft:data_component_type": {"entries": {
"minecraft:dye": {"protocol_id": 46}
"minecraft:blast_furnace": {"protocol_id": 5}
}},
"minecraft:slot_display": {"entries": {
"minecraft:empty": {"protocol_id": 0},
@@ -1033,105 +962,6 @@ mod tests {
std::fs::remove_dir_all(&root).unwrap();
}
/// DyeRecipe.display: the target, then the dyes kept to what carries the
/// DYE component, the demo of dyeing the target with one, a crafting
/// table; its placement is the target and the dye.
#[test]
fn a_dye_recipe_reaches_the_book_as_its_dyed_demo() {
let root = resources(
"dye_recipe",
&[(
"leather_helmet_dyed",
r##"{"type":"minecraft:crafting_dye","dye":"#minecraft:dyes",
"group":"dyed_armor","result":{"id":"minecraft:leather_helmet"},
"target":"minecraft:leather_helmet"}"##,
)],
);
let (sync, e) = entry(&root, "minecraft:leather_helmet_dyed");
let item = |n: &str| sync.id("minecraft:item", n).unwrap();
let leather = item("minecraft:leather_helmet");
let dye = SlotDisplay::OnlyWithComponent(
Box::new(SlotDisplay::Tag(HolderSet::Tag("minecraft:dyes".into()))),
sync.id("minecraft:data_component_type", "minecraft:dye")
.unwrap(),
);
assert_eq!(
e.display,
RecipeDisplay::Shapeless {
ingredients: vec![
SlotDisplay::Tag(HolderSet::Items(vec![leather])),
dye.clone(),
],
result: SlotDisplay::Dyed {
dye: Box::new(dye),
target: Box::new(SlotDisplay::Tag(HolderSet::Items(vec![leather]))),
},
station: SlotDisplay::Item(item("minecraft:crafting_table")),
}
);
assert_eq!(
e.requirements,
Some(vec![
HolderSet::Items(vec![leather]),
HolderSet::Tag("minecraft:dyes".into()),
])
);
let _ = std::fs::remove_dir_all(&root);
}
/// ImbueRecipe.display: a 3×3 of four materials, the source kept to any
/// potion, four materials again; the result is potion-wrapped, and the
/// source sits in the middle of the placement.
#[test]
fn an_imbue_recipe_reaches_the_book_with_any_potion() {
let root = resources(
"imbue_recipe",
&[(
"tipped_arrow",
r#"{"type":"minecraft:crafting_imbue","material":"minecraft:arrow",
"result":{"count":8,"id":"minecraft:tipped_arrow"},
"source":"minecraft:lingering_potion"}"#,
)],
);
let (sync, e) = entry(&root, "minecraft:tipped_arrow");
let item = |n: &str| sync.id("minecraft:item", n).unwrap();
let material = SlotDisplay::Tag(HolderSet::Items(vec![item("minecraft:arrow")]));
let source = SlotDisplay::Tag(HolderSet::Items(vec![item("minecraft:lingering_potion")]));
let mut cells = vec![material.clone(); 4];
cells.push(SlotDisplay::WithAnyPotion(Box::new(source.clone())));
cells.extend(std::iter::repeat_n(material, 4));
assert_eq!(
e.display,
RecipeDisplay::Shaped {
width: 3,
height: 3,
ingredients: cells,
result: SlotDisplay::WithAnyPotion(Box::new(SlotDisplay::ItemStack(Stack::new(
item("minecraft:tipped_arrow"),
8,
)))),
station: SlotDisplay::Item(item("minecraft:crafting_table")),
}
);
let arrow = HolderSet::Items(vec![item("minecraft:arrow")]);
let potion = HolderSet::Items(vec![item("minecraft:lingering_potion")]);
assert_eq!(
e.requirements,
Some(vec![
arrow.clone(),
arrow.clone(),
arrow.clone(),
arrow.clone(),
potion,
arrow.clone(),
arrow.clone(),
arrow.clone(),
arrow,
])
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_recipe_with_an_unknown_ingredient_kind_stays_out_of_the_book() {
let root = resources(
+36 -5
View File
@@ -38,7 +38,6 @@ use horizon_world::{
};
use std::sync::{Arc, Mutex, atomic::Ordering};
mod acknowledgements;
/// GameEvent types (ClientboundGameEventPacket).
pub mod chat;
pub mod chat_filter;
@@ -2417,6 +2416,41 @@ impl Hub {
events
}
/// Keeps, in a frozen player's inbox, the packets that answer the server
/// (teleport, chunk batch, loaded, keep-alive, ping, tick end, client
/// information) and drops the rest. A chat message or signed command is
/// dropped too, but the messages it says the client has seen are taken
/// first (`unpackAndApplyLastSeen`), and a chat acknowledgement is handled
/// here, in the order they came: the client counts them as acknowledged,
/// and its next message after the freeze would not validate otherwise.
pub(crate) fn keep_acknowledgements(&mut self, i: usize, events: &mut Vec<HostEvent>) {
let inbox = std::mem::take(&mut self.guests[i].inbox);
let mut bytes = 0;
for (packet, size) in inbox {
match &packet {
S::Chat(c) => {
self.apply_last_seen(i, &c.last_seen, events);
}
S::ChatCommandSigned(c) => {
self.apply_last_seen(i, &c.last_seen, events);
}
S::ChatAck(c) => self.chat_ack(i, c.offset, events),
S::AcceptTeleportation(_)
| S::PlayerLoaded(_)
| S::ChunkBatchReceived(_)
| S::ClientInformation(_)
| S::ClientTickEnd(_)
| S::KeepAlive(_)
| S::Pong(_) => {
bytes += size;
self.guests[i].inbox.push_back((packet, size));
}
_ => {}
}
}
self.guests[i].inbox_bytes = bytes;
}
/// Handle the players in this level's packets (`ServerGamePacketListenerImpl`
/// for each), before the level's scheduled block ticks so a place landed in
/// the same tick as a falling block's schedule still sees the support
@@ -4035,8 +4069,7 @@ impl Hub {
| S::RecipeBookSeenRecipe(_)
| S::ClientTickEnd(_)
| S::KeepAlive(_)
| S::Pong(_)
| S::PingRequest(_) => false,
| S::Pong(_) => false,
S::MovePlayerPos(p) => self.moved_far(i, [p.x, p.y, p.z]),
S::MovePlayerPosRot(p) => self.moved_far(i, [p.x, p.y, p.z]),
S::PlayerInput(_) => self.guests[i].client_loaded(),
@@ -4134,8 +4167,6 @@ impl Hub {
g.max_unacknowledged = 10;
}
S::Pong(p) => self.anticheat_pong(i, p.id),
// ServerGamePacketListenerImpl.handlePingRequest: echo on this connection.
S::PingRequest(p) => self.send_to(conn, &C::PongResponse(p)),
// handleAttack is the game's too: a move queued behind an attack would move
// the player (or land them on the ground, losing a falling attack's critical
// hit) before the game checks their reach and state.
@@ -1,40 +0,0 @@
//! Transport acknowledgements retained while a guest is held or handed over.
use super::*;
impl Hub {
/// Keeps, in a frozen player's inbox, the packets that answer the server
/// (teleport, chunk batch, loaded, keep-alive, ping, tick end, client
/// information) and drops the rest. A chat message or signed command is
/// dropped too, but the messages it says the client has seen are taken
/// first (`unpackAndApplyLastSeen`), and a chat acknowledgement is handled
/// here, in the order they came: the client counts them as acknowledged,
/// and its next message after the freeze would not validate otherwise.
pub(crate) fn keep_acknowledgements(&mut self, i: usize, events: &mut Vec<HostEvent>) {
let inbox = std::mem::take(&mut self.guests[i].inbox);
let mut bytes = 0;
for (packet, size) in inbox {
match &packet {
S::Chat(c) => {
self.apply_last_seen(i, &c.last_seen, events);
}
S::ChatCommandSigned(c) => {
self.apply_last_seen(i, &c.last_seen, events);
}
S::ChatAck(c) => self.chat_ack(i, c.offset, events),
S::AcceptTeleportation(_)
| S::PlayerLoaded(_)
| S::ChunkBatchReceived(_)
| S::ClientInformation(_)
| S::ClientTickEnd(_)
| S::KeepAlive(_)
| S::Pong(_)
| S::PingRequest(_) => {
bytes += size;
self.guests[i].inbox.push_back((packet, size));
}
_ => {}
}
}
self.guests[i].inbox_bytes = bytes;
}
}
+1
View File
@@ -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)]
+12 -48
View File
@@ -2362,13 +2362,19 @@ fn never_removed_when_far(s: Species) -> bool {
| Species::Wither
)
}
/// The movement speed multiplier of the mob's transient `minecraft:baby`
/// modifier (`Zombie.SPEED_MODIFIER_BABY` +0.5, `Piglin.SPEED_MODIFIER_BABY`
/// +0.2, both `ADD_MULTIPLIED_BASE`): the same modifier `/attribute` reads, so
/// one taken away with `modifier remove` no longer speeds the baby up.
/// Movement speed bonus multiplier (baby zombies are 50% faster, etc.).
pub fn speed_bonus(m: &Mob) -> f64 {
match super::finalize::transient(m, super::finalize::Attr::MovementSpeed) {
Some((amount, super::finalize::Op::AddMultipliedBase)) => 1.0 + amount,
match m.species() {
Species::Zombie
| Species::Husk
| Species::Drowned
| Species::ZombieVillager
| Species::ZombifiedPiglin
| Species::Piglin
if m.is_baby() =>
{
1.5
}
_ => 1.0,
}
}
@@ -6284,45 +6290,3 @@ mod hurt_teleport_contact_tests {
);
}
}
#[cfg(test)]
mod baby_speed_tests {
use super::*;
use crate::mobs::{finalize, sensor_scan_tests::kind};
use horizon_core::geometry::Vec3;
use std::sync::Arc;
fn baby(species: Species, id: &str) -> Mob {
let mut k = kind();
let km = Arc::make_mut(&mut k);
km.id = id.into();
km.species = species;
let mut m = Mob::new(1, k, Vec3::ZERO, 0.0);
finalize::set_baby(&mut m, true);
m
}
/// #11994: movement reads the transient `minecraft:baby` modifier itself:
/// `Zombie.SPEED_MODIFIER_BABY` +0.5 and `Piglin.SPEED_MODIFIER_BABY` +0.2
/// (`ADD_MULTIPLIED_BASE`), and none once `/attribute ... modifier remove`
/// took it away, until the next `setBaby`.
#[test]
fn a_babys_speed_follows_its_baby_modifier() {
let mut zombie = baby(Species::Zombie, "minecraft:zombie");
assert_eq!(speed_bonus(&zombie), 1.5);
zombie
.extra
.attributes
.get_or_insert_default()
.baby_speed_removed = true;
assert_eq!(speed_bonus(&zombie), 1.0, "removed with /attribute");
finalize::set_baby(&mut zombie, true);
assert_eq!(speed_bonus(&zombie), 1.5, "setBaby adds it back");
// The piglin's is 0.2F widened to a double, not the zombie's 0.5.
let piglin = baby(Species::Piglin, "minecraft:piglin");
assert_eq!(speed_bonus(&piglin), 1.0 + f64::from(0.2f32));
let mut adult = baby(Species::Zombie, "minecraft:zombie");
finalize::set_baby(&mut adult, false);
assert_eq!(speed_bonus(&adult), 1.0);
}
}
+2 -64
View File
@@ -1218,13 +1218,8 @@ impl Goal for Wander {
pub fn stable_destination(w: &MobWorld, p: [i32; 3]) -> bool {
!w.facts.is_air(w.world.block(p[0], p[1] - 1, p[2]))
}
/// `Bee.addAdditionalSaveData` (and NeutralMob's `anger_end_time` and
/// `angry_at`).
pub fn to_nbt(
m: &Mob,
map: &mut horizon_world::nbt::Compound,
uuid_of: &mut impl FnMut(u32) -> Option<[i32; 4]>,
) {
/// `Bee.addAdditionalSaveData` (and NeutralMob's `anger_end_time`).
pub fn to_nbt(m: &Mob, map: &mut horizon_world::nbt::Compound) {
use horizon_world::nbt::Tag;
let b = get(m).cloned().unwrap_or_default();
for (key, pos) in [("hive_pos", b.hive), ("flower_pos", b.flower)] {
@@ -1246,20 +1241,6 @@ pub fn to_nbt(
map.insert("CannotEnterHiveTicks".into(), Tag::Int(b.stay_out_of_hive));
map.insert("CropsGrownSincePollination".into(), Tag::Int(b.crops_grown));
map.insert("anger_end_time".into(), Tag::Long(b.anger_end));
// `NeutralMob.addPersistentAngerSaveData`: `storeNullable`, nothing when
// the bee has no target. A mob target read from the save but not in the
// level (`Who::Mob(0)`) keeps the loaded UUID.
match b.angry_at {
Some(Who::Mob(0)) => {}
target => match target.and_then(|who| crate::entity_nbt::who_uuid(who, uuid_of)) {
Some(uuid) => {
map.insert("angry_at".into(), Tag::IntArray(uuid.to_vec()));
}
None => {
map.remove(&"angry_at".into());
}
},
}
}
/// `Bee.readAdditionalSaveData`.
pub fn from_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
@@ -1279,13 +1260,6 @@ pub fn from_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
Some(Tag::Long(v)) => *v,
_ => -1,
};
// `EntityReference.read`: the player's UUID is the player, any other one a
// mob the level may not have loaded (`Who::Mob(0)`).
let angry_at = match crate::save::uuid_from_tag(tag.get("angry_at")) {
Some(uuid) if uuid == crate::entity_nbt::player_uuid() => Some(Who::Player),
Some(_) => Some(Who::Mob(0)),
None => None,
};
let b = st(m);
b.ticks_without_nectar = int("TicksSincePollination").unwrap_or(0);
b.stay_out_of_hive = int("CannotEnterHiveTicks").unwrap_or(0);
@@ -1293,7 +1267,6 @@ pub fn from_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
b.hive = pos("hive_pos");
b.flower = pos("flower_pos");
b.anger_end = anger_end;
b.angry_at = angry_at;
}
#[cfg(test)]
@@ -1446,39 +1419,4 @@ mod tests {
}
});
}
/// `NeutralMob.addPersistentAngerSaveData` and
/// `readPersistentAngerSaveData`: `anger_end_time` and `angry_at`, the
/// reference to the player the bee is angry at (`Bee` keeps the anger
/// itself). `mob_to_nbt`/`mob_from_nbt` are the pair `Game::mob_tree_nbt`
/// and `Game::load_mob` go through. Horizon modelled the target in
/// `Bee.angry_at` and wrote only the timer, so a bee that had stung a
/// player came back with its anger still counting but no one to attack.
#[test]
fn a_bees_anger_target_survives_a_save() {
use crate::entity_nbt::{mob_from_nbt, mob_to_nbt, player_uuid};
use horizon_world::nbt::Tag;
let mut m = bee();
{
let b = super::st(&mut m);
b.anger_end = 500;
b.angry_at = Some(Who::Player);
}
let saved = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
assert_eq!(
saved.get("angry_at"),
Some(&Tag::IntArray(player_uuid().to_vec())),
"the save names the player"
);
let types = std::collections::HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
let back = mob_from_nbt(&saved, &types, 2, None).unwrap();
let b = super::get(&back).unwrap();
assert_eq!(b.angry_at, Some(Who::Player), "the reloaded target");
assert_eq!(b.anger_end, 500, "the reloaded timer");
// `storeNullable`: a calm bee writes no `angry_at`, even over a
// compound that still names one.
let calm = bee();
let saved = mob_to_nbt(&calm, [1, 2, 3, 4], Some(&saved), None, |_| None);
assert_eq!(saved.get("angry_at"), None, "no target, no key");
}
}
+14 -20
View File
@@ -151,26 +151,20 @@ pub fn damage_bonus(levels: &[(Arc<str>, u32)], target: Option<Species>) -> f32
/// to the attribute map when the item is equipped.
pub fn attack_damage(m: &Mob, items: Option<&crate::items::Items>) -> f32 {
let base = f64::from(m.attrs.attack_damage);
let modifiers = match items {
Some(items) => m.extra.main_hand_stack().map_or_else(
|| {
m.extra
.main_hand
.as_deref()
.and_then(|id| items.get(id))
.map(|i| i.modifiers.clone())
.unwrap_or_default()
},
|stack| items.attribute_modifiers(stack),
),
None => Vec::new(),
};
super::equip::attribute_with(
&modifiers,
"minecraft:attack_damage",
base,
super::equip::Slot::MainHand,
) as f32
let modifiers = m
.extra
.main_hand
.as_deref()
.and_then(|id| items?.get(id))
.map(|i| i.modifiers.as_slice());
modifiers.map_or(base, |modifiers| {
super::equip::attribute_with(
modifiers,
"minecraft:attack_damage",
base,
super::equip::Slot::MainHand,
)
}) as f32
}
/// The extra damage of the mob's held weapon against a target species.
pub fn melee_bonus(m: &Mob, target: Option<Species>) -> f32 {
-21
View File
@@ -830,12 +830,6 @@ impl Mob {
let length = moved.x * moved.x + moved.y * moved.y + moved.z * moved.z;
let wanted_length = wanted.x * wanted.x + wanted.y * wanted.y + wanted.z * wanted.z;
if length > 1.0e-7 || wanted_length - length < 1.0e-7 {
// A move of a block or more through water or a reset block (a
// vine, a ladder) resets the fall before the move's drop counts.
if self.fall_distance != 0.0 && length >= 1.0 && fall_reset_hit(w, self.position, moved)
{
self.fall_distance = 0.0;
}
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
@@ -2195,18 +2189,3 @@ impl RestitutionWorld for MobRestitution<'_, '_> {
#[cfg(test)]
#[path = "living_tests.rs"]
mod tests;
/// `Entity.move`'s fall-reset ray for a mob, with the world's
/// `minecraft:fall_damage_resetting` blocks.
fn fall_reset_hit(w: &MobWorld, from: Vec3, moved: Vec3) -> bool {
let reset = w.block_tags.get("minecraft:fall_damage_resetting");
crate::entities::fall_reset_hit(
w.world,
w.fluid_tables,
|p| {
let name = &w.facts.get(w.world.block(p.x, p.y, p.z)).name;
reset.is_some_and(|t| t.contains(&**name))
},
from,
moved,
)
}
@@ -90,11 +90,6 @@ pub fn is_food(m: &Mob, item: &str, taming: &[String]) -> bool {
m.kind.food.iter().any(|t| t == item)
}
}
/// `TamableAnimal.feed`'s heal: twice the food's nutrition, or 1 for an item
/// without a food component (a fish bucket).
pub fn feed_heal(nutrition: Option<i32>) -> f32 {
nutrition.map_or(1.0, |n| 2.0 * n as f32)
}
/// The ambient, hurt and death sounds by age and whether it is under water.
pub fn sounds(m: &Mob) -> [&'static str; 3] {
let under = m.fluids.eye_in_water;
@@ -1127,22 +1122,3 @@ mod cooldown_memory_tests {
);
}
}
#[cfg(test)]
mod feed_heal_tests {
use super::feed_heal;
#[test]
fn heals_twice_the_nutrition_of_vanilla_foods() {
// Foods.java: cod 2, pufferfish 1, cooked cod 5, cooked salmon 6.
assert_eq!(feed_heal(Some(2)), 4.0);
assert_eq!(feed_heal(Some(1)), 2.0);
assert_eq!(feed_heal(Some(5)), 10.0);
assert_eq!(feed_heal(Some(6)), 12.0);
}
#[test]
fn item_without_food_component_heals_one() {
assert_eq!(feed_heal(None), 1.0);
}
}
+10 -202
View File
@@ -48,144 +48,39 @@ impl SeenGrid {
return;
}
if let Some(list) = self.cells.get_mut(&old)
&& let Ok(k) = list.binary_search(&(i as u32))
&& let Some(k) = list.iter().position(|&j| j == i as u32)
{
list.remove(k);
list.swap_remove(k);
}
let list = self.cells.entry(at).or_default();
let idx = match list.binary_search(&(i as u32)) {
Ok(k) | Err(k) => k,
};
list.insert(idx, i as u32);
self.cells.entry(at).or_default().push(i as u32);
}
/// The indices, ascending, of every entry whose box can meet `area`
/// (a superset: callers still test it). False when the area spans more
/// columns than the grid has entries (at least 64), or is not finite:
/// scan them all.
/// columns than the grid holds (or is not finite): scan them all.
pub fn candidates(&self, area: Aabb, out: &mut Vec<u32>) -> bool {
out.clear();
// A box meets `area` only if its minimum corner is below area.max
// and within `reach` (plus a margin for rounding) of area.min.
let margin = self.reach + 1.0;
let min_x = area.min(Axis::X) - margin;
let min_z = area.min(Axis::Z) - margin;
let max_x = area.max(Axis::X);
let max_z = area.max(Axis::Z);
if !min_x.is_finite() || !min_z.is_finite() || !max_x.is_finite() || !max_z.is_finite() {
return false;
}
let lo = column(min_x, min_z);
let hi = column(max_x, max_z);
let lo = column(area.min(Axis::X) - margin, area.min(Axis::Z) - margin);
let hi = column(area.max(Axis::X), area.max(Axis::Z));
let span = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
let (nx, nz) = (span(lo.0, hi.0), span(lo.1, hi.1));
let max_cells = (self.cell_of.len() as i64).max(64);
if nx < 1 || nz < 1 || nx.saturating_mul(nz) > max_cells {
if nx < 1 || nz < 1 || nx.saturating_mul(nz) > self.cells.len() as i64 {
return false;
}
let mut slices = [&[][..]; 16];
let mut num_slices = 0;
let mut overflow = false;
for x in lo.0..=hi.0 {
for z in lo.1..=hi.1 {
if let Some(list) = self.cells.get(&(x, z))
&& !list.is_empty()
{
if num_slices < slices.len() {
slices[num_slices] = list.as_slice();
num_slices += 1;
} else {
overflow = true;
out.extend_from_slice(list);
}
if let Some(list) = self.cells.get(&(x, z)) {
out.extend_from_slice(list);
}
}
}
if overflow {
for s in &slices[..num_slices] {
out.extend_from_slice(s);
}
out.sort_unstable();
return true;
}
merge_slices(&slices[..num_slices], out);
out.sort_unstable();
true
}
}
fn merge_2(s0: &[u32], s1: &[u32], out: &mut Vec<u32>) {
out.reserve(s0.len() + s1.len());
let (mut i, mut j) = (0, 0);
while i < s0.len() && j < s1.len() {
if s0[i] < s1[j] {
out.push(s0[i]);
i += 1;
} else {
out.push(s1[j]);
j += 1;
}
}
out.extend_from_slice(&s0[i..]);
out.extend_from_slice(&s1[j..]);
}
fn merge_slices(slices: &[&[u32]], out: &mut Vec<u32>) {
match slices.len() {
0 => {}
1 => out.extend_from_slice(slices[0]),
2 => merge_2(slices[0], slices[1], out),
3 => {
let (mut i, mut j, mut k) = (0, 0, 0);
let (s0, s1, s2) = (slices[0], slices[1], slices[2]);
out.reserve(s0.len() + s1.len() + s2.len());
while i < s0.len() || j < s1.len() || k < s2.len() {
let v0 = if i < s0.len() { s0[i] } else { u32::MAX };
let v1 = if j < s1.len() { s1[j] } else { u32::MAX };
let v2 = if k < s2.len() { s2[k] } else { u32::MAX };
if v0 <= v1 && v0 <= v2 {
out.push(v0);
i += 1;
} else if v1 <= v2 {
out.push(v1);
j += 1;
} else {
out.push(v2);
k += 1;
}
}
}
4 => {
let (mut i, mut j, mut k, mut l) = (0, 0, 0, 0);
let (s0, s1, s2, s3) = (slices[0], slices[1], slices[2], slices[3]);
out.reserve(s0.len() + s1.len() + s2.len() + s3.len());
while i < s0.len() || j < s1.len() || k < s2.len() || l < s3.len() {
let v0 = if i < s0.len() { s0[i] } else { u32::MAX };
let v1 = if j < s1.len() { s1[j] } else { u32::MAX };
let v2 = if k < s2.len() { s2[k] } else { u32::MAX };
let v3 = if l < s3.len() { s3[l] } else { u32::MAX };
if v0 <= v1 && v0 <= v2 && v0 <= v3 {
out.push(v0);
i += 1;
} else if v1 <= v2 && v1 <= v3 {
out.push(v1);
j += 1;
} else if v2 <= v3 {
out.push(v2);
k += 1;
} else {
out.push(v3);
l += 1;
}
}
}
_ => {
for s in slices {
out.extend_from_slice(s);
}
out.sort_unstable();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -250,91 +145,4 @@ mod tests {
);
assert!(!grid.candidates(huge, &mut out));
}
#[test]
fn candidates_merges_multiple_cells_in_strict_order() {
// Place boxes across 4 adjacent grid cells: (0, 0), (4, 0), (0, 4), (4, 4)
// With interleaving snapshot indices to test 1, 2, 3, and 4-way merges.
let boxes = vec![
boxed(0.5, 64.0, 0.5, 0.6), // 0: cell (0, 0)
boxed(4.5, 64.0, 0.5, 0.6), // 1: cell (1, 0)
boxed(0.5, 64.0, 4.5, 0.6), // 2: cell (0, 1)
boxed(4.5, 64.0, 4.5, 0.6), // 3: cell (1, 1)
boxed(0.8, 64.0, 0.8, 0.6), // 4: cell (0, 0)
boxed(4.8, 64.0, 0.8, 0.6), // 5: cell (1, 0)
boxed(0.8, 64.0, 4.8, 0.6), // 6: cell (0, 1)
boxed(4.8, 64.0, 4.8, 0.6), // 7: cell (1, 1)
];
let grid = SeenGrid::new(boxes.into_iter());
let mut out = Vec::new();
// Query covering only cell (0, 0): indices 0, 4
let q1 = boxed(0.1, 64.0, 0.1, 1.5);
assert!(grid.candidates(q1, &mut out));
assert_eq!(out, vec![0, 4]);
// Query covering cells (0, 0) and (1, 0): indices 0, 1, 4, 5
let q2 = Aabb::new(Vec3::new(0.0, 64.0, 0.0), Vec3::new(6.0, 66.0, 1.5));
assert!(grid.candidates(q2, &mut out));
assert_eq!(out, vec![0, 1, 4, 5]);
// Query covering all 4 cells: indices 0..8 in order
let q4 = Aabb::new(Vec3::new(0.0, 64.0, 0.0), Vec3::new(6.0, 66.0, 6.0));
assert!(grid.candidates(q4, &mut out));
assert_eq!(out, vec![0, 1, 2, 3, 4, 5, 6, 7]);
}
#[test]
#[ignore]
fn bench_dense_farm() {
let n = 300;
let mut seed = 0x1234_5678_9abc_def0_u64;
let mut next = |range: f64| {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
(seed >> 11) as f64 / (1u64 << 53) as f64 * range
};
// 300 mobs in a 1.5 x 1.5 block pen around (3.5, 64.0, 3.5) crossing cell boundary at 4.0
let mut boxes: Vec<Aabb> = (0..n)
.map(|_| {
let x = 3.5 + next(1.5);
let z = 3.5 + next(1.5);
boxed(x, 64.0, z, 0.6)
})
.collect();
let mut grid = SeenGrid::new(boxes.iter().copied());
let mut out = Vec::new();
let t = std::time::Instant::now();
let rounds = 100;
let mut fallback_count = 0;
let mut query_dur = std::time::Duration::ZERO;
let mut update_dur = std::time::Duration::ZERO;
for _ in 0..rounds {
for i in 0..n {
let area = boxes[i];
let t0 = std::time::Instant::now();
if !grid.candidates(area, &mut out) {
fallback_count += 1;
out.clear();
out.extend(0..n as u32);
}
query_dur += t0.elapsed();
// small jitter
let x = 3.5 + next(1.5);
let z = 3.5 + next(1.5);
boxes[i] = boxed(x, 64.0, z, 0.6);
let t1 = std::time::Instant::now();
grid.update(i, boxes[i]);
update_dur += t1.elapsed();
}
}
let elapsed = t.elapsed();
println!(
"{rounds} ticks of {n} mobs in 1.5x1.5 pen: {elapsed:?} (query: {:.3} ms, update: {:.3} ms, per tick: {:.3} ms, fallback: {fallback_count}/{})",
query_dur.as_secs_f64() * 1e3 / rounds as f64,
update_dur.as_secs_f64() * 1e3 / rounds as f64,
elapsed.as_secs_f64() * 1e3 / rounds as f64,
rounds * n
);
}
}
+7 -88
View File
@@ -30,63 +30,6 @@ pub struct TargetInfo {
/// `isPassenger`.
pub passenger: bool,
}
thread_local! {
static NEAR_BUF_POOL: std::cell::RefCell<Vec<Vec<u32>>> = const { std::cell::RefCell::new(Vec::new()) };
}
pub struct NearIter<'a> {
others: &'a [Seen],
indices: Option<Vec<u32>>,
pos: usize,
fallback: Option<std::slice::Iter<'a, Seen>>,
}
impl<'a> Iterator for NearIter<'a> {
type Item = &'a Seen;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
if let Some(indices) = &self.indices
&& self.pos < indices.len()
{
let idx = indices[self.pos];
self.pos += 1;
return Some(&self.others[idx as usize]);
}
if let Some(all) = &mut self.fallback {
return all.next();
}
None
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
if let Some(indices) = &self.indices {
let rem = indices.len().saturating_sub(self.pos);
(rem, Some(rem))
} else if let Some(all) = &self.fallback {
all.size_hint()
} else {
(0, Some(0))
}
}
}
impl Drop for NearIter<'_> {
fn drop(&mut self) {
if let Some(mut buf) = self.indices.take() {
buf.clear();
NEAR_BUF_POOL.with(|pool| {
if let Ok(mut pool) = pool.try_borrow_mut()
&& pool.len() < 8
{
pool.push(buf);
}
});
}
}
}
/// Chunk and section cache for raycast block traversals.
/// Caches the current section to avoid section index checks and hashmap lookups
/// for consecutive voxels in the same 16x16x16 section.
@@ -569,39 +512,15 @@ impl MobWorld<'_> {
}
/// The other mobs whose boxes may meet `area` (callers test the boxes),
/// in snapshot order: through the grid when there is one, else all.
pub fn others_near(&self, area: Aabb) -> NearIter<'_> {
let mut buf = NEAR_BUF_POOL.with(|pool| {
pool.try_borrow_mut()
.ok()
.and_then(|mut p| p.pop())
.unwrap_or_default()
});
pub fn others_near(&self, area: Aabb) -> impl Iterator<Item = &Seen> {
let mut near = Vec::new();
let indexed = self
.others_grid
.is_some_and(|grid| grid.candidates(area, &mut buf));
if indexed {
NearIter {
others: self.others,
indices: Some(buf),
pos: 0,
fallback: None,
}
} else {
buf.clear();
NEAR_BUF_POOL.with(|pool| {
if let Ok(mut pool) = pool.try_borrow_mut()
&& pool.len() < 8
{
pool.push(buf);
}
});
NearIter {
others: self.others,
indices: None,
pos: 0,
fallback: Some(self.others.iter()),
}
}
.is_some_and(|grid| grid.candidates(area, &mut near));
let all = (!indexed).then(|| self.others.iter());
near.into_iter()
.map(|i| &self.others[i as usize])
.chain(all.into_iter().flatten())
}
pub fn seen(&self, id: u32) -> Option<&Seen> {
match self.others_index {
+258 -5
View File
@@ -330,7 +330,7 @@ fn ping_all(servers: &[ServerEntry], pings: &Pings, icons: &Icons, file: &Path)
/// Save the list after a move or delete, keeping the icons pings stored in
/// servers.dat since it was loaded, and the Code of Conduct accepted on a server.
fn save_servers(file: &Path, servers: &mut [ServerEntry]) {
let _guard = servers::lock();
let _guard = SAVING.lock();
let saved = servers::load(file);
for entry in servers.iter_mut() {
if let Some(s) = saved.iter().find(|s| s.ip == entry.ip) {
@@ -340,9 +340,11 @@ fn save_servers(file: &Path, servers: &mut [ServerEntry]) {
}
let _ = servers::save(file, servers);
}
/// Held while servers.dat is read and written back.
static SAVING: Mutex<()> = Mutex::new(());
/// Store `icon` on the entries for `address` (OnlineServerEntry.updateServerList).
fn save_icon(file: &Path, address: &str, icon: Option<String>) {
let _guard = servers::lock();
let _guard = SAVING.lock();
let mut list = servers::load(file);
let mut changed = false;
for entry in list.iter_mut().filter(|e| e.ip == address) {
@@ -362,6 +364,113 @@ pub fn save_server_icon(saves: &Path, address: &str, png: &[u8]) {
}
}
/// Horizon's official survival server: a name and an address like any entry. The address is
/// `horizon_telemetry::server_status::OFFICIAL_ADDRESS`, the one place it is kept.
pub const OFFICIAL_SERVER: (&str, &str) = (
"Horizon Official",
horizon_telemetry::server_status::OFFICIAL_ADDRESS,
);
/// Beside servers.dat once the official server was offered, so a player who
/// deletes it never gets it back: the address offered, so a new one moves the
/// list once. The launcher writes the same file.
const OFFICIAL_MARKER: &str = "horizon_official_server.txt";
/// An address as the game connects to it: any case, 25565 when no port is given.
fn address_key(ip: &str) -> Option<(String, u16)> {
servers::parse_address(ip).map(|(h, p)| (h.to_ascii_lowercase(), p))
}
/// Puts the official server at the top of the list in `game_dir`, once: not
/// again after the player deletes it, and not when the list has its address.
/// When it moved (the marker names another address), the entries for its
/// earlier addresses move to the new one, once (`move_official`).
pub fn add_official_server(game_dir: &Path) -> std::io::Result<()> {
let (name, address) = OFFICIAL_SERVER;
let marker = game_dir.join(OFFICIAL_MARKER);
// A marker that is there but can't be read: offered already, at an address not known.
let offered = match std::fs::read_to_string(&marker) {
Ok(text) => Some(text),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(_) => Some(String::new()),
};
if offered
.as_deref()
.is_some_and(|a| address_key(a) == address_key(address))
{
return Ok(());
}
let file = game_dir.join("servers.dat");
let _guard = SAVING.lock();
let mut list = servers::load(&file);
let previous = horizon_telemetry::server_status::OFFICIAL_PREVIOUS;
if move_official(&mut list, address, previous) {
servers::save(&file, &list)?;
}
if offered.is_none()
&& !list
.iter()
.any(|s| address_key(&s.ip) == address_key(address))
{
servers::prepend(&file, &ServerEntry::new(name, address))?;
}
std::fs::write(marker, format!("{address}\n"))
}
/// Afri-Network, which the game added once below the official server (#9299) until the
/// partnership ended: the name and address the game gave its entry.
const WITHDRAWN_PARTNER: (&str, &str) = ("Afri-Network (South Africa)", "afri-network.co.za");
/// Beside servers.dat: the partner addresses the game offered, one per line (#9299).
const PARTNER_MARKER: &str = "horizon_partner_servers.txt";
/// Takes the withdrawn partner server back out of the list in `game_dir`, once, where the game
/// added it: the marker says it was offered and the entry still has the name the game gave it
/// (an entry the player made or renamed keeps its place). Then the marker goes.
pub fn withdraw_partner_servers(game_dir: &Path) -> std::io::Result<()> {
let marker = game_dir.join(PARTNER_MARKER);
let offered = match std::fs::read_to_string(&marker) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
let (name, address) = WITHDRAWN_PARTNER;
if offered
.lines()
.any(|l| address_key(l) == address_key(address))
{
let _guard = SAVING.lock();
servers::remove_where(&game_dir.join("servers.dat"), |n, ip| {
n == name && address_key(ip) == address_key(address)
})?;
}
std::fs::remove_file(marker)
}
/// Points the entries for an earlier official address at `address`, each
/// keeping the player's name and fields. One entry for the server at most: an
/// old one goes when the list already has `address`. Whether anything changed.
fn move_official(list: &mut Vec<ServerEntry>, address: &str, previous: &[&str]) -> bool {
let new = address_key(address);
let old = |ip: &str| {
let ip = address_key(ip);
ip.is_some() && ip != new && previous.iter().any(|p| address_key(p) == ip)
};
let mut present = list.iter().any(|s| address_key(&s.ip) == new);
let mut changed = false;
list.retain_mut(|s| {
if !old(&s.ip) {
return true;
}
changed = true;
if present {
return false;
}
present = true;
s.ip = address.to_owned();
true
});
changed
}
/// The address and kind of list row `index`: saved servers, then discovered LAN games.
fn entry_address(servers: &[ServerEntry], lan: &Scanner, index: usize) -> Option<(String, bool)> {
match servers.get(index) {
@@ -1991,7 +2100,7 @@ pub fn remember_direct_join(saves: &Path, address: &str) {
return;
}
let file = servers_file(saves);
let _guard = servers::lock();
let _guard = SAVING.lock();
let (shown, hidden) = servers::load_all(&file);
if shown.iter().chain(&hidden).any(|s| s.ip == address) {
return;
@@ -2188,10 +2297,22 @@ mod tests {
));
}
#[cfg(feature = "friends")]
#[test]
fn direct_connection_keeps_host_names() {
fn direct_connection_joins_a_pasted_invite_and_keeps_host_names() {
let invite = horizon_friends::invite::Invite {
secret: *b"0123456789",
port: 25580,
endpoints: vec!["192.168.1.20:25580".parse().unwrap()],
relay: None,
}
.encode();
assert_eq!(
direct_address(&format!(" {invite} ")),
format!("{}{invite}", crate::net::FRIEND)
);
for host in ["localhost", "example.org:25566", "abcdefgh", "[::1]:25568"] {
assert_eq!(direct_address(&format!(" {host} ")), host);
assert_eq!(direct_address(host), host);
}
}
@@ -2425,6 +2546,138 @@ mod tests {
assert!(favicon_bytes(&serde_json::json!({ "favicon": encoded })).is_none());
}
#[test]
fn the_official_server_is_added_once_at_the_top() {
let dir = std::env::temp_dir().join(format!("horizon-mp-official-{}", std::process::id()));
let (name, address) = OFFICIAL_SERVER;
let file = dir.join("servers.dat");
let ips = || -> Vec<String> { servers::load(&file).into_iter().map(|s| s.ip).collect() };
let fresh = |list: &[ServerEntry]| {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
if !list.is_empty() {
servers::save(&file, list).unwrap();
}
};
// A fresh list: just the official server, named like it.
fresh(&[]);
add_official_server(&dir).unwrap();
assert_eq!(servers::load(&file), [ServerEntry::new(name, address)]);
// An existing list: on top, the player's servers kept in their order.
let mine = [
ServerEntry::new("Mine", "127.0.0.1:2"),
ServerEntry::new("Other", "example.org"),
];
fresh(&mine);
add_official_server(&dir).unwrap();
assert_eq!(ips(), [address, "127.0.0.1:2", "example.org"]);
// Once only: a second start adds nothing.
add_official_server(&dir).unwrap();
assert_eq!(ips(), [address, "127.0.0.1:2", "example.org"]);
// Already there under the player's own name and spelling: not duplicated.
fresh(&[
ServerEntry::new("SMP", "Play.PlayHorizon.dev:25565"),
mine[0].clone(),
]);
add_official_server(&dir).unwrap();
assert_eq!(ips(), ["Play.PlayHorizon.dev:25565", "127.0.0.1:2"]);
// Deleted by the player: never back.
fresh(&mine);
add_official_server(&dir).unwrap();
servers::save(&file, &mine).unwrap();
add_official_server(&dir).unwrap();
assert_eq!(ips(), ["127.0.0.1:2", "example.org"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_withdrawn_partner_server_leaves_lists_the_game_put_it_in_once() {
let dir = std::env::temp_dir().join(format!("horizon-mp-partner-{}", std::process::id()));
let official = OFFICIAL_SERVER.1;
let file = dir.join("servers.dat");
let marker = dir.join(PARTNER_MARKER);
let ips = || -> Vec<String> { servers::load(&file).into_iter().map(|s| s.ip).collect() };
let fresh = |list: &[ServerEntry], offered: bool| {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
servers::save(&file, list).unwrap();
if offered {
std::fs::write(&marker, "afri-network.co.za\n").unwrap();
}
};
let added = ServerEntry::new("Afri-Network (South Africa)", "afri-network.co.za");
// The game added it: it goes, the rest stays in order, and only once.
fresh(
&[
ServerEntry::new("Horizon Official", official),
added.clone(),
ServerEntry::new("Mine", "127.0.0.1:2"),
],
true,
);
withdraw_partner_servers(&dir).unwrap();
assert_eq!(ips(), [official, "127.0.0.1:2"]);
assert!(!marker.exists());
servers::save(&file, std::slice::from_ref(&added)).unwrap();
withdraw_partner_servers(&dir).unwrap();
assert_eq!(ips(), ["afri-network.co.za"]);
// The player's own entry for that address (their name) stays.
fresh(&[ServerEntry::new("ZA", "Afri-Network.co.za:25565")], true);
withdraw_partner_servers(&dir).unwrap();
assert_eq!(ips(), ["Afri-Network.co.za:25565"]);
// Never offered (no marker): nothing changes.
fresh(&[added], false);
withdraw_partner_servers(&dir).unwrap();
assert_eq!(ips(), ["afri-network.co.za"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_moved_official_server_moves_the_old_entry_once() {
let dir = std::env::temp_dir().join(format!("horizon-mp-moved-{}", std::process::id()));
let address = OFFICIAL_SERVER.1;
let file = dir.join("servers.dat");
let marker = dir.join(OFFICIAL_MARKER);
let ips = || -> Vec<String> { servers::load(&file).into_iter().map(|s| s.ip).collect() };
let offered_before = |list: &[ServerEntry]| {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
servers::save(&file, list).unwrap();
std::fs::write(&marker, "horizon.bunchy7s.dev\n").unwrap();
};
// The old address under the player's own name and spelling: moved, name and place kept.
let mut old = ServerEntry::new("Our SMP", "Horizon.Bunchy7s.dev:25565");
old.icon = Some("AAAA".into());
offered_before(&[ServerEntry::new("Mine", "127.0.0.1:2"), old]);
add_official_server(&dir).unwrap();
let list = servers::load(&file);
assert_eq!(ips(), ["127.0.0.1:2", address]);
assert_eq!(
(list[1].name.as_str(), list[1].icon.as_deref()),
("Our SMP", Some("AAAA"))
);
assert_eq!(
std::fs::read_to_string(&marker).unwrap(),
format!("{address}\n")
);
// Once: the old address added back afterwards is the player's.
servers::prepend(&file, &ServerEntry::new("Old", "horizon.bunchy7s.dev")).unwrap();
add_official_server(&dir).unwrap();
assert_eq!(ips(), ["horizon.bunchy7s.dev", "127.0.0.1:2", address]);
// The new address already listed: no second entry.
offered_before(&[
ServerEntry::new("Old", "horizon.bunchy7s.dev"),
ServerEntry::new("New", address),
]);
add_official_server(&dir).unwrap();
assert_eq!(ips(), [address]);
// Deleted by the player before the move: not offered again; another port is left alone.
offered_before(&[ServerEntry::new("Test", "horizon.bunchy7s.dev:25570")]);
add_official_server(&dir).unwrap();
assert_eq!(ips(), ["horizon.bunchy7s.dev:25570"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn arrow_keys_select_servers_and_shift_moves_them() {
let dir = std::env::temp_dir().join(format!("horizon-mp-keys-{}", std::process::id()));
+80 -36
View File
@@ -30,8 +30,6 @@ use std::{
time::Duration,
};
use horizon_hooks::tunnel;
/// `CommonListenerCookie.serverCookies`: cookies the server asked the client
/// to store (`ClientboundStoreCookiePacket`), answered to
/// `ClientboundCookieRequestPacket`. A `transfer` carries them to the next
@@ -498,36 +496,62 @@ impl Drop for Session {
}
/// An address of the form `friend:K7QM-3XPD` joins a friend's world through
/// its invite code (Play with Friends, through a hook's [`tunnel::Resolver`]):
/// the session goes through a tunnel the client reaches on a loopback port, so
/// everything after is a plain join.
pub const FRIEND: &str = tunnel::PREFIX;
/// its invite code (horizon-friends): the session goes through a tunnel the
/// client reaches on a loopback port, so everything after is a plain join.
#[cfg(feature = "friends")]
pub const FRIEND: &str = horizon_friends::ADDRESS_PREFIX;
/// Without the `friends` feature such an address is refused (`join_friend`).
#[cfg(not(feature = "friends"))]
pub const FRIEND: &str = "friend:";
/// Whether `code` is an invite code; with `full`, only a full one (a short
/// relay code could also be a host name). Without a resolver nothing is one.
/// relay code could also be a host name).
#[cfg(feature = "friends")]
pub fn is_invite(code: &str, full: bool) -> bool {
horizon_hooks::engine::tunnel().is_some_and(|r| r.is_code(code, full))
use horizon_friends::invite::{Parsed, parse};
match parse(code) {
Some(Parsed::Long(_)) => true,
Some(Parsed::Short(_)) => !full,
None => false,
}
}
/// Without the `friends` feature nothing is one.
#[cfg(not(feature = "friends"))]
pub fn is_invite(_code: &str, _full: bool) -> bool {
false
}
/// An address as players and logs see it: a friend's invite code stays
/// out of it (its secret is in there).
pub fn shown_address(address: &str) -> String {
match address.strip_prefix(FRIEND) {
Some(code) => match horizon_hooks::engine::tunnel().and_then(|r| r.nameplate(code)) {
Some(nameplate) => format!("Friend's world ({nameplate}-****)"),
None => "Friend's world".to_owned(),
#[cfg(feature = "friends")]
Some(code) => match horizon_friends::invite::parse(code) {
Some(horizon_friends::invite::Parsed::Short(c)) => {
format!("Friend's world ({}-****)", c.nameplate)
}
_ => "Friend's world".to_owned(),
},
#[cfg(not(feature = "friends"))]
Some(_) => "Friend's world".to_owned(),
None => address.to_owned(),
}
}
/// The relay the player set (`friendsRelay` in horizon.txt, beside
/// options.txt), else the resolver's (`HORIZON_RELAY_URL`). None by default.
/// options.txt), else `HORIZON_RELAY_URL`. None by default.
pub fn friends_relay_setting() -> Option<String> {
let dir = std::env::current_dir().ok()?;
crate::ui_theme::preference(&dir, "friendsRelay")
.filter(|r| !r.is_empty())
.or_else(|| horizon_hooks::engine::tunnel().and_then(|r| r.relay_from_env()))
.or_else(relay_from_env)
}
#[cfg(feature = "friends")]
use horizon_friends::relay_url as relay_from_env;
/// `HORIZON_RELAY_URL` is the friends feature's: none without it.
#[cfg(not(feature = "friends"))]
fn relay_from_env() -> Option<String> {
None
}
/// Remember the relay typed on Join a friend (empty forgets it).
pub fn save_friends_relay(relay: &str) {
@@ -538,35 +562,55 @@ pub fn save_friends_relay(relay: &str) {
/// Reach the friend's world from the code (directly, or through a relay)
/// and open the tunnel: the loopback address to connect to, and the token to send on it first.
/// Without a resolver (a build without Play with Friends) the address is refused, never retried.
#[cfg(feature = "friends")]
fn join_friend(
code: &str,
lang: &Language,
send: &Sender<Event>,
) -> Result<(String, u16, Vec<u8>), Reason> {
let Some(resolver) = horizon_hooks::engine::tunnel() else {
return Err(Reason::keyed_arg(
"horizon.multiplayer.invalid_address",
lang,
&shown_address(&format!("{FRIEND}{code}")),
));
use horizon_friends::{
PathMode,
guest::{self, GuestConfig},
};
let mut progress = |p: tunnel::Progress| {
let key = match p {
tunnel::Progress::Finding => "horizon.multiplayer.finding_friend",
tunnel::Progress::Connected { direct: true } => {
"horizon.multiplayer.connected_friend_direct"
}
tunnel::Progress::Connected { direct: false } => {
"horizon.multiplayer.connected_friend_relay"
}
};
status(send, &tr(lang, key));
};
let opened = resolver
.open(code, friends_relay_setting(), &mut progress)
.map_err(Reason::plain)?;
Ok(("127.0.0.1".to_owned(), opened.port, opened.token))
status(send, &tr(lang, "horizon.multiplayer.finding_friend"));
let tunnel = guest::join(&GuestConfig {
code: code.to_owned(),
relay: friends_relay_setting(),
protocol: PROTOCOL_VERSION,
game: horizon_protocol::GAME_VERSION.to_owned(),
path: PathMode::from_env(),
})
.map_err(|e| Reason::plain(e.to_string()))?;
status(
send,
&if tunnel.direct {
tr(lang, "horizon.multiplayer.connected_friend_direct")
} else {
tr(lang, "horizon.multiplayer.connected_friend_relay")
},
);
let loopback = tunnel
.serve_loopback()
.map_err(|e| Reason::plain(e.to_string()))?;
Ok((
"127.0.0.1".to_owned(),
loopback.port,
loopback.token.to_vec(),
))
}
/// A build without Play with Friends: the address is refused, never retried.
#[cfg(not(feature = "friends"))]
fn join_friend(
code: &str,
lang: &Language,
_: &Sender<Event>,
) -> Result<(String, u16, Vec<u8>), Reason> {
Err(Reason::keyed_arg(
"horizon.multiplayer.invalid_address",
lang,
&shown_address(&format!("{FRIEND}{code}")),
))
}
fn status(send: &Sender<Event>, text: &str) {
+43 -27
View File
@@ -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);
}
+12 -12
View File
@@ -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(" "),
);
}
+4 -13
View File
@@ -232,7 +232,10 @@ pub fn roll_container(
/// A saved `LootTableSeed`, if any.
pub fn loot_seed(nbt: &Tag) -> Option<i64> {
horizon_client_core::nbt_value::number(nbt.get("LootTableSeed"))
match nbt.get("LootTableSeed") {
Some(Tag::Long(seed)) => Some(*seed),
_ => None,
}
}
fn text(nbt: &Compound, key: &str) -> Option<String> {
@@ -315,18 +318,6 @@ mod tests {
use super::*;
use horizon_world::loot::{ExplorationMap, LootStack};
#[test]
fn a_loot_seed_is_any_number() {
assert_eq!(
loot_seed(&Tag::compound([("LootTableSeed", Tag::Int(42))])),
Some(42)
);
assert_eq!(
loot_seed(&Tag::compound([("LootTableSeed", Tag::Long(7))])),
Some(7)
);
}
#[test]
fn a_saved_mod_sign_uses_its_vanilla_entity_type() {
for hanging in [false, true] {
@@ -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;
+8 -4
View File
@@ -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,
);
}
+21 -3
View File
@@ -60,10 +60,28 @@ pub fn set_display(width: u32, height: u32, refresh_hz: u32, scale: f64) {
});
}
/// Keeps the screen `window` is on ([`crate::window::display`]) for the items' `system.display`.
/// The screen `window` is on, else the primary one, else the only one: right after the window
/// is made, Wayland doesn't say yet which screen it is on.
pub fn monitor(window: &winit::window::Window) -> Option<winit::monitor::MonitorHandle> {
let only = || {
let mut all = window.available_monitors();
all.next().filter(|_| all.next().is_none())
};
window
.current_monitor()
.or_else(|| window.primary_monitor())
.or_else(only)
}
/// Keeps [`monitor`]'s size, refresh rate and scale for the items' `system.display`.
pub fn note_display(window: &winit::window::Window) {
if let Some(d) = crate::window::display(window) {
set_display(d.width, d.height, d.refresh_hz.unwrap_or(0), d.scale);
if let Some(m) = monitor(window) {
set_display(
m.size().width,
m.size().height,
m.refresh_rate_millihertz().map_or(0, |r| r.div_ceil(1000)),
m.scale_factor(),
);
}
}
+111 -46
View File
@@ -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()
-15
View File
@@ -1,15 +0,0 @@
[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
-7
View File
@@ -8,8 +8,6 @@ description = "Play with friends: invite codes, the relay protocol, the end-to-e
[dependencies]
horizon-addresses = { path = "../horizon-addresses" }
# The game's side (`game`): the platform's hooks, for horizon-official.
horizon-platform = { path = "../horizon-platform", optional = true }
# The key exchange keyed by the code's secret, then the frames.
spake2 = { version = "0.4", features = ["getrandom"] }
chacha20poly1305 = "0.10"
@@ -23,10 +21,5 @@ if-addrs = "0.15"
serde = { version = "1", features = ["derive"] }
serde_json = "1.0.151"
[features]
# Play with Friends in the game: the resolver of `friend:` addresses on the platform's hooks
# (`game.rs`), which horizon-official registers. The launcher and the relay don't need it.
game = ["dep:horizon-platform"]
[lints]
workspace = true
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more