mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 17:18:25 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc88b517b | ||
|
|
0b21fcbf60 | ||
|
|
72fe5b13ba | ||
|
|
aaf5aa6fcf | ||
|
|
eaa027179b | ||
|
|
bbaa73ec43 | ||
|
|
d3c8eb00ea | ||
|
|
5f61de11d9 | ||
|
|
1ef6e26dfa | ||
|
|
2c9aa0ac86 |
No files matched your search
Generated
+47
-10
@@ -706,6 +706,25 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.14"
|
||||
@@ -926,6 +945,12 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
@@ -1554,6 +1579,7 @@ dependencies = [
|
||||
"open",
|
||||
"png",
|
||||
"pollster",
|
||||
"rayon",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1584,6 +1610,7 @@ dependencies = [
|
||||
"horizon-client-input",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-cosmetics",
|
||||
"horizon-hdr",
|
||||
"horizon-hooks",
|
||||
"horizon-items",
|
||||
@@ -1649,15 +1676,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"
|
||||
@@ -1801,7 +1819,6 @@ dependencies = [
|
||||
name = "horizon-official"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-discord",
|
||||
"horizon-friends",
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
@@ -3663,6 +3680,26 @@ dependencies = [
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rc2"
|
||||
version = "0.8.1"
|
||||
|
||||
@@ -34,7 +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",
|
||||
|
||||
@@ -9,13 +9,11 @@ description = "The client's input layer: key bindings and their names, mouse, co
|
||||
[dependencies]
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
winit = "0.30.13"
|
||||
# The window icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
png = "0.18"
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# The window icon test decodes Horizon's icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
|
||||
[features]
|
||||
controllers = []
|
||||
|
||||
|
||||
@@ -638,30 +638,10 @@ pub fn fullscreen_request(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
pub fn icon() -> Option<winit::window::Icon> {
|
||||
decode_icon(icon_png()?)
|
||||
}
|
||||
|
||||
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
@@ -674,15 +654,14 @@ fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
|
||||
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
|
||||
}
|
||||
|
||||
/// 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"))]
|
||||
/// 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 {
|
||||
let icon = icon();
|
||||
#[cfg(target_os = "windows")]
|
||||
let icon =
|
||||
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
|
||||
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
|
||||
|
||||
let attributes = attributes.with_window_icon(icon.clone());
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -760,8 +739,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn the_window_icon_decodes() {
|
||||
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
|
||||
assert!(decode_icon(b"not a png").is_none());
|
||||
assert!(icon().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,6 +27,9 @@ horizon-mcp = { path = "../horizon-mcp" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
# One pool of helper threads for the server tick's parallel phases (`parallel.rs`): threads made
|
||||
# once, not for every phase of every tick.
|
||||
rayon = "1.10"
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
@@ -70,6 +73,7 @@ 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" }
|
||||
@@ -108,12 +112,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", "horizon-extras"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
|
||||
# Horizon's own parts still in this crate (docs/forking.md, docs/layers.md): the features below.
|
||||
horizon-extras = ["discord"]
|
||||
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
|
||||
discord = []
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
@@ -125,8 +133,6 @@ 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).
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "The client's world data, rendering and screens: chunks, meshing,
|
||||
[dependencies]
|
||||
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
|
||||
horizon-hooks = { path = "../horizon-hooks" }
|
||||
horizon-cosmetics = { path = "../horizon-cosmetics" }
|
||||
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
|
||||
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
@@ -28,6 +29,7 @@ horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
ab_glyph = "0.2.32"
|
||||
@@ -83,7 +85,5 @@ horizon-extras = []
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "glsl-out", "msl-out", "spv-out"] }
|
||||
@@ -2,7 +2,7 @@
|
||||
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
|
||||
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
|
||||
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
|
||||
//! (No AI, Agent Control) 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,
|
||||
@@ -166,6 +168,24 @@ pub struct Extras {
|
||||
/// built-in track and leaving the off ones out (`musicPage`, off by
|
||||
/// default: vanilla plays exactly as before).
|
||||
pub music_page: bool,
|
||||
/// Cosmetics (`horizon_cosmetics`, off unless enabled), the lines `horizon_cosmetics::prefs`
|
||||
/// shares with the launcher's Wardrobe: Show my cosmetics (the account's, sent to the service;
|
||||
/// on by default), how much of other players' to show (no line until chosen:
|
||||
/// `effective_others`), and whether their trails and auras are hidden (off by default).
|
||||
pub cosmetics_mine: bool,
|
||||
pub cosmetics_others: Option<horizon_cosmetics::prefs::Others>,
|
||||
pub cosmetics_hide_fx: bool,
|
||||
/// My cosmetics in single player too (`cosmeticsSolo`, off by default): the window's own server
|
||||
/// names the player by their offline profile, which the service doesn't know, so their own
|
||||
/// cosmetics and name tag style show only with this on.
|
||||
pub cosmetics_solo: bool,
|
||||
/// Whether the signed-in account owns a cosmetic, as the service said (never saved; the game
|
||||
/// sets it): with no choice made, others' cosmetics show only then.
|
||||
pub cosmetics_owner: bool,
|
||||
/// Horizon's mark before the names of other Horizon players (name tags, player list), and
|
||||
/// whether the service may say this player plays Horizon: both on by default.
|
||||
pub horizon_marks: bool,
|
||||
pub horizon_listed: bool,
|
||||
/// 3D skin layers (`threeDSkinLayers`, Options → Skin Customization): each
|
||||
/// painted texel of a player's outer layer drawn as a slab
|
||||
/// (`horizon_client::mobs::skin_layers`).
|
||||
@@ -213,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,
|
||||
@@ -220,6 +241,13 @@ impl Default for Extras {
|
||||
wheel_cycles_settings: false,
|
||||
music_off: Vec::new(),
|
||||
music_page: false,
|
||||
cosmetics_mine: true,
|
||||
cosmetics_others: None,
|
||||
cosmetics_hide_fx: false,
|
||||
cosmetics_solo: false,
|
||||
cosmetics_owner: false,
|
||||
horizon_marks: true,
|
||||
horizon_listed: true,
|
||||
three_d_skin_layers: false,
|
||||
pvp: Default::default(),
|
||||
}
|
||||
@@ -229,6 +257,11 @@ fn bool_text(v: bool) -> &'static str {
|
||||
if v { "true" } else { "false" }
|
||||
}
|
||||
impl Extras {
|
||||
/// How much of other players' cosmetics shows: the player's choice, else all of it for an
|
||||
/// account that owns a cosmetic and none for any other (a vanilla game).
|
||||
pub fn effective_others(&self) -> horizon_cosmetics::prefs::Others {
|
||||
horizon_cosmetics::prefs::Others::effective(self.cosmetics_others, self.cosmetics_owner)
|
||||
}
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
fs::read_to_string(dir.join(FILE))
|
||||
.map(|t| Self::parse(&t))
|
||||
@@ -305,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),
|
||||
@@ -323,6 +357,15 @@ impl Extras {
|
||||
.collect()
|
||||
}
|
||||
"musicPage" => e.music_page = flag(e.music_page),
|
||||
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
|
||||
"cosmeticsOthers" => {
|
||||
e.cosmetics_others =
|
||||
horizon_cosmetics::prefs::Others::parse(value).or(e.cosmetics_others)
|
||||
}
|
||||
"cosmeticsHideFx" => e.cosmetics_hide_fx = flag(e.cosmetics_hide_fx),
|
||||
"cosmeticsSolo" => e.cosmetics_solo = flag(e.cosmetics_solo),
|
||||
horizon_cosmetics::prefs::MARKS => e.horizon_marks = flag(e.horizon_marks),
|
||||
horizon_cosmetics::prefs::LISTED => e.horizon_listed = flag(e.horizon_listed),
|
||||
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
|
||||
key => {
|
||||
e.pvp.parse_line(key, value);
|
||||
@@ -333,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:{}\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),
|
||||
@@ -375,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,
|
||||
@@ -382,6 +426,16 @@ impl Extras {
|
||||
bool_text(self.wheel_cycles_settings),
|
||||
self.music_off.join(","),
|
||||
bool_text(self.music_page),
|
||||
bool_text(self.cosmetics_mine),
|
||||
// Written once the player chose.
|
||||
self.cosmetics_others
|
||||
.map_or(String::new(), |o| format!("cosmeticsOthers:{}\n", o.name())),
|
||||
bool_text(self.cosmetics_hide_fx),
|
||||
bool_text(self.cosmetics_solo),
|
||||
horizon_cosmetics::prefs::MARKS,
|
||||
bool_text(self.horizon_marks),
|
||||
horizon_cosmetics::prefs::LISTED,
|
||||
bool_text(self.horizon_listed),
|
||||
SKIN_LAYERS_KEY,
|
||||
bool_text(self.three_d_skin_layers),
|
||||
);
|
||||
@@ -559,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.
|
||||
@@ -567,6 +623,13 @@ pub enum Item {
|
||||
/// The Select Resource Packs screen's Add button (Modrinth).
|
||||
PackBrowser,
|
||||
Pregen,
|
||||
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
|
||||
CosmeticsMine,
|
||||
CosmeticsOthers,
|
||||
CosmeticsHideFx,
|
||||
CosmeticsSolo,
|
||||
HorizonMarks,
|
||||
HorizonMe,
|
||||
/// On the PvP & HUD page (`pvp::rows`).
|
||||
Pvp(crate::pvp::Opt),
|
||||
}
|
||||
@@ -574,10 +637,8 @@ pub enum Item {
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
use Item::*;
|
||||
// Fullbright, Dynamic lights and Hide block-break particles change the picture: they
|
||||
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here. The Cosmetics
|
||||
// group comes last, from the cosmetics' own section (`pages::Place::Extras`) while they are
|
||||
// offered.
|
||||
vec![
|
||||
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here.
|
||||
let mut rows = vec![
|
||||
Err("horizon.extras.header.third_person"),
|
||||
Ok(OwnNameTag),
|
||||
Err("horizon.extras.header.inventory"),
|
||||
@@ -598,7 +659,19 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Err("horizon.extras.header.menus"),
|
||||
Ok(WheelCyclesSettings),
|
||||
Ok(PackBrowser),
|
||||
]
|
||||
];
|
||||
if horizon_cosmetics::enabled() {
|
||||
rows.extend([
|
||||
Err("horizon.extras.header.cosmetics"),
|
||||
Ok(CosmeticsMine),
|
||||
Ok(CosmeticsSolo),
|
||||
Ok(CosmeticsOthers),
|
||||
Ok(CosmeticsHideFx),
|
||||
Ok(HorizonMarks),
|
||||
Ok(HorizonMe),
|
||||
]);
|
||||
}
|
||||
rows
|
||||
}
|
||||
impl Item {
|
||||
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
|
||||
@@ -618,12 +691,19 @@ 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",
|
||||
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
|
||||
Item::PackBrowser => "horizon.extras.pack_browser",
|
||||
Item::Pregen => "horizon.extras.pregen",
|
||||
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
|
||||
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
|
||||
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx",
|
||||
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo",
|
||||
Item::HorizonMarks => "horizon.extras.horizon_marks",
|
||||
Item::HorizonMe => "horizon.extras.horizon_me",
|
||||
Item::Pvp(o) => o.caption_key(),
|
||||
}
|
||||
}
|
||||
@@ -644,12 +724,19 @@ 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",
|
||||
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_list_settings.tooltip",
|
||||
Item::PackBrowser => "horizon.extras.pack_browser.tooltip",
|
||||
Item::Pregen => "horizon.extras.pregen.tooltip",
|
||||
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
|
||||
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
|
||||
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx.tooltip",
|
||||
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo.tooltip",
|
||||
Item::HorizonMarks => "horizon.extras.horizon_marks.tooltip",
|
||||
Item::HorizonMe => "horizon.extras.horizon_me.tooltip",
|
||||
Item::Pvp(o) => o.description_key(),
|
||||
}
|
||||
}
|
||||
@@ -681,10 +768,17 @@ 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",
|
||||
Item::Pregen => "Chunk pregeneration",
|
||||
Item::CosmeticsMine => "My cosmetics",
|
||||
Item::CosmeticsOthers => "Other players' cosmetics",
|
||||
Item::CosmeticsHideFx => "Hide other players' trails and auras",
|
||||
Item::CosmeticsSolo => "In single player",
|
||||
Item::HorizonMarks => "Horizon logos",
|
||||
Item::HorizonMe => "Show me as Horizon",
|
||||
Item::Pvp(o) => o.caption(),
|
||||
}
|
||||
}
|
||||
@@ -698,8 +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 | Pregen => {
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
|
||||
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
|
||||
| Pregen => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -748,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."
|
||||
}
|
||||
@@ -757,6 +856,24 @@ impl Item {
|
||||
Item::ReproKeys => {
|
||||
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
|
||||
}
|
||||
Item::CosmeticsMine => {
|
||||
"Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe."
|
||||
}
|
||||
Item::CosmeticsOthers => {
|
||||
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear."
|
||||
}
|
||||
Item::CosmeticsHideFx => {
|
||||
"Other players' trails and auras stay hidden; their other cosmetics still show. Off by default."
|
||||
}
|
||||
Item::CosmeticsSolo => {
|
||||
"Show your cosmetics and your name tag's badge in your single-player worlds too (your name tag with Own name tag). Off: only on servers."
|
||||
}
|
||||
Item::HorizonMarks => {
|
||||
"Show the Horizon logo next to Horizon players: a small logo before the names of other players who use Horizon, above their heads and in the player list. Only Horizon players see it: servers receive nothing."
|
||||
}
|
||||
Item::HorizonMe => {
|
||||
"Show me as a Horizon player: other Horizon players see the logo by your name. On, the service answers anyone who asks about you that you played Horizon in the last 3 hours; off, it never reports you."
|
||||
}
|
||||
Item::Pvp(o) => o.description(),
|
||||
Item::WheelCyclesSettings => {
|
||||
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
|
||||
@@ -783,6 +900,12 @@ impl Item {
|
||||
| Item::Splitscreen
|
||||
| Item::WheelCyclesSettings
|
||||
| Item::PackBrowser => Some(Cost::None),
|
||||
Item::CosmeticsMine
|
||||
| Item::CosmeticsOthers
|
||||
| Item::CosmeticsHideFx
|
||||
| Item::CosmeticsSolo
|
||||
| Item::HorizonMarks
|
||||
| Item::HorizonMe => Some(Cost::Low),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -804,10 +927,15 @@ 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,
|
||||
Item::Pregen => e.pregen,
|
||||
Item::CosmeticsHideFx => e.cosmetics_hide_fx,
|
||||
Item::CosmeticsSolo => e.cosmetics_solo,
|
||||
Item::HorizonMarks => e.horizon_marks,
|
||||
Item::HorizonMe => e.horizon_listed,
|
||||
Item::Pvp(o) => return o.switch(&e.pvp),
|
||||
_ => return None,
|
||||
})
|
||||
@@ -817,14 +945,30 @@ impl Item {
|
||||
match self {
|
||||
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
|
||||
Item::Pvp(o) if o.switch(&e.pvp).is_none() => o.value(&e.pvp).unwrap_or_default(),
|
||||
Item::CosmeticsMine => match e.cosmetics_mine {
|
||||
true => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
|
||||
false => crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden"),
|
||||
},
|
||||
Item::CosmeticsOthers => {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
match e.effective_others() {
|
||||
Others::Shown => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
|
||||
Others::NameTags => {
|
||||
crate::lang::get_or("horizon.extras.cosmetics.name_tags", "Name tags only")
|
||||
}
|
||||
Others::Hidden => {
|
||||
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
|
||||
}
|
||||
}
|
||||
}
|
||||
other => match other.switch(e) {
|
||||
Some(true) => crate::lang::get_or("On", "On"),
|
||||
_ => crate::lang::get_or("Off", "Off"),
|
||||
},
|
||||
}
|
||||
}
|
||||
/// A click (`back` for the right button: a PvP & HUD choice steps back; a switch flips
|
||||
/// either way). The sort key binds instead.
|
||||
/// A click (`back` for the right button: it turns the three-way cosmetics button the other
|
||||
/// way; every other item left is a switch, so both flip it). The sort key binds instead.
|
||||
pub fn cycle(self, e: &mut Extras, back: bool) {
|
||||
match self {
|
||||
Item::Fullbright => e.fullbright = !e.fullbright,
|
||||
@@ -842,10 +986,21 @@ 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,
|
||||
Item::Pregen => e.pregen = !e.pregen,
|
||||
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
|
||||
Item::CosmeticsOthers => {
|
||||
// From what the button shows; the result is the player's choice from now on.
|
||||
let o = e.effective_others();
|
||||
e.cosmetics_others = Some(if back { o.next().next() } else { o.next() });
|
||||
}
|
||||
Item::CosmeticsHideFx => e.cosmetics_hide_fx = !e.cosmetics_hide_fx,
|
||||
Item::CosmeticsSolo => e.cosmetics_solo = !e.cosmetics_solo,
|
||||
Item::HorizonMarks => e.horizon_marks = !e.horizon_marks,
|
||||
Item::HorizonMe => e.horizon_listed = !e.horizon_listed,
|
||||
Item::Pvp(o) => o.cycle(&mut e.pvp, back),
|
||||
Item::SortKey => {}
|
||||
}
|
||||
@@ -864,6 +1019,73 @@ impl Item {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn other_players_cosmetics_cycle_three_ways_and_read_the_old_switch() {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
let mut e = Extras {
|
||||
cosmetics_owner: true,
|
||||
..Extras::default()
|
||||
};
|
||||
assert_eq!(
|
||||
(e.effective_others(), e.cosmetics_hide_fx, e.cosmetics_mine),
|
||||
(Others::Shown, false, true)
|
||||
);
|
||||
Item::CosmeticsOthers.cycle(&mut e, false);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
|
||||
Item::CosmeticsOthers.cycle(&mut e, false);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::Hidden));
|
||||
// The right button goes back.
|
||||
Item::CosmeticsOthers.cycle(&mut e, true);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
|
||||
// Ownership is the service's, never written.
|
||||
assert_eq!(
|
||||
Extras::parse(&e.serialize()),
|
||||
Extras {
|
||||
cosmetics_owner: false,
|
||||
..e.clone()
|
||||
}
|
||||
);
|
||||
// The launcher's Wardrobe writes the same lines.
|
||||
assert_eq!(
|
||||
horizon_cosmetics::prefs::Prefs::parse(&e.serialize()).others,
|
||||
Some(Others::NameTags)
|
||||
);
|
||||
// Before this, the line was on or off.
|
||||
assert_eq!(
|
||||
Extras::parse("cosmeticsOthers:false").cosmetics_others,
|
||||
Some(Others::Hidden)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn others_cosmetics_are_off_for_a_player_who_owns_none_until_chosen() {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
let mut e = Extras::default();
|
||||
// Nothing chosen, nothing owned: the button shows Hidden and no line is written.
|
||||
assert_eq!(e.effective_others(), Others::Hidden);
|
||||
assert_eq!(
|
||||
Item::CosmeticsOthers.value(&e),
|
||||
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
|
||||
);
|
||||
assert!(!e.serialize().contains("cosmeticsOthers"));
|
||||
// The service says the account owns one: shown, still not a choice.
|
||||
e.cosmetics_owner = true;
|
||||
assert_eq!(e.effective_others(), Others::Shown);
|
||||
assert!(!e.serialize().contains("cosmeticsOthers"));
|
||||
// A non-owner pressing the button goes on from Hidden, and that choice is kept.
|
||||
e.cosmetics_owner = false;
|
||||
Item::CosmeticsOthers.cycle(&mut e, false);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::Shown));
|
||||
let saved = Extras::parse(&e.serialize());
|
||||
assert_eq!(saved.effective_others(), Others::Shown);
|
||||
// An owner who chose Hidden keeps it.
|
||||
let hidden = Extras {
|
||||
cosmetics_owner: true,
|
||||
..Extras::parse("cosmeticsOthers:hidden\n")
|
||||
};
|
||||
assert_eq!(hidden.effective_others(), Others::Hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_wheel_setting_from_before_the_default_changed_is_dropped() {
|
||||
assert!(!Extras::parse("wheelCyclesSettings:true\n").wheel_cycles_settings);
|
||||
@@ -882,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();
|
||||
@@ -921,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));
|
||||
|
||||
@@ -206,13 +206,14 @@ impl Screen for ExtrasScreen {
|
||||
fn note_lines(&self) -> usize {
|
||||
NOTE_LINES
|
||||
}
|
||||
/// The rows read the extras, the mods and the sections' rows.
|
||||
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
let mut added = Vec::new();
|
||||
for (_, section) in sections() {
|
||||
section.rows(&mut added);
|
||||
}
|
||||
shell::stamp_of(&(&s.extras, &s.mods, added))
|
||||
let cosmetics = horizon_cosmetics::enabled();
|
||||
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
|
||||
let mut groups = vec![Group {
|
||||
@@ -320,6 +321,22 @@ mod tests {
|
||||
assert_ne!(s.extras.mouse_tweaks_wheel, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_way_cosmetics_row_turns_both_ways() {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
let mut s = Settings::default();
|
||||
let mut screen = ExtrasScreen;
|
||||
let row = ExtraRow::Item(Item::CosmeticsOthers);
|
||||
// Not chosen, nothing owned: it shows Hidden, and forward wraps to Shown.
|
||||
assert_eq!(s.extras.effective_others(), Others::Hidden);
|
||||
screen.press(&mut s, row, false);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
|
||||
screen.press(&mut s, row, true);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
|
||||
screen.press(&mut s, row, true);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
|
||||
}
|
||||
|
||||
/// The sort key binds instead of cycling; the wheel leaves it be.
|
||||
#[test]
|
||||
fn the_key_row_waits_for_a_key() {
|
||||
|
||||
@@ -1001,14 +1001,12 @@ impl Hdr {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
use wgpu::util::DeviceExt;
|
||||
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 (w, h, rgba) =
|
||||
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
|
||||
let mask: Vec<u8> = rgba
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
|
||||
@@ -2,21 +2,16 @@
|
||||
//! colours and the controls it draws with. The screen (`graphics_ui`) only
|
||||
//! lays out rows and handles input; a menu theme replaces this module.
|
||||
//!
|
||||
//! 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};
|
||||
//! 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.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
|
||||
/// The settings pages' colours (`menu_style`, given at startup).
|
||||
fn b() -> &'static SettingsColours {
|
||||
&menu_style::get().settings
|
||||
}
|
||||
use horizon_theme::BLOCKS_DARK as B;
|
||||
|
||||
/// What a setting costs in frame time, shown with its description.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -41,91 +36,54 @@ 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use menu_style::hud_colour as hex;
|
||||
/// `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)
|
||||
}
|
||||
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
|
||||
fn glass() -> u32 {
|
||||
hex(b().page, 118)
|
||||
}
|
||||
const GLASS: u32 = hex(B.page, 118);
|
||||
/// Their 1 px edge.
|
||||
pub fn edge() -> u32 {
|
||||
hex(b().ink, 26)
|
||||
}
|
||||
const EDGE: u32 = hex(B.ink, 26);
|
||||
/// A group's panel, a little lighter than the plate it sits on.
|
||||
fn group_fill() -> u32 {
|
||||
hex(b().ink, 11)
|
||||
}
|
||||
const GROUP: u32 = hex(B.ink, 11);
|
||||
/// A hovered or focused row, lit on its group's panel.
|
||||
pub fn hover() -> u32 {
|
||||
hex(b().ink, 18)
|
||||
}
|
||||
pub const HOVER: u32 = hex(B.ink, 18);
|
||||
/// The open page in the page list.
|
||||
pub fn open() -> u32 {
|
||||
hex(b().ink, 26)
|
||||
}
|
||||
pub const OPEN: u32 = hex(B.ink, 26);
|
||||
/// The rule under the plate's heading, and between two rows of a group.
|
||||
pub fn head_rule() -> u32 {
|
||||
hex(b().ink, 31)
|
||||
}
|
||||
pub fn row_rule() -> u32 {
|
||||
hex(b().ink, 15)
|
||||
}
|
||||
pub const HEAD_RULE: u32 = hex(B.ink, 31);
|
||||
pub const ROW_RULE: u32 = hex(B.ink, 15);
|
||||
/// Fields, boxes and the value stepper: sunk, see-through.
|
||||
fn well_fill() -> u32 {
|
||||
hex(b().outline, 102)
|
||||
}
|
||||
const WELL: u32 = hex(B.outline, 102);
|
||||
/// A button, a slider's track, the scroller's thumb.
|
||||
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)
|
||||
}
|
||||
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);
|
||||
/// The one main button.
|
||||
pub fn face() -> u32 {
|
||||
hex(b().face, 255)
|
||||
}
|
||||
fn face_hover() -> u32 {
|
||||
hex(b().face_hover, 255)
|
||||
}
|
||||
const FACE: u32 = hex(B.face, 255);
|
||||
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
|
||||
/// A tick box's edge when off.
|
||||
fn box_edge() -> u32 {
|
||||
hex(b().ink_soft, 89)
|
||||
}
|
||||
fn outline() -> u32 {
|
||||
hex(b().outline, 255)
|
||||
}
|
||||
pub fn accent() -> u32 {
|
||||
hex(b().accent, 255)
|
||||
}
|
||||
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
|
||||
const OUTLINE: u32 = hex(B.outline, 255);
|
||||
pub const 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 fn muted() -> u32 {
|
||||
hex(b().muted, 255)
|
||||
}
|
||||
pub const MUTED: u32 = hex(B.muted, 255);
|
||||
/// A page that isn't open, a value at rest.
|
||||
pub fn soft() -> u32 {
|
||||
hex(b().ink_soft, 255)
|
||||
}
|
||||
pub const 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 fn veil() -> u32 {
|
||||
hex(b().page, 77)
|
||||
}
|
||||
pub const 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.
|
||||
@@ -162,21 +120,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());
|
||||
fill(batch, atlas, rect, GROUP);
|
||||
}
|
||||
/// 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());
|
||||
fill(batch, atlas, rect, WELL);
|
||||
}
|
||||
/// 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());
|
||||
fill(batch, atlas, track, ROW_RULE);
|
||||
fill(batch, atlas, thumb, TRACK);
|
||||
}
|
||||
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control: the HUD
|
||||
/// kit's `fit::ellipsize`.
|
||||
@@ -184,11 +142,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());
|
||||
fill(batch, atlas, rect, WELL);
|
||||
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 {
|
||||
@@ -211,7 +169,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);
|
||||
}
|
||||
}
|
||||
@@ -240,17 +198,17 @@ pub fn button_with_room(
|
||||
) {
|
||||
let [x, y, w, h] = rect;
|
||||
let face = match (live, primary, hover) {
|
||||
(false, _, _) => well_fill(),
|
||||
(true, true, false) => face(),
|
||||
(true, true, true) => face_hover(),
|
||||
(true, false, false) => button_fill(),
|
||||
(true, false, true) => button_hover(),
|
||||
(false, _, _) => WELL,
|
||||
(true, true, false) => FACE,
|
||||
(true, true, true) => FACE_HOVER,
|
||||
(true, false, false) => BUTTON,
|
||||
(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(
|
||||
@@ -294,7 +252,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,
|
||||
@@ -305,22 +263,17 @@ 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());
|
||||
fill(batch, atlas, [x, line, w, 2.0], TRACK);
|
||||
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_fill() } else { muted() },
|
||||
);
|
||||
fill(batch, atlas, handle, if live { HANDLE } 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.
|
||||
@@ -337,13 +290,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,9 +3,8 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! 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 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.
|
||||
//!
|
||||
//! 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
|
||||
@@ -17,7 +16,7 @@
|
||||
//! use std::time::{Duration, Instant};
|
||||
//!
|
||||
//! let opened = Instant::now();
|
||||
//! let open = Tween::new(Duration::from_millis(90), [0.4, 0.0, 0.2, 1.0]);
|
||||
//! let open = Tween::new(Duration::from_millis(90), horizon_theme::QUICK);
|
||||
//! // 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);
|
||||
@@ -27,6 +26,7 @@
|
||||
//! 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,20 +50,28 @@ impl Clock {
|
||||
}
|
||||
}
|
||||
|
||||
/// A duration and an easing curve.
|
||||
/// A duration and one of the design system's easing curves.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tween {
|
||||
/// How long it runs.
|
||||
pub duration: Duration,
|
||||
/// The CSS `cubic-bezier` control points of its curve.
|
||||
/// The CSS `cubic-bezier` control points of its curve (a `horizon_theme::Motion`'s).
|
||||
pub curve: [f32; 4],
|
||||
}
|
||||
|
||||
impl Tween {
|
||||
/// `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 }
|
||||
/// `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)
|
||||
}
|
||||
|
||||
/// The linear progress, 0 to 1, `since` a start at this clock; 1 on a still clock.
|
||||
@@ -76,7 +84,7 @@ impl Tween {
|
||||
|
||||
/// The eased progress, 0 to 1, on the tween's curve.
|
||||
pub fn progress(self, clock: Clock, since: Instant) -> f32 {
|
||||
bezier(self.curve, self.linear(clock, since))
|
||||
horizon_theme::bezier(self.curve, self.linear(clock, since))
|
||||
}
|
||||
|
||||
/// Whether it has finished (always, on a still clock).
|
||||
@@ -90,29 +98,6 @@ 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)
|
||||
@@ -160,7 +145,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.curve);
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE);
|
||||
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);
|
||||
@@ -175,7 +160,8 @@ mod tests {
|
||||
fn a_still_clock_shows_every_motion_settled() {
|
||||
let t0 = Instant::now();
|
||||
let still = Clock::new(t0, true);
|
||||
let tween = Tween::new(horizon_theme::QUICK.duration(), horizon_theme::QUICK.curve);
|
||||
let tween = Tween::of(horizon_theme::QUICK);
|
||||
assert_eq!(tween.duration, horizon_theme::QUICK.duration());
|
||||
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);
|
||||
@@ -187,7 +173,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_helpers_start_where_they_say() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(100), horizon_theme::QUICK.curve);
|
||||
let tween = Tween::new(MS(100), horizon_theme::QUICK);
|
||||
let c = at(t0, 0);
|
||||
assert_eq!(fade(tween, c, t0), 0.0);
|
||||
assert_eq!(fade_out(tween, c, t0), 1.0);
|
||||
@@ -198,25 +184,10 @@ 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.curve);
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE);
|
||||
// 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,12 +38,6 @@
|
||||
//! ```
|
||||
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];
|
||||
@@ -51,12 +45,8 @@ 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 fn arrow_colour() -> u32 {
|
||||
colour(overlay().backdrop_muted, 255)
|
||||
}
|
||||
pub fn arrow_off_colour() -> u32 {
|
||||
colour(overlay().backdrop_muted, 77)
|
||||
}
|
||||
pub const ARROW: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
|
||||
pub const ARROW_OFF: u32 = colour(horizon_theme::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);
|
||||
@@ -75,15 +65,9 @@ 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 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)
|
||||
}
|
||||
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);
|
||||
/// The line of text's height in GUI px (vanilla's font, without its shadow).
|
||||
const LINE: f32 = 8.0;
|
||||
|
||||
@@ -160,11 +144,7 @@ pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
|
||||
arrow,
|
||||
ax,
|
||||
ay,
|
||||
if on {
|
||||
arrow_colour()
|
||||
} else {
|
||||
arrow_off_colour()
|
||||
},
|
||||
if on { ARROW } else { ARROW_OFF },
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -302,9 +282,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.
|
||||
@@ -400,16 +380,15 @@ mod tests {
|
||||
let [ax, _] = strip.arrows[i];
|
||||
(batch.vertices.iter())
|
||||
.filter(|v| {
|
||||
(v.pos[0] - ax).abs() < 4.0
|
||||
&& v.color & 0x00FF_FFFF == arrow_colour() & 0x00FF_FFFF
|
||||
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 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_colour() >> 24);
|
||||
assert_eq!(alpha(&first, 1), arrow_colour() >> 24);
|
||||
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
|
||||
assert_eq!(alpha(&first, 1), ARROW >> 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -74,7 +74,6 @@ 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;
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
//! The look of the screens vanilla doesn't have that the engine still draws: the settings pages
|
||||
//! (`graphics_style`), the HUD kit's tab strip, and the overlays drawn with `ui_theme` (the
|
||||
//! on-screen keyboard, the boot screen, the missing-resources window). Vanilla's screens never
|
||||
//! read it.
|
||||
//!
|
||||
//! The layer above gives it once at startup ([`install`]), before the first frame; the
|
||||
//! official game gives Horizon's design system (`docs/design-system.md`). With none given, the
|
||||
//! screens draw in [`NEUTRAL`]: vanilla's greys and chat colours, and no text where they would
|
||||
//! use their own fonts. A read is one atomic load, and only those screens make it.
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Colours as `0xRRGGBB`, sizes in dp (the launcher's logical pixels).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MenuStyle {
|
||||
pub settings: SettingsColours,
|
||||
pub overlay: OverlayColours,
|
||||
pub sizes: Sizes,
|
||||
/// The overlays' TrueType faces; none: they draw no text.
|
||||
pub fonts: Option<Fonts>,
|
||||
}
|
||||
|
||||
/// The settings pages: square panels of dark glass over the world, flat controls, one face.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SettingsColours {
|
||||
/// The plate behind everything, drawn see-through.
|
||||
pub page: u32,
|
||||
/// Text, and the light the panels, rules, hovers and controls are drawn in.
|
||||
pub ink: u32,
|
||||
/// Secondary text and values.
|
||||
pub ink_soft: u32,
|
||||
/// Labels, descriptions, what can't be used.
|
||||
pub muted: u32,
|
||||
/// Wells and the dark edge of small marks.
|
||||
pub outline: u32,
|
||||
/// Focus, a checked box, a slider's fill.
|
||||
pub accent: u32,
|
||||
/// The one main button, and while hovered.
|
||||
pub face: u32,
|
||||
pub face_hover: u32,
|
||||
pub success: u32,
|
||||
pub warn: u32,
|
||||
pub danger: u32,
|
||||
}
|
||||
|
||||
/// Overlays drawn over the game (the HUD kit's tab strip, `ui_theme`'s overlays).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OverlayColours {
|
||||
/// The dark page under text and tabs.
|
||||
pub page: u32,
|
||||
/// Muted text over a veiled backdrop.
|
||||
pub backdrop_muted: u32,
|
||||
/// Text and the light hover and pressed plates are drawn in.
|
||||
pub ink: u32,
|
||||
/// Secondary text and values.
|
||||
pub ink_soft: u32,
|
||||
/// One-pixel rules between rows.
|
||||
pub rule: u32,
|
||||
/// The main action, and its text.
|
||||
pub accent: u32,
|
||||
pub on_accent: u32,
|
||||
}
|
||||
|
||||
/// `ui_theme`'s type scale and shapes, in dp.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Sizes {
|
||||
pub caption: f32,
|
||||
pub label: f32,
|
||||
pub ui: f32,
|
||||
pub body: f32,
|
||||
pub title: f32,
|
||||
/// The boot screen's wordmark at its smallest, and its console lines.
|
||||
pub display: f32,
|
||||
pub micro: f32,
|
||||
/// Fields; plates (hover rows, selection); a large control's height.
|
||||
pub radius: f32,
|
||||
pub radius_plate: f32,
|
||||
pub control_large: f32,
|
||||
}
|
||||
|
||||
/// TrueType faces (`ui_theme::Face`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Fonts {
|
||||
pub regular: &'static [u8],
|
||||
pub medium: &'static [u8],
|
||||
pub semibold: &'static [u8],
|
||||
pub mono: &'static [u8],
|
||||
}
|
||||
|
||||
/// The look with nothing given: vanilla's greys and chat colours.
|
||||
pub const NEUTRAL: MenuStyle = MenuStyle {
|
||||
settings: SettingsColours {
|
||||
page: 0x101010,
|
||||
ink: 0xffffff,
|
||||
ink_soft: 0xe0e0e0,
|
||||
muted: 0xa0a0a0,
|
||||
outline: 0x000000,
|
||||
accent: 0xffffff,
|
||||
face: 0x555555,
|
||||
face_hover: 0x6f6f6f,
|
||||
success: 0x55ff55,
|
||||
warn: 0xffff55,
|
||||
danger: 0xff5555,
|
||||
},
|
||||
overlay: OverlayColours {
|
||||
page: 0x101010,
|
||||
backdrop_muted: 0xa0a0a0,
|
||||
ink: 0xffffff,
|
||||
ink_soft: 0xe0e0e0,
|
||||
rule: 0x303030,
|
||||
accent: 0xffffff,
|
||||
on_accent: 0x000000,
|
||||
},
|
||||
sizes: Sizes {
|
||||
caption: 12.0,
|
||||
label: 13.0,
|
||||
ui: 14.0,
|
||||
body: 15.0,
|
||||
title: 28.0,
|
||||
display: 44.0,
|
||||
micro: 11.0,
|
||||
radius: 0.0,
|
||||
radius_plate: 0.0,
|
||||
control_large: 40.0,
|
||||
},
|
||||
fonts: None,
|
||||
};
|
||||
|
||||
static STYLE: OnceLock<MenuStyle> = OnceLock::new();
|
||||
|
||||
/// Give the menus their look: call it once at startup. A second call keeps the first look and
|
||||
/// returns false.
|
||||
pub fn install(style: MenuStyle) -> bool {
|
||||
STYLE.set(style).is_ok()
|
||||
}
|
||||
|
||||
/// The look given to [`install`], or [`NEUTRAL`].
|
||||
#[inline]
|
||||
pub fn get() -> &'static MenuStyle {
|
||||
STYLE.get().unwrap_or(&NEUTRAL)
|
||||
}
|
||||
|
||||
/// `0xRRGGBB` and an alpha in the HUD's colour encoding (`0xAABBGGRR`).
|
||||
pub const fn hud_colour(rgb: u32, alpha: u8) -> u32 {
|
||||
(alpha as u32) << 24 | (rgb & 0xff) << 16 | (rgb >> 8 & 0xff) << 8 | (rgb >> 16 & 0xff)
|
||||
}
|
||||
@@ -124,15 +124,21 @@ impl OptionsUi {
|
||||
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)),
|
||||
)
|
||||
},
|
||||
];
|
||||
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();
|
||||
self.added_entries(&mut rows);
|
||||
rows
|
||||
}
|
||||
|
||||
@@ -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,14 +2134,7 @@ 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));
|
||||
@@ -2247,7 +2240,7 @@ impl<S: Screen> Shell<S> {
|
||||
// Labelled for the widget tree: what the strip says is what a test or an agent reads.
|
||||
crate::widgets::record(about, cols.dock, false);
|
||||
let color = if why.is_some() {
|
||||
style::muted()
|
||||
style::MUTED
|
||||
} else {
|
||||
style::GREY
|
||||
};
|
||||
|
||||
@@ -304,19 +304,19 @@ impl StudioUi {
|
||||
|
||||
// Card background & highlight
|
||||
if is_selected {
|
||||
style::fill(batch, atlas, rect, style::open());
|
||||
style::fill(batch, atlas, rect, style::OPEN);
|
||||
} else if is_hovered {
|
||||
style::fill(batch, atlas, rect, style::hover());
|
||||
style::fill(batch, atlas, rect, style::HOVER);
|
||||
} else {
|
||||
style::well(batch, atlas, rect);
|
||||
}
|
||||
|
||||
let border_color = if is_selected {
|
||||
style::accent()
|
||||
style::ACCENT
|
||||
} else if is_hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
style::row_rule()
|
||||
style::ROW_RULE
|
||||
};
|
||||
style::ring(batch, atlas, rect, border_color);
|
||||
|
||||
|
||||
@@ -119,11 +119,14 @@ 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, and ray tracing on
|
||||
# request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends, and ray
|
||||
# tracing on request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
|
||||
# client is best effort: CI builds only the default.
|
||||
horizon-extras = ["horizon-client-render/horizon-extras", "friends"]
|
||||
horizon-extras = ["horizon-client-lib/horizon-extras", "horizon-client-render/horizon-extras", "discord", "friends"]
|
||||
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
|
||||
# on Discord (game/discord.rs).
|
||||
discord = ["horizon-client-lib/discord"]
|
||||
# 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"]
|
||||
|
||||
@@ -1208,6 +1208,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
others_grid: $grid,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod animate_tick;
|
||||
pub mod arrows;
|
||||
pub mod bed_geometry;
|
||||
pub mod block_contact;
|
||||
pub mod parallel;
|
||||
pub mod powder_snow;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, audio, auth, autotune, ban_notice, bidi, biome, block_edit,
|
||||
@@ -17,8 +18,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, menu_style, 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, mesher, minimap,
|
||||
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
|
||||
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
|
||||
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
|
||||
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
|
||||
@@ -58,6 +59,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;
|
||||
|
||||
+29
-189
@@ -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,141 +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). One presence per computer: the first splitscreen player's
|
||||
/// switch, in their settings, on their Extras page; the other players have none.
|
||||
static ON: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
/// Whether the player whose turn it is owns the switch: the first one.
|
||||
fn first_player() -> bool {
|
||||
hooks::seat() == 0
|
||||
}
|
||||
|
||||
/// Turns the presence on or off; off clears the activity at once.
|
||||
pub fn set_enabled(on: bool) {
|
||||
ON.store(on, Ordering::Relaxed);
|
||||
let mut shared = lock();
|
||||
let status = shown(shared.game);
|
||||
let changed = shared.wanted.update(status, unix_now());
|
||||
drop(shared);
|
||||
if let Some(thread) = THREAD.get().filter(|_| changed) {
|
||||
thread.unpark();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enabled() -> bool {
|
||||
ON.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// What Discord is told for the game's `status`: nothing with the switch off.
|
||||
fn shown(status: Status) -> Status {
|
||||
if enabled() { status } else { Status::Off }
|
||||
}
|
||||
|
||||
/// The switch's line of `horizon-extras.txt`, for `horizon_platform::settings::register`.
|
||||
pub static SETTING: Setting = Setting;
|
||||
|
||||
pub struct Setting;
|
||||
|
||||
impl Store for Setting {
|
||||
fn file(&self) -> &'static str {
|
||||
"horizon-extras.txt"
|
||||
}
|
||||
fn owns(&self, key: &str) -> bool {
|
||||
key == KEY
|
||||
}
|
||||
fn load(&self, _key: &str, value: &str) {
|
||||
if !first_player() {
|
||||
return;
|
||||
}
|
||||
match value {
|
||||
"true" => set_enabled(true),
|
||||
"false" => set_enabled(false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
|
||||
if first_player() {
|
||||
line(KEY, if enabled() { "true" } else { "false" });
|
||||
}
|
||||
}
|
||||
fn is_default(&self) -> bool {
|
||||
enabled() || !first_player()
|
||||
}
|
||||
}
|
||||
|
||||
/// The switch's row in the Extras page's Multiplayer group, for
|
||||
/// `horizon_platform::pages::register`.
|
||||
pub static SECTION: Section = Section;
|
||||
|
||||
pub struct Section;
|
||||
|
||||
impl Page for Section {
|
||||
fn id(&self) -> &'static str {
|
||||
"horizon-discord.extras"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.extras.header.multiplayer"
|
||||
}
|
||||
fn place(&self) -> Place {
|
||||
Place::Extras
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<Row>) {
|
||||
if !first_player() {
|
||||
return;
|
||||
}
|
||||
rows.push(Row::Toggle {
|
||||
label: LABEL,
|
||||
on: enabled(),
|
||||
});
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
if row == 0 && first_player() && matches!(act, Act::Press | Act::PressBack) {
|
||||
set_enabled(!enabled());
|
||||
}
|
||||
}
|
||||
fn tooltip(&self, _row: usize) -> Option<Label> {
|
||||
Some(Label::Key(ABOUT))
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
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)]
|
||||
@@ -223,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,
|
||||
@@ -244,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")
|
||||
@@ -253,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)
|
||||
@@ -264,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();
|
||||
}
|
||||
}
|
||||
@@ -711,37 +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_at_once() {
|
||||
let wanted = || lock().wanted.status;
|
||||
Setting.load(KEY, "false");
|
||||
set(Status::Menus);
|
||||
assert!(!enabled());
|
||||
assert_eq!(wanted(), Status::Off);
|
||||
let mut rows = Vec::new();
|
||||
SECTION.rows(&mut rows);
|
||||
assert_eq!(
|
||||
rows,
|
||||
[Row::Toggle {
|
||||
label: LABEL,
|
||||
on: false
|
||||
}]
|
||||
);
|
||||
let mut lines = Vec::new();
|
||||
Setting.save(&mut |k, v| lines.push(format!("{k}:{v}")));
|
||||
assert_eq!(lines, ["discordPresence:false"]);
|
||||
assert!(!Setting.is_default());
|
||||
// On from the page: what the game said last is shown at once.
|
||||
SECTION.act(0, Act::Press);
|
||||
assert_eq!(wanted(), Status::Menus);
|
||||
assert!(Setting.is_default());
|
||||
SECTION.act(0, Act::Press);
|
||||
assert_eq!(wanted(), Status::Off);
|
||||
SECTION.act(0, Act::Press);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn talks_to_discord_over_its_socket() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-discord-{}", std::process::id()));
|
||||
@@ -84,7 +84,9 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
|
||||
mod fluids;
|
||||
mod item_fall;
|
||||
mod item_no_physics;
|
||||
mod obstacles;
|
||||
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
|
||||
pub use obstacles::ObstacleGrid;
|
||||
|
||||
#[cfg(test)]
|
||||
mod drop_motion_tests {
|
||||
@@ -1111,110 +1113,6 @@ fn lava_cauldron_contacts(
|
||||
}
|
||||
contacts
|
||||
}
|
||||
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
|
||||
/// binned by 8-block cell, so an item looks only at the ones near it: a farm with many drops and
|
||||
/// many vehicles checks a handful of boxes per item, not every box in the level.
|
||||
#[derive(Default)]
|
||||
pub struct ObstacleGrid {
|
||||
cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>>,
|
||||
boxes: Vec<Aabb>,
|
||||
/// Each box's entity's `getRootVehicle` when it rides something; empty
|
||||
/// when none was given.
|
||||
roots: Vec<Option<u32>>,
|
||||
found: Vec<u32>,
|
||||
}
|
||||
impl ObstacleGrid {
|
||||
const CELL: f64 = 8.0;
|
||||
pub fn new(boxes: Vec<Aabb>) -> Self {
|
||||
Self::with_roots(boxes, Vec::new())
|
||||
}
|
||||
/// [`Self::new`] with the vehicle at the bottom of what each box's entity
|
||||
/// rides (`roots[i]` for `boxes[i]`), so [`Self::near_except`] can leave a
|
||||
/// mover's own riders out.
|
||||
pub fn with_roots(boxes: Vec<Aabb>, roots: Vec<Option<u32>>) -> Self {
|
||||
let mut cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
|
||||
for (i, b) in boxes.iter().enumerate() {
|
||||
let (lo, hi) = Self::span(*b);
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
cells.entry((x, y, z)).or_default().push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
cells,
|
||||
boxes,
|
||||
roots,
|
||||
found: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
|
||||
let cell = |v: Vec3| {
|
||||
(
|
||||
(v.x / Self::CELL).floor() as i32,
|
||||
(v.y / Self::CELL).floor() as i32,
|
||||
(v.z / Self::CELL).floor() as i32,
|
||||
)
|
||||
};
|
||||
(cell(b.min_position()), cell(b.max_position()))
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.boxes.is_empty()
|
||||
}
|
||||
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
|
||||
pub fn near(&mut self, area: Aabb, out: &mut Vec<Aabb>) {
|
||||
self.near_except(area, None, out);
|
||||
}
|
||||
/// [`Self::near`] without the boxes of what rides `vehicle` (given with
|
||||
/// [`Self::with_roots`]): `Entity.canCollideWith` leaves out
|
||||
/// `isPassengerOfSameVehicle`, so a stand's riders do not stop it.
|
||||
pub fn near_except(&mut self, area: Aabb, vehicle: Option<u32>, out: &mut Vec<Aabb>) {
|
||||
out.clear();
|
||||
if self.boxes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let kept = |i: usize| vehicle.is_none() || self.roots.get(i).copied().flatten() != vehicle;
|
||||
let (lo, hi) = Self::span(area);
|
||||
let side = |lo: i32, hi: i32| (i64::from(hi) - i64::from(lo) + 1).max(0);
|
||||
let cells = side(lo.0, hi.0)
|
||||
.saturating_mul(side(lo.1, hi.1))
|
||||
.saturating_mul(side(lo.2, hi.2));
|
||||
// An area over more cells than there are boxes (a stand loaded with a
|
||||
// huge Motion asks for one millions of blocks long): test each box
|
||||
// once rather than walk the cells.
|
||||
if cells > self.boxes.len() as i64 {
|
||||
out.extend(
|
||||
self.boxes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(i, b)| kept(i) && b.intersects(area))
|
||||
.map(|(_, b)| *b),
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.found.clear();
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
if let Some(list) = self.cells.get(&(x, y, z)) {
|
||||
self.found.extend(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.found.sort_unstable();
|
||||
self.found.dedup();
|
||||
for &i in &self.found {
|
||||
let b = self.boxes[i as usize];
|
||||
if kept(i as usize) && b.intersects(area) {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropped items by block cell, for `ItemEntity.mergeWithNeighbours`'s
|
||||
/// search around an item. Holds indexes into the tick's item list.
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! The boxes that stop a dropped item, by cell (`ObstacleGrid`).
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
|
||||
/// binned by 8-block cell, so an item looks only at the ones near it: a farm with many drops and
|
||||
/// many vehicles checks a handful of boxes per item, not every box in the level.
|
||||
#[derive(Default)]
|
||||
pub struct ObstacleGrid {
|
||||
cells: FxHashMap<(i32, i32, i32), Vec<u32>>,
|
||||
boxes: Vec<Aabb>,
|
||||
/// Each box's entity's `getRootVehicle` when it rides something; empty
|
||||
/// when none was given.
|
||||
roots: Vec<Option<u32>>,
|
||||
found: Vec<u32>,
|
||||
}
|
||||
impl ObstacleGrid {
|
||||
const CELL: f64 = 8.0;
|
||||
pub fn new(boxes: Vec<Aabb>) -> Self {
|
||||
Self::with_roots(boxes, Vec::new())
|
||||
}
|
||||
/// [`Self::new`] with the vehicle at the bottom of what each box's entity
|
||||
/// rides (`roots[i]` for `boxes[i]`), so [`Self::near_except`] can leave a
|
||||
/// mover's own riders out.
|
||||
pub fn with_roots(boxes: Vec<Aabb>, roots: Vec<Option<u32>>) -> Self {
|
||||
let mut cells: FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
|
||||
for (i, b) in boxes.iter().enumerate() {
|
||||
let (lo, hi) = Self::span(*b);
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
cells.entry((x, y, z)).or_default().push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self {
|
||||
cells,
|
||||
boxes,
|
||||
roots,
|
||||
found: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
|
||||
let cell = |v: Vec3| {
|
||||
(
|
||||
(v.x / Self::CELL).floor() as i32,
|
||||
(v.y / Self::CELL).floor() as i32,
|
||||
(v.z / Self::CELL).floor() as i32,
|
||||
)
|
||||
};
|
||||
(cell(b.min_position()), cell(b.max_position()))
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.boxes.is_empty()
|
||||
}
|
||||
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
|
||||
pub fn near(&mut self, area: Aabb, out: &mut Vec<Aabb>) {
|
||||
self.near_except(area, None, out);
|
||||
}
|
||||
/// [`Self::near`] without the boxes of what rides `vehicle` (given with
|
||||
/// [`Self::with_roots`]): `Entity.canCollideWith` leaves out
|
||||
/// `isPassengerOfSameVehicle`, so a stand's riders do not stop it.
|
||||
pub fn near_except(&mut self, area: Aabb, vehicle: Option<u32>, out: &mut Vec<Aabb>) {
|
||||
let mut found = std::mem::take(&mut self.found);
|
||||
self.near_with(area, vehicle, &mut found, out);
|
||||
self.found = found;
|
||||
}
|
||||
/// [`Self::near`] with the caller's scratch, so several threads can ask one grid.
|
||||
pub fn near_shared(&self, area: Aabb, found: &mut Vec<u32>, out: &mut Vec<Aabb>) {
|
||||
self.near_with(area, None, found, out);
|
||||
}
|
||||
fn near_with(
|
||||
&self,
|
||||
area: Aabb,
|
||||
vehicle: Option<u32>,
|
||||
found: &mut Vec<u32>,
|
||||
out: &mut Vec<Aabb>,
|
||||
) {
|
||||
out.clear();
|
||||
if self.boxes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let kept = |i: usize| vehicle.is_none() || self.roots.get(i).copied().flatten() != vehicle;
|
||||
let (lo, hi) = Self::span(area);
|
||||
let side = |lo: i32, hi: i32| (i64::from(hi) - i64::from(lo) + 1).max(0);
|
||||
let cells = side(lo.0, hi.0)
|
||||
.saturating_mul(side(lo.1, hi.1))
|
||||
.saturating_mul(side(lo.2, hi.2));
|
||||
// An area over more cells than there are boxes (a stand loaded with a
|
||||
// huge Motion asks for one millions of blocks long): test each box
|
||||
// once rather than walk the cells.
|
||||
if cells > self.boxes.len() as i64 {
|
||||
out.extend(
|
||||
self.boxes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(i, b)| kept(i) && b.intersects(area))
|
||||
.map(|(_, b)| *b),
|
||||
);
|
||||
return;
|
||||
}
|
||||
found.clear();
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
if let Some(list) = self.cells.get(&(x, y, z)) {
|
||||
found.extend(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found.sort_unstable();
|
||||
found.dedup();
|
||||
for &i in found.iter() {
|
||||
let b = self.boxes[i as usize];
|
||||
if kept(i as usize) && b.intersects(area) {
|
||||
out.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
log::warn!("Trait mods already installed; ignoring the second list");
|
||||
}
|
||||
horizon_threads::configure_allocator();
|
||||
crate::horizon_look::give();
|
||||
// OptiFine's and Polytone's pack formats (platform, docs/layers.md), before any pack loads.
|
||||
horizon_client::pack_formats::install();
|
||||
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
|
||||
|
||||
@@ -49,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;
|
||||
@@ -6716,8 +6717,8 @@ impl Game<'_> {
|
||||
.extend(out.drain(player_start..));
|
||||
}
|
||||
self.mark_motion(out.len(), 0);
|
||||
// What crates draw around players: first person, see-through (game/player_hooks.rs).
|
||||
self.players_after_geometry(camera, alpha, &mut out);
|
||||
// Companions in first person, trails and auras (game/cosmetics/fx.rs).
|
||||
self.cosmetics_fx_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:evoker_fangs");
|
||||
self.fangs_geometry(camera, alpha, &mut out);
|
||||
self.block_item_display_geometry(camera, alpha, &mut out);
|
||||
@@ -11252,7 +11253,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();
|
||||
@@ -12527,8 +12529,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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1278,21 +1278,10 @@ impl Game<'_> {
|
||||
.raw
|
||||
.get(&id)
|
||||
.is_some_and(|tag| horizon_client::entity_nbt::flag(tag, "Invulnerable"));
|
||||
// Entity.isInvulnerableTo: the flag, then EnchantmentHelper.isImmuneToDamage.
|
||||
let a = &self.armor_stands[i];
|
||||
if (invulnerable && !creative)
|
||||
|| a.invisible
|
||||
|| a.marker
|
||||
|| self.stand_immune_to_damage(i, damage_type, causing)
|
||||
{
|
||||
if (invulnerable && !creative) || a.invisible || a.marker {
|
||||
return false;
|
||||
}
|
||||
// ArmorStand.hurtServer attributes its game events to the source
|
||||
// entity: the player, a LAN guest or the mob.
|
||||
let by = match causing {
|
||||
Some(Target::Player | Target::Guest(_) | Target::Mob(_)) => causing,
|
||||
_ => None,
|
||||
};
|
||||
let (explosion, ignites, burns) = (
|
||||
tagged("minecraft:is_explosion"),
|
||||
tagged("minecraft:ignites_armor_stands"),
|
||||
@@ -1303,26 +1292,21 @@ impl Game<'_> {
|
||||
tagged("minecraft:always_kills_armor_stands"),
|
||||
);
|
||||
if explosion {
|
||||
self.break_armor_stand_with_source(i, by);
|
||||
self.break_armor_stand_by_anything(i);
|
||||
return false;
|
||||
}
|
||||
if ignites {
|
||||
if self.armor_stands[i].fire_ticks > 0 {
|
||||
self.cause_stand_damage(i, 0.15, by);
|
||||
self.cause_stand_damage(i, 0.15);
|
||||
} else {
|
||||
// igniteForSeconds(5.0f): at least 100 ticks, and
|
||||
// Entity.igniteForTicks calls clearFreeze.
|
||||
// igniteForSeconds(5.0f): at least 100 ticks.
|
||||
let a = &mut self.armor_stands[i];
|
||||
a.fire_ticks = a.fire_ticks.max(100);
|
||||
if let Some(horizon_world::nbt::Tag::Compound(map)) = self.persist.raw.get_mut(&id)
|
||||
{
|
||||
map.remove(&"TicksFrozen".into());
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if burns && self.armor_stands[i].health > 0.5 {
|
||||
self.cause_stand_damage(i, 4.0, by);
|
||||
self.cause_stand_damage(i, 4.0);
|
||||
return false;
|
||||
}
|
||||
if !breaks && !always_kills {
|
||||
@@ -1337,85 +1321,22 @@ impl Game<'_> {
|
||||
if !may_build {
|
||||
return false;
|
||||
}
|
||||
self.break_armor_stand_by_hit(i, creative, always_kills, by)
|
||||
}
|
||||
/// `EnchantmentHelper.isImmuneToDamage` for the stand at `i`: whether an
|
||||
/// enchantment on an active equipment slot has a `minecraft:damage_immunity`
|
||||
/// effect whose requirements hold for `damage_type` and `causing`.
|
||||
fn stand_immune_to_damage(
|
||||
&mut self,
|
||||
i: usize,
|
||||
damage_type: &str,
|
||||
causing: Option<super::commands::Target>,
|
||||
) -> bool {
|
||||
use super::commands::Target;
|
||||
let mut checks = Vec::new();
|
||||
let equipment = &self.armor_stands[i].equipment;
|
||||
for (stack, slot) in equipment.iter().zip(ArmorStand::SLOTS) {
|
||||
let Some(stack) = stack else { continue };
|
||||
let armor = matches!(slot, "head" | "chest" | "legs" | "feet");
|
||||
let hand = matches!(slot, "mainhand" | "offhand");
|
||||
for (id, level) in stack.enchantments() {
|
||||
let Some(def) = self.res.stations().enchantments.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if !def.slots.iter().any(|s| {
|
||||
s == slot || s == "any" || (s == "armor" && armor) || (s == "hand" && hand)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let immunity = &def.effects["minecraft:damage_immunity"];
|
||||
for entry in immunity.as_array().into_iter().flatten() {
|
||||
checks.push((entry.get("requirements").cloned(), *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
if checks.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let attacker = match causing {
|
||||
Some(Target::Player | Target::Guest(_)) => Some("minecraft:player".to_owned()),
|
||||
Some(Target::Mob(m_id)) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == m_id)
|
||||
.map(|m| m.kind.id.clone()),
|
||||
Some(Target::Stand(_)) => Some("minecraft:armor_stand".to_owned()),
|
||||
// ArmorStand.hurtServer attributes its game events to the source
|
||||
// entity: the player, a LAN guest or the mob.
|
||||
let by = match causing {
|
||||
Some(Target::Player | Target::Guest(_) | Target::Mob(_)) => causing,
|
||||
_ => None,
|
||||
};
|
||||
let canonical = if damage_type.contains(':') {
|
||||
damage_type.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{damage_type}")
|
||||
};
|
||||
let ctx = horizon_client::enchant::EffectContext {
|
||||
items: Some(self.res.items()),
|
||||
damage_type: Some(&canonical),
|
||||
is_direct: attacker.is_some(),
|
||||
this: Some("minecraft:armor_stand"),
|
||||
attacker: attacker.as_deref(),
|
||||
direct_attacker: attacker.as_deref(),
|
||||
..Default::default()
|
||||
};
|
||||
let ench = &self.res.stations().enchantments;
|
||||
let rng = &mut self.world.random;
|
||||
for (req, level) in checks {
|
||||
if req.is_none_or(|r| ench.condition(&r, level, &ctx, rng)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
self.break_armor_stand_by_hit(i, creative, always_kills, by)
|
||||
}
|
||||
/// `ArmorStand.causeDamage`: the health less `amount`, broken by anything
|
||||
/// at 0.5 or less, else `gameEvent(ENTITY_DAMAGE, damageSource.getEntity())`.
|
||||
fn cause_stand_damage(&mut self, i: usize, amount: f32, by: Option<super::commands::Target>) {
|
||||
/// at 0.5 or less.
|
||||
fn cause_stand_damage(&mut self, i: usize, amount: f32) {
|
||||
let health = self.armor_stands[i].health - amount;
|
||||
if health <= 0.5 {
|
||||
self.break_armor_stand_with_source(i, by);
|
||||
self.break_armor_stand_by_anything(i);
|
||||
} else {
|
||||
let at = self.armor_stands[i].position;
|
||||
self.armor_stands[i].health = health;
|
||||
self.stand_game_event("minecraft:entity_damage", [at.x, at.y, at.z], by);
|
||||
}
|
||||
}
|
||||
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
|
||||
|
||||
@@ -239,6 +239,19 @@ impl Game<'_> {
|
||||
self.tick_scheduled()?;
|
||||
}
|
||||
self.with_level(|b, level, ticks| {
|
||||
// The masks the random ticks will read, built on helper threads first while the
|
||||
// parallel tick is on (`HORIZON_PARALLEL_TICK`): the draws and ticks don't depend on them.
|
||||
if !debug && horizon_client::parallel::lanes() > 1 {
|
||||
horizon_client::spans::time("random_tick_masks", || {
|
||||
let chunks = &level.world.chunks;
|
||||
level.random_tick_sections.prepare_masks(
|
||||
&scratch.ticking,
|
||||
|key| chunks.get(&key).cloned(),
|
||||
level.facts,
|
||||
&|id| b.random_tick_candidate(BlockStateId(id)),
|
||||
);
|
||||
});
|
||||
}
|
||||
horizon_client::spans::time("random_ticks", || {
|
||||
for (x, z) in scratch.ticking.drain(..).filter(|_| !debug) {
|
||||
// The last answer, asked from Y `from` at a world revision:
|
||||
|
||||
@@ -682,19 +682,14 @@ impl Game<'_> {
|
||||
/// of it (`ClipContext.Block.FALLDAMAGE_RESETTING`, `ClipContext.Fluid.WATER`)
|
||||
/// and a hit resets the fall, before the move's own drop counts.
|
||||
pub(super) fn vehicle_fall_reset(&self, fall: f64, from: Vec3, moved: Vec3) -> f64 {
|
||||
let length = moved.length_squared();
|
||||
if fall == 0.0 || length < 1.0 {
|
||||
return fall;
|
||||
}
|
||||
let reset = &self.res.motion().fall_reset;
|
||||
let hit = horizon_client::entities::fall_reset_hit(
|
||||
fall_reset(
|
||||
&self.world,
|
||||
&self.res.motion().fluids,
|
||||
|p| reset.contains(&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name),
|
||||
self.res.motion(),
|
||||
self.res.facts(),
|
||||
fall,
|
||||
from,
|
||||
moved,
|
||||
);
|
||||
if hit { 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
|
||||
@@ -2071,3 +2066,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
horizon_client::impl_joined!(Boat);
|
||||
|
||||
/// [`Game::vehicle_fall_reset`] from the tables it reads.
|
||||
pub(super) fn fall_reset(
|
||||
world: &horizon_client::world::World,
|
||||
motion: &horizon_client::motion::MotionResources,
|
||||
facts: &horizon_client::blocks::BlockFacts,
|
||||
fall: f64,
|
||||
from: Vec3,
|
||||
moved: Vec3,
|
||||
) -> f64 {
|
||||
if fall == 0.0 || moved.length_squared() < 1.0 {
|
||||
return fall;
|
||||
}
|
||||
let reset = &motion.fall_reset;
|
||||
let hit = horizon_client::entities::fall_reset_hit(
|
||||
world,
|
||||
&motion.fluids,
|
||||
|p| reset.contains(&*facts.get(world.block(p.x, p.y, p.z)).name),
|
||||
from,
|
||||
moved,
|
||||
);
|
||||
if hit { 0.0 } else { fall }
|
||||
}
|
||||
@@ -380,7 +380,7 @@ impl Game<'_> {
|
||||
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
|
||||
super::lan::mob_meta(
|
||||
m,
|
||||
hub,
|
||||
hub.sync_data(),
|
||||
None,
|
||||
g.world.game_time,
|
||||
&mut meta,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -71,6 +71,11 @@ pub(in crate::game) fn pose_playing(pose: Option<&Arc<dyn BodyPose>>) -> Option<
|
||||
any.downcast_ref::<EmotePose>().map(|e| &e.0)
|
||||
}
|
||||
|
||||
/// The emote `m` plays, if any.
|
||||
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
|
||||
pose_playing(m.emote.as_ref())
|
||||
}
|
||||
|
||||
/// The pose that plays `next` where `now` is laid, unless it plays that already (None: keep
|
||||
/// it; a new one allocates).
|
||||
fn replaced(
|
||||
|
||||
@@ -1,78 +1,47 @@
|
||||
//! Companions, trails and auras ([docs/cosmetics.md](../../../../../docs/cosmetics.md)): worn
|
||||
//! like the other cosmetics but on no bone. A companion floats beside its wearer and follows with
|
||||
//! a lag (`horizon_cosmetics::follow`), drawn with the wearer; a trail's particles
|
||||
//! (`horizon_cosmetics::trail`) and an aura blend, so they are drawn see-through after every
|
||||
//! player (`PlayerDraw::particles`, `Look::see_through`). Nothing of them is an entity, collides
|
||||
//! or is sent to a server.
|
||||
//! (`horizon_cosmetics::trail`) and an aura blend, so they go after every player in one
|
||||
//! see-through span (`cosmetics_fx_geometry`). Nothing of them is an entity, collides or is sent
|
||||
//! to a server.
|
||||
|
||||
use super::Seat;
|
||||
use super::{FULL_LIGHT, OWN};
|
||||
use crate::game::Game;
|
||||
use glam::{DVec3, Mat4, Vec3};
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::mesher::Vertex;
|
||||
use horizon_client::mobs::anim::Poses;
|
||||
use horizon_client::mobs::{Mob, Part};
|
||||
use horizon_cosmetics::follow::{self, Follow};
|
||||
use horizon_cosmetics::trail::{Budget, Emitter};
|
||||
use horizon_cosmetics::trail::Emitter;
|
||||
use horizon_cosmetics::{Item, Slot};
|
||||
use horizon_hooks::players::{Kind, Light, Look, Particle, Pass, PlayerDraw, PlayerModel};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
/// How far from the camera (blocks) a wearer's companion, trail and aura are drawn.
|
||||
const FAR: f32 = 48.0;
|
||||
|
||||
/// The key of the player's own model in `Fx`'s maps (others go by entity id).
|
||||
const OWN: u32 = u32::MAX;
|
||||
|
||||
/// What companions and trails keep between frames, per wearer (marked with the frame they were
|
||||
/// last drawn in; the others are dropped).
|
||||
#[derive(Default)]
|
||||
pub(super) struct Fx {
|
||||
companions: FxHashMap<u32, (Follow, u32)>,
|
||||
trails: FxHashMap<u32, (Emitter, u32)>,
|
||||
budget: Budget,
|
||||
clock: Option<(Instant, Instant)>,
|
||||
/// Ticks since the first frame, and the last frame's length in seconds.
|
||||
now: f64,
|
||||
dt: f32,
|
||||
frame: u32,
|
||||
/// Kept so a frame of trails allocates none.
|
||||
particles: Vec<Particle>,
|
||||
/// Who wears it, as drawn this frame.
|
||||
pub(super) struct Wearer<'a> {
|
||||
pub m: &'a Mob,
|
||||
/// The player's own model.
|
||||
pub own: bool,
|
||||
/// `OWN`, or the entity id.
|
||||
pub key: u32,
|
||||
/// The wearer's model matrix (`render::base_transform`), relative to `camera`.
|
||||
pub base: Mat4,
|
||||
pub camera: DVec3,
|
||||
pub alpha: f32,
|
||||
pub light: u32,
|
||||
}
|
||||
|
||||
impl Fx {
|
||||
/// A new frame: the clock moves on and what no frame drew since the last one goes.
|
||||
pub(super) fn frame(&mut self) {
|
||||
self.frame = self.frame.wrapping_add(1);
|
||||
let frame = self.frame;
|
||||
let now = Instant::now();
|
||||
let (start, last) = self.clock.get_or_insert((now, now));
|
||||
self.dt = now.duration_since(*last).as_secs_f32().min(0.25);
|
||||
self.now = now.duration_since(*start).as_secs_f64() * 20.0;
|
||||
*last = now;
|
||||
let seen = |mark: u32| frame.wrapping_sub(mark) <= 1;
|
||||
self.companions.retain(|_, (_, mark)| seen(*mark));
|
||||
let budget = &mut self.budget;
|
||||
self.trails.retain(|_, (e, mark)| {
|
||||
let keep = seen(*mark);
|
||||
if !keep {
|
||||
e.clear(budget);
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
/// `m`'s head yaw this frame (degrees).
|
||||
fn head_yaw(m: &Mob, alpha: f32) -> f32 {
|
||||
let turn = horizon_client::mobs::living::wrap(m.head_yaw - m.previous_head_yaw);
|
||||
m.previous_head_yaw + turn * alpha
|
||||
}
|
||||
|
||||
/// The companion of wearer `key` moved toward `target` (once a frame, however many times it
|
||||
/// is drawn).
|
||||
fn follow(&mut self, key: u32, target: DVec3, yaw: f32, lag: f32) -> Follow {
|
||||
let (frame, dt) = (self.frame, self.dt);
|
||||
let (f, mark) = self
|
||||
.companions
|
||||
.entry(key)
|
||||
.or_insert((Follow::at(target, yaw), frame));
|
||||
if *mark != frame {
|
||||
f.step(target, yaw, lag, dt);
|
||||
*mark = frame;
|
||||
}
|
||||
*f
|
||||
}
|
||||
/// Whether `m` lies in a bed.
|
||||
fn asleep(m: &Mob) -> bool {
|
||||
m.extra.avatar_pose == horizon_client::pose::Pose::Sleeping
|
||||
}
|
||||
|
||||
/// Where a companion floats: `at` (relative to the camera) facing `yaw`, at the wearer's scale,
|
||||
@@ -83,144 +52,270 @@ fn companion_matrix(at: Vec3, yaw: f32, scale: f32) -> Mat4 {
|
||||
* Mat4::from_scale(Vec3::new(-scale, -scale, scale))
|
||||
}
|
||||
|
||||
/// Full light for what glows, else `otherwise`.
|
||||
fn glowing(glow: bool, otherwise: Light) -> Light {
|
||||
if glow { Light::Full } else { otherwise }
|
||||
}
|
||||
|
||||
impl Seat {
|
||||
/// A companion, trail or aura `m` wears, around its model as `draw` draws it. Other players'
|
||||
/// trails and auras stay hidden with "Hide other players' trails and auras".
|
||||
pub(super) fn fx(&mut self, m: &PlayerModel, item: &Arc<Item>, draw: &mut dyn PlayerDraw) {
|
||||
let own = m.kind == Kind::Own;
|
||||
if !own && item.slot.is_effect() && self.settings.hide_fx {
|
||||
return;
|
||||
impl Game<'_> {
|
||||
/// A companion, trail or aura `at` wears: a companion goes where the wearer's other items go
|
||||
/// (`gpu` and `out`, `bounds` grown), trails and auras to the frame's see-through vertices.
|
||||
/// Other players' trails and auras stay hidden with "Hide other players' trails and auras".
|
||||
pub(super) fn cosmetics_fx(
|
||||
&self,
|
||||
at: &Wearer,
|
||||
item: &Arc<Item>,
|
||||
gpu: bool,
|
||||
bounds: Option<(Vec3, Vec3)>,
|
||||
poses: &mut Poses,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> Option<(Vec3, Vec3)> {
|
||||
if !at.own && item.slot.is_effect() && self.settings.extras.cosmetics_hide_fx {
|
||||
return bounds;
|
||||
}
|
||||
// Past `FAR` a companion, trail or aura is a few pixels across for the cost of a model:
|
||||
// not drawn, and a trail that isn't drawn doesn't spawn (its particles go back to the
|
||||
// budget).
|
||||
let base = Mat4::from_cols_array_2d(&draw.base());
|
||||
if base.w_axis.truncate().length_squared() > FAR * FAR {
|
||||
return;
|
||||
if at.base.w_axis.truncate().length_squared() > FAR * FAR {
|
||||
return bounds;
|
||||
}
|
||||
let Some(uv) = self.uv(item, |id| draw.sprite(id)) else {
|
||||
return;
|
||||
};
|
||||
let key = if own { OWN } else { m.id };
|
||||
match item.slot {
|
||||
Slot::Companion => self.companion(key, item, uv, base, draw),
|
||||
Slot::Trail => self.trail(key, item, uv, draw),
|
||||
Slot::Aura => self.aura(item, uv, base, draw),
|
||||
_ => {}
|
||||
Slot::Companion => {
|
||||
let Some((model, uv, matrix)) = self.companion_at(at, item, None) else {
|
||||
return bounds;
|
||||
};
|
||||
self.pose_companion(item, at.m, at.alpha, poses, &model);
|
||||
let glow = item.companion.as_ref().is_some_and(|c| c.glow);
|
||||
let light = if glow { FULL_LIGHT } else { at.light };
|
||||
self.cosmetics_model(&model, matrix, poses, uv, light, gpu, bounds, out)
|
||||
}
|
||||
Slot::Trail => {
|
||||
self.trail(at, item);
|
||||
bounds
|
||||
}
|
||||
Slot::Aura => {
|
||||
self.aura(at, item, poses);
|
||||
bounds
|
||||
}
|
||||
_ => bounds,
|
||||
}
|
||||
}
|
||||
|
||||
/// The companion where it floats, its follow moved on once a frame. In first person it keeps
|
||||
/// out of the middle of the view, lit where it floats.
|
||||
fn companion(
|
||||
&mut self,
|
||||
key: u32,
|
||||
/// The companion's model, texture and matrix this frame, its follow moved on once a frame.
|
||||
/// `first_person`: the camera's forward and left, for the player's own in first person: the
|
||||
/// companion keeps out of the middle of the view.
|
||||
fn companion_at(
|
||||
&self,
|
||||
at: &Wearer,
|
||||
item: &Arc<Item>,
|
||||
uv: [f32; 4],
|
||||
base: Mat4,
|
||||
draw: &mut dyn PlayerDraw,
|
||||
) {
|
||||
let Some(c) = item.companion.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some((_, mesh)) = self.kept(item).meshes.first().cloned() else {
|
||||
return;
|
||||
};
|
||||
let first_person = draw.pass() == Pass::FirstPerson;
|
||||
let (motion, view) = (draw.motion(), draw.view());
|
||||
let camera = DVec3::from(view.camera);
|
||||
let scale = base.x_axis.truncate().length();
|
||||
let target = base.transform_point3(Vec3::from(c.anchor) / 16.0);
|
||||
let head = base.transform_point3(Vec3::from(horizon_cosmetics::HEAD) / 16.0);
|
||||
let follow = self
|
||||
.fx
|
||||
.follow(key, camera + target.as_dvec3(), motion.head_yaw, c.lag);
|
||||
first_person: Option<(Vec3, Vec3)>,
|
||||
) -> Option<(Arc<Part>, [f32; 4], Mat4)> {
|
||||
let c = item.companion.as_ref()?;
|
||||
let (models, uv) = self.cosmetics_baked(item)?;
|
||||
let model = models.first()?.1.clone();
|
||||
let scale = at.base.x_axis.truncate().length();
|
||||
let target = at.base.transform_point3(Vec3::from(c.anchor) / 16.0);
|
||||
let head = at
|
||||
.base
|
||||
.transform_point3(Vec3::from(horizon_cosmetics::HEAD) / 16.0);
|
||||
let yaw = head_yaw(at.m, at.alpha);
|
||||
let follow = self.companion_follow(at.key, at.camera + target.as_dvec3(), yaw, c.lag);
|
||||
let clearance = (horizon_cosmetics::HEAD_CLEARANCE + c.reach) / 16.0 * scale;
|
||||
let mut rel = (follow.pos - camera).as_vec3();
|
||||
if first_person {
|
||||
rel = follow::out_of_view(rel, Vec3::from(view.forward), Vec3::from(view.left));
|
||||
let mut rel = (follow.pos - at.camera).as_vec3();
|
||||
if let Some((forward, left)) = first_person {
|
||||
rel = follow::out_of_view(rel, forward, left);
|
||||
}
|
||||
rel = follow::clear_of(rel, head, clearance);
|
||||
let matrix = companion_matrix(rel, follow.yaw, scale).to_cols_array_2d();
|
||||
let near = if first_person {
|
||||
Light::AtPlace
|
||||
} else {
|
||||
Light::Model
|
||||
};
|
||||
let look = Look::new(uv).lit(glowing(c.glow, near));
|
||||
let ticks = draw.ticks();
|
||||
// Its animations, and its `look` part turned with the wearer's pitch.
|
||||
let pitch = (motion.head_pitch * 0.6).clamp(-30.0, 30.0).to_radians();
|
||||
draw.draw(&mesh, matrix, look, &mut |p| {
|
||||
item.animate(ticks, |name, pose| *p.pose(name) = pose);
|
||||
if let Some(part) = c.look.as_deref() {
|
||||
p.pose(part)[3] += pitch;
|
||||
}
|
||||
});
|
||||
Some((model, uv, companion_matrix(rel, follow.yaw, scale)))
|
||||
}
|
||||
|
||||
/// The companion of wearer `key` moved toward `target` (once a frame, however many times it
|
||||
/// is drawn).
|
||||
fn companion_follow(&self, key: u32, target: DVec3, yaw: f32, lag: f32) -> Follow {
|
||||
let frame = self.cosmetics.frame.get();
|
||||
let mut fx = self.cosmetics.fx.borrow_mut();
|
||||
let dt = fx.dt;
|
||||
let (f, mark) = fx
|
||||
.companions
|
||||
.entry(key)
|
||||
.or_insert((Follow::at(target, yaw), frame));
|
||||
if *mark != frame {
|
||||
f.step(target, yaw, lag, dt);
|
||||
*mark = frame;
|
||||
}
|
||||
*f
|
||||
}
|
||||
|
||||
/// The companion's animations, and its `look` part turned with the wearer's pitch.
|
||||
fn pose_companion(&self, item: &Item, m: &Mob, alpha: f32, poses: &mut Poses, model: &Part) {
|
||||
poses.reset(model);
|
||||
item.animate(m.age as f32 + alpha, |name, pose| *poses.get(name) = pose);
|
||||
if let Some(look) = item.companion.as_ref().and_then(|c| c.look.as_deref()) {
|
||||
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * alpha;
|
||||
poses.get(look)[3] += (pitch * 0.6).clamp(-30.0, 30.0).to_radians();
|
||||
}
|
||||
}
|
||||
|
||||
/// The trail's particles: spawned once a frame by the distance walked (not while sneaking or
|
||||
/// asleep), facing the camera.
|
||||
fn trail(&mut self, key: u32, item: &Arc<Item>, uv: [f32; 4], draw: &mut dyn PlayerDraw) {
|
||||
fn trail(&self, at: &Wearer, item: &Arc<Item>) {
|
||||
let Some(t) = &item.trail else {
|
||||
return;
|
||||
};
|
||||
let (motion, view) = (draw.motion(), draw.view());
|
||||
let moves = !motion.crouching && !motion.asleep;
|
||||
let Fx {
|
||||
let Some((_, uv)) = self.cosmetics_baked(item) else {
|
||||
return;
|
||||
};
|
||||
let m = at.m;
|
||||
let a = f64::from(at.alpha);
|
||||
let feet = DVec3::new(
|
||||
m.previous.x + (m.position.x - m.previous.x) * a,
|
||||
m.previous.y + (m.position.y - m.previous.y) * a,
|
||||
m.previous.z + (m.position.z - m.previous.z) * a,
|
||||
);
|
||||
let moves = !m.extra.crouching && !asleep(m);
|
||||
let q = self.camera_orientation();
|
||||
let (right, up) = (q * Vec3::X, q * Vec3::Y);
|
||||
let light = if t.glow { FULL_LIGHT } else { at.light };
|
||||
let frame = self.cosmetics.frame.get();
|
||||
let mut fx = self.cosmetics.fx.borrow_mut();
|
||||
let super::Fx {
|
||||
trails,
|
||||
budget,
|
||||
now,
|
||||
frame,
|
||||
particles,
|
||||
clear,
|
||||
..
|
||||
} = &mut self.fx;
|
||||
} = &mut *fx;
|
||||
let (e, mark) = trails
|
||||
.entry(key)
|
||||
.entry(at.key)
|
||||
.or_insert_with(|| (Emitter::default(), frame.wrapping_sub(1)));
|
||||
if *mark != *frame {
|
||||
e.step(t, DVec3::from(motion.feet), moves, *now, budget);
|
||||
*mark = *frame;
|
||||
if *mark != frame {
|
||||
e.step(t, feet, moves, *now, budget);
|
||||
*mark = frame;
|
||||
}
|
||||
for quad in e.quads(t, *now, at.camera, right, up) {
|
||||
let color = ((quad.alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
for (corner, [u, v]) in quad.corners.iter().zip(quad.uv) {
|
||||
clear.push(Vertex {
|
||||
pos: corner.to_array(),
|
||||
uv: [uv[0] + (uv[2] - uv[0]) * u, uv[1] + (uv[3] - uv[1]) * v],
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
// Unlit: a particle keeps its colour whichever way it faces.
|
||||
normal: [0, 0, 0, 0],
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
let (camera, right, up) = (
|
||||
DVec3::from(view.camera),
|
||||
Vec3::from(view.right),
|
||||
Vec3::from(view.up),
|
||||
);
|
||||
particles.clear();
|
||||
particles.extend(
|
||||
e.quads(t, *now, camera, right, up)
|
||||
.map(|q| Particle::new(q.corners.map(|c| c.to_array()), q.uv, q.alpha)),
|
||||
);
|
||||
draw.particles(uv, particles, glowing(t.glow, Light::Model));
|
||||
}
|
||||
|
||||
/// The aura at the wearer's feet, see-through. None while they fly, swim or sleep: it would
|
||||
/// tip over with the body.
|
||||
fn aura(&mut self, item: &Arc<Item>, uv: [f32; 4], base: Mat4, draw: &mut dyn PlayerDraw) {
|
||||
let Some(a) = &item.aura else {
|
||||
fn aura(&self, at: &Wearer, item: &Arc<Item>, poses: &mut Poses) {
|
||||
let (m, Some(a)) = (at.m, &item.aura) else {
|
||||
return;
|
||||
};
|
||||
let motion = draw.motion();
|
||||
if motion.flying || motion.asleep || motion.swimming {
|
||||
if m.extra.fall_flying || asleep(m) || m.extra.swim.amount(at.alpha) > 0.0 {
|
||||
return;
|
||||
}
|
||||
let Some((_, mesh)) = self.kept(item).meshes.first().cloned() else {
|
||||
let Some((models, uv)) = self.cosmetics_baked(item) else {
|
||||
return;
|
||||
};
|
||||
let Some((_, model)) = models.first() else {
|
||||
return;
|
||||
};
|
||||
// The model's origin is the middle of the feet: 24 pixels under the neck.
|
||||
let matrix = base * Mat4::from_translation(Vec3::new(0.0, 24.0 / 16.0, 0.0));
|
||||
let look = Look::new(uv)
|
||||
.lit(glowing(a.glow, Light::Model))
|
||||
.see_through(a.alpha);
|
||||
let ticks = draw.ticks();
|
||||
draw.draw(&mesh, matrix.to_cols_array_2d(), look, &mut |p| {
|
||||
item.animate(ticks, |name, pose| *p.pose(name) = pose)
|
||||
let matrix = at.base * Mat4::from_translation(Vec3::new(0.0, 24.0 / 16.0, 0.0));
|
||||
poses.reset(model);
|
||||
item.animate(m.age as f32 + at.alpha, |name, pose| {
|
||||
*poses.get(name) = pose
|
||||
});
|
||||
let light = if a.glow { FULL_LIGHT } else { at.light };
|
||||
let mut fx = self.cosmetics.fx.borrow_mut();
|
||||
let start = fx.clear.len();
|
||||
horizon_client::mobs::render_model(
|
||||
model,
|
||||
matrix,
|
||||
&|p: &Part| poses.pose(p),
|
||||
uv,
|
||||
light,
|
||||
0,
|
||||
&mut fx.clear,
|
||||
);
|
||||
let color = ((a.alpha * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
for v in &mut fx.clear[start..] {
|
||||
v.color = color;
|
||||
// Seen through, a face turned away would add to the near one (and a card's two
|
||||
// faces would double it): `entity.wgsl` drops back faces, so it is `alpha` at most.
|
||||
v.normal[3] = horizon_client::mesher::SHADED_CULL_W;
|
||||
}
|
||||
}
|
||||
|
||||
/// After every player: the player's own companion in first person (out of the middle of the
|
||||
/// view, after the first-person model went to the shadow pass only), then the frame's trails
|
||||
/// and auras in one see-through span.
|
||||
pub(in crate::game) fn cosmetics_fx_geometry(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if !super::enabled() {
|
||||
return;
|
||||
}
|
||||
if !self.own_body_in_view()
|
||||
&& let Some(m) = self.player_model.as_ref()
|
||||
{
|
||||
self.mark_entity(out.len(), "minecraft:player");
|
||||
self.own_companion_first_person(m, camera, alpha as f32, out);
|
||||
}
|
||||
let fx = self.cosmetics.fx.borrow();
|
||||
if !fx.clear.is_empty() {
|
||||
self.mark_translucent_entity(out.len(), "minecraft:player");
|
||||
out.extend_from_slice(&fx.clear);
|
||||
}
|
||||
}
|
||||
|
||||
fn own_companion_first_person(
|
||||
&self,
|
||||
m: &Mob,
|
||||
camera: DVec3,
|
||||
alpha: f32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if horizon_client::mobs::trader::is_invisible(m) || self.own_invisible() {
|
||||
return;
|
||||
}
|
||||
let Some(worn) = self.worn(m, true) else {
|
||||
return;
|
||||
};
|
||||
let Some(item) = worn.iter().find(|i| i.slot == Slot::Companion) else {
|
||||
return;
|
||||
};
|
||||
let at = Wearer {
|
||||
m,
|
||||
own: true,
|
||||
key: OWN,
|
||||
base: horizon_client::mobs::render::base_transform(m, alpha, camera),
|
||||
camera,
|
||||
alpha,
|
||||
light: FULL_LIGHT,
|
||||
};
|
||||
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
|
||||
let forward = Vec3::new(
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
);
|
||||
let left = Vec3::new(yaw.cos(), 0.0, yaw.sin());
|
||||
let Some((model, uv, matrix)) = self.companion_at(&at, item, Some((forward, left))) else {
|
||||
return;
|
||||
};
|
||||
// Lit where it floats.
|
||||
let p = (camera + matrix.w_axis.truncate().as_dvec3()).floor();
|
||||
let light = self.world.light(p.x as i32, p.y as i32, p.z as i32);
|
||||
let mut light = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
if item.companion.as_ref().is_some_and(|c| c.glow) {
|
||||
light = FULL_LIGHT;
|
||||
}
|
||||
let mut poses = self.cosmetics.poses.borrow_mut();
|
||||
let poses = &mut poses.1;
|
||||
self.pose_companion(item, m, alpha, poses, &model);
|
||||
self.cosmetics_model(&model, matrix, poses, uv, light, false, None, out);
|
||||
}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
//! The cosmetics' settings (spec #14091 step 5a): their lines of `horizon-extras.txt`, the ones
|
||||
//! the launcher's Wardrobe shares (`horizon_cosmetics::prefs`), through the settings store
|
||||
//! ([`STORE`]), and their rows in the Extras page's Cosmetics group ([`SECTION`]). Each
|
||||
//! splitscreen player has their own, as their own settings file: they live in the player's
|
||||
//! [`Seat`].
|
||||
|
||||
use super::{Seat, service};
|
||||
use horizon_cosmetics::prefs::{self, Others};
|
||||
use horizon_hooks::pages::{Act, Label, Page, Place, Row};
|
||||
use horizon_hooks::settings::Store;
|
||||
|
||||
/// My cosmetics in single player too.
|
||||
const SOLO: &str = "cosmeticsSolo";
|
||||
|
||||
/// The settings, each with its line in `horizon-extras.txt`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct Settings {
|
||||
/// Show my cosmetics (the account's, told to the service; `prefs::MINE`).
|
||||
pub mine: bool,
|
||||
/// How much of other players' cosmetics shows: no line until the player chooses
|
||||
/// (`Seat::others`).
|
||||
pub others: Option<Others>,
|
||||
/// Other players' trails and auras hidden.
|
||||
pub hide_fx: bool,
|
||||
/// My cosmetics in single player too: the window's own server names the player by their
|
||||
/// offline profile, which the service doesn't know, so they show only with this on.
|
||||
pub solo: bool,
|
||||
/// Horizon's mark before Horizon players' names (name tags, player list).
|
||||
pub marks: bool,
|
||||
/// The service may say this player plays Horizon.
|
||||
pub listed: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mine: true,
|
||||
others: None,
|
||||
hide_fx: false,
|
||||
solo: false,
|
||||
marks: true,
|
||||
listed: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const KEYS: [&str; 6] = [
|
||||
prefs::MINE,
|
||||
prefs::OTHERS,
|
||||
prefs::HIDE_FX,
|
||||
SOLO,
|
||||
prefs::MARKS,
|
||||
prefs::LISTED,
|
||||
];
|
||||
|
||||
impl Settings {
|
||||
/// One line; a value it can't read keeps the setting.
|
||||
fn load(&mut self, key: &str, value: &str) {
|
||||
let flag = |was: bool| match value {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => was,
|
||||
};
|
||||
match key {
|
||||
prefs::MINE => self.mine = flag(self.mine),
|
||||
// `Others::parse` reads the old two-way switch too.
|
||||
prefs::OTHERS => self.others = Others::parse(value).or(self.others),
|
||||
prefs::HIDE_FX => self.hide_fx = flag(self.hide_fx),
|
||||
SOLO => self.solo = flag(self.solo),
|
||||
prefs::MARKS => self.marks = flag(self.marks),
|
||||
prefs::LISTED => self.listed = flag(self.listed),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Their lines; others' only once chosen.
|
||||
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
|
||||
let text = |on: bool| if on { "true" } else { "false" };
|
||||
line(prefs::MINE, text(self.mine));
|
||||
if let Some(others) = self.others {
|
||||
line(prefs::OTHERS, others.name());
|
||||
}
|
||||
line(prefs::HIDE_FX, text(self.hide_fx));
|
||||
line(SOLO, text(self.solo));
|
||||
line(prefs::MARKS, text(self.marks));
|
||||
line(prefs::LISTED, text(self.listed));
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings' lines of `horizon-extras.txt`, the player's own file in splitscreen: the game
|
||||
/// loads and saves it on the player's turn, so they are that seat's.
|
||||
pub(super) static STORE: Lines = Lines;
|
||||
|
||||
pub(super) struct Lines;
|
||||
|
||||
impl Store for Lines {
|
||||
fn file(&self) -> &'static str {
|
||||
prefs::FILE
|
||||
}
|
||||
fn owns(&self, key: &str) -> bool {
|
||||
KEYS.contains(&key)
|
||||
}
|
||||
fn load(&self, key: &str, value: &str) {
|
||||
service(|s| s.settings.load(key, value));
|
||||
}
|
||||
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
|
||||
service(|s| s.settings).save(line);
|
||||
}
|
||||
fn is_default(&self) -> bool {
|
||||
service(|s| s.settings) == Settings::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows, top to bottom.
|
||||
const ROWS: [&str; 6] = [
|
||||
"horizon.extras.cosmetics_mine",
|
||||
"horizon.extras.cosmetics_solo",
|
||||
"horizon.extras.cosmetics_others",
|
||||
"horizon.extras.cosmetics_hide_fx",
|
||||
"horizon.extras.horizon_marks",
|
||||
"horizon.extras.horizon_me",
|
||||
];
|
||||
|
||||
const TOOLTIPS: [&str; 6] = [
|
||||
"horizon.extras.cosmetics_mine.tooltip",
|
||||
"horizon.extras.cosmetics_solo.tooltip",
|
||||
"horizon.extras.cosmetics_others.tooltip",
|
||||
"horizon.extras.cosmetics_hide_fx.tooltip",
|
||||
"horizon.extras.horizon_marks.tooltip",
|
||||
"horizon.extras.horizon_me.tooltip",
|
||||
];
|
||||
|
||||
/// The rows in the Extras page's Cosmetics group, while cosmetics are offered
|
||||
/// ([`super::enabled`]).
|
||||
pub(super) static SECTION: Section = Section;
|
||||
|
||||
pub(super) struct Section;
|
||||
|
||||
impl Section {
|
||||
fn rows_of(seat: &Seat, rows: &mut Vec<Row>) {
|
||||
let s = &seat.settings;
|
||||
let shown = |on: bool| {
|
||||
Label::Key(if on {
|
||||
"horizon.extras.cosmetics.shown"
|
||||
} else {
|
||||
"horizon.extras.cosmetics.hidden"
|
||||
})
|
||||
};
|
||||
let others = Label::Key(match seat.others() {
|
||||
Others::Shown => "horizon.extras.cosmetics.shown",
|
||||
Others::NameTags => "horizon.extras.cosmetics.name_tags",
|
||||
Others::Hidden => "horizon.extras.cosmetics.hidden",
|
||||
});
|
||||
let toggle = |label, on| Row::Toggle { label, on };
|
||||
let cycle = |label, value| Row::Cycle { label, value };
|
||||
rows.extend([
|
||||
cycle(ROWS[0], shown(s.mine)),
|
||||
toggle(ROWS[1], s.solo),
|
||||
cycle(ROWS[2], others),
|
||||
toggle(ROWS[3], s.hide_fx),
|
||||
toggle(ROWS[4], s.marks),
|
||||
toggle(ROWS[5], s.listed),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A press on row `row`; `back` turns the three-way row the other way.
|
||||
fn act_on(seat: &mut Seat, row: usize, back: bool) {
|
||||
let s = &mut seat.settings;
|
||||
match row {
|
||||
0 => s.mine = !s.mine,
|
||||
1 => s.solo = !s.solo,
|
||||
2 => {
|
||||
// From what the row shows; the result is the player's choice from now on.
|
||||
let o = seat.others();
|
||||
seat.settings.others = Some(if back { o.next().next() } else { o.next() });
|
||||
}
|
||||
3 => s.hide_fx = !s.hide_fx,
|
||||
4 => s.marks = !s.marks,
|
||||
5 => s.listed = !s.listed,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Page for Section {
|
||||
fn id(&self) -> &'static str {
|
||||
"horizon-cosmetics.extras"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.extras.header.cosmetics"
|
||||
}
|
||||
fn place(&self) -> Place {
|
||||
Place::Extras
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<Row>) {
|
||||
if super::enabled() {
|
||||
service(|seat| Self::rows_of(seat, rows));
|
||||
}
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
let back = match act {
|
||||
Act::Press => false,
|
||||
Act::PressBack => true,
|
||||
_ => return,
|
||||
};
|
||||
service(|seat| Self::act_on(seat, row, back));
|
||||
}
|
||||
fn tooltip(&self, row: usize) -> Option<Label> {
|
||||
TOOLTIPS.get(row).copied().map(Label::Key)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn loaded(text: &str) -> Settings {
|
||||
let mut s = Settings::default();
|
||||
for (key, value) in text.lines().filter_map(|l| l.split_once(':')) {
|
||||
s.load(key.trim(), value.trim());
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn saved(s: &Settings) -> String {
|
||||
let mut text = String::new();
|
||||
s.save(&mut |k, v| text.push_str(&format!("{k}:{v}\n")));
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lines_round_trip_and_the_wardrobe_reads_them() {
|
||||
let s = Settings {
|
||||
mine: false,
|
||||
others: Some(Others::NameTags),
|
||||
hide_fx: true,
|
||||
solo: true,
|
||||
marks: false,
|
||||
listed: false,
|
||||
};
|
||||
let text = saved(&s);
|
||||
assert_eq!(loaded(&text), s);
|
||||
// The launcher's Wardrobe reads the same lines.
|
||||
let wardrobe = prefs::Prefs::parse(&text);
|
||||
assert_eq!(
|
||||
(wardrobe.mine, wardrobe.others, wardrobe.hide_fx),
|
||||
(false, Some(Others::NameTags), true)
|
||||
);
|
||||
// No choice made: no `cosmeticsOthers` line; the old switch still reads.
|
||||
assert!(!saved(&Settings::default()).contains(prefs::OTHERS));
|
||||
assert_eq!(
|
||||
loaded("cosmeticsOthers:false\n").others,
|
||||
Some(Others::Hidden)
|
||||
);
|
||||
// A value it can't read keeps the setting.
|
||||
assert!(loaded("cosmeticsMine:maybe\n").mine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn others_cycle_three_ways_from_what_the_row_shows() {
|
||||
let mut seat = Seat::default();
|
||||
// Not chosen, nothing owned: Hidden, and forward wraps to Shown.
|
||||
assert_eq!(seat.others(), Others::Hidden);
|
||||
Section::act_on(&mut seat, 2, false);
|
||||
assert_eq!(seat.settings.others, Some(Others::Shown));
|
||||
Section::act_on(&mut seat, 2, true);
|
||||
assert_eq!(seat.settings.others, Some(Others::Hidden));
|
||||
Section::act_on(&mut seat, 2, true);
|
||||
assert_eq!(seat.settings.others, Some(Others::NameTags));
|
||||
// Owning one shows them by default, until the player chooses.
|
||||
let mut owner = Seat::default();
|
||||
owner.owner.owns = true;
|
||||
assert_eq!(owner.others(), Others::Shown);
|
||||
let mut rows = Vec::new();
|
||||
Section::rows_of(&owner, &mut rows);
|
||||
assert_eq!(
|
||||
rows[2],
|
||||
Row::Cycle {
|
||||
label: ROWS[2],
|
||||
value: Label::Key("horizon.extras.cosmetics.shown")
|
||||
}
|
||||
);
|
||||
assert_eq!(rows.len(), TOOLTIPS.len());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
//! breeding, conversions, drops), projectiles, player interactions
|
||||
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
|
||||
mod mob_geometry;
|
||||
mod mob_group;
|
||||
mod prep;
|
||||
mod projectile_world;
|
||||
mod pufferfish_player;
|
||||
mod spawn_eggs;
|
||||
@@ -1048,18 +1050,11 @@ impl Game<'_> {
|
||||
teams: std::sync::Arc::new(self.mob_teams()),
|
||||
items: Some(self.items()),
|
||||
lid_openers: &self.lid_openers,
|
||||
mod_effects: if self.remote.is_none() {
|
||||
Some(self)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
mod_effects: self.remote.is_none().then_some(self as _),
|
||||
// A warden's away suspects are UUIDs: on the server side, where the
|
||||
// entity UUIDs live, they resolve back to the mobs that are here.
|
||||
who_lookup: if self.remote.is_none() {
|
||||
Some(self)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
who_lookup: self.remote.is_none().then_some(self as _),
|
||||
foreign: None,
|
||||
border: Some(self.border.geometry()),
|
||||
// The first player in the level: a server has no player of its own.
|
||||
difficulty: self.local_difficulty(
|
||||
@@ -1187,50 +1182,32 @@ impl Game<'_> {
|
||||
self.init_sulfur_attribute_maps();
|
||||
let uuid = self.level.player_uuid;
|
||||
let level_id = self.dim.id();
|
||||
// LivingEntity.isPushable reads onClimbable live; a mob's cached one
|
||||
// is only refreshed by its own push step, so one that has not ticked
|
||||
// yet (the first tick after a load, a fresh spawn) or has moved since
|
||||
// would be pushed out of a ladder or scaffolding. Refreshed here, from
|
||||
// where each mob stands now, before the snapshot others push it by.
|
||||
let climbing: Vec<bool> = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
self.mobs.iter().map(|m| m.on_climbable(&w)).collect()
|
||||
};
|
||||
for (m, climbing) in self.mobs.iter_mut().zip(climbing) {
|
||||
m.on_climbable = climbing;
|
||||
}
|
||||
// Each mob's climbing, refreshed, and its snapshot entry (creatures/prep.rs).
|
||||
let mut snapshot = std::mem::take(&mut self.creatures.seen_buf);
|
||||
snapshot.extend(self.mobs.iter().map(|m| seen(m, uuid)));
|
||||
self.prepare_mobs(uuid, &mut snapshot);
|
||||
let mut events = std::mem::take(&mut self.creatures.event_buf);
|
||||
events.clear();
|
||||
// SulfurCube.primeWhenOnPoweredPosition runs before super.tick adopts
|
||||
// the body: the cubes whose adopted body explodes, as this tick starts.
|
||||
let primable = self.sulfur_cubes_adopted_explosive();
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
let mobs = std::mem::take(&mut self.mobs);
|
||||
// Each mob's target as the mods last heard it, by its place in `mobs`
|
||||
// (`native_living_change_target`).
|
||||
let mut targets = std::mem::take(&mut self.creatures.target_buf);
|
||||
targets.clear();
|
||||
targets.extend(mobs.iter().map(|m| m.ai.target));
|
||||
let mut dead = Vec::new();
|
||||
let mut corpses = Vec::new();
|
||||
let mut died = std::mem::take(&mut self.died);
|
||||
let died = std::mem::take(&mut self.died);
|
||||
let mut ticked = std::mem::take(&mut self.creatures.ticked);
|
||||
ticked.clear();
|
||||
let ticking = self.entity_ticking_area();
|
||||
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
|
||||
let mut guests = self.lan_guest_views();
|
||||
// What stands in a guest's slot while a mob holds that guest's view for
|
||||
// its tick (see `lent` below).
|
||||
let mut spare = guests.first().map(|g| g.1.clone());
|
||||
let players = self.level_players();
|
||||
let nearest = super::natural_spawning::NearestPlayers::new(&players);
|
||||
let mut bindings = match self.lan.as_mut() {
|
||||
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
|
||||
let guests = self.lan_guest_views();
|
||||
let bindings = match self.lan.as_mut() {
|
||||
Some(host) if !guests.is_empty() => std::mem::take(&mut host.mob_players),
|
||||
_ => Default::default(),
|
||||
};
|
||||
// The guests' entity ids by connection, for the shulker bullets fired
|
||||
// at the guest a mob sees.
|
||||
let guest_ids: Vec<(u32, u32)> = match self.lan.as_ref() {
|
||||
Some(host) if !guests.is_empty() => host
|
||||
.hub
|
||||
@@ -1240,23 +1217,9 @@ impl Game<'_> {
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut guest_events = Vec::new();
|
||||
// The block writes since the last tick: mobs re-path around them.
|
||||
let path_writes = std::mem::take(self.world.path_writes.get_or_insert_with(Vec::new));
|
||||
// The mobs whose bodies go this tick: (id, feet, width, height, split).
|
||||
// 🤖 Agent: the last field is the grown slime, magma cube or sulfur
|
||||
// cube the body leaves behind (`AbstractCubeMob.remove`), captured as
|
||||
// the body goes because it has left the list by the time it splits.
|
||||
let mut gone = Vec::new();
|
||||
let level_random;
|
||||
{
|
||||
// The level random, lent to the mobs (brain behaviours draw from
|
||||
// it) and taken back after their ticks.
|
||||
let mut scratch = self.world.random.clone();
|
||||
let mut pushes = horizon_client::mobs::PendingPushes::default();
|
||||
let mut keep = Vec::with_capacity(mobs.len());
|
||||
let mut grid =
|
||||
horizon_client::mobs::seen_grid::SeenGrid::new(snapshot.iter().map(|s| s.bounds));
|
||||
let group = {
|
||||
// All but the snapshot is the same for every mob of the tick, so
|
||||
// build it once: the player view and the teams scan every UUID.
|
||||
self.leash_boats = self
|
||||
@@ -1266,87 +1229,52 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let mut base = self.mob_world_base(&[]);
|
||||
let path_changes = horizon_client::mobs::collision_changes(&base, &path_writes);
|
||||
// Each mob keeps its snapshot slot all tick; the first slot of an
|
||||
// id answers, as a scan would.
|
||||
let mut index = horizon_client::fast_hash::FxHashMap::with_capacity_and_hasher(
|
||||
snapshot.len(),
|
||||
Default::default(),
|
||||
);
|
||||
let mut villager_slots = Vec::new();
|
||||
let mut solid_slots = Vec::new();
|
||||
for (i, s) in snapshot.iter().enumerate() {
|
||||
index.entry(s.id).or_insert(i as u32);
|
||||
if s.species == horizon_client::mobs::Species::Villager {
|
||||
villager_slots.push(i as u32);
|
||||
}
|
||||
if s.may_block_mobs() {
|
||||
solid_slots.push(i as u32);
|
||||
}
|
||||
}
|
||||
laps.lap("creature_prepare");
|
||||
let single_player = horizon_client::mobs::single_player_pos(
|
||||
players.iter().map(|p| (p.position, p.spectator)),
|
||||
);
|
||||
let nearest = prep::MobsNearest::new(&nearest, &mobs, single_player.is_none());
|
||||
let mut despawn_w = base.despawn_view();
|
||||
let mut cached_difficulty: Option<((i32, i32), spawning::LocalDifficulty)> = None;
|
||||
for i in 0..mobs.len() {
|
||||
let m = &mut mobs[i];
|
||||
let (bx, bz) = (m.position.x.floor() as i32, m.position.z.floor() as i32);
|
||||
let seen_guest = if guests.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let bound = bindings.get(&m.id).copied();
|
||||
let conn = super::lan_mobs::player_for(m, base.player.as_ref(), &guests, bound);
|
||||
match conn {
|
||||
Some(conn) => {
|
||||
bindings.insert(m.id, conn);
|
||||
}
|
||||
None => {
|
||||
bindings.remove(&m.id);
|
||||
}
|
||||
}
|
||||
conn
|
||||
};
|
||||
let shared = mob_group::Shared {
|
||||
uuid,
|
||||
level_id,
|
||||
path_changes: &path_changes,
|
||||
guest_ids: &guest_ids,
|
||||
};
|
||||
let start = mob_group::Start {
|
||||
events,
|
||||
// The level random, lent to the mobs and taken back after their ticks.
|
||||
random: self.world.random.clone(),
|
||||
guests,
|
||||
player: base.player.take(),
|
||||
bindings,
|
||||
died,
|
||||
ticked,
|
||||
targets,
|
||||
};
|
||||
let mut group = mob_group::Group::new(mobs.into(), snapshot, start);
|
||||
for i in 0..group.mobs.len() {
|
||||
let position = group.mobs[i].position;
|
||||
let (bx, bz) = (position.x.floor() as i32, position.z.floor() as i32);
|
||||
let seen_guest = group.bind_guest(i);
|
||||
if !base.world.loaded(bx, bz) {
|
||||
keep.push(!m.hostile() || m.persistent);
|
||||
group.unloaded(i);
|
||||
continue;
|
||||
}
|
||||
let nearest_player = single_player.unwrap_or_else(|| {
|
||||
nearest
|
||||
.nearest(m.position.x, m.position.y, m.position.z)
|
||||
.map(|p| p.position)
|
||||
});
|
||||
let nearest_player = single_player.unwrap_or_else(|| nearest.at(i, position));
|
||||
// ServerLevel.tick: every loaded entity checks despawning, but
|
||||
// only those in entity-ticking chunks (within the simulation
|
||||
// distance) tick. The despawn check reads the nearest player
|
||||
// and the difficulty, not the player the mob sees.
|
||||
// distance) tick.
|
||||
if !ticking(bx, bz) {
|
||||
despawn_w.nearest_player = nearest_player;
|
||||
let kept = horizon_client::mobs::ai::despawn_check(m, &despawn_w);
|
||||
if !kept {
|
||||
died.remove(&m.id);
|
||||
snapshot[i].alive = false;
|
||||
}
|
||||
keep.push(kept);
|
||||
group.rest(i, &despawn_w);
|
||||
continue;
|
||||
}
|
||||
let controlling_player_movement = horizon_client::mobs::equine::controlled(m)
|
||||
.then(|| {
|
||||
base.player
|
||||
.as_ref()
|
||||
.filter(|p| p.riding_vehicle == Some(m.id))
|
||||
.map(|p| p.velocity)
|
||||
.or_else(|| {
|
||||
guests
|
||||
.iter()
|
||||
.find(|(_, p)| p.riding_vehicle == Some(m.id))
|
||||
.map(|(_, p)| p.velocity)
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let controlling = group.controlling(i);
|
||||
// A mod canceled its tick: it stays as it is.
|
||||
if round.canceled(m.id) {
|
||||
keep.push(true);
|
||||
if round.canceled(group.mobs[i].id) {
|
||||
group.keep.push(true);
|
||||
continue;
|
||||
}
|
||||
// getCurrentDifficultyAt(blockPosition()): the mob's own chunk,
|
||||
@@ -1356,175 +1284,34 @@ impl Game<'_> {
|
||||
Some((key, difficulty)) if key == chunk_key => difficulty,
|
||||
_ => {
|
||||
let difficulty =
|
||||
self.local_difficulty([m.position.x, m.position.y, m.position.z]);
|
||||
self.local_difficulty([position.x, position.y, position.z]);
|
||||
cached_difficulty = Some((chunk_key, difficulty));
|
||||
difficulty
|
||||
}
|
||||
};
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
horizon_client::mobs::blocks_changed(m, &path_changes);
|
||||
let from = events.len();
|
||||
// The guest's view is lent to the mob for its tick, not copied:
|
||||
// the tick only reads it (`&MobWorld`), and it goes back to the
|
||||
// guest's slot straight after.
|
||||
let lent = seen_guest.and_then(|conn| guests.iter().position(|g| g.0 == conn));
|
||||
let active_player = match (seen_guest, lent) {
|
||||
(None, _) => base.player.take(),
|
||||
(Some(_), Some(k)) => spare
|
||||
.take()
|
||||
.map(|stand_in| std::mem::replace(&mut guests[k].1, stand_in)),
|
||||
(Some(_), None) => None,
|
||||
};
|
||||
let mut w = MobWorld {
|
||||
others: &snapshot,
|
||||
others_grid: Some(&grid),
|
||||
others_index: Some(&index),
|
||||
others_villagers: Some(&villager_slots),
|
||||
others_solid: Some(&solid_slots),
|
||||
difficulty,
|
||||
nearest_player,
|
||||
player: active_player,
|
||||
teams: std::sync::Arc::clone(&base.teams),
|
||||
..base
|
||||
};
|
||||
w.controlling_player_movement = controlling_player_movement;
|
||||
ticked.insert(mobs[i].id);
|
||||
// `horizon-report`'s cost by mob type (tick_profile.rs).
|
||||
let sampled = super::tick_profile::start().map(|t| (t, mobs[i].kind.clone()));
|
||||
let kept =
|
||||
horizon_client::mobs::riders::tick(&mut mobs, i, &w, &mut scratch, &mut events);
|
||||
if let Some((started, kind)) = sampled {
|
||||
super::tick_profile::add(level_id, &kind, started);
|
||||
}
|
||||
let m = &mut mobs[i];
|
||||
if seen_guest.is_none() {
|
||||
base.player = w.player;
|
||||
} else if let Some(k) = lent {
|
||||
spare = w.player.map(|v| std::mem::replace(&mut guests[k].1, v));
|
||||
}
|
||||
// Its tick changed its target: the mods may keep the one it had
|
||||
// (`native_living_change_target`), and then what the tick did to
|
||||
// the new one is not dealt.
|
||||
if let Some(&before) = targets.get(i)
|
||||
&& m.ai.target != before
|
||||
{
|
||||
let named = target_named(
|
||||
m.ai.target,
|
||||
seen_guest,
|
||||
&guests,
|
||||
&guest_ids,
|
||||
base.player.as_ref(),
|
||||
);
|
||||
if let Some((player, mob)) = named {
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
let mut cancel = false;
|
||||
Self::native_living_change_target(
|
||||
level_id,
|
||||
m.id,
|
||||
&kind,
|
||||
player,
|
||||
mob,
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
if let Some(new) = m.ai.target {
|
||||
let mut k = from;
|
||||
while k < events.len() {
|
||||
if hits_target(&events[k], new) {
|
||||
events.remove(k);
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
m.ai.target = before;
|
||||
}
|
||||
}
|
||||
targets[i] = m.ai.target;
|
||||
}
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
// death run (`LivingEntity.remove` with KILLED).
|
||||
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
|
||||
corpses.push(m.clone());
|
||||
}
|
||||
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
|
||||
// LivingEntity.tickDeath: after twenty ticks the body is removed.
|
||||
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
|
||||
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
|
||||
// 🤖 Agent: `AbstractCubeMob.remove` splits a grown cube into
|
||||
// its smaller halves when it is removed, twenty ticks after
|
||||
// the death. The parent leaves the list before the split
|
||||
// runs, so its size, position, kind and persistence are
|
||||
// captured here, as they are at the removal.
|
||||
let split = if matches!(
|
||||
m.species(),
|
||||
Species::Slime | Species::MagmaCube | Species::SulfurCube
|
||||
) && m.extra.size > 1
|
||||
{
|
||||
Some(m.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
gone.push((m.id, m.position, width, height, split));
|
||||
}
|
||||
if let Some(conn) = seen_guest {
|
||||
// ShulkerBullet.finalTarget is the guest's entity: the
|
||||
// bullet homes on it while the projectiles tick, where
|
||||
// `Who::Player` is the host's own player.
|
||||
if let Some(&(_, id)) = guest_ids.iter().find(|g| g.0 == conn) {
|
||||
for e in &mut events[from..] {
|
||||
if let MobEvent::Projectile(p) = e
|
||||
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
|
||||
&& *target == Some(Who::Player)
|
||||
{
|
||||
*target = Some(Who::Mob(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut k = from;
|
||||
while k < events.len() {
|
||||
if super::lan_mobs::acts_on_player(&events[k]) {
|
||||
guest_events.push((conn, m.id, events.remove(k)));
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Killed during its own tick (drowning, burning, falling...).
|
||||
if !m.alive() && !m.removed && died.insert(m.id) {
|
||||
dead.push(m.clone());
|
||||
}
|
||||
if !kept {
|
||||
died.remove(&m.id);
|
||||
}
|
||||
keep.push(kept);
|
||||
// ServerLevel ticks entities one after another: the ones
|
||||
// after this mob see it as it is now.
|
||||
snapshot[i] = seen(m, uuid);
|
||||
snapshot[i].alive &= kept;
|
||||
grid.update(i, snapshot[i].bounds);
|
||||
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
|
||||
let player = (seen_guest, nearest_player, controlling);
|
||||
group.tick(i, player, difficulty, &base, &shared);
|
||||
}
|
||||
// Targets changed after the mob's own tick (another mob's alert):
|
||||
// heard now, and a cancel puts the old one back before its next tick.
|
||||
for (i, m) in mobs.iter_mut().enumerate() {
|
||||
let Some(&before) = targets.get(i) else {
|
||||
for (i, m) in group.mobs.iter_mut().enumerate() {
|
||||
let Some(&before) = group.targets.get(i) else {
|
||||
break;
|
||||
};
|
||||
if m.ai.target == before {
|
||||
continue;
|
||||
}
|
||||
let seen_guest = if guests.is_empty() {
|
||||
let seen_guest = if group.guests.is_empty() {
|
||||
None
|
||||
} else {
|
||||
bindings.get(&m.id).copied()
|
||||
group.bindings.get(&m.id).copied()
|
||||
};
|
||||
let named = target_named(
|
||||
m.ai.target,
|
||||
seen_guest,
|
||||
&guests,
|
||||
&group.guests,
|
||||
&guest_ids,
|
||||
base.player.as_ref(),
|
||||
group.player.as_ref(),
|
||||
);
|
||||
if let Some((player, mob)) = named {
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
@@ -1543,13 +1330,29 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
laps.lap("creature_loop");
|
||||
let mut flags = keep.into_iter();
|
||||
mobs.retain(|_| flags.next().unwrap_or(true));
|
||||
for m in &mut mobs {
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
let mut flags = std::mem::take(&mut group.keep).into_iter();
|
||||
group.mobs.retain(|_| flags.next().unwrap_or(true));
|
||||
for m in &mut group.mobs {
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut group.pushes);
|
||||
}
|
||||
level_random = scratch;
|
||||
}
|
||||
group
|
||||
};
|
||||
let mob_group::Group {
|
||||
mut mobs,
|
||||
mut snapshot,
|
||||
mut events,
|
||||
random: level_random,
|
||||
guests,
|
||||
mut bindings,
|
||||
died,
|
||||
ticked,
|
||||
mut targets,
|
||||
dead,
|
||||
corpses,
|
||||
gone,
|
||||
guest_events,
|
||||
..
|
||||
} = group;
|
||||
self.world.random = level_random;
|
||||
if let Some(host) = self.lan.as_mut()
|
||||
&& !guests.is_empty()
|
||||
@@ -1565,7 +1368,7 @@ impl Game<'_> {
|
||||
// first, as `append` put them; with none, the ticked list goes back
|
||||
// as it is instead of being copied into a new one, every mob a tick.
|
||||
if self.mobs.is_empty() {
|
||||
self.mobs = mobs;
|
||||
self.mobs = mobs.into();
|
||||
} else {
|
||||
self.mobs.append(&mut mobs);
|
||||
}
|
||||
@@ -8376,6 +8179,7 @@ mod pearl_tests {
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
//! The mob loop of `tick_creature_entities` over a group of mobs, in their list order, with
|
||||
//! everything the loop keeps for them: their snapshot and its indexes, the pushes and events
|
||||
//! their ticks give, the level random they draw from, the players they see and what dies. The
|
||||
//! serial loop is one group holding every mob; a group of the parallel mob tick (#14457) holds
|
||||
//! the mobs no mob outside it can reach this tick.
|
||||
use super::*;
|
||||
use horizon_client::{
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
mobs::{MobSlot, PendingPushes, seen_grid::SeenGrid, spawning::LocalDifficulty},
|
||||
};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
|
||||
/// What every mob of the loop reads and none writes.
|
||||
pub(super) struct Shared<'a> {
|
||||
pub uuid: Option<[i32; 4]>,
|
||||
pub level_id: &'static str,
|
||||
/// The blocks whose collision changed since the last tick: mobs re-path around them.
|
||||
pub path_changes: &'a [[i32; 3]],
|
||||
/// The guests' entity ids by connection, for the shulker bullets fired at the guest a mob
|
||||
/// sees.
|
||||
pub guest_ids: &'a [(u32, u32)],
|
||||
}
|
||||
|
||||
/// A mob whose body goes this tick: (id, feet, width, height, the grown cube it splits into).
|
||||
/// The last field is the grown slime, magma cube or sulfur cube the body leaves behind
|
||||
/// (`AbstractCubeMob.remove`), captured as the body goes because it has left the list by the
|
||||
/// time it splits.
|
||||
pub(super) type Gone = (u32, Vec3, f64, f64, Option<Mob>);
|
||||
|
||||
/// A group's mobs and everything the loop keeps for them, in their list order.
|
||||
pub(super) struct Group<M: MobSlot> {
|
||||
pub mobs: Vec<M>,
|
||||
/// Each mob's snapshot entry: as it was before the tick until its turn, as its turn left it
|
||||
/// after.
|
||||
pub snapshot: Vec<Seen>,
|
||||
grid: SeenGrid,
|
||||
/// The first slot of each id, as a scan would find it.
|
||||
index: FxHashMap<u32, u32>,
|
||||
villager_slots: Vec<u32>,
|
||||
solid_slots: Vec<u32>,
|
||||
pub pushes: PendingPushes,
|
||||
pub events: Vec<MobEvent>,
|
||||
/// The level random, lent to the mobs (brain behaviours draw from it).
|
||||
pub random: LegacyRandom,
|
||||
pub guests: super::super::lan_mobs::GuestViews,
|
||||
/// What stands in a guest's slot while a mob holds that guest's view for its tick.
|
||||
spare: Option<PlayerView>,
|
||||
/// The host's own player, lent to each mob that sees it for its tick.
|
||||
pub player: Option<PlayerView>,
|
||||
/// The guest each mob sees, by mob id.
|
||||
pub bindings: FxHashMap<u32, u32>,
|
||||
pub died: FxHashSet<u32>,
|
||||
pub ticked: FxHashSet<u32>,
|
||||
/// Each mob's target as the mods last heard it (`native_living_change_target`).
|
||||
pub targets: Vec<Option<Who>>,
|
||||
/// Whether each mob stays in the list, pushed at its turn.
|
||||
pub keep: Vec<bool>,
|
||||
pub dead: Vec<Mob>,
|
||||
pub corpses: Vec<Mob>,
|
||||
pub gone: Vec<Gone>,
|
||||
pub guest_events: Vec<(u32, u32, MobEvent)>,
|
||||
}
|
||||
|
||||
/// What a group starts from besides its mobs and their snapshot.
|
||||
pub(super) struct Start {
|
||||
pub events: Vec<MobEvent>,
|
||||
pub random: LegacyRandom,
|
||||
pub guests: super::super::lan_mobs::GuestViews,
|
||||
pub player: Option<PlayerView>,
|
||||
pub bindings: FxHashMap<u32, u32>,
|
||||
pub died: FxHashSet<u32>,
|
||||
pub ticked: FxHashSet<u32>,
|
||||
pub targets: Vec<Option<Who>>,
|
||||
}
|
||||
|
||||
impl<M: MobSlot> Group<M> {
|
||||
/// A group of `mobs`, whose snapshot entries are `snapshot`.
|
||||
pub(super) fn new(mobs: Vec<M>, snapshot: Vec<Seen>, start: Start) -> Self {
|
||||
let grid = SeenGrid::new(snapshot.iter().map(|s| s.bounds));
|
||||
let mut index = FxHashMap::with_capacity_and_hasher(snapshot.len(), Default::default());
|
||||
let (mut villager_slots, mut solid_slots) = (Vec::new(), Vec::new());
|
||||
for (i, s) in snapshot.iter().enumerate() {
|
||||
index.entry(s.id).or_insert(i as u32);
|
||||
if s.species == Species::Villager {
|
||||
villager_slots.push(i as u32);
|
||||
}
|
||||
if s.may_block_mobs() {
|
||||
solid_slots.push(i as u32);
|
||||
}
|
||||
}
|
||||
let n = mobs.len();
|
||||
Self {
|
||||
spare: start.guests.first().map(|g| g.1.clone()),
|
||||
mobs,
|
||||
snapshot,
|
||||
grid,
|
||||
index,
|
||||
villager_slots,
|
||||
solid_slots,
|
||||
pushes: PendingPushes::default(),
|
||||
events: start.events,
|
||||
random: start.random,
|
||||
guests: start.guests,
|
||||
player: start.player,
|
||||
bindings: start.bindings,
|
||||
died: start.died,
|
||||
ticked: start.ticked,
|
||||
targets: start.targets,
|
||||
keep: Vec::with_capacity(n),
|
||||
dead: Vec::new(),
|
||||
corpses: Vec::new(),
|
||||
gone: Vec::new(),
|
||||
guest_events: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The guest mob `i` sees now, kept in the bindings: none while no guest is on.
|
||||
pub(super) fn bind_guest(&mut self, i: usize) -> Option<u32> {
|
||||
if self.guests.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let m = self.mobs[i].mob();
|
||||
let bound = self.bindings.get(&m.id).copied();
|
||||
let conn = super::super::lan_mobs::player_for(m, self.player.as_ref(), &self.guests, bound);
|
||||
match conn {
|
||||
Some(conn) => self.bindings.insert(m.id, conn),
|
||||
None => self.bindings.remove(&m.id),
|
||||
};
|
||||
conn
|
||||
}
|
||||
|
||||
/// Mob `i`'s turn when its chunk isn't loaded: it is kept as it is, unless it would despawn.
|
||||
pub(super) fn unloaded(&mut self, i: usize) {
|
||||
let m = self.mobs[i].mob();
|
||||
self.keep.push(!m.hostile() || m.persistent);
|
||||
}
|
||||
|
||||
/// Mob `i`'s turn outside the entity-ticking area: only its despawn check, which reads the
|
||||
/// nearest player and the difficulty, not the player the mob sees.
|
||||
pub(super) fn rest(&mut self, i: usize, despawn_w: &MobWorld) {
|
||||
let m = self.mobs[i].mob_mut();
|
||||
let kept = horizon_client::mobs::ai::despawn_check(m, despawn_w);
|
||||
if !kept {
|
||||
self.died.remove(&m.id);
|
||||
self.snapshot[i].alive = false;
|
||||
}
|
||||
self.keep.push(kept);
|
||||
}
|
||||
|
||||
/// Who controls mob `i`'s moves this tick: the velocity of the player riding it, when it is
|
||||
/// a controlled mount.
|
||||
pub(super) fn controlling(&self, i: usize) -> Option<Vec3> {
|
||||
let m = self.mobs[i].mob();
|
||||
horizon_client::mobs::equine::controlled(m)
|
||||
.then(|| {
|
||||
self.player
|
||||
.as_ref()
|
||||
.filter(|p| p.riding_vehicle == Some(m.id))
|
||||
.map(|p| p.velocity)
|
||||
.or_else(|| {
|
||||
self.guests
|
||||
.iter()
|
||||
.find(|(_, p)| p.riding_vehicle == Some(m.id))
|
||||
.map(|(_, p)| p.velocity)
|
||||
})
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Mob `i`'s own tick, then what the loop does with what it did. `players`: the guest it
|
||||
/// sees (none: the host's player), its nearest player, and the velocity of the player
|
||||
/// controlling it as a mount ([`Self::controlling`]).
|
||||
pub(super) fn tick(
|
||||
&mut self,
|
||||
i: usize,
|
||||
(seen_guest, nearest_player, controlling_player_movement): (
|
||||
Option<u32>,
|
||||
Option<Vec3>,
|
||||
Option<Vec3>,
|
||||
),
|
||||
difficulty: LocalDifficulty,
|
||||
base: &MobWorld,
|
||||
shared: &Shared,
|
||||
) {
|
||||
let m = self.mobs[i].mob_mut();
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut self.pushes);
|
||||
horizon_client::mobs::blocks_changed(m, shared.path_changes);
|
||||
let from = self.events.len();
|
||||
// The guest's view is lent to the mob for its tick, not copied:
|
||||
// the tick only reads it (`&MobWorld`), and it goes back to the
|
||||
// guest's slot straight after.
|
||||
let lent = seen_guest.and_then(|conn| self.guests.iter().position(|g| g.0 == conn));
|
||||
let active_player = match (seen_guest, lent) {
|
||||
(None, _) => self.player.take(),
|
||||
(Some(_), Some(k)) => self
|
||||
.spare
|
||||
.take()
|
||||
.map(|stand_in| std::mem::replace(&mut self.guests[k].1, stand_in)),
|
||||
(Some(_), None) => None,
|
||||
};
|
||||
let mut w = MobWorld {
|
||||
others: &self.snapshot,
|
||||
others_grid: Some(&self.grid),
|
||||
others_index: Some(&self.index),
|
||||
others_villagers: Some(&self.villager_slots),
|
||||
others_solid: Some(&self.solid_slots),
|
||||
difficulty,
|
||||
nearest_player,
|
||||
player: active_player,
|
||||
teams: std::sync::Arc::clone(&base.teams),
|
||||
..*base
|
||||
};
|
||||
w.controlling_player_movement = controlling_player_movement;
|
||||
self.ticked.insert(self.mobs[i].mob().id);
|
||||
// `horizon-report`'s cost by mob type (tick_profile.rs).
|
||||
let sampled =
|
||||
super::super::tick_profile::start().map(|t| (t, self.mobs[i].mob().kind.clone()));
|
||||
let kept = horizon_client::mobs::riders::tick(
|
||||
&mut self.mobs,
|
||||
i,
|
||||
&w,
|
||||
&mut self.random,
|
||||
&mut self.events,
|
||||
);
|
||||
if let Some((started, kind)) = sampled {
|
||||
super::super::tick_profile::add(shared.level_id, &kind, started);
|
||||
}
|
||||
let m = self.mobs[i].mob_mut();
|
||||
if seen_guest.is_none() {
|
||||
self.player = w.player;
|
||||
} else if let Some(k) = lent {
|
||||
self.spare = w
|
||||
.player
|
||||
.map(|v| std::mem::replace(&mut self.guests[k].1, v));
|
||||
}
|
||||
// Its tick changed its target: the mods may keep the one it had
|
||||
// (`native_living_change_target`), and then what the tick did to
|
||||
// the new one is not dealt.
|
||||
if let Some(&before) = self.targets.get(i)
|
||||
&& m.ai.target != before
|
||||
{
|
||||
let named = target_named(
|
||||
m.ai.target,
|
||||
seen_guest,
|
||||
&self.guests,
|
||||
shared.guest_ids,
|
||||
self.player.as_ref(),
|
||||
);
|
||||
if let Some((player, mob)) = named {
|
||||
let kind = super::super::native_hooks::mob_kind(m);
|
||||
let mut cancel = false;
|
||||
Game::native_living_change_target(
|
||||
shared.level_id,
|
||||
m.id,
|
||||
&kind,
|
||||
player,
|
||||
mob,
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
if let Some(new) = m.ai.target {
|
||||
let mut k = from;
|
||||
while k < self.events.len() {
|
||||
if hits_target(&self.events[k], new) {
|
||||
self.events.remove(k);
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
m.ai.target = before;
|
||||
}
|
||||
}
|
||||
self.targets[i] = m.ai.target;
|
||||
}
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
// death run (`LivingEntity.remove` with KILLED).
|
||||
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
|
||||
self.corpses.push(m.clone());
|
||||
}
|
||||
horizon_client::mobs::take_mob_pushes(&mut self.events, from, &mut self.pushes);
|
||||
// LivingEntity.tickDeath: after twenty ticks the body is removed.
|
||||
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
|
||||
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
|
||||
// `AbstractCubeMob.remove` splits a grown cube into its smaller halves when it is
|
||||
// removed, twenty ticks after the death. The parent leaves the list before the split
|
||||
// runs, so its size, position, kind and persistence are captured here, as they are
|
||||
// at the removal.
|
||||
let split = if matches!(
|
||||
m.species(),
|
||||
Species::Slime | Species::MagmaCube | Species::SulfurCube
|
||||
) && m.extra.size > 1
|
||||
{
|
||||
Some(m.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.gone.push((m.id, m.position, width, height, split));
|
||||
}
|
||||
if let Some(conn) = seen_guest {
|
||||
// ShulkerBullet.finalTarget is the guest's entity: the
|
||||
// bullet homes on it while the projectiles tick, where
|
||||
// `Who::Player` is the host's own player.
|
||||
if let Some(&(_, id)) = shared.guest_ids.iter().find(|g| g.0 == conn) {
|
||||
for e in &mut self.events[from..] {
|
||||
if let MobEvent::Projectile(p) = e
|
||||
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
|
||||
&& *target == Some(Who::Player)
|
||||
{
|
||||
*target = Some(Who::Mob(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut k = from;
|
||||
while k < self.events.len() {
|
||||
if super::super::lan_mobs::acts_on_player(&self.events[k]) {
|
||||
self.guest_events.push((conn, m.id, self.events.remove(k)));
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Killed during its own tick (drowning, burning, falling...).
|
||||
if !m.alive() && !m.removed && self.died.insert(m.id) {
|
||||
self.dead.push(m.clone());
|
||||
}
|
||||
if !kept {
|
||||
self.died.remove(&m.id);
|
||||
}
|
||||
self.keep.push(kept);
|
||||
// ServerLevel ticks entities one after another: the ones
|
||||
// after this mob see it as it is now.
|
||||
self.snapshot[i] = seen(m, shared.uuid);
|
||||
self.snapshot[i].alive &= kept;
|
||||
self.grid.update(i, self.snapshot[i].bounds);
|
||||
horizon_client::mobs::apply_brain_events(&mut self.mobs, &mut self.events, from);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! What the mob loop (`tick_creature_entities`) prepares for every mob before it runs, on the tick's
|
||||
//! helper threads while the parallel tick is on (`horizon_client::parallel`): each answer reads only
|
||||
//! that mob, the blocks and the players, so the helpers give what one thread gives.
|
||||
use super::super::{Game, natural_spawning::NearestPlayers};
|
||||
use horizon_client::mobs::{Mob, MobWorld, Seen};
|
||||
use horizon_client::parallel::par_map;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// From this many mobs a preparation goes to the helper threads.
|
||||
const MIN_MOBS: usize = 256;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `LivingEntity.isPushable` reads onClimbable live; a mob's cached one is only refreshed by its
|
||||
/// own push step, so one that has not ticked yet (the first tick after a load, a fresh spawn) or
|
||||
/// has moved since would be pushed out of a ladder or scaffolding. Refreshed here, from where
|
||||
/// each mob stands now; then each mob's entry of the snapshot others push it by goes in
|
||||
/// `snapshot`.
|
||||
pub(super) fn prepare_mobs(&mut self, player: Option<[i32; 4]>, snapshot: &mut Vec<Seen>) {
|
||||
let climbing: Vec<bool> = {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let rebuild = hookless(&w);
|
||||
par_map(&self.mobs, MIN_MOBS, |part, out| {
|
||||
let w = rebuild();
|
||||
out.extend(part.iter().map(|m| m.on_climbable(&w)));
|
||||
})
|
||||
};
|
||||
for (m, climbing) in self.mobs.iter_mut().zip(climbing) {
|
||||
m.on_climbable = climbing;
|
||||
}
|
||||
snapshot.extend(par_map(&self.mobs, MIN_MOBS, |part, out| {
|
||||
out.extend(part.iter().map(|m| super::seen(m, player)));
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The nearest player each mob's despawn check reads (`Mob.checkDespawn`), found for every mob before
|
||||
/// the loop. A mob that moved before its turn is asked again where it stands.
|
||||
pub(super) struct MobsNearest<'p> {
|
||||
players: &'p NearestPlayers<'p>,
|
||||
/// Each mob's position as the loop started and its nearest player there; empty while the
|
||||
/// parallel tick is off (every mob asks at its turn).
|
||||
found: Vec<(Vec3, Option<Vec3>)>,
|
||||
}
|
||||
|
||||
impl<'p> MobsNearest<'p> {
|
||||
/// `wanted`: whether the loop asks (not with one player, whose position answers).
|
||||
pub(super) fn new(players: &'p NearestPlayers<'p>, mobs: &[Mob], wanted: bool) -> Self {
|
||||
let found = if wanted && horizon_client::parallel::lanes() > 1 {
|
||||
par_map(mobs, MIN_MOBS, |part, out| {
|
||||
out.extend(
|
||||
part.iter()
|
||||
.map(|m| (m.position, Self::ask(players, m.position))),
|
||||
);
|
||||
})
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Self { players, found }
|
||||
}
|
||||
fn ask(players: &NearestPlayers, at: Vec3) -> Option<Vec3> {
|
||||
players.nearest(at.x, at.y, at.z).map(|p| p.position)
|
||||
}
|
||||
/// The nearest player to mob `i` of the loop, standing at `at`.
|
||||
pub(super) fn at(&self, i: usize, at: Vec3) -> Option<Vec3> {
|
||||
match self.found.get(i) {
|
||||
Some(&(was, nearest)) if was == at => nearest,
|
||||
_ => Self::ask(self.players, at),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `base` without its hooks into the game, which can't leave the server thread, as a recipe a
|
||||
/// helper thread builds its own world from: native mod effects (the phases on helpers don't run
|
||||
/// them) and the UUID lookup.
|
||||
fn hookless<'w>(base: &MobWorld<'w>) -> impl Fn() -> MobWorld<'w> + Sync + use<'w> {
|
||||
// Every field but the two hooks, named, so that a field added to `MobWorld` is decided here.
|
||||
let MobWorld {
|
||||
world,
|
||||
registry,
|
||||
facts,
|
||||
fluid_tables,
|
||||
movement,
|
||||
collision,
|
||||
suffocation,
|
||||
data,
|
||||
player,
|
||||
controlling_player_movement,
|
||||
nearest_player,
|
||||
others,
|
||||
dragon,
|
||||
leash_boats,
|
||||
others_grid,
|
||||
others_index,
|
||||
others_villagers,
|
||||
others_solid,
|
||||
daylight,
|
||||
monsters_burn,
|
||||
game_time,
|
||||
day_time,
|
||||
raining,
|
||||
thundering,
|
||||
level,
|
||||
block_tags,
|
||||
landing,
|
||||
biomes,
|
||||
mob_griefing,
|
||||
universal_anger,
|
||||
mob_drops,
|
||||
max_entity_cramming,
|
||||
difficulty,
|
||||
dimension_type,
|
||||
world_seed,
|
||||
sea_level,
|
||||
teams,
|
||||
items,
|
||||
lid_openers,
|
||||
mod_effects: _,
|
||||
who_lookup: _,
|
||||
foreign: _,
|
||||
border,
|
||||
} = base.clone();
|
||||
move || MobWorld {
|
||||
world,
|
||||
registry,
|
||||
facts,
|
||||
fluid_tables,
|
||||
movement,
|
||||
collision,
|
||||
suffocation,
|
||||
data,
|
||||
player: player.clone(),
|
||||
controlling_player_movement,
|
||||
nearest_player,
|
||||
others,
|
||||
dragon,
|
||||
leash_boats,
|
||||
others_grid,
|
||||
others_index,
|
||||
others_villagers,
|
||||
others_solid,
|
||||
daylight,
|
||||
monsters_burn,
|
||||
game_time,
|
||||
day_time,
|
||||
raining,
|
||||
thundering,
|
||||
level,
|
||||
block_tags,
|
||||
landing,
|
||||
biomes,
|
||||
mob_griefing,
|
||||
universal_anger,
|
||||
mob_drops,
|
||||
max_entity_cramming,
|
||||
difficulty,
|
||||
dimension_type,
|
||||
world_seed,
|
||||
sea_level,
|
||||
teams: std::sync::Arc::clone(&teams),
|
||||
items,
|
||||
lid_openers,
|
||||
mod_effects: None,
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
border,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::super::natural_spawning::LevelPlayer;
|
||||
use super::*;
|
||||
use horizon_core::geometry::Aabb;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources and scratch homes/cwd"]
|
||||
fn preparation_refreshes_climbing_and_nearest_after_a_move() {
|
||||
let mut game = super::super::super::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
for command in [
|
||||
"setblock 8 80 9 stone",
|
||||
"setblock 8 80 8 ladder[facing=north]",
|
||||
"summon pig 8.5 80 8.5 {NoAI:1b}",
|
||||
"summon pig 9.5 80 8.5 {NoAI:1b}",
|
||||
] {
|
||||
assert!(game.run_command(command), "{command}");
|
||||
}
|
||||
assert_eq!(game.mobs.len(), 2);
|
||||
let examples = game.mobs.clone();
|
||||
game.mobs = (0..MIN_MOBS + 37)
|
||||
.map(|i| {
|
||||
let mut mob = examples[i % 2].clone();
|
||||
mob.id = 10_000 + i as u32;
|
||||
mob.on_climbable = i % 2 != 0; // deliberately stale in both cases
|
||||
mob
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into();
|
||||
let mut snapshot = Vec::new();
|
||||
game.prepare_mobs(None, &mut snapshot);
|
||||
assert_eq!(snapshot.len(), game.mobs.len());
|
||||
for (i, (m, seen)) in game.mobs.iter().zip(&snapshot).enumerate() {
|
||||
assert_eq!(m.on_climbable, i % 2 == 0, "mob {i}");
|
||||
assert_eq!(seen.id, m.id);
|
||||
assert_eq!(seen.position, m.position);
|
||||
assert_eq!(seen.pushable, i % 2 != 0, "snapshot {i}");
|
||||
}
|
||||
|
||||
let player = |x, spectator| {
|
||||
let position = Vec3::new(x, 80.0, 8.5);
|
||||
LevelPlayer {
|
||||
uuid: 0,
|
||||
position,
|
||||
bounds: Aabb::new(position, position),
|
||||
alive: false, // dead players still count for checkDespawn
|
||||
creative: false,
|
||||
spectator,
|
||||
}
|
||||
};
|
||||
let players = [player(8.5, true), player(7.5, false), player(9.5, false)];
|
||||
let index = NearestPlayers::new(&players);
|
||||
let nearest = MobsNearest::new(&index, &game.mobs, true);
|
||||
for (i, mob) in game.mobs.iter().enumerate() {
|
||||
let expected = if i % 2 == 0 {
|
||||
players[1].position
|
||||
} else {
|
||||
players[2].position
|
||||
};
|
||||
assert_eq!(nearest.at(i, mob.position), Some(expected));
|
||||
// A previous mob's push moved this mob across the nearest-player tie.
|
||||
assert_eq!(
|
||||
nearest.at(i, Vec3::new(10.0, 80.0, 8.5)),
|
||||
Some(players[2].position)
|
||||
);
|
||||
}
|
||||
let no_cache = MobsNearest::new(&index, &game.mobs, false);
|
||||
assert_eq!(
|
||||
no_cache.at(0, game.mobs[0].position),
|
||||
Some(players[1].position)
|
||||
);
|
||||
let spectators = [player(8.5, true)];
|
||||
let index = NearestPlayers::new(&spectators);
|
||||
let nearest = MobsNearest::new(&index, &game.mobs, true);
|
||||
assert_eq!(nearest.at(0, game.mobs[0].position), None);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -594,24 +594,9 @@ impl Game<'_> {
|
||||
splash: &mut impl FnMut(horizon_client::entities::ItemSplash),
|
||||
) -> (bool, bool) {
|
||||
let no_physics = escape_speed.is_some();
|
||||
let heights = item.push_by_fluid(&self.world, &self.res.motion().fluids);
|
||||
// Entity.baseTick splashes before moving, except on the first tick.
|
||||
item.water_entry(heights.0 > 0.0, item.ticks == 0, splash);
|
||||
item.lava_fall(heights.1 > 0.0);
|
||||
let step = item.tick_move_before_block_effects(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
no_physics,
|
||||
escape_speed,
|
||||
heights,
|
||||
entity_boxes,
|
||||
Some(self.border.geometry()),
|
||||
Some(&self.res.motion().blocks),
|
||||
);
|
||||
item.check_fall(&step, |fall, from, moved| {
|
||||
self.vehicle_fall_reset(fall, from, moved)
|
||||
});
|
||||
let step = self
|
||||
.item_tables()
|
||||
.move_item(item, escape_speed, entity_boxes, splash);
|
||||
if let Some(pos) = step.vertical_contact {
|
||||
let state = self.world.block(pos.x, pos.y, pos.z);
|
||||
let position = item.position;
|
||||
@@ -630,23 +615,9 @@ impl Game<'_> {
|
||||
return (false, true);
|
||||
}
|
||||
}
|
||||
item.finish_move_block_effects(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
no_physics,
|
||||
Some(&self.res.motion().blocks),
|
||||
step,
|
||||
);
|
||||
item.push_by_bubble_column(
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
);
|
||||
let alive = item.tick_settle(&self.world, self.res.facts(), no_physics, step);
|
||||
// ItemEntity.tick refreshes immersion and splashes on water entry.
|
||||
let (water, _) = item.push_by_fluid(&self.world, &self.res.motion().fluids);
|
||||
item.water_entry(water > 0.0, false, splash);
|
||||
let alive = self
|
||||
.item_tables()
|
||||
.settle_item(item, no_physics, step, splash);
|
||||
(alive, false)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! `tick_experience`), the stacks a destroyed container lets fall, and spawning and merging orbs.
|
||||
use super::*;
|
||||
|
||||
mod ahead;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_items(&mut self) {
|
||||
// EntityTickEvent.Pre for the items about to tick, and the Post after them.
|
||||
@@ -43,6 +45,13 @@ impl Game<'_> {
|
||||
horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
|
||||
let mut nearby = Vec::new();
|
||||
let mut bubble_tops = Vec::new();
|
||||
let mut moves = ahead::Moves::work_out(self, &items, &obstacles, round, |item| {
|
||||
!self.joined_during_entity_pass(item.id)
|
||||
&& ticking(
|
||||
item.position.x.floor() as i32,
|
||||
item.position.z.floor() as i32,
|
||||
)
|
||||
});
|
||||
// `ItemUtils.onContainerDestroyed`: what a destroyed item held, and
|
||||
// where it lay, for adding to the level once its own tick is done.
|
||||
let mut destroyed: Vec<(ItemStack, Vec3)> = Vec::new();
|
||||
@@ -65,33 +74,35 @@ impl Game<'_> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the boxes around
|
||||
// where the item can reach this tick (a block of slack for the next velocity).
|
||||
let reach = Vec3::new(
|
||||
item.velocity.x.abs() + 1.0,
|
||||
item.velocity.y.abs() + 1.0,
|
||||
item.velocity.z.abs() + 1.0,
|
||||
);
|
||||
obstacles.near(item.bounds().inflate(reach), &mut nearby);
|
||||
// ItemEntity.tick's noPhysics (servers only): out of a block, a solid entity
|
||||
// or the border, placed or closed on it, towards the closest open side (#1252).
|
||||
let no_physics = merging
|
||||
&& item.inside_block(
|
||||
&self.world,
|
||||
self.collision,
|
||||
let mut at_turn = |item: &mut ItemEntity| {
|
||||
// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the boxes around
|
||||
// where the item can reach this tick.
|
||||
obstacles.near(ahead::reach(item), &mut nearby);
|
||||
// ItemEntity.tick's noPhysics (servers only): out of a block, a solid entity
|
||||
// or the border, placed or closed on it, towards the closest open side (#1252).
|
||||
let no_physics = merging
|
||||
&& item.inside_block(
|
||||
&self.world,
|
||||
self.collision,
|
||||
&nearby,
|
||||
Some(self.border.geometry()),
|
||||
);
|
||||
// `moveTowardsClosestSpace` draws the speed from the item's own random.
|
||||
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
|
||||
let mut entered = Vec::new();
|
||||
let (alive, discarded) = self.tick_item_with_block_contacts(
|
||||
item,
|
||||
escape_speed,
|
||||
&nearby,
|
||||
Some(self.border.geometry()),
|
||||
false,
|
||||
&mut |splash| entered.push(splash),
|
||||
);
|
||||
// `moveTowardsClosestSpace` draws the speed from the item's own random.
|
||||
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
|
||||
let mut entered = Vec::new();
|
||||
let (alive, discarded) = self.tick_item_with_block_contacts(
|
||||
item,
|
||||
escape_speed,
|
||||
&nearby,
|
||||
false,
|
||||
&mut |splash| entered.push(splash),
|
||||
);
|
||||
(no_physics, alive, discarded, entered)
|
||||
};
|
||||
let (no_physics, alive, discarded, entered) = match moves.take(i, item, &mut at_turn) {
|
||||
Some(moved) => moved,
|
||||
None => at_turn(item),
|
||||
};
|
||||
// Entity.doWaterSplashEffect: the server plays the sound (broadcast
|
||||
// to clients). Its addParticle is empty.
|
||||
for splash in entered {
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
//! The dropped items' own moves, worked out ahead of the item loop on the tick's helper threads
|
||||
//! while the parallel tick is on (`horizon_client::parallel`).
|
||||
//!
|
||||
//! An item's move (`ItemEntity.tick` up to its merge) reads only itself, the level's blocks, the
|
||||
//! obstacle boxes and the border, and the item loop changes none of them: so an item's move worked
|
||||
//! out on a copy before the loop is the move its turn would make. Its escape from inside a block
|
||||
//! draws the item's own random, which the copy carries. A merge before its turn can change its
|
||||
//! count, age and pickup delay: such an item moves at its turn instead. With a native mod loaded
|
||||
//! (its hooks see every item) or a mod listening to the round, every item moves at its turn.
|
||||
use super::super::{boats::fall_reset, entity::Entity as _, native_ticks};
|
||||
use super::*;
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
entities::{ItemEntity, ItemSplash, ItemStep, ObstacleGrid},
|
||||
motion::MotionResources,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3, border::BorderGeometry};
|
||||
use horizon_world::{block_collision::BlockCollisionRegistry, registry::BlockRegistry};
|
||||
|
||||
/// From this many items their moves go to the helper threads.
|
||||
const MIN_ITEMS: usize = 256;
|
||||
|
||||
/// What an item's own move reads.
|
||||
pub(in crate::game) struct ItemTables<'a> {
|
||||
world: &'a World,
|
||||
facts: &'a BlockFacts,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
motion: &'a MotionResources,
|
||||
registry: &'a BlockRegistry,
|
||||
border: &'a BorderGeometry,
|
||||
}
|
||||
|
||||
impl<'w> Game<'w> {
|
||||
pub(in crate::game) fn item_tables(&self) -> ItemTables<'_> {
|
||||
ItemTables {
|
||||
world: &self.world,
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
motion: self.res.motion(),
|
||||
registry: self.pack.assets.registry,
|
||||
border: self.border.geometry(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ItemTables<'_> {
|
||||
/// `ItemEntity.tick` up to its move's vertical contact: the fluids, the water splash, the move
|
||||
/// (pushed out at `escape_speed` when inside a block) and its fall.
|
||||
pub(in crate::game) fn move_item(
|
||||
&self,
|
||||
item: &mut ItemEntity,
|
||||
escape_speed: Option<f32>,
|
||||
entity_boxes: &[Aabb],
|
||||
splash: &mut impl FnMut(ItemSplash),
|
||||
) -> ItemStep {
|
||||
let heights = item.push_by_fluid(self.world, &self.motion.fluids);
|
||||
// Entity.baseTick splashes before moving, except on the first tick.
|
||||
item.water_entry(heights.0 > 0.0, item.ticks == 0, splash);
|
||||
item.lava_fall(heights.1 > 0.0);
|
||||
let step = item.tick_move_before_block_effects(
|
||||
self.world,
|
||||
self.facts,
|
||||
self.collision,
|
||||
escape_speed.is_some(),
|
||||
escape_speed,
|
||||
heights,
|
||||
entity_boxes,
|
||||
Some(self.border),
|
||||
Some(&self.motion.blocks),
|
||||
);
|
||||
item.check_fall(&step, |fall, from, moved| {
|
||||
fall_reset(self.world, self.motion, self.facts, fall, from, moved)
|
||||
});
|
||||
step
|
||||
}
|
||||
/// The rest of the move after its vertical contact: block effects, bubble columns, settling
|
||||
/// and the water entry. Whether the item lives on.
|
||||
pub(in crate::game) fn settle_item(
|
||||
&self,
|
||||
item: &mut ItemEntity,
|
||||
no_physics: bool,
|
||||
step: ItemStep,
|
||||
splash: &mut impl FnMut(ItemSplash),
|
||||
) -> bool {
|
||||
item.finish_move_block_effects(
|
||||
self.world,
|
||||
self.facts,
|
||||
no_physics,
|
||||
Some(&self.motion.blocks),
|
||||
step,
|
||||
);
|
||||
item.push_by_bubble_column(self.world, self.registry, self.facts, self.collision);
|
||||
let alive = item.tick_settle(self.world, self.facts, no_physics, step);
|
||||
// ItemEntity.tick refreshes immersion and splashes on water entry.
|
||||
let (water, _) = item.push_by_fluid(self.world, &self.motion.fluids);
|
||||
item.water_entry(water > 0.0, false, splash);
|
||||
alive
|
||||
}
|
||||
}
|
||||
|
||||
/// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the box around an item that its
|
||||
/// move reads others' boxes in (a block of slack for the next velocity).
|
||||
pub(super) fn reach(item: &ItemEntity) -> Aabb {
|
||||
let v = item.velocity;
|
||||
item.bounds()
|
||||
.inflate(Vec3::new(v.x.abs() + 1.0, v.y.abs() + 1.0, v.z.abs() + 1.0))
|
||||
}
|
||||
|
||||
/// An item's move as its turn makes it: without physics (inside a block), alive, discarded, and
|
||||
/// its splashes.
|
||||
pub(super) type Moved = (bool, bool, bool, Vec<ItemSplash>);
|
||||
|
||||
/// One item's move, worked out on a copy.
|
||||
pub(super) struct Ahead {
|
||||
moved: ItemEntity,
|
||||
result: Moved,
|
||||
/// What a merge before its turn can change: its count, age and pickup delay.
|
||||
started: (u32, i32, i64),
|
||||
}
|
||||
|
||||
fn started(item: &ItemEntity) -> (u32, i32, i64) {
|
||||
(item.stack.count, item.age, item.pickup_delay)
|
||||
}
|
||||
|
||||
/// `item`'s whole move on a server with no native mod, from the tables alone.
|
||||
fn move_alone(
|
||||
tables: &ItemTables,
|
||||
obstacles: &ObstacleGrid,
|
||||
item: &mut ItemEntity,
|
||||
found: &mut Vec<u32>,
|
||||
near: &mut Vec<Aabb>,
|
||||
) -> Moved {
|
||||
obstacles.near_shared(reach(item), found, near);
|
||||
let no_physics = item.inside_block(tables.world, tables.collision, near, Some(tables.border));
|
||||
// `moveTowardsClosestSpace` draws the speed from the item's own random.
|
||||
let escape_speed = no_physics.then(|| item.random.next_float() * 0.2 + 0.1);
|
||||
let mut splashes = Vec::new();
|
||||
let step = tables.move_item(item, escape_speed, near, &mut |s| splashes.push(s));
|
||||
if step.vertical_contact.is_some() && item.stack.count == 0 {
|
||||
return (no_physics, false, true, splashes);
|
||||
}
|
||||
let alive = tables.settle_item(item, no_physics, step, &mut |s| splashes.push(s));
|
||||
(no_physics, alive, false, splashes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's choice of the path, over the parallel tick's switch.
|
||||
static AHEAD: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
|
||||
/// The moves a test's tick put in place, and those a merge made it refuse.
|
||||
static TAKEN: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) };
|
||||
}
|
||||
|
||||
/// Whether the moves are worked out ahead: while the parallel tick is on.
|
||||
fn ahead() -> bool {
|
||||
#[cfg(test)]
|
||||
if let Some(ahead) = AHEAD.with(std::cell::Cell::get) {
|
||||
return ahead;
|
||||
}
|
||||
horizon_client::parallel::lanes() > 1
|
||||
}
|
||||
|
||||
/// The moves worked out ahead, by the item's place in the loop.
|
||||
pub(super) struct Moves(Vec<Option<Ahead>>);
|
||||
|
||||
impl Moves {
|
||||
/// The moves of the items that will tick (`ticks`), on the helper threads; none while the
|
||||
/// parallel tick is off, for few items, or with a mod that could see an item's move.
|
||||
pub(super) fn work_out(
|
||||
game: &Game,
|
||||
items: &[ItemEntity],
|
||||
obstacles: &ObstacleGrid,
|
||||
round: &native_ticks::TickRound,
|
||||
ticks: impl Fn(&ItemEntity) -> bool,
|
||||
) -> Self {
|
||||
if !ahead()
|
||||
|| items.len() < MIN_ITEMS
|
||||
|| game.remote.is_some()
|
||||
|| round.listening()
|
||||
|| !horizon_client::mod_api::mods().is_empty()
|
||||
{
|
||||
return Self(Vec::new());
|
||||
}
|
||||
let todo: Vec<usize> = (0..items.len()).filter(|&i| ticks(&items[i])).collect();
|
||||
let tables = game.item_tables();
|
||||
let done = horizon_client::parallel::par_map(&todo, MIN_ITEMS / 4, |part, out| {
|
||||
let (mut found, mut near) = (Vec::new(), Vec::new());
|
||||
for &i in part {
|
||||
let mut moved = items[i].clone();
|
||||
let result = move_alone(&tables, obstacles, &mut moved, &mut found, &mut near);
|
||||
let started = started(&items[i]);
|
||||
out.push((
|
||||
i,
|
||||
Ahead {
|
||||
moved,
|
||||
result,
|
||||
started,
|
||||
},
|
||||
));
|
||||
}
|
||||
});
|
||||
let mut moves: Vec<Option<Ahead>> = (0..items.len()).map(|_| None).collect();
|
||||
for (i, a) in done {
|
||||
moves[i] = Some(a);
|
||||
}
|
||||
Self(moves)
|
||||
}
|
||||
/// Item `i`'s move, put in place, when it was worked out and no merge changed the item since.
|
||||
/// Under `HORIZON_PARALLEL_TICK=verify` the move is made again at its turn, on a copy, and a
|
||||
/// difference is logged.
|
||||
pub(super) fn take(
|
||||
&mut self,
|
||||
i: usize,
|
||||
item: &mut ItemEntity,
|
||||
again: impl FnOnce(&mut ItemEntity) -> Moved,
|
||||
) -> Option<Moved> {
|
||||
let a = self.0.get_mut(i)?.take()?;
|
||||
let fits = a.started == started(item);
|
||||
#[cfg(test)]
|
||||
TAKEN.with(|t| {
|
||||
let (taken, refused) = t.get();
|
||||
t.set((taken + usize::from(fits), refused + usize::from(!fits)));
|
||||
});
|
||||
if !fits {
|
||||
return None;
|
||||
}
|
||||
if horizon_client::parallel::parallel_tick() == 2 {
|
||||
let mut serial = item.clone();
|
||||
let expected = again(&mut serial);
|
||||
check(item.id, &serial, &expected, &a.moved, &a.result);
|
||||
}
|
||||
*item = a.moved;
|
||||
Some(a.result)
|
||||
}
|
||||
}
|
||||
|
||||
/// `HORIZON_PARALLEL_TICK=verify`: an item's move worked out ahead against the one at its turn.
|
||||
fn check(id: u32, serial: &ItemEntity, expected: &Moved, ahead: &ItemEntity, got: &Moved) {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static CHECKED: AtomicU64 = AtomicU64::new(0);
|
||||
static DIFFERED: AtomicU64 = AtomicU64::new(0);
|
||||
if format!("{serial:?} {expected:?}") != format!("{ahead:?} {got:?}") {
|
||||
DIFFERED.fetch_add(1, Ordering::Relaxed);
|
||||
log::error!("parallel tick: item {id}'s move worked out ahead differs from its turn's");
|
||||
}
|
||||
let n = CHECKED.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if n == 1 || n.is_multiple_of(10_000) {
|
||||
log::info!(
|
||||
"parallel tick: {n} item moves worked out ahead checked, {} differed",
|
||||
DIFFERED.load(Ordering::Relaxed)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::super::native_machines::tests::server_game;
|
||||
use super::{AHEAD, TAKEN};
|
||||
use horizon_client::{chunk::Chunk, items::ItemStack};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// One item tick with the moves worked out ahead leaves every item, and the level's random,
|
||||
/// as the tick that moves each item at its turn: items escaping a block (their own random),
|
||||
/// floating in water, falling, and merging into a later item before its turn (whose move
|
||||
/// is then made at its turn).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn moves_worked_out_ahead_match_those_made_at_each_turn() {
|
||||
let mut game = server_game();
|
||||
let feet = horizon_core::geometry::Vec3::new(8.5, 70.0, 8.5);
|
||||
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
|
||||
// `ChunkMap.prepareEntityTickingChunk`: the chunks within 2 are loaded too.
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
}
|
||||
}
|
||||
game.update_chunk_tickets();
|
||||
let registry = game.pack.assets.registry;
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let water = registry.default_state("minecraft:water").unwrap();
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
let (fx, fz) = (f64::from(x) + 0.5, f64::from(z) + 0.5);
|
||||
let stack = |count| ItemStack::new("minecraft:stone", count);
|
||||
match (x + z) % 5 {
|
||||
0 => {
|
||||
game.world.set(x, 65, z, stone).unwrap();
|
||||
game.spawn_item(stack(1), [fx, 65.3, fz], [0.0; 3], 10);
|
||||
}
|
||||
1 => {
|
||||
game.world.set(x, 65, z, water).unwrap();
|
||||
game.spawn_item(stack(1), [fx, 65.5, fz], [0.0; 3], 10);
|
||||
}
|
||||
2 => {
|
||||
game.spawn_item(stack(1), [fx, 66.0, fz], [0.0; 3], 0);
|
||||
game.spawn_item(stack(5), [fx, 66.0, fz], [0.0; 3], 0);
|
||||
}
|
||||
_ => game.spawn_item(stack(1), [fx, 67.0, fz], [0.1, 0.2, -0.1], 10),
|
||||
}
|
||||
}
|
||||
}
|
||||
for item in game.dropped.iter_mut() {
|
||||
item.ticks = 39;
|
||||
}
|
||||
game.ticked_entities_through = u32::MAX;
|
||||
let (items, random) = (game.dropped.clone(), game.world.random.clone());
|
||||
let mut runs = Vec::new();
|
||||
for ahead in [false, true] {
|
||||
game.dropped = items.clone();
|
||||
game.world.random = random.clone();
|
||||
AHEAD.with(|a| a.set(Some(ahead)));
|
||||
TAKEN.with(|t| t.set((0, 0)));
|
||||
game.tick_items();
|
||||
let left: Vec<String> = game.dropped.iter().map(|i| format!("{i:?}")).collect();
|
||||
runs.push((
|
||||
left,
|
||||
format!("{:?}", game.world.random),
|
||||
TAKEN.with(|t| t.get()),
|
||||
));
|
||||
}
|
||||
AHEAD.with(|a| a.set(None));
|
||||
assert!(runs[0].0.len() < items.len(), "some items merged");
|
||||
assert_eq!(runs[0].0, runs[1].0);
|
||||
assert_eq!(runs[0].1, runs[1].1);
|
||||
// The path under test ran: most moves were put in place, and a merge refused some.
|
||||
let (taken, refused) = runs[1].2;
|
||||
assert!(
|
||||
taken >= 200 && refused > 0,
|
||||
"{taken} put in place, {refused} refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! costs one `Option` check per tick.
|
||||
mod event_timings;
|
||||
mod level_event_targets;
|
||||
mod mob_states;
|
||||
|
||||
use super::{Game, shield::OFFHAND_SLOT};
|
||||
use event_timings::EventTimings;
|
||||
@@ -363,12 +364,12 @@ pub(super) fn baby_index(species: Species) -> Option<u8> {
|
||||
/// own: `mob_meta`'s arms hold these mobs' other entries.
|
||||
fn mob_variant_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
sync: &SyncData,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
let (kind, e) = (m.kind.id.as_str(), &m.extra);
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
let id = |registry: &str, name: &str| sync.id(registry, &format!("minecraft:{name}"));
|
||||
match kind {
|
||||
// Axolotl.DATA_VARIANT (after AgeableMob's two).
|
||||
"minecraft:axolotl" => {
|
||||
@@ -501,7 +502,7 @@ fn mob_variant_meta(
|
||||
/// `defaults` gets the defaults of the entries that can go back to theirs.
|
||||
pub(super) fn mob_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
sync: &SyncData,
|
||||
player_entity: Option<i32>,
|
||||
game_time: i64,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
@@ -558,7 +559,7 @@ pub(super) fn mob_meta(
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID: the
|
||||
// swirls of its effects (the ones a mob has are visible, not ambient).
|
||||
meta.extend(
|
||||
hub.effect_meta(
|
||||
sync.effect_meta(
|
||||
(m.extra.effects.iter())
|
||||
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
|
||||
.map(|e| (e.0, m.extra.ambient_effects.contains(&e.0))),
|
||||
@@ -590,7 +591,7 @@ pub(super) fn mob_meta(
|
||||
}
|
||||
}
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
let id = |registry: &str, name: &str| sync.id(registry, &format!("minecraft:{name}"));
|
||||
// Pig.DATA_BOOST_TIME and Strider.DATA_BOOST_TIME (18, the first entry
|
||||
// after AgeableMob's two). The total `boost()` stored in `counter2`.
|
||||
// The default 0 is not registered: vanilla never writes it back, and the
|
||||
@@ -598,7 +599,7 @@ pub(super) fn mob_meta(
|
||||
if matches!(kind, "minecraft:pig" | "minecraft:strider") && e.counter2 > 0 {
|
||||
meta.push((18, serializer::INT, Value::Int(e.counter2)));
|
||||
}
|
||||
mob_variant_meta(m, hub, meta, defaults);
|
||||
mob_variant_meta(m, sync, meta, defaults);
|
||||
mob_raider_meta(m, meta, defaults);
|
||||
match kind {
|
||||
"minecraft:guardian" | "minecraft:elder_guardian" => {
|
||||
@@ -1948,14 +1949,15 @@ impl Game<'_> {
|
||||
mut kept: Option<&mut super::lan_idle::IdlePass<'_>>,
|
||||
) {
|
||||
out.clear();
|
||||
let mut speeds = super::lan_attributes::SpeedByType::default();
|
||||
let mut states = mob_states::Later::new(self.world.game_time);
|
||||
// ChunkMap.tick sends the changes of an entity outside the
|
||||
// entity-ticking range only when it changed section or needs sync.
|
||||
let levels = self.tickets.levels();
|
||||
let idle_at = |p: horizon_core::geometry::Vec3| {
|
||||
!levels.in_entity_ticking_range((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4)
|
||||
};
|
||||
for m in self.mobs.iter().filter(|m| !m.removed && m.id > after) {
|
||||
for (index, m) in (self.mobs.iter().enumerate()).filter(|(_, m)| !m.removed && m.id > after)
|
||||
{
|
||||
// The lead's holder: the player the mob sees, another mob or a
|
||||
// fence knot (game/lan_entities.rs).
|
||||
let player_entity = || {
|
||||
@@ -1986,6 +1988,7 @@ impl Game<'_> {
|
||||
let bare = idle
|
||||
&& (kept.as_deref_mut()).is_some_and(|k| k.bare(hub, m.id as i32, kind, &place))
|
||||
&& !m.needs_sync;
|
||||
let slot = out.as_slice().len();
|
||||
let e = out.add(m.id as i32, entity_uuid(m.id), kind);
|
||||
e.position = place.position;
|
||||
(e.yaw, e.pitch, e.head_yaw) = place.rotation;
|
||||
@@ -1999,21 +2002,11 @@ impl Game<'_> {
|
||||
if bare {
|
||||
continue;
|
||||
}
|
||||
mob_meta(
|
||||
m,
|
||||
hub,
|
||||
player_entity(),
|
||||
self.world.game_time,
|
||||
&mut e.meta,
|
||||
&mut e.defaults,
|
||||
);
|
||||
// HappyGhast.aiStep: requiresPrecisePosition while on a still timeout.
|
||||
e.precise = m.species() == horizon_client::mobs::Species::HappyGhast
|
||||
&& horizon_client::mobs::happy_ghast::on_still_timeout(m);
|
||||
mob_freeze_meta(m, &mut e.meta, &mut e.defaults);
|
||||
let (sync, sources) = (hub.sync_data(), self.attr_sources());
|
||||
states.state((slot, index), m, player_entity(), sync, sources, e);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut speeds, &mut e.attributes);
|
||||
}
|
||||
states.fill(&self.mobs, hub.sync_data(), self.attr_sources(), out);
|
||||
for d in self.dropped.iter().filter(|d| d.id > after) {
|
||||
let idle = idle_at(d.position);
|
||||
let place = super::lan_idle::Place {
|
||||
@@ -4116,7 +4109,7 @@ mod tests {
|
||||
let hub = meta_hub("RaiderMetaTest");
|
||||
let meta_of = |m: &Mob| {
|
||||
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
|
||||
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
|
||||
super::mob_meta(m, hub.sync_data(), None, 0, &mut meta, &mut defaults);
|
||||
(meta, defaults)
|
||||
};
|
||||
|
||||
@@ -4145,7 +4138,7 @@ mod tests {
|
||||
// The same health: the ghoul's own maximum is 40, the zombie's 20.
|
||||
m.health = 10.0;
|
||||
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
|
||||
super::mob_meta(&m, &hub, None, 0, &mut meta, &mut defaults);
|
||||
super::mob_meta(&m, hub.sync_data(), None, 0, &mut meta, &mut defaults);
|
||||
(meta, defaults)
|
||||
};
|
||||
for (template, id) in [
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
//! The part of each mob's state for its clients (`Game::lan_entities`) that reads only the mob and
|
||||
//! the synced tables: its entity data and attribute instances. While the parallel tick is on
|
||||
//! (`horizon_client::parallel`) it is built after the pass, on the tick's helper threads, and put in
|
||||
//! place; otherwise in place during the pass, as one thread always did.
|
||||
use super::super::lan_attributes::{AttrSources, SpeedByType};
|
||||
use super::{mob_freeze_meta, mob_meta};
|
||||
use horizon_client::lan::{
|
||||
data::SyncData,
|
||||
entity::{EntityState, States},
|
||||
};
|
||||
use horizon_client::mobs::Mob;
|
||||
use horizon_protocol::{entity_data::Value, packets::play::AttributeSnapshot};
|
||||
|
||||
/// From this many mobs the states go to the helper threads.
|
||||
const MIN_MOBS: usize = 1024;
|
||||
|
||||
/// One mob's entity data (`meta`, and the `defaults` its entries can go back to) and attribute
|
||||
/// instances; whether its position is sent precisely.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn mob_state(
|
||||
m: &Mob,
|
||||
sync: &SyncData,
|
||||
sources: AttrSources,
|
||||
player_entity: Option<i32>,
|
||||
game_time: i64,
|
||||
speeds: &mut SpeedByType,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
attributes: &mut Vec<AttributeSnapshot>,
|
||||
) -> bool {
|
||||
mob_meta(m, sync, player_entity, game_time, meta, defaults);
|
||||
mob_freeze_meta(m, meta, defaults);
|
||||
sources.mob_attributes(m, speeds, attributes);
|
||||
// HappyGhast.aiStep: requiresPrecisePosition while on a still timeout.
|
||||
m.species() == horizon_client::mobs::Species::HappyGhast
|
||||
&& horizon_client::mobs::happy_ghast::on_still_timeout(m)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's choice of the path, over the parallel tick's switch.
|
||||
static DEFER: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Whether the states wait for the helper threads: while the parallel tick is on.
|
||||
fn deferring() -> bool {
|
||||
#[cfg(test)]
|
||||
if let Some(defer) = DEFER.with(std::cell::Cell::get) {
|
||||
return defer;
|
||||
}
|
||||
horizon_client::parallel::lanes() > 1
|
||||
}
|
||||
|
||||
/// The mobs' states of one pass: built in place, or (while the parallel tick is on) the mobs whose
|
||||
/// states wait for the helper threads: (their slot in the states, their index in the mobs, the
|
||||
/// player entity their lead may name).
|
||||
pub(super) struct Later {
|
||||
waiting: Option<Vec<(usize, usize, Option<i32>)>>,
|
||||
game_time: i64,
|
||||
speeds: SpeedByType,
|
||||
}
|
||||
|
||||
impl Later {
|
||||
pub(super) fn new(game_time: i64) -> Self {
|
||||
Self {
|
||||
waiting: deferring().then(Vec::new),
|
||||
game_time,
|
||||
speeds: SpeedByType::default(),
|
||||
}
|
||||
}
|
||||
/// Mob `index`'s state `e`, in `slot` of the states: built now, or left for [`Self::fill`].
|
||||
pub(super) fn state(
|
||||
&mut self,
|
||||
(slot, index): (usize, usize),
|
||||
m: &Mob,
|
||||
player_entity: Option<i32>,
|
||||
sync: &SyncData,
|
||||
sources: AttrSources,
|
||||
e: &mut EntityState,
|
||||
) {
|
||||
if let Some(waiting) = &mut self.waiting {
|
||||
waiting.push((slot, index, player_entity));
|
||||
return;
|
||||
}
|
||||
let (meta, defaults, attributes) = (&mut e.meta, &mut e.defaults, &mut e.attributes);
|
||||
e.precise = mob_state(
|
||||
m,
|
||||
sync,
|
||||
sources,
|
||||
player_entity,
|
||||
self.game_time,
|
||||
&mut self.speeds,
|
||||
meta,
|
||||
defaults,
|
||||
attributes,
|
||||
);
|
||||
}
|
||||
/// The waiting states, built on the helper threads and put in place.
|
||||
pub(super) fn fill(
|
||||
self,
|
||||
mobs: &[Mob],
|
||||
sync: &SyncData,
|
||||
sources: AttrSources,
|
||||
out: &mut States,
|
||||
) {
|
||||
let Some(waiting) = self.waiting else {
|
||||
return;
|
||||
};
|
||||
let game_time = self.game_time;
|
||||
let built = horizon_client::parallel::par_map(&waiting, MIN_MOBS, |part, done| {
|
||||
let mut speeds = SpeedByType::default();
|
||||
for &(slot, index, player) in part {
|
||||
let (mut meta, mut defaults, mut attributes) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let precise = mob_state(
|
||||
&mobs[index],
|
||||
sync,
|
||||
sources,
|
||||
player,
|
||||
game_time,
|
||||
&mut speeds,
|
||||
&mut meta,
|
||||
&mut defaults,
|
||||
&mut attributes,
|
||||
);
|
||||
done.push((slot, meta, defaults, attributes, precise));
|
||||
}
|
||||
});
|
||||
let states = out.as_mut_slice();
|
||||
for (slot, meta, defaults, attributes, precise) in built {
|
||||
let e = &mut states[slot];
|
||||
(e.meta, e.defaults, e.attributes, e.precise) = (meta, defaults, attributes, precise);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::lan::{NetConfig, hub::HubConfig};
|
||||
|
||||
/// The states put in place after the pass are those built in place during it, for mobs whose
|
||||
/// entity data and attributes vary: effects, armor, a horse's rolled jump, variants, a baby,
|
||||
/// a happy ghast, and what `/attribute` changed.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources and scratch homes/cwd"]
|
||||
fn deferred_states_match_those_built_in_place() {
|
||||
crate::mod_api::in_own_process(
|
||||
"game::lan::mob_states::tests::deferred_states_match_those_built_in_place",
|
||||
deferred_states_match_those_built_in_place_in_own_process,
|
||||
);
|
||||
}
|
||||
|
||||
fn deferred_states_match_those_built_in_place_in_own_process() {
|
||||
let mut game = super::super::super::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
for command in [
|
||||
"summon piglin 8.5 80 8.5 {NoAI:1b,PersistenceRequired:1b}",
|
||||
"effect give @e[type=piglin] speed 30 1",
|
||||
"item replace entity @e[type=piglin,limit=1] armor.head with iron_helmet",
|
||||
"summon horse 10.5 80 8.5 {NoAI:1b}",
|
||||
"summon sheep 12.5 80 8.5 {NoAI:1b,Color:3b,Age:-24000}",
|
||||
"summon axolotl 14.5 80 8.5 {NoAI:1b,Variant:2}",
|
||||
"summon happy_ghast 16.5 84 8.5 {NoAI:1b}",
|
||||
"summon pig 18.5 80 8.5 {NoAI:1b}",
|
||||
"summon frog 20.5 80 8.5 {NoAI:1b}",
|
||||
"attribute @e[type=pig,limit=1] minecraft:scale base set 1.5",
|
||||
] {
|
||||
assert!(game.run_command(command), "{command}");
|
||||
}
|
||||
let examples = game.mobs.clone();
|
||||
game.mobs = (0..MIN_MOBS * 2 + 37)
|
||||
.map(|i| {
|
||||
let mut mob = examples[i % examples.len()].clone();
|
||||
mob.id = 10_000 + i as u32;
|
||||
mob.removed = i % 11 == 0;
|
||||
mob
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into();
|
||||
game.lan_host(
|
||||
super::super::LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig::loopback(0, String::new(), 2),
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 20,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut lan = game.lan.take().unwrap();
|
||||
// The shared tables must retain pack-selected effect colors, not just
|
||||
// vanilla's registry color. This process owns the global pack palette.
|
||||
horizon_client::brewing::set_pack_colors(vec![("speed".into(), 0xAA12_3456)]);
|
||||
let players = Default::default();
|
||||
let mut runs = Vec::new();
|
||||
struct Reset;
|
||||
impl Drop for Reset {
|
||||
fn drop(&mut self) {
|
||||
DEFER.with(|d| d.set(None));
|
||||
}
|
||||
}
|
||||
let _reset = Reset;
|
||||
for after in [0, 10_012] {
|
||||
runs.clear();
|
||||
for defer in [false, true, false] {
|
||||
DEFER.with(|d| d.set(Some(defer)));
|
||||
let mut states = States::default();
|
||||
game.lan_entities(&players, &mut lan.hub, &mut states, false, after, None);
|
||||
runs.push(states.as_slice().to_vec());
|
||||
}
|
||||
let expected_ids: Vec<_> = game
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| !m.removed && m.id > after)
|
||||
.map(|m| m.id as i32)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
runs[0].iter().map(|e| e.id).collect::<Vec<_>>(),
|
||||
expected_ids
|
||||
);
|
||||
assert!(runs[0].iter().filter(|e| !e.meta.is_empty()).count() >= MIN_MOBS);
|
||||
assert!(runs[0].iter().any(|e| !e.attributes.is_empty()));
|
||||
let colored: Vec<_> = runs[0]
|
||||
.iter()
|
||||
.filter(|e| e.kind == "minecraft:piglin")
|
||||
.collect();
|
||||
assert!(!colored.is_empty());
|
||||
for e in colored {
|
||||
let particles = e.meta.iter().find(|e| e.0 == 10).unwrap();
|
||||
let Value::Particles(particles) = &particles.2 else {
|
||||
panic!("effect particle metadata");
|
||||
};
|
||||
assert_eq!(particles.len(), 1);
|
||||
assert_eq!(
|
||||
particles[0].data,
|
||||
Some(horizon_protocol::entity_data::ParticleData::Color(
|
||||
0xFF12_3456_u32 as i32
|
||||
))
|
||||
);
|
||||
}
|
||||
assert_eq!(runs[0], runs[1], "deferred, after {after}");
|
||||
assert_eq!(runs[0], runs[2], "switch off, after {after}");
|
||||
|
||||
// With deferral off the caller can read the metadata immediately,
|
||||
// before fill, and fill must not replace the vectors it already owns.
|
||||
let m = game
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| !m.removed && m.id > after)
|
||||
.unwrap();
|
||||
let mut states = States::default();
|
||||
let e = states.add(m.id as i32, 0, &m.kind.id);
|
||||
let mut later = Later::new(game.world.game_time);
|
||||
later.state((0, 0), m, None, lan.hub.sync_data(), game.attr_sources(), e);
|
||||
assert_eq!(e.meta, runs[0][0].meta);
|
||||
assert_eq!(e.defaults, runs[0][0].defaults);
|
||||
assert_eq!(e.attributes, runs[0][0].attributes);
|
||||
let pointers = (e.meta.as_ptr(), e.defaults.as_ptr(), e.attributes.as_ptr());
|
||||
later.fill(
|
||||
&game.mobs,
|
||||
lan.hub.sync_data(),
|
||||
game.attr_sources(),
|
||||
&mut states,
|
||||
);
|
||||
let e = &states.as_slice()[0];
|
||||
assert_eq!(
|
||||
pointers,
|
||||
(e.meta.as_ptr(), e.defaults.as_ptr(), e.attributes.as_ptr())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ fn a_sulfur_cube_sends_its_fuse_at_19_and_from_bucket_at_20() {
|
||||
let hub = meta_hub("SulfurCubeMetaTest");
|
||||
let meta_of = |m: &Mob| {
|
||||
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
|
||||
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
|
||||
super::mob_meta(m, hub.sync_data(), None, 0, &mut meta, &mut defaults);
|
||||
(meta, defaults)
|
||||
};
|
||||
|
||||
|
||||
@@ -103,10 +103,32 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a mob's synced attribute instances are read from (`mob_attributes`).
|
||||
pub(super) fn attr_sources(&self) -> AttrSources<'_> {
|
||||
AttrSources {
|
||||
catalog: &self.motion.attributes,
|
||||
items: self.items(),
|
||||
commands: &self.cmds.attributes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the synced attribute instances are read from: the attribute catalog, the item table and
|
||||
/// what `/attribute` changed. Plain tables, so the tick's helper threads can read them
|
||||
/// (`Game::lan_entities`).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct AttrSources<'a> {
|
||||
catalog: &'a horizon_world::attributes::entity::Catalog,
|
||||
items: &'a horizon_client::items::Items,
|
||||
commands:
|
||||
&'a std::collections::HashMap<Target, horizon_world::attributes::entity::EntityAttributes>,
|
||||
}
|
||||
|
||||
impl AttrSources<'_> {
|
||||
/// The registry id of a client-syncable attribute the entity type has
|
||||
/// (`AttributeMap.getInstance` is null for the others), by name.
|
||||
fn synced_attribute(&self, entity: &str, name: &str) -> Option<i32> {
|
||||
let catalog = &self.motion.attributes;
|
||||
let catalog = self.catalog;
|
||||
let id = catalog.id(name)?;
|
||||
(catalog.attribute(id).syncable && catalog.default_base(entity, id).is_some())
|
||||
.then(|| id.registry_id())
|
||||
@@ -114,7 +136,7 @@ impl Game<'_> {
|
||||
|
||||
/// The type's base value of an attribute, as the instance starts.
|
||||
fn attribute_default(&self, entity: &str, name: &str) -> f64 {
|
||||
let catalog = &self.motion.attributes;
|
||||
let catalog = self.catalog;
|
||||
catalog
|
||||
.id(name)
|
||||
.and_then(|id| catalog.default_base(entity, id))
|
||||
@@ -132,7 +154,7 @@ impl Game<'_> {
|
||||
out: &mut Vec<AttributeSnapshot>,
|
||||
) {
|
||||
for (slot, item) in slots {
|
||||
let Some(info) = item.and_then(|i| self.items().get(i)) else {
|
||||
let Some(info) = item.and_then(|i| self.items.get(i)) else {
|
||||
continue;
|
||||
};
|
||||
for (attribute, id, amount, op, group) in &info.modifiers {
|
||||
@@ -306,6 +328,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The instances `/attribute` created in an entity's generic map
|
||||
/// replace the ones derived from its state.
|
||||
fn generic_attributes_of(&self, t: Target, out: &mut Vec<AttributeSnapshot>) {
|
||||
if let Some(map) = self.commands.get(&t) {
|
||||
generic_attributes_into(map, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// An armor stand's synced instances: movement speed (its first
|
||||
/// `aiStep`), its equipment's modifiers and what `/attribute` changed.
|
||||
pub(super) fn stand_attributes(
|
||||
@@ -315,18 +347,19 @@ impl Game<'_> {
|
||||
) -> Vec<AttributeSnapshot> {
|
||||
const KIND: &str = "minecraft:armor_stand";
|
||||
let mut out = Vec::new();
|
||||
if let Some(a) = self.synced_attribute(KIND, "minecraft:movement_speed") {
|
||||
let sources = self.attr_sources();
|
||||
if let Some(a) = sources.synced_attribute(KIND, "minecraft:movement_speed") {
|
||||
instance(
|
||||
&mut out,
|
||||
a,
|
||||
self.attribute_default(KIND, "minecraft:movement_speed"),
|
||||
sources.attribute_default(KIND, "minecraft:movement_speed"),
|
||||
);
|
||||
}
|
||||
let slots = horizon_client::entities::ArmorStand::SLOTS
|
||||
.into_iter()
|
||||
.zip(equipment.iter().map(|s| s.as_ref().map(|s| &s.item)));
|
||||
self.equipment_attributes(KIND, |_| None, slots, &mut out);
|
||||
self.generic_attributes_of(Target::Stand(id), &mut out);
|
||||
sources.equipment_attributes(KIND, |_| None, slots, &mut out);
|
||||
sources.generic_attributes_of(Target::Stand(id), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -372,14 +405,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The instances `/attribute` created in an entity's generic map
|
||||
/// replace the ones derived from its state.
|
||||
fn generic_attributes_of(&self, t: Target, out: &mut Vec<AttributeSnapshot>) {
|
||||
if let Some(map) = self.cmds.attributes.get(&t) {
|
||||
generic_attributes_into(map, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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,8 +98,9 @@ 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_icon(Window::default_attributes().with_title("Horizon"));
|
||||
let attributes = horizon_client::window::with_horizon_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(
|
||||
@@ -127,25 +130,22 @@ 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")]
|
||||
// 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
|
||||
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));
|
||||
}
|
||||
});
|
||||
stop
|
||||
});
|
||||
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
|
||||
let boot_step = startup::step("renderer.boot");
|
||||
match loading_renderer(
|
||||
|
||||
@@ -93,9 +93,10 @@ impl ApplicationHandler for Missing {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
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 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 made = event_loop
|
||||
.create_window(attributes)
|
||||
.map_err(Into::into)
|
||||
|
||||
@@ -801,8 +801,19 @@ impl Game<'_> {
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
let world = horizon_hooks::players::Pass::World;
|
||||
self.own_layers_geometry(m, world, camera, alpha as f32, packed, out);
|
||||
if super::cosmetics::enabled() {
|
||||
let base = horizon_client::mobs::render::base_transform(m, alpha as f32, camera);
|
||||
self.cosmetics_geometry(
|
||||
m,
|
||||
true,
|
||||
base,
|
||||
Some(camera),
|
||||
alpha as f32,
|
||||
packed,
|
||||
false,
|
||||
out,
|
||||
);
|
||||
}
|
||||
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
|
||||
@@ -916,8 +927,10 @@ impl Game<'_> {
|
||||
240 | 240 << 8,
|
||||
&mut verts,
|
||||
);
|
||||
let picture = horizon_hooks::players::Pass::Picture;
|
||||
self.own_layers_geometry(&m, picture, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
if super::cosmetics::enabled() {
|
||||
let base = horizon_client::mobs::render::base_transform(&m, 1.0, camera);
|
||||
self.cosmetics_geometry(&m, true, base, None, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
}
|
||||
// `WingsLayer` draws through `RenderTypes.armorCutoutNoCull`: the
|
||||
// wings' faces show from both sides (the elytra texture is empty on
|
||||
// the face that looks at the body).
|
||||
|
||||
@@ -11,15 +11,17 @@ use horizon_client::mobs::anim::{self, BodyPose, PooledPoses, Poses};
|
||||
use horizon_client::mobs::{Mob, Part, Polygon, render};
|
||||
use horizon_hooks::engine;
|
||||
use horizon_hooks::players::{
|
||||
Bone, CameraView, Frame, Gear, Kind, Light, Look, Mesh, Motion, PartPoses, Particle, Pass,
|
||||
PlayerDraw, PlayerModel, PlayerModels, SpriteStore, Sprites, View,
|
||||
Bone, CameraView, Frame, Gear, Kind, Mesh, PartPoses, Pass, PlayerDraw, PlayerModel,
|
||||
PlayerModels, SpriteStore, Sprites,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Registers the hooks of the Horizon parts still in this crate (cosmetics: spec #14091 step 5
|
||||
/// moves them out). Before the hooks are sealed.
|
||||
pub(crate) fn register() {
|
||||
if let Err(e) = super::cosmetics::register() {
|
||||
if super::cosmetics::enabled()
|
||||
&& let Err(e) = horizon_hooks::register(&super::cosmetics::HOOKS)
|
||||
{
|
||||
log::error!("Cosmetics: their hooks came too late: {e}");
|
||||
}
|
||||
}
|
||||
@@ -180,14 +182,6 @@ fn part_of(mesh: &Arc<Mesh>) -> Arc<Part> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Full light, packed as the vertices carry it.
|
||||
const FULL: u32 = 240 | 240 << 8;
|
||||
|
||||
thread_local! {
|
||||
/// What crates draw see-through this frame, added after every player.
|
||||
static SEE_THROUGH: std::cell::RefCell<Vec<Vertex>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Forgets the meshes no crate holds any more (once a frame).
|
||||
fn forget_meshes() {
|
||||
let _ = MESHES.try_with(|m| {
|
||||
@@ -205,7 +199,7 @@ impl PartPoses for PosesOf<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A player model drawn, as a crate draws on it: posed on the GPU's skin path
|
||||
/// A player model drawn in the world pass, as a crate draws on it: posed on the GPU's skin path
|
||||
/// when `gpu` (the mob pass's skin frame, begun; the bounds of what went there grow, for
|
||||
/// culling), else its vertices go to `out`.
|
||||
struct DrawPlayer<'a, 'g> {
|
||||
@@ -214,7 +208,6 @@ struct DrawPlayer<'a, 'g> {
|
||||
pass: Pass,
|
||||
/// The model's matrix (`render::base_transform`).
|
||||
base: glam::Mat4,
|
||||
camera: glam::DVec3,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
gpu: bool,
|
||||
@@ -230,19 +223,6 @@ impl DrawPlayer<'_, '_> {
|
||||
let (key, _) = render::body(self.m);
|
||||
self.m.kind.layer(&key).unwrap_or(&self.m.kind.model)
|
||||
}
|
||||
|
||||
/// `light` packed; `at`: the place `Light::AtPlace` reads, relative to the camera.
|
||||
fn packed(&self, light: Light, at: glam::Vec3) -> u32 {
|
||||
match light {
|
||||
Light::Full => FULL,
|
||||
Light::AtPlace => {
|
||||
let p = (self.camera + at.as_dvec3()).floor();
|
||||
let l = self.game.world.light(p.x as i32, p.y as i32, p.z as i32);
|
||||
(u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8)
|
||||
}
|
||||
_ => self.light,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerDraw for DrawPlayer<'_, '_> {
|
||||
@@ -254,52 +234,7 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
|
||||
self.m.age as f32 + self.alpha
|
||||
}
|
||||
|
||||
fn base(&self) -> [[f32; 4]; 4] {
|
||||
self.base.to_cols_array_2d()
|
||||
}
|
||||
|
||||
fn motion(&self) -> Motion {
|
||||
let (m, a) = (self.m, self.alpha);
|
||||
let t = f64::from(a);
|
||||
let (p, n) = (m.previous, m.position);
|
||||
let feet = [
|
||||
p.x + (n.x - p.x) * t,
|
||||
p.y + (n.y - p.y) * t,
|
||||
p.z + (n.z - p.z) * t,
|
||||
];
|
||||
let turn = horizon_client::mobs::living::wrap(m.head_yaw - m.previous_head_yaw);
|
||||
let yaw = m.previous_head_yaw + turn * a;
|
||||
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * a;
|
||||
let asleep = m.extra.avatar_pose == horizon_client::pose::Pose::Sleeping;
|
||||
let swimming = m.extra.swim.amount(a) > 0.0;
|
||||
Motion::new(feet, yaw, pitch).poses(
|
||||
m.extra.crouching,
|
||||
asleep,
|
||||
m.extra.fall_flying,
|
||||
swimming,
|
||||
)
|
||||
}
|
||||
|
||||
fn view(&self) -> View {
|
||||
let q = self.game.camera_orientation();
|
||||
let (right, up) = (
|
||||
(q * glam::Vec3::X).to_array(),
|
||||
(q * glam::Vec3::Y).to_array(),
|
||||
);
|
||||
let (yaw, pitch) = (self.game.yaw.to_radians(), self.game.pitch.to_radians());
|
||||
let forward = [
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
];
|
||||
let left = [yaw.cos(), 0.0, yaw.sin()];
|
||||
View::new(self.camera.to_array(), right, up).looking(forward, left)
|
||||
}
|
||||
|
||||
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
|
||||
if self.pass == Pass::FirstPerson {
|
||||
return None;
|
||||
}
|
||||
if self.body.is_none() {
|
||||
let mut poses = Poses::pooled();
|
||||
poses.reset(self.body());
|
||||
@@ -325,30 +260,13 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
|
||||
&mut self,
|
||||
mesh: &Arc<Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
look: Look,
|
||||
uv: [f32; 4],
|
||||
pose: &mut dyn FnMut(&mut dyn PartPoses),
|
||||
) {
|
||||
let model = part_of(mesh);
|
||||
let matrix = glam::Mat4::from_cols_array_2d(&matrix);
|
||||
let (uv, light) = (look.uv, self.packed(look.light, matrix.w_axis.truncate()));
|
||||
self.poses.reset(&model);
|
||||
pose(&mut PosesOf(&mut self.poses));
|
||||
let poses = &self.poses;
|
||||
if let Some(alpha) = look.see_through {
|
||||
let color = ((alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
let pose_of = |p: &Part| poses.pose(p);
|
||||
SEE_THROUGH.with_borrow_mut(|out| {
|
||||
let start = out.len();
|
||||
horizon_client::mobs::render_model(&model, matrix, &pose_of, uv, light, 0, out);
|
||||
for v in &mut out[start..] {
|
||||
v.color = color;
|
||||
// Seen through, a face turned away would add to the near one: `entity.wgsl`
|
||||
// drops back faces, so it is `alpha` at most.
|
||||
v.normal[3] = horizon_client::mesher::SHADED_CULL_W;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if self.gpu {
|
||||
let mut models = self.game.skin_models.borrow_mut();
|
||||
let mut frame = self.game.skin_frame.borrow_mut();
|
||||
@@ -358,43 +276,30 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
|
||||
uv,
|
||||
scroll: 0.0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
light: self.light,
|
||||
overlay: 0,
|
||||
};
|
||||
if skin.draw(&model, matrix, poses, paint) {
|
||||
if skin.draw(&model, matrix, &self.poses, paint) {
|
||||
self.bounds = skin.bounds;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let pose_of = |p: &Part| poses.pose(p);
|
||||
horizon_client::mobs::render_model(&model, matrix, &pose_of, uv, light, 0, self.out);
|
||||
}
|
||||
|
||||
fn particles(&mut self, uv: [f32; 4], particles: &[Particle], light: Light) {
|
||||
let light = self.packed(light, glam::Vec3::ZERO);
|
||||
SEE_THROUGH.with_borrow_mut(|out| {
|
||||
for q in particles {
|
||||
let color = ((q.alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
for (corner, [u, v]) in q.corners.iter().zip(q.uv) {
|
||||
out.push(Vertex {
|
||||
pos: *corner,
|
||||
uv: [uv[0] + (uv[2] - uv[0]) * u, uv[1] + (uv[3] - uv[1]) * v],
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
// Unlit: a particle keeps its colour whichever way it faces.
|
||||
normal: [0, 0, 0, 0],
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
let poses = &self.poses;
|
||||
horizon_client::mobs::render_model(
|
||||
&model,
|
||||
matrix,
|
||||
&|p: &Part| poses.pose(p),
|
||||
uv,
|
||||
self.light,
|
||||
0,
|
||||
self.out,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// What crates draw on player model `m` in the world pass (`creatures.rs`): see
|
||||
/// [`DrawPlayer`]. The bounds of what went to the GPU.
|
||||
/// What crates draw on player model `m` in the world pass (`creatures.rs`), then the
|
||||
/// cosmetics still drawn here: see [`DrawPlayer`]. The bounds of what went to the GPU.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn player_layers_geometry(
|
||||
&self,
|
||||
@@ -410,102 +315,33 @@ impl Game<'_> {
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
let kind = kind(m).filter(|_| engine::any())?;
|
||||
let model = model(m, kind, self.remote_player_uuid(m.id));
|
||||
let mut draw = DrawPlayer {
|
||||
game: self,
|
||||
m,
|
||||
pass: Pass::World,
|
||||
base,
|
||||
camera,
|
||||
alpha,
|
||||
light,
|
||||
gpu,
|
||||
out,
|
||||
bounds: None,
|
||||
body: None,
|
||||
poses: Poses::pooled(),
|
||||
};
|
||||
engine::player_layers(&model, &mut draw);
|
||||
draw.bounds
|
||||
}
|
||||
|
||||
/// What crates draw on the window's own model `m` (its vertices to `out`): in the world in
|
||||
/// third person (`Pass::World`), or in the inventory's picture of it (`Pass::Picture`).
|
||||
/// `camera`: the world view's camera, or the picture's.
|
||||
pub(super) fn own_layers_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
pass: Pass,
|
||||
camera: glam::DVec3,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
if !engine::any() {
|
||||
return;
|
||||
}
|
||||
let model = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
|
||||
let mut draw = DrawPlayer {
|
||||
game: self,
|
||||
m,
|
||||
pass,
|
||||
base: render::base_transform(m, alpha, camera),
|
||||
camera,
|
||||
alpha,
|
||||
light,
|
||||
gpu: false,
|
||||
out,
|
||||
bounds: None,
|
||||
body: None,
|
||||
poses: Poses::pooled(),
|
||||
};
|
||||
engine::player_layers(&model, &mut draw);
|
||||
}
|
||||
|
||||
/// After every player (`game.rs`): what crates draw around the window's own model in first
|
||||
/// person, then what was drawn see-through, in one span.
|
||||
pub(super) fn players_after_geometry(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if engine::any()
|
||||
&& !self.own_body_in_view()
|
||||
&& let Some(m) = self.player_model.as_ref()
|
||||
{
|
||||
let own = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
|
||||
let own = own.looks(own.gear, own.invisible || self.own_invisible());
|
||||
self.mark_entity(out.len(), "minecraft:player");
|
||||
let kind = kind(m)?;
|
||||
let mut bounds = None;
|
||||
if engine::any() {
|
||||
let model = model(m, kind, self.remote_player_uuid(m.id));
|
||||
let mut draw = DrawPlayer {
|
||||
game: self,
|
||||
m,
|
||||
pass: Pass::FirstPerson,
|
||||
base: render::base_transform(m, alpha as f32, camera),
|
||||
camera,
|
||||
alpha: alpha as f32,
|
||||
light: FULL,
|
||||
gpu: false,
|
||||
pass: Pass::World,
|
||||
base,
|
||||
alpha,
|
||||
light,
|
||||
gpu,
|
||||
out: &mut *out,
|
||||
bounds: None,
|
||||
body: None,
|
||||
poses: Poses::pooled(),
|
||||
};
|
||||
engine::first_person(&own, &mut draw);
|
||||
engine::player_layers(&model, &mut draw);
|
||||
bounds = draw.bounds;
|
||||
}
|
||||
let worn = super::cosmetics::enabled()
|
||||
.then(|| self.cosmetics_geometry(m, false, base, Some(camera), alpha, light, gpu, out))
|
||||
.flatten();
|
||||
match (bounds, worn) {
|
||||
(Some((l, h)), Some((lo, hi))) => Some((l.min(lo), h.max(hi))),
|
||||
(b, w) => b.or(w),
|
||||
}
|
||||
let _ = SEE_THROUGH.try_with(|v| {
|
||||
let mut v = v.borrow_mut();
|
||||
if !v.is_empty() {
|
||||
self.mark_translucent_entity(out.len(), "minecraft:player");
|
||||
out.extend_from_slice(&v);
|
||||
v.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Once a frame, before drawing.
|
||||
@@ -522,7 +358,6 @@ impl Game<'_> {
|
||||
let frame = Frame::new(connection, self.integrated.is_some());
|
||||
engine::frame(&frame, &mut HookStore(self));
|
||||
forget_meshes();
|
||||
let _ = SEE_THROUGH.try_with(|v| v.borrow_mut().clear());
|
||||
self.cosmetics_frame();
|
||||
}
|
||||
|
||||
|
||||
@@ -11463,7 +11463,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()
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -516,9 +516,10 @@ 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_icon(Window::default_attributes())
|
||||
.with_active(false)
|
||||
.with_visible(window_shown(true, self.offscreen));
|
||||
let attributes =
|
||||
horizon_client::window::with_horizon_icon(Window::default_attributes())
|
||||
.with_active(false)
|
||||
.with_visible(window_shown(true, self.offscreen));
|
||||
// A monitor's window covers it, borderless. On macOS not the system's fullscreen, a
|
||||
// Space of its own that hides the other windows as it slides in.
|
||||
let placement = self.placement(*key).cloned();
|
||||
|
||||
@@ -113,117 +113,6 @@ fn fire_from_damage_lights_or_burns_the_stand_though_the_command_fails() {
|
||||
assert!(health.ends_with(": 16.0f"), "{health}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn igniting_a_frozen_stand_clears_its_freeze_while_burning_keeps_it() {
|
||||
let mut game = game();
|
||||
summon(&mut game, ",TicksFrozen:140");
|
||||
let (_, frozen) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
|
||||
assert!(frozen.ends_with(": 140"), "{frozen}");
|
||||
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
|
||||
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
|
||||
assert!(fire.ends_with(": 100s"), "{fire}");
|
||||
let (ok, frozen_after) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
|
||||
assert!(!ok, "{frozen_after}");
|
||||
|
||||
// When already burning, in_fire calls causeDamage(0.15) instead of igniteForSeconds.
|
||||
assert!(game.run_command(&format!("data merge entity {STAND} {{TicksFrozen:140}}")));
|
||||
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
|
||||
let (ok, still_frozen) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
|
||||
assert!(ok && still_frozen.ends_with(": 140"), "{still_frozen}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn damage_immunity_on_worn_boots_refuses_campfire_but_not_in_fire_or_wrong_slot() {
|
||||
let mut game = game();
|
||||
summon(
|
||||
&mut game,
|
||||
r#",equipment:{feet:{id:"minecraft:diamond_boots",count:1,components:{"minecraft:enchantments":{"minecraft:frost_walker":1}}}}"#,
|
||||
);
|
||||
// campfire is in both #ignites_armor_stands and #burn_from_stepping.
|
||||
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:campfire")));
|
||||
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
|
||||
assert!(fire.ends_with(": 0s"), "{fire}");
|
||||
// in_fire is not in #burn_from_stepping, so it still ignites the stand.
|
||||
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
|
||||
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
|
||||
assert!(fire.ends_with(": 100s"), "{fire}");
|
||||
|
||||
// Frost Walker on a helmet (head) is not in an active slot for the enchantment.
|
||||
let mut helmeted = self::game();
|
||||
summon(
|
||||
&mut helmeted,
|
||||
r#",equipment:{head:{id:"minecraft:diamond_helmet",count:1,components:{"minecraft:enchantments":{"minecraft:frost_walker":1}}}}"#,
|
||||
);
|
||||
assert!(!helmeted.run_command(&format!("damage {STAND} 1 minecraft:campfire")));
|
||||
let (_, fire) = say(&mut helmeted, &format!("data get entity {STAND} Fire"));
|
||||
assert!(fire.ends_with(": 100s"), "{fire}");
|
||||
}
|
||||
|
||||
fn capture() {
|
||||
super::sculk::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
|
||||
}
|
||||
|
||||
fn take_events() -> Vec<(&'static str, [f64; 3], Option<horizon_client::mobs::Who>)> {
|
||||
super::sculk::TEST_POSTED_EVENTS.with(|events| events.borrow_mut().take().unwrap_or_default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn cause_damage_and_explosion_post_attributed_game_events() {
|
||||
use horizon_client::mobs::Who;
|
||||
let mut game = game();
|
||||
summon(&mut game, ",Fire:200s");
|
||||
assert!(game.run_command("summon pig 14.5 100 2.5 {NoAI:1b}"));
|
||||
let pig_id = game.mobs[0].id;
|
||||
let at = [2.5, 100.0, 8.5];
|
||||
|
||||
// Burning stand hurt by in_fire (0.15) posts entity_damage attributed to the pig.
|
||||
capture();
|
||||
assert!(!game.run_command(&format!(
|
||||
"damage {STAND} 1 minecraft:in_fire by @e[type=pig,limit=1]"
|
||||
)));
|
||||
assert_eq!(
|
||||
take_events(),
|
||||
vec![("minecraft:entity_damage", at, Some(Who::Mob(pig_id)))]
|
||||
);
|
||||
|
||||
// Burning stand hurt by on_fire (4.0) while surviving posts entity_damage.
|
||||
capture();
|
||||
assert!(!game.run_command(&format!(
|
||||
"damage {STAND} 1 minecraft:on_fire by @e[type=pig,limit=1]"
|
||||
)));
|
||||
assert_eq!(
|
||||
take_events(),
|
||||
vec![("minecraft:entity_damage", at, Some(Who::Mob(pig_id)))]
|
||||
);
|
||||
|
||||
// Burning stand broken by on_fire (health <= 0.5) posts entity_die attributed to the pig.
|
||||
assert!(game.run_command(&format!("data merge entity {STAND} {{Health:4f}}")));
|
||||
capture();
|
||||
assert!(!game.run_command(&format!(
|
||||
"damage {STAND} 1 minecraft:on_fire by @e[type=pig,limit=1]"
|
||||
)));
|
||||
assert!(!game.run_command(&format!("execute if entity {STAND}")));
|
||||
assert_eq!(
|
||||
take_events(),
|
||||
vec![("minecraft:entity_die", at, Some(Who::Mob(pig_id)))]
|
||||
);
|
||||
|
||||
// Stand broken by explosion posts entity_die attributed to the pig.
|
||||
summon(&mut game, "");
|
||||
capture();
|
||||
assert!(!game.run_command(&format!(
|
||||
"damage {STAND} 1 minecraft:explosion by @e[type=pig,limit=1]"
|
||||
)));
|
||||
assert!(!game.run_command(&format!("execute if entity {STAND}")));
|
||||
assert_eq!(
|
||||
take_events(),
|
||||
vec![("minecraft:entity_die", at, Some(Who::Mob(pig_id)))]
|
||||
);
|
||||
}
|
||||
|
||||
// Controls added after the baseline: the guards before the acting sources,
|
||||
// and a stand broken once however often it is hurt.
|
||||
|
||||
|
||||
@@ -273,8 +273,9 @@ 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_icon(Window::default_attributes().with_title("Horizon"));
|
||||
let attributes = horizon_client::window::with_horizon_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(
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
//! 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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! (`data/minecraft/...`) and the registries report.
|
||||
use crate::chunk::LevelShapes;
|
||||
use horizon_protocol::{
|
||||
entity_data,
|
||||
packets::configuration::{RegistryData, RegistryEntry, RegistryTags, TagEntry, UpdateTags},
|
||||
registry::{Builtin, Dynamic},
|
||||
};
|
||||
@@ -506,6 +507,58 @@ impl SyncData {
|
||||
.any(|(_, e)| e.from_pack.contains(&true))
|
||||
}
|
||||
|
||||
/// `LivingEntity.updateSynchronizedMobEffectParticles`: DATA_EFFECT_PARTICLES
|
||||
/// (10) and DATA_EFFECT_AMBIENCE_ID (11) for the visible `effects`, as
|
||||
/// (effect, ambient). The entries are left out when there is nothing to
|
||||
/// show, which is their default.
|
||||
pub fn effect_meta<'a>(
|
||||
&self,
|
||||
effects: impl IntoIterator<Item = (&'a str, bool)>,
|
||||
) -> Vec<(u8, i32, entity_data::Value)> {
|
||||
let mut particles = Vec::new();
|
||||
let mut all_ambient = true;
|
||||
for (effect, ambient) in effects {
|
||||
let effect = effect.trim_start_matches("minecraft:");
|
||||
// MobEffect.particleFactory: the omens have their own particles,
|
||||
// the others an entity effect in their colour (faint if ambient).
|
||||
let (name, data) = match effect {
|
||||
"trial_omen" | "raid_omen" => (effect, None),
|
||||
_ => {
|
||||
let Some(def) = crate::brewing::effect(effect) else {
|
||||
continue;
|
||||
};
|
||||
let alpha = if ambient { 38 } else { 255 };
|
||||
let argb = (alpha << 24) | def.shown_color() as i32;
|
||||
(
|
||||
"entity_effect",
|
||||
Some(entity_data::ParticleData::Color(argb)),
|
||||
)
|
||||
}
|
||||
};
|
||||
let Some(id) = self.id("minecraft:particle_type", &format!("minecraft:{name}")) else {
|
||||
continue;
|
||||
};
|
||||
particles.push(entity_data::Particle { id, data });
|
||||
all_ambient &= ambient;
|
||||
}
|
||||
if particles.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut meta = vec![(
|
||||
10,
|
||||
entity_data::serializer::PARTICLES,
|
||||
entity_data::Value::Particles(particles),
|
||||
)];
|
||||
if all_ambient {
|
||||
meta.push((
|
||||
11,
|
||||
entity_data::serializer::BOOLEAN,
|
||||
entity_data::Value::Bool(true),
|
||||
));
|
||||
}
|
||||
meta
|
||||
}
|
||||
|
||||
/// The id of a name in a built-in or synchronized registry.
|
||||
pub fn id(&self, registry: &str, name: &str) -> Option<i32> {
|
||||
if registry == "minecraft:data_component_type"
|
||||
|
||||
@@ -24,7 +24,7 @@ use horizon_core::{
|
||||
};
|
||||
use horizon_protocol::ids::play::clientbound as ids;
|
||||
use horizon_protocol::{
|
||||
entity_data::{Particle, ParticleData, Value, serializer},
|
||||
entity_data::{Value, serializer},
|
||||
packets::play::{self, Clientbound as C, Serverbound as S},
|
||||
text::{literal, translatable},
|
||||
};
|
||||
@@ -3250,39 +3250,12 @@ impl Hub {
|
||||
&self,
|
||||
effects: impl IntoIterator<Item = (&'a str, bool)>,
|
||||
) -> Vec<(u8, i32, Value)> {
|
||||
let mut particles = Vec::new();
|
||||
let mut all_ambient = true;
|
||||
for (effect, ambient) in effects {
|
||||
let effect = effect.trim_start_matches("minecraft:");
|
||||
// MobEffect.particleFactory: the omens have their own particles,
|
||||
// the others an entity effect in their colour (faint if ambient).
|
||||
let (name, data) = match effect {
|
||||
"trial_omen" | "raid_omen" => (effect, None),
|
||||
_ => {
|
||||
let Some(def) = crate::brewing::effect(effect) else {
|
||||
continue;
|
||||
};
|
||||
let alpha = if ambient { 38 } else { 255 };
|
||||
let argb = (alpha << 24) | def.shown_color() as i32;
|
||||
("entity_effect", Some(ParticleData::Color(argb)))
|
||||
}
|
||||
};
|
||||
let Some(id) =
|
||||
self.registry_id("minecraft:particle_type", &format!("minecraft:{name}"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
particles.push(Particle { id, data });
|
||||
all_ambient &= ambient;
|
||||
}
|
||||
if particles.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut meta = vec![(10, serializer::PARTICLES, Value::Particles(particles))];
|
||||
if all_ambient {
|
||||
meta.push((11, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
meta
|
||||
self.sync.effect_meta(effects)
|
||||
}
|
||||
|
||||
/// The registries and tables the clients are synced with (read by helper threads).
|
||||
pub fn sync_data(&self) -> &SyncData {
|
||||
&self.sync
|
||||
}
|
||||
|
||||
fn player_state(
|
||||
|
||||
@@ -6,7 +6,6 @@ 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)]
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
//! fly and amphibious node evaluators (`path`, `nav`), move/look/jump/body
|
||||
//! controls (`control`), `LivingEntity` physics (`living`) and per-species
|
||||
//! behaviour, models, animations and sounds.
|
||||
mod mob_slot;
|
||||
mod pending_pushes;
|
||||
pub use mob_slot::MobSlot;
|
||||
pub use pending_pushes::{PendingPushes, apply_mob_pushes};
|
||||
pub mod ai;
|
||||
pub mod allay;
|
||||
pub mod ambient;
|
||||
@@ -2923,28 +2927,6 @@ pub fn take_mob_pushes(events: &mut Vec<MobEvent>, from: usize, pending: &mut Pe
|
||||
}
|
||||
events.truncate(kept);
|
||||
}
|
||||
/// Pushes waiting for their mob's tick, in push order. A list while short;
|
||||
/// past [`PendingPushes::LIST_MAX`] (a crowd pushing each other) one list
|
||||
/// per mob, as scanning the whole list for each mob grows with its square.
|
||||
#[derive(Default)]
|
||||
pub struct PendingPushes {
|
||||
list: Vec<(u32, Vec3)>,
|
||||
by_mob: crate::fast_hash::FxHashMap<u32, Vec<Vec3>>,
|
||||
}
|
||||
impl PendingPushes {
|
||||
const LIST_MAX: usize = 64;
|
||||
pub fn push(&mut self, id: u32, v: Vec3) {
|
||||
if self.by_mob.is_empty() && self.list.len() < Self::LIST_MAX {
|
||||
self.list.push((id, v));
|
||||
return;
|
||||
}
|
||||
// Older pushes first, so each mob's stay in order.
|
||||
for (id, v) in self.list.drain(..) {
|
||||
self.by_mob.entry(id).or_default().push(v);
|
||||
}
|
||||
self.by_mob.entry(id).or_default().push(v);
|
||||
}
|
||||
}
|
||||
/// `ServerLevel.sendBlockUpdated`: for each write that changed a block's collision
|
||||
/// shape, a mob whose path runs near it re-paths (`PathNavigation.shouldRecomputePath`).
|
||||
/// The writes whose shape changed are found once per tick ([`collision_changes`]).
|
||||
@@ -2972,50 +2954,28 @@ pub fn collision_changes(w: &MobWorld, writes: &[((i32, i32, i32), u32, u32)]) -
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Adds the pending pushes aimed at `m` to its velocity, in push order.
|
||||
pub fn apply_mob_pushes(m: &mut Mob, pending: &mut PendingPushes) {
|
||||
// Entity.push(x, y, z) sets needsSync.
|
||||
let mut add = |v: Vec3| {
|
||||
m.velocity = Vec3::new(m.velocity.x + v.x, m.velocity.y + v.y, m.velocity.z + v.z);
|
||||
m.needs_sync = true;
|
||||
};
|
||||
if !pending.list.is_empty() {
|
||||
pending.list.retain(|(id, v)| {
|
||||
if *id != m.id {
|
||||
return true;
|
||||
}
|
||||
add(*v);
|
||||
false
|
||||
});
|
||||
}
|
||||
if !pending.by_mob.is_empty()
|
||||
&& let Some(pushes) = pending.by_mob.remove(&m.id)
|
||||
{
|
||||
pushes.into_iter().for_each(add);
|
||||
}
|
||||
}
|
||||
/// Applies the brain memories a mob's tick set on other mobs (from event
|
||||
/// index `from` on), at once: vanilla sets them on the other entity during
|
||||
/// the tick, before the mobs after it tick.
|
||||
pub fn apply_brain_events(mobs: &mut [Mob], events: &mut Vec<MobEvent>, from: usize) {
|
||||
pub fn apply_brain_events<M: MobSlot>(mobs: &mut [M], events: &mut Vec<MobEvent>, from: usize) {
|
||||
let mut i = from;
|
||||
while i < events.len() {
|
||||
match events[i] {
|
||||
MobEvent::BreedTarget { mob, partner } => {
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == mob) {
|
||||
if let Some(m) = mobs.iter_mut().map(MobSlot::mob_mut).find(|m| m.id == mob) {
|
||||
animal_brain::set_breed_partner(m, partner);
|
||||
}
|
||||
events.remove(i);
|
||||
}
|
||||
MobEvent::LockGaze { mob, partner } => {
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == mob) {
|
||||
if let Some(m) = mobs.iter_mut().map(MobSlot::mob_mut).find(|m| m.id == mob) {
|
||||
animal_brain::lock_gaze(m, partner);
|
||||
villager_brain::lock_gaze_half(m, Who::Mob(partner));
|
||||
}
|
||||
events.remove(i);
|
||||
}
|
||||
MobEvent::Retreat { mob, from, at } => {
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == mob) {
|
||||
if let Some(m) = mobs.iter_mut().map(MobSlot::mob_mut).find(|m| m.id == mob) {
|
||||
piglin::receive_retreat(m, from, at);
|
||||
}
|
||||
events.remove(i);
|
||||
@@ -3029,7 +2989,11 @@ pub fn apply_brain_events(mobs: &mut [Mob], events: &mut Vec<MobEvent>, from: us
|
||||
events.remove(i);
|
||||
}
|
||||
MobEvent::SchoolSize { leader, by } => {
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == leader) {
|
||||
if let Some(m) = mobs
|
||||
.iter_mut()
|
||||
.map(MobSlot::mob_mut)
|
||||
.find(|m| m.id == leader)
|
||||
{
|
||||
m.extra.followers += by;
|
||||
}
|
||||
events.remove(i);
|
||||
@@ -3039,7 +3003,11 @@ pub fn apply_brain_events(mobs: &mut [Mob], events: &mut Vec<MobEvent>, from: us
|
||||
MobEvent::Breed {
|
||||
parent, partner, ..
|
||||
} => {
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == partner) {
|
||||
if let Some(m) = mobs
|
||||
.iter_mut()
|
||||
.map(MobSlot::mob_mut)
|
||||
.find(|m| m.id == partner)
|
||||
{
|
||||
m.extra.age = 6000;
|
||||
m.extra.in_love = 0;
|
||||
animal_brain::forget_breed_target(m, parent);
|
||||
@@ -3061,6 +3029,7 @@ pub fn apply_brain_events(mobs: &mut [Mob], events: &mut Vec<MobEvent>, from: us
|
||||
let mut raised = Vec::new();
|
||||
let (aim, fish_pos, fish_silent) = mobs
|
||||
.iter()
|
||||
.map(MobSlot::mob)
|
||||
.find(|m| m.id == mob)
|
||||
.map(|m| {
|
||||
(
|
||||
@@ -3075,7 +3044,10 @@ pub fn apply_brain_events(mobs: &mut [Mob], events: &mut Vec<MobEvent>, from: us
|
||||
})
|
||||
.map(|(aim, pos, silent)| (Some(aim), Some(pos), silent))
|
||||
.unwrap_or((None, None, true));
|
||||
if let Some(t) = mobs.iter_mut().find(|m| m.id == target)
|
||||
if let Some(t) = mobs
|
||||
.iter_mut()
|
||||
.map(MobSlot::mob_mut)
|
||||
.find(|m| m.id == target)
|
||||
&& t.damage_aimed(amount, source, aim)
|
||||
{
|
||||
t.add_effect("minecraft:poison", poison_ticks, 0);
|
||||
@@ -3290,6 +3262,9 @@ pub struct MobWorld<'a> {
|
||||
/// not in the level (a warden's anger, `AngerManagement.angerByUuid`).
|
||||
/// None: this world keeps no entity UUIDs, so nothing is resolved.
|
||||
pub who_lookup: Option<&'a dyn WhoLookup>,
|
||||
/// Another thread's mobs, for a lookup by id that misses `others`: a group of the server's
|
||||
/// parallel mob loop (`game/mob_islands.rs`) answers as the serial loop would.
|
||||
pub foreign: Option<&'a dyn ForeignSeen>,
|
||||
/// The level's world border, whose walls stop a mob's moves
|
||||
/// (`Entity.collectColliders`); None: nothing stops them there.
|
||||
pub border: Option<&'a horizon_core::geometry::border::BorderGeometry>,
|
||||
@@ -3304,6 +3279,10 @@ pub trait ModEffectTicks {
|
||||
/// names while it is in the level, never some other entity as a stand-in. Only
|
||||
/// the game layer keeps entity UUIDs, so it answers this (`Game::who_by_uuid`)
|
||||
/// and the mob layer reaches it through `MobWorld`.
|
||||
/// A mob outside this thread's part of the mob loop, as the serial loop would see it now.
|
||||
pub trait ForeignSeen {
|
||||
fn seen(&self, id: u32) -> Option<&Seen>;
|
||||
}
|
||||
pub trait WhoLookup {
|
||||
fn who_by_uuid(&self, uuid: [i32; 4]) -> Option<Who>;
|
||||
}
|
||||
|
||||
@@ -749,18 +749,35 @@ fn can_be_followed(s: Species, followers: i32) -> bool {
|
||||
}
|
||||
/// `startFollowing` on a recruit, applied after the recruiter's tick. A fish
|
||||
/// another leader took this tick (its snapshot was older) stays with it.
|
||||
pub fn start_following(mobs: &mut [Mob], fish: u32, leader: u32) {
|
||||
let alive = |mobs: &[Mob], id: u32| mobs.iter().any(|m| m.id == id && m.alive() && !m.removed);
|
||||
let Some(current) = mobs.iter().find(|m| m.id == fish).map(|m| m.extra.leader) else {
|
||||
pub fn start_following<M: super::MobSlot>(mobs: &mut [M], fish: u32, leader: u32) {
|
||||
let alive = |mobs: &[M], id: u32| {
|
||||
mobs.iter()
|
||||
.map(super::MobSlot::mob)
|
||||
.any(|m| m.id == id && m.alive() && !m.removed)
|
||||
};
|
||||
let Some(current) = mobs
|
||||
.iter()
|
||||
.map(super::MobSlot::mob)
|
||||
.find(|m| m.id == fish)
|
||||
.map(|m| m.extra.leader)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if current.is_some_and(|l| alive(mobs, l)) {
|
||||
return;
|
||||
}
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == fish) {
|
||||
if let Some(m) = mobs
|
||||
.iter_mut()
|
||||
.map(super::MobSlot::mob_mut)
|
||||
.find(|m| m.id == fish)
|
||||
{
|
||||
m.extra.leader = Some(leader);
|
||||
}
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == leader) {
|
||||
if let Some(m) = mobs
|
||||
.iter_mut()
|
||||
.map(super::MobSlot::mob_mut)
|
||||
.find(|m| m.id == leader)
|
||||
{
|
||||
m.extra.followers += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ pub struct Cx<'a, 'w> {
|
||||
pub lr: &'a mut LegacyRandom,
|
||||
pub events: &'a mut Vec<MobEvent>,
|
||||
}
|
||||
pub trait Goal: std::fmt::Debug {
|
||||
pub trait Goal: std::fmt::Debug + Send + Sync {
|
||||
fn flags(&self) -> u8;
|
||||
/// A goal that draws its first start delay in its constructor (a schooling fish's
|
||||
/// `FollowFlockLeaderGoal`): the delay again, when the mob's random is seeded afterwards.
|
||||
|
||||
@@ -174,6 +174,7 @@ fn a_mob_bounces_off_a_bouncy_block_and_not_off_stone() {
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
@@ -291,6 +292,7 @@ fn a_ridden_horse_floats_in_deep_water() {
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
@@ -411,6 +413,7 @@ fn a_falling_mob_lands_on_a_shulker_or_a_held_happy_ghast() {
|
||||
nearest_player: None,
|
||||
others: &others,
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
others_grid: None,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! [`MobSlot`]: a mob a list hands out.
|
||||
use super::Mob;
|
||||
|
||||
/// A mob a list hands out: the list's own, or a borrow of one in a larger list (a group of the
|
||||
/// server's parallel mob loop, `game/mob_islands.rs`, works on its mobs in place).
|
||||
pub trait MobSlot {
|
||||
fn mob(&self) -> &Mob;
|
||||
fn mob_mut(&mut self) -> &mut Mob;
|
||||
}
|
||||
impl MobSlot for Mob {
|
||||
#[inline]
|
||||
fn mob(&self) -> &Mob {
|
||||
self
|
||||
}
|
||||
#[inline]
|
||||
fn mob_mut(&mut self) -> &mut Mob {
|
||||
self
|
||||
}
|
||||
}
|
||||
impl MobSlot for &mut Mob {
|
||||
#[inline]
|
||||
fn mob(&self) -> &Mob {
|
||||
self
|
||||
}
|
||||
#[inline]
|
||||
fn mob_mut(&mut self) -> &mut Mob {
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! The pushes a mob's tick gives others, waiting for each one's own tick.
|
||||
use super::Mob;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// Pushes waiting for their mob's tick, in push order. A list while short;
|
||||
/// past [`PendingPushes::LIST_MAX`] (a crowd pushing each other) one list
|
||||
/// per mob, as scanning the whole list for each mob grows with its square.
|
||||
#[derive(Default)]
|
||||
pub struct PendingPushes {
|
||||
list: Vec<(u32, Vec3)>,
|
||||
by_mob: crate::fast_hash::FxHashMap<u32, Vec<Vec3>>,
|
||||
}
|
||||
impl PendingPushes {
|
||||
const LIST_MAX: usize = 64;
|
||||
/// `other`'s pushes after these, each mob's in their order.
|
||||
pub fn append(&mut self, other: PendingPushes) {
|
||||
for (id, v) in other.list {
|
||||
self.push(id, v);
|
||||
}
|
||||
for (id, list) in other.by_mob {
|
||||
for v in list {
|
||||
self.push(id, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn push(&mut self, id: u32, v: Vec3) {
|
||||
if self.by_mob.is_empty() && self.list.len() < Self::LIST_MAX {
|
||||
self.list.push((id, v));
|
||||
return;
|
||||
}
|
||||
// Older pushes first, so each mob's stay in order.
|
||||
for (id, v) in self.list.drain(..) {
|
||||
self.by_mob.entry(id).or_default().push(v);
|
||||
}
|
||||
self.by_mob.entry(id).or_default().push(v);
|
||||
}
|
||||
}
|
||||
/// Adds the pending pushes aimed at `m` to its velocity, in push order.
|
||||
pub fn apply_mob_pushes(m: &mut Mob, pending: &mut PendingPushes) {
|
||||
// Entity.push(x, y, z) sets needsSync.
|
||||
let mut add = |v: Vec3| {
|
||||
m.velocity = Vec3::new(m.velocity.x + v.x, m.velocity.y + v.y, m.velocity.z + v.z);
|
||||
m.needs_sync = true;
|
||||
};
|
||||
if !pending.list.is_empty() {
|
||||
pending.list.retain(|(id, v)| {
|
||||
if *id != m.id {
|
||||
return true;
|
||||
}
|
||||
add(*v);
|
||||
false
|
||||
});
|
||||
}
|
||||
if !pending.by_mob.is_empty()
|
||||
&& let Some(pushes) = pending.by_mob.remove(&m.id)
|
||||
{
|
||||
pushes.into_iter().for_each(add);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PendingPushes;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// Each mob's pushes, in the order they apply.
|
||||
fn per_mob(p: &PendingPushes) -> std::collections::BTreeMap<u32, Vec<[f64; 3]>> {
|
||||
let mut out = std::collections::BTreeMap::<u32, Vec<[f64; 3]>>::new();
|
||||
for (id, v) in &p.list {
|
||||
out.entry(*id).or_default().push([v.x, v.y, v.z]);
|
||||
}
|
||||
for (id, list) in &p.by_mob {
|
||||
out.entry(*id)
|
||||
.or_default()
|
||||
.extend(list.iter().map(|v| [v.x, v.y, v.z]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Two lists of pushes, the second appended to the first, apply as one list of them all in
|
||||
/// that order, on either side of the switch from one list to a list per mob.
|
||||
#[test]
|
||||
fn appended_pushes_apply_in_order() {
|
||||
let pushes: Vec<(u32, Vec3)> = (0..200u32)
|
||||
.map(|k| (k * 7 % 11, Vec3::new(f64::from(k), 0.0, -f64::from(k))))
|
||||
.collect();
|
||||
let mut all = PendingPushes::default();
|
||||
for &(id, v) in &pushes {
|
||||
all.push(id, v);
|
||||
}
|
||||
for split in [0, 30, 64, 70, 150, 200] {
|
||||
let (mut first, mut second) = (PendingPushes::default(), PendingPushes::default());
|
||||
for &(id, v) in &pushes[..split] {
|
||||
first.push(id, v);
|
||||
}
|
||||
for &(id, v) in &pushes[split..] {
|
||||
second.push(id, v);
|
||||
}
|
||||
first.append(second);
|
||||
assert_eq!(per_mob(&first), per_mob(&all), "split at {split}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,16 +186,23 @@ pub fn seat(attachment: [f64; 3], rider_offset: f64, at: Vec3, yaw: f32) -> Vec3
|
||||
|
||||
/// `Entity.startRiding`: the link it makes, `rider` on `vehicle` as its first
|
||||
/// passenger (`addPassenger`).
|
||||
pub fn link(mobs: &mut [Mob], rider: u32, vehicle: u32) {
|
||||
pub fn link<M: super::MobSlot>(mobs: &mut [M], rider: u32, vehicle: u32) {
|
||||
if rider == vehicle {
|
||||
return;
|
||||
}
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == rider) {
|
||||
if let Some(m) = mobs
|
||||
.iter_mut()
|
||||
.map(super::MobSlot::mob_mut)
|
||||
.find(|m| m.id == rider)
|
||||
{
|
||||
super::shulker::on_start_riding(m);
|
||||
m.extra.mount = Some(vehicle);
|
||||
m.extra.passenger = true;
|
||||
}
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == vehicle)
|
||||
if let Some(m) = mobs
|
||||
.iter_mut()
|
||||
.map(super::MobSlot::mob_mut)
|
||||
.find(|m| m.id == vehicle)
|
||||
&& m.extra.rider.is_none()
|
||||
{
|
||||
m.extra.rider = Some(rider);
|
||||
@@ -213,9 +220,10 @@ pub fn can_add_passenger(v: &Mob, aboard: usize) -> bool {
|
||||
}
|
||||
/// `Piglin.getTopPassenger`: down the chain of first passengers from
|
||||
/// `vehicle`, stopping at the `counter`th seat.
|
||||
fn top_passenger(mobs: &[Mob], vehicle: u32, counter: i32) -> u32 {
|
||||
fn top_passenger<M: super::MobSlot>(mobs: &[M], vehicle: u32, counter: i32) -> u32 {
|
||||
let first = mobs
|
||||
.iter()
|
||||
.map(super::MobSlot::mob)
|
||||
.find(|m| m.id == vehicle)
|
||||
.and_then(|m| m.extra.rider);
|
||||
match first {
|
||||
@@ -228,17 +236,22 @@ fn top_passenger(mobs: &[Mob], vehicle: u32, counter: i32) -> u32 {
|
||||
/// Not the vehicle it is on already, nor one it carries itself, and only
|
||||
/// after its boarding cooldown, onto a vehicle with no passenger
|
||||
/// (`canRide`, `canAddPassenger`). Returns whether it boarded.
|
||||
pub fn start_riding(mobs: &mut [Mob], rider: u32, vehicle: u32) -> bool {
|
||||
pub fn start_riding<M: super::MobSlot>(mobs: &mut [M], rider: u32, vehicle: u32) -> bool {
|
||||
// `Shulker.startRiding` puts its attach face back before the ride is
|
||||
// decided, so a shulker that is refused still ends up lid up.
|
||||
if let Some(m) = mobs.iter_mut().find(|m| m.id == rider) {
|
||||
if let Some(m) = mobs
|
||||
.iter_mut()
|
||||
.map(super::MobSlot::mob_mut)
|
||||
.find(|m| m.id == rider)
|
||||
{
|
||||
super::shulker::on_start_riding(m);
|
||||
}
|
||||
let Some(r) = mobs.iter().find(|m| m.id == rider) else {
|
||||
let Some(r) = mobs.iter().map(super::MobSlot::mob).find(|m| m.id == rider) else {
|
||||
return false;
|
||||
};
|
||||
let on_hoglin = mobs
|
||||
.iter()
|
||||
.map(super::MobSlot::mob)
|
||||
.any(|m| m.id == vehicle && m.species() == Species::Hoglin);
|
||||
let vehicle = if r.species() == Species::Piglin && r.is_baby() && on_hoglin {
|
||||
top_passenger(mobs, vehicle, MAX_PASSENGERS_ON_ONE_HOGLIN)
|
||||
@@ -252,17 +265,26 @@ pub fn start_riding(mobs: &mut [Mob], rider: u32, vehicle: u32) -> bool {
|
||||
// its vehicle.
|
||||
let mut up = Some(vehicle);
|
||||
while let Some(v) = up {
|
||||
up = mobs.iter().find(|m| m.id == v).and_then(|m| m.extra.mount);
|
||||
up = mobs
|
||||
.iter()
|
||||
.map(super::MobSlot::mob)
|
||||
.find(|m| m.id == v)
|
||||
.and_then(|m| m.extra.mount);
|
||||
if up == Some(rider) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let Some(v) = mobs.iter().find(|m| m.id == vehicle) else {
|
||||
let Some(v) = mobs
|
||||
.iter()
|
||||
.map(super::MobSlot::mob)
|
||||
.find(|m| m.id == vehicle)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let count = usize::from(v.rider.is_some())
|
||||
+ mobs
|
||||
.iter()
|
||||
.map(super::MobSlot::mob)
|
||||
.filter(|m| m.extra.mount == Some(vehicle))
|
||||
.count()
|
||||
.max(usize::from(v.extra.rider.is_some()));
|
||||
@@ -470,25 +492,26 @@ pub fn solve_body_rot(head: f32, vehicle_body: f32) -> f32 {
|
||||
/// Tick a passenger with Mob.getNavigation's recursive vehicle redirection.
|
||||
/// Navigation is lent, not copied: goal start/stop/path progress all address
|
||||
/// the same object the vehicle ticks. The pathfinder sees the vehicle body.
|
||||
pub fn tick(
|
||||
mobs: &mut [Mob],
|
||||
pub fn tick<M: super::MobSlot>(
|
||||
mobs: &mut [M],
|
||||
index: usize,
|
||||
w: &MobWorld,
|
||||
random: &mut horizon_core::random::LegacyRandom,
|
||||
events: &mut Vec<super::MobEvent>,
|
||||
) -> bool {
|
||||
let mut owner = index;
|
||||
if !mobs[index].no_ai {
|
||||
if !mobs[index].mob().no_ai {
|
||||
for _ in 0..mobs.len() {
|
||||
let Some(vehicle) = mobs[owner]
|
||||
.mob()
|
||||
.extra
|
||||
.mount
|
||||
.and_then(|id| mobs.iter().position(|m| m.id == id))
|
||||
.and_then(|id| mobs.iter().position(|m| m.mob().id == id))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
if vehicle == index
|
||||
|| controller(&mobs[vehicle], w).is_none_or(|r| r.id != mobs[owner].id)
|
||||
|| controller(mobs[vehicle].mob(), w).is_none_or(|r| r.id != mobs[owner].mob().id)
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -496,14 +519,14 @@ pub fn tick(
|
||||
}
|
||||
}
|
||||
if owner == index {
|
||||
return mobs[index].tick(w, random, events);
|
||||
return mobs[index].mob_mut().tick(w, random, events);
|
||||
}
|
||||
let (rider, vehicle) = if index < owner {
|
||||
let (left, right) = mobs.split_at_mut(owner);
|
||||
(&mut left[index], &mut right[0])
|
||||
(left[index].mob_mut(), right[0].mob_mut())
|
||||
} else {
|
||||
let (left, right) = mobs.split_at_mut(index);
|
||||
(&mut right[0], &mut left[owner])
|
||||
(right[0].mob_mut(), left[owner].mob_mut())
|
||||
};
|
||||
let body = vehicle.path_mob();
|
||||
std::mem::swap(&mut rider.ai.nav, &mut vehicle.ai.nav);
|
||||
|
||||
@@ -15,6 +15,9 @@ pub struct SeenGrid {
|
||||
/// The widest box (along x or z) the grid has held: no box reaches
|
||||
/// further than this past its minimum corner.
|
||||
reach: f64,
|
||||
/// The entries a query counts when it decides to scan them all instead (at least the grid's
|
||||
/// own): a grid of part of a list answers as the whole list's would.
|
||||
entries: usize,
|
||||
}
|
||||
|
||||
fn column(x: f64, z: f64) -> (i32, i32) {
|
||||
@@ -39,6 +42,22 @@ impl SeenGrid {
|
||||
}
|
||||
grid
|
||||
}
|
||||
/// The widest box and the entries it has, for a grid of part of its entries that must meet
|
||||
/// the same entries in the same order (the server's parallel mob tick).
|
||||
pub fn bounds(&self) -> (f64, usize) {
|
||||
(self.reach, self.cell_of.len().max(self.entries))
|
||||
}
|
||||
/// [`Self::bounds`] of the grid of `boxes`, without building it.
|
||||
pub fn bounds_of(boxes: impl Iterator<Item = Aabb>) -> (f64, usize) {
|
||||
boxes.fold((0.0_f64, 0), |(reach, n), b| (reach.max(width(b)), n + 1))
|
||||
}
|
||||
/// Answer queries as a grid of `bounds` would: as wide a margin, and a scan past as many
|
||||
/// entries.
|
||||
pub fn answering_as(mut self, (reach, entries): (f64, usize)) -> Self {
|
||||
self.reach = self.reach.max(reach);
|
||||
self.entries = entries;
|
||||
self
|
||||
}
|
||||
/// Entry `i`'s box is now `b` (the mob moved or resized).
|
||||
pub fn update(&mut self, i: usize, b: Aabb) {
|
||||
self.reach = self.reach.max(width(b));
|
||||
@@ -78,7 +97,7 @@ impl SeenGrid {
|
||||
let hi = column(max_x, max_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);
|
||||
let max_cells = (self.cell_of.len().max(self.entries) as i64).max(64);
|
||||
if nx < 1 || nz < 1 || nx.saturating_mul(nz) > max_cells {
|
||||
return false;
|
||||
}
|
||||
@@ -337,4 +356,46 @@ mod tests {
|
||||
rounds * n
|
||||
);
|
||||
}
|
||||
|
||||
/// A grid of part of a list's boxes that answers as the whole list's grid decides when to scan
|
||||
/// every entry and when to look in columns: the part meets its boxes as the whole would.
|
||||
#[test]
|
||||
fn a_part_answers_as_the_whole_list() {
|
||||
let mut seed = 0x9e37_79b9_7f4a_7c15u64;
|
||||
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
|
||||
};
|
||||
let boxes: Vec<Aabb> = (0..400)
|
||||
.map(|_| boxed(next(400.0) - 200.0, next(8.0), next(400.0) - 200.0, 0.6))
|
||||
.collect();
|
||||
let whole = SeenGrid::new(boxes.iter().copied());
|
||||
assert_eq!(SeenGrid::bounds_of(boxes.iter().copied()), whole.bounds());
|
||||
let part: Vec<usize> = (0..boxes.len()).step_by(4).collect();
|
||||
let grid = SeenGrid::new(part.iter().map(|&i| boxes[i])).answering_as(whole.bounds());
|
||||
let (mut a, mut b) = (Vec::new(), Vec::new());
|
||||
let mut scanned = 0;
|
||||
for _ in 0..500 {
|
||||
let side = next(100.0);
|
||||
let area = boxed(next(400.0) - 200.0, next(8.0), next(400.0) - 200.0, side);
|
||||
let indexed = whole.candidates(area, &mut a);
|
||||
assert_eq!(grid.candidates(area, &mut b), indexed, "{side} blocks");
|
||||
scanned += usize::from(!indexed);
|
||||
if indexed {
|
||||
let met = |out: &[u32]| -> Vec<usize> {
|
||||
out.iter()
|
||||
.map(|&j| part[j as usize])
|
||||
.filter(|&i| boxes[i].intersects(area))
|
||||
.collect()
|
||||
};
|
||||
let expected: Vec<usize> = a
|
||||
.iter()
|
||||
.map(|&j| j as usize)
|
||||
.filter(|i| i % 4 == 0 && boxes[*i].intersects(area))
|
||||
.collect();
|
||||
assert_eq!(met(&b), expected);
|
||||
}
|
||||
}
|
||||
assert!(scanned > 0 && scanned < 500, "{scanned} of 500 scanned");
|
||||
}
|
||||
}
|
||||
@@ -604,13 +604,14 @@ impl MobWorld<'_> {
|
||||
}
|
||||
}
|
||||
pub fn seen(&self, id: u32) -> Option<&Seen> {
|
||||
match self.others_index {
|
||||
let own = match self.others_index {
|
||||
Some(index) => index
|
||||
.get(&id)
|
||||
.and_then(|&i| self.others.get(i as usize))
|
||||
.filter(|s| s.id == id),
|
||||
None => self.others.iter().find(|s| s.id == id),
|
||||
}
|
||||
};
|
||||
own.or_else(|| self.foreign?.seen(id))
|
||||
}
|
||||
/// Living mobs of the given species within `range` of `from`, nearest first.
|
||||
pub fn nearby<'s>(
|
||||
@@ -773,6 +774,7 @@ pub(crate) fn with_resource_test_world(
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
|
||||
@@ -261,6 +261,7 @@ mod tests {
|
||||
dragon: None,
|
||||
leash_boats: &[],
|
||||
who_lookup: None,
|
||||
foreign: None,
|
||||
others_grid: None,
|
||||
others_index: None,
|
||||
others_villagers: None,
|
||||
|
||||
@@ -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,29 +80,21 @@ 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;
|
||||
@@ -110,20 +102,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,
|
||||
@@ -131,11 +123,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,
|
||||
)
|
||||
@@ -145,11 +137,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,
|
||||
);
|
||||
}
|
||||
@@ -165,22 +157,14 @@ impl NoResources {
|
||||
}
|
||||
}
|
||||
|
||||
fn title_size() -> f32 {
|
||||
ui_theme::sizes().title
|
||||
}
|
||||
const TITLE_SIZE: f32 = horizon_theme::TITLE.px;
|
||||
const TITLE_LINE: f32 = 40.0;
|
||||
fn body() -> f32 {
|
||||
ui_theme::sizes().body
|
||||
}
|
||||
const BODY: f32 = horizon_theme::BODY.px;
|
||||
const LINE: f32 = 24.0;
|
||||
const PARAGRAPH_GAP: f32 = 10.0;
|
||||
fn button_text() -> f32 {
|
||||
ui_theme::sizes().ui
|
||||
}
|
||||
const BUTTON_TEXT: f32 = horizon_theme::UI.px;
|
||||
const SMALL_LINE: f32 = 18.0;
|
||||
fn control() -> f32 {
|
||||
ui_theme::sizes().control_large
|
||||
}
|
||||
const CONTROL: f32 = horizon_theme::CONTROL_LARGE;
|
||||
|
||||
struct Layout {
|
||||
x: f32,
|
||||
@@ -200,7 +184,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +232,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]),
|
||||
@@ -271,7 +255,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:?}"
|
||||
);
|
||||
});
|
||||
@@ -282,10 +266,10 @@ mod tests {
|
||||
fn text_wraps_within_the_column_and_keeps_every_word() {
|
||||
with_ui([1280, 720], (0.0, 0.0), |ui| {
|
||||
for paragraph in PARAGRAPHS {
|
||||
let lines = wrap(ui, Face::Regular, body(), paragraph, 300.0);
|
||||
let lines = wrap(ui, Face::Regular, BODY, paragraph, 300.0);
|
||||
assert!(lines.len() > 1, "{paragraph}");
|
||||
for line in &lines {
|
||||
assert!(ui.measure(Face::Regular, body(), line) <= 300.0, "{line}");
|
||||
assert!(ui.measure(Face::Regular, BODY, line) <= 300.0, "{line}");
|
||||
}
|
||||
assert_eq!(lines.join(" "), paragraph);
|
||||
}
|
||||
|
||||
@@ -293,16 +293,16 @@ impl Keyboard {
|
||||
y0 - 12.0,
|
||||
width + 24.0,
|
||||
height,
|
||||
ui_theme::radius_plate(),
|
||||
color::with_alpha(color::bg(), 0.94),
|
||||
ui_theme::RADIUS_PLATE,
|
||||
color::with_alpha(color::BG, 0.94),
|
||||
);
|
||||
ui.border(
|
||||
x0 - 12.0,
|
||||
y0 - 12.0,
|
||||
width + 24.0,
|
||||
height,
|
||||
ui_theme::radius_plate(),
|
||||
color::border(),
|
||||
ui_theme::RADIUS_PLATE,
|
||||
color::BORDER,
|
||||
);
|
||||
for (r, row) in self.rows().iter().enumerate() {
|
||||
let y = y0 + r as f32 * (KEY_H + GAP);
|
||||
@@ -314,13 +314,13 @@ impl Keyboard {
|
||||
let lit = matches!(key, Key::Shift) && (self.shift || self.caps)
|
||||
|| matches!(key, Key::Layer) && self.symbols;
|
||||
let (plate, ink) = if focused {
|
||||
(color::accent(), color::on_accent())
|
||||
(color::ACCENT, color::ON_ACCENT)
|
||||
} else if lit {
|
||||
(color::hover(), color::text())
|
||||
(color::HOVER, color::TEXT)
|
||||
} else {
|
||||
(color::raised(), color::text())
|
||||
(color::RAISED, color::TEXT)
|
||||
};
|
||||
ui.rounded(x, y, w, KEY_H, ui_theme::radius(), plate);
|
||||
ui.rounded(x, y, w, KEY_H, ui_theme::RADIUS, plate);
|
||||
let label = match key {
|
||||
Key::Char(ch) => self.letter(*ch),
|
||||
Key::Shift if self.caps => "CAPS".into(),
|
||||
@@ -331,10 +331,10 @@ impl Keyboard {
|
||||
Key::Backspace => tr("horizon.osk.delete", "Delete"),
|
||||
Key::Done => tr("gui.done", "Done"),
|
||||
};
|
||||
let lw = ui.measure(Face::Medium, ui_theme::body(), &label);
|
||||
let lw = ui.measure(Face::Medium, ui_theme::BODY, &label);
|
||||
ui.text_line(
|
||||
Face::Medium,
|
||||
ui_theme::body(),
|
||||
ui_theme::BODY,
|
||||
KEY_H,
|
||||
x + ((w - lw) / 2.0).round(),
|
||||
y,
|
||||
@@ -359,10 +359,10 @@ impl Keyboard {
|
||||
.collect();
|
||||
ui.text_centered(
|
||||
Face::Regular,
|
||||
ui_theme::small(),
|
||||
ui_theme::SMALL,
|
||||
x0 + width / 2.0,
|
||||
y0 + 5.0 * (KEY_H + GAP) + 2.0,
|
||||
color::muted(),
|
||||
color::MUTED,
|
||||
&line.join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! The helper threads of the server tick's parallel phases (`HORIZON_PARALLEL_TICK`): one pool, made
|
||||
//! once, instead of threads started for every phase of every tick. A phase hands it work that gives
|
||||
//! the same result in whatever order the threads take it, and each part's results stay in order.
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
|
||||
/// `HORIZON_PARALLEL_TICK`: off when unset (0). `1` turns the server tick's parallel phases on (1),
|
||||
/// and `verify` does too (2), also checking the phases that can be checked against the serial code.
|
||||
/// Each phase gives the serial result either way.
|
||||
pub fn parallel_tick() -> u8 {
|
||||
static ON: OnceLock<u8> = OnceLock::new();
|
||||
*ON.get_or_init(|| match std::env::var("HORIZON_PARALLEL_TICK").as_deref() {
|
||||
Ok("1" | "on" | "true") => 1,
|
||||
Ok("verify") => 2,
|
||||
_ => 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// How many threads a phase may share its work among: `horizon-threads`' count (at most 8) while
|
||||
/// the parallel tick is on, else 1.
|
||||
pub fn lanes() -> usize {
|
||||
if parallel_tick() > 0 {
|
||||
horizon_threads::worker_count().clamp(1, 8)
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
/// The pool: one thread fewer than a phase's lanes (the server thread takes parts too).
|
||||
fn pool() -> &'static rayon::ThreadPool {
|
||||
static POOL: OnceLock<rayon::ThreadPool> = OnceLock::new();
|
||||
POOL.get_or_init(|| {
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(
|
||||
horizon_threads::worker_count()
|
||||
.clamp(1, 8)
|
||||
.saturating_sub(1)
|
||||
.max(1),
|
||||
)
|
||||
.thread_name(|i| format!("tick-helper-{i}"))
|
||||
.build()
|
||||
.expect("the tick's helper threads start")
|
||||
})
|
||||
}
|
||||
|
||||
/// `f` over `items` in parts on the pool, or all on this thread while the parallel tick is off or
|
||||
/// for fewer than `min` items: every part's results, in the order of `items`, as one thread would
|
||||
/// give them.
|
||||
pub fn par_map<T: Sync, R: Send>(
|
||||
items: &[T],
|
||||
min: usize,
|
||||
f: impl Fn(&[T], &mut Vec<R>) + Sync,
|
||||
) -> Vec<R> {
|
||||
par_map_on(items, min, lanes(), f)
|
||||
}
|
||||
|
||||
/// [`par_map`] on `lanes` threads.
|
||||
///
|
||||
/// The parts are small (four a thread) and go to whichever thread is free, this one too: a helper
|
||||
/// the machine is slow to run (the chunk workers compete for the same cores) holds the phase up by
|
||||
/// one part at most instead of an eighth of the work, and the server thread works instead of only
|
||||
/// waiting.
|
||||
fn par_map_on<T: Sync, R: Send>(
|
||||
items: &[T],
|
||||
min: usize,
|
||||
lanes: usize,
|
||||
f: impl Fn(&[T], &mut Vec<R>) + Sync,
|
||||
) -> Vec<R> {
|
||||
if lanes <= 1 || items.len() < min.max(2) {
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
f(items, &mut out);
|
||||
return out;
|
||||
}
|
||||
let size = items.len().div_ceil(lanes * 4);
|
||||
let count = items.len().div_ceil(size);
|
||||
let next = AtomicUsize::new(0);
|
||||
let slots: Vec<Mutex<Vec<R>>> = (0..count).map(|_| Mutex::new(Vec::new())).collect();
|
||||
let work = || {
|
||||
loop {
|
||||
let k = next.fetch_add(1, Ordering::Relaxed);
|
||||
if k >= count {
|
||||
break;
|
||||
}
|
||||
let part = &items[k * size..((k + 1) * size).min(items.len())];
|
||||
let mut done = Vec::with_capacity(part.len());
|
||||
f(part, &mut done);
|
||||
*slots[k].lock().unwrap_or_else(PoisonError::into_inner) = done;
|
||||
}
|
||||
};
|
||||
pool().in_place_scope(|s| {
|
||||
for _ in 1..lanes {
|
||||
s.spawn(|_| work());
|
||||
}
|
||||
work();
|
||||
});
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for slot in slots {
|
||||
out.extend(slot.into_inner().unwrap_or_else(PoisonError::into_inner));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// On several threads, every item's result comes back once, in the items' order, for item
|
||||
/// counts that don't divide into the parts evenly; a part may give several results or none.
|
||||
#[test]
|
||||
fn parts_come_back_in_order() {
|
||||
for n in [0usize, 1, 7, 33, 1000, 4097] {
|
||||
let items: Vec<u64> = (0..n as u64).collect();
|
||||
let f = |part: &[u64], out: &mut Vec<u64>| {
|
||||
for &x in part {
|
||||
// Spin a little so the threads overlap.
|
||||
let mut h = x;
|
||||
for _ in 0..200 {
|
||||
h = h.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
}
|
||||
if x % 3 != 0 {
|
||||
out.push(x * 10);
|
||||
}
|
||||
if x % 5 == 0 {
|
||||
out.push(h);
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut serial = Vec::new();
|
||||
f(&items, &mut serial);
|
||||
assert_eq!(par_map_on(&items, 1, 4, f), serial, "{n} items");
|
||||
}
|
||||
}
|
||||
|
||||
/// A panic in a part reaches the caller instead of being lost on a helper.
|
||||
#[test]
|
||||
fn a_panic_reaches_the_caller() {
|
||||
let items: Vec<u32> = (0..256).collect();
|
||||
let caught = std::panic::catch_unwind(|| {
|
||||
par_map_on(&items, 1, 4, |part, out: &mut Vec<u32>| {
|
||||
assert!(!part.contains(&200), "item 200");
|
||||
out.extend(part);
|
||||
})
|
||||
});
|
||||
assert!(caught.is_err());
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,44 @@ impl RandomTickSections {
|
||||
});
|
||||
Some((y, mask.clone()))
|
||||
}
|
||||
/// Builds, on the tick's helper threads (`crate::parallel`), the masks [`Self::next_masked`]
|
||||
/// will build at the first draw in each ticking section of the chunks `keys` names, from their
|
||||
/// blocks now (`chunk_of`), so the random tick loop finds them built. A mask only tells the loop
|
||||
/// which drawn blocks to read: the draws and the ticks are the same either way, and an edit
|
||||
/// before the loop reaches a section drops its mask as usual.
|
||||
pub fn prepare_masks(
|
||||
&mut self,
|
||||
keys: &[(i32, i32)],
|
||||
chunk_of: impl Fn((i32, i32)) -> Option<Arc<Chunk>>,
|
||||
facts: &BlockFacts,
|
||||
candidate: &(impl Fn(u32) -> bool + Sync),
|
||||
) {
|
||||
let mut todo: Vec<((i32, i32), usize, Arc<Chunk>)> = Vec::new();
|
||||
for &key in keys {
|
||||
let Some(chunk) = chunk_of(key) else {
|
||||
continue;
|
||||
};
|
||||
let cached = Self::refresh(&mut self.chunks, key, &chunk, &mut |id| {
|
||||
facts.get(BlockStateId(id)).random_ticks
|
||||
});
|
||||
for i in cached.sections.mask.iter() {
|
||||
if cached.masks.get(i).is_some_and(Option::is_none) {
|
||||
todo.push((key, i, Arc::clone(&chunk)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let masks = crate::parallel::par_map(&todo, 128, |part, out| {
|
||||
out.extend(
|
||||
(part.iter())
|
||||
.map(|(_, i, chunk)| Self::candidate_mask(&chunk.sections[*i], candidate)),
|
||||
);
|
||||
});
|
||||
for ((key, i, _), mask) in todo.into_iter().zip(masks) {
|
||||
if let Some(cached) = self.chunks.get_mut(&key) {
|
||||
cached.masks[i] = Some(Rc::new(mask));
|
||||
}
|
||||
}
|
||||
}
|
||||
fn candidate_mask(section: &Section, mut candidate: impl FnMut(u32) -> bool) -> RandomTickMask {
|
||||
let blocks = §ion.blocks;
|
||||
let mut ids = [0u16; 4096];
|
||||
@@ -481,25 +519,41 @@ mod tick_parity {
|
||||
time: 0,
|
||||
speed: 3,
|
||||
};
|
||||
// More than 128 ticking sections: prepare_masks must exercise its
|
||||
// helper path when HORIZON_PARALLEL_TICK is enabled, not just defer work.
|
||||
for x in 2..=65 {
|
||||
original.chunks.insert((x, 0), chunk(x));
|
||||
}
|
||||
Arc::make_mut(original.chunks.get_mut(&(1, 0)).unwrap()).lit = true; // precipitation-only chunk
|
||||
let mut cached = original.clone();
|
||||
// The masked path reads only the drawn blocks that can tick.
|
||||
let mut masked = original.clone();
|
||||
// The masked path again, its masks built ahead of each tick (`prepare_masks`).
|
||||
let mut prepared = original.clone();
|
||||
let mut prep_cache = RandomTickSections::default();
|
||||
let mut d = Ticks::new(12345);
|
||||
let mut cache = RandomTickSections::default();
|
||||
let mut mask_cache = RandomTickSections::default();
|
||||
let (mut a, mut b, mut c) = (Ticks::new(12345), Ticks::new(12345), Ticks::new(12345));
|
||||
for x in -1..=1 {
|
||||
for x in -1..=65 {
|
||||
a.add_empty_chunk(ChunkPos::new(x, 0)).unwrap();
|
||||
b.add_empty_chunk(ChunkPos::new(x, 0)).unwrap();
|
||||
c.add_empty_chunk(ChunkPos::new(x, 0)).unwrap();
|
||||
d.add_empty_chunk(ChunkPos::new(x, 0)).unwrap();
|
||||
}
|
||||
for step in 0..128 {
|
||||
for level in [&mut original, &mut cached, &mut masked] {
|
||||
for level in [&mut original, &mut cached, &mut masked, &mut prepared] {
|
||||
level.time = step;
|
||||
level.speed = [0, 1, 3, 9][step as usize % 4];
|
||||
level.reads.borrow_mut().clear();
|
||||
level.events.clear();
|
||||
match step {
|
||||
4 => {
|
||||
// The large fixture has exercised every tick speed and
|
||||
// helper-mask invalidation; keep the remaining edit,
|
||||
// unload and reload cases small as before.
|
||||
level.chunks.retain(|&(x, _), _| x <= 1);
|
||||
}
|
||||
12 => {
|
||||
level.chunks.remove(&(-1, 0));
|
||||
}
|
||||
@@ -528,15 +582,17 @@ mod tick_parity {
|
||||
if step == 12 {
|
||||
cache.remove(&(-1, 0));
|
||||
mask_cache.remove(&(-1, 0));
|
||||
prep_cache.remove(&(-1, 0));
|
||||
}
|
||||
if step == 50 || step == 60 {
|
||||
facts.states[grass.0 as usize].random_ticks = step == 60;
|
||||
cache.clear();
|
||||
mask_cache.clear();
|
||||
prep_cache.clear();
|
||||
}
|
||||
// A write between ticks.
|
||||
if step == 20 {
|
||||
for level in [&mut original, &mut cached, &mut masked] {
|
||||
for level in [&mut original, &mut cached, &mut masked, &mut prepared] {
|
||||
level.set_raw(BlockPos::new(0, MIN_Y + 16, 0), grass);
|
||||
}
|
||||
}
|
||||
@@ -589,6 +645,55 @@ mod tick_parity {
|
||||
};
|
||||
behaviours.tick_chunk_random_masked(&mut masked, &mut c, ChunkPos::new(x, z), next);
|
||||
}
|
||||
let keys = lit(&prepared);
|
||||
let candidate = |id| behaviours.random_tick_candidate(BlockStateId(id));
|
||||
prep_cache.prepare_masks(
|
||||
&keys,
|
||||
|key| prepared.chunks.get(&key).cloned(),
|
||||
&facts,
|
||||
&candidate,
|
||||
);
|
||||
for &(x, z) in &keys {
|
||||
// Every ticking section's mask is there before the tick asks.
|
||||
let now = prepared.chunks[&(x, z)].clone();
|
||||
let mut from = MIN_Y;
|
||||
while let Some((y, _)) = prep_cache.next_masked(
|
||||
(x, z),
|
||||
&now,
|
||||
&facts,
|
||||
|_| unreachable!("built ahead"),
|
||||
from,
|
||||
) {
|
||||
from = y + 16;
|
||||
}
|
||||
}
|
||||
for (x, z) in keys {
|
||||
let next = |level: &mut Level, from| {
|
||||
let now = level.chunks[&(x, z)].clone();
|
||||
prep_cache
|
||||
.next_masked((x, z), &now, &facts, candidate, from)
|
||||
.map(|(y, mask)| (y, Some(mask)))
|
||||
};
|
||||
behaviours.tick_chunk_random_masked(
|
||||
&mut prepared,
|
||||
&mut d,
|
||||
ChunkPos::new(x, z),
|
||||
next,
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
original.events, prepared.events,
|
||||
"prepared events at tick {step}"
|
||||
);
|
||||
assert_eq!(
|
||||
a.random_tick_value, d.random_tick_value,
|
||||
"prepared position RNG at tick {step}"
|
||||
);
|
||||
assert_eq!(
|
||||
original.random.clone().next_long(),
|
||||
prepared.random.next_long(),
|
||||
"prepared level RNG at tick {step}"
|
||||
);
|
||||
assert_eq!(original.reads, cached.reads, "ordered reads at tick {step}");
|
||||
assert_eq!(
|
||||
original.events, masked.events,
|
||||
@@ -629,6 +734,9 @@ mod tick_parity {
|
||||
for (s, t) in c.sections.iter().zip(&masked.chunks[key].sections) {
|
||||
assert_eq!(s.blocks, t.blocks);
|
||||
}
|
||||
for (s, t) in c.sections.iter().zip(&prepared.chunks[key].sections) {
|
||||
assert_eq!(s.blocks, t.blocks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,7 +408,7 @@ impl TimelineUi {
|
||||
let tx = start_x + sec * px_per_sec;
|
||||
if tx <= start_x + w {
|
||||
// Major tick line
|
||||
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::head_rule());
|
||||
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::HEAD_RULE);
|
||||
// Label every 2 or 5 seconds depending on width
|
||||
let is_labeled = if total_secs > 60.0 {
|
||||
(sec as u32).is_multiple_of(10)
|
||||
@@ -424,7 +424,7 @@ impl TimelineUi {
|
||||
"horizon.studio.timeline.seconds",
|
||||
&[Text::literal(seconds)],
|
||||
);
|
||||
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::soft(), true);
|
||||
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::SOFT, true);
|
||||
}
|
||||
}
|
||||
sec += sec_step;
|
||||
@@ -459,7 +459,7 @@ impl TimelineUi {
|
||||
// Base lane well
|
||||
let lane_rect = [start_x, y, track_w, bar_h];
|
||||
style::fill(batch, atlas, lane_rect, 0xFF14_171F);
|
||||
style::ring(batch, atlas, lane_rect, style::row_rule());
|
||||
style::ring(batch, atlas, lane_rect, style::ROW_RULE);
|
||||
|
||||
let total_dur = self.duration_ms.max(1) as f32;
|
||||
|
||||
@@ -514,7 +514,7 @@ impl TimelineUi {
|
||||
&scene_title,
|
||||
seg_x + 5.0,
|
||||
y + 5.0,
|
||||
style::muted(),
|
||||
style::MUTED,
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -577,7 +577,7 @@ impl TimelineUi {
|
||||
let px = start_x + norm * track_w;
|
||||
|
||||
// Scrubber line from ruler through bottom of tracks
|
||||
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::accent());
|
||||
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::ACCENT);
|
||||
|
||||
// Inverted triangle marker at ruler top
|
||||
let tri_color = style::WHITE;
|
||||
|
||||
@@ -178,7 +178,7 @@ impl TopBarUi {
|
||||
);
|
||||
}
|
||||
let color = if open {
|
||||
style::accent()
|
||||
style::ACCENT
|
||||
} else if hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
@@ -220,7 +220,7 @@ impl TopBarUi {
|
||||
if pause_hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
style::muted()
|
||||
style::MUTED
|
||||
},
|
||||
true,
|
||||
);
|
||||
@@ -339,11 +339,7 @@ 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() {
|
||||
@@ -354,7 +350,7 @@ impl TopBarUi {
|
||||
&shortcut,
|
||||
x + menu_width - shortcut_width - 8.0,
|
||||
y + 6.0,
|
||||
style::soft(),
|
||||
style::SOFT,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The design system's widgets drawn in the game: 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
|
||||
//! 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
|
||||
//! 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,76 +39,44 @@ pub(crate) fn set_preference(dir: &Path, key: &str, value: Option<&str>) {
|
||||
let _ = std::fs::write(dir.join("horizon.txt"), out);
|
||||
}
|
||||
|
||||
/// The overlays' palette (`menu_style`) in the HUD's colour encoding, plus the
|
||||
/// The dark palette (`horizon_theme::DARK`) in the HUD's colour encoding, plus the
|
||||
/// translucent plates the overlays draw over the game.
|
||||
pub(crate) mod color {
|
||||
use horizon_client_render::menu_style::{self, OverlayColours};
|
||||
pub use menu_style::hud_colour as hex;
|
||||
fn p() -> &'static OverlayColours {
|
||||
&menu_style::get().overlay
|
||||
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)
|
||||
}
|
||||
/// The page, under every veil.
|
||||
pub fn bg() -> u32 {
|
||||
hex(p().page, 255)
|
||||
}
|
||||
pub const 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 fn raised() -> u32 {
|
||||
hex(p().ink, 20)
|
||||
}
|
||||
pub const RAISED: u32 = hex(P.ink, 20);
|
||||
/// Pressed plates and a switch that is off (`plate`, the same way).
|
||||
pub fn hover() -> u32 {
|
||||
hex(p().ink, 36)
|
||||
}
|
||||
pub const HOVER: u32 = hex(P.ink, 36);
|
||||
/// One-pixel rules between rows, slider tracks.
|
||||
pub fn border() -> u32 {
|
||||
hex(p().rule, 255)
|
||||
}
|
||||
pub const BORDER: u32 = hex(P.rule, 255);
|
||||
/// Text and selection (`ink`).
|
||||
pub fn text() -> u32 {
|
||||
hex(p().ink, 255)
|
||||
}
|
||||
pub const TEXT: u32 = hex(P.ink, 255);
|
||||
/// Values and secondary text (`ink_soft`).
|
||||
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 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(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 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
|
||||
}
|
||||
/// 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;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum Face {
|
||||
@@ -119,34 +87,20 @@ 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 {
|
||||
/// 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 {
|
||||
fn load() -> Self {
|
||||
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("bundled font");
|
||||
Self {
|
||||
faces: [
|
||||
f(given.regular),
|
||||
f(given.medium),
|
||||
f(given.semibold),
|
||||
f(given.mono),
|
||||
f(horizon_theme::fonts::GEIST_REGULAR),
|
||||
f(horizon_theme::fonts::GEIST_MEDIUM),
|
||||
f(horizon_theme::fonts::GEIST_SEMIBOLD),
|
||||
f(horizon_theme::fonts::GEIST_MONO),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
fn face(&self, f: Face) -> &FontRef<'static> {
|
||||
&self.faces[f as usize]
|
||||
@@ -188,8 +142,7 @@ struct Cached {
|
||||
}
|
||||
/// Glyphs and corner masks rasterized into the atlas region `horizon/ui-text`.
|
||||
struct Cache {
|
||||
/// None: no fonts were given, and text draws nothing.
|
||||
fonts: Option<Fonts>,
|
||||
fonts: Fonts,
|
||||
entries: HashMap<Key, Cached>,
|
||||
shelf: (u32, u32, u32),
|
||||
pending: Vec<crate::font::Patch>,
|
||||
@@ -277,16 +230,9 @@ impl Cache {
|
||||
if let Some(c) = self.entries.get(&key) {
|
||||
return *c;
|
||||
}
|
||||
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 scale = self.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 fonts.face(face).outline_glyph(glyph) {
|
||||
let made = match self.fonts.face(face).outline_glyph(glyph) {
|
||||
Some(outline) => {
|
||||
let b = outline.px_bounds();
|
||||
let (w, h) = (b.width() as u32, b.height() as u32);
|
||||
@@ -513,17 +459,14 @@ impl<'a> Ui<'a> {
|
||||
let atlas = self.atlas;
|
||||
let gui = self.scale;
|
||||
with_cache(atlas, |c| {
|
||||
let Some(fonts) = &c.fonts else {
|
||||
return (0.0, 0.0, 0.0, Vec::new());
|
||||
};
|
||||
let scale = fonts.scale(face, px);
|
||||
let scale = c.fonts.scale(face, px);
|
||||
let (ascent, descent) = {
|
||||
let f = fonts.face(face).as_scaled(scale);
|
||||
let f = c.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() && fonts.face(face).glyph_id(ch).0 == 0)
|
||||
let pack = (!ch.is_whitespace() && c.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);
|
||||
@@ -548,10 +491,7 @@ impl<'a> Ui<'a> {
|
||||
continue;
|
||||
}
|
||||
let (id, advance, kern) = {
|
||||
let Some(fonts) = &c.fonts else {
|
||||
break;
|
||||
};
|
||||
let font = fonts.face(face);
|
||||
let font = c.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)))
|
||||
@@ -666,8 +606,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");
|
||||
@@ -689,7 +629,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;
|
||||
@@ -756,12 +696,7 @@ mod tests {
|
||||
let atlas = HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
reset_atlas();
|
||||
let draw = |cache: &mut Cache| {
|
||||
let glyph = cache
|
||||
.fonts
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.face(Face::Regular)
|
||||
.glyph_id('A');
|
||||
let glyph = cache.fonts.face(Face::Regular).glyph_id('A');
|
||||
assert!(
|
||||
cache
|
||||
.glyph(&atlas, Face::Regular, glyph, 16.0)
|
||||
@@ -847,7 +782,7 @@ mod tests {
|
||||
let lines = wrapping_lines(
|
||||
&ui,
|
||||
Face::Mono,
|
||||
micro(),
|
||||
MICRO,
|
||||
"the quick brown fox jumps over the lazy dog",
|
||||
90.0,
|
||||
);
|
||||
@@ -855,7 +790,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!(
|
||||
@@ -877,7 +812,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()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Vanilla's two seeded random streams. No platform/default Rust RNG is used.
|
||||
mod hash;
|
||||
mod ordered;
|
||||
mod positional;
|
||||
mod worldgen;
|
||||
pub use hash::{seed_from_hash, seed_from_hash_utf16};
|
||||
pub use ordered::{LaneDone, OrderedDraws};
|
||||
pub use positional::{
|
||||
LegacyPositionalRandom, PositionalRandom, XoroshiroPositionalRandom, java_string_hash,
|
||||
position_seed,
|
||||
@@ -104,10 +106,23 @@ pub trait RandomSource: Sized {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Debug)]
|
||||
pub struct LegacyRandom {
|
||||
state: u64,
|
||||
gaussian: Option<f64>,
|
||||
/// A lane of an [`OrderedDraws`] stream: each item's first draw waits for its turn.
|
||||
gate: Option<Box<ordered::Gate>>,
|
||||
}
|
||||
|
||||
/// A copy draws on its own, from the state it was copied at: it is never gated.
|
||||
impl Clone for LegacyRandom {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
state: self.state,
|
||||
gaussian: self.gaussian,
|
||||
gate: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LegacyRandom {
|
||||
@@ -118,6 +133,7 @@ impl LegacyRandom {
|
||||
Self {
|
||||
state: (seed as u64 ^ Self::MULTIPLIER) & Self::MASK,
|
||||
gaussian: None,
|
||||
gate: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +142,7 @@ impl LegacyRandom {
|
||||
Self {
|
||||
state,
|
||||
gaussian: None,
|
||||
gate: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +151,18 @@ impl LegacyRandom {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// A gated lane's first draw of an item waits for its turn and takes the shared state.
|
||||
#[inline]
|
||||
fn sync(&mut self) {
|
||||
if let Some(gate) = self.gate.as_deref_mut()
|
||||
&& !gate.held()
|
||||
{
|
||||
(self.state, self.gaussian) = gate.take();
|
||||
}
|
||||
}
|
||||
|
||||
fn bits(&mut self, count: u32) -> i32 {
|
||||
self.sync();
|
||||
self.state = self.state.wrapping_mul(Self::MULTIPLIER).wrapping_add(11) & Self::MASK;
|
||||
let value = (self.state >> (48 - count)) as i32;
|
||||
#[cfg(feature = "rng-trace")]
|
||||
@@ -149,7 +177,11 @@ impl RandomSource for LegacyRandom {
|
||||
LegacyPositionalRandom::new(self.next_long())
|
||||
}
|
||||
fn set_seed(&mut self, seed: i64) {
|
||||
*self = Self::new(seed);
|
||||
// LegacyRandomSource.setSeed resets the stream and its gaussian cache, not the lane.
|
||||
// Reseeding is an ordered mutation even when this item makes no draws afterwards.
|
||||
self.sync();
|
||||
self.state = (seed as u64 ^ Self::MULTIPLIER) & Self::MASK;
|
||||
self.gaussian = None;
|
||||
}
|
||||
|
||||
fn next_int(&mut self) -> i32 {
|
||||
@@ -190,6 +222,7 @@ impl RandomSource for LegacyRandom {
|
||||
value as f64 * (1.0 / 9_007_199_254_740_992.0)
|
||||
}
|
||||
fn next_gaussian(&mut self) -> f64 {
|
||||
self.sync();
|
||||
let mut cache = self.gaussian.take();
|
||||
let value = marsaglia_polar(&mut cache, || self.next_double());
|
||||
self.gaussian = cache;
|
||||
@@ -352,6 +385,24 @@ mod tests {
|
||||
invalid_bounds_do_not_consume_state(XoroshiroRandom::new(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_reseed_resets_the_gaussian_cache_and_stream() {
|
||||
let mut rng = LegacyRandom::new(42);
|
||||
for seed in [i64::MIN, -1, 0, 42, i64::MAX] {
|
||||
// Leave a cached second gaussian before reseeding.
|
||||
rng.next_gaussian();
|
||||
rng.set_seed(seed);
|
||||
let mut fresh = LegacyRandom::new(seed);
|
||||
for _ in 0..3 {
|
||||
assert_eq!(
|
||||
rng.next_gaussian().to_bits(),
|
||||
fresh.next_gaussian().to_bits()
|
||||
);
|
||||
assert_eq!(rng.next_long(), fresh.next_long());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_state_fallback_and_reseed() {
|
||||
let mut a = XoroshiroRandom::from_state(0, 0);
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
//! One [`LegacyRandom`] stream shared by several threads that each run their part of a list in list
|
||||
//! order (the server's parallel mob tick, `game/mob_islands.rs`): every draw comes out exactly as if
|
||||
//! the whole list had run on one thread.
|
||||
//!
|
||||
//! Each thread (a lane) runs a subset of the list's items, in ascending item order, and says which
|
||||
//! item it is on ([`LegacyRandom::begin_item`]). The first draw or reseed waits until every other
|
||||
//! lane has passed that item, then takes the stream's state; the item's end gives it back
|
||||
//! ([`LegacyRandom::end_item`]). So the items before it have made all their draws and the items after
|
||||
//! it none. The lane on the smallest item never waits, so the lanes can't deadlock, as long as every
|
||||
//! lane has its own thread. An item that never draws or reseeds never waits.
|
||||
use super::LegacyRandom;
|
||||
use std::sync::{
|
||||
Arc, Mutex, PoisonError,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
/// The shared stream and where each lane is.
|
||||
#[derive(Debug)]
|
||||
pub struct OrderedDraws {
|
||||
/// The stream's state between holders: the generator state and the gaussian cache.
|
||||
state: Mutex<(u64, Option<f64>)>,
|
||||
/// The item each lane is on; `usize::MAX` once it has finished.
|
||||
lanes: Box<[AtomicUsize]>,
|
||||
}
|
||||
|
||||
/// A lane's hold on the shared stream, inside its [`LegacyRandom`].
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Gate {
|
||||
draws: Arc<OrderedDraws>,
|
||||
lane: usize,
|
||||
item: usize,
|
||||
held: bool,
|
||||
}
|
||||
|
||||
impl OrderedDraws {
|
||||
/// `random`'s stream shared by `lanes` lanes, none of them started.
|
||||
pub fn new(random: &LegacyRandom, lanes: usize) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
state: Mutex::new((random.state, random.gaussian)),
|
||||
lanes: (0..lanes).map(|_| AtomicUsize::new(0)).collect(),
|
||||
})
|
||||
}
|
||||
/// The random lane `lane` draws from: its draws wait for their turn.
|
||||
pub fn lane(self: &Arc<Self>, lane: usize) -> LegacyRandom {
|
||||
let mut random = LegacyRandom::from_state(0);
|
||||
random.gate = Some(Box::new(Gate {
|
||||
draws: Arc::clone(self),
|
||||
lane,
|
||||
item: 0,
|
||||
held: false,
|
||||
}));
|
||||
random
|
||||
}
|
||||
/// The stream as the lanes left it, once they have all finished.
|
||||
pub fn finish(&self) -> LegacyRandom {
|
||||
let (state, gaussian) = *self.state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut random = LegacyRandom::from_state(state);
|
||||
random.gaussian = gaussian;
|
||||
random
|
||||
}
|
||||
/// Wait until lane `lane` has finished item `item` (it is on a later one, or done).
|
||||
pub fn wait_past(&self, lane: usize, item: usize) {
|
||||
let mut spins = 0u32;
|
||||
while self.lanes[lane].load(Ordering::Acquire) <= item {
|
||||
spins += 1;
|
||||
if spins < 64 {
|
||||
std::hint::spin_loop();
|
||||
} else {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn wait_for_turn(&self, lane: usize, item: usize) {
|
||||
let mut spins = 0u32;
|
||||
loop {
|
||||
let ready = self
|
||||
.lanes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(k, at)| k == lane || at.load(Ordering::Acquire) > item);
|
||||
if ready {
|
||||
return;
|
||||
}
|
||||
spins += 1;
|
||||
if spins < 64 {
|
||||
std::hint::spin_loop();
|
||||
} else {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Gate {
|
||||
/// The shared state for this item's first draw, after the items before it.
|
||||
pub(super) fn take(&mut self) -> (u64, Option<f64>) {
|
||||
self.draws.wait_for_turn(self.lane, self.item);
|
||||
self.held = true;
|
||||
*self
|
||||
.draws
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
pub(super) fn held(&self) -> bool {
|
||||
self.held
|
||||
}
|
||||
}
|
||||
|
||||
impl LegacyRandom {
|
||||
/// A gated random's lane starts item `item` (ascending within the lane). Nothing for an
|
||||
/// ungated one.
|
||||
pub fn begin_item(&mut self, item: usize) {
|
||||
if let Some(gate) = self.gate.as_deref_mut() {
|
||||
gate.item = item;
|
||||
gate.draws.lanes[gate.lane].store(item, Ordering::Release);
|
||||
}
|
||||
}
|
||||
/// The item's draws are done: the shared stream goes on from where it left it.
|
||||
pub fn end_item(&mut self) {
|
||||
if let Some(gate) = self.gate.as_deref_mut()
|
||||
&& gate.held
|
||||
{
|
||||
*gate
|
||||
.draws
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner) = (self.state, self.gaussian);
|
||||
gate.held = false;
|
||||
}
|
||||
}
|
||||
/// The lane has run all its items: no other lane waits for it any more.
|
||||
pub fn finish_lane(&mut self) {
|
||||
self.end_item();
|
||||
if let Some(gate) = self.gate.as_deref_mut() {
|
||||
gate.draws.lanes[gate.lane].store(usize::MAX, Ordering::Release);
|
||||
}
|
||||
}
|
||||
/// A gated random's [`LaneDone`], for its lane's thread to hold while it runs; None for an
|
||||
/// ungated one.
|
||||
pub fn lane_done(&self) -> Option<LaneDone> {
|
||||
self.gate.as_deref().map(|gate| LaneDone {
|
||||
draws: Arc::clone(&gate.draws),
|
||||
lane: gate.lane,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a lane finished when dropped, also while a panic unwinds its thread: the other lanes
|
||||
/// then run to their end instead of waiting for a lane that stopped, and the panic reaches the
|
||||
/// caller when it joins the threads.
|
||||
#[must_use = "the lane is marked finished when this is dropped"]
|
||||
pub struct LaneDone {
|
||||
draws: Arc<OrderedDraws>,
|
||||
lane: usize,
|
||||
}
|
||||
|
||||
impl Drop for LaneDone {
|
||||
fn drop(&mut self) {
|
||||
self.draws.lanes[self.lane].store(usize::MAX, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::random::RandomSource;
|
||||
|
||||
/// The timeout lives outside the scoped joins: a broken gate must fail, not hang the test suite.
|
||||
fn bounded<T: Send + 'static>(run: impl FnOnce() -> T + Send + 'static) -> T {
|
||||
let (sent, finished) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(run));
|
||||
let _ = sent.send(result);
|
||||
});
|
||||
match finished
|
||||
.recv_timeout(std::time::Duration::from_secs(20))
|
||||
.expect("ordered lanes must finish within the timeout")
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(panic) => std::panic::resume_unwind(panic),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reseeding_lanes_preserve_the_serial_stream() {
|
||||
let mut initial = LegacyRandom::new(42);
|
||||
initial.next_gaussian(); // A reseed before the first draw must also clear this cache.
|
||||
// Independent serial expectation: replace an ungated generator, without calling set_seed.
|
||||
let mut serial = LegacyRandom::new(11);
|
||||
let mut expected = vec![vec![serial.next_gaussian().to_bits()]];
|
||||
let before_reseed = serial.next_gaussian().to_bits();
|
||||
serial = LegacyRandom::new(-7);
|
||||
expected.push(vec![before_reseed, serial.next_gaussian().to_bits()]);
|
||||
serial = LegacyRandom::new(i64::MIN);
|
||||
expected.push(vec![]); // Reseed-only item must publish the state and clear the cache.
|
||||
expected.push(vec![serial.next_gaussian().to_bits()]);
|
||||
expected.push(vec![
|
||||
serial.next_gaussian().to_bits(),
|
||||
serial.next_int() as u64,
|
||||
]);
|
||||
let before_reseed = serial.next_int() as u64;
|
||||
serial = LegacyRandom::new(0);
|
||||
expected.push(vec![
|
||||
before_reseed,
|
||||
serial.next_long() as u64,
|
||||
serial.next_gaussian().to_bits(),
|
||||
]);
|
||||
|
||||
let (got, mut final_random) = bounded(move || {
|
||||
let shared = OrderedDraws::new(&initial, 3);
|
||||
let mut got: Vec<_> = std::thread::scope(|s| {
|
||||
let handles: Vec<_> = (0..3)
|
||||
.map(|lane| {
|
||||
let shared = Arc::clone(&shared);
|
||||
s.spawn(move || {
|
||||
let mut random = shared.lane(lane);
|
||||
let mut out = Vec::new();
|
||||
// Intentionally no LaneDone: finish_lane itself must survive reseeding.
|
||||
for item in (lane..6).step_by(3) {
|
||||
random.begin_item(item);
|
||||
let values = match item {
|
||||
0 => {
|
||||
random.set_seed(11);
|
||||
vec![random.next_gaussian().to_bits()]
|
||||
}
|
||||
1 => {
|
||||
let before = random.next_gaussian().to_bits();
|
||||
random.set_seed(-7);
|
||||
vec![before, random.next_gaussian().to_bits()]
|
||||
}
|
||||
2 => {
|
||||
random.set_seed(i64::MIN);
|
||||
vec![]
|
||||
}
|
||||
3 => vec![random.next_gaussian().to_bits()],
|
||||
4 => vec![
|
||||
random.next_gaussian().to_bits(),
|
||||
random.next_int() as u64,
|
||||
],
|
||||
5 => {
|
||||
let before = random.next_int() as u64;
|
||||
random.set_seed(0);
|
||||
vec![
|
||||
before,
|
||||
random.next_long() as u64,
|
||||
random.next_gaussian().to_bits(),
|
||||
]
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
random.end_item();
|
||||
out.push((item, values));
|
||||
}
|
||||
random.finish_lane();
|
||||
out
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles
|
||||
.into_iter()
|
||||
.flat_map(|h| h.join().unwrap())
|
||||
.collect()
|
||||
});
|
||||
got.sort_by_key(|(item, _)| *item);
|
||||
(
|
||||
got.into_iter().map(|(_, v)| v).collect::<Vec<_>>(),
|
||||
shared.finish(),
|
||||
)
|
||||
});
|
||||
assert_eq!(got, expected);
|
||||
assert_eq!(final_random.state(), serial.state());
|
||||
// finish() must keep the cached gaussian as well as the LCG state.
|
||||
assert_eq!(
|
||||
final_random.next_gaussian().to_bits(),
|
||||
serial.next_gaussian().to_bits()
|
||||
);
|
||||
assert_eq!(final_random.next_long(), serial.next_long());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reseeded_lane_keeps_its_panic_guard_and_published_stream() {
|
||||
bounded(|| {
|
||||
let shared = OrderedDraws::new(&LegacyRandom::new(42), 2);
|
||||
std::thread::scope(|s| {
|
||||
let panicking = Arc::clone(&shared);
|
||||
let handle = s.spawn(move || {
|
||||
let mut random = panicking.lane(0);
|
||||
random.begin_item(0);
|
||||
random.set_seed(7);
|
||||
let _done = random.lane_done().expect("reseed must keep the lane guard");
|
||||
random.next_gaussian();
|
||||
random.end_item();
|
||||
panic!("after publishing the reseeded item");
|
||||
});
|
||||
let mut random = shared.lane(1);
|
||||
random.begin_item(1);
|
||||
let mut serial = LegacyRandom::new(7);
|
||||
serial.next_gaussian();
|
||||
assert_eq!(
|
||||
random.next_gaussian().to_bits(),
|
||||
serial.next_gaussian().to_bits()
|
||||
);
|
||||
random.finish_lane();
|
||||
assert!(handle.join().is_err(), "the panic must reach the caller");
|
||||
assert_eq!(shared.finish().next_long(), serial.next_long());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Items 0..40 over three lanes, each drawing on some items: the same values as one thread.
|
||||
#[test]
|
||||
fn lanes_draw_in_list_order() {
|
||||
let draws_on = |item: usize| match item % 5 {
|
||||
0 => 0,
|
||||
1 => 1,
|
||||
2 => 3,
|
||||
_ => (item % 3) as i32,
|
||||
};
|
||||
let mut serial = LegacyRandom::new(42);
|
||||
let expected: Vec<Vec<i32>> = (0..40)
|
||||
.map(|i| {
|
||||
(0..draws_on(i))
|
||||
.map(|_| serial.next_int_bound(1000).unwrap())
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let shared = OrderedDraws::new(&LegacyRandom::new(42), 3);
|
||||
let got: Vec<(usize, Vec<i32>)> = std::thread::scope(|s| {
|
||||
let handles: Vec<_> = (0..3)
|
||||
.map(|lane| {
|
||||
let shared = Arc::clone(&shared);
|
||||
s.spawn(move || {
|
||||
let mut random = shared.lane(lane);
|
||||
let mut out = Vec::new();
|
||||
for item in (0..40).filter(|i| (i * 7 + 3) % 3 == lane) {
|
||||
random.begin_item(item);
|
||||
let values = (0..draws_on(item))
|
||||
.map(|_| random.next_int_bound(1000).unwrap())
|
||||
.collect();
|
||||
random.end_item();
|
||||
out.push((item, values));
|
||||
}
|
||||
random.finish_lane();
|
||||
out
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles
|
||||
.into_iter()
|
||||
.flat_map(|h| h.join().unwrap())
|
||||
.collect()
|
||||
});
|
||||
let mut got = got;
|
||||
got.sort_by_key(|(i, _)| *i);
|
||||
assert_eq!(
|
||||
got.into_iter().map(|(_, v)| v).collect::<Vec<_>>(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(shared.finish().state(), serial.state());
|
||||
}
|
||||
|
||||
/// A lane that panics part way, holding its [`LaneDone`], doesn't hold up a lane whose later
|
||||
/// items wait for it: that lane finishes, and the panic reaches the caller.
|
||||
#[test]
|
||||
fn a_panicking_lane_lets_the_others_finish() {
|
||||
let (sent, finished) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let shared = OrderedDraws::new(&LegacyRandom::new(7), 2);
|
||||
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
std::thread::scope(|s| {
|
||||
let panicking = Arc::clone(&shared);
|
||||
s.spawn(move || {
|
||||
let mut random = panicking.lane(0);
|
||||
let _done = random.lane_done();
|
||||
for item in [0, 2, 4] {
|
||||
random.begin_item(item);
|
||||
random.next_int_bound(10).unwrap();
|
||||
assert_ne!(item, 2, "item 2");
|
||||
random.end_item();
|
||||
}
|
||||
random.finish_lane();
|
||||
});
|
||||
let mut random = shared.lane(1);
|
||||
let _done = random.lane_done();
|
||||
for item in [1, 3, 5] {
|
||||
random.begin_item(item);
|
||||
random.next_int_bound(10).unwrap();
|
||||
random.end_item();
|
||||
}
|
||||
random.finish_lane();
|
||||
});
|
||||
}));
|
||||
let _ = sent.send(caught.is_err());
|
||||
});
|
||||
let panicked = finished
|
||||
.recv_timeout(std::time::Duration::from_secs(20))
|
||||
.expect("the lanes finished instead of waiting for the one that panicked");
|
||||
assert!(panicked, "the panic reached the caller");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -77,79 +77,12 @@ pub trait Hooks: Sync {
|
||||
/// Draws on a player model, after its vanilla layers, each time it is drawn.
|
||||
fn player_layers(&self, _model: &players::PlayerModel, _draw: &mut dyn players::PlayerDraw) {}
|
||||
|
||||
/// Draws around the window's own model while its body is out of view (first person), once a
|
||||
/// frame after the first-person model: [`players::Pass::FirstPerson`].
|
||||
fn first_person(&self, _own: &players::PlayerModel, _draw: &mut dyn players::PlayerDraw) {}
|
||||
|
||||
/// The resolver of `friend:` addresses this crate offers ([`tunnel`]); the first hook that
|
||||
/// offers one is used. Asked on the network thread when joining and on the game thread when
|
||||
/// an address is shown, never per frame.
|
||||
fn tunnel(&self) -> Option<&dyn tunnel::Resolver> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A game window opened: once, before the first frame, for every run that has a window,
|
||||
/// automated ones included ([`Client::automated`]); never for a dedicated server.
|
||||
fn client_started(&self, _client: &Client) {}
|
||||
|
||||
/// What the player is doing, for a status shown outside the game (Discord's): called when it
|
||||
/// changes, not every frame. Splitscreen: the first player's only.
|
||||
fn activity(&self, _now: Activity) {}
|
||||
}
|
||||
|
||||
/// What [`Hooks::client_started`] gets.
|
||||
#[non_exhaustive]
|
||||
pub struct Client {
|
||||
/// A run nobody plays: offscreen, a benchmark, a run that quits by itself after some frames.
|
||||
pub automated: bool,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new(automated: bool) -> Self {
|
||||
Self { automated }
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player is doing ([`Hooks::activity`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Activity {
|
||||
/// On the title screen or another menu outside a world.
|
||||
Menus,
|
||||
World {
|
||||
play: Play,
|
||||
dimension: Dimension,
|
||||
mode: GameMode,
|
||||
},
|
||||
}
|
||||
|
||||
/// How the player is in a world.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Play {
|
||||
Singleplayer,
|
||||
/// A server or someone else's LAN world.
|
||||
Multiplayer,
|
||||
/// Their world is open to LAN or friends; `players` counts the host, `max` is how many may
|
||||
/// join.
|
||||
Hosting {
|
||||
players: usize,
|
||||
max: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Dimension {
|
||||
Overworld,
|
||||
Nether,
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GameMode {
|
||||
Survival,
|
||||
Creative,
|
||||
Adventure,
|
||||
Spectator,
|
||||
}
|
||||
|
||||
/// What [`Hooks::started`] gets.
|
||||
@@ -245,32 +178,6 @@ pub mod engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::client_started`].
|
||||
pub fn client_started(client: &Client) {
|
||||
for hooks in all() {
|
||||
hooks.client_started(client);
|
||||
}
|
||||
}
|
||||
|
||||
/// The activity at the last call (`None` before the first).
|
||||
static ACTIVITY: std::sync::Mutex<Option<Activity>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// [`Hooks::activity`], when `now` differs from the last call's. Cheap enough to call every
|
||||
/// frame: with no hook registered it only loads the empty list.
|
||||
pub fn activity(now: Activity) {
|
||||
let hooks = all();
|
||||
if hooks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut last = ACTIVITY.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.replace(now) != Some(now) {
|
||||
drop(last);
|
||||
for h in hooks {
|
||||
h.activity(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::shutdown`].
|
||||
pub fn shutdown() {
|
||||
for hooks in all() {
|
||||
@@ -310,13 +217,6 @@ pub mod engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::first_person`].
|
||||
pub fn first_person(own: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
|
||||
for hooks in all() {
|
||||
hooks.first_person(own, draw);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::camera`].
|
||||
pub fn camera(own: &players::CameraView) -> Option<players::CameraOverride> {
|
||||
all().iter().find_map(|h| h.camera(own))
|
||||
@@ -355,9 +255,6 @@ mod tests {
|
||||
fn published(&self, open: bool) {
|
||||
self.0.lock().unwrap().push(format!("published {open}"));
|
||||
}
|
||||
fn activity(&self, now: Activity) {
|
||||
self.0.lock().unwrap().push(format!("{now:?}"));
|
||||
}
|
||||
fn frame(&self, frame: &players::Frame, sprites: &mut dyn players::SpriteStore) {
|
||||
self.0
|
||||
.lock()
|
||||
@@ -388,14 +285,7 @@ mod tests {
|
||||
return;
|
||||
};
|
||||
let turn = draw.ticks() / 100.0;
|
||||
let look = players::Look::new(uv);
|
||||
draw.draw(&HAT, head, look, &mut |p| p.pose("brim")[4] = turn);
|
||||
}
|
||||
/// A spark at the eyes, as bright as the head turns.
|
||||
fn first_person(&self, _: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
|
||||
let alpha = draw.motion().head_yaw / 90.0;
|
||||
let spark = players::Particle::new([[0.0; 3]; 4], [[0.0; 2]; 4], alpha);
|
||||
draw.particles([0.0; 4], &[spark], players::Light::Full);
|
||||
draw.draw(&HAT, head, uv, &mut |p| p.pose("brim")[4] = turn);
|
||||
}
|
||||
fn tick(&self, models: &mut dyn players::PlayerModels) {
|
||||
let mut ids = Vec::new();
|
||||
@@ -472,10 +362,9 @@ mod tests {
|
||||
))
|
||||
});
|
||||
|
||||
/// A player drawn at age 50, its head where the camera is, turned 45 degrees; logs each draw
|
||||
/// and its particles' alphas.
|
||||
/// A player drawn at age 50, its head where the camera is; logs each draw.
|
||||
#[derive(Default)]
|
||||
struct Canvas(Vec<(String, [[f32; 4]; 4], [f32; 4], f32)>, Vec<f32>);
|
||||
struct Canvas(Vec<(String, [[f32; 4]; 4], [f32; 4], f32)>);
|
||||
impl players::PlayerDraw for Canvas {
|
||||
fn pass(&self) -> players::Pass {
|
||||
players::Pass::World
|
||||
@@ -483,15 +372,6 @@ mod tests {
|
||||
fn ticks(&self) -> f32 {
|
||||
50.0
|
||||
}
|
||||
fn base(&self) -> [[f32; 4]; 4] {
|
||||
[[1.0, 0.0, 0.0, 0.0]; 4]
|
||||
}
|
||||
fn motion(&self) -> players::Motion {
|
||||
players::Motion::new([0.0; 3], 45.0, 0.0)
|
||||
}
|
||||
fn view(&self) -> players::View {
|
||||
players::View::default()
|
||||
}
|
||||
fn bone(&mut self, bone: players::Bone) -> Option<[[f32; 4]; 4]> {
|
||||
(bone == players::Bone::Head).then_some([[1.0, 0.0, 0.0, 0.0]; 4])
|
||||
}
|
||||
@@ -505,7 +385,7 @@ mod tests {
|
||||
&mut self,
|
||||
mesh: &std::sync::Arc<players::Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
look: players::Look,
|
||||
uv: [f32; 4],
|
||||
pose: &mut dyn FnMut(&mut dyn players::PartPoses),
|
||||
) {
|
||||
struct Rest([f32; 9]);
|
||||
@@ -516,10 +396,7 @@ mod tests {
|
||||
}
|
||||
let mut brim = Rest(mesh.children[0].pose);
|
||||
pose(&mut brim);
|
||||
self.0.push((mesh.name.clone(), matrix, look.uv, brim.0[4]));
|
||||
}
|
||||
fn particles(&mut self, _: [f32; 4], particles: &[players::Particle], _: players::Light) {
|
||||
self.1.extend(particles.iter().map(|p| p.alpha));
|
||||
self.0.push((mesh.name.clone(), matrix, uv, brim.0[4]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,14 +415,6 @@ mod tests {
|
||||
for open in [false, true, true, true, false, false] {
|
||||
engine::published(open);
|
||||
}
|
||||
let world = Activity::World {
|
||||
play: Play::Singleplayer,
|
||||
dimension: Dimension::Nether,
|
||||
mode: GameMode::Creative,
|
||||
};
|
||||
for now in [Activity::Menus, Activity::Menus, world, world] {
|
||||
engine::activity(now);
|
||||
}
|
||||
engine::shutdown();
|
||||
assert!(engine::any());
|
||||
let mut atlas = Atlas::default();
|
||||
@@ -576,8 +445,6 @@ mod tests {
|
||||
0.5
|
||||
)]
|
||||
);
|
||||
engine::first_person(&model(players::Kind::Own), &mut canvas);
|
||||
assert_eq!(canvas.1, [0.5]);
|
||||
let mut models = Two(Default::default());
|
||||
engine::tick(&mut models);
|
||||
assert!(models.body_pose(3).is_some() && models.body_pose(7).is_some());
|
||||
@@ -587,8 +454,6 @@ mod tests {
|
||||
"started 0.2.31",
|
||||
"published true",
|
||||
"published false",
|
||||
"Menus",
|
||||
"World { play: Singleplayer, dimension: Nether, mode: Creative }",
|
||||
"frame 4"
|
||||
]
|
||||
);
|
||||
|
||||
@@ -274,149 +274,8 @@ impl Bone {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Pass {
|
||||
/// In the world, seen by the camera (the window's own model too, in third person).
|
||||
/// In the world, seen by the camera.
|
||||
World,
|
||||
/// The inventory's picture of the window's own model.
|
||||
Picture,
|
||||
/// The window's own model while its body is out of view (first person): no bone, only what
|
||||
/// floats around it ([`crate::Hooks::first_person`]).
|
||||
FirstPerson,
|
||||
}
|
||||
|
||||
/// How a player model moves this frame ([`PlayerDraw::motion`]), with the frame's part of a tick.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct Motion {
|
||||
/// Where its feet are, in the world.
|
||||
pub feet: [f64; 3],
|
||||
/// Where its head turns (degrees, the game's yaw and pitch).
|
||||
pub head_yaw: f32,
|
||||
pub head_pitch: f32,
|
||||
pub crouching: bool,
|
||||
/// Lies in a bed.
|
||||
pub asleep: bool,
|
||||
/// Flies with an elytra.
|
||||
pub flying: bool,
|
||||
/// Swims (any part of the swimming pose).
|
||||
pub swimming: bool,
|
||||
}
|
||||
|
||||
impl Motion {
|
||||
pub fn new(feet: [f64; 3], head_yaw: f32, head_pitch: f32) -> Self {
|
||||
Self {
|
||||
feet,
|
||||
head_yaw,
|
||||
head_pitch,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// With its poses: crouching, asleep, flying, swimming.
|
||||
pub fn poses(self, crouching: bool, asleep: bool, flying: bool, swimming: bool) -> Self {
|
||||
Self {
|
||||
crouching,
|
||||
asleep,
|
||||
flying,
|
||||
swimming,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera a player model is drawn for ([`PlayerDraw::view`]).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
#[non_exhaustive]
|
||||
pub struct View {
|
||||
/// Where it is, in the world: matrices and [`Particle`] corners are relative to it.
|
||||
pub camera: [f64; 3],
|
||||
/// Its right and up, for what faces it.
|
||||
pub right: [f32; 3],
|
||||
pub up: [f32; 3],
|
||||
/// Where the window's player looks, and their level left (first person).
|
||||
pub forward: [f32; 3],
|
||||
pub left: [f32; 3],
|
||||
}
|
||||
|
||||
impl View {
|
||||
pub fn new(camera: [f64; 3], right: [f32; 3], up: [f32; 3]) -> Self {
|
||||
Self {
|
||||
camera,
|
||||
right,
|
||||
up,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// With where the window's player looks and their level left.
|
||||
pub fn looking(self, forward: [f32; 3], left: [f32; 3]) -> Self {
|
||||
Self {
|
||||
forward,
|
||||
left,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a mesh or particles are lit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Light {
|
||||
/// As the player model is.
|
||||
Model,
|
||||
/// Fully lit: it glows.
|
||||
Full,
|
||||
/// By the world where the mesh's origin is (as particles: like [`Light::Model`]).
|
||||
AtPlace,
|
||||
}
|
||||
|
||||
/// How [`PlayerDraw::draw`] draws a mesh.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Look {
|
||||
/// The sprite part it is textured by (u0, v0, u1, v1).
|
||||
pub uv: [f32; 4],
|
||||
pub light: Light,
|
||||
/// Drawn see-through at this alpha, after every player, its back faces dropped.
|
||||
pub see_through: Option<f32>,
|
||||
}
|
||||
|
||||
impl Look {
|
||||
/// Opaque, lit as the model.
|
||||
pub fn new(uv: [f32; 4]) -> Self {
|
||||
Self {
|
||||
uv,
|
||||
light: Light::Model,
|
||||
see_through: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lit(self, light: Light) -> Self {
|
||||
Self { light, ..self }
|
||||
}
|
||||
|
||||
pub fn see_through(self, alpha: f32) -> Self {
|
||||
Self {
|
||||
see_through: Some(alpha),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A quad that faces the camera ([`PlayerDraw::particles`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Particle {
|
||||
/// Its corners, relative to the camera.
|
||||
pub corners: [[f32; 3]; 4],
|
||||
/// Each corner's place across the sprite part (0 to 1).
|
||||
pub uv: [[f32; 2]; 4],
|
||||
pub alpha: f32,
|
||||
}
|
||||
|
||||
impl Particle {
|
||||
pub fn new(corners: [[f32; 3]; 4], uv: [[f32; 2]; 4], alpha: f32) -> Self {
|
||||
Self { corners, uv, alpha }
|
||||
}
|
||||
}
|
||||
|
||||
/// The poses of a [`Mesh`]'s parts for one [`PlayerDraw::draw`], each at rest to begin with.
|
||||
@@ -432,25 +291,19 @@ pub trait PlayerDraw {
|
||||
fn pass(&self) -> Pass;
|
||||
/// The model's age in ticks, with the frame's part of a tick.
|
||||
fn ticks(&self) -> f32;
|
||||
/// The model's own matrix, its origin at the neck (y down, 16 units a block).
|
||||
fn base(&self) -> [[f32; 4]; 4];
|
||||
fn motion(&self) -> Motion;
|
||||
fn view(&self) -> View;
|
||||
/// Where `bone` is as the body is drawn this frame; None if the model has no such part (or
|
||||
/// no body is drawn: [`Pass::FirstPerson`]).
|
||||
/// Where `bone` is as the body is drawn this frame; None if the model has no such part.
|
||||
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]>;
|
||||
/// The pose laid over the model's body ([`PlayerModels::set_body_pose`]), if any.
|
||||
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>>;
|
||||
/// As [`Sprites::get`].
|
||||
fn sprite(&self, id: u64) -> Option<[f32; 4]>;
|
||||
/// Draws `mesh` at `matrix` as `look` says; `pose` poses its parts first.
|
||||
/// Draws `mesh` at `matrix`, textured by the sprite part `uv` (u0, v0, u1, v1) and lit as the
|
||||
/// model is; `pose` poses its parts first.
|
||||
fn draw(
|
||||
&mut self,
|
||||
mesh: &Arc<Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
look: Look,
|
||||
uv: [f32; 4],
|
||||
pose: &mut dyn FnMut(&mut dyn PartPoses),
|
||||
);
|
||||
/// Draws `particles`, textured by the sprite part `uv`, see-through after every player.
|
||||
fn particles(&mut self, uv: [f32; 4], particles: &[Particle], light: Light);
|
||||
}
|
||||
@@ -16,7 +16,6 @@ horizon-platform = { path = "../horizon-platform" }
|
||||
horizon-heartbeat = { path = "../horizon-heartbeat" }
|
||||
horizon-friends = { path = "../horizon-friends", features = ["game"] }
|
||||
horizon-official-servers = { path = "../horizon-official-servers" }
|
||||
horizon-discord = { path = "../horizon-discord" }
|
||||
log = "0.4"
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -4,36 +4,27 @@
|
||||
//! (`game-build.yml`); horizon-client keeps its own plain executable.
|
||||
//!
|
||||
//! Each Horizon crate that runs in the game gives a `&'static` value implementing
|
||||
//! `horizon_platform::hooks::Hooks`, and its settings store and pages if it has any, and
|
||||
//! [`register`] adds them. Nothing here depends on an engine crate.
|
||||
//! `horizon_platform::hooks::Hooks`, and [`register`] adds it. Nothing here depends on an engine
|
||||
//! crate.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use horizon_platform::hooks::{self, Hooks};
|
||||
use horizon_platform::{pages, settings};
|
||||
|
||||
/// Every Horizon part in the game, in the order its hooks run.
|
||||
const PARTS: [&dyn Hooks; 4] = [
|
||||
const PARTS: [&dyn Hooks; 3] = [
|
||||
// The anonymous "I'm playing" ping.
|
||||
&horizon_heartbeat::HOOKS,
|
||||
// Play with Friends: `friend:` addresses join through its tunnel.
|
||||
&horizon_friends::game::HOOKS,
|
||||
// The official server in the player's server list.
|
||||
&horizon_official_servers::HOOKS,
|
||||
// "Playing Horizon" on the player's Discord profile.
|
||||
&horizon_discord::HOOKS,
|
||||
];
|
||||
/// Their settings, in the player's settings files.
|
||||
const STORES: [&dyn settings::Store; 1] = [&horizon_discord::SETTING];
|
||||
/// Their rows and pages in the options screens.
|
||||
const PAGES: [&dyn pages::Page; 1] = [&horizon_discord::SECTION];
|
||||
|
||||
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
|
||||
/// game, and so does the launcher's generated executable for native mods.
|
||||
pub fn register() {
|
||||
let late = PARTS.into_iter().map(hooks::register);
|
||||
let late = late.chain(STORES.into_iter().map(settings::register));
|
||||
for result in late.chain(PAGES.into_iter().map(pages::register)) {
|
||||
if let Err(e) = result {
|
||||
for part in PARTS {
|
||||
if let Err(e) = hooks::register(part) {
|
||||
// The executable registered after starting the game: a bug, not a player's problem.
|
||||
log::warn!("Horizon: an in-game part did not register: {e}");
|
||||
}
|
||||
|
||||
@@ -50,14 +50,11 @@ pub mod hooks {
|
||||
/// [`Hooks::tick`] get, the pose a crate lays over a player's body, what goes by their names,
|
||||
/// the crate's sprites, the camera, and what a crate draws on them.
|
||||
pub use horizon_hooks::players::{
|
||||
BODY_PARTS, Badge, BodyPose, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Light,
|
||||
Look, Mark, Mesh, Motion, NameDecoration, PartPoses, Particle, Pass, PlayerDraw,
|
||||
PlayerModel, PlayerModels, Quad, SpriteStore, Sprites, View,
|
||||
};
|
||||
pub use horizon_hooks::{
|
||||
Activity, Client, Dimension, GameMode, Hooks, Play, Started, TooLate, register, seat,
|
||||
tunnel,
|
||||
BODY_PARTS, Badge, BodyPose, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Mark,
|
||||
Mesh, NameDecoration, PartPoses, Pass, PlayerDraw, PlayerModel, PlayerModels, Quad,
|
||||
SpriteStore, Sprites,
|
||||
};
|
||||
pub use horizon_hooks::{Hooks, Started, TooLate, register, seat, tunnel};
|
||||
}
|
||||
|
||||
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
|
||||
|
||||
+3
-3
@@ -23,14 +23,14 @@ What changes in that build:
|
||||
|---|---|---|
|
||||
| Cosmetics, emotes and the emote wheel, "Show me as a Horizon player" | `game/cosmetics.rs` (+ `cosmetics/fx.rs`, `cosmetics/emotes.rs`, `cosmetics/emote_wheel.rs`), crate `horizon-cosmetics` | `game::cosmetics::enabled()`: the service is never asked, nothing is drawn, B opens no wheel |
|
||||
| The official server added to the server list, the withdrawn partner server taken out | crate `horizon-official-servers` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
|
||||
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
|
||||
| Discord Rich Presence and its switch | crate `horizon-discord` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
|
||||
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent, Discord) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
|
||||
| Discord Rich Presence | `discord_presence.rs`, `game/discord.rs` | the `discord` feature, which `horizon-extras` turns on |
|
||||
| Play with Friends | hosting: `game/friends.rs`, `game/friends/screen.rs`; joining: crate `horizon-friends` (`game`) | hosting: the `friends` feature (`game/friends_off.rs` stands in); joining: only the official executable registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
|
||||
| The anonymous "I'm playing" ping | crate `horizon-heartbeat` | not in horizon-client at all: only the official executable (`horizon-official`) registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
|
||||
|
||||
Credits & Attribution then says where the official build is (`horizon.plain.official`).
|
||||
|
||||
`grep -rn horizon-extras crates/` lists every gate. Apart from the `friends` feature above, the gates
|
||||
`grep -rn horizon-extras crates/` lists every gate. Apart from the three features above, the gates
|
||||
are `cfg!(feature = "horizon-extras")`: both sides compile in every build, so no stub has to follow
|
||||
the code it replaces. To remove an extra for good, delete its files from the table and the lines its
|
||||
gate guards.
|
||||
|
||||
+2
-2
@@ -156,8 +156,8 @@ Horizon's own extras and its product, and every built-in address of an outside s
|
||||
benchmark uploads, releases and updates, the hosting supervisor, the launcher.
|
||||
- **Examples:** `horizon-addresses`, `horizon-cosmetics`, `horizon-friends`, `horizon-relay`, `horizon-telemetry`,
|
||||
`horizon-bench`, `horizon-release-sign`, `horizon-server`, `horizon-mod-registry`, `horizon-heartbeat`,
|
||||
`horizon-official`, `horizon-official-servers`, `horizon-discord`, `launcher/`. Still in engine crates today:
|
||||
`game/cosmetics.rs`, `game/emotes.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_ui.rs`, `pvp.rs`.
|
||||
`horizon-official`, `horizon-official-servers`, `launcher/`. Still in engine crates today: `game/cosmetics.rs`, `game/emotes.rs`,
|
||||
`game/discord.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_ui.rs`, `pvp.rs`.
|
||||
- **Rules:**
|
||||
- It uses the platform's public API only, never the engine.
|
||||
- Until it moves, every Horizon feature in the game sits behind the `horizon-extras` feature
|
||||
|
||||
+6
-11
@@ -423,18 +423,13 @@ While Discord runs on the same computer, the player's Discord profile shows
|
||||
"Playing Horizon" and what they are doing: "In the menus", "Singleplayer",
|
||||
"Multiplayer" or "Hosting a world" (with the party size, of 8), the dimension
|
||||
("In the Nether") and the time since they entered the world. It never shows a
|
||||
world's name or a server's address. The switch is on the Extras page, in its
|
||||
Multiplayer group (**Discord Rich Presence**, on by default; `discordPresence`
|
||||
in `horizon-extras.txt`; in splitscreen only the first player has it), and
|
||||
`HORIZON_NO_DISCORD=1` turns it off for one run.
|
||||
world's name or a server's address. Horizon Settings has the switch
|
||||
(**Discord Rich Presence**, on by default; `discordPresence` in
|
||||
`horizon-extras.txt`), and `HORIZON_NO_DISCORD=1` turns it off for one run.
|
||||
|
||||
The crate `horizon-discord` (Horizon layer) speaks Discord's local IPC itself
|
||||
(the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its own.
|
||||
The game hands it a status only when it changes, through the platform's
|
||||
`activity` hook (`game/activity.rs` says what the player does), and keeps the
|
||||
switch itself: its settings store and its Extras row (the platform's settings
|
||||
and pages registries). Only the official executable (`horizon-official`)
|
||||
registers it: horizon-client's own executable shows no presence and no switch. It retries every 15 s
|
||||
`crates/horizon-client/src/discord_presence.rs` speaks Discord's local IPC
|
||||
itself (the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its
|
||||
own: the frame only hands it a status when it changes. It retries every 15 s
|
||||
while Discord isn't running and stays within Discord's 5 updates per 20 s.
|
||||
Dedicated servers never start it, and automated runs (offscreen, benchmarks)
|
||||
only talk to a fake Discord: `lab.game_env` sets `HORIZON_NO_DISCORD=1`, and
|
||||
|
||||
+2
-2
@@ -427,8 +427,8 @@ game built in `Game::new`, the integrated server on its own thread. On that base
|
||||
**Binaries.** The release archives hold `horizon-client`, `horizon-ctl`, `horizon-prepare` and
|
||||
`horizon-server`. On Linux the release also has `horizon-server-linux-x86_64.tar.gz`: `horizon-server`,
|
||||
`horizon-prepare` and `horizon-engine`, the game built with `--no-default-features` (no sound, no
|
||||
controllers, no Play with Friends: the `friends` feature; nor Discord presence or the "I'm playing" ping, which
|
||||
only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
|
||||
controllers, no Discord presence or Play with Friends: the `discord` and `friends` features; nor the "I'm
|
||||
playing" ping, which only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
|
||||
client archive (`xtask package-server`) and fails if `tools/check_imports.py --server` finds either library;
|
||||
CI lints the same build. `horizon-server` runs `horizon-engine` when it is beside it or on the PATH, else
|
||||
`horizon-client`. Build it yourself with `cargo build --release -p horizon-client --no-default-features`
|
||||
|
||||
@@ -9,7 +9,7 @@ The game goes title -> world -> Nether -> quit through horizon-ctl, and the acti
|
||||
single single player on its server thread
|
||||
lan a host opens its world to LAN and a guest joins: the guest shows "Multiplayer",
|
||||
the host "Hosting a world" with a party of 2 of 8
|
||||
setting off in horizon-extras.txt: nothing reaches Discord; the Extras switch
|
||||
setting off in horizon-extras.txt: nothing reaches Discord; the Horizon Settings switch
|
||||
turns it on (the activity appears) and off (it is cleared)
|
||||
late Discord starts after the game: found within the 15 s retry
|
||||
|
||||
@@ -136,7 +136,7 @@ class Game:
|
||||
self.discord.start()
|
||||
extra = {'HORIZON_HOME': self.home, 'HORIZON_TELEMETRY_URL': 'off', 'HORIZON_DISCORD_IPC_DIR': self.discord.dir}
|
||||
self.env = lab.game_env(**extra)
|
||||
self.ctl_run('launch', '--offscreen', '--game', str(bins / 'horizon-official'), '--resources',
|
||||
self.ctl_run('launch', '--offscreen', '--game', str(bins / 'horizon-client'), '--resources',
|
||||
str(lab.RESOURCES.resolve()), *args)
|
||||
|
||||
def ctl_run(self, *words, timeout=600):
|
||||
@@ -207,21 +207,8 @@ def lan(bins):
|
||||
g.close()
|
||||
|
||||
|
||||
def extras_row(game, label):
|
||||
"""The Extras page's row whose label starts with `label`, scrolling the list down to it: only
|
||||
the rows on screen are widgets."""
|
||||
first = game.ctl_run('ui')['widgets'][0]['bounds']
|
||||
for _ in range(20):
|
||||
found = [w for w in game.ctl_run('ui')['widgets'] if (w.get('label') or '').startswith(label)]
|
||||
if found:
|
||||
return found[0]
|
||||
game.ctl_run('scroll-at', str(first[0] + 5), str(first[1] + 5), '-5')
|
||||
time.sleep(0.3)
|
||||
raise AssertionError(f'no {label!r} row on the Extras page')
|
||||
|
||||
|
||||
def setting(bins):
|
||||
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Extras: the
|
||||
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Horizon Settings: the
|
||||
activity appears; off again: it is cleared."""
|
||||
game = Game(bins, 'setting', extras='discordPresence:false\n')
|
||||
d = game.discord
|
||||
@@ -229,8 +216,8 @@ def setting(bins):
|
||||
game.ctl_run('wait-for', 'screen', 'title')
|
||||
time.sleep(3)
|
||||
assert not d.handshakes and not d.activities, (d.handshakes, d.activities)
|
||||
game.ctl_run('options', 'extras')
|
||||
switch = extras_row(game, 'Discord Rich')
|
||||
game.ctl_run('options', 'horizon')
|
||||
switch = next(w for w in game.ctl_run('ui')['widgets'] if w.get('label', '').startswith('Discord Rich'))
|
||||
assert switch['label'] == 'Discord Rich Presence: Off', switch
|
||||
game.ctl_run('click', switch['id'])
|
||||
d.wait('turned on', shows('In the menus'))
|
||||
@@ -264,14 +251,14 @@ CHECKS = {'single': single, 'lan': lan, 'setting': setting, 'late': late}
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--check', choices=[*CHECKS, 'all'], default='all')
|
||||
parser.add_argument('--bin-dir', help='prebuilt horizon-official and horizon-ctl (default: build with the fast profile)')
|
||||
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
|
||||
args = parser.parse_args()
|
||||
if sys.platform == 'win32':
|
||||
sys.exit('the fake Discord is a Unix socket: macOS or Linux only')
|
||||
if args.bin_dir:
|
||||
bins = Path(args.bin_dir).resolve()
|
||||
else:
|
||||
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-official', '-p', 'horizon-client-lib', '--bins'],
|
||||
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-p', 'horizon-client-lib', '--bins'],
|
||||
cwd=ROOT, check=True)
|
||||
meta = json.loads(subprocess.run(['cargo', 'metadata', '--format-version', '1', '--no-deps'], cwd=ROOT,
|
||||
capture_output=True, text=True, check=True).stdout)
|
||||
|
||||
+5
-2
@@ -51,7 +51,6 @@
|
||||
"horizon-addresses": "horizon",
|
||||
"horizon-bench": "horizon",
|
||||
"horizon-cosmetics": "horizon",
|
||||
"horizon-discord": "horizon",
|
||||
"horizon-friends": "horizon",
|
||||
"horizon-heartbeat": "horizon",
|
||||
"horizon-launcher": "horizon",
|
||||
@@ -96,6 +95,7 @@
|
||||
"horizon-client -> horizon-shaders",
|
||||
"horizon-client -> horizon-telemetry",
|
||||
"horizon-client -> horizon-theme",
|
||||
"horizon-client-input -> horizon-theme",
|
||||
"horizon-client-lib -> horizon-anticheat",
|
||||
"horizon-client-lib -> horizon-bench",
|
||||
"horizon-client-lib -> horizon-chat-filter",
|
||||
@@ -106,10 +106,13 @@
|
||||
"horizon-client-lib -> horizon-shader-api",
|
||||
"horizon-client-lib -> horizon-shaders",
|
||||
"horizon-client-lib -> horizon-telemetry",
|
||||
"horizon-client-lib -> horizon-theme",
|
||||
"horizon-client-render -> horizon-cosmetics",
|
||||
"horizon-client-render -> horizon-hdr",
|
||||
"horizon-client-render -> horizon-optifine",
|
||||
"horizon-client-render -> horizon-polytone",
|
||||
"horizon-client-render -> horizon-shader-api",
|
||||
"horizon-client-render -> horizon-shaders"
|
||||
"horizon-client-render -> horizon-shaders",
|
||||
"horizon-client-render -> horizon-theme"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user