mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Compare commits
178
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7311870047 | ||
|
|
12578352dc | ||
|
|
11ebc3f199 | ||
|
|
bc08885276 | ||
|
|
bc7b8889de | ||
|
|
7180a52bf2 | ||
|
|
8e37c4a4b7 | ||
|
|
94ba80f90d | ||
|
|
238fc99add | ||
|
|
ac96d3fa35 | ||
|
|
ceff25fca1 | ||
|
|
36d0695742 | ||
|
|
94484675a8 | ||
|
|
9097d2973d | ||
|
|
00e573cf87 | ||
|
|
a3bf6531e0 | ||
|
|
0ddd7ec41d | ||
|
|
cea1a54e31 | ||
|
|
23dd21e928 | ||
|
|
6cf95bb4d7 | ||
|
|
cf09a6610a | ||
|
|
094bb83dff | ||
|
|
dfc91826bd | ||
|
|
2782c5e810 | ||
|
|
86f1749664 | ||
|
|
d149cc1dca | ||
|
|
ae963b0a98 | ||
|
|
c30911cddf | ||
|
|
38eac394be | ||
|
|
5546650bd7 | ||
|
|
c277636360 | ||
|
|
68a01ee7ca | ||
|
|
15e83578ee | ||
|
|
66e8c0d04d | ||
|
|
6bab737cae | ||
|
|
7093c79eeb | ||
|
|
a4da356144 | ||
|
|
6c2c706959 | ||
|
|
6769422c88 | ||
|
|
ebe7b4a4fd | ||
|
|
1cce1cbf31 | ||
|
|
ed5da43953 | ||
|
|
3295c54097 | ||
|
|
d0da7ec157 | ||
|
|
042d2ce46a | ||
|
|
8f756e8487 | ||
|
|
3f1a50ccb2 | ||
|
|
3dcfd7276b | ||
|
|
bfbf06a439 | ||
|
|
b7b137a829 | ||
|
|
75071c8b9b | ||
|
|
725208d8de | ||
|
|
3516aa9641 | ||
|
|
971f341977 | ||
|
|
cd362fb354 | ||
|
|
216eca2f51 | ||
|
|
7dccd1fa39 | ||
|
|
e84e5b2dd3 | ||
|
|
7b9f3f8e83 | ||
|
|
c9d73d5bea | ||
|
|
56b703899c | ||
|
|
63a6a92bef | ||
|
|
cf93d0dd3a | ||
|
|
c51a5eec3f | ||
|
|
21aabee4c0 | ||
|
|
7be13f1e9a | ||
|
|
ff83eccf6a | ||
|
|
f2718e086d | ||
|
|
e39ec11e59 | ||
|
|
dc382b898a | ||
|
|
b5f396461e | ||
|
|
e963d24058 | ||
|
|
3eec96a8b1 | ||
|
|
249e8db13c | ||
|
|
1d2dd56a06 | ||
|
|
22f3da10c0 | ||
|
|
c992f24496 | ||
|
|
005249b984 | ||
|
|
63dae1216c | ||
|
|
eb3157d251 | ||
|
|
d577d49a78 | ||
|
|
8a9bd15a3d | ||
|
|
a40f3d82c1 | ||
|
|
db8048a1b6 | ||
|
|
7383ff1908 | ||
|
|
4f4c87df64 | ||
|
|
6676c3a9a9 | ||
|
|
af3c22b9f1 | ||
|
|
182eec2cb7 | ||
|
|
53d1afc5b1 | ||
|
|
a94b5a1b2e | ||
|
|
b4c7f409d2 | ||
|
|
3365fd6bc4 | ||
|
|
c0da1ac2e1 | ||
|
|
45cefe3278 | ||
|
|
032ae31f34 | ||
|
|
47d55b5690 | ||
|
|
db4a145e6b | ||
|
|
59d958c09e | ||
|
|
1a2015e305 | ||
|
|
197172ef3c | ||
|
|
c3c0e2d868 | ||
|
|
528d98bf36 | ||
|
|
9d6d292348 | ||
|
|
7973f9935b | ||
|
|
d119d6da2d | ||
|
|
4bc93d9a5f | ||
|
|
671af567f4 | ||
|
|
1f19e94d74 | ||
|
|
fcbeaefd37 | ||
|
|
8bf0614d9d | ||
|
|
83b3c9306f | ||
|
|
e6184afbe2 | ||
|
|
96c5616505 | ||
|
|
7766619ecb | ||
|
|
6e21ce9476 | ||
|
|
365a44ff91 | ||
|
|
358456bb02 | ||
|
|
07068153c6 | ||
|
|
f5c6b7dfd1 | ||
|
|
8d5dd89421 | ||
|
|
6280d2630c | ||
|
|
0568b2beff | ||
|
|
aa741b3570 | ||
|
|
21860eefba | ||
|
|
0ccd2a4397 | ||
|
|
dd8214d3a3 | ||
|
|
9fd16efb45 | ||
|
|
34b5df1397 | ||
|
|
74eaac0c21 | ||
|
|
b9efd702dc | ||
|
|
79cd63b8bc | ||
|
|
a2fb9bc76e | ||
|
|
72c6784172 | ||
|
|
678d6da047 | ||
|
|
24c9ad202b | ||
|
|
6020c75004 | ||
|
|
1215645c4e | ||
|
|
c587403015 | ||
|
|
3ebcf19eed | ||
|
|
f2f02e57bd | ||
|
|
c10aea9eb8 | ||
|
|
bf22beb825 | ||
|
|
10fb981317 | ||
|
|
443a42f97a | ||
|
|
7f67dc8333 | ||
|
|
93ecc8d0c1 | ||
|
|
230b167744 | ||
|
|
3c710450fb | ||
|
|
6a6aa75cb9 | ||
|
|
9b72080c32 | ||
|
|
c93db2be5f | ||
|
|
df6c3384fb | ||
|
|
2e45a0750d | ||
|
|
8ad2c758ad | ||
|
|
c71978372d | ||
|
|
987dc976d3 | ||
|
|
6187796e59 | ||
|
|
70db97e662 | ||
|
|
cf28dc7745 | ||
|
|
e8196d58c4 | ||
|
|
0ef952921d | ||
|
|
9698eed74f | ||
|
|
f9dfc692e5 | ||
|
|
233e09221d | ||
|
|
af9edec8b9 | ||
|
|
cbba6ead3a | ||
|
|
2a44a86383 | ||
|
|
de9ab4646d | ||
|
|
2d2a2621f2 | ||
|
|
067ca7dfb3 | ||
|
|
b3dd537570 | ||
|
|
d2dbe9227c | ||
|
|
e0b91d71bf | ||
|
|
5ed7535acd | ||
|
|
1bb2149e88 | ||
|
|
d59333ab3a | ||
|
|
4b502a3c48 |
No files matched your search
@@ -13,6 +13,14 @@ else (videos, Discord and other community material) lives elsewhere.
|
||||
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
|
||||
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
|
||||
|
||||
**Mod-related pull requests go to [arismoko/horizon](https://github.com/arismoko/horizon)**, the fork that carries the
|
||||
mod API and reviews and merges that work. That covers the mod API, native hooks and events for mods, native mods,
|
||||
content mods (`.hmod`), mixins, wasm mods, `cargo horizon-mod`, `horizon-mods`, mod builds and the registry, and
|
||||
their docs ([mods](docs/mods.md), [native mods](docs/native-mods.md)). Open the pull request there from your usual
|
||||
branch. Mod-related pull requests opened here are closed with a pointer to it. Engine extension points that Horizon's
|
||||
own crates register (the [layer split](docs/layers.md)), resource and shader pack formats, and gameplay fixes stay
|
||||
here.
|
||||
|
||||
## Build it
|
||||
|
||||
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
|
||||
|
||||
Generated
+3
-7
@@ -1418,6 +1418,7 @@ dependencies = [
|
||||
name = "horizon-chat-filter"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-server-files",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
@@ -1528,7 +1529,6 @@ dependencies = [
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-hooks",
|
||||
"horizon-items",
|
||||
@@ -1592,7 +1592,6 @@ dependencies = [
|
||||
"horizon-optifine",
|
||||
"horizon-polytone",
|
||||
"horizon-protocol",
|
||||
"horizon-render-pipeline",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-system",
|
||||
@@ -1668,6 +1667,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"hkdf",
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"if-addrs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1793,6 +1793,7 @@ dependencies = [
|
||||
name = "horizon-official"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-friends",
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
"horizon-platform",
|
||||
@@ -1891,10 +1892,6 @@ dependencies = [
|
||||
"ring",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-render-pipeline"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "horizon-server"
|
||||
version = "0.1.0"
|
||||
@@ -1957,7 +1954,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"horizon-core",
|
||||
"horizon-render-pipeline",
|
||||
"naga",
|
||||
"png",
|
||||
"zip",
|
||||
|
||||
@@ -43,7 +43,6 @@ members = [
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-render-pipeline",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-polytone",
|
||||
"crates/horizon-shader-api",
|
||||
|
||||
@@ -7,6 +7,8 @@ rust-version.workspace = true
|
||||
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
|
||||
|
||||
[dependencies]
|
||||
# The moderation durations, shared with `/tempban`.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "1.1"
|
||||
|
||||
@@ -10,6 +10,9 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
mod normalise;
|
||||
|
||||
pub use horizon_server_files::duration::{
|
||||
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
|
||||
};
|
||||
pub use normalise::{Run, Text};
|
||||
|
||||
/// The list built into the game, used when `words_file` is empty.
|
||||
@@ -474,61 +477,6 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
|
||||
writeln!(f, "{line}")
|
||||
}
|
||||
|
||||
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
|
||||
/// `w` (`10m`, `2h`, `1d`).
|
||||
pub fn parse_duration(text: &str) -> Option<i64> {
|
||||
let unit = match text.chars().last()? {
|
||||
's' => 1_000,
|
||||
'm' => MINUTE,
|
||||
'h' => 60 * MINUTE,
|
||||
'd' => 24 * 60 * MINUTE,
|
||||
'w' => 7 * 24 * 60 * MINUTE,
|
||||
_ => return None,
|
||||
};
|
||||
let count: i64 = text[..text.len() - 1].parse().ok()?;
|
||||
(count > 0).then(|| count.checked_mul(unit)).flatten()
|
||||
}
|
||||
|
||||
/// The unit used when presenting a moderation duration to a player.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DurationUnit {
|
||||
Minute,
|
||||
Hour,
|
||||
Day,
|
||||
}
|
||||
|
||||
/// A duration's rounded count and unit, shared by logs and translated replies.
|
||||
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
|
||||
let (unit, kind) = if millis <= 60 * MINUTE {
|
||||
(MINUTE, DurationUnit::Minute)
|
||||
} else if millis <= 48 * 60 * MINUTE {
|
||||
(60 * MINUTE, DurationUnit::Hour)
|
||||
} else {
|
||||
(24 * 60 * MINUTE, DurationUnit::Day)
|
||||
};
|
||||
(
|
||||
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
|
||||
kind,
|
||||
)
|
||||
}
|
||||
|
||||
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
|
||||
pub fn duration_text(millis: i64) -> String {
|
||||
let (count, unit) = duration_parts(millis);
|
||||
let unit = match unit {
|
||||
DurationUnit::Minute => "minute",
|
||||
DurationUnit::Hour => "hour",
|
||||
DurationUnit::Day => "day",
|
||||
};
|
||||
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
pub fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64)
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
|
||||
pub fn utc(millis: i64) -> String {
|
||||
let secs = millis.div_euclid(1000);
|
||||
|
||||
@@ -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")]
|
||||
@@ -691,6 +670,39 @@ pub fn with_icon(attributes: winit::window::WindowAttributes) -> winit::window::
|
||||
attributes
|
||||
}
|
||||
|
||||
/// The screen a window is on: its size in physical pixels, its refresh rate where the OS gives
|
||||
/// it, and its scale. For `horizon-ctl status` and the performance statistics' `system.display`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Display {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub refresh_hz: Option<u32>,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
/// The screen `window` is on, else the primary one, else the only one: right after the window
|
||||
/// is made, Wayland doesn't say yet which screen it is on.
|
||||
pub fn monitor(window: &winit::window::Window) -> Option<winit::monitor::MonitorHandle> {
|
||||
let only = || {
|
||||
let mut all = window.available_monitors();
|
||||
all.next().filter(|_| all.next().is_none())
|
||||
};
|
||||
window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor())
|
||||
.or_else(only)
|
||||
}
|
||||
|
||||
/// [`monitor`]'s size, refresh rate and scale.
|
||||
pub fn display(window: &winit::window::Window) -> Option<Display> {
|
||||
monitor(window).map(|m| Display {
|
||||
width: m.size().width,
|
||||
height: m.size().height,
|
||||
refresh_hz: m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
|
||||
scale: m.scale_factor(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -727,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]
|
||||
|
||||
@@ -42,8 +42,6 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
# Play with Friends: invite codes and their tunnel; the `friends` feature.
|
||||
horizon-friends = { path = "../horizon-friends", optional = true }
|
||||
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
@@ -72,6 +70,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" }
|
||||
@@ -110,16 +109,17 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers", "discord", "friends"]
|
||||
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 (docs/forking.md, docs/layers.md): the features below, and the official
|
||||
# server in the list (`official_servers`).
|
||||
horizon-extras = ["discord"]
|
||||
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
|
||||
discord = []
|
||||
# Play with Friends, joining side (`net.rs`): without it no invite code joins.
|
||||
friends = ["dep:horizon-friends"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
@@ -131,8 +131,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).
|
||||
|
||||
@@ -26,10 +26,10 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-polytone = { path = "../horizon-polytone" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-render-pipeline = { path = "../horizon-render-pipeline" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
ab_glyph = "0.2.32"
|
||||
@@ -76,12 +76,14 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
audio = ["dep:cpal"]
|
||||
# Helpers horizon-client's tests build their fixtures with.
|
||||
test-support = []
|
||||
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
|
||||
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
|
||||
# the variable.
|
||||
horizon-extras = []
|
||||
|
||||
[lints]
|
||||
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"] }
|
||||
@@ -382,8 +382,8 @@ impl RenderAssets {
|
||||
};
|
||||
// After the connected tiles: a tile identical to a block shares its layer, and the
|
||||
// overlays skip shared layers.
|
||||
if draws {
|
||||
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
|
||||
if draws && let Some(suffix) = crate::pack_hooks::formats().emissive_suffix(root) {
|
||||
emissive = crate::native_emissive::load_overlays(root, &suffix, &mut sprites);
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
if draws {
|
||||
|
||||
@@ -233,11 +233,11 @@ pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
|
||||
}
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
let rel = format!("assets/{namespace}/{directory}/{path}.png");
|
||||
root.join(if horizon_optifine::paths::valid(&rel) {
|
||||
&rel
|
||||
} else {
|
||||
NOWHERE
|
||||
})
|
||||
// No `.`, `..` or empty segment, and no `\` (a separator on Windows, where `..\..` climbs).
|
||||
let stays = rel
|
||||
.split('/')
|
||||
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'));
|
||||
root.join(if stays { &rel } else { NOWHERE })
|
||||
}
|
||||
|
||||
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The Horizon Extras page, on the settings shell (`settings_shell`): which rows it has and what
|
||||
//! pressing them does. Each one's value and what it does stay in `extras::Item`.
|
||||
//! pressing them does. Each one's value and what it does stay in `extras::Item`; the sections crates
|
||||
//! add (`horizon_hooks::pages::Place::Extras`) keep theirs.
|
||||
use crate::{
|
||||
extras::{self, Item},
|
||||
lang,
|
||||
@@ -7,6 +8,7 @@ use crate::{
|
||||
settings::Settings,
|
||||
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
|
||||
};
|
||||
use horizon_hooks::pages::{self as added, Act, Label};
|
||||
|
||||
const TITLE: &str = "horizon.pages.extras";
|
||||
const ABOUT: &str = "horizon.pages.extras.tooltip";
|
||||
@@ -31,6 +33,125 @@ pub(crate) enum ExtraRow {
|
||||
/// It isn't installed: where to get it. A note, not a button: Options > Mods already has the
|
||||
/// one way into the Mods page (`every_setting_and_page_is_reached_from_one_place`).
|
||||
ZoomInstall,
|
||||
/// Row `.1` of the section registered page `.0` adds (`Place::Extras`).
|
||||
Added(usize, usize),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
|
||||
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
|
||||
const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// The pages crates registered, all places.
|
||||
fn pages() -> &'static [&'static dyn added::Page] {
|
||||
#[cfg(test)]
|
||||
if let Some(pages) = TEST_PAGES.get() {
|
||||
return pages;
|
||||
}
|
||||
added::engine::all()
|
||||
}
|
||||
|
||||
/// The sections of this page, by their index among the registered pages.
|
||||
fn sections() -> impl Iterator<Item = (usize, &'static dyn added::Page)> {
|
||||
let pages = pages().iter().copied().enumerate();
|
||||
pages.filter(|(_, page)| page.place() == added::Place::Extras)
|
||||
}
|
||||
|
||||
fn label_text(label: Label) -> String {
|
||||
match label {
|
||||
Label::Key(key) => lang::get(key),
|
||||
Label::Value(text) => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's group named `name`, made at the end if it has none.
|
||||
fn group_named<'a>(
|
||||
groups: &'a mut Vec<Group<ExtraRow>>,
|
||||
name: &'static str,
|
||||
) -> &'a mut Group<ExtraRow> {
|
||||
let at = groups.iter().position(|group| group.name == name);
|
||||
let at = at.unwrap_or_else(|| {
|
||||
groups.push(Group {
|
||||
name,
|
||||
rows: Vec::new(),
|
||||
});
|
||||
groups.len() - 1
|
||||
});
|
||||
&mut groups[at]
|
||||
}
|
||||
|
||||
/// The sections' rows, in the groups their titles and headings name.
|
||||
fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
|
||||
for (page, section) in sections() {
|
||||
let mut rows = Vec::new();
|
||||
section.rows(&mut rows);
|
||||
let mut name = section.title();
|
||||
for (r, row) in rows.into_iter().enumerate() {
|
||||
let id = ExtraRow::Added(page, r);
|
||||
let row = match row {
|
||||
added::Row::Heading(key) => {
|
||||
name = key;
|
||||
continue;
|
||||
}
|
||||
added::Row::Note(text) => Row::new(id, text, Value::Note).live(false),
|
||||
added::Row::Toggle { label, on } => {
|
||||
Row::new(id, lang::get(label), Value::Switch(on))
|
||||
}
|
||||
added::Row::Cycle { label, value } => {
|
||||
Row::new(id, lang::get(label), Value::Choice(label_text(value)))
|
||||
}
|
||||
added::Row::Slider { label, value, text } => {
|
||||
let position = value.clamp(0.0, 1.0);
|
||||
let text = label_text(text);
|
||||
let value = Value::Slider {
|
||||
position,
|
||||
steps: None,
|
||||
text,
|
||||
};
|
||||
Row::new(id, lang::get(label), value)
|
||||
}
|
||||
added::Row::Button {
|
||||
label,
|
||||
active: true,
|
||||
..
|
||||
} => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Key { label, key } => {
|
||||
let key = crate::keybinds::display_name(&key).plain();
|
||||
Row::new(id, lang::get(label), Value::Read(key))
|
||||
}
|
||||
// A row kind newer than this engine.
|
||||
_ => continue,
|
||||
};
|
||||
let row = match section.tooltip(r) {
|
||||
Some(about) => row.about(label_text(about)),
|
||||
None => row,
|
||||
};
|
||||
group_named(groups, name).rows.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Row `r` of registered page `page` was acted on: the options screen saves when it closes.
|
||||
fn act(page: usize, r: usize, act: Act) -> Vec<Output> {
|
||||
let Some(section) = pages().get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
section.act(r, act);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
|
||||
/// Whether row `r` of registered page `page` steps (a switch or a cycle).
|
||||
fn steps(page: usize, r: usize) -> bool {
|
||||
let mut rows = Vec::new();
|
||||
if let Some(section) = pages().get(page) {
|
||||
section.rows(&mut rows);
|
||||
}
|
||||
matches!(
|
||||
rows.get(r),
|
||||
Some(added::Row::Toggle { .. } | added::Row::Cycle { .. })
|
||||
)
|
||||
}
|
||||
|
||||
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
|
||||
@@ -85,9 +206,14 @@ impl Screen for ExtrasScreen {
|
||||
fn note_lines(&self) -> usize {
|
||||
NOTE_LINES
|
||||
}
|
||||
/// The rows read the extras, the mods and whether cosmetics are offered.
|
||||
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
shell::stamp_of(&(&s.extras, &s.mods, horizon_cosmetics::enabled()))
|
||||
let mut added = Vec::new();
|
||||
for (_, section) in sections() {
|
||||
section.rows(&mut added);
|
||||
}
|
||||
let cosmetics = horizon_cosmetics::enabled();
|
||||
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
|
||||
let mut groups = vec![Group {
|
||||
@@ -107,6 +233,7 @@ impl Screen for ExtrasScreen {
|
||||
}
|
||||
}
|
||||
}
|
||||
add_sections(&mut groups);
|
||||
vec![Sheet::new((), lang::get(TITLE), lang::get(ABOUT), groups)]
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
|
||||
@@ -125,16 +252,29 @@ impl Screen for ExtrasScreen {
|
||||
Output::Event(Event::NativeSettings(i)),
|
||||
],
|
||||
ExtraRow::ZoomInstall => Vec::new(),
|
||||
ExtraRow::Added(page, r) => {
|
||||
act(page, r, if back { Act::PressBack } else { Act::Press })
|
||||
}
|
||||
}
|
||||
}
|
||||
fn slide(&mut self, _s: &mut Settings, row: ExtraRow, position: f32) {
|
||||
if let ExtraRow::Added(page, r) = row {
|
||||
act(page, r, Act::Slide(position));
|
||||
}
|
||||
}
|
||||
/// With the wheel extra on, the wheel over an item changes it, without a click; the mod's rows
|
||||
/// let the list scroll.
|
||||
fn wheel_step(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Option<Vec<Output>> {
|
||||
let ExtraRow::Item(item) = row else {
|
||||
return None;
|
||||
};
|
||||
item.cycle(&mut s.extras, back);
|
||||
Some(vec![Output::Event(Event::Changed)])
|
||||
match row {
|
||||
ExtraRow::Item(item) => {
|
||||
item.cycle(&mut s.extras, back);
|
||||
Some(vec![Output::Event(Event::Changed)])
|
||||
}
|
||||
ExtraRow::Added(page, r) if steps(page, r) => {
|
||||
Some(act(page, r, if back { Act::PressBack } else { Act::Press }))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,4 +432,146 @@ mod tests {
|
||||
shell.key(&mut s, K::ArrowDown, None, false, size);
|
||||
assert_eq!(shell.focused_row(), Some(first));
|
||||
}
|
||||
|
||||
/// A section with a switch in the page's Multiplayer group, then a heading of its own with a
|
||||
/// number that steps either way, a note and a volume.
|
||||
struct Section {
|
||||
on: std::sync::Mutex<bool>,
|
||||
step: std::sync::Mutex<i32>,
|
||||
volume: std::sync::Mutex<f32>,
|
||||
}
|
||||
impl added::Page for Section {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.section"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.extras.header.multiplayer"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::Extras
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::Toggle {
|
||||
label: "test.section.switch",
|
||||
on: *self.on.lock().unwrap(),
|
||||
});
|
||||
rows.push(added::Row::Heading("test.section.heading"));
|
||||
rows.push(added::Row::Cycle {
|
||||
label: "test.section.step",
|
||||
value: Label::Value(self.step.lock().unwrap().to_string()),
|
||||
});
|
||||
rows.push(added::Row::Note("a note".into()));
|
||||
rows.push(added::Row::Slider {
|
||||
label: "test.section.volume",
|
||||
value: *self.volume.lock().unwrap(),
|
||||
text: Label::Value(String::new()),
|
||||
});
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
match (row, act) {
|
||||
(0, Act::Press | Act::PressBack) => {
|
||||
let mut on = self.on.lock().unwrap();
|
||||
*on = !*on;
|
||||
}
|
||||
(2, Act::Press) => *self.step.lock().unwrap() += 1,
|
||||
(2, Act::PressBack) => *self.step.lock().unwrap() -= 1,
|
||||
(4, Act::Slide(v)) => *self.volume.lock().unwrap() = v,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A page for Horizon Settings: never on Extras.
|
||||
struct Elsewhere;
|
||||
impl added::Page for Elsewhere {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.elsewhere"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.elsewhere"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::button("test.elsewhere.button"));
|
||||
}
|
||||
fn act(&self, _: usize, _: Act) {}
|
||||
}
|
||||
|
||||
fn with_section() -> &'static Section {
|
||||
let section: &'static Section = Box::leak(Box::new(Section {
|
||||
on: false.into(),
|
||||
step: 0.into(),
|
||||
volume: 0.5.into(),
|
||||
}));
|
||||
let pages: &'static [&'static dyn added::Page] = Box::leak(Box::new([
|
||||
&Elsewhere as &dyn added::Page,
|
||||
section as &dyn added::Page,
|
||||
]));
|
||||
TEST_PAGES.set(Some(pages));
|
||||
section
|
||||
}
|
||||
|
||||
/// The section's first row joins the page's own Multiplayer group, after its rows; its heading
|
||||
/// starts a group after the page's; a page for elsewhere adds nothing.
|
||||
#[test]
|
||||
fn a_section_joins_the_group_its_title_names_and_heads_its_own() {
|
||||
with_section();
|
||||
let s = Settings::default();
|
||||
let sheets = ExtrasScreen.sheets(&s);
|
||||
let groups = &sheets[0].groups;
|
||||
let ids = |name: &str| -> Vec<ExtraRow> {
|
||||
let group = groups.iter().find(|g| g.name == name).unwrap();
|
||||
group.rows.iter().map(|r| r.id).collect()
|
||||
};
|
||||
let multiplayer = ids("horizon.extras.header.multiplayer");
|
||||
assert_eq!(multiplayer.last(), Some(&ExtraRow::Added(1, 0)));
|
||||
assert_eq!(groups.last().unwrap().name, "test.section.heading");
|
||||
let own = [
|
||||
ExtraRow::Added(1, 2),
|
||||
ExtraRow::Added(1, 3),
|
||||
ExtraRow::Added(1, 4),
|
||||
];
|
||||
assert_eq!(ids("test.section.heading"), own);
|
||||
let all = rows_of(&s);
|
||||
assert!(!all.iter().any(|r| matches!(r.id, ExtraRow::Added(0, _))));
|
||||
}
|
||||
|
||||
/// A click flips the switch, a Shift click steps the number back, the wheel steps it, and the
|
||||
/// note takes no click; the rows show what the section holds after each.
|
||||
#[test]
|
||||
fn a_sections_rows_act_on_it() {
|
||||
let section = with_section();
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<ExtrasScreen>::default();
|
||||
let switch = ExtraRow::Added(1, 0);
|
||||
let at = shell.point_at(&s, SIZE, switch);
|
||||
assert!(
|
||||
!shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(*section.on.lock().unwrap());
|
||||
let value = |s: &Settings, id| rows_of(s).into_iter().find(|r| r.id == id).unwrap().value;
|
||||
assert_eq!(value(&s, switch), Value::Switch(true));
|
||||
let step = ExtraRow::Added(1, 2);
|
||||
let mut screen = ExtrasScreen;
|
||||
screen.press(&mut s, step, true);
|
||||
assert_eq!(value(&s, step), Value::Choice("-1".into()));
|
||||
assert!(screen.wheel_step(&mut s, step, false).is_some());
|
||||
assert_eq!(*section.step.lock().unwrap(), 0);
|
||||
assert_eq!(
|
||||
screen.wheel_step(&mut s, ExtraRow::Added(1, 3), false),
|
||||
None
|
||||
);
|
||||
let note = shell.point_at(&s, SIZE, ExtraRow::Added(1, 3));
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, note, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
screen.slide(&mut s, ExtraRow::Added(1, 4), 0.25);
|
||||
assert_eq!(*section.volume.lock().unwrap(), 0.25);
|
||||
}
|
||||
}
|
||||
@@ -231,8 +231,8 @@ pub struct FogInputs<'a> {
|
||||
/// `GameRenderer.nightVisionScale` while Night Vision is on without
|
||||
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
|
||||
pub night_vision: f32,
|
||||
/// The packs' Polytone `environment` fog settings.
|
||||
pub environment: horizon_polytone::colors::Environment,
|
||||
/// The packs' `environment` fog settings (Polytone's).
|
||||
pub environment: crate::pack_palette::Environment,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -581,7 +581,7 @@ mod tests {
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let environment = horizon_polytone::colors::Environment {
|
||||
let environment = crate::pack_palette::Environment {
|
||||
powder_snow_fog: Some(0xFF0000),
|
||||
blindness_fog_distance: 40.0,
|
||||
water_fog_brightening: 0.0,
|
||||
@@ -613,7 +613,7 @@ mod tests {
|
||||
}
|
||||
fn inputs_with<'a>(
|
||||
input: &FogInputs<'a>,
|
||||
environment: horizon_polytone::colors::Environment,
|
||||
environment: crate::pack_palette::Environment,
|
||||
) -> FogInputs<'a> {
|
||||
FogInputs {
|
||||
environment,
|
||||
|
||||
@@ -239,6 +239,7 @@ mod reuse;
|
||||
mod stars;
|
||||
mod surface_setup;
|
||||
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
|
||||
mod off_page;
|
||||
pub mod submit;
|
||||
mod sunrise_fan;
|
||||
mod timing;
|
||||
@@ -250,6 +251,7 @@ mod wgsl_prefix;
|
||||
mod world_post;
|
||||
pub mod world_primitives;
|
||||
mod world_warp;
|
||||
use off_page::move_off_page;
|
||||
|
||||
/// A section's instance: its camera-relative origin and, in `w`, the step of
|
||||
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
|
||||
@@ -726,62 +728,6 @@ impl Part {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
|
||||
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
|
||||
/// until the next range would pass `budget` bytes (it keeps what is left; the
|
||||
/// first range moves whatever its size, so none keeps the page closed), the
|
||||
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
|
||||
/// section arena, GPU culling's records (`records`) along. False when the other
|
||||
/// pages have no room left.
|
||||
fn move_off_page<'a>(
|
||||
arena: &mut arena::Arena,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
|
||||
mut records: Option<&mut gpu_culling::Records>,
|
||||
meshes: impl Iterator<Item = &'a mut SectionGpu>,
|
||||
page: u32,
|
||||
part: Part,
|
||||
budget: &mut u64,
|
||||
) -> bool {
|
||||
debug_assert!(
|
||||
records.is_none() || matches!(part, Part::Section { .. }),
|
||||
"records follow the section arena's ranges"
|
||||
);
|
||||
let mut moved = false;
|
||||
for s in meshes {
|
||||
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
|
||||
// range of page 0); in the other arenas, one without translucent quads.
|
||||
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
|
||||
continue;
|
||||
};
|
||||
let bytes = vertices * arena.stride();
|
||||
if moved && bytes > *budget {
|
||||
break;
|
||||
}
|
||||
let Some(to) = arena.alloc_moved(vertices) else {
|
||||
return false;
|
||||
};
|
||||
arena.copy(encoder, (page, start), to, vertices);
|
||||
arena.release(page, start, vertices);
|
||||
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
|
||||
let key = match part {
|
||||
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
|
||||
_ => to.0,
|
||||
};
|
||||
flat.place(device, encoder, uploads, id, key, to.1, vertices);
|
||||
}
|
||||
part.set(s, to);
|
||||
// Its solid quads start the range in either layout.
|
||||
if let (Part::Section { .. }, Some(records), Some(id)) =
|
||||
(part, records.as_deref_mut(), s.gpu_id)
|
||||
{
|
||||
records.relocate(id, to.0, u64::from(s.solid_base()));
|
||||
}
|
||||
*budget = budget.saturating_sub(bytes);
|
||||
moved = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Starts moving a page of a large arena far larger than its ranges in use
|
||||
/// (`Arena::oversized`) into a new page of the size they get (`Arena::open_page`),
|
||||
/// for `Renderer::shrink_translucent`: the page closes and `move_off_page` empties
|
||||
@@ -10116,7 +10062,11 @@ impl Renderer {
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
laps.lap("r_uploads");
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
// Post effects (a resource pack's chains, `use_depth_buffer`) read the world's depth
|
||||
// after the world pass, as `GameRenderer.render` hands them the main target's.
|
||||
let post_reads_depth = !frame.title && !frame.post_effects.effects.is_empty();
|
||||
let keep_depth = pack_reads_world
|
||||
|| post_reads_depth
|
||||
|| (taa && self.world_post.taa)
|
||||
|| oit_on
|
||||
|| native_render::render_needs().world_depth
|
||||
@@ -13714,6 +13664,7 @@ mod tests {
|
||||
Some(Reported {
|
||||
paper_white: 240,
|
||||
peak: 998,
|
||||
peak_guessed: true,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -13721,6 +13672,7 @@ mod tests {
|
||||
Some(Reported {
|
||||
paper_white: 480,
|
||||
peak: 480,
|
||||
peak_guessed: true,
|
||||
})
|
||||
);
|
||||
// White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456).
|
||||
@@ -13729,6 +13681,7 @@ mod tests {
|
||||
Some(Reported {
|
||||
paper_white: 456,
|
||||
peak: 456,
|
||||
peak_guessed: true,
|
||||
})
|
||||
);
|
||||
assert_eq!(os_reported(&info(Some(1000.0), None)), None);
|
||||
@@ -15419,11 +15372,9 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
|
||||
None => "Horizon renderer: the system doesn't report the display's luminance".into(),
|
||||
});
|
||||
}
|
||||
/// What the OS reports in absolute nits (Windows: DXGI's `MaxLuminance` and the SDR
|
||||
/// white level the brightness slider sets), which is HDR output's scale there: SDR
|
||||
/// white is Paper White, the display's peak is Peak Brightness. Both are kept within
|
||||
/// the settings' ranges. The OS's figures often come from the display's EDID and may
|
||||
/// run optimistic; the player's own value always wins.
|
||||
/// What the OS reports in absolute nits, HDR output's scale (Windows: DXGI's SDR white level,
|
||||
/// the brightness slider's, as Paper White, and its `MaxLuminance`, mostly the display's EDID
|
||||
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
|
||||
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
|
||||
let luminance = info.luminance?;
|
||||
let usable = |n: f32| n.is_finite() && n > 0.0;
|
||||
@@ -15435,6 +15386,7 @@ fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported>
|
||||
// Windows' SDR brightness slider can set white above the reported peak (480 against
|
||||
// a calibrated 380): the highlights then stay at white instead of dropping below it.
|
||||
peak: (peak.round() as u32).clamp(200, 10_000).max(paper_white),
|
||||
peak_guessed: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15470,6 +15422,7 @@ fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
|
||||
crate::graphics::Reported {
|
||||
paper_white: 203,
|
||||
peak: peak.clamp(200, 10_000),
|
||||
peak_guessed: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15718,11 +15671,11 @@ mod shadow_caster_tests {
|
||||
#[test]
|
||||
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
|
||||
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
|
||||
let shadow = horizon_render_pipeline::Shadow {
|
||||
let shadow = horizon_shaders::Shadow {
|
||||
distance: 128.0,
|
||||
near,
|
||||
far,
|
||||
..horizon_render_pipeline::Shadow::default()
|
||||
..horizon_shaders::Shadow::default()
|
||||
};
|
||||
let camera = DVec3::new(13.5, y + 1.62, -13.5);
|
||||
let view = pack_post::ShadowView {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -338,7 +338,7 @@ pub struct NativeFrame {
|
||||
reflection_sampler: wgpu::Sampler,
|
||||
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
|
||||
pub shadow_map: super::native_shadow::ShadowMap,
|
||||
shadow_camera: Option<horizon_render_pipeline::Shadow>,
|
||||
shadow_camera: Option<horizon_shaders::Shadow>,
|
||||
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
|
||||
/// is drawn and `has_reflection` stays 0.
|
||||
pub reflects: bool,
|
||||
@@ -1497,7 +1497,7 @@ pub fn frame_inputs(
|
||||
size: [u32; 2],
|
||||
seconds: f32,
|
||||
index: u32,
|
||||
shadow: Option<&horizon_render_pipeline::Shadow>,
|
||||
shadow: Option<&horizon_shaders::Shadow>,
|
||||
) -> FrameInputs {
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
|
||||
//! prelude's `shadow_position` undoes.
|
||||
|
||||
use horizon_render_pipeline::Shadow;
|
||||
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
|
||||
use horizon_shaders::Shadow;
|
||||
|
||||
use super::native_material::TerrainShape;
|
||||
use super::wgsl_prefix::{TERRAIN, prefixed, section};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! `move_off_page`, moved out of `gfx.rs` unchanged.
|
||||
use super::*;
|
||||
|
||||
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
|
||||
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
|
||||
/// until the next range would pass `budget` bytes (it keeps what is left; the
|
||||
/// first range moves whatever its size, so none keeps the page closed), the
|
||||
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
|
||||
/// section arena, GPU culling's records (`records`) along. False when the other
|
||||
/// pages have no room left.
|
||||
pub(super) fn move_off_page<'a>(
|
||||
arena: &mut arena::Arena,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
|
||||
mut records: Option<&mut gpu_culling::Records>,
|
||||
meshes: impl Iterator<Item = &'a mut SectionGpu>,
|
||||
page: u32,
|
||||
part: Part,
|
||||
budget: &mut u64,
|
||||
) -> bool {
|
||||
debug_assert!(
|
||||
records.is_none() || matches!(part, Part::Section { .. }),
|
||||
"records follow the section arena's ranges"
|
||||
);
|
||||
let mut moved = false;
|
||||
for s in meshes {
|
||||
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
|
||||
// range of page 0); in the other arenas, one without translucent quads.
|
||||
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
|
||||
continue;
|
||||
};
|
||||
let bytes = vertices * arena.stride();
|
||||
if moved && bytes > *budget {
|
||||
break;
|
||||
}
|
||||
let Some(to) = arena.alloc_moved(vertices) else {
|
||||
return false;
|
||||
};
|
||||
arena.copy(encoder, (page, start), to, vertices);
|
||||
arena.release(page, start, vertices);
|
||||
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
|
||||
let key = match part {
|
||||
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
|
||||
_ => to.0,
|
||||
};
|
||||
flat.place(device, encoder, uploads, id, key, to.1, vertices);
|
||||
}
|
||||
part.set(s, to);
|
||||
// Its solid quads start the range in either layout.
|
||||
if let (Part::Section { .. }, Some(records), Some(id)) =
|
||||
(part, records.as_deref_mut(), s.gpu_id)
|
||||
{
|
||||
records.relocate(id, to.0, u64::from(s.solid_base()));
|
||||
}
|
||||
*budget = budget.saturating_sub(bytes);
|
||||
moved = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -300,6 +300,7 @@ pub(crate) mod tests {
|
||||
luminance: Some(Reported {
|
||||
paper_white: 400,
|
||||
peak: 1000,
|
||||
peak_guessed: false,
|
||||
}),
|
||||
hdr: system,
|
||||
},
|
||||
@@ -650,6 +651,7 @@ pub(crate) mod tests {
|
||||
window_reading.luminance = Some(Reported {
|
||||
paper_white: 300,
|
||||
peak: 800,
|
||||
peak_guessed: false,
|
||||
});
|
||||
assert_eq!(window_output(window_reading).nits[..2], [300.0, 800.0]);
|
||||
assert_eq!(
|
||||
|
||||
@@ -549,6 +549,7 @@ mod tests {
|
||||
reading.luminance = Some(Reported {
|
||||
paper_white: 250,
|
||||
peak: 600,
|
||||
peak_guessed: false,
|
||||
});
|
||||
// 150 nits under the reported paper white of 250: 0.6 of it.
|
||||
settings.hdr_ui_nits = 150;
|
||||
@@ -584,6 +585,7 @@ mod tests {
|
||||
reading.luminance = Some(Reported {
|
||||
paper_white,
|
||||
peak: 1000,
|
||||
peak_guessed: false,
|
||||
});
|
||||
let next = candidate(&settings, caps, reading, &published);
|
||||
let again = commit(&mut swapchain, &published, next, caps, &mut rejected);
|
||||
@@ -729,6 +731,7 @@ mod tests {
|
||||
luminance: (hdr == Some(true)).then_some(Reported {
|
||||
paper_white: 300,
|
||||
peak: 800,
|
||||
peak_guessed: false,
|
||||
}),
|
||||
hdr,
|
||||
};
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use horizon_render_pipeline::{Format, Kind};
|
||||
use horizon_shaders::{BufferObject, CustomImage};
|
||||
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
|
||||
|
||||
pub(super) struct PackImage {
|
||||
pub(super) def: CustomImage,
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use glam::{DMat4, DVec3};
|
||||
use horizon_render_pipeline::{BUFFERS, Format, Kind, SHADOW_COLORS};
|
||||
use horizon_shaders::{Group, Post};
|
||||
use horizon_shaders::{BUFFERS, Format, Group, Kind, Post, SHADOW_COLORS};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::pack_draw::DrawUniforms;
|
||||
@@ -893,7 +892,7 @@ fn depth_linear(interface: &horizon_shaders::Interface, linear: u8) -> [f32; 4]
|
||||
}
|
||||
|
||||
fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool) -> Source {
|
||||
let shadow_color = horizon_render_pipeline::shadow_color(name);
|
||||
let shadow_color = horizon_shaders::shadow_color(name);
|
||||
let shadow_depth = match name {
|
||||
"shadowtex0" | "watershadow" | "shadowtex0HW" => Some(0),
|
||||
"shadowtex1" | "shadowtex1HW" => Some(1),
|
||||
@@ -912,7 +911,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
|
||||
};
|
||||
}
|
||||
}
|
||||
if shadow_space && horizon_render_pipeline::buffer_index(name).is_some() {
|
||||
if shadow_space && horizon_shaders::buffer_index(name).is_some() {
|
||||
// colortex buffers are not this pass's.
|
||||
return Source::Dummy;
|
||||
}
|
||||
@@ -926,7 +925,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
|
||||
"normals" => Source::Normals,
|
||||
"specular" => Source::Specular,
|
||||
horizon_shaders::WATER_MASK => Source::WaterMask,
|
||||
n => horizon_render_pipeline::buffer_index(n).map_or(Source::Dummy, Source::Color),
|
||||
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1583,7 +1582,7 @@ fn bindings(
|
||||
!unbound
|
||||
&& (t.stage != pack_textures::stage(group)
|
||||
|| released.0 != t.stage
|
||||
|| horizon_render_pipeline::buffer_index(&t.name)
|
||||
|| horizon_shaders::buffer_index(&t.name)
|
||||
.is_none_or(|b| released.1 & 1 << b == 0))
|
||||
});
|
||||
let image = pack
|
||||
@@ -2433,7 +2432,7 @@ impl PackPost {
|
||||
let shadow_config = post.config.shadow.clone();
|
||||
let noise_size = post.config.noise_resolution.unwrap_or(256);
|
||||
let half_lives = post.config.half_lives;
|
||||
for (name, ticks) in horizon_render_pipeline::HALF_LIVES.iter().zip(half_lives) {
|
||||
for (name, ticks) in horizon_shaders::HALF_LIVES.iter().zip(half_lives) {
|
||||
if let Some(t) = ticks {
|
||||
notes.push(format!("`{name}` is {t} ticks"));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
|
||||
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
|
||||
|
||||
use horizon_render_pipeline::{Format, Kind};
|
||||
use horizon_shaders::{Format, Kind};
|
||||
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
|
||||
|
||||
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! with two sides that `shadowcomp` programs flip as composite programs flip colortex (6.1.6).
|
||||
//! Square, `shadowMapResolution` pixels.
|
||||
|
||||
use horizon_render_pipeline::SHADOW_COLORS;
|
||||
use horizon_shaders::SHADOW_COLORS;
|
||||
|
||||
type Ops<T> = wgpu::Operations<T>;
|
||||
type Color = wgpu::Color;
|
||||
@@ -97,11 +97,7 @@ pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
|
||||
}
|
||||
|
||||
impl ShadowTargets {
|
||||
pub fn reset_history(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
shadow: &horizon_render_pipeline::Shadow,
|
||||
) {
|
||||
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
|
||||
let mut colors = 0;
|
||||
let mut doubled = 0;
|
||||
let formats = std::array::from_fn(|n| match &self.color[n] {
|
||||
@@ -129,7 +125,7 @@ impl ShadowTargets {
|
||||
/// resolution is kept within the device's limit.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shadow: &horizon_render_pipeline::Shadow,
|
||||
shadow: &horizon_shaders::Shadow,
|
||||
formats: [wgpu::TextureFormat; SHADOW_COLORS],
|
||||
colors: u16,
|
||||
doubled: u16,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! A shader pack's custom textures (`texture.<stage>.<name>`, `texture.noise`) on the GPU, and
|
||||
//! which of them a program's sampler reads.
|
||||
|
||||
use horizon_render_pipeline::Kind;
|
||||
use horizon_shaders::{CustomTexture, Group, Texels, Texture, TextureDim, TextureSource};
|
||||
use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
|
||||
|
||||
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
|
||||
pub(super) enum Image {
|
||||
@@ -119,12 +118,12 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
|
||||
} else {
|
||||
wgpu::TextureViewDimension::D2
|
||||
};
|
||||
let buffer = horizon_render_pipeline::buffer_index(name);
|
||||
let buffer = horizon_shaders::buffer_index(name);
|
||||
let staged = textures.iter().position(|t| {
|
||||
t.stage == stage(group)
|
||||
&& t.dimension == dimension
|
||||
&& (t.name == name
|
||||
|| buffer.is_some() && horizon_render_pipeline::buffer_index(&t.name) == buffer)
|
||||
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
|
||||
});
|
||||
staged.or_else(|| {
|
||||
textures.iter().position(|t| {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
//! [-w, w], and the depth buffers packs read hold GL window depth.
|
||||
|
||||
use glam::{DMat4, DVec3, Mat4, Vec3, Vec4};
|
||||
use horizon_render_pipeline::Shadow;
|
||||
use horizon_shaders::Shadow;
|
||||
|
||||
use super::Frame;
|
||||
|
||||
@@ -440,7 +440,7 @@ pub struct FrameUniforms {
|
||||
center_depth: Option<f32>,
|
||||
/// The pack's shadow settings (7.1).
|
||||
pub shadow: Shadow,
|
||||
/// The pack's smoothing half-lives in ticks (`horizon_render_pipeline::HALF_LIVES`).
|
||||
/// The pack's smoothing half-lives in ticks (`horizon_shaders::HALF_LIVES`).
|
||||
pub half_lives: [Option<f32>; 4],
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Hardware ray queries, only on request (#13780, after the #1180 spike): `HORIZON_RAY_TRACING=1`
|
||||
//! at startup asks the device for wgpu's `EXPERIMENTAL_RAY_QUERY` when the GPU has it. Nothing
|
||||
//! draws with it yet. Without the variable, and in a build without `horizon-extras` (a Horizon
|
||||
//! extra, docs/layers.md), the device is opened exactly as before.
|
||||
|
||||
/// The ray query feature.
|
||||
const RAY_QUERY: wgpu::Features = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
|
||||
|
||||
/// Whether the player asked for ray tracing (the device needs the feature from its creation).
|
||||
fn requested() -> bool {
|
||||
asked(std::env::var("HORIZON_RAY_TRACING").ok().as_deref())
|
||||
}
|
||||
|
||||
/// Whether `HORIZON_RAY_TRACING` set to `var` asks for ray tracing: only in a `horizon-extras`
|
||||
/// build.
|
||||
fn asked(var: Option<&str>) -> bool {
|
||||
cfg!(feature = "horizon-extras") && var == Some("1")
|
||||
}
|
||||
|
||||
/// The features to request on `adapter`: ray queries, when asked for and offered.
|
||||
pub(super) fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
let features = pick(requested(), adapter.features());
|
||||
if requested() {
|
||||
let info = adapter.get_info();
|
||||
if features.is_empty() {
|
||||
log::warn!(
|
||||
"Horizon renderer: ray tracing asked for, but {} on {:?} has no hardware ray \
|
||||
queries; it stays off",
|
||||
info.name,
|
||||
info.backend
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"Horizon renderer: ray queries on ({} on {:?})",
|
||||
info.name,
|
||||
info.backend
|
||||
);
|
||||
}
|
||||
}
|
||||
features
|
||||
}
|
||||
|
||||
fn pick(requested: bool, offered: wgpu::Features) -> wgpu::Features {
|
||||
if requested {
|
||||
offered & RAY_QUERY
|
||||
} else {
|
||||
wgpu::Features::empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The device's opt-in to `features`' experimental ones.
|
||||
pub(super) fn experimental(features: wgpu::Features) -> wgpu::ExperimentalFeatures {
|
||||
if features.contains(RAY_QUERY) {
|
||||
horizon_hdr::experimental_features()
|
||||
} else {
|
||||
wgpu::ExperimentalFeatures::disabled()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_an_extras_build_asks_for_ray_tracing() {
|
||||
assert_eq!(asked(Some("1")), cfg!(feature = "horizon-extras"));
|
||||
assert!(!asked(None));
|
||||
assert!(!asked(Some("0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_queries_only_when_asked_and_offered() {
|
||||
let all = wgpu::Features::all();
|
||||
assert_eq!(pick(true, all), RAY_QUERY);
|
||||
assert!(pick(false, all).is_empty());
|
||||
assert!(pick(true, all - RAY_QUERY).is_empty());
|
||||
assert!(!experimental(wgpu::Features::empty()).is_enabled());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ use super::{
|
||||
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
|
||||
timing,
|
||||
};
|
||||
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
|
||||
#[path = "ray_query.rs"]
|
||||
mod ray_query;
|
||||
use std::sync::Arc;
|
||||
use winit::window::Window;
|
||||
|
||||
@@ -134,6 +137,7 @@ pub(super) async fn open_adapter(
|
||||
let features = timing::GpuTimer::features(adapter)
|
||||
| cull::Cull::features(adapter)
|
||||
| oit::features(adapter)
|
||||
| ray_query::features(adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MULTI_DRAW_INDIRECT_COUNT
|
||||
@@ -166,6 +170,7 @@ pub(super) async fn open_adapter(
|
||||
// with the larger blocks a few small buffers kept whole blocks the arena's pages had
|
||||
// left, and over half the video memory reserved was unused (1,344 MiB for 500).
|
||||
memory_hints: wgpu::MemoryHints::MemoryUsage,
|
||||
experimental_features: ray_query::experimental(features),
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
|
||||
@@ -30,6 +30,22 @@ fn marker_driver(text: &str) -> &str {
|
||||
pub struct Reported {
|
||||
pub paper_white: u32,
|
||||
pub peak: u32,
|
||||
/// The peak is the display's own claim (DXGI's `MaxLuminance`, mostly its EDID figure),
|
||||
/// which often runs high: Auto still uses it, but the HDR page asks to calibrate.
|
||||
pub peak_guessed: bool,
|
||||
}
|
||||
|
||||
/// Whether HDR is set up for the display, and whether the player kept the system's setup.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HdrSetup {
|
||||
/// The system says nothing: the player calibrates.
|
||||
Unknown,
|
||||
/// The system says, but only guesses the peak: the player calibrates.
|
||||
Guessed,
|
||||
/// Peak Brightness and Paper White follow the system.
|
||||
Automatic,
|
||||
/// The system said, and the player changed them anyway.
|
||||
Changed,
|
||||
}
|
||||
|
||||
/// The top of Peak Brightness's scale: `peak_nits_at(PEAK_STEPS)` is PQ's 10 000 nits.
|
||||
@@ -1207,6 +1223,15 @@ impl Graphics {
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// How HDR is set up for the display the system `reported`.
|
||||
pub fn hdr_setup(&self, reported: Option<Reported>) -> HdrSetup {
|
||||
match reported {
|
||||
None => HdrSetup::Unknown,
|
||||
Some(_) if self.hdr_peak != 0 || self.hdr_paper_white != 0 => HdrSetup::Changed,
|
||||
Some(r) if r.peak_guessed => HdrSetup::Guessed,
|
||||
Some(_) => HdrSetup::Automatic,
|
||||
}
|
||||
}
|
||||
/// The black level, in nits. Not the system's: files from before saved 0 for that
|
||||
/// (Auto), and a reported black would lift every display's shadows by default.
|
||||
pub fn black_level_nits(&self) -> f32 {
|
||||
@@ -1320,6 +1345,7 @@ mod tests {
|
||||
let reported = Some(Reported {
|
||||
paper_white: 300,
|
||||
peak: 1000,
|
||||
peak_guessed: false,
|
||||
});
|
||||
assert_eq!(g.ui_nits(reported), 300);
|
||||
g.hdr_paper_white = 120;
|
||||
@@ -1328,6 +1354,33 @@ mod tests {
|
||||
assert_eq!((g.ui_nits(None), g.ui_nits(reported)), (400, 400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
|
||||
let wayland = Reported {
|
||||
paper_white: 203,
|
||||
peak: 1015,
|
||||
peak_guessed: false,
|
||||
};
|
||||
let windows = Reported {
|
||||
paper_white: 400,
|
||||
peak: 1000,
|
||||
peak_guessed: true,
|
||||
};
|
||||
let mut g = Graphics::default();
|
||||
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
|
||||
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Automatic);
|
||||
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Guessed);
|
||||
// Auto still takes the guess, over the 1000-nit default.
|
||||
assert_eq!(g.peak_nits(Some(windows)), 1000);
|
||||
assert_eq!(g.paper_white_nits(Some(windows)), 400);
|
||||
for (peak, paper_white) in [(800, 0), (0, 240)] {
|
||||
(g.hdr_peak, g.hdr_paper_white) = (peak, paper_white);
|
||||
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Changed);
|
||||
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Changed);
|
||||
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_modes_keep_boolean_files_and_round_trip_auto() {
|
||||
assert_eq!(Graphics::parse("").hdr, HdrMode::Off);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ use crate::{
|
||||
gfx::EffectiveOutput,
|
||||
graphics::{
|
||||
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi, HdrMode,
|
||||
LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
|
||||
HdrSetup as Setup, LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode,
|
||||
Preset, Upscaling,
|
||||
},
|
||||
graphics_style::Cost,
|
||||
lang,
|
||||
@@ -675,28 +676,9 @@ const LOD_STEPS: [u32; 24] = [
|
||||
crate::lod::MAX_CHUNKS,
|
||||
];
|
||||
|
||||
/// Whether the system set HDR up for the display, and whether the player kept it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Setup {
|
||||
/// The system says nothing: the player calibrates.
|
||||
Unknown,
|
||||
/// Peak Brightness and Paper White follow the system.
|
||||
Automatic,
|
||||
/// The system said, and the player changed them anyway.
|
||||
Changed,
|
||||
}
|
||||
/// `out`: the output of the renderer the settings are shown for, with what its display reports.
|
||||
fn setup(s: &Settings, out: &EffectiveOutput) -> Setup {
|
||||
setup_of(&s.horizon, out.reported.is_some())
|
||||
}
|
||||
fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
|
||||
if !reported {
|
||||
Setup::Unknown
|
||||
} else if g.hdr_peak == 0 && g.hdr_paper_white == 0 {
|
||||
Setup::Automatic
|
||||
} else {
|
||||
Setup::Changed
|
||||
}
|
||||
s.horizon.hdr_setup(out.reported)
|
||||
}
|
||||
/// What the HDR page says while HDR output is held back on this window's graphics driver
|
||||
/// (`EffectiveOutput::held`): picking a mode tries again (`H::cycle`).
|
||||
@@ -714,13 +696,19 @@ fn description(
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
|
||||
"Your display didn't say how bright it gets: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
|
||||
"Your display's reported peak is often too high: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
|
||||
"Puts Peak Brightness and Paper White back to your display's setup."
|
||||
}
|
||||
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
|
||||
// A guessed peak's Auto is what calibration replaced; paper white is the player's own.
|
||||
(Item::Horizon(h @ (H::HdrPeak | H::HdrPaperWhite)), Setup::Automatic | Setup::Changed)
|
||||
if h == H::HdrPaperWhite || out.reported.is_some_and(|r| !r.peak_guessed) =>
|
||||
{
|
||||
"Set up for your display. Leave on Auto unless it looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::GpuCulling), _) if s.horizon.gpu_culling == GpuCullingMode::Auto => {
|
||||
@@ -944,7 +932,7 @@ impl H {
|
||||
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
|
||||
H::Hdr => labelled("HDR", g.hdr.label()),
|
||||
H::HdrCalibrate => Text::literal(lang::get(match setup(s, out) {
|
||||
Setup::Unknown => "Calibrate...",
|
||||
Setup::Unknown | Setup::Guessed => "Calibrate...",
|
||||
Setup::Automatic => "Calibrate Anyway...",
|
||||
Setup::Changed => "Use Automatic Setup",
|
||||
})),
|
||||
|
||||
@@ -120,14 +120,86 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
|
||||
assert_eq!(label(H::HdrCalibrate, &silent), "Calibrate...");
|
||||
}
|
||||
|
||||
/// The setup rows' label and description for each kind of report: none, a compositor's
|
||||
/// (Wayland), and Windows', whose peak is the display's own guess.
|
||||
#[test]
|
||||
fn hdr_setup_follows_what_the_system_reported() {
|
||||
let mut g = crate::graphics::Graphics::default();
|
||||
assert_eq!(setup_of(&g, false), Setup::Unknown);
|
||||
assert_eq!(setup_of(&g, true), Setup::Automatic);
|
||||
g.hdr_paper_white = 80;
|
||||
assert_eq!(setup_of(&g, true), Setup::Changed);
|
||||
assert_eq!(setup_of(&g, false), Setup::Unknown);
|
||||
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
|
||||
let trusted = offering(true, Some(true));
|
||||
let mut guessed = trusted;
|
||||
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
|
||||
peak_guessed: true,
|
||||
..r
|
||||
});
|
||||
let rows = |s: &Settings, out: &EffectiveOutput| {
|
||||
[H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite].map(|h| {
|
||||
let item = Item::Horizon(h);
|
||||
(item.label(s, out).plain(), description(item, s, out, "-"))
|
||||
})
|
||||
};
|
||||
let calibrate = "Calibrate...";
|
||||
let set_up = "Set up for your display. Leave on Auto unless it looks wrong.";
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let none = (
|
||||
calibrate.into(),
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.",
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&s, &NONE),
|
||||
[
|
||||
none.clone(),
|
||||
("Peak Brightness: Auto (1000)".into(), "-"),
|
||||
("Paper White: Auto (203)".into(), "-"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&s, &guessed),
|
||||
[
|
||||
(
|
||||
calibrate.into(),
|
||||
"Your display's reported peak is often too high: calibrate to set it up."
|
||||
),
|
||||
("Peak Brightness: Auto (1000)".into(), "-"),
|
||||
("Paper White: Auto (400)".into(), "-"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&s, &trusted),
|
||||
[
|
||||
(
|
||||
"Calibrate Anyway...".into(),
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong."
|
||||
),
|
||||
("Peak Brightness: Auto (1000)".into(), set_up),
|
||||
("Paper White: Auto (400)".into(), set_up),
|
||||
]
|
||||
);
|
||||
// Calibrated or set by hand, either report offers its automatic setup back.
|
||||
s.horizon.hdr_peak = 800;
|
||||
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
|
||||
let changed = |peak| {
|
||||
[
|
||||
(
|
||||
"Use Automatic Setup".into(),
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.",
|
||||
),
|
||||
("Peak Brightness: 800".into(), peak),
|
||||
("Paper White: Auto (400)".into(), set_up),
|
||||
]
|
||||
};
|
||||
assert_eq!(rows(&s, &guessed), changed("-"));
|
||||
assert_eq!(rows(&s, &trusted), changed(set_up));
|
||||
assert_eq!(rows(&s, &NONE)[0], none);
|
||||
let mut ui = ui();
|
||||
ui.screen.output = guessed;
|
||||
let at = |[x, y, ..]: [f32; 4]| (x + 8.0, y + 4.0);
|
||||
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
|
||||
assert_eq!(press(&mut ui, &mut s, at(calibrate)), [CLICK, CHANGED]);
|
||||
assert_eq!((s.horizon.hdr_peak, s.horizon.hdr_paper_white), (0, 0));
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, at(calibrate)),
|
||||
[CLICK, Output::Push(OptionsPage::HdrCalibration)]
|
||||
);
|
||||
}
|
||||
|
||||
/// UI Brightness reads as Paper White does, in nits apart from it: Auto shows the paper white
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -67,7 +67,6 @@ mod jei_sprites;
|
||||
pub mod jigsaw_block;
|
||||
pub mod keypad;
|
||||
pub mod terrain_model;
|
||||
pub use horizon_polytone::colors as polytone_colors;
|
||||
pub use horizon_polytone::creative_tabs as polytone_creative_tabs;
|
||||
pub use horizon_world::json_folder;
|
||||
pub mod level_loading;
|
||||
@@ -75,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;
|
||||
|
||||
@@ -2593,8 +2593,7 @@ impl Lod {
|
||||
self.returned_at = std::time::Instant::now();
|
||||
let nodes = &self.plan.nodes;
|
||||
self.mesh_samples.retain(|key| nodes.contains(key));
|
||||
// Queued tiles the view left are not sampled (a running one is
|
||||
// dropped when it finishes, `finish_sample`).
|
||||
// Queued tiles the view left are not sampled; a running one is dropped (`finish_sample`).
|
||||
drop_queued_samples(&self.shared, &mut self.pending, |key| {
|
||||
self.plan.nodes.contains(key)
|
||||
});
|
||||
@@ -2680,6 +2679,7 @@ impl Lod {
|
||||
}
|
||||
if let Some((columns, mask)) = self.kept.take(&key) {
|
||||
self.ready.insert(key, columns);
|
||||
self.sync_sample(key);
|
||||
if let Some(mask) = mask {
|
||||
self.detail_masks.insert(key, mask);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Optional, texture-matched overlays drawn full bright over their base texture in the same
|
||||
//! terrain fragment: native mods' (`<ns>:emissive/...`) and OptiFine's emissive block textures
|
||||
//! (`block/stone_e.png` beside `block/stone.png`). No extra draw calls or world light changes.
|
||||
//! terrain fragment: native mods' (`<ns>:emissive/...`) and a pack's emissive block textures
|
||||
//! (OptiFine's `block/stone_e.png` beside `block/stone.png`, the suffix from the pack formats,
|
||||
//! [`crate::pack_hooks`]). No extra draw calls or world light changes.
|
||||
use crate::sprites::Sprites;
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
@@ -35,14 +36,12 @@ fn tile_file(name: &str) -> Option<&str> {
|
||||
name.strip_suffix(".png")
|
||||
}
|
||||
|
||||
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
|
||||
/// items draw from the HUD atlas): for each
|
||||
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
|
||||
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
|
||||
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
|
||||
/// the table). A pack without such files adds nothing.
|
||||
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
|
||||
let suffix = horizon_optifine::emissive::suffix(root);
|
||||
/// A pack's emissive overlays of the block sprites already loaded (what the terrain draws;
|
||||
/// items draw from the HUD atlas): for each `<ns>:block/<name>`, or connected-texture tile
|
||||
/// `assets/<ns>/.../<name>.png`, whose `<name><suffix>.png` sits beside it, the overlay is loaded
|
||||
/// too and paired with it, blocks first, then tiles, each by name, at most [`MAX`] - [`NATIVE`]
|
||||
/// (native mods keep their share of the table). A pack without such files adds nothing.
|
||||
pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(String, String)> {
|
||||
// Blocks with identical pixels share a layer (and the shader pairs by layer): a shared
|
||||
// base would glow on every block that shares it, so it gets no overlay.
|
||||
let mut sharing: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
@@ -56,8 +55,8 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
|
||||
sharing[layer] == 1
|
||||
&& (name
|
||||
.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|
||||
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
|
||||
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(suffix))
|
||||
|| tile_file(name).is_some_and(|stem| !stem.ends_with(suffix)))
|
||||
})
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
@@ -110,7 +109,7 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
|
||||
pub const CUTOUT: u32 = 1 << 31;
|
||||
|
||||
/// The frame's overlay table: native mods' pairs (at most [`NATIVE`] requested), then the packs'
|
||||
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_optifine`] loads),
|
||||
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_overlays`] loads),
|
||||
/// one per base layer with native mods' first, sorted by base layer for the shader's early exit.
|
||||
pub fn table<'a>(
|
||||
layers: &BTreeMap<String, u32>,
|
||||
@@ -204,7 +203,7 @@ mod tests {
|
||||
// Items draw from the HUD atlas, not the terrain: no pair.
|
||||
sprites.insert(&root, "minecraft:item/stick").unwrap();
|
||||
// glowstone isn't loaded: its overlay pairs with nothing.
|
||||
let pairs = load_optifine(&root, &mut sprites);
|
||||
let pairs = load_overlays(&root, "_e", &mut sprites);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
[(
|
||||
@@ -215,9 +214,6 @@ mod tests {
|
||||
assert!(sprites.layers.contains_key("minecraft:block/stone_e"));
|
||||
assert!(!sprites.layers.contains_key("minecraft:block/granite_e"));
|
||||
// The pack's own suffix.
|
||||
let props = root.join("assets/minecraft/optifine/emissive.properties");
|
||||
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
|
||||
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
|
||||
// Connected-texture tiles pair with the overlay beside them.
|
||||
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
|
||||
png(&tiles.join("0.png"), 230);
|
||||
@@ -234,7 +230,7 @@ mod tests {
|
||||
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
|
||||
.unwrap();
|
||||
}
|
||||
let pairs = load_optifine(&root, &mut sprites);
|
||||
let pairs = load_overlays(&root, "_glow", &mut sprites);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
// Blocks first, then tiles.
|
||||
|
||||
@@ -339,6 +339,7 @@ impl Opt {
|
||||
Opt::SharePresence => "options.sharePresence",
|
||||
Opt::DirectionalAudio => "options.directionalAudio",
|
||||
Opt::MusicToast => "options.musicToast",
|
||||
Opt::Page(page, row) => pages::slider_caption(page, row),
|
||||
}
|
||||
}
|
||||
pub(crate) fn kind(self, s: &Settings) -> Kind {
|
||||
@@ -402,6 +403,7 @@ impl Opt {
|
||||
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
|
||||
Opt::PadTriggerThreshold => unit(s.controller.trigger_threshold, 0.1, 0.9),
|
||||
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
|
||||
Opt::Page(page, row) => pages::slider_kind(page, row),
|
||||
_ => Kind::Cycle,
|
||||
}
|
||||
}
|
||||
@@ -878,6 +880,7 @@ impl Opt {
|
||||
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
|
||||
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
|
||||
}),
|
||||
Opt::Page(page, row) => pages::slider_text(page, row),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
}
|
||||
@@ -952,6 +955,7 @@ impl Opt {
|
||||
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::PadTriggerThreshold => s.controller.trigger_threshold = step(10.0, 90.0) / 100.0,
|
||||
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
|
||||
Opt::Page(page, row) => pages::slide(page, row, v),
|
||||
_ => {}
|
||||
}
|
||||
if self.affects_preset() {
|
||||
@@ -1331,6 +1335,8 @@ pub enum Page {
|
||||
ModProject,
|
||||
/// A native mod's settings page: its index in `mods.installed`.
|
||||
NativeMod(usize),
|
||||
/// A page a crate registered (`horizon_hooks::pages`): its index in `OptionsUi::pages`.
|
||||
Added(usize),
|
||||
/// Horizon's shader packs, either kind (`shader_pack_list`).
|
||||
ShaderPacks,
|
||||
/// The picked pack's options: the shader screen (`shader_ui`) at the pack's main screen.
|
||||
@@ -1390,6 +1396,8 @@ enum Act {
|
||||
/// A native mod's key (mod, key) waits for a key; a button of its page.
|
||||
NativeKey(usize, usize),
|
||||
NativeButton(usize, usize),
|
||||
/// A registered page's row: (page, row).
|
||||
PageRow(usize, usize),
|
||||
OpenModFolder,
|
||||
/// Browse mods: the page of listing `.0` of `mod_browser`.
|
||||
ModProject(usize),
|
||||
@@ -1617,7 +1625,8 @@ pub struct OptionsUi {
|
||||
/// Key Binds: the mapping waiting for a key ([`NATIVE_KEY`]: a native
|
||||
/// mod's, `selected_native_key`).
|
||||
pub selected_key: Option<&'static str>,
|
||||
/// The native mod's key waiting: (mod, key) in `mods.installed`.
|
||||
/// The native mod's key waiting: (mod, key) in `mods.installed`, or a registered page's
|
||||
/// (page, row) ([`pages::PAGE_KEY`]).
|
||||
selected_native_key: Option<(usize, usize)>,
|
||||
/// Bindings: the action waiting for a pad button.
|
||||
pub selected_pad_action: Option<&'static str>,
|
||||
@@ -1861,25 +1870,6 @@ fn wide_extras(s: &Settings, width: impl Fn(&str) -> f32) -> Vec<crate::extras::
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// What a button opening one of Horizon's pages says on hover.
|
||||
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
Page::Horizon => "horizon.pages.settings.tooltip",
|
||||
Page::Studio => "horizon.pages.studio.tooltip",
|
||||
Page::Graphics => "horizon.pages.graphics.tooltip",
|
||||
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
|
||||
Page::Controller => "horizon.pages.controller.tooltip",
|
||||
Page::Extras => "horizon.pages.extras.tooltip",
|
||||
Page::Pvp => "horizon.pages.pvp.tooltip",
|
||||
Page::Agent => "horizon.pages.agent.tooltip",
|
||||
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
|
||||
Page::Mods => "horizon.pages.mods.tooltip",
|
||||
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
|
||||
Page::Music => "horizon.pages.music.tooltip",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Rows of an OptionsList: (big?, options) or a header.
|
||||
enum Row {
|
||||
Big(Opt),
|
||||
@@ -1899,14 +1889,17 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
|
||||
return None;
|
||||
};
|
||||
let key = opt.tooltip_key(s);
|
||||
return lang::has(&key).then(|| tr(&key));
|
||||
let Opt::Page(page, row) = opt else {
|
||||
return lang::has(&key).then(|| tr(&key));
|
||||
};
|
||||
return pages::tooltip(*page, *row);
|
||||
};
|
||||
let mut tip = None;
|
||||
if let Act::Extra(item) = act {
|
||||
tip = Some(Text::literal(item.description_text()));
|
||||
}
|
||||
if let Act::Push(page) = act
|
||||
&& let Some(text) = horizon_page_tooltip(*page)
|
||||
&& let Some(text) = pages::horizon_page_tooltip(*page)
|
||||
{
|
||||
tip = Some(tr(text));
|
||||
}
|
||||
@@ -1926,6 +1919,7 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
|
||||
}
|
||||
match act {
|
||||
Act::Cycle(opt) if !opt.available() => Some(tr("horizon.options.unavailable")),
|
||||
Act::PageRow(page, row) => pages::tooltip(*page, *row),
|
||||
Act::ToggleMod(i) => match s.mods.installed.get(*i) {
|
||||
Some(installed) => {
|
||||
let description = installed
|
||||
@@ -2367,6 +2361,7 @@ impl OptionsUi {
|
||||
Page::ModBrowser => "horizon.mods.browse.title",
|
||||
Page::ModProject => "horizon.mods.browse.project",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::Added(i) => pages::title(i),
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::ShaderOptions => "horizon.pages.shader_options",
|
||||
Page::PackOptions => "horizon.pack.options.title",
|
||||
@@ -2647,47 +2642,7 @@ impl OptionsUi {
|
||||
flush(&mut rows, &mut section);
|
||||
rows
|
||||
}
|
||||
Page::Horizon => {
|
||||
// Two to a row, as vanilla's OptionsList.addSmall lays out
|
||||
// buttons; each opens one of Horizon's settings pages. Each page has
|
||||
// one way in: graphics and shader packs are Video Settings' (the graphics
|
||||
// screen), the controller is Controls', mods are the Options screen's.
|
||||
let open =
|
||||
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
|
||||
vec![
|
||||
Row::Buttons(
|
||||
open("horizon.pages.open.extras", Page::Extras),
|
||||
Some(open("horizon.pages.open.pvp", Page::Pvp)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open(
|
||||
"horizon.pages.open.split_setup",
|
||||
if self.split_guest {
|
||||
Page::SplitProfiles
|
||||
} else {
|
||||
Page::SplitSetup
|
||||
},
|
||||
),
|
||||
Some(open("horizon.pages.open.studio", Page::Studio)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::DiscordPresence),
|
||||
Some(if s.extras.no_ai {
|
||||
self.extra_button(s, crate::extras::Item::NoAi)
|
||||
} else {
|
||||
open("horizon.pages.open.agent", Page::Agent)
|
||||
}),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(
|
||||
(!s.extras.no_ai).then(|| {
|
||||
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
|
||||
}),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
Page::Horizon => self.horizon_rows(s),
|
||||
Page::Agent => {
|
||||
let mut rows = vec![Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::AgentControl),
|
||||
@@ -2970,6 +2925,7 @@ impl OptionsUi {
|
||||
Page::ModBrowser => self.mod_browser_rows(),
|
||||
Page::ModProject => self.mod_project_rows(s),
|
||||
Page::NativeMod(i) => self.native_rows(s, i),
|
||||
Page::Added(i) => self.added_rows(i),
|
||||
_ => {
|
||||
let _ = s;
|
||||
Vec::new()
|
||||
@@ -4238,6 +4194,7 @@ impl OptionsUi {
|
||||
}
|
||||
Act::NativeKey(i, k) => self.wait_native_key(i, k),
|
||||
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
|
||||
Act::PageRow(p, r) => events.extend(self.page_act(p, r, self.shift)),
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::ModProject(i) => {
|
||||
if let Some(listing) = self.mod_browser.listings.get(i) {
|
||||
@@ -4741,8 +4698,8 @@ impl OptionsUi {
|
||||
let Some(name) = self.selected_key.take() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if name == NATIVE_KEY {
|
||||
return self.bind_native_key(s, value);
|
||||
if name == NATIVE_KEY || name == pages::PAGE_KEY {
|
||||
return self.bind_native_key(s, name, value);
|
||||
}
|
||||
if !s.extras.set_key(name, value.clone()) {
|
||||
s.keys.set(name, &value);
|
||||
@@ -5126,6 +5083,7 @@ impl OptionsUi {
|
||||
}
|
||||
Act::NativeKey(i, k) => self.wait_native_key(i, k),
|
||||
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
|
||||
Act::PageRow(p, r) => events.extend(self.page_act(p, r, shift)),
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::ModProject(i) => {
|
||||
if let Some(listing) = self.mod_browser.listings.get(i) {
|
||||
|
||||
@@ -1,8 +1,261 @@
|
||||
//! The pages crates add to the options screens: a native mod's settings page (its keys and
|
||||
//! buttons). Kept out of options_ui.rs, which may not grow (tools/check_file_size.py).
|
||||
//! buttons), and the pages crates above the engine register (`horizon_hooks::pages`, docs/layers.md
|
||||
//! "Platform registries") with their entries on Horizon Settings. Kept out of options_ui.rs, which
|
||||
//! may not grow (tools/check_file_size.py).
|
||||
use super::*;
|
||||
use horizon_hooks::pages as added;
|
||||
|
||||
/// `selected_key` while a registered page's key waits (`selected_native_key`: page, row).
|
||||
pub(super) const PAGE_KEY: &str = "horizon.pageKey";
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
|
||||
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
|
||||
const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// The pages crates registered.
|
||||
fn registered() -> &'static [&'static dyn added::Page] {
|
||||
#[cfg(test)]
|
||||
if let Some(pages) = TEST_PAGES.get() {
|
||||
return pages;
|
||||
}
|
||||
added::engine::all()
|
||||
}
|
||||
|
||||
/// A registered page's title (a translation key).
|
||||
pub(super) fn title(page: usize) -> &'static str {
|
||||
registered().get(page).map_or("", |p| p.title())
|
||||
}
|
||||
|
||||
/// Row `r` of a registered page, as it is now.
|
||||
fn row(page: usize, r: usize) -> Option<added::Row> {
|
||||
let mut rows = Vec::new();
|
||||
registered().get(page)?.rows(&mut rows);
|
||||
rows.into_iter().nth(r)
|
||||
}
|
||||
|
||||
/// A registered page's slider ([`Opt::Page`]): its caption (a translation key).
|
||||
pub(super) fn slider_caption(page: usize, r: usize) -> &'static str {
|
||||
match row(page, r) {
|
||||
Some(added::Row::Slider { label, .. }) => label,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's slider: its position.
|
||||
pub(super) fn slider_kind(page: usize, r: usize) -> Kind {
|
||||
match row(page, r) {
|
||||
Some(added::Row::Slider { value, .. }) => Kind::Slider(value.clamp(0.0, 1.0)),
|
||||
_ => Kind::Cycle,
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's slider: the value its message shows after the caption.
|
||||
pub(super) fn slider_text(page: usize, r: usize) -> Text {
|
||||
match row(page, r) {
|
||||
Some(added::Row::Slider { text, .. }) => label_text(text),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's slider moved.
|
||||
pub(super) fn slide(page: usize, r: usize, value: f32) {
|
||||
if let Some(registered) = registered().get(page) {
|
||||
registered.act(r, added::Act::Slide(value));
|
||||
}
|
||||
}
|
||||
|
||||
/// What a registered page's row says on hover.
|
||||
pub(super) fn tooltip(page: usize, r: usize) -> Option<Text> {
|
||||
registered().get(page)?.tooltip(r).map(label_text)
|
||||
}
|
||||
|
||||
/// What a button opening one of Horizon's pages says on hover.
|
||||
pub(super) fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
Page::Horizon => "horizon.pages.settings.tooltip",
|
||||
Page::Studio => "horizon.pages.studio.tooltip",
|
||||
Page::Graphics => "horizon.pages.graphics.tooltip",
|
||||
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
|
||||
Page::Controller => "horizon.pages.controller.tooltip",
|
||||
Page::Extras => "horizon.pages.extras.tooltip",
|
||||
Page::Pvp => "horizon.pages.pvp.tooltip",
|
||||
Page::Agent => "horizon.pages.agent.tooltip",
|
||||
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
|
||||
Page::Mods => "horizon.pages.mods.tooltip",
|
||||
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
|
||||
Page::Music => "horizon.pages.music.tooltip",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn label_text(label: added::Label) -> Text {
|
||||
match label {
|
||||
added::Label::Key(key) => tr(key),
|
||||
added::Label::Value(value) => Text::literal(value),
|
||||
}
|
||||
}
|
||||
|
||||
impl OptionsUi {
|
||||
/// Horizon Settings: its pages, then the pages crates registered for it.
|
||||
pub(super) fn horizon_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
// Two to a row, as vanilla's OptionsList.addSmall lays out
|
||||
// buttons; each opens one of Horizon's settings pages. Each page has
|
||||
// one way in: graphics and shader packs are Video Settings' (the graphics
|
||||
// screen), the controller is Controls', mods are the Options screen's.
|
||||
let open = |key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
|
||||
let mut rows: Vec<Row> = vec![
|
||||
Row::Buttons(
|
||||
open("horizon.pages.open.extras", Page::Extras),
|
||||
Some(open("horizon.pages.open.pvp", Page::Pvp)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open(
|
||||
"horizon.pages.open.split_setup",
|
||||
if self.split_guest {
|
||||
Page::SplitProfiles
|
||||
} else {
|
||||
Page::SplitSetup
|
||||
},
|
||||
),
|
||||
Some(open("horizon.pages.open.studio", Page::Studio)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
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
|
||||
}
|
||||
|
||||
/// The entries of the pages registered for Horizon Settings, two to a row like the engine's.
|
||||
fn added_entries(&self, rows: &mut Vec<Row>) {
|
||||
let mut entries = registered()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, page)| page.place() == added::Place::HorizonSettings)
|
||||
.map(|(i, page)| button(0.0, 0.0, 150.0, tr(page.title()), Act::Push(Page::Added(i))));
|
||||
while let Some(first) = entries.next() {
|
||||
rows.push(Row::Buttons(first, entries.next()));
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's rows, asked of it each time the page is drawn.
|
||||
pub(super) fn added_rows(&self, page: usize) -> Vec<Row> {
|
||||
let Some(registered) = registered().get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
registered.rows(&mut rows);
|
||||
let mut out = Vec::new();
|
||||
// The row a half-width button started, waiting for the next to sit beside it.
|
||||
let mut half_open = false;
|
||||
for (r, row) in rows.into_iter().enumerate() {
|
||||
match row {
|
||||
// Wrapped as the engine's own notes; space is for pages drawn as a screen.
|
||||
added::Row::Text { text, .. } => {
|
||||
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
|
||||
}
|
||||
added::Row::Space(_) => continue,
|
||||
added::Row::Button {
|
||||
label,
|
||||
active,
|
||||
half: true,
|
||||
..
|
||||
} => {
|
||||
let mut half = button(0.0, 0.0, 150.0, tr(label), Act::PageRow(page, r));
|
||||
if !active {
|
||||
half = inactive(half);
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(Row::Buttons(_, second @ None)) if half_open => *second = Some(half),
|
||||
_ => out.push(Row::Buttons(half, None)),
|
||||
}
|
||||
half_open = !half_open;
|
||||
continue;
|
||||
}
|
||||
row => out.push(self.added_row(page, r, row)),
|
||||
}
|
||||
half_open = false;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
|
||||
let act = Act::PageRow(page, r);
|
||||
let wide = |label: Text| Row::Buttons(button(0.0, 0.0, 310.0, label, act.clone()), None);
|
||||
match row {
|
||||
added::Row::Heading(key) => Row::Heading(key.to_owned()),
|
||||
added::Row::Note(text) => Row::Note(text),
|
||||
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
|
||||
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
|
||||
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
|
||||
added::Row::Button { label, active, .. } => {
|
||||
let wide = button(0.0, 0.0, 310.0, tr(label), act);
|
||||
Row::Buttons(if active { wide } else { inactive(wide) }, None)
|
||||
}
|
||||
added::Row::Key { label, key } => {
|
||||
let mut message = keybinds::display_name(&key);
|
||||
if self.selected_key == Some(PAGE_KEY)
|
||||
&& self.selected_native_key == Some((page, r))
|
||||
{
|
||||
message = Text::literal("> ")
|
||||
.append(message.with_color(text::WHITE))
|
||||
.append(" <")
|
||||
.with_color(text::YELLOW);
|
||||
}
|
||||
let caption = button(0.0, 0.0, 150.0, tr(label), Act::None);
|
||||
let change = button(0.0, 0.0, 150.0, message, act);
|
||||
Row::Buttons(inactive(caption), Some(change))
|
||||
}
|
||||
// A row kind newer than this engine: shown empty.
|
||||
_ => Row::Note(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's row was pressed: a key row waits for a key, any other row hears it.
|
||||
pub(super) fn page_act(&mut self, page: usize, r: usize, shift: bool) -> Vec<Event> {
|
||||
let Some(registered) = registered().get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
registered.rows(&mut rows);
|
||||
match rows.get(r) {
|
||||
Some(added::Row::Key { .. }) => {
|
||||
self.selected_key = Some(PAGE_KEY);
|
||||
self.selected_native_key = Some((page, r));
|
||||
Vec::new()
|
||||
}
|
||||
Some(_) => {
|
||||
registered.act(
|
||||
r,
|
||||
if shift {
|
||||
added::Act::PressBack
|
||||
} else {
|
||||
added::Act::Press
|
||||
},
|
||||
);
|
||||
vec![Event::Changed]
|
||||
}
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A native mod's page: its name, its keys as Key Binds rows (the
|
||||
/// player's choice, else the default), then its buttons.
|
||||
pub(super) fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
|
||||
@@ -46,9 +299,21 @@ impl OptionsUi {
|
||||
self.selected_native_key = Some((i, k));
|
||||
}
|
||||
|
||||
/// The key pressed for the native mod's key that waited for one.
|
||||
pub(super) fn bind_native_key(&mut self, s: &mut Settings, value: String) -> Vec<Event> {
|
||||
/// The key pressed for the native mod's key, or the registered page's ([`PAGE_KEY`]), that
|
||||
/// waited for one.
|
||||
pub(super) fn bind_native_key(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
name: &str,
|
||||
value: String,
|
||||
) -> Vec<Event> {
|
||||
let (i, k) = self.selected_native_key.take().unwrap_or_default();
|
||||
if name == PAGE_KEY {
|
||||
if let Some(page) = registered().get(i) {
|
||||
page.act(k, added::Act::Bind(value));
|
||||
}
|
||||
return vec![Event::Changed];
|
||||
}
|
||||
let Some(m) = s.mods.installed.get(i) else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -60,3 +325,251 @@ impl OptionsUi {
|
||||
vec![Event::Changed]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A page with a heading, a switch, a number that steps either way, a key and a volume.
|
||||
struct Sample {
|
||||
on: Mutex<bool>,
|
||||
step: Mutex<i32>,
|
||||
key: Mutex<String>,
|
||||
volume: Mutex<f32>,
|
||||
}
|
||||
|
||||
impl added::Page for Sample {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.sample"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.sample.title"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::Heading("test.sample.heading"));
|
||||
rows.push(added::Row::Toggle {
|
||||
label: "test.sample.switch",
|
||||
on: *self.on.lock().unwrap(),
|
||||
});
|
||||
rows.push(added::Row::Cycle {
|
||||
label: "test.sample.step",
|
||||
value: added::Label::Value(self.step.lock().unwrap().to_string()),
|
||||
});
|
||||
rows.push(added::Row::Key {
|
||||
label: "test.sample.key",
|
||||
key: self.key.lock().unwrap().clone(),
|
||||
});
|
||||
let volume = *self.volume.lock().unwrap();
|
||||
rows.push(added::Row::Slider {
|
||||
label: "test.sample.volume",
|
||||
value: volume,
|
||||
text: added::Label::Value(format!("{}%", (volume * 100.0).round())),
|
||||
});
|
||||
}
|
||||
fn act(&self, row: usize, act: added::Act) {
|
||||
match (row, act) {
|
||||
(1, added::Act::Press) => {
|
||||
let mut on = self.on.lock().unwrap();
|
||||
*on = !*on;
|
||||
}
|
||||
(2, added::Act::Press) => *self.step.lock().unwrap() += 1,
|
||||
(2, added::Act::PressBack) => *self.step.lock().unwrap() -= 1,
|
||||
(3, added::Act::Bind(key)) => *self.key.lock().unwrap() = key,
|
||||
(4, added::Act::Slide(v)) => *self.volume.lock().unwrap() = v,
|
||||
other => panic!("row and act the page doesn't expect: {other:?}"),
|
||||
}
|
||||
}
|
||||
fn tooltip(&self, row: usize) -> Option<added::Label> {
|
||||
(row == 1).then_some(added::Label::Key("test.sample.switch.tooltip"))
|
||||
}
|
||||
}
|
||||
|
||||
static SAMPLE: Sample = Sample {
|
||||
on: Mutex::new(false),
|
||||
step: Mutex::new(0),
|
||||
key: Mutex::new(String::new()),
|
||||
volume: Mutex::new(0.0),
|
||||
};
|
||||
static PAGES: [&dyn added::Page; 1] = [&SAMPLE];
|
||||
|
||||
/// Clicks the active button that does `act`, with Shift held or not.
|
||||
fn press(ui: &mut OptionsUi, s: &mut Settings, act: Act, shift: bool) -> Vec<Event> {
|
||||
let size = (480.0, 240.0);
|
||||
let (widgets, ..) = ui.layout(s, size.0, size.1);
|
||||
let (x, y) = widgets
|
||||
.iter()
|
||||
.find_map(|w| match &w.kind {
|
||||
WKind::Button {
|
||||
act: a,
|
||||
active: true,
|
||||
..
|
||||
} if *a == act => Some((w.x + 1.0, w.y + 1.0)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no button does {act:?}"));
|
||||
ui.shift = shift;
|
||||
let atlas = super::super::tests::atlas();
|
||||
ui.click(s, &atlas, x, y, size, [480, 240], false, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_registered_page_opens_from_horizon_settings_and_hears_its_rows() {
|
||||
let mut s = Settings::default();
|
||||
TEST_PAGES.set(Some(&PAGES));
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Horizon);
|
||||
press(&mut ui, &mut s, Act::Push(Page::Added(0)), false);
|
||||
assert_eq!(ui.page(), Page::Added(0));
|
||||
// Rows: the heading (0), the switch (1), the step (2), the key (3).
|
||||
let events = press(&mut ui, &mut s, Act::PageRow(0, 1), false);
|
||||
assert_eq!(
|
||||
events.last(),
|
||||
Some(&Event::Changed),
|
||||
"the settings get saved"
|
||||
);
|
||||
assert!(*SAMPLE.on.lock().unwrap());
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 2), false);
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 2), true);
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 2), true);
|
||||
assert_eq!(*SAMPLE.step.lock().unwrap(), -1, "Shift steps back");
|
||||
// A key row waits for the next key, as on Key Binds, and the page gets its name.
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
|
||||
let atlas = super::super::tests::atlas();
|
||||
let events = ui.key(&mut s, &atlas, winit::keyboard::KeyCode::KeyZ, None);
|
||||
assert_eq!(events, [Event::Changed]);
|
||||
assert_eq!(*SAMPLE.key.lock().unwrap(), "key.keyboard.z");
|
||||
assert_eq!(ui.selected_key, None);
|
||||
// The volume is one of the screen's own sliders: dragged to its middle, then let go.
|
||||
let size = (480.0, 240.0);
|
||||
let (widgets, ..) = ui.layout(&s, size.0, size.1);
|
||||
let slider = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
w.kind,
|
||||
WKind::Slider {
|
||||
opt: Opt::Page(0, 4),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("the volume's slider");
|
||||
let (x, y) = (slider.x + slider.w / 2.0, slider.y + 1.0);
|
||||
ui.click(&mut s, &atlas, x, y, size, [480, 240], false, false);
|
||||
let volume = *SAMPLE.volume.lock().unwrap();
|
||||
assert!((0.45..0.55).contains(&volume), "{volume}");
|
||||
assert!(ui.release().contains(&Event::Changed));
|
||||
// The slider shows the page's value once let go.
|
||||
let (widgets, ..) = ui.layout(&s, size.0, size.1);
|
||||
let shown = widgets.iter().find_map(|w| match w.kind {
|
||||
WKind::Slider {
|
||||
opt: Opt::Page(0, 4),
|
||||
value,
|
||||
..
|
||||
} => Some(value),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(shown, Some(volume));
|
||||
// The page's tooltip for its switch.
|
||||
let switch = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::PageRow(0, 1),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("the switch");
|
||||
assert_eq!(
|
||||
tooltip_text(&s, switch).map(|t| t.plain()),
|
||||
Some(tr("test.sample.switch.tooltip").plain())
|
||||
);
|
||||
}
|
||||
|
||||
/// Text, a grey note, space, two half buttons (the second inactive) and a footer button.
|
||||
struct Shaped(Mutex<Vec<usize>>);
|
||||
|
||||
impl added::Page for Shaped {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.shaped"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.shaped.title"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::text("ABCDE-FGHIJ").groups());
|
||||
rows.push(added::Row::text("grey").quiet());
|
||||
rows.push(added::Row::Space(6));
|
||||
rows.push(added::Row::button("test.shaped.copy").half());
|
||||
rows.push(added::Row::button("test.shaped.stop").half().active(false));
|
||||
rows.push(added::Row::button("test.shaped.help").footer());
|
||||
}
|
||||
fn act(&self, row: usize, _: added::Act) {
|
||||
self.0.lock().unwrap().push(row);
|
||||
}
|
||||
}
|
||||
|
||||
static SHAPED: Shaped = Shaped(Mutex::new(Vec::new()));
|
||||
static SHAPED_PAGES: [&dyn added::Page; 1] = [&SHAPED];
|
||||
|
||||
#[test]
|
||||
fn half_buttons_share_a_row_and_an_inactive_one_is_never_pressed() {
|
||||
let mut s = Settings::default();
|
||||
TEST_PAGES.set(Some(&SHAPED_PAGES));
|
||||
let ui = OptionsUi::default();
|
||||
let rows = ui.added_rows(0);
|
||||
// The two texts as notes (space shows nothing here), the halves on one row, the
|
||||
// footer button below them (the settings shell has no footer of its own).
|
||||
assert!(matches!(&rows[0], Row::Note(t) if t == "ABCDE-FGHIJ"));
|
||||
assert!(matches!(&rows[1], Row::Note(t) if t == "grey"));
|
||||
let Row::Buttons(copy, Some(stop)) = &rows[2] else {
|
||||
panic!("the halves sit side by side: {:?}", rows.len());
|
||||
};
|
||||
assert_eq!((copy.w, stop.w), (150.0, 150.0));
|
||||
assert!(matches!(&rows[3], Row::Buttons(help, None) if help.w == 310.0));
|
||||
assert_eq!(rows.len(), 4);
|
||||
let mut ui = ui;
|
||||
ui.show(Page::Added(0));
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 5), false);
|
||||
let (widgets, ..) = ui.layout(&s, 480.0, 240.0);
|
||||
let stop = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::PageRow(0, 4),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("Stop is drawn");
|
||||
let atlas = super::super::tests::atlas();
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
stop.x + 1.0,
|
||||
stop.y + 1.0,
|
||||
(480.0, 240.0),
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
*SHAPED.0.lock().unwrap(),
|
||||
[3, 5],
|
||||
"the inactive half heard nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Polytone's custom colours (`horizon_polytone::colors`, `colors.json` or `color.properties`)
|
||||
//! as the engine's pack colours ([`crate::pack_palette::PackColors`]).
|
||||
use std::path::Path;
|
||||
|
||||
use horizon_polytone::colors::PackColors;
|
||||
|
||||
use crate::block_entity_render::DYES;
|
||||
use crate::pack_palette::{self, Environment};
|
||||
|
||||
/// The colours of every namespace's file under the pack overlay at `root`.
|
||||
pub fn read(root: &Path) -> pack_palette::PackColors {
|
||||
let colors = PackColors::read(root);
|
||||
let by_dye = |f: &dyn Fn(&str) -> Option<u32>| std::array::from_fn(|i| f(DYES[i].0));
|
||||
let env = colors.env;
|
||||
pack_palette::PackColors {
|
||||
dye_diffuse: by_dye(&|name| colors.dye(name, "diffuse")),
|
||||
dye_text: by_dye(&|name| colors.dye(name, "text")),
|
||||
sheep: by_dye(&|name| colors.sheep(name)),
|
||||
redstone: std::array::from_fn(|i| colors.redstone(i as u8)),
|
||||
effects: colors.effects(),
|
||||
entity_shadows: colors.entity_shadows(),
|
||||
xp_bar: colors.xp_bar(),
|
||||
env: Environment {
|
||||
powder_snow_fog: env.powder_snow_fog,
|
||||
blindness_fog_distance: env.blindness_fog_distance,
|
||||
darkness_fog_distance: env.darkness_fog_distance,
|
||||
boss_fog_distance: env.boss_fog_distance,
|
||||
water_fog_brightening: env.water_fog_brightening,
|
||||
water_fog_brightening_time: env.water_fog_brightening_time,
|
||||
horizon_height: env.horizon_height,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pack_palette::{diffuse, redstone, sheep_wool, text};
|
||||
|
||||
#[test]
|
||||
fn a_pack_recolours_only_what_it_names() {
|
||||
// Dyes no other test draws: the table is process-wide.
|
||||
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
|
||||
let polytone = dir.join("assets/obs/polytone");
|
||||
std::fs::create_dir_all(&polytone).unwrap();
|
||||
std::fs::write(
|
||||
polytone.join("colors.json"),
|
||||
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
crate::pack_palette::set(&read(&dir));
|
||||
let (cyan, pink, purple) = (9, 6, 10);
|
||||
assert_eq!(diffuse(cyan), 0x00ff00);
|
||||
assert_eq!(text(cyan), 0x0000ff);
|
||||
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
|
||||
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
|
||||
assert_eq!(sheep_wool(cyan as u8), 0x107575);
|
||||
assert_eq!(diffuse(purple), 0x8932B8);
|
||||
assert_eq!(text(purple), 0xA020F0);
|
||||
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
|
||||
crate::pack_palette::set(&Default::default());
|
||||
assert_eq!(redstone(7), None);
|
||||
assert_eq!(diffuse(cyan), 0x169C9C);
|
||||
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Polytone's item modifiers' durability bar colours, on the engine's
|
||||
//! [`crate::pack_palette::ItemBars`].
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An item modifier's `bar_color` of the `damage` type: a row of colours picked by the stack's
|
||||
/// damage, the first pixel undamaged, the last at full damage (measured on the real mod).
|
||||
struct DamageBar {
|
||||
targets: horizon_polytone::Targets,
|
||||
row: Vec<u32>,
|
||||
}
|
||||
|
||||
/// The packs' damage bars, the last read first.
|
||||
struct Bars(Vec<DamageBar>);
|
||||
|
||||
impl crate::pack_palette::ItemBars for Bars {
|
||||
fn color(&self, item: &str, damage: f32) -> Option<u32> {
|
||||
let bar = self
|
||||
.0
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| b.targets.matches(item, |_| false))?;
|
||||
let last = (bar.row.len() - 1) as f32;
|
||||
Some(bar.row[(damage.clamp(0.0, 1.0) * last).round() as usize])
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the packs' item modifiers' durability bar colours under a pack overlay root: an
|
||||
/// `<item>_bar.png` alone, or a json whose `bar_color` is `damage` with its `<name>_bar.png` (or
|
||||
/// `<name>.png`) beside it. Colormap ids and inline colormaps aren't read. `None` without any.
|
||||
pub fn read(root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
let mut bars = Vec::new();
|
||||
for namespace in std::fs::read_dir(root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let base = namespace.path().join("polytone/item_modifiers");
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if let Some(bar) = item_bar(root, &base, &path) {
|
||||
bars.push(bar);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(!bars.is_empty()).then(|| Arc::new(Bars(bars)) as _)
|
||||
}
|
||||
|
||||
fn item_bar(root: &Path, base: &Path, path: &Path) -> Option<DamageBar> {
|
||||
let name = path.file_name()?.to_str()?;
|
||||
let row = |png: &Path| {
|
||||
let (width, _, rgba) = crate::sprites::read_rgba(png).ok()?;
|
||||
let row: Vec<u32> = rgba[..width as usize * 4]
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
|
||||
.collect();
|
||||
(!row.is_empty()).then_some(row)
|
||||
};
|
||||
if let Some(stem) = name.strip_suffix("_bar.png") {
|
||||
// A json of the same name owns its png.
|
||||
if path.with_file_name(format!("{stem}.json")).exists() {
|
||||
return None;
|
||||
}
|
||||
let relative = path.parent()?.strip_prefix(base).ok()?.join(stem);
|
||||
let namespace = base.parent()?.parent()?.file_name()?.to_str()?;
|
||||
let id = format!("{namespace}:{}", relative.to_str()?.replace('\\', "/"));
|
||||
let targets = horizon_polytone::Targets::parse(None, Some(&id)).ok()?;
|
||||
return Some(DamageBar {
|
||||
targets,
|
||||
row: row(path)?,
|
||||
});
|
||||
}
|
||||
let stem = name.strip_suffix(".json")?;
|
||||
let json: serde_json::Value = horizon_world::json_file::read(path).ok()?;
|
||||
if json.get("bar_color")?.as_str()? != "damage" {
|
||||
return None;
|
||||
}
|
||||
let png = [format!("{stem}_bar.png"), format!("{stem}.png")]
|
||||
.into_iter()
|
||||
.map(|png| path.with_file_name(png))
|
||||
.find(|png| png.exists())?;
|
||||
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
|
||||
let implicit = horizon_polytone::implicit_id(&relative, "item_modifiers");
|
||||
let targets =
|
||||
horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref()).ok()?;
|
||||
Some(DamageBar {
|
||||
targets,
|
||||
row: row(&png)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn png_row(path: &Path, pixels: &[[u8; 3]]) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let file = std::fs::File::create(path).unwrap();
|
||||
let mut encoder = png::Encoder::new(file, pixels.len() as u32, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
let data: Vec<u8> = pixels
|
||||
.iter()
|
||||
.flat_map(|p| [p[0], p[1], p[2], 255])
|
||||
.collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&data)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damage_bar_picks_its_pixel_by_the_stacks_damage() {
|
||||
let dir = std::env::temp_dir().join(format!("pack-bars-{}", std::process::id()));
|
||||
let modifiers = dir.join("assets/minecraft/polytone/item_modifiers");
|
||||
let rgby = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
|
||||
png_row(&modifiers.join("diamond_pickaxe_bar.png"), &rgby);
|
||||
std::fs::write(
|
||||
modifiers.join("tools.json"),
|
||||
r#"{"targets": ["minecraft:iron_axe"], "bar_color": "damage"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
png_row(&modifiers.join("tools_bar.png"), &[[1, 2, 3], [4, 5, 6]]);
|
||||
let bars = read(&dir).unwrap();
|
||||
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
|
||||
assert_eq!(
|
||||
bars.color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
|
||||
Some(0xFF0000)
|
||||
);
|
||||
assert_eq!(
|
||||
bars.color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
|
||||
Some(0xFFFF00)
|
||||
);
|
||||
assert_eq!(bars.color("minecraft:iron_axe", 0.9), Some(0x040506));
|
||||
assert_eq!(bars.color("minecraft:diamond_shovel", 0.5), None);
|
||||
assert!(read(&dir.join("none")).is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
//! crate until the platform's executable installs it: it uses only the crate's public API, so it
|
||||
//! moves to a platform crate unchanged, and nothing else in the crate names it.
|
||||
|
||||
mod colors;
|
||||
mod custom_sky;
|
||||
mod item_bars;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
@@ -14,10 +16,22 @@ use crate::pack_hooks::PackFormats;
|
||||
pub struct Formats;
|
||||
|
||||
impl PackFormats for Formats {
|
||||
fn colors(&self, root: &Path) -> Option<crate::pack_palette::PackColors> {
|
||||
Some(colors::read(root))
|
||||
}
|
||||
|
||||
fn emissive_suffix(&self, root: &Path) -> Option<String> {
|
||||
Some(horizon_optifine::emissive::suffix(root))
|
||||
}
|
||||
|
||||
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
let sky = custom_sky::CustomSky::load(root);
|
||||
(!sky.is_empty()).then(|| Arc::new(sky) as _)
|
||||
}
|
||||
|
||||
fn item_bars(&self, root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
item_bars::read(root)
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs these formats ([`crate::pack_hooks::install`]); the game does at startup.
|
||||
|
||||
@@ -10,10 +10,26 @@ use std::sync::{Arc, OnceLock};
|
||||
/// What the pack formats beyond vanilla's add to a pack. Each method reads one overlay of a pack
|
||||
/// (its folder at `root`); the default is "nothing in this format".
|
||||
pub trait PackFormats: Send + Sync {
|
||||
/// The colours the overlay replaces where vanilla hardcodes them.
|
||||
fn colors(&self, _root: &Path) -> Option<crate::pack_palette::PackColors> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The name ending of the overlay's emissive textures (`stone_e.png` beside `stone.png`),
|
||||
/// if its format has them.
|
||||
fn emissive_suffix(&self, _root: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The sky layers the overlay draws over the vanilla sky, if it has any.
|
||||
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The durability bar colours the overlay sets by item, if any.
|
||||
fn item_bars(&self, _root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// No format beyond vanilla's.
|
||||
|
||||
@@ -1,14 +1,87 @@
|
||||
//! Polytone's dye, sheep and redstone colours (`dye.<name>.diffuse|text`, `sheep.<name>`,
|
||||
//! `redstone.<power>`), for drawing only: the pack recolours collars, tropical fish, banners, wool,
|
||||
//! sign text and redstone wire and dust, while gameplay (crafting, item data, the beacon) keeps
|
||||
//! vanilla's `DyeColor`. Lock-free: the draw paths and the mesher's workers read it per face.
|
||||
//! The colours a pack replaces where vanilla hardcodes them (Polytone's `colors.json`, read by the
|
||||
//! platform's pack formats, [`crate::pack_hooks`]): dye, sheep and redstone colours, for drawing
|
||||
//! only (the pack recolours collars, tropical fish, banners, wool, sign text and redstone wire and
|
||||
//! dust, while gameplay keeps vanilla's `DyeColor`), mob effect colours, the experience number's,
|
||||
//! entity shadows, the effect fogs' settings and durability bar colours. Lock-free: the draw paths
|
||||
//! and the mesher's workers read the dye, sheep and redstone tables per face.
|
||||
use std::path::Path;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::block_entity_render::DYES;
|
||||
use crate::signs::DYE_TEXT;
|
||||
use horizon_polytone::colors::PackColors;
|
||||
|
||||
/// The fog settings vanilla hardcodes, as the packs set them (vanilla's by default).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Environment {
|
||||
/// The fog colour inside powder snow (RGB).
|
||||
pub powder_snow_fog: Option<u32>,
|
||||
/// The fog ends of Blindness (5), Darkness (15) and bosses that add world fog (96).
|
||||
pub blindness_fog_distance: f32,
|
||||
pub darkness_fog_distance: f32,
|
||||
pub boss_fog_distance: f32,
|
||||
/// How much the underwater fog colour washes out (1 vanilla, 0 none).
|
||||
pub water_fog_brightening: f32,
|
||||
/// The ticks under water it takes (vanilla's 600).
|
||||
pub water_fog_brightening_time: f32,
|
||||
/// Below it the sky draws its dark disc (vanilla's 63; flat worlds keep their floor).
|
||||
pub horizon_height: f32,
|
||||
}
|
||||
|
||||
impl Default for Environment {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
powder_snow_fog: None,
|
||||
blindness_fog_distance: 5.0,
|
||||
darkness_fog_distance: 15.0,
|
||||
boss_fog_distance: 96.0,
|
||||
water_fog_brightening: 1.0,
|
||||
water_fog_brightening_time: 600.0,
|
||||
horizon_height: 63.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The packs' colours; `None` (and the defaults) where they keep vanilla's.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PackColors {
|
||||
/// By dye id (`DyeColor` order): `getTextureDiffuseColor`, `getTextColor`, the sheep's wool.
|
||||
pub dye_diffuse: [Option<u32>; 16],
|
||||
pub dye_text: [Option<u32>; 16],
|
||||
pub sheep: [Option<u32>; 16],
|
||||
/// `RedStoneWireBlock.COLORS` by power.
|
||||
pub redstone: [Option<u32>; 16],
|
||||
/// Mob effects' colours, by effect id without vanilla's namespace.
|
||||
pub effects: Vec<(String, u32)>,
|
||||
/// False hides every entity's round shadow, whatever the video option says.
|
||||
pub entity_shadows: bool,
|
||||
/// The experience level number's colour and outline.
|
||||
pub xp_bar: [Option<u32>; 2],
|
||||
/// The effect fogs' settings: the fog reads them every frame.
|
||||
pub env: Environment,
|
||||
}
|
||||
|
||||
impl Default for PackColors {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dye_diffuse: [None; 16],
|
||||
dye_text: [None; 16],
|
||||
sheep: [None; 16],
|
||||
redstone: [None; 16],
|
||||
effects: Vec::new(),
|
||||
entity_shadows: true,
|
||||
xp_bar: [None; 2],
|
||||
env: Environment::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PackColors {
|
||||
/// Whether entities draw their round shadow, as far as the packs say.
|
||||
pub fn entity_shadows(&self) -> bool {
|
||||
self.entity_shadows
|
||||
}
|
||||
}
|
||||
|
||||
/// 0 is unset, else `1 << 32 | rgb`.
|
||||
static DIFFUSE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
@@ -16,6 +89,30 @@ static TEXT: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
static SHEEP: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
static REDSTONE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
|
||||
/// The colours of the pack overlay at `root`, from the installed pack formats; their dye, sheep
|
||||
/// and redstone colours go to the draw paths' tables ([`set`]).
|
||||
pub fn read(root: &Path) -> PackColors {
|
||||
let formats = crate::pack_hooks::formats();
|
||||
let colors = formats.colors(root).unwrap_or_default();
|
||||
set(&colors);
|
||||
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = formats.item_bars(root);
|
||||
colors
|
||||
}
|
||||
|
||||
/// The packs' durability bar colours by item (Polytone's item modifiers' `bar_color`).
|
||||
pub trait ItemBars: Send + Sync {
|
||||
/// The bar colour (RGB) of `item` at this damage (0 to 1), when a pack sets one.
|
||||
fn color(&self, item: &str, damage: f32) -> Option<u32>;
|
||||
}
|
||||
|
||||
static ITEM_BARS: RwLock<Option<Arc<dyn ItemBars>>> = RwLock::new(None);
|
||||
|
||||
/// A pack's durability bar colour (RGB) for an item at this damage (0 to 1).
|
||||
pub fn item_bar_color(item: &str, damage: f32) -> Option<u32> {
|
||||
let bars = ITEM_BARS.read().unwrap_or_else(|e| e.into_inner());
|
||||
bars.as_ref()?.color(item, damage)
|
||||
}
|
||||
|
||||
/// Takes the colours of the packs just loaded (vanilla's where they set none).
|
||||
pub fn set(colors: &PackColors) {
|
||||
let store = |table: &[AtomicU64; 16], i: usize, color: Option<u32>| {
|
||||
@@ -24,129 +121,39 @@ pub fn set(colors: &PackColors) {
|
||||
Relaxed,
|
||||
);
|
||||
};
|
||||
for (i, (name, _)) in DYES.iter().enumerate() {
|
||||
store(&DIFFUSE, i, colors.dye(name, "diffuse"));
|
||||
store(&TEXT, i, colors.dye(name, "text"));
|
||||
store(&SHEEP, i, colors.sheep(name));
|
||||
store(&REDSTONE, i, colors.redstone(i as u8));
|
||||
for i in 0..16 {
|
||||
store(&DIFFUSE, i, colors.dye_diffuse[i]);
|
||||
store(&TEXT, i, colors.dye_text[i]);
|
||||
store(&SHEEP, i, colors.sheep[i]);
|
||||
store(&REDSTONE, i, colors.redstone[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/// An item modifier's `bar_color` of the `damage` type: a row of colours picked by the stack's
|
||||
/// damage, the first pixel undamaged, the last at full damage (measured on the real mod).
|
||||
struct DamageBar {
|
||||
targets: horizon_polytone::Targets,
|
||||
row: Vec<u32>,
|
||||
}
|
||||
static ITEM_BARS: RwLock<Vec<DamageBar>> = RwLock::new(Vec::new());
|
||||
|
||||
/// Reads the packs' item modifiers' durability bar colours under a pack overlay root: an
|
||||
/// `<item>_bar.png` alone, or a json whose `bar_color` is `damage` with its `<name>_bar.png` (or
|
||||
/// `<name>.png`) beside it. Colormap ids and inline colormaps aren't read.
|
||||
pub fn set_item_bars(root: &Path) {
|
||||
let mut bars = Vec::new();
|
||||
for namespace in std::fs::read_dir(root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let base = namespace.path().join("polytone/item_modifiers");
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if let Some(bar) = item_bar(root, &base, &path) {
|
||||
bars.push(bar);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = bars;
|
||||
}
|
||||
|
||||
fn item_bar(root: &Path, base: &Path, path: &Path) -> Option<DamageBar> {
|
||||
let name = path.file_name()?.to_str()?;
|
||||
let row = |png: &Path| {
|
||||
let (width, _, rgba) = crate::sprites::read_rgba(png).ok()?;
|
||||
let row: Vec<u32> = rgba[..width as usize * 4]
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
|
||||
.collect();
|
||||
(!row.is_empty()).then_some(row)
|
||||
};
|
||||
if let Some(stem) = name.strip_suffix("_bar.png") {
|
||||
// A json of the same name owns its png.
|
||||
if path.with_file_name(format!("{stem}.json")).exists() {
|
||||
return None;
|
||||
}
|
||||
let relative = path.parent()?.strip_prefix(base).ok()?.join(stem);
|
||||
let namespace = base.parent()?.parent()?.file_name()?.to_str()?;
|
||||
let id = format!("{namespace}:{}", relative.to_str()?.replace('\\', "/"));
|
||||
let targets = horizon_polytone::Targets::parse(None, Some(&id)).ok()?;
|
||||
return Some(DamageBar {
|
||||
targets,
|
||||
row: row(path)?,
|
||||
});
|
||||
}
|
||||
let stem = name.strip_suffix(".json")?;
|
||||
let json: serde_json::Value = horizon_world::json_file::read(path).ok()?;
|
||||
if json.get("bar_color")?.as_str()? != "damage" {
|
||||
return None;
|
||||
}
|
||||
let png = [format!("{stem}_bar.png"), format!("{stem}.png")]
|
||||
.into_iter()
|
||||
.map(|png| path.with_file_name(png))
|
||||
.find(|png| png.exists())?;
|
||||
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
|
||||
let implicit = horizon_polytone::implicit_id(&relative, "item_modifiers");
|
||||
let targets =
|
||||
horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref()).ok()?;
|
||||
Some(DamageBar {
|
||||
targets,
|
||||
row: row(&png)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// A pack's durability bar colour (RGB) for an item at this damage (0 to 1).
|
||||
pub fn item_bar_color(item: &str, damage: f32) -> Option<u32> {
|
||||
let bars = ITEM_BARS.read().unwrap_or_else(|e| e.into_inner());
|
||||
let bar = bars
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| b.targets.matches(item, |_| false))?;
|
||||
let last = (bar.row.len() - 1) as f32;
|
||||
Some(bar.row[(damage.clamp(0.0, 1.0) * last).round() as usize])
|
||||
}
|
||||
|
||||
fn read(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
|
||||
fn get(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
|
||||
let v = table[i & 15].load(Relaxed);
|
||||
(v != 0).then_some(v as u32)
|
||||
}
|
||||
|
||||
/// `DyeColor.getTextureDiffuseColor` (RGB) as the packs draw it.
|
||||
pub fn diffuse(dye: usize) -> u32 {
|
||||
read(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
|
||||
get(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
|
||||
}
|
||||
|
||||
/// `DyeColor.getTextColor` (RGB) as the packs draw it.
|
||||
pub fn text(dye: usize) -> u32 {
|
||||
read(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
|
||||
get(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
|
||||
}
|
||||
|
||||
/// A pack's `RedStoneWireBlock.COLORS` entry for this power (RGB), when it sets one.
|
||||
pub fn redstone(power: u8) -> Option<u32> {
|
||||
read(&REDSTONE, usize::from(power))
|
||||
get(&REDSTONE, usize::from(power))
|
||||
}
|
||||
|
||||
/// `ColorLerper.Type.SHEEP` wool colour: a pack's `sheep.<name>`, else white 0xE6E6E6 and the
|
||||
/// others 75% of vanilla's dye (a pack's `dye.*.diffuse` doesn't reach wool).
|
||||
pub fn sheep_wool(dye: u8) -> u32 {
|
||||
let i = usize::from(dye & 15);
|
||||
if let Some(color) = read(&SHEEP, i) {
|
||||
if let Some(color) = get(&SHEEP, i) {
|
||||
return color;
|
||||
}
|
||||
if i == 0 {
|
||||
@@ -156,81 +163,3 @@ pub fn sheep_wool(dye: u8) -> u32 {
|
||||
let f = |c: u32| ((c as f32 * 0.75).floor() as u32).min(255);
|
||||
f(src >> 16 & 255) << 16 | f(src >> 8 & 255) << 8 | f(src & 255)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_pack_recolours_only_what_it_names() {
|
||||
// Dyes no other test draws: the table is process-wide.
|
||||
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
|
||||
let polytone = dir.join("assets/obs/polytone");
|
||||
std::fs::create_dir_all(&polytone).unwrap();
|
||||
std::fs::write(
|
||||
polytone.join("colors.json"),
|
||||
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
set(&PackColors::read(&dir));
|
||||
let (cyan, pink, purple) = (9, 6, 10);
|
||||
assert_eq!(diffuse(cyan), 0x00ff00);
|
||||
assert_eq!(text(cyan), 0x0000ff);
|
||||
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
|
||||
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
|
||||
assert_eq!(sheep_wool(cyan as u8), 0x107575);
|
||||
assert_eq!(diffuse(purple), 0x8932B8);
|
||||
assert_eq!(text(purple), 0xA020F0);
|
||||
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
|
||||
set(&PackColors::default());
|
||||
assert_eq!(redstone(7), None);
|
||||
assert_eq!(diffuse(cyan), 0x169C9C);
|
||||
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
fn png_row(path: &Path, pixels: &[[u8; 3]]) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let file = std::fs::File::create(path).unwrap();
|
||||
let mut encoder = png::Encoder::new(file, pixels.len() as u32, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
let data: Vec<u8> = pixels
|
||||
.iter()
|
||||
.flat_map(|p| [p[0], p[1], p[2], 255])
|
||||
.collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&data)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damage_bar_picks_its_pixel_by_the_stacks_damage() {
|
||||
let dir = std::env::temp_dir().join(format!("pack-bars-{}", std::process::id()));
|
||||
let modifiers = dir.join("assets/minecraft/polytone/item_modifiers");
|
||||
let rgby = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
|
||||
png_row(&modifiers.join("diamond_pickaxe_bar.png"), &rgby);
|
||||
std::fs::write(
|
||||
modifiers.join("tools.json"),
|
||||
r#"{"targets": ["minecraft:iron_axe"], "bar_color": "damage"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
png_row(&modifiers.join("tools_bar.png"), &[[1, 2, 3], [4, 5, 6]]);
|
||||
set_item_bars(&dir);
|
||||
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
|
||||
assert_eq!(
|
||||
item_bar_color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
|
||||
Some(0xFF0000)
|
||||
);
|
||||
assert_eq!(
|
||||
item_bar_color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
|
||||
Some(0xFFFF00)
|
||||
);
|
||||
assert_eq!(item_bar_color("minecraft:iron_axe", 0.9), Some(0x040506));
|
||||
assert_eq!(item_bar_color("minecraft:diamond_shovel", 0.5), None);
|
||||
set_item_bars(&dir.join("none"));
|
||||
assert_eq!(item_bar_color("minecraft:diamond_pickaxe", 0.5), None);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ mod native;
|
||||
pub mod polytone;
|
||||
mod polytone_gpu;
|
||||
mod polytone_model;
|
||||
mod spawn;
|
||||
pub use native::{ParticleController, ParticleUpdate};
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
@@ -2026,73 +2027,6 @@ impl ParticleEngine {
|
||||
(eye[0] - pos[0]).powi(2) + (eye[1] - pos[1]).powi(2) + (eye[2] - pos[2]).powi(2) <= 9.0
|
||||
})
|
||||
}
|
||||
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
|
||||
pub fn send_particles(
|
||||
&mut self,
|
||||
options: &ParticleOptions,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: u32,
|
||||
) {
|
||||
if count == 0 {
|
||||
let v = [speed * spread[0], speed * spread[1], speed * spread[2]];
|
||||
self.add(options, at, v);
|
||||
return;
|
||||
}
|
||||
for _ in 0..count {
|
||||
let r = &mut self.random;
|
||||
let p = [
|
||||
at[0] + r.next_gaussian() * spread[0],
|
||||
at[1] + r.next_gaussian() * spread[1],
|
||||
at[2] + r.next_gaussian() * spread[2],
|
||||
];
|
||||
let v = [
|
||||
r.next_gaussian() * speed,
|
||||
r.next_gaussian() * speed,
|
||||
r.next_gaussian() * speed,
|
||||
];
|
||||
self.add(options, p, v);
|
||||
}
|
||||
}
|
||||
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
|
||||
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
|
||||
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
|
||||
if let Some(requests) = &mut self.created_requests {
|
||||
requests.push((options.clone(), p, v));
|
||||
return;
|
||||
}
|
||||
let seed = self.seeds.next_long();
|
||||
if let Some(mut particle) = self.make(options, p, v, seed) {
|
||||
*self.made.entry(options.ty.name()).or_default() += 1;
|
||||
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
|
||||
if let Some(registry) = &self.polytone
|
||||
&& registry.modifies_particles()
|
||||
{
|
||||
let id = match &options.mod_particle {
|
||||
Some(name) => name.clone(),
|
||||
None => format!("minecraft:{}", options.ty.name()),
|
||||
};
|
||||
polytone::modify(registry, &id, options.source.as_deref(), &mut particle);
|
||||
}
|
||||
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
|
||||
// shortcut: counts live plus queued particles by scanning instead of
|
||||
// vanilla's counted map; use a counter if spore spawning shows in profiles.
|
||||
if particle.ty == ParticleType::SporeBlossomAir
|
||||
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
|
||||
&& self
|
||||
.particles
|
||||
.iter()
|
||||
.chain(self.pending.iter())
|
||||
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
|
||||
.count()
|
||||
>= SPORE_BLOSSOM_LIMIT
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.pending.push(particle);
|
||||
}
|
||||
}
|
||||
/// How many particles of each type were made so far.
|
||||
pub fn made(&self) -> &std::collections::BTreeMap<&'static str, u64> {
|
||||
&self.made
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Where particles come into the engine: a server's particle packet and a single particle made
|
||||
//! where the game asks for one.
|
||||
use super::*;
|
||||
|
||||
impl ParticleEngine {
|
||||
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
|
||||
pub fn send_particles(
|
||||
&mut self,
|
||||
options: &ParticleOptions,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: u32,
|
||||
) {
|
||||
if count == 0 {
|
||||
let v = [speed * spread[0], speed * spread[1], speed * spread[2]];
|
||||
self.add(options, at, v);
|
||||
return;
|
||||
}
|
||||
for _ in 0..count {
|
||||
let r = &mut self.random;
|
||||
let p = [
|
||||
at[0] + r.next_gaussian() * spread[0],
|
||||
at[1] + r.next_gaussian() * spread[1],
|
||||
at[2] + r.next_gaussian() * spread[2],
|
||||
];
|
||||
let v = [
|
||||
r.next_gaussian() * speed,
|
||||
r.next_gaussian() * speed,
|
||||
r.next_gaussian() * speed,
|
||||
];
|
||||
self.add(options, p, v);
|
||||
}
|
||||
}
|
||||
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
|
||||
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
|
||||
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
|
||||
if let Some(requests) = &mut self.created_requests {
|
||||
requests.push((options.clone(), p, v));
|
||||
return;
|
||||
}
|
||||
let seed = self.seeds.next_long();
|
||||
if let Some(mut particle) = self.make(options, p, v, seed) {
|
||||
*self.made.entry(options.ty.name()).or_default() += 1;
|
||||
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
|
||||
if let Some(registry) = &self.polytone
|
||||
&& registry.modifies_particles()
|
||||
{
|
||||
let id = match &options.mod_particle {
|
||||
Some(name) => name.clone(),
|
||||
None => format!("minecraft:{}", options.ty.name()),
|
||||
};
|
||||
polytone::modify(registry, &id, options.source.as_deref(), &mut particle);
|
||||
}
|
||||
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
|
||||
// shortcut: counts live plus queued particles by scanning instead of
|
||||
// vanilla's counted map; use a counter if spore spawning shows in profiles.
|
||||
if particle.ty == ParticleType::SporeBlossomAir
|
||||
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
|
||||
&& self
|
||||
.particles
|
||||
.iter()
|
||||
.chain(self.pending.iter())
|
||||
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
|
||||
.count()
|
||||
>= SPORE_BLOSSOM_LIMIT
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.pending.push(particle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -864,6 +864,8 @@ pub enum Opt {
|
||||
PadGameplayGuide,
|
||||
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
|
||||
ModelPart(usize),
|
||||
/// A slider of a page a crate registered (`horizon_hooks::pages`): (page, row).
|
||||
Page(usize, usize),
|
||||
/// 3D skin layers: the player's outer layer as extruded slabs.
|
||||
SkinLayers3d,
|
||||
MainHand,
|
||||
|
||||
@@ -237,17 +237,17 @@ impl<P: Copy, S: Copy> Lines<P, S> {
|
||||
pub(crate) fn backdrop(batch: &mut HudBatch, atlas: &HudAtlas, in_world: bool) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
style::fill(batch, atlas, [0.0, 0.0, w, h], style::veil());
|
||||
style::fill(batch, atlas, [0.0, 0.0, w, h], style::VEIL);
|
||||
}
|
||||
|
||||
/// The mark and the screen's name over the search field.
|
||||
pub(crate) fn mark(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, title: &str) {
|
||||
let (x, y) = (cols.side_x, cols.side_y);
|
||||
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::accent());
|
||||
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::ACCENT);
|
||||
let horizon = Text::literal("Horizon");
|
||||
batch.text_styled(atlas, &horizon, x + 10.0, y, style::WHITE, true);
|
||||
let title = Text::literal(title);
|
||||
batch.text_styled(atlas, &title, x, y + 10.0, style::muted(), true);
|
||||
batch.text_styled(atlas, &title, x, y + 10.0, style::MUTED, true);
|
||||
}
|
||||
|
||||
/// A page in the page list: the open page on a plate with the accent's bar beside it, the
|
||||
@@ -273,13 +273,13 @@ pub(crate) fn page_tab(
|
||||
crate::widgets::target_as(rect, crate::widgets::Kind::Tab(open));
|
||||
}
|
||||
if open {
|
||||
style::fill(batch, atlas, rect, style::open());
|
||||
style::fill(batch, atlas, [px, py, 2.0, ph], style::accent());
|
||||
style::fill(batch, atlas, rect, style::OPEN);
|
||||
style::fill(batch, atlas, [px, py, 2.0, ph], style::ACCENT);
|
||||
}
|
||||
let color = match (open || hover, has) {
|
||||
(true, _) => style::WHITE,
|
||||
(false, true) => style::soft(),
|
||||
(false, false) => style::muted(),
|
||||
(false, true) => style::SOFT,
|
||||
(false, false) => style::MUTED,
|
||||
};
|
||||
let ty = py + ((ph - 8.0) / 2.0).floor();
|
||||
let name = Text::literal(style::fit(atlas, name, pw - 12.0));
|
||||
@@ -312,12 +312,12 @@ pub(crate) fn search_field(
|
||||
search.draw(batch, atlas, None);
|
||||
if search.value.is_empty() && !focused {
|
||||
let hint = Text::literal(search.message.clone());
|
||||
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::muted(), true);
|
||||
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::MUTED, true);
|
||||
}
|
||||
if !search.value.is_empty() {
|
||||
let hover = ui::hovered(cursor, clear[0], clear[1], clear[2], clear[3]);
|
||||
crate::widgets::widget_cursor(hover, true);
|
||||
let color = if hover { style::WHITE } else { style::muted() };
|
||||
let color = if hover { style::WHITE } else { style::MUTED };
|
||||
let cy = y + ((h - 8.0) / 2.0).floor();
|
||||
ui::centered_text(
|
||||
batch,
|
||||
@@ -344,7 +344,7 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
|
||||
batch,
|
||||
atlas,
|
||||
[px + 8.0, py + 24.0, pw - 16.0, 1.0],
|
||||
style::head_rule(),
|
||||
style::HEAD_RULE,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -383,9 +383,9 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
}
|
||||
let Some(setting) = line.setting else {
|
||||
let (name, color, dy) = match line.group {
|
||||
Some(("", _)) => (page_name(line.page), style::muted(), 3.0),
|
||||
Some((name, _)) => (lang::get(name), style::muted(), 3.0),
|
||||
None => (page_name(line.page), style::accent(), 4.0),
|
||||
Some(("", _)) => (page_name(line.page), style::MUTED, 3.0),
|
||||
Some((name, _)) => (lang::get(name), style::MUTED, 3.0),
|
||||
None => (page_name(line.page), style::ACCENT, 4.0),
|
||||
};
|
||||
let name = Text::literal(name);
|
||||
batch.text_styled(atlas, &name, cols.list_x + 2.0, y + dy, color, true);
|
||||
@@ -393,7 +393,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
};
|
||||
if !after_name {
|
||||
let rule = [cols.list_x + 8.0, y, cols.list_w - 16.0, 1.0];
|
||||
style::fill(batch, atlas, rule, style::row_rule());
|
||||
style::fill(batch, atlas, rule, style::ROW_RULE);
|
||||
}
|
||||
row(batch, setting, y);
|
||||
}
|
||||
@@ -404,7 +404,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
&none,
|
||||
cols.list_x + 2.0,
|
||||
cols.top + 4.0,
|
||||
style::muted(),
|
||||
style::MUTED,
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -499,7 +499,7 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
|
||||
let active = live && (hover || focused || held);
|
||||
if active {
|
||||
let lit = [rect[0] + 2.0, y + 1.0, rect[2] - 4.0, style::ROW - 2.0];
|
||||
style::fill(batch, atlas, lit, style::hover());
|
||||
style::fill(batch, atlas, lit, style::HOVER);
|
||||
}
|
||||
crate::widgets::widget_cursor(hover, live);
|
||||
// The control socket's widget tree, the narrator and a controller's focus.
|
||||
@@ -523,8 +523,8 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
|
||||
if live {
|
||||
crate::widgets::target_as(rect, kind);
|
||||
}
|
||||
let color = if live { style::WHITE } else { style::muted() };
|
||||
let soft = if live { style::soft() } else { style::muted() };
|
||||
let color = if live { style::WHITE } else { style::MUTED };
|
||||
let soft = if live { style::SOFT } else { style::MUTED };
|
||||
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
|
||||
let lit = live && (hover || held);
|
||||
let left = rect[0] + style::INSET;
|
||||
@@ -611,7 +611,7 @@ fn lit(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, _]: [f32; 4]) {
|
||||
batch,
|
||||
atlas,
|
||||
[x + 2.0, y + 1.0, w - 4.0, style::ROW - 2.0],
|
||||
style::hover(),
|
||||
style::HOVER,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -644,7 +644,7 @@ fn read_row(
|
||||
let room = rect[2] - 2.0 * style::INSET - name_w - 8.0;
|
||||
let value = style::fit(atlas, value, room);
|
||||
let vx = rect[0] + rect[2] - style::INSET - atlas.font.width(&value);
|
||||
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::soft(), true);
|
||||
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::SOFT, true);
|
||||
if focused {
|
||||
style::ring(
|
||||
batch,
|
||||
@@ -749,7 +749,7 @@ fn pad_row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32, view:
|
||||
batch.text_styled(atlas, &yellow(close), right.ceil(), ty, style::WHITE, true);
|
||||
}
|
||||
let left = rect[0] + style::INSET;
|
||||
let color = if live { style::WHITE } else { style::muted() };
|
||||
let color = if live { style::WHITE } else { style::MUTED };
|
||||
let shown = Text::literal(style::fit(atlas, name, chip[0] - 6.0 - left));
|
||||
batch.text_styled(atlas, &shown, left, ty, color, true);
|
||||
if focused {
|
||||
@@ -799,7 +799,7 @@ fn side_lines(
|
||||
];
|
||||
crate::widgets::record(line, rect, false);
|
||||
let shown = Text::literal(style::fit(atlas, line, rect[2]));
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
|
||||
}
|
||||
}
|
||||
/// A line under the page list: its inset, and its height.
|
||||
@@ -881,7 +881,7 @@ fn heading(batch: &mut HudBatch, atlas: &HudAtlas, text: &str, [x, y, w, _]: [f3
|
||||
let rect = [x + SIDE_INSET, y + HEADING_Y, w - 2.0 * SIDE_INSET, 8.0];
|
||||
crate::widgets::record(text, rect, false);
|
||||
let shown = Text::literal(style::fit(atlas, text, rect[2]));
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
|
||||
}
|
||||
|
||||
/// Vanilla's scroller, inside the panel's right edge, sized as `ui::Scroll` drags it.
|
||||
@@ -1896,8 +1896,8 @@ impl<S: Screen> Shell<S> {
|
||||
// The row's page is open: lit as the list's open page is.
|
||||
if list.lit == Some(*shown) {
|
||||
let row @ [x, y, _, h] = strip.area;
|
||||
style::fill(batch, atlas, row, style::open());
|
||||
style::fill(batch, atlas, [x, y, 2.0, h], style::accent());
|
||||
style::fill(batch, atlas, row, style::OPEN);
|
||||
style::fill(batch, atlas, [x, y, 2.0, h], style::ACCENT);
|
||||
}
|
||||
tabs::draw(batch, atlas, strip);
|
||||
}
|
||||
@@ -2134,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,15 +119,17 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
|
||||
# cosmetics and emotes, the "Horizon player" presence, the official server added to
|
||||
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. 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 = ["discord", "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) and joining one.
|
||||
friends = ["horizon-client-lib/friends", "dep:horizon-friends"]
|
||||
# Play with Friends: sharing a world behind an invite code (game/friends.rs). Joining one is
|
||||
# horizon-friends' `game` side, which horizon-official registers (`horizon_hooks::tunnel`).
|
||||
friends = ["dep:horizon-friends"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
|
||||
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
|
||||
|
||||
@@ -441,6 +441,7 @@
|
||||
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
|
||||
"Your display's reported peak is often too high: calibrate to set it up.": "Your display's reported peak is often too high: calibrate to set it up.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
|
||||
|
||||
@@ -17,15 +17,15 @@ 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_colors, polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit,
|
||||
providers, pvp, pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask,
|
||||
server_terrain, settings, shader_options, shader_pack_list, signs, skins, social, sound_engine,
|
||||
sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites,
|
||||
streaming, structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
|
||||
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
|
||||
pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain,
|
||||
settings, shader_options, shader_pack_list, signs, skins, social, sound_engine, sound_events,
|
||||
sound_preview, spans, split_layout, split_map, split_profiles, sprites, streaming,
|
||||
structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
|
||||
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
@@ -70,7 +70,6 @@ pub mod falling_block;
|
||||
pub mod fire_render;
|
||||
pub mod first_person;
|
||||
pub mod flatgen;
|
||||
pub mod friends_screen;
|
||||
pub mod furnace;
|
||||
pub mod game_mode_switcher;
|
||||
pub mod game_rules_screen;
|
||||
@@ -103,6 +102,8 @@ pub mod native_data;
|
||||
pub mod net;
|
||||
pub mod nether_portal;
|
||||
pub mod no_resources;
|
||||
#[cfg(feature = "horizon-extras")]
|
||||
pub mod official_servers;
|
||||
pub mod offline_name;
|
||||
pub mod old_world;
|
||||
pub mod osk;
|
||||
|
||||
@@ -84,7 +84,7 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
|
||||
mod fluids;
|
||||
mod item_fall;
|
||||
mod item_no_physics;
|
||||
pub use fluids::{fluid_flow, update_entity_fluids, water_clip};
|
||||
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
|
||||
|
||||
#[cfg(test)]
|
||||
mod drop_motion_tests {
|
||||
|
||||
@@ -45,6 +45,30 @@ pub fn fluid_flow(world: &World, fluids: &FluidResources, pos: [i32; 3]) -> [f64
|
||||
let flow = flow_at(&mut scene, state, at).unwrap_or(Vec3::ZERO);
|
||||
[flow.x, flow.y, flow.z]
|
||||
}
|
||||
/// `Entity.move`'s fall-reset ray along a move from `from`, up to 8 blocks:
|
||||
/// `ClipContext.Fluid.WATER` (the water's shape) and
|
||||
/// `ClipContext.Block.FALLDAMAGE_RESETTING` (a block `tagged` says is a reset
|
||||
/// block clips as a full block). Both shapes are queried for every visited
|
||||
/// block, the water's first (its first query fixes the shared cached shape);
|
||||
/// either one hitting ends the ray.
|
||||
pub fn fall_reset_hit(
|
||||
world: &World,
|
||||
fluids: &FluidResources,
|
||||
tagged: impl Fn(BlockPos) -> bool,
|
||||
from: Vec3,
|
||||
moved: Vec3,
|
||||
) -> bool {
|
||||
let to = from.add_vector(moved.normalize().scale(moved.length().min(8.0)));
|
||||
let hit = horizon_core::geometry::traverse_blocks(from, to, 1_000_000, |p| {
|
||||
let water = water_clip(world, fluids, from, to, p);
|
||||
let reset = tagged(p)
|
||||
&& horizon_core::geometry::VoxelShape::block()
|
||||
.clip(from, to, p)
|
||||
.is_some();
|
||||
(water || reset).then_some(())
|
||||
});
|
||||
matches!(hit, Ok(Some(())))
|
||||
}
|
||||
/// `ClipContext.Fluid.WATER` in a fall-reset ray through `p`: the water's
|
||||
/// shape from the shared `FlowingFluid.shapes` cache (the first query of a
|
||||
/// fluid state fixes its height), as the player's own ray reads it.
|
||||
|
||||
@@ -233,7 +233,7 @@ fn is_animal(id: &str) -> bool {
|
||||
use crate::mobs::persistent_anger;
|
||||
/// The single player's UUID (`offline_uuid("Player")`): the entity the
|
||||
/// neutral mobs are angry at.
|
||||
fn player_uuid() -> [i32; 4] {
|
||||
pub(crate) fn player_uuid() -> [i32; 4] {
|
||||
let u = horizon_protocol::crypto::offline_uuid("Player");
|
||||
[
|
||||
(u >> 96) as u32 as i32,
|
||||
@@ -1297,7 +1297,7 @@ pub fn mob_to_nbt(
|
||||
crate::mobs::nautilus::to_nbt(m, map);
|
||||
}
|
||||
"minecraft:sulfur_cube" => crate::mobs::sulfur_cube::to_nbt(m, map),
|
||||
"minecraft:bee" => crate::mobs::bee::to_nbt(m, map),
|
||||
"minecraft:bee" => crate::mobs::bee::to_nbt(m, map, &mut uuid_of),
|
||||
"minecraft:sniffer" => crate::mobs::sniffer::to_nbt(m, map),
|
||||
"minecraft:wither" => {
|
||||
map.insert("Invul".into(), Tag::Int(m.extra.counter));
|
||||
@@ -2666,9 +2666,9 @@ pub fn mob_from_nbt_with_random(
|
||||
m.extra.counter = number(tag.get("still_timeout")).unwrap_or(0) as i32;
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Shulker {
|
||||
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
|
||||
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
|
||||
m.extra.color = number(tag.get("Color")).map_or(16, |c| c.clamp(0, 16) as u8);
|
||||
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) % 6).unsigned_abs() as u8;
|
||||
m.extra.counter = number(tag.get("Peek")).map_or(0, |v| v as i8 as i32);
|
||||
m.extra.color = number(tag.get("Color")).map_or(16, |v| v as i8 as u8);
|
||||
m.anim.v[0] = m.extra.counter as f32 * 0.01;
|
||||
m.anim.vo[0] = m.anim.v[0];
|
||||
// Shulker.setPos: square on its block.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! HUD boxes in the corners: the minimap's place and mods' panels, small
|
||||
//! boxes in GUI pixels stacked per corner (`docs/extras.md`).
|
||||
//! boxes in GUI pixels stacked per corner (`docs/extras.md`), and the layers crates above the
|
||||
//! engine register (`horizon_hooks::hud`).
|
||||
use crate::{
|
||||
extras::Corner,
|
||||
hud::{HudAtlas, HudBatch, Rect, rgba},
|
||||
};
|
||||
use horizon_hooks::hud;
|
||||
|
||||
/// The gap between an overlay and the screen edge or the next overlay.
|
||||
const MARGIN: f32 = 2.0;
|
||||
@@ -125,6 +127,68 @@ pub fn draw_lines(
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the HUD layers crates registered, after the extras' and mods' boxes, which they stack
|
||||
/// with. `shown`: whether each corner of `Corner::ALL` shows (the debug screen covers some).
|
||||
pub fn draw_layers(
|
||||
layers: &[&dyn hud::Layer],
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
corners: &mut Corners,
|
||||
shown: [bool; 4],
|
||||
) {
|
||||
let mut canvas = Canvas {
|
||||
batch,
|
||||
atlas,
|
||||
corners,
|
||||
shown,
|
||||
};
|
||||
for layer in layers {
|
||||
layer.draw(&mut canvas);
|
||||
}
|
||||
}
|
||||
|
||||
struct Canvas<'a> {
|
||||
batch: &'a mut HudBatch,
|
||||
atlas: &'a HudAtlas,
|
||||
corners: &'a mut Corners,
|
||||
shown: [bool; 4],
|
||||
}
|
||||
|
||||
/// `0xAARRGGBB` as the batch's colour.
|
||||
fn argb_color(argb: u32) -> u32 {
|
||||
let [a, r, g, b] = argb.to_be_bytes();
|
||||
rgba(r, g, b, a)
|
||||
}
|
||||
|
||||
impl hud::Canvas for Canvas<'_> {
|
||||
fn size(&self) -> (f32, f32) {
|
||||
(self.batch.width, self.batch.height)
|
||||
}
|
||||
fn tr(&self, key: &str) -> String {
|
||||
crate::lang::get(key)
|
||||
}
|
||||
fn text_width(&self, text: &str) -> f32 {
|
||||
HudBatch::text_width(self.atlas, text)
|
||||
}
|
||||
fn text(&mut self, x: f32, y: f32, text: &str, argb: u32, shadow: bool) {
|
||||
self.batch
|
||||
.text(self.atlas, text, x, y, argb_color(argb), shadow);
|
||||
}
|
||||
fn fill(&mut self, x: f32, y: f32, w: f32, h: f32, argb: u32) {
|
||||
self.batch.fill(self.atlas, x, y, w, h, argb_color(argb));
|
||||
}
|
||||
fn place(&mut self, corner: hud::Corner, w: f32, h: f32) -> Option<(f32, f32)> {
|
||||
let corner = match corner {
|
||||
hud::Corner::TopLeft => Corner::TopLeft,
|
||||
hud::Corner::TopRight => Corner::TopRight,
|
||||
hud::Corner::BottomLeft => Corner::BottomLeft,
|
||||
hud::Corner::BottomRight => Corner::BottomRight,
|
||||
};
|
||||
let i = Corner::ALL.iter().position(|c| *c == corner)?;
|
||||
self.shown[i].then(|| self.corners.place(corner, w, h))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -140,4 +204,58 @@ mod tests {
|
||||
assert_eq!(c.place(Corner::BottomRight, 50.0, 10.0), (348.0, 274.0));
|
||||
assert_eq!(c.place(Corner::BottomLeft, 10.0, 10.0), (2.0, 288.0));
|
||||
}
|
||||
|
||||
/// Draws a box of one line in a corner and keeps where it went.
|
||||
struct Panel {
|
||||
corner: hud::Corner,
|
||||
at: std::sync::Mutex<Vec<Option<(f32, f32)>>>,
|
||||
}
|
||||
|
||||
impl hud::Layer for Panel {
|
||||
fn draw(&self, canvas: &mut dyn hud::Canvas) {
|
||||
let w = canvas.text_width("12 CPS") + 4.0;
|
||||
let at = canvas.place(self.corner, w, 12.0);
|
||||
if let Some((x, y)) = at {
|
||||
canvas.fill(x, y, w, 12.0, 0x7800_0000);
|
||||
canvas.text(x + 2.0, y + 2.0, "12 CPS", 0xFFFF_FFFF, true);
|
||||
}
|
||||
self.at.lock().unwrap().push(at);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_stack_with_the_extras_and_skip_hidden_corners() {
|
||||
let atlas =
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
let mut batch = HudBatch::new([800, 600]);
|
||||
let (width, height) = (batch.width, batch.height);
|
||||
let mut corners = Corners::new(width, height, 0.0);
|
||||
// The minimap already took the top right.
|
||||
corners.place(Corner::TopRight, 64.0, 64.0);
|
||||
let right = Panel {
|
||||
corner: hud::Corner::TopRight,
|
||||
at: Default::default(),
|
||||
};
|
||||
let left = Panel {
|
||||
corner: hud::Corner::TopLeft,
|
||||
at: Default::default(),
|
||||
};
|
||||
let layers: [&dyn hud::Layer; 2] = [&right, &left];
|
||||
let empty = batch.vertices.len();
|
||||
draw_layers(
|
||||
&layers,
|
||||
&mut batch,
|
||||
&atlas,
|
||||
&mut corners,
|
||||
[false, true, true, true],
|
||||
);
|
||||
let (_, y) = right.at.lock().unwrap()[0].expect("the top right shows");
|
||||
assert_eq!(y, 2.0 + 64.0 + 2.0, "under the minimap");
|
||||
assert_eq!(
|
||||
*left.at.lock().unwrap(),
|
||||
[None],
|
||||
"the debug screen covers the top left"
|
||||
);
|
||||
assert!(batch.vertices.len() > empty, "the shown box is drawn");
|
||||
}
|
||||
}
|
||||
@@ -650,7 +650,7 @@ enum Screen {
|
||||
/// WorldOptionsScreen (game/lan_screen.rs).
|
||||
WorldOptions,
|
||||
/// Play with Friends, from the pause menu's Friends button
|
||||
/// (`friends_screen`, game/friends.rs).
|
||||
/// (game/friends.rs, game/friends/screen.rs).
|
||||
Friends,
|
||||
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
|
||||
Social,
|
||||
@@ -2028,7 +2028,7 @@ pub struct PackResources {
|
||||
/// The packs' Polytone post chains (#11486), when a pack has some.
|
||||
polytone_post: Option<Arc<horizon_client::polytone_post::PolytonePost>>,
|
||||
/// The packs' Polytone custom colours (`polytone/colors.json`).
|
||||
polytone_colors: Arc<horizon_client::polytone_colors::PackColors>,
|
||||
polytone_colors: Arc<horizon_client::pack_palette::PackColors>,
|
||||
}
|
||||
/// A quick craft drag as the screen draws it: each dragged slot's count and
|
||||
/// whether the slot caps it (None: hidden), and what stays in hand.
|
||||
@@ -5490,34 +5490,6 @@ impl Game<'_> {
|
||||
);
|
||||
cancel
|
||||
}
|
||||
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
|
||||
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
|
||||
/// moves only while the world runs.
|
||||
pub(super) fn visual_ticks(&self) -> u64 {
|
||||
if self.clock.still() {
|
||||
self.world.game_time.max(0) as u64
|
||||
} else {
|
||||
self.ticks
|
||||
}
|
||||
}
|
||||
/// The clock of the animated textures: `Minecraft.runTick` ticks the `TextureManager`
|
||||
/// only while the level runs normally. Under `HORIZON_STILL` it is the world's ticks.
|
||||
pub(super) fn texture_clock(&self) -> u64 {
|
||||
if self.clock.still() {
|
||||
self.visual_ticks()
|
||||
} else {
|
||||
self.texture_ticks
|
||||
}
|
||||
}
|
||||
/// `Util.getMillis` for what the frame shows (the world border's scroll, the hearts' blink,
|
||||
/// the glint's under the switch): under `HORIZON_STILL`, the world's ticks in milliseconds.
|
||||
pub(super) fn visual_millis(&self) -> i64 {
|
||||
if self.clock.still() {
|
||||
self.visual_ticks() as i64 * 50
|
||||
} else {
|
||||
self.started.elapsed().as_millis() as i64
|
||||
}
|
||||
}
|
||||
/// `PresenceHandler.getPresenceStatus`: what an all-sharing player is doing, for
|
||||
/// the friends service's `updatePresence`.
|
||||
fn presence_status(&self) -> horizon_client::social::PresenceStatus {
|
||||
@@ -6170,7 +6142,7 @@ impl Game<'_> {
|
||||
let atlas = &g.gui.atlas;
|
||||
let width = |s: &str| HudBatch::text_width(atlas, s) as i32;
|
||||
let draws = hud_layout::experience_colored(
|
||||
g.pack.polytone_colors.xp_bar(),
|
||||
g.pack.polytone_colors.xp_bar,
|
||||
wi,
|
||||
hi,
|
||||
g.experience.progress,
|
||||
@@ -6503,18 +6475,6 @@ impl Game<'_> {
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
|
||||
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
|
||||
/// changes hands: a screen that opens or closes starts with its own
|
||||
/// state, and a press whose release never reached the screen -- the
|
||||
/// pointer left the window, or the screen closed first -- would
|
||||
/// otherwise leave every later press on every later screen swallowed.
|
||||
pub(super) fn reset_inventory_clicks(&mut self) {
|
||||
self.inv_clicks.cancel_drag();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
}
|
||||
pub(super) fn close_container(&mut self) {
|
||||
// `Gui.setScreen` clears the old screen's focus before it goes: the
|
||||
// key that closed the container (Tab bound to the inventory) has
|
||||
@@ -6558,20 +6518,6 @@ impl Game<'_> {
|
||||
}
|
||||
self.close_ender_chest();
|
||||
}
|
||||
/// A sound a menu click made the player play (a bundle's insert or
|
||||
/// remove): `Entity.playSound` at the player.
|
||||
pub(super) fn player_menu_sound(&mut self, sound: &str) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
|
||||
(1.0, 1.0)
|
||||
} else {
|
||||
(0.8, 0.8 + self.world.random.next_float() * 0.4)
|
||||
};
|
||||
self.audio.play(sound, Some(at), volume, pitch);
|
||||
}
|
||||
/// `Camera.setRotation`: the camera's orientation, turned round in
|
||||
/// third-person front view.
|
||||
pub(super) fn camera_orientation(&self) -> glam::Quat {
|
||||
@@ -12720,16 +12666,9 @@ pub fn run_with() -> Result<()> {
|
||||
if !options.offscreen && !synchronous_startup(&options) {
|
||||
let version = horizon_client::telemetry::version();
|
||||
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
|
||||
let player_dir = horizon_client::logging::player_dir();
|
||||
// horizon-extras: the official server is Horizon's (docs/forking.md).
|
||||
if cfg!(feature = "horizon-extras")
|
||||
&& let Err(e) = horizon_client::multiplayer::add_official_server(&player_dir)
|
||||
{
|
||||
log::warn!("could not add the official server to servers.dat: {e}");
|
||||
}
|
||||
if let Err(e) = horizon_client::multiplayer::withdraw_partner_servers(&player_dir) {
|
||||
log::warn!("could not take the withdrawn partner server out of servers.dat: {e}");
|
||||
}
|
||||
#[cfg(feature = "horizon-extras")]
|
||||
horizon_client::official_servers::update(&horizon_client::logging::player_dir());
|
||||
}
|
||||
mods::register_components(&vanilla_root)?;
|
||||
mods::register_standalone_models();
|
||||
|
||||
@@ -686,23 +686,15 @@ impl Game<'_> {
|
||||
if fall == 0.0 || length < 1.0 {
|
||||
return fall;
|
||||
}
|
||||
let to = from.add_vector(moved.normalize().scale(moved.length().min(8.0)));
|
||||
let reset = &self.res.motion().fall_reset;
|
||||
let fluids = &self.res.motion().fluids;
|
||||
let hit = horizon_core::geometry::traverse_blocks(from, to, 1_000_000, |p| {
|
||||
// A tagged block clips as a full block, not its collider.
|
||||
let name = &self.res.facts().get(self.world.block(p.x, p.y, p.z)).name;
|
||||
if reset.contains(&**name) && VoxelShape::block().clip(from, to, p).is_some() {
|
||||
return Some(());
|
||||
}
|
||||
// The water's shape, through the shared fluid shape cache.
|
||||
horizon_client::entities::water_clip(&self.world, fluids, from, to, p).then_some(())
|
||||
});
|
||||
if matches!(hit, Ok(Some(()))) {
|
||||
0.0
|
||||
} else {
|
||||
fall
|
||||
}
|
||||
let hit = horizon_client::entities::fall_reset_hit(
|
||||
&self.world,
|
||||
&self.res.motion().fluids,
|
||||
|p| reset.contains(&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name),
|
||||
from,
|
||||
moved,
|
||||
);
|
||||
if hit { 0.0 } else { fall }
|
||||
}
|
||||
/// `AbstractBoat.checkFallDamage` after a move that went `moved_y` (with
|
||||
/// `b` already where it landed): on the ground the fall resets; a move
|
||||
|
||||
@@ -70,6 +70,23 @@ pub(super) enum BookScreen {
|
||||
lectern: Option<(i32, i32, i32)>,
|
||||
},
|
||||
}
|
||||
impl BookScreen {
|
||||
/// `BookSignScreen`'s constructor: its title box opens with the title
|
||||
/// kept from the last time, which Cancel stored (`titleValue`).
|
||||
fn begin_signing(&mut self) {
|
||||
if let Self::Edit {
|
||||
signing,
|
||||
title,
|
||||
title_edit,
|
||||
..
|
||||
} = self
|
||||
{
|
||||
*signing = true;
|
||||
title_edit.set_value(title.as_str());
|
||||
title_edit.reset_blink();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The pages a book screen reads: a written book's components (so their
|
||||
/// click events stay), else a writable book's text.
|
||||
fn view_pages(data: Option<&StackData>) -> Vec<Text> {
|
||||
@@ -988,7 +1005,7 @@ impl Game<'_> {
|
||||
title,
|
||||
..
|
||||
}) if !title.trim().is_empty() => self.save_book(true),
|
||||
Some(BookScreen::Edit { signing, .. }) => *signing = true,
|
||||
Some(book @ BookScreen::Edit { .. }) => book.begin_signing(),
|
||||
_ => return Err("not a book and quill".into()),
|
||||
},
|
||||
Some("done") => match self.book.as_mut() {
|
||||
@@ -1190,8 +1207,8 @@ impl Game<'_> {
|
||||
self.turn_page(false);
|
||||
} else if inside(w / 2.0 - 100.0, 196.0, 98.0, 20.0) {
|
||||
click(self);
|
||||
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
|
||||
*signing = true;
|
||||
if let Some(book) = self.book.as_mut() {
|
||||
book.begin_signing();
|
||||
}
|
||||
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
|
||||
click(self);
|
||||
@@ -1809,3 +1826,46 @@ mod lectern_reach_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sign_tests {
|
||||
use super::{BookScreen, book_page_box, book_title_box};
|
||||
|
||||
/// `BookSignScreen`'s constructor sets its title box to `titleValue`, what
|
||||
/// Cancel kept; reopening the sign screen shows the title typed before,
|
||||
/// not an empty box beside a stale `title`.
|
||||
#[test]
|
||||
fn the_sign_screen_opens_with_the_title_it_kept() {
|
||||
let mut book = BookScreen::Edit {
|
||||
slot: 0,
|
||||
pages: vec!["page".into()],
|
||||
page: 0,
|
||||
signing: false,
|
||||
title: "My Book".into(),
|
||||
title_edit: book_title_box(),
|
||||
page_edit: book_page_box(),
|
||||
};
|
||||
assert_eq!(book_title_value(&book), "");
|
||||
book.begin_signing();
|
||||
let BookScreen::Edit {
|
||||
signing,
|
||||
title,
|
||||
title_edit,
|
||||
..
|
||||
} = &book
|
||||
else {
|
||||
panic!("the book stays open");
|
||||
};
|
||||
assert!(signing);
|
||||
assert_eq!(title.as_str(), "My Book");
|
||||
assert_eq!(title_edit.value, "My Book");
|
||||
assert_eq!(title_edit.cursor, 7);
|
||||
}
|
||||
|
||||
fn book_title_value(book: &BookScreen) -> String {
|
||||
match book {
|
||||
BookScreen::Edit { title_edit, .. } => title_edit.value.clone(),
|
||||
BookScreen::View { .. } => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,8 @@ impl Reply {
|
||||
}
|
||||
|
||||
pub(super) fn duration(millis: i64) -> Text {
|
||||
use horizon_chat_filter::DurationUnit;
|
||||
let (count, unit) = horizon_chat_filter::duration_parts(millis);
|
||||
use horizon_server_files::duration::{DurationUnit, duration_parts};
|
||||
let (count, unit) = duration_parts(millis);
|
||||
let key = match (unit, count == 1) {
|
||||
(DurationUnit::Minute, true) => "horizon.moderation.duration.minute",
|
||||
(DurationUnit::Minute, false) => "horizon.moderation.duration.minutes",
|
||||
@@ -93,7 +93,7 @@ pub(super) const MUTE_USAGES: [&str; 4] = [
|
||||
fn mute_time(words: &[&str]) -> (Option<i64>, usize) {
|
||||
match words
|
||||
.get(2)
|
||||
.and_then(|w| horizon_chat_filter::parse_duration(w))
|
||||
.and_then(|w| horizon_server_files::duration::parse_duration(w))
|
||||
{
|
||||
Some(ms) => (Some(ms), 3),
|
||||
None => (None, 2),
|
||||
@@ -133,7 +133,7 @@ impl Game<'_> {
|
||||
count: u32,
|
||||
action: Action,
|
||||
) {
|
||||
let now = horizon_chat_filter::now_millis();
|
||||
let now = horizon_server_files::duration::now_millis();
|
||||
let summary = format!(
|
||||
"[chat-filter] Refused {name}'s {} (hit {count}: {})",
|
||||
place.name(),
|
||||
@@ -281,7 +281,7 @@ impl Game<'_> {
|
||||
let reason = |from: usize| (words.len() > from).then(|| words[from..].join(" "));
|
||||
match words {
|
||||
["mutelist"] => {
|
||||
let now = horizon_chat_filter::now_millis();
|
||||
let now = horizon_server_files::duration::now_millis();
|
||||
let mutes = host.hub.mutes();
|
||||
if mutes.is_empty() {
|
||||
return vec![Reply::translated("horizon.moderation.mutelist.empty", &[])];
|
||||
@@ -319,13 +319,15 @@ impl Game<'_> {
|
||||
let (time, from) = mute_time(words);
|
||||
vec![self.mute(host, name, time, reason(from), by)]
|
||||
}
|
||||
["tempmute", name, time, ..] => match horizon_chat_filter::parse_duration(time) {
|
||||
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
|
||||
None => vec![Reply::translated(
|
||||
"horizon.moderation.invalid_duration",
|
||||
&[Text::literal(*time)],
|
||||
)],
|
||||
},
|
||||
["tempmute", name, time, ..] => {
|
||||
match horizon_server_files::duration::parse_duration(time) {
|
||||
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
|
||||
None => vec![Reply::translated(
|
||||
"horizon.moderation.invalid_duration",
|
||||
&[Text::literal(*time)],
|
||||
)],
|
||||
}
|
||||
}
|
||||
["mute", ..] => vec![Reply::translated("horizon.moderation.usage.mute", &[])],
|
||||
["tempmute", ..] => vec![Reply::translated("horizon.moderation.usage.tempmute", &[])],
|
||||
["unmute", ..] => vec![Reply::translated("horizon.moderation.usage.unmute", &[])],
|
||||
@@ -353,10 +355,10 @@ impl Game<'_> {
|
||||
let Some((uuid, name)) = user else {
|
||||
return Reply::no_player();
|
||||
};
|
||||
let until = time.map(|ms| horizon_chat_filter::now_millis() + ms);
|
||||
let until = time.map(|ms| horizon_server_files::duration::now_millis() + ms);
|
||||
let what = time.map_or_else(
|
||||
|| "until unmuted".into(),
|
||||
|ms| format!("for {}", horizon_chat_filter::duration_text(ms)),
|
||||
|ms| format!("for {}", horizon_server_files::duration::duration_text(ms)),
|
||||
);
|
||||
let why = reason
|
||||
.as_deref()
|
||||
|
||||
@@ -1567,11 +1567,11 @@ impl Game<'_> {
|
||||
"streaming": self.streaming.stages().into_iter().map(|(k, v)| (k.to_string(), v.into())).collect::<serde_json::Map<_, _>>(),
|
||||
"dirty_light": self.world.dirty_light.len(),
|
||||
"renderer": renderer,
|
||||
"display": self.window.as_deref().and_then(horizon_client::telemetry::monitor).map(|m| json!({
|
||||
"width": m.size().width,
|
||||
"height": m.size().height,
|
||||
"scale": m.scale_factor(),
|
||||
"refresh_hz": m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
|
||||
"display": self.window.as_deref().and_then(horizon_client::window::display).map(|d| json!({
|
||||
"width": d.width,
|
||||
"height": d.height,
|
||||
"scale": d.scale,
|
||||
"refresh_hz": d.refresh_hz,
|
||||
})),
|
||||
"uptime_seconds": self.started.elapsed().as_secs_f64(),
|
||||
"server": self.remote_status(),
|
||||
|
||||
@@ -623,3 +623,6 @@ fn stack_drop_requires_ctrl_even_with_sprint_latched() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "play_ping_tests.rs"]
|
||||
mod play_ping_tests;
|
||||
@@ -2,8 +2,10 @@
|
||||
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
|
||||
//! breeding, conversions, drops), projectiles, player interactions
|
||||
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
|
||||
mod mob_geometry;
|
||||
mod projectile_world;
|
||||
mod pufferfish_player;
|
||||
mod spawn_eggs;
|
||||
|
||||
use super::{
|
||||
Dim, Game, Hand, InteractTarget, entity_culling::EntityCullDecision,
|
||||
@@ -7450,171 +7452,6 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
|
||||
/// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby
|
||||
/// at the mob's feet, taking the parent's variant as `getBreedOffspring`
|
||||
/// does, and one egg used. False (PASS) when the mob has no baby form.
|
||||
fn spawn_egg_offspring(&mut self, index: usize) -> bool {
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return false;
|
||||
};
|
||||
let parent = &self.mobs[index];
|
||||
if !parent.alive()
|
||||
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|
||||
|| !horizon_client::mobs::species::can_be_a_baby(parent.species())
|
||||
// WanderingTrader.getBreedOffspring returns null: no offspring, the
|
||||
// click carries on to trading.
|
||||
|| parent.species() == Species::WanderingTrader
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (kind, at) = (
|
||||
parent.kind.id.clone(),
|
||||
[parent.position.x, parent.position.y, parent.position.z],
|
||||
);
|
||||
let (species, variant, color) =
|
||||
(parent.species(), parent.extra.variant, parent.extra.color);
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
// SpawnEggItem.spawnOffspringFromSpawnEgg: `type.create(level, SPAWN_ITEM_USE)`
|
||||
// creates the baby without finalizeSpawn, and `snapTo(pos, 0, 0)` faces
|
||||
// it south without a level-random draw.
|
||||
let Some(id) =
|
||||
self.spawn_mob_internal(&kind, at, false, false, false, false, SpawnYaw::Fixed(0.0))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
|
||||
}
|
||||
// `Mob.checkAndHandleImportantInteractions` then calls
|
||||
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
|
||||
// who used the egg (a fox egg used on a block trusts nobody).
|
||||
if species == Species::Fox {
|
||||
let spawner = self.acting_player_uuid();
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
|
||||
}
|
||||
}
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
|
||||
self.consume_held();
|
||||
true
|
||||
}
|
||||
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
|
||||
pub(super) fn spawn_egg_fluid_hit(&self) -> Option<super::Hit> {
|
||||
let hit = self.bucket_hit()?;
|
||||
matches!(
|
||||
&*self.block_name(hit.pos),
|
||||
"minecraft:water" | "minecraft:lava"
|
||||
)
|
||||
.then_some(hit)
|
||||
}
|
||||
/// Shared peaceful-mode eligibility for spawn eggs.
|
||||
pub(super) fn spawn_egg_allowed(&self, item: &str) -> bool {
|
||||
let kind = item.trim_end_matches("_spawn_egg");
|
||||
self.ui.difficulty != 0
|
||||
|| self
|
||||
.res
|
||||
.mob_types()
|
||||
.get(kind)
|
||||
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
|
||||
}
|
||||
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
|
||||
fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
|
||||
let fluid = matches!(self.guest_use, Some(super::GuestUse::Item(_)));
|
||||
let Some(hit) = (if fluid {
|
||||
self.spawn_egg_fluid_hit()
|
||||
} else {
|
||||
self.pick()
|
||||
}) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
|
||||
if !self.spawn_egg_allowed(&stack.item) {
|
||||
self.use_failed = true;
|
||||
return Ok(true);
|
||||
}
|
||||
if fluid
|
||||
&& !self.may_build()
|
||||
&& !self.can_place_on_in_adventure([
|
||||
hit.pos[0] - hit.face[0],
|
||||
hit.pos[1] - hit.face[1],
|
||||
hit.pos[2] - hit.face[2],
|
||||
])
|
||||
{
|
||||
self.use_failed = true;
|
||||
return Ok(true);
|
||||
}
|
||||
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
|
||||
let (spawner_id, data) = horizon_client::block_entity_item::spawn_egg_on_spawner(
|
||||
stack,
|
||||
self.acting_player_is_op(),
|
||||
);
|
||||
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &spawner_id, data.as_ref()) {
|
||||
if done {
|
||||
self.swing_arm();
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.consume_held();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
// useOn: a clicked block without a collision shape (vines, grass)
|
||||
// takes the mob itself, any other the one on the clicked face.
|
||||
let p = if fluid || self.collision_empty(hit.pos) {
|
||||
hit.pos
|
||||
} else {
|
||||
[
|
||||
hit.pos[0] + hit.face[0],
|
||||
hit.pos[1] + hit.face[1],
|
||||
hit.pos[2] + hit.face[2],
|
||||
]
|
||||
};
|
||||
let at = [
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
];
|
||||
// SpawnEggItem.spawnMob: `EntityType.spawn`.
|
||||
let Some(id) = self.spawn_mob_spawned(&kind, at, SpawnYaw::Created) else {
|
||||
return Ok(false);
|
||||
};
|
||||
// SpawnEggItem.spawnMob: `EntityType.create` finalizes the mob, then
|
||||
// `PostSpawnProcessor` puts the egg's components on it.
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::entity_nbt::apply_item_components(m, stack);
|
||||
}
|
||||
self.apply_spawn_egg_entity_data(id, stack);
|
||||
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
if !self.creative
|
||||
&& let Some(s) = self.held_mut().as_mut()
|
||||
{
|
||||
s.count -= 1;
|
||||
if s.count == 0 {
|
||||
*self.held_mut() = None;
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `EntityType.updateCustomEntityTag`: the egg's `minecraft:entity_data` on
|
||||
/// the mob it spawned, once the guards let it through, as
|
||||
/// `TypedEntityData.loadInto`: the mob's own compound, the data merged over
|
||||
/// it, loaded back into the same mob (`/data merge entity`'s loader).
|
||||
pub(super) fn apply_spawn_egg_entity_data(&mut self, id: u32, stack: &ItemStack) {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let kind = self.mobs[i].kind.id.clone();
|
||||
let op = self.acting_player_is_op();
|
||||
let Some(data) = horizon_client::block_entity_item::spawn_egg_entity_data(stack, &kind, op)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let own = self.mob_tree_nbt(i);
|
||||
let tag = horizon_client::data_cmd::merged(&own, data);
|
||||
// Nothing to report: the mob's own compound always loads back.
|
||||
let _ = self.apply_entity_nbt(super::commands::Target::Mob(id), tag);
|
||||
}
|
||||
/// A beam target's centre (half its height), interpolated.
|
||||
fn beam_target(&self, who: Option<Who>, alpha: f64) -> Option<glam::DVec3> {
|
||||
match who? {
|
||||
@@ -7785,624 +7622,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
// The coarse mob bounds pay off only in dense async scenes; otherwise,
|
||||
// keep the existing exact mesh-culling path.
|
||||
if self.settings.horizon.entity_culling == EntityCullingMode::Asynchronous
|
||||
&& self
|
||||
.async_occlusion_culler
|
||||
.as_ref()
|
||||
.is_some_and(|culler| culler.camera_is_stationary())
|
||||
&& self.settings.horizon.entity_culling_precheck != 0
|
||||
&& self.mobs.len() >= self.settings.horizon.entity_culling_precheck as usize
|
||||
&& !self.pack_shadow_casters.0
|
||||
&& horizon_client::mod_api::mods().is_empty()
|
||||
{
|
||||
self.mob_geometry_inner::<true>(camera, alpha, out);
|
||||
} else {
|
||||
self.mob_geometry_inner::<false>(camera, alpha, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn mob_geometry_inner<const PRECHECK: bool>(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let scale = self.view_scale();
|
||||
self.rendered_mobs.set(0);
|
||||
let skinning = self.skinning();
|
||||
// What custom entity model animations read of the frame.
|
||||
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
|
||||
time: self.world.game_time as f64 + alpha,
|
||||
day_time: self.world.day_time as f64 + alpha,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => 0.0,
|
||||
Dim::Nether => -1.0,
|
||||
Dim::End => 1.0,
|
||||
},
|
||||
player: self.player.as_ref().map_or(camera.to_array(), |p| {
|
||||
[p.position.x, p.position.y, p.position.z]
|
||||
}),
|
||||
player_rot: [self.pitch, self.yaw],
|
||||
paused: self.pause,
|
||||
first_person: self.camera_type.first_person(),
|
||||
third_person_reversed: self.camera_type
|
||||
== super::perspective::CameraType::ThirdPersonFront,
|
||||
..Default::default()
|
||||
});
|
||||
for mob in &self.mobs {
|
||||
// LevelRenderer: the entity the view looks out of is not drawn
|
||||
// in first person.
|
||||
if self.hides_as_camera(mob.id) {
|
||||
continue;
|
||||
}
|
||||
let is_player = &*mob.kind.id == "minecraft:player";
|
||||
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
|
||||
// mean edge, scaled by the view scale. RemotePlayer's box counts
|
||||
// ten times.
|
||||
let (w, h, _) = mob.dims();
|
||||
let edge = (w + h + w) / 3.0 * if is_player { 10.0 } else { 1.0 };
|
||||
let range = edge * 64.0 * scale;
|
||||
if d.length_squared() >= range * range {
|
||||
continue;
|
||||
}
|
||||
let beam = horizon_client::mobs::guardian::beam(mob, alpha as f32);
|
||||
// EntityRenderer.shouldRender: the renderer's interpolated culling box,
|
||||
// grown by 0.5 (GuardianRenderer also draws while its beam is out).
|
||||
if let Some(view) = &self.view_frustum
|
||||
&& beam.is_none()
|
||||
{
|
||||
let (lo, hi) = super::entity_culling::cull_box(mob, alpha);
|
||||
let (lo, hi) = (
|
||||
(lo - camera - glam::DVec3::splat(0.5)).as_vec3(),
|
||||
(hi - camera + glam::DVec3::splat(0.5)).as_vec3(),
|
||||
);
|
||||
if !view.intersects(lo, hi) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let outline = self.mob_outline(mob);
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
|
||||
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
|
||||
// A player keeps its armour, elytra and held items
|
||||
// (`LivingEntityRenderer.submit` skips only the body).
|
||||
let layers_only = mob.invisible && outline.is_none() && is_player;
|
||||
if mob.invisible && outline.is_none() && !layers_only {
|
||||
continue;
|
||||
}
|
||||
let culling = if PRECHECK
|
||||
&& outline.is_none()
|
||||
&& !is_player
|
||||
&& mob.kind.id.starts_with("minecraft:")
|
||||
&& [
|
||||
&mob.extra.head,
|
||||
&mob.extra.chest,
|
||||
&mob.extra.legs,
|
||||
&mob.extra.feet,
|
||||
&mob.extra.main_hand,
|
||||
&mob.extra.off_hand,
|
||||
]
|
||||
.iter()
|
||||
.all(|item| item.is_none())
|
||||
&& beam.is_none()
|
||||
{
|
||||
self.entity_cull_decision(mob, alpha)
|
||||
} else {
|
||||
EntityCullDecision::CheckVertices
|
||||
};
|
||||
if culling.hides_entity() {
|
||||
continue;
|
||||
}
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
|
||||
self.mark_entity(entity_start, &mob.kind.id);
|
||||
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
|
||||
// `EntityRenderer.getPackedLightCoords`: the block the mob's light
|
||||
// probe (its eye) is in, at the position this frame draws rather
|
||||
// than this tick's.
|
||||
let probe = mob.light_probe(alpha);
|
||||
let light = self.world.light(probe[0], probe[1], probe[2]);
|
||||
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
|
||||
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
|
||||
// renderers' overrides; the sky light stays the level's.
|
||||
if mob.fire_ticks > 0
|
||||
|| matches!(
|
||||
mob.species(),
|
||||
Species::Allay
|
||||
| Species::Blaze
|
||||
| Species::MagmaCube
|
||||
| Species::Vex
|
||||
| Species::Wither
|
||||
)
|
||||
{
|
||||
packed = (packed & 0xFF) | ((15 * 16) << 8);
|
||||
}
|
||||
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
|
||||
// dark ticks run out (the block light is the second byte).
|
||||
if mob.species() == Species::GlowSquid {
|
||||
let lit = (packed >> 12) & 15;
|
||||
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
|
||||
packed = (packed & 0xFF) | ((block * 16) << 8);
|
||||
}
|
||||
// A native mod's own model in place of the vanilla one
|
||||
// (`native_mob_submit`), before any of the vanilla model's parts
|
||||
// (a sulfur cube's swallowed block) are drawn.
|
||||
// A player's own hook (`native_player_submit`) is asked for the
|
||||
// other players in view.
|
||||
let who = is_player
|
||||
.then(|| self.remote_player_name(mob.id))
|
||||
.flatten()
|
||||
.map(|name| NativePlayerRender { name, local: false });
|
||||
let modded = if layers_only {
|
||||
false
|
||||
} else if let Some(who) = who {
|
||||
self.mod_player_geometry(mob, who, alpha as f32, camera, packed, out)
|
||||
} else {
|
||||
!is_player && self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
|
||||
};
|
||||
if modded {
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
if mob.invisible {
|
||||
out.truncate(entity_start);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if outline.is_none() {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if mob.species() == Species::SulfurCube
|
||||
&& !horizon_client::mobs::trader::is_invisible(mob)
|
||||
{
|
||||
self.sulfur_cube_body_geometry(
|
||||
mob,
|
||||
alpha as f32,
|
||||
horizon_client::mobs::render::base_transform(mob, alpha as f32, camera),
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
}
|
||||
// Other players on a server: PlayerRenderer's HumanoidArmorLayer.
|
||||
let armor = if is_player {
|
||||
self.worn_armor_layers(mob)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let body = out.len();
|
||||
// The GPU path: an ordinary mob, no layers the CPU path changes after drawing them
|
||||
// (a player's, the HDR tiers below, the sulfur cube's shell). A glowing mob's
|
||||
// silhouette is posed too (`SkinFrame::outline_since`), unless it is invisible:
|
||||
// then only the outline draws.
|
||||
let silhouette_on_gpu = outline.is_none() || !mob.invisible;
|
||||
let posable = skinning
|
||||
&& !is_player
|
||||
&& !layers_only
|
||||
&& silhouette_on_gpu
|
||||
&& armor.is_empty()
|
||||
&& !matches!(
|
||||
mob.species(),
|
||||
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
|
||||
);
|
||||
let mut skinned = None;
|
||||
if posable {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
let mut frame = self.skin_frame.borrow_mut();
|
||||
let mark = frame.mark();
|
||||
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
|
||||
horizon_client::mobs::render::render_skinned(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
out,
|
||||
&mut skin,
|
||||
);
|
||||
skinned = Some((mark, skin.bounds));
|
||||
} else {
|
||||
let render = if layers_only {
|
||||
horizon_client::mobs::render::render_layers
|
||||
} else {
|
||||
horizon_client::mobs::render::render_with
|
||||
};
|
||||
render(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&armor,
|
||||
out,
|
||||
);
|
||||
}
|
||||
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
|
||||
// fs_hdr_mark); a tier's window lifts only their bright texels.
|
||||
if let Some(tier) = match mob.species() {
|
||||
Species::Blaze => Some(horizon_client::mesher::BRIGHT),
|
||||
Species::MagmaCube | Species::GlowSquid => Some(horizon_client::mesher::LOW),
|
||||
_ => None,
|
||||
} {
|
||||
for v in &mut out[body..] {
|
||||
v.layer |= u32::from(tier) << 16;
|
||||
}
|
||||
}
|
||||
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
|
||||
let layers = horizon_client::mobs::render::layers_shown(mob);
|
||||
if is_player && layers {
|
||||
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
|
||||
// and SpinAttackEffectLayer. Only the cape hides with the body;
|
||||
// the others have no `isInvisible` check.
|
||||
let chest = mob.extra.chest.as_deref();
|
||||
if !layers_only {
|
||||
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
}
|
||||
if let Some((_, name)) = self
|
||||
.remote_player_names()
|
||||
.into_iter()
|
||||
.find(|(id, _)| *id == mob.id)
|
||||
{
|
||||
self.player_ears_geometry(mob, &name, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
self.shoulder_parrots_geometry(mob, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
|
||||
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
|
||||
}
|
||||
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
|
||||
// with it.
|
||||
if !layers_only
|
||||
&& super::cosmetics::enabled()
|
||||
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
|
||||
{
|
||||
let mark = self.skin_frame.borrow().mark();
|
||||
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
|
||||
let gpu = skinning;
|
||||
if let Some((lo, hi)) = self.cosmetics_geometry(
|
||||
mob,
|
||||
false,
|
||||
base,
|
||||
Some(camera),
|
||||
alpha as f32,
|
||||
packed,
|
||||
gpu,
|
||||
out,
|
||||
) {
|
||||
skinned = Some(match skinned {
|
||||
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
|
||||
Some((first, None)) => (first, Some((lo, hi))),
|
||||
None => (mark, Some((lo, hi))),
|
||||
});
|
||||
}
|
||||
}
|
||||
// CustomHeadLayer (a pumpkin, skull or head item) included.
|
||||
if layers {
|
||||
// Held items join the GPU path's draws, their silhouette too.
|
||||
let skin = (skinning && silhouette_on_gpu).then_some(&mut skinned);
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
|
||||
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
|
||||
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
|
||||
}
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
if let Some(((first, _, _), _)) = skinned {
|
||||
let color = horizon_client::mobs::render::abgr(rgb);
|
||||
self.skin_frame.borrow_mut().outline_since(first, color);
|
||||
}
|
||||
if mob.invisible {
|
||||
out.truncate(entity_start);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Glowing outlines and a guardian's beam remain visible independently
|
||||
// of whether the body is hidden behind terrain.
|
||||
if outline.is_none() && culling.checks_vertices() && beam.is_none() {
|
||||
// What the GPU draws of the mob counts by its bounds' corners.
|
||||
self.cull_skinned(out, entity_start, skinned);
|
||||
}
|
||||
// GuardianRenderer: the laser to the locked target.
|
||||
if let Some((time, scale)) = beam
|
||||
&& let Some(target) = self.beam_target(mob.ai.target, alpha)
|
||||
&& let Some(uv) = lookup("entity/guardian/guardian_beam")
|
||||
{
|
||||
let a = alpha;
|
||||
let eye = glam::DVec3::new(
|
||||
mob.previous.x + (mob.position.x - mob.previous.x) * a,
|
||||
mob.previous.y + (mob.position.y - mob.previous.y) * a + mob.eye_height(),
|
||||
mob.previous.z + (mob.position.z - mob.previous.z) * a,
|
||||
) - camera;
|
||||
horizon_client::mobs::guardian::beam_geometry(
|
||||
eye.as_vec3(),
|
||||
(target - camera).as_vec3(),
|
||||
time,
|
||||
scale,
|
||||
uv,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
// What follows isn't a tracked mob: close the last one's span.
|
||||
self.mark_motion(out.len(), 0);
|
||||
self.elder_apparition_geometry(alpha, out);
|
||||
self.spawner_geometry(camera, alpha, out);
|
||||
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
|
||||
let (right, up) = thrown_item_axes(self.camera_type, self.yaw, self.pitch);
|
||||
let live = self.creatures.projectiles.iter().map(|a| {
|
||||
let at = [
|
||||
a.previous.x + (a.position.x - a.previous.x) * alpha,
|
||||
a.previous.y + (a.position.y - a.previous.y) * alpha,
|
||||
a.previous.z + (a.position.z - a.previous.z) * alpha,
|
||||
];
|
||||
(a, at)
|
||||
});
|
||||
// ItemPickupParticle: a picked-up arrow flying to its collector.
|
||||
let taken = self.pickups.live.iter().filter_map(|p| match &p.taken {
|
||||
super::pickups::Taken::Arrow(a) => Some((&**a, super::pickups::position(p, alpha))),
|
||||
_ => None,
|
||||
});
|
||||
// A glowing projectile's outline: what it drew up to the next one.
|
||||
let mut glowing = None;
|
||||
for (a, at) in live.chain(taken) {
|
||||
if let Some((start, rgb)) = glowing.take() {
|
||||
self.mark_outline(&out[start..], rgb);
|
||||
}
|
||||
if a.id != 0
|
||||
&& let Some(rgb) = self.thing_outline(a.id, a.kind.id())
|
||||
{
|
||||
glowing = Some((out.len(), rgb));
|
||||
}
|
||||
let p = glam::DVec3::from_array(at) - camera;
|
||||
let c = p.as_vec3();
|
||||
// EntityRenderer.getPackedLightCoords: the block its light probe
|
||||
// (the eye) is in, at the position this frame draws rather than
|
||||
// this tick's, and not the one under its feet.
|
||||
let light = self.world.light(
|
||||
at[0].floor() as i32,
|
||||
a.light_probe_y(alpha).floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
);
|
||||
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// EntityRenderer.getBlockLightLevel: a shulker bullet and anything
|
||||
// on fire are drawn at 15 block light (the fireball renderers'
|
||||
// own override); the sky light stays the level's.
|
||||
if let Some(block) = a.block_light_override() {
|
||||
packed = (packed & 0xFF) | (u32::from(block) * 16) << 8;
|
||||
}
|
||||
if a.is_arrow() {
|
||||
let Some(model) = self.arrow_model.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
// SpectralArrowRenderer, and TippableArrowRenderer's `getColor() > 0`.
|
||||
let texture = if a.is_spectral_arrow() {
|
||||
"entity/projectiles/arrow_spectral"
|
||||
} else if a.arrow.as_ref().is_some_and(|s| s.effect_color > 0) {
|
||||
"entity/projectiles/arrow_tipped"
|
||||
} else {
|
||||
"entity/projectiles/arrow"
|
||||
};
|
||||
let Some(r) = atlas.sprites.get(texture).copied() else {
|
||||
continue;
|
||||
};
|
||||
// ArrowRenderer: `Mth.lerp(partialTick, state.yRotO, state.yRot)`
|
||||
// and the same for the pitch, so a turning arrow is drawn
|
||||
// between the two ticks it turned over.
|
||||
let alpha32 = alpha as f32;
|
||||
let yaw = a.previous_yaw + alpha32 * (a.yaw - a.previous_yaw);
|
||||
let pitch = a.previous_pitch + alpha32 * (a.pitch - a.previous_pitch);
|
||||
let m = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_rotation_y((yaw - 90.0).to_radians())
|
||||
* glam::Mat4::from_rotation_z(pitch.to_radians());
|
||||
// ArrowRenderer: `state.shake = shakeTime - partialTicks`; ArrowModel.setupAnim
|
||||
// rolls the root while the shake timer runs.
|
||||
let shake = a
|
||||
.arrow
|
||||
.as_ref()
|
||||
.map_or(0.0, |s| s.shake as f32 - alpha as f32);
|
||||
let roll = if shake > 0.0 {
|
||||
-(shake * 3.0).msin() * shake * (std::f32::consts::PI / 180.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let pose_for = |part: &horizon_client::mobs::Part| {
|
||||
let mut pose = part.pose;
|
||||
if std::ptr::eq(part, model) {
|
||||
pose[5] += roll;
|
||||
}
|
||||
pose
|
||||
};
|
||||
horizon_client::mobs::render_model(
|
||||
model,
|
||||
m,
|
||||
&pose_for,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
if a.arrow.as_ref().is_some_and(|s| s.on_fire()) {
|
||||
flame_geometry(atlas, c, 0.5, 0.5, self.yaw, light, out);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ThrownTridentRenderer.
|
||||
if matches!(a.kind, ProjectileKind::Trident) {
|
||||
let Some(model) = self.trident_model.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
|
||||
continue;
|
||||
};
|
||||
let m = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
|
||||
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
|
||||
let poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
m,
|
||||
&poses,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// WindChargeRenderer.
|
||||
if matches!(
|
||||
a.kind,
|
||||
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
|
||||
) {
|
||||
self.wind_charge_geometry(a, c, packed, alpha as f32, out);
|
||||
continue;
|
||||
}
|
||||
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
|
||||
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
|
||||
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
|
||||
let model = &self.entity_models.shulker_bullet;
|
||||
if let (Some(model), Some(r)) =
|
||||
(model, atlas.sprites.get("entity/shulker/spark").copied())
|
||||
{
|
||||
use horizon_core::geometry::trig;
|
||||
let tc = a.age as f32 + alpha as f32;
|
||||
let mut poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
poses.yrot("main", a.yaw.to_radians());
|
||||
poses.xrot("main", a.pitch.to_radians());
|
||||
let light = (packed & 0xFF) | ((15 * 16) << 8);
|
||||
let base = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
|
||||
* glam::Mat4::from_rotation_y(
|
||||
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_rotation_x(
|
||||
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_rotation_z(
|
||||
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
|
||||
let uv = [r.u0, r.v0, r.u1, r.v1];
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
uv,
|
||||
0xFFFF_FFFF,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
// The translucent shell blends over the opaque core
|
||||
// (`RenderTypes.entityTranslucent`).
|
||||
let shell = out.len();
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
|
||||
&poses,
|
||||
uv,
|
||||
0x26FF_FFFF,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
horizon_client::gfx::mark_oit(&mut out[shell..]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// WitherSkullRenderer: the skull model facing its flight.
|
||||
if let Some(dangerous) = super::wither::skull_kind(a) {
|
||||
let model = &self.entity_models.wither_skull;
|
||||
let tex = if dangerous {
|
||||
"entity/wither/wither_invulnerable"
|
||||
} else {
|
||||
"entity/wither/wither"
|
||||
};
|
||||
if let (Some(model), Some(r)) = (model, atlas.sprites.get(tex).copied()) {
|
||||
let mut poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
poses.yrot("head", a.yaw.to_radians());
|
||||
poses.xrot("head", a.pitch.to_radians());
|
||||
let base = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0));
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(stack) = a.drawn_item() {
|
||||
let owner = super::item_draw::owner_at(at, 0.0, a.kind.id());
|
||||
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
|
||||
let pose = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_quat(self.camera_orientation());
|
||||
self.submit_item_world(&state, pose, packed, out);
|
||||
continue;
|
||||
}
|
||||
// `Entity.displayFireAnimation`: a thrown item that flew through a
|
||||
// fire block burns (`ThrowableProjectile.tick`'s
|
||||
// `applyEffectsFromBlocks`), and ThrownItemRenderer draws the flame
|
||||
// over its box as any other entity renderer does.
|
||||
if a.on_fire() {
|
||||
let size = a.kind.size() as f32;
|
||||
flame_geometry(atlas, c, size, size, self.yaw, light, out);
|
||||
}
|
||||
// `ThrownItemRenderer` for a fireball: the item `Fireball.getItem`
|
||||
// returns (a fire charge until `setItem` gave it another), its model
|
||||
// at the renderer's scale for the kind. `packed` is already 15, the
|
||||
// renderer's full bright.
|
||||
if let Some(stack) = a.item.as_ref() {
|
||||
let owner = super::item_draw::owner_at(at, 0.0, a.kind.id());
|
||||
let state = self.resolve_item(stack, DisplayContext::Ground, Some(&owner), 0);
|
||||
let pose = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_quat(self.camera_orientation())
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(a.sprite_scale()));
|
||||
self.submit_item_world(&state, pose, packed, out);
|
||||
continue;
|
||||
}
|
||||
let Some(name) = a.item_sprite() else {
|
||||
continue;
|
||||
};
|
||||
let Some(r) = atlas.sprites.get(name).copied() else {
|
||||
continue;
|
||||
};
|
||||
let lift = is_item_billboard(&a.kind);
|
||||
let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift);
|
||||
for (v, uv) in quad
|
||||
.iter()
|
||||
.zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]])
|
||||
{
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: v.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some((start, rgb)) = glowing {
|
||||
self.mark_outline(&out[start..], rgb);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ThrownItemRenderer.submit` turns the item by `CameraRenderState.orientation`
|
||||
/// (`Camera.setRotation`): the billboard's right and up axes are the camera's.
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
//! Drawing the mobs, players and projectiles the client sees, each frame (`mob_geometry`).
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(in crate::game) fn mob_geometry(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
// The coarse mob bounds pay off only in dense async scenes; otherwise,
|
||||
// keep the existing exact mesh-culling path.
|
||||
if self.settings.horizon.entity_culling == EntityCullingMode::Asynchronous
|
||||
&& self
|
||||
.async_occlusion_culler
|
||||
.as_ref()
|
||||
.is_some_and(|culler| culler.camera_is_stationary())
|
||||
&& self.settings.horizon.entity_culling_precheck != 0
|
||||
&& self.mobs.len() >= self.settings.horizon.entity_culling_precheck as usize
|
||||
&& !self.pack_shadow_casters.0
|
||||
&& horizon_client::mod_api::mods().is_empty()
|
||||
{
|
||||
self.mob_geometry_inner::<true>(camera, alpha, out);
|
||||
} else {
|
||||
self.mob_geometry_inner::<false>(camera, alpha, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn mob_geometry_inner<const PRECHECK: bool>(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let scale = self.view_scale();
|
||||
self.rendered_mobs.set(0);
|
||||
let skinning = self.skinning();
|
||||
// What custom entity model animations read of the frame.
|
||||
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
|
||||
time: self.world.game_time as f64 + alpha,
|
||||
day_time: self.world.day_time as f64 + alpha,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => 0.0,
|
||||
Dim::Nether => -1.0,
|
||||
Dim::End => 1.0,
|
||||
},
|
||||
player: self.player.as_ref().map_or(camera.to_array(), |p| {
|
||||
[p.position.x, p.position.y, p.position.z]
|
||||
}),
|
||||
player_rot: [self.pitch, self.yaw],
|
||||
paused: self.pause,
|
||||
first_person: self.camera_type.first_person(),
|
||||
third_person_reversed: self.camera_type
|
||||
== super::super::perspective::CameraType::ThirdPersonFront,
|
||||
..Default::default()
|
||||
});
|
||||
for mob in &self.mobs {
|
||||
// LevelRenderer: the entity the view looks out of is not drawn
|
||||
// in first person.
|
||||
if self.hides_as_camera(mob.id) {
|
||||
continue;
|
||||
}
|
||||
let is_player = &*mob.kind.id == "minecraft:player";
|
||||
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
|
||||
// mean edge, scaled by the view scale. RemotePlayer's box counts
|
||||
// ten times.
|
||||
let (w, h, _) = mob.dims();
|
||||
let edge = (w + h + w) / 3.0 * if is_player { 10.0 } else { 1.0 };
|
||||
let range = edge * 64.0 * scale;
|
||||
if d.length_squared() >= range * range {
|
||||
continue;
|
||||
}
|
||||
let beam = horizon_client::mobs::guardian::beam(mob, alpha as f32);
|
||||
// EntityRenderer.shouldRender: the renderer's interpolated culling box,
|
||||
// grown by 0.5 (GuardianRenderer also draws while its beam is out).
|
||||
if let Some(view) = &self.view_frustum
|
||||
&& beam.is_none()
|
||||
{
|
||||
let (lo, hi) = super::super::entity_culling::cull_box(mob, alpha);
|
||||
let (lo, hi) = (
|
||||
(lo - camera - glam::DVec3::splat(0.5)).as_vec3(),
|
||||
(hi - camera + glam::DVec3::splat(0.5)).as_vec3(),
|
||||
);
|
||||
if !view.intersects(lo, hi) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let outline = self.mob_outline(mob);
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
|
||||
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
|
||||
// A player keeps its armour, elytra and held items
|
||||
// (`LivingEntityRenderer.submit` skips only the body).
|
||||
let layers_only = mob.invisible && outline.is_none() && is_player;
|
||||
if mob.invisible && outline.is_none() && !layers_only {
|
||||
continue;
|
||||
}
|
||||
let culling = if PRECHECK
|
||||
&& outline.is_none()
|
||||
&& !is_player
|
||||
&& mob.kind.id.starts_with("minecraft:")
|
||||
&& [
|
||||
&mob.extra.head,
|
||||
&mob.extra.chest,
|
||||
&mob.extra.legs,
|
||||
&mob.extra.feet,
|
||||
&mob.extra.main_hand,
|
||||
&mob.extra.off_hand,
|
||||
]
|
||||
.iter()
|
||||
.all(|item| item.is_none())
|
||||
&& beam.is_none()
|
||||
{
|
||||
self.entity_cull_decision(mob, alpha)
|
||||
} else {
|
||||
EntityCullDecision::CheckVertices
|
||||
};
|
||||
if culling.hides_entity() {
|
||||
continue;
|
||||
}
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
|
||||
self.mark_entity(entity_start, &mob.kind.id);
|
||||
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
|
||||
// `EntityRenderer.getPackedLightCoords`: the block the mob's light
|
||||
// probe (its eye) is in, at the position this frame draws rather
|
||||
// than this tick's.
|
||||
let probe = mob.light_probe(alpha);
|
||||
let light = self.world.light(probe[0], probe[1], probe[2]);
|
||||
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
|
||||
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
|
||||
// renderers' overrides; the sky light stays the level's.
|
||||
if mob.fire_ticks > 0
|
||||
|| matches!(
|
||||
mob.species(),
|
||||
Species::Allay
|
||||
| Species::Blaze
|
||||
| Species::MagmaCube
|
||||
| Species::Vex
|
||||
| Species::Wither
|
||||
)
|
||||
{
|
||||
packed = (packed & 0xFF) | ((15 * 16) << 8);
|
||||
}
|
||||
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
|
||||
// dark ticks run out (the block light is the second byte).
|
||||
if mob.species() == Species::GlowSquid {
|
||||
let lit = (packed >> 12) & 15;
|
||||
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
|
||||
packed = (packed & 0xFF) | ((block * 16) << 8);
|
||||
}
|
||||
// A native mod's own model in place of the vanilla one
|
||||
// (`native_mob_submit`), before any of the vanilla model's parts
|
||||
// (a sulfur cube's swallowed block) are drawn.
|
||||
// A player's own hook (`native_player_submit`) is asked for the
|
||||
// other players in view.
|
||||
let who = is_player
|
||||
.then(|| self.remote_player_name(mob.id))
|
||||
.flatten()
|
||||
.map(|name| NativePlayerRender { name, local: false });
|
||||
let modded = if layers_only {
|
||||
false
|
||||
} else if let Some(who) = who {
|
||||
self.mod_player_geometry(mob, who, alpha as f32, camera, packed, out)
|
||||
} else {
|
||||
!is_player && self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
|
||||
};
|
||||
if modded {
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
if mob.invisible {
|
||||
out.truncate(entity_start);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if outline.is_none() {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if mob.species() == Species::SulfurCube
|
||||
&& !horizon_client::mobs::trader::is_invisible(mob)
|
||||
{
|
||||
self.sulfur_cube_body_geometry(
|
||||
mob,
|
||||
alpha as f32,
|
||||
horizon_client::mobs::render::base_transform(mob, alpha as f32, camera),
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
}
|
||||
// Other players on a server: PlayerRenderer's HumanoidArmorLayer.
|
||||
let armor = if is_player {
|
||||
self.worn_armor_layers(mob)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let body = out.len();
|
||||
// The GPU path: an ordinary mob, no layers the CPU path changes after drawing them
|
||||
// (a player's, the HDR tiers below, the sulfur cube's shell). A glowing mob's
|
||||
// silhouette is posed too (`SkinFrame::outline_since`), unless it is invisible:
|
||||
// then only the outline draws.
|
||||
let silhouette_on_gpu = outline.is_none() || !mob.invisible;
|
||||
let posable = skinning
|
||||
&& !is_player
|
||||
&& !layers_only
|
||||
&& silhouette_on_gpu
|
||||
&& armor.is_empty()
|
||||
&& !matches!(
|
||||
mob.species(),
|
||||
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
|
||||
);
|
||||
let mut skinned = None;
|
||||
if posable {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
let mut frame = self.skin_frame.borrow_mut();
|
||||
let mark = frame.mark();
|
||||
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
|
||||
horizon_client::mobs::render::render_skinned(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
out,
|
||||
&mut skin,
|
||||
);
|
||||
skinned = Some((mark, skin.bounds));
|
||||
} else {
|
||||
let render = if layers_only {
|
||||
horizon_client::mobs::render::render_layers
|
||||
} else {
|
||||
horizon_client::mobs::render::render_with
|
||||
};
|
||||
render(
|
||||
mob,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&armor,
|
||||
out,
|
||||
);
|
||||
}
|
||||
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
|
||||
// fs_hdr_mark); a tier's window lifts only their bright texels.
|
||||
if let Some(tier) = match mob.species() {
|
||||
Species::Blaze => Some(horizon_client::mesher::BRIGHT),
|
||||
Species::MagmaCube | Species::GlowSquid => Some(horizon_client::mesher::LOW),
|
||||
_ => None,
|
||||
} {
|
||||
for v in &mut out[body..] {
|
||||
v.layer |= u32::from(tier) << 16;
|
||||
}
|
||||
}
|
||||
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
|
||||
let layers = horizon_client::mobs::render::layers_shown(mob);
|
||||
if is_player && layers {
|
||||
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
|
||||
// and SpinAttackEffectLayer. Only the cape hides with the body;
|
||||
// the others have no `isInvisible` check.
|
||||
let chest = mob.extra.chest.as_deref();
|
||||
if !layers_only {
|
||||
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
}
|
||||
if let Some((_, name)) = self
|
||||
.remote_player_names()
|
||||
.into_iter()
|
||||
.find(|(id, _)| *id == mob.id)
|
||||
{
|
||||
self.player_ears_geometry(mob, &name, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
|
||||
self.shoulder_parrots_geometry(mob, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
|
||||
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
|
||||
}
|
||||
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
|
||||
// with it.
|
||||
if !layers_only
|
||||
&& super::super::cosmetics::enabled()
|
||||
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
|
||||
{
|
||||
let mark = self.skin_frame.borrow().mark();
|
||||
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
|
||||
let gpu = skinning;
|
||||
if let Some((lo, hi)) = self.cosmetics_geometry(
|
||||
mob,
|
||||
false,
|
||||
base,
|
||||
Some(camera),
|
||||
alpha as f32,
|
||||
packed,
|
||||
gpu,
|
||||
out,
|
||||
) {
|
||||
skinned = Some(match skinned {
|
||||
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
|
||||
Some((first, None)) => (first, Some((lo, hi))),
|
||||
None => (mark, Some((lo, hi))),
|
||||
});
|
||||
}
|
||||
}
|
||||
// CustomHeadLayer (a pumpkin, skull or head item) included.
|
||||
if layers {
|
||||
// Held items join the GPU path's draws, their silhouette too.
|
||||
let skin = (skinning && silhouette_on_gpu).then_some(&mut skinned);
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
|
||||
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
|
||||
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
|
||||
}
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
if let Some(((first, _, _), _)) = skinned {
|
||||
let color = horizon_client::mobs::render::abgr(rgb);
|
||||
self.skin_frame.borrow_mut().outline_since(first, color);
|
||||
}
|
||||
if mob.invisible {
|
||||
out.truncate(entity_start);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Glowing outlines and a guardian's beam remain visible independently
|
||||
// of whether the body is hidden behind terrain.
|
||||
if outline.is_none() && culling.checks_vertices() && beam.is_none() {
|
||||
// What the GPU draws of the mob counts by its bounds' corners.
|
||||
self.cull_skinned(out, entity_start, skinned);
|
||||
}
|
||||
// GuardianRenderer: the laser to the locked target.
|
||||
if let Some((time, scale)) = beam
|
||||
&& let Some(target) = self.beam_target(mob.ai.target, alpha)
|
||||
&& let Some(uv) = lookup("entity/guardian/guardian_beam")
|
||||
{
|
||||
let a = alpha;
|
||||
let eye = glam::DVec3::new(
|
||||
mob.previous.x + (mob.position.x - mob.previous.x) * a,
|
||||
mob.previous.y + (mob.position.y - mob.previous.y) * a + mob.eye_height(),
|
||||
mob.previous.z + (mob.position.z - mob.previous.z) * a,
|
||||
) - camera;
|
||||
horizon_client::mobs::guardian::beam_geometry(
|
||||
eye.as_vec3(),
|
||||
(target - camera).as_vec3(),
|
||||
time,
|
||||
scale,
|
||||
uv,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
// What follows isn't a tracked mob: close the last one's span.
|
||||
self.mark_motion(out.len(), 0);
|
||||
self.elder_apparition_geometry(alpha, out);
|
||||
self.spawner_geometry(camera, alpha, out);
|
||||
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
|
||||
let (right, up) = thrown_item_axes(self.camera_type, self.yaw, self.pitch);
|
||||
let live = self.creatures.projectiles.iter().map(|a| {
|
||||
let at = [
|
||||
a.previous.x + (a.position.x - a.previous.x) * alpha,
|
||||
a.previous.y + (a.position.y - a.previous.y) * alpha,
|
||||
a.previous.z + (a.position.z - a.previous.z) * alpha,
|
||||
];
|
||||
(a, at)
|
||||
});
|
||||
// ItemPickupParticle: a picked-up arrow flying to its collector.
|
||||
let taken = self.pickups.live.iter().filter_map(|p| match &p.taken {
|
||||
super::super::pickups::Taken::Arrow(a) => {
|
||||
Some((&**a, super::super::pickups::position(p, alpha)))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
// A glowing projectile's outline: what it drew up to the next one.
|
||||
let mut glowing = None;
|
||||
for (a, at) in live.chain(taken) {
|
||||
if let Some((start, rgb)) = glowing.take() {
|
||||
self.mark_outline(&out[start..], rgb);
|
||||
}
|
||||
if a.id != 0
|
||||
&& let Some(rgb) = self.thing_outline(a.id, a.kind.id())
|
||||
{
|
||||
glowing = Some((out.len(), rgb));
|
||||
}
|
||||
let p = glam::DVec3::from_array(at) - camera;
|
||||
let c = p.as_vec3();
|
||||
// EntityRenderer.getPackedLightCoords: the block its light probe
|
||||
// (the eye) is in, at the position this frame draws rather than
|
||||
// this tick's, and not the one under its feet.
|
||||
let light = self.world.light(
|
||||
at[0].floor() as i32,
|
||||
a.light_probe_y(alpha).floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
);
|
||||
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// EntityRenderer.getBlockLightLevel: a shulker bullet and anything
|
||||
// on fire are drawn at 15 block light (the fireball renderers'
|
||||
// own override); the sky light stays the level's.
|
||||
if let Some(block) = a.block_light_override() {
|
||||
packed = (packed & 0xFF) | (u32::from(block) * 16) << 8;
|
||||
}
|
||||
if a.is_arrow() {
|
||||
let Some(model) = self.arrow_model.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
// SpectralArrowRenderer, and TippableArrowRenderer's `getColor() > 0`.
|
||||
let texture = if a.is_spectral_arrow() {
|
||||
"entity/projectiles/arrow_spectral"
|
||||
} else if a.arrow.as_ref().is_some_and(|s| s.effect_color > 0) {
|
||||
"entity/projectiles/arrow_tipped"
|
||||
} else {
|
||||
"entity/projectiles/arrow"
|
||||
};
|
||||
let Some(r) = atlas.sprites.get(texture).copied() else {
|
||||
continue;
|
||||
};
|
||||
// ArrowRenderer: `Mth.lerp(partialTick, state.yRotO, state.yRot)`
|
||||
// and the same for the pitch, so a turning arrow is drawn
|
||||
// between the two ticks it turned over.
|
||||
let alpha32 = alpha as f32;
|
||||
let yaw = a.previous_yaw + alpha32 * (a.yaw - a.previous_yaw);
|
||||
let pitch = a.previous_pitch + alpha32 * (a.pitch - a.previous_pitch);
|
||||
let m = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_rotation_y((yaw - 90.0).to_radians())
|
||||
* glam::Mat4::from_rotation_z(pitch.to_radians());
|
||||
// ArrowRenderer: `state.shake = shakeTime - partialTicks`; ArrowModel.setupAnim
|
||||
// rolls the root while the shake timer runs.
|
||||
let shake = a
|
||||
.arrow
|
||||
.as_ref()
|
||||
.map_or(0.0, |s| s.shake as f32 - alpha as f32);
|
||||
let roll = if shake > 0.0 {
|
||||
-(shake * 3.0).msin() * shake * (std::f32::consts::PI / 180.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let pose_for = |part: &horizon_client::mobs::Part| {
|
||||
let mut pose = part.pose;
|
||||
if std::ptr::eq(part, model) {
|
||||
pose[5] += roll;
|
||||
}
|
||||
pose
|
||||
};
|
||||
horizon_client::mobs::render_model(
|
||||
model,
|
||||
m,
|
||||
&pose_for,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
if a.arrow.as_ref().is_some_and(|s| s.on_fire()) {
|
||||
flame_geometry(atlas, c, 0.5, 0.5, self.yaw, light, out);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ThrownTridentRenderer.
|
||||
if matches!(a.kind, ProjectileKind::Trident) {
|
||||
let Some(model) = self.trident_model.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
|
||||
continue;
|
||||
};
|
||||
let m = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
|
||||
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
|
||||
let poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
m,
|
||||
&poses,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// WindChargeRenderer.
|
||||
if matches!(
|
||||
a.kind,
|
||||
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
|
||||
) {
|
||||
self.wind_charge_geometry(a, c, packed, alpha as f32, out);
|
||||
continue;
|
||||
}
|
||||
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
|
||||
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
|
||||
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
|
||||
let model = &self.entity_models.shulker_bullet;
|
||||
if let (Some(model), Some(r)) =
|
||||
(model, atlas.sprites.get("entity/shulker/spark").copied())
|
||||
{
|
||||
use horizon_core::geometry::trig;
|
||||
let tc = a.age as f32 + alpha as f32;
|
||||
let mut poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
poses.yrot("main", a.yaw.to_radians());
|
||||
poses.xrot("main", a.pitch.to_radians());
|
||||
let light = (packed & 0xFF) | ((15 * 16) << 8);
|
||||
let base = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
|
||||
* glam::Mat4::from_rotation_y(
|
||||
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_rotation_x(
|
||||
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_rotation_z(
|
||||
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
|
||||
)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
|
||||
let uv = [r.u0, r.v0, r.u1, r.v1];
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
uv,
|
||||
0xFFFF_FFFF,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
// The translucent shell blends over the opaque core
|
||||
// (`RenderTypes.entityTranslucent`).
|
||||
let shell = out.len();
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
|
||||
&poses,
|
||||
uv,
|
||||
0x26FF_FFFF,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
horizon_client::gfx::mark_oit(&mut out[shell..]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// WitherSkullRenderer: the skull model facing its flight.
|
||||
if let Some(dangerous) = super::super::wither::skull_kind(a) {
|
||||
let model = &self.entity_models.wither_skull;
|
||||
let tex = if dangerous {
|
||||
"entity/wither/wither_invulnerable"
|
||||
} else {
|
||||
"entity/wither/wither"
|
||||
};
|
||||
if let (Some(model), Some(r)) = (model, atlas.sprites.get(tex).copied()) {
|
||||
let mut poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
poses.yrot("head", a.yaw.to_radians());
|
||||
poses.xrot("head", a.pitch.to_radians());
|
||||
let base = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0));
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
base,
|
||||
&poses,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(stack) = a.drawn_item() {
|
||||
let owner = super::super::item_draw::owner_at(at, 0.0, a.kind.id());
|
||||
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
|
||||
let pose = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_quat(self.camera_orientation());
|
||||
self.submit_item_world(&state, pose, packed, out);
|
||||
continue;
|
||||
}
|
||||
// `Entity.displayFireAnimation`: a thrown item that flew through a
|
||||
// fire block burns (`ThrowableProjectile.tick`'s
|
||||
// `applyEffectsFromBlocks`), and ThrownItemRenderer draws the flame
|
||||
// over its box as any other entity renderer does.
|
||||
if a.on_fire() {
|
||||
let size = a.kind.size() as f32;
|
||||
flame_geometry(atlas, c, size, size, self.yaw, light, out);
|
||||
}
|
||||
// `ThrownItemRenderer` for a fireball: the item `Fireball.getItem`
|
||||
// returns (a fire charge until `setItem` gave it another), its model
|
||||
// at the renderer's scale for the kind. `packed` is already 15, the
|
||||
// renderer's full bright.
|
||||
if let Some(stack) = a.item.as_ref() {
|
||||
let owner = super::super::item_draw::owner_at(at, 0.0, a.kind.id());
|
||||
let state = self.resolve_item(stack, DisplayContext::Ground, Some(&owner), 0);
|
||||
let pose = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_quat(self.camera_orientation())
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(a.sprite_scale()));
|
||||
self.submit_item_world(&state, pose, packed, out);
|
||||
continue;
|
||||
}
|
||||
let Some(name) = a.item_sprite() else {
|
||||
continue;
|
||||
};
|
||||
let Some(r) = atlas.sprites.get(name).copied() else {
|
||||
continue;
|
||||
};
|
||||
let lift = is_item_billboard(&a.kind);
|
||||
let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift);
|
||||
for (v, uv) in quad
|
||||
.iter()
|
||||
.zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]])
|
||||
{
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: v.to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some((start, rgb)) = glowing {
|
||||
self.mark_outline(&out[start..], rgb);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Spawn eggs: a baby from an egg used on its own kind, an egg used on a block or a fluid, and the
|
||||
//! egg's entity data on the mob it spawned.
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
|
||||
/// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby
|
||||
/// at the mob's feet, taking the parent's variant as `getBreedOffspring`
|
||||
/// does, and one egg used. False (PASS) when the mob has no baby form.
|
||||
pub(super) fn spawn_egg_offspring(&mut self, index: usize) -> bool {
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return false;
|
||||
};
|
||||
let parent = &self.mobs[index];
|
||||
if !parent.alive()
|
||||
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|
||||
|| !horizon_client::mobs::species::can_be_a_baby(parent.species())
|
||||
// WanderingTrader.getBreedOffspring returns null: no offspring, the
|
||||
// click carries on to trading.
|
||||
|| parent.species() == Species::WanderingTrader
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (kind, at) = (
|
||||
parent.kind.id.clone(),
|
||||
[parent.position.x, parent.position.y, parent.position.z],
|
||||
);
|
||||
let (species, variant, color) =
|
||||
(parent.species(), parent.extra.variant, parent.extra.color);
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
// SpawnEggItem.spawnOffspringFromSpawnEgg: `type.create(level, SPAWN_ITEM_USE)`
|
||||
// creates the baby without finalizeSpawn, and `snapTo(pos, 0, 0)` faces
|
||||
// it south without a level-random draw.
|
||||
let Some(id) =
|
||||
self.spawn_mob_internal(&kind, at, false, false, false, false, SpawnYaw::Fixed(0.0))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
|
||||
}
|
||||
// `Mob.checkAndHandleImportantInteractions` then calls
|
||||
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
|
||||
// who used the egg (a fox egg used on a block trusts nobody).
|
||||
if species == Species::Fox {
|
||||
let spawner = self.acting_player_uuid();
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
|
||||
}
|
||||
}
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
|
||||
self.consume_held();
|
||||
true
|
||||
}
|
||||
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
|
||||
pub(in crate::game) fn spawn_egg_fluid_hit(&self) -> Option<super::super::Hit> {
|
||||
let hit = self.bucket_hit()?;
|
||||
matches!(
|
||||
&*self.block_name(hit.pos),
|
||||
"minecraft:water" | "minecraft:lava"
|
||||
)
|
||||
.then_some(hit)
|
||||
}
|
||||
/// Shared peaceful-mode eligibility for spawn eggs.
|
||||
pub(in crate::game) fn spawn_egg_allowed(&self, item: &str) -> bool {
|
||||
let kind = item.trim_end_matches("_spawn_egg");
|
||||
self.ui.difficulty != 0
|
||||
|| self
|
||||
.res
|
||||
.mob_types()
|
||||
.get(kind)
|
||||
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
|
||||
}
|
||||
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
|
||||
pub(super) fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
|
||||
let fluid = matches!(self.guest_use, Some(super::super::GuestUse::Item(_)));
|
||||
let Some(hit) = (if fluid {
|
||||
self.spawn_egg_fluid_hit()
|
||||
} else {
|
||||
self.pick()
|
||||
}) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
|
||||
if !self.spawn_egg_allowed(&stack.item) {
|
||||
self.use_failed = true;
|
||||
return Ok(true);
|
||||
}
|
||||
if fluid
|
||||
&& !self.may_build()
|
||||
&& !self.can_place_on_in_adventure([
|
||||
hit.pos[0] - hit.face[0],
|
||||
hit.pos[1] - hit.face[1],
|
||||
hit.pos[2] - hit.face[2],
|
||||
])
|
||||
{
|
||||
self.use_failed = true;
|
||||
return Ok(true);
|
||||
}
|
||||
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
|
||||
let (spawner_id, data) = horizon_client::block_entity_item::spawn_egg_on_spawner(
|
||||
stack,
|
||||
self.acting_player_is_op(),
|
||||
);
|
||||
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &spawner_id, data.as_ref()) {
|
||||
if done {
|
||||
self.swing_arm();
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.consume_held();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
// useOn: a clicked block without a collision shape (vines, grass)
|
||||
// takes the mob itself, any other the one on the clicked face.
|
||||
let p = if fluid || self.collision_empty(hit.pos) {
|
||||
hit.pos
|
||||
} else {
|
||||
[
|
||||
hit.pos[0] + hit.face[0],
|
||||
hit.pos[1] + hit.face[1],
|
||||
hit.pos[2] + hit.face[2],
|
||||
]
|
||||
};
|
||||
let at = [
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
];
|
||||
// SpawnEggItem.spawnMob: `EntityType.spawn`.
|
||||
let Some(id) = self.spawn_mob_spawned(&kind, at, SpawnYaw::Created) else {
|
||||
return Ok(false);
|
||||
};
|
||||
// SpawnEggItem.spawnMob: `EntityType.create` finalizes the mob, then
|
||||
// `PostSpawnProcessor` puts the egg's components on it.
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::entity_nbt::apply_item_components(m, stack);
|
||||
}
|
||||
self.apply_spawn_egg_entity_data(id, stack);
|
||||
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
if !self.creative
|
||||
&& let Some(s) = self.held_mut().as_mut()
|
||||
{
|
||||
s.count -= 1;
|
||||
if s.count == 0 {
|
||||
*self.held_mut() = None;
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `EntityType.updateCustomEntityTag`: the egg's `minecraft:entity_data` on
|
||||
/// the mob it spawned, once the guards let it through, as
|
||||
/// `TypedEntityData.loadInto`: the mob's own compound, the data merged over
|
||||
/// it, loaded back into the same mob (`/data merge entity`'s loader).
|
||||
pub(in crate::game) fn apply_spawn_egg_entity_data(&mut self, id: u32, stack: &ItemStack) {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let kind = self.mobs[i].kind.id.clone();
|
||||
let op = self.acting_player_is_op();
|
||||
let Some(data) = horizon_client::block_entity_item::spawn_egg_entity_data(stack, &kind, op)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let own = self.mob_tree_nbt(i);
|
||||
let tag = horizon_client::data_cmd::merged(&own, data);
|
||||
// Nothing to report: the mob's own compound always loads back.
|
||||
let _ = self.apply_entity_nbt(super::super::commands::Target::Mob(id), tag);
|
||||
}
|
||||
}
|
||||
@@ -2671,7 +2671,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_temporarily_banned_player_is_refused_with_the_end_until_it_has_passed() {
|
||||
use super::{BanEntry, BanList, Lists, NameAndId};
|
||||
use horizon_chat_filter::now_millis;
|
||||
use horizon_server_files::duration::now_millis;
|
||||
let dir = std::env::temp_dir().join(format!("horizon-can-login-{}", std::process::id()));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let mut lists = Lists {
|
||||
|
||||
@@ -273,6 +273,12 @@ impl Game<'_> {
|
||||
}
|
||||
self.draw_pvp_hud(batch, &mut corners);
|
||||
self.native_hud(batch, &mut corners);
|
||||
// The layers crates registered (`horizon_hooks::hud`): none, no canvas.
|
||||
let layers = horizon_hooks::hud::engine::all();
|
||||
if !layers.is_empty() {
|
||||
let shown = Corner::ALL.map(|corner| self.native_corner_shown(corner));
|
||||
extras_hud::draw_layers(layers, batch, &self.gui.atlas, &mut corners, shown);
|
||||
}
|
||||
}
|
||||
/// The height the status effect icons take at the top right
|
||||
/// (`Hud.extractEffects`: beneficial at y 1, harmful at y 26).
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
//! Play with friends, host side: "Start Sharing" on the Play with Friends
|
||||
//! screen (`friends_screen`, the pause menu's Friends button) opens the world to LAN if
|
||||
//! screen (`screen`, the pause menu's Friends button) opens the world to LAN if
|
||||
//! it isn't (with Open to LAN's defaults) and shares that port behind an
|
||||
//! invite code (horizon-friends). The code holds this computer's addresses
|
||||
//! and the key, so friends join with no server of ours; a relay the player
|
||||
//! configured adds a short code and a fallback. The integrated server is the
|
||||
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
|
||||
//! loopback address.
|
||||
//! loopback address. The joining side is horizon-friends' (`game`), registered by
|
||||
//! horizon-official.
|
||||
use super::{Game, lan::LanSettings};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
friends_screen::{self, Action, FriendsView},
|
||||
hud::HudBatch,
|
||||
lang,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_client::{Result, hud::HudBatch, lang, text::Text};
|
||||
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
|
||||
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
mod screen;
|
||||
|
||||
use screen::{self as friends_screen, Action, FriendsView};
|
||||
pub use screen::{TITLE, title};
|
||||
|
||||
pub(super) struct Sharing {
|
||||
host: Host,
|
||||
/// Sharing opened the world to LAN, so stopping closes it again.
|
||||
@@ -240,6 +240,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pause menu offers Play with Friends (a singleplayer world).
|
||||
pub(super) fn friends_shown(&self) -> bool {
|
||||
self.friends_view().shown
|
||||
}
|
||||
|
||||
/// What Play with Friends shows.
|
||||
pub(super) fn friends_view(&self) -> FriendsView {
|
||||
let guests = match &self.lan {
|
||||
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
//! Play with Friends: Horizon's own sharing of the open world behind an
|
||||
//! invite code (`game/friends.rs`). The pause menu's Friends button, the icon
|
||||
//! invite code (`game/friends.rs`), with the rest of Play with Friends' game glue. The pause menu's Friends button, the icon
|
||||
//! vanilla's PauseScreen already has, opens it. It is drawn the way a mod adds
|
||||
//! a screen to vanilla: the menu background, a centred title and text,
|
||||
//! vanilla's buttons and Done in the footer.
|
||||
use crate::{
|
||||
use horizon_client::{
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
multiplayer::wrap,
|
||||
text::Text,
|
||||
@@ -14,7 +14,7 @@ use crate::{
|
||||
pub const TITLE: &str = "Play with Friends";
|
||||
/// A string of this screen: the language table's, or the English text.
|
||||
fn tr(key: &str, english: &str) -> String {
|
||||
crate::lang::get_or(key, english)
|
||||
horizon_client::lang::get_or(key, english)
|
||||
}
|
||||
/// The title in the current language.
|
||||
pub fn title() -> String {
|
||||
@@ -245,8 +245,8 @@ pub fn draw(
|
||||
for b in buttons {
|
||||
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
|
||||
if matches!(b.action, Action::Done) {
|
||||
crate::widgets::declare_action(
|
||||
crate::widgets::Action::Done,
|
||||
horizon_client::widgets::declare_action(
|
||||
horizon_client::widgets::Action::Done,
|
||||
[b.x, b.y, b.w, 20.0],
|
||||
b.active,
|
||||
);
|
||||
@@ -1,11 +1,17 @@
|
||||
//! Play with Friends left out of the build (the `friends` feature off, as in the server build):
|
||||
//! no world is shared, the pause menu has no Friends button (`FriendsView::shown` is false, as on
|
||||
//! no world is shared, the pause menu has no Friends button (`friends_shown` is false, as on
|
||||
//! another server) and `horizon-ctl friends` says it isn't there. game/friends.rs is the real one.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{Result, friends_screen::FriendsView, hud::HudBatch};
|
||||
use horizon_client::{Result, hud::HudBatch};
|
||||
|
||||
pub const HELP: &[&str] = &[];
|
||||
|
||||
/// The pause menu's Friends icon is vanilla's, drawn inactive: its name for the widget tree.
|
||||
pub const TITLE: &str = "Play with Friends";
|
||||
pub fn title() -> String {
|
||||
horizon_client::lang::get_or("horizon.friends.title", TITLE)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn friends_lan_closed(&mut self) {}
|
||||
|
||||
@@ -18,8 +24,8 @@ impl Game<'_> {
|
||||
|
||||
pub(super) fn friends_screen_click(&mut self) {}
|
||||
|
||||
pub(super) fn friends_view(&self) -> FriendsView {
|
||||
FriendsView::default()
|
||||
pub(super) fn friends_shown(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// `horizon-ctl friends ...`.
|
||||
|
||||
@@ -1820,7 +1820,7 @@ impl Game<'_> {
|
||||
// from carries those, as `save_guest` gives them on leaving. The map
|
||||
// stays their one owner.
|
||||
let live = self.guest_attachments.get(&uuid).cloned();
|
||||
let g = &mut host.hub.guests[i];
|
||||
let g = playerdata::with_warden(&mut host.hub.guests[i], self.warden_tracker_tag());
|
||||
let held = live.map(|a| std::mem::replace(&mut g.attachments, a));
|
||||
playerdata::store(g, &mut self.level, vehicle);
|
||||
if let Some(held) = held {
|
||||
|
||||
@@ -98,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(
|
||||
@@ -129,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(
|
||||
|
||||
@@ -899,4 +899,32 @@ impl Game<'_> {
|
||||
f.input_clicked(world_recipes!(self), before);
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
|
||||
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
|
||||
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
|
||||
/// changes hands: a screen that opens or closes starts with its own
|
||||
/// state, and a press whose release never reached the screen -- the
|
||||
/// pointer left the window, or the screen closed first -- would
|
||||
/// otherwise leave every later press on every later screen swallowed.
|
||||
pub(super) fn reset_inventory_clicks(&mut self) {
|
||||
self.inv_clicks.cancel_drag();
|
||||
self.inv_drag = None;
|
||||
self.last_inv_click = None;
|
||||
}
|
||||
|
||||
/// A sound a menu click made the player play (a bundle's insert or
|
||||
/// remove): `Entity.playSound` at the player.
|
||||
pub(super) fn player_menu_sound(&mut self, sound: &str) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
|
||||
(1.0, 1.0)
|
||||
} else {
|
||||
(0.8, 0.8 + self.world.random.next_float() * 0.4)
|
||||
};
|
||||
self.audio.play(sound, Some(at), volume, pitch);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ use horizon_core::{
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
mod index;
|
||||
mod nbt;
|
||||
pub(super) use index::CartIndex;
|
||||
use index::cart_at;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(super) enum CartKind {
|
||||
@@ -187,102 +191,6 @@ pub(super) const TNT_PRIME_EVENT: i8 = 70;
|
||||
mod display;
|
||||
use display::{cart_render_bounds, display_offset, shown_unless_air};
|
||||
pub(super) use display::{default_display_offset, forget_unreadable_display};
|
||||
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
|
||||
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
|
||||
/// every mob of the level. Both answer in slot order, as the scans did.
|
||||
///
|
||||
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
|
||||
/// rebuilt once the list's length changed (a cart gone or added), and dropped
|
||||
/// after a command, which can move or replace any cart
|
||||
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
|
||||
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
|
||||
/// unseen, so with mods loaded both scans stay.
|
||||
#[derive(Default)]
|
||||
pub(super) struct CartIndex {
|
||||
carts: Option<(SeenGrid, Vec<u32>)>,
|
||||
/// The grid, and the carts a mob rides.
|
||||
mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
|
||||
near: Vec<u32>,
|
||||
}
|
||||
impl CartIndex {
|
||||
fn usable() -> bool {
|
||||
horizon_client::mod_api::mods().is_empty()
|
||||
}
|
||||
/// The cart in slot `i` has these bounds now.
|
||||
fn moved(&mut self, i: usize, bounds: Aabb) {
|
||||
if let Some((grid, ids)) = &mut self.carts
|
||||
&& i < ids.len()
|
||||
{
|
||||
grid.update(i, bounds);
|
||||
}
|
||||
}
|
||||
fn forget_carts(&mut self) {
|
||||
self.carts = None;
|
||||
}
|
||||
/// The slots of the carts whose box may meet `area`, for the cart in slot
|
||||
/// `i` (just moved); false: scan them all.
|
||||
fn carts_near(&mut self, carts: &[Minecart], i: usize, area: Aabb, out: &mut Vec<u32>) -> bool {
|
||||
if !Self::usable() {
|
||||
return false;
|
||||
}
|
||||
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
|
||||
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
|
||||
self.carts = Some((
|
||||
SeenGrid::new(carts.iter().map(Minecart::bounds)),
|
||||
carts.iter().map(|c| c.id).collect(),
|
||||
));
|
||||
}
|
||||
let Some((grid, ids)) = &mut self.carts else {
|
||||
return false;
|
||||
};
|
||||
grid.update(i, carts[i].bounds());
|
||||
if !grid.candidates(area, out) {
|
||||
return false;
|
||||
}
|
||||
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
|
||||
// A cart was replaced in place: start over next time.
|
||||
self.carts = None;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The slots of the mobs whose box may meet `area` into `out`, and
|
||||
/// whether a mob rides cart `cart`; None: scan them all.
|
||||
fn mobs_near(
|
||||
&mut self,
|
||||
mobs: &JoinList<Mob>,
|
||||
cart: u32,
|
||||
area: Aabb,
|
||||
out: &mut Vec<u32>,
|
||||
) -> Option<bool> {
|
||||
if !Self::usable() {
|
||||
return None;
|
||||
}
|
||||
if !self
|
||||
.mobs
|
||||
.as_ref()
|
||||
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
|
||||
{
|
||||
self.mobs = Some((
|
||||
mobs.stamp(),
|
||||
SeenGrid::new(mobs.iter().map(Mob::bounds)),
|
||||
mobs.iter().filter_map(|m| m.extra.mount).collect(),
|
||||
));
|
||||
}
|
||||
let (_, grid, mounted) = self.mobs.as_ref()?;
|
||||
grid.candidates(area, out).then(|| mounted.contains(&cart))
|
||||
}
|
||||
}
|
||||
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
|
||||
/// the usual case while carts tick in order) or found by a scan. A world
|
||||
/// with hundreds of mineshaft chest carts asked this of every cart several
|
||||
/// times a tick.
|
||||
fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
|
||||
if carts.get(hint).is_some_and(|c| c.id == id) {
|
||||
return Some(hint);
|
||||
}
|
||||
carts.iter().position(|c| c.id == id)
|
||||
}
|
||||
/// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`:
|
||||
/// the push the cart loses (the entity gets a quarter of it back).
|
||||
pub(super) fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
|
||||
@@ -2984,177 +2892,6 @@ impl Game<'_> {
|
||||
.render_state(&scene, state.0, m, light, out);
|
||||
}
|
||||
}
|
||||
/// Saving and loading.
|
||||
impl Minecart {
|
||||
pub fn to_nbt(
|
||||
&self,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&horizon_world::nbt::Tag>,
|
||||
) -> horizon_world::nbt::Tag {
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
|
||||
self.kind.id(),
|
||||
self.position,
|
||||
self.velocity,
|
||||
[self.yaw, self.pitch],
|
||||
(self.on_ground, self.fall_distance),
|
||||
uuid,
|
||||
raw,
|
||||
);
|
||||
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
// ContainerEntity.addChestVehicleSaveData belongs only to the
|
||||
// container subclasses, never to a plain or specialized cart.
|
||||
if self.kind.slots() == 0 {
|
||||
map.remove(&JavaString::from("Items"));
|
||||
}
|
||||
if self.kind == CartKind::CommandBlock {
|
||||
for key in ["LastOutput", "LastExecution"] {
|
||||
map.remove(&JavaString::from(key));
|
||||
}
|
||||
}
|
||||
map.remove(&JavaString::from("DisplayOffset"));
|
||||
map.remove(&JavaString::from("DisplayState"));
|
||||
let mut put = |k: &str, v: Tag| {
|
||||
map.insert(JavaString::from(k), v);
|
||||
};
|
||||
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
|
||||
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
|
||||
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
|
||||
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
|
||||
put("Fire", Tag::Short(0));
|
||||
// addAdditionalSaveData: the offset only off its default, the state only when set.
|
||||
if let Some(offset) = self.custom_offset
|
||||
&& f64::from(offset) != default_display_offset(self.kind)
|
||||
{
|
||||
put("DisplayOffset", Tag::Int(offset));
|
||||
}
|
||||
if let Some(state) = &self.custom_display {
|
||||
put("DisplayState", state.clone());
|
||||
}
|
||||
match self.kind {
|
||||
CartKind::Chest | CartKind::Hopper => {
|
||||
put("Items", horizon_client::save::encode_items(&self.items));
|
||||
if self.kind == CartKind::Hopper {
|
||||
put("Enabled", Tag::Byte(i8::from(self.enabled)));
|
||||
}
|
||||
}
|
||||
CartKind::Furnace => {
|
||||
put("PushX", Tag::Double(self.push.x.to_bits()));
|
||||
put("PushZ", Tag::Double(self.push.z.to_bits()));
|
||||
put("Fuel", Tag::Short(self.fuel as i16));
|
||||
}
|
||||
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
|
||||
// BaseCommandBlock.save (its CustomName shares the entity's key).
|
||||
CartKind::CommandBlock => {
|
||||
for (key, value) in self.command.save() {
|
||||
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
|
||||
CartKind::Spawner => {
|
||||
if let Some(spawner) = &self.spawner {
|
||||
for (key, value) in
|
||||
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
|
||||
{
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
CartKind::Rideable => {}
|
||||
}
|
||||
// A loaded loot table goes back while the items are still its roll;
|
||||
// otherwise the items replace it, as vanilla's unpackLootTable does.
|
||||
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
|
||||
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
|
||||
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
|
||||
{
|
||||
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
|
||||
}
|
||||
} else {
|
||||
map.remove(&JavaString::from("LootTable"));
|
||||
map.remove(&JavaString::from("LootTableSeed"));
|
||||
}
|
||||
}
|
||||
tag
|
||||
}
|
||||
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
|
||||
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
|
||||
let mut c = Self::new(id, kind, position);
|
||||
c.load(tag)?;
|
||||
Some(c)
|
||||
}
|
||||
/// `Entity.load` on this cart: the saved fields are read over the live
|
||||
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
|
||||
/// `None`, with nothing changed, when the compound has no position.
|
||||
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
|
||||
use horizon_world::nbt::Tag;
|
||||
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
|
||||
let kind = self.kind;
|
||||
let c = self;
|
||||
c.position = position;
|
||||
c.previous = position;
|
||||
c.velocity = velocity;
|
||||
c.yaw = yaw;
|
||||
c.previous_yaw = yaw;
|
||||
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
|
||||
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
|
||||
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
|
||||
c.previous_pitch = c.pitch;
|
||||
// ValueInput's numeric getters take any NumericTag: getIntOr is its
|
||||
// intValue, getShortOr the low 16 bits of it, getBooleanOr
|
||||
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
|
||||
let int = |k: &str| {
|
||||
tag.get(k)
|
||||
.and_then(horizon_client_core::nbt_path::int_value)
|
||||
};
|
||||
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
|
||||
let double = |k: &str| match tag.get(k) {
|
||||
Some(Tag::Byte(v)) => f64::from(*v),
|
||||
Some(Tag::Short(v)) => f64::from(*v),
|
||||
Some(Tag::Int(v)) => f64::from(*v),
|
||||
Some(Tag::Long(v)) => *v as f64,
|
||||
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
|
||||
Some(Tag::Double(v)) => f64::from_bits(*v),
|
||||
_ => 0.0,
|
||||
};
|
||||
// Entity.load: OnGround and fall_distance, as saved.
|
||||
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
|
||||
c.flipped = flag("FlippedRotation", false);
|
||||
c.first_tick = flag("HasTicked", false);
|
||||
if kind.slots() > 0 {
|
||||
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
|
||||
}
|
||||
c.enabled = flag("Enabled", true);
|
||||
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
|
||||
// readAdditionalSaveData: the saved push has no y.
|
||||
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
|
||||
c.fuse = int("fuse").unwrap_or(-1);
|
||||
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
|
||||
c.custom_display = tag.get("DisplayState").cloned();
|
||||
c.custom_offset = int("DisplayOffset");
|
||||
if kind == CartKind::CommandBlock {
|
||||
c.command = horizon_client::command_block::CommandBlock::load(tag);
|
||||
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
|
||||
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
|
||||
if c.synced_command != c.command.command {
|
||||
c.synced_command.clone_from(&c.command.command);
|
||||
c.command.set_command(&c.synced_command);
|
||||
}
|
||||
c.synced_output = c.command.last_output_string();
|
||||
}
|
||||
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
|
||||
if kind == CartKind::Spawner {
|
||||
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
|
||||
tag,
|
||||
)));
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `VehicleEntity.hurtServer`'s destroy test: a creative player, damage past
|
||||
/// 40, or (`MinecartTNT.shouldSourceDestroy`) fire or an explosion hitting a
|
||||
/// TNT cart.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! The carts' and mobs' boxes for a tick's cart pushes ([`CartIndex`]).
|
||||
use super::*;
|
||||
|
||||
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
|
||||
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
|
||||
/// every mob of the level. Both answer in slot order, as the scans did.
|
||||
///
|
||||
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
|
||||
/// rebuilt once the list's length changed (a cart gone or added), and dropped
|
||||
/// after a command, which can move or replace any cart
|
||||
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
|
||||
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
|
||||
/// unseen, so with mods loaded both scans stay.
|
||||
#[derive(Default)]
|
||||
pub(in crate::game) struct CartIndex {
|
||||
pub(super) carts: Option<(SeenGrid, Vec<u32>)>,
|
||||
/// The grid, and the carts a mob rides.
|
||||
pub(super) mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
|
||||
pub(super) near: Vec<u32>,
|
||||
}
|
||||
impl CartIndex {
|
||||
pub(super) fn usable() -> bool {
|
||||
horizon_client::mod_api::mods().is_empty()
|
||||
}
|
||||
/// The cart in slot `i` has these bounds now.
|
||||
pub(super) fn moved(&mut self, i: usize, bounds: Aabb) {
|
||||
if let Some((grid, ids)) = &mut self.carts
|
||||
&& i < ids.len()
|
||||
{
|
||||
grid.update(i, bounds);
|
||||
}
|
||||
}
|
||||
pub(super) fn forget_carts(&mut self) {
|
||||
self.carts = None;
|
||||
}
|
||||
/// The slots of the carts whose box may meet `area`, for the cart in slot
|
||||
/// `i` (just moved); false: scan them all.
|
||||
pub(super) fn carts_near(
|
||||
&mut self,
|
||||
carts: &[Minecart],
|
||||
i: usize,
|
||||
area: Aabb,
|
||||
out: &mut Vec<u32>,
|
||||
) -> bool {
|
||||
if !Self::usable() {
|
||||
return false;
|
||||
}
|
||||
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
|
||||
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
|
||||
self.carts = Some((
|
||||
SeenGrid::new(carts.iter().map(Minecart::bounds)),
|
||||
carts.iter().map(|c| c.id).collect(),
|
||||
));
|
||||
}
|
||||
let Some((grid, ids)) = &mut self.carts else {
|
||||
return false;
|
||||
};
|
||||
grid.update(i, carts[i].bounds());
|
||||
if !grid.candidates(area, out) {
|
||||
return false;
|
||||
}
|
||||
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
|
||||
// A cart was replaced in place: start over next time.
|
||||
self.carts = None;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The slots of the mobs whose box may meet `area` into `out`, and
|
||||
/// whether a mob rides cart `cart`; None: scan them all.
|
||||
pub(super) fn mobs_near(
|
||||
&mut self,
|
||||
mobs: &JoinList<Mob>,
|
||||
cart: u32,
|
||||
area: Aabb,
|
||||
out: &mut Vec<u32>,
|
||||
) -> Option<bool> {
|
||||
if !Self::usable() {
|
||||
return None;
|
||||
}
|
||||
if !self
|
||||
.mobs
|
||||
.as_ref()
|
||||
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
|
||||
{
|
||||
self.mobs = Some((
|
||||
mobs.stamp(),
|
||||
SeenGrid::new(mobs.iter().map(Mob::bounds)),
|
||||
mobs.iter().filter_map(|m| m.extra.mount).collect(),
|
||||
));
|
||||
}
|
||||
let (_, grid, mounted) = self.mobs.as_ref()?;
|
||||
grid.candidates(area, out).then(|| mounted.contains(&cart))
|
||||
}
|
||||
}
|
||||
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
|
||||
/// the usual case while carts tick in order) or found by a scan. A world
|
||||
/// with hundreds of mineshaft chest carts asked this of every cart several
|
||||
/// times a tick.
|
||||
pub(super) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
|
||||
if carts.get(hint).is_some_and(|c| c.id == id) {
|
||||
return Some(hint);
|
||||
}
|
||||
carts.iter().position(|c| c.id == id)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Saving and loading a minecart.
|
||||
use super::*;
|
||||
|
||||
/// Saving and loading.
|
||||
impl Minecart {
|
||||
pub fn to_nbt(
|
||||
&self,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&horizon_world::nbt::Tag>,
|
||||
) -> horizon_world::nbt::Tag {
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
|
||||
self.kind.id(),
|
||||
self.position,
|
||||
self.velocity,
|
||||
[self.yaw, self.pitch],
|
||||
(self.on_ground, self.fall_distance),
|
||||
uuid,
|
||||
raw,
|
||||
);
|
||||
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
// ContainerEntity.addChestVehicleSaveData belongs only to the
|
||||
// container subclasses, never to a plain or specialized cart.
|
||||
if self.kind.slots() == 0 {
|
||||
map.remove(&JavaString::from("Items"));
|
||||
}
|
||||
if self.kind == CartKind::CommandBlock {
|
||||
for key in ["LastOutput", "LastExecution"] {
|
||||
map.remove(&JavaString::from(key));
|
||||
}
|
||||
}
|
||||
map.remove(&JavaString::from("DisplayOffset"));
|
||||
map.remove(&JavaString::from("DisplayState"));
|
||||
let mut put = |k: &str, v: Tag| {
|
||||
map.insert(JavaString::from(k), v);
|
||||
};
|
||||
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
|
||||
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
|
||||
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
|
||||
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
|
||||
put("Fire", Tag::Short(0));
|
||||
// addAdditionalSaveData: the offset only off its default, the state only when set.
|
||||
if let Some(offset) = self.custom_offset
|
||||
&& f64::from(offset) != default_display_offset(self.kind)
|
||||
{
|
||||
put("DisplayOffset", Tag::Int(offset));
|
||||
}
|
||||
if let Some(state) = &self.custom_display {
|
||||
put("DisplayState", state.clone());
|
||||
}
|
||||
match self.kind {
|
||||
CartKind::Chest | CartKind::Hopper => {
|
||||
put("Items", horizon_client::save::encode_items(&self.items));
|
||||
if self.kind == CartKind::Hopper {
|
||||
put("Enabled", Tag::Byte(i8::from(self.enabled)));
|
||||
}
|
||||
}
|
||||
CartKind::Furnace => {
|
||||
put("PushX", Tag::Double(self.push.x.to_bits()));
|
||||
put("PushZ", Tag::Double(self.push.z.to_bits()));
|
||||
put("Fuel", Tag::Short(self.fuel as i16));
|
||||
}
|
||||
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
|
||||
// BaseCommandBlock.save (its CustomName shares the entity's key).
|
||||
CartKind::CommandBlock => {
|
||||
for (key, value) in self.command.save() {
|
||||
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
|
||||
CartKind::Spawner => {
|
||||
if let Some(spawner) = &self.spawner {
|
||||
for (key, value) in
|
||||
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
|
||||
{
|
||||
put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
CartKind::Rideable => {}
|
||||
}
|
||||
// A loaded loot table goes back while the items are still its roll;
|
||||
// otherwise the items replace it, as vanilla's unpackLootTable does.
|
||||
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
|
||||
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
|
||||
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
|
||||
{
|
||||
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
|
||||
}
|
||||
} else {
|
||||
map.remove(&JavaString::from("LootTable"));
|
||||
map.remove(&JavaString::from("LootTableSeed"));
|
||||
}
|
||||
}
|
||||
tag
|
||||
}
|
||||
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
|
||||
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
|
||||
let mut c = Self::new(id, kind, position);
|
||||
c.load(tag)?;
|
||||
Some(c)
|
||||
}
|
||||
/// `Entity.load` on this cart: the saved fields are read over the live
|
||||
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
|
||||
/// `None`, with nothing changed, when the compound has no position.
|
||||
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
|
||||
use horizon_world::nbt::Tag;
|
||||
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
|
||||
let kind = self.kind;
|
||||
let c = self;
|
||||
c.position = position;
|
||||
c.previous = position;
|
||||
c.velocity = velocity;
|
||||
c.yaw = yaw;
|
||||
c.previous_yaw = yaw;
|
||||
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
|
||||
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
|
||||
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
|
||||
c.previous_pitch = c.pitch;
|
||||
// ValueInput's numeric getters take any NumericTag: getIntOr is its
|
||||
// intValue, getShortOr the low 16 bits of it, getBooleanOr
|
||||
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
|
||||
let int = |k: &str| {
|
||||
tag.get(k)
|
||||
.and_then(horizon_client_core::nbt_path::int_value)
|
||||
};
|
||||
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
|
||||
let double = |k: &str| match tag.get(k) {
|
||||
Some(Tag::Byte(v)) => f64::from(*v),
|
||||
Some(Tag::Short(v)) => f64::from(*v),
|
||||
Some(Tag::Int(v)) => f64::from(*v),
|
||||
Some(Tag::Long(v)) => *v as f64,
|
||||
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
|
||||
Some(Tag::Double(v)) => f64::from_bits(*v),
|
||||
_ => 0.0,
|
||||
};
|
||||
// Entity.load: OnGround and fall_distance, as saved.
|
||||
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
|
||||
c.flipped = flag("FlippedRotation", false);
|
||||
c.first_tick = flag("HasTicked", false);
|
||||
if kind.slots() > 0 {
|
||||
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
|
||||
}
|
||||
c.enabled = flag("Enabled", true);
|
||||
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
|
||||
// readAdditionalSaveData: the saved push has no y.
|
||||
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
|
||||
c.fuse = int("fuse").unwrap_or(-1);
|
||||
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
|
||||
c.custom_display = tag.get("DisplayState").cloned();
|
||||
c.custom_offset = int("DisplayOffset");
|
||||
if kind == CartKind::CommandBlock {
|
||||
c.command = horizon_client::command_block::CommandBlock::load(tag);
|
||||
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
|
||||
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
|
||||
if c.synced_command != c.command.command {
|
||||
c.synced_command.clone_from(&c.command.command);
|
||||
c.command.set_command(&c.synced_command);
|
||||
}
|
||||
c.synced_output = c.command.last_output_string();
|
||||
}
|
||||
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
|
||||
if kind == CartKind::Spawner {
|
||||
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
|
||||
tag,
|
||||
)));
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
@@ -157,7 +157,7 @@ impl Game<'_> {
|
||||
/// The numbers `Mob.compareArmor` and `compareWeapons` read off `stack`
|
||||
/// in `slot` for the mob `m`.
|
||||
pub(super) fn stack_facts(&self, m: &Mob, stack: &ItemStack, slot: Slot) -> Facts {
|
||||
let (armor, toughness, attack) = self.attributes(m, &stack.item, slot);
|
||||
let (armor, toughness, attack) = self.attributes(m, stack, slot);
|
||||
Facts {
|
||||
armor,
|
||||
armor_toughness: toughness,
|
||||
@@ -173,22 +173,18 @@ impl Game<'_> {
|
||||
|
||||
/// `Mob.getApproximateAttributeWith` for the item `id` in `slot`: each
|
||||
/// attribute starts from the mob's own value. (armour, toughness, damage)
|
||||
fn attributes(&self, m: &Mob, id: &str, slot: Slot) -> (f64, f64, f64) {
|
||||
let empty: &[(String, String, f64, String, String)] = &[];
|
||||
let modifiers = self
|
||||
.items()
|
||||
.get(id)
|
||||
.map_or(empty, |i| i.modifiers.as_slice());
|
||||
fn attributes(&self, m: &Mob, stack: &ItemStack, slot: Slot) -> (f64, f64, f64) {
|
||||
let modifiers = self.items().attribute_modifiers(stack);
|
||||
(
|
||||
equip::attribute_with(modifiers, "minecraft:armor", f64::from(m.kind.armor), slot),
|
||||
equip::attribute_with(&modifiers, "minecraft:armor", f64::from(m.kind.armor), slot),
|
||||
equip::attribute_with(
|
||||
modifiers,
|
||||
&modifiers,
|
||||
"minecraft:armor_toughness",
|
||||
f64::from(m.kind.armor_toughness),
|
||||
slot,
|
||||
),
|
||||
equip::attribute_with(
|
||||
modifiers,
|
||||
&modifiers,
|
||||
"minecraft:attack_damage",
|
||||
f64::from(m.attrs.attack_damage),
|
||||
slot,
|
||||
|
||||
@@ -10,11 +10,7 @@ use horizon_client::{
|
||||
net::FRIEND,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
#[cfg(feature = "friends")]
|
||||
use horizon_friends::invite::{self, Parsed};
|
||||
use std::borrow::Cow;
|
||||
#[cfg(feature = "friends")]
|
||||
use std::net::SocketAddr;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// HUD and first-person hand visibility. Other hand-render restrictions still apply.
|
||||
@@ -109,28 +105,11 @@ impl NativeWorldId {
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of a friend's invite anyone may see. A host's code is fresh each
|
||||
/// time it shares, so a friend's world is known for that sharing only. The
|
||||
/// relay is kept beside the addresses: two hosts on other networks can share
|
||||
/// a private address and the fixed direct port.
|
||||
#[cfg(feature = "friends")]
|
||||
fn invite_public_part(code: &str) -> String {
|
||||
match invite::parse(code) {
|
||||
Some(Parsed::Short(code)) => code.nameplate,
|
||||
Some(Parsed::Long(invite)) => {
|
||||
let relay = invite
|
||||
.relay
|
||||
.map(|r| format!("{}/{}", r.address, r.code.nameplate));
|
||||
let endpoints = invite.endpoints.iter().map(SocketAddr::to_string);
|
||||
endpoints.chain(relay).collect::<Vec<_>>().join(",")
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
/// The part of a friend's invite anyone may see (`horizon_hooks::tunnel::Resolver::public_part`).
|
||||
/// A host's code is fresh each time it shares, so a friend's world is known for that sharing only.
|
||||
/// Without Play with Friends no invite is read: a `friend:` world shows no part of it.
|
||||
#[cfg(not(feature = "friends"))]
|
||||
fn invite_public_part(_code: &str) -> String {
|
||||
String::new()
|
||||
fn invite_public_part(code: &str) -> String {
|
||||
horizon_hooks::engine::tunnel().map_or_else(String::new, |r| r.public_part(code))
|
||||
}
|
||||
|
||||
/// A living entity known to the client, before interpolation. No authoritative writes.
|
||||
@@ -558,8 +537,6 @@ impl Game<'_> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[cfg(feature = "friends")]
|
||||
use horizon_friends::code::Code;
|
||||
|
||||
#[test]
|
||||
fn a_world_is_its_save_in_single_player_and_its_address_on_a_server() {
|
||||
@@ -573,52 +550,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "friends")]
|
||||
fn friends_worlds_are_told_apart_by_their_hosts_never_by_their_secrets() {
|
||||
const PORT: u16 = 50000;
|
||||
const RELAY: &str = "relay.example.com:443";
|
||||
// A short code: its nameplate, then its secret.
|
||||
let short = |nameplate: &str, secret: &str| Code {
|
||||
nameplate: nameplate.into(),
|
||||
secret: secret.into(),
|
||||
};
|
||||
let invite = |secret: u8, host: &str, relay: Option<Code>| {
|
||||
let code = invite::Invite {
|
||||
secret: [secret; invite::SECRET],
|
||||
port: PORT,
|
||||
endpoints: vec![format!("{host}:{PORT}").parse().unwrap()],
|
||||
relay: relay.map(|code| invite::RelayRef {
|
||||
address: RELAY.into(),
|
||||
code,
|
||||
}),
|
||||
}
|
||||
.encode();
|
||||
NativeWorldId::of(None, &format!("{FRIEND}{code}"))
|
||||
};
|
||||
assert_ne!(
|
||||
invite(1, "192.168.1.5", None),
|
||||
invite(1, "192.168.1.6", None),
|
||||
"two friends"
|
||||
);
|
||||
assert_ne!(
|
||||
invite(1, "192.168.1.5", Some(short("ABCD", "WXYZ"))),
|
||||
invite(1, "192.168.1.5", Some(short("EFGH", "WXYZ"))),
|
||||
"two friends on other networks behind the same private address"
|
||||
);
|
||||
assert_eq!(
|
||||
invite(1, "192.168.1.5", Some(short("ABCD", "WXYZ"))),
|
||||
invite(2, "192.168.1.5", Some(short("ABCD", "QRST"))),
|
||||
"the invite's and the relay code's secrets take no part"
|
||||
);
|
||||
let typed = |code: Code| NativeWorldId::of(None, &format!("{FRIEND}{code}"));
|
||||
assert_eq!(typed(short("ABCD", "WXYZ")), typed(short("ABCD", "QRST")));
|
||||
assert_eq!(
|
||||
typed(short("ABCD", "WXYZ")),
|
||||
NativeWorldId::Server(format!("{FRIEND}ABCD"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hud_tells_chat_and_death_from_other_screens() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -102,8 +102,16 @@ impl Game<'_> {
|
||||
if food && m.health < m.attrs.max_health {
|
||||
// feed(player, hand, stack, 2, 1).
|
||||
let sound = nautilus::eat_sound(m);
|
||||
// TamableAnimal.feed: heals 2 x the food's nutrition, 1 for an
|
||||
// item with no food component.
|
||||
let nutrition = self
|
||||
.res
|
||||
.items()
|
||||
.get(item)
|
||||
.and_then(|i| i.food.as_ref())
|
||||
.map(|f| f.0 as i32);
|
||||
let m = &mut self.mobs[index];
|
||||
m.heal(2.0);
|
||||
m.heal(nautilus::feed_heal(nutrition));
|
||||
self.use_nautilus_food(item);
|
||||
self.nautilus_sound(index, sound);
|
||||
return true;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -226,6 +226,25 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
/// `Entity.collide`'s `getEntityCollisions` for a body a piston or a
|
||||
/// shulker lid pushes: the entities it can collide with
|
||||
/// (`canCollideWith`, which for every mover but a vehicle is
|
||||
/// `canBeCollidedWith`): a boat, a living shulker, a happy ghast held
|
||||
/// still. A pushed boat or minecart collides by `canVehicleCollide`
|
||||
/// instead, which this doesn't cover, and the player moves itself.
|
||||
fn pushed_entity_colliders(&self, target: Target) -> Vec<Aabb> {
|
||||
match target {
|
||||
Target::Entity(EntityHandle::Boat(_) | EntityHandle::Cart(_)) => Vec::new(),
|
||||
Target::Entity(handle) => {
|
||||
let own = with_entity!(self, handle, entity => Entity::bounds(entity));
|
||||
let mut boxes = self.item_entity_collisions();
|
||||
boxes.retain(|b| *b != own);
|
||||
boxes
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The collision view of the moving piston at `pos` takes the progress
|
||||
/// its tick gave it. One no longer moving keeps its entry until
|
||||
/// `sync_moving_pistons`.
|
||||
@@ -284,6 +303,7 @@ impl Game<'_> {
|
||||
self.shulker_push_player(movement);
|
||||
return;
|
||||
}
|
||||
let entities = self.pushed_entity_colliders(target);
|
||||
let Ok(moved) = pushed_movement(
|
||||
SceneCollisions {
|
||||
world: &self.world,
|
||||
@@ -298,6 +318,7 @@ impl Game<'_> {
|
||||
body.step_height,
|
||||
body.on_ground,
|
||||
None,
|
||||
&entities,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -665,6 +686,7 @@ impl Game<'_> {
|
||||
self.projectile_piston_block_effects(i, at);
|
||||
}
|
||||
self.piston_block_triggers(before, before);
|
||||
self.release_after_block_move(target);
|
||||
return;
|
||||
}
|
||||
// `Entity.move`: a projectile a cobweb or powder snow holds stops dead
|
||||
@@ -695,6 +717,7 @@ impl Game<'_> {
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let entities = self.pushed_entity_colliders(target);
|
||||
let resolved = with_piston_noclip(piston_direction, || {
|
||||
pushed_movement(
|
||||
SceneCollisions {
|
||||
@@ -709,6 +732,7 @@ impl Game<'_> {
|
||||
step_height,
|
||||
on_ground,
|
||||
tnt.as_ref().map(|t| t.3),
|
||||
&entities,
|
||||
)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
});
|
||||
@@ -747,6 +771,9 @@ impl Game<'_> {
|
||||
|| limited.length_squared() - resolved.length_squared() < 1.0e-7;
|
||||
if moved {
|
||||
self.apply_push(target, resolved);
|
||||
} else {
|
||||
// A move the collisions cut to nothing still ends in the check.
|
||||
self.release_after_block_move(target);
|
||||
}
|
||||
if let Some((i, from, ..)) = tnt {
|
||||
self.finish_tnt_piston_move(i, from, resolved);
|
||||
@@ -864,26 +891,37 @@ impl Game<'_> {
|
||||
}
|
||||
Target::Entity(handle) => {
|
||||
with_entity_mut!(self, handle, entity => entity.move_by_piston(movement));
|
||||
let Some(bounds) =
|
||||
with_entity!(self, handle, entity => entity.piston_release_bounds())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let no_block_collision = {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
no_collision_empty(&mut scene, bounds)
|
||||
};
|
||||
with_entity_mut!(self, handle, entity => {
|
||||
entity.release_after_piston(no_block_collision)
|
||||
});
|
||||
self.release_after_block_move(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractArrow.move` with a mover other than the arrow itself (a
|
||||
/// piston, a shulker lid): after `Entity.move`, whether or not it went
|
||||
/// anywhere, a stuck arrow or trident with nothing to collide with within
|
||||
/// 0.06 starts falling (`shouldFall`, `startFalling`). Its own tick only
|
||||
/// checks once the block at its position changes, a tick later.
|
||||
fn release_after_block_move(&mut self, target: Target) {
|
||||
let Target::Entity(handle) = target else {
|
||||
return;
|
||||
};
|
||||
let Some(bounds) = with_entity!(self, handle, entity => entity.piston_release_bounds())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let no_block_collision = {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
no_collision_empty(&mut scene, bounds)
|
||||
};
|
||||
with_entity_mut!(self, handle, entity => {
|
||||
entity.release_after_piston(no_block_collision)
|
||||
});
|
||||
}
|
||||
|
||||
/// `causeBounce`: a slime block sets the velocity along the movement axis.
|
||||
fn bounce(&mut self, target: Target, movement: Direction) {
|
||||
match target {
|
||||
@@ -939,19 +977,16 @@ fn pushed_movement<W>(
|
||||
step_height: f32,
|
||||
on_ground: bool,
|
||||
context: Option<CollisionContext<'_>>,
|
||||
entities: &[Aabb],
|
||||
) -> horizon_client::Result<Vec3>
|
||||
where
|
||||
W: BlockCollisionWorld<Error = std::io::Error> + EntityMovementWorld<Error = std::io::Error>,
|
||||
{
|
||||
// Without the entity's own context, the one `resolve` reads at its feet.
|
||||
let context = context.unwrap_or_else(|| CollisionContext::position(before.min_position().y));
|
||||
EntityCollisions::with_border(blocks, border, at).resolve_with_context(
|
||||
before,
|
||||
movement,
|
||||
step_height,
|
||||
on_ground,
|
||||
context,
|
||||
)
|
||||
let mut collisions = EntityCollisions::with_border(blocks, border, at);
|
||||
collisions.entities = entities;
|
||||
collisions.resolve_with_context(before, movement, step_height, on_ground, context)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -980,6 +1015,7 @@ mod tests {
|
||||
0.0,
|
||||
false,
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.expect("the push resolves");
|
||||
// The wall stops the body 0.4 blocks short of the whole push.
|
||||
@@ -1003,6 +1039,7 @@ mod tests {
|
||||
0.0,
|
||||
false,
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.expect("the push resolves");
|
||||
assert!(
|
||||
@@ -1521,6 +1558,48 @@ mod h255_mob_piston_ground_tests {
|
||||
&mut std::collections::HashMap::new(),
|
||||
);
|
||||
}
|
||||
/// `AbstractArrow.move` after a push the collisions cut to nothing: a stuck
|
||||
/// arrow with nothing within 0.06 of it starts falling all the same.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_stuck_arrow_whose_push_a_wall_stops_falls_when_nothing_holds_it() {
|
||||
with_game(|game| {
|
||||
assert!(game.run_command("setblock 6 202 4 minecraft:stone"));
|
||||
game.creatures.projectiles.clear();
|
||||
assert!(game.run_command("summon minecraft:arrow 5.75 202.5 4.5 {NoGravity:0b}"));
|
||||
let i = game
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.position(|p| p.arrow.is_some())
|
||||
.expect("the arrow");
|
||||
{
|
||||
let p = &mut game.creatures.projectiles[i];
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.stuck = true;
|
||||
p.arrow.as_mut().unwrap().in_ground = true;
|
||||
}
|
||||
let p = &game.creatures.projectiles[i];
|
||||
let (bounds, at) = (p.bounds(), [p.position.x, p.position.z]);
|
||||
let before = p.position;
|
||||
game.move_pushed(
|
||||
Target::Entity(EntityHandle::Projectile(i)),
|
||||
bounds,
|
||||
at,
|
||||
0.0,
|
||||
false,
|
||||
Vec3::new(0.3, 0.0, 0.0),
|
||||
Direction::East,
|
||||
&mut std::collections::HashMap::new(),
|
||||
);
|
||||
let p = &game.creatures.projectiles[i];
|
||||
assert_eq!(p.position, before, "the stone stops the push");
|
||||
assert!(
|
||||
!p.arrow.as_ref().unwrap().in_ground,
|
||||
"nothing holds it, so it falls"
|
||||
);
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h255_added_no_request_and_exhausted_budget_keep_state() {
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
//! Registered play-Ping regression with the production sender-flush completion boundary.
|
||||
use crate::game::{Game, lan::LanSettings};
|
||||
use horizon_client::lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig};
|
||||
use horizon_protocol::{
|
||||
buf::Reader,
|
||||
packets::{
|
||||
configuration::ClientInformation,
|
||||
play::{Clientbound as C, Serverbound as S},
|
||||
status,
|
||||
},
|
||||
};
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
fn decode(packet: S) -> (S, usize) {
|
||||
let bytes = packet.encode();
|
||||
let mut reader = Reader::new(&bytes);
|
||||
let id = reader.varint().unwrap();
|
||||
(S::decode(id, reader.rest()).unwrap(), bytes.len())
|
||||
}
|
||||
|
||||
fn output(rx: &mpsc::Receiver<Out>) -> Vec<C> {
|
||||
rx.try_iter()
|
||||
.flat_map(|out| match out {
|
||||
Out::Packet(body) => vec![body],
|
||||
Out::Packets(bodies) => bodies,
|
||||
Out::Disconnect(reason) => panic!("unexpected disconnect: {reason:?}"),
|
||||
})
|
||||
.map(|body| {
|
||||
println!("actual outbound play body={body:02x?}");
|
||||
let mut reader = Reader::new(&body);
|
||||
let id = reader.varint().unwrap();
|
||||
C::decode(id, reader.rest()).unwrap()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pass(game: &mut Game<'_>, conn: u32, packet: S) {
|
||||
let (packet, bytes) = decode(packet);
|
||||
if let S::PingRequest(p) = &packet {
|
||||
println!(
|
||||
"actual decoded play PingRequest conn={conn} value={} bits={:016x}",
|
||||
p.time, p.time as u64
|
||||
);
|
||||
}
|
||||
let hub = &game.lan.as_ref().unwrap().hub;
|
||||
assert_eq!(hub.guests.len(), 2);
|
||||
assert_ne!(hub.guests[0].uuid, hub.guests[1].uuid);
|
||||
assert_ne!(hub.guests[0].conn, hub.guests[1].conn);
|
||||
let registered = hub.guest(conn).expect("origin connection registered");
|
||||
assert_eq!(registered.uuid, u128::from(conn));
|
||||
assert!(!registered.fake && !registered.sender.is_fake());
|
||||
assert!(registered.left_reason.is_empty());
|
||||
assert_eq!(registered.dimension, "minecraft:overworld");
|
||||
let guest = game.lan.as_mut().unwrap().hub.guest_mut(conn).unwrap();
|
||||
guest.inbox.push_back((packet, bytes));
|
||||
guest.inbox_bytes += bytes;
|
||||
// Already registered connections: this is a second level pass, without polling sockets.
|
||||
game.level_pass.first = false;
|
||||
game.level_pass.packets_drained = false;
|
||||
let clock_before = game.world.game_time;
|
||||
game.tick_lan_packets();
|
||||
// tick_lan_packets has ended its Holding scope; production flushes these senders later.
|
||||
horizon_client::lan::flush_all(
|
||||
game.lan
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.map(|g| &g.sender),
|
||||
);
|
||||
assert_eq!(
|
||||
game.world.game_time, clock_before,
|
||||
"sender completion adds no world tick"
|
||||
);
|
||||
println!("actual sender completion conn={conn} world_clock={clock_before}");
|
||||
assert!(
|
||||
game.level_pass.packets_drained,
|
||||
"real Game packet phase completed"
|
||||
);
|
||||
assert!(
|
||||
game.lan
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.hub
|
||||
.guest(conn)
|
||||
.unwrap()
|
||||
.inbox
|
||||
.is_empty(),
|
||||
"decoded request was drained by the actual Hub"
|
||||
);
|
||||
}
|
||||
|
||||
fn fixture() -> (Game<'static>, [mpsc::Receiver<Out>; 2]) {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.lan_host(
|
||||
LanSettings {
|
||||
port: 0,
|
||||
guest_commands: false,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::loopback(0, "play-ping-test".into(), 2)
|
||||
},
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: false,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 2,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let receivers = [1, 2].map(|conn| {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn,
|
||||
uuid: u128::from(conn),
|
||||
name: format!("PingGuest{conn}"),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
conn as i32,
|
||||
[0.5, 80.0, 0.5],
|
||||
2,
|
||||
);
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
guest.loading = false;
|
||||
guest.load_timeout = 0;
|
||||
guest.last_action_tick = 7;
|
||||
game.lan.as_mut().unwrap().hub.guests.push(guest);
|
||||
rx
|
||||
});
|
||||
game.lan.as_mut().unwrap().hub.ticks = 123;
|
||||
(game, receivers)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
|
||||
fn registered_play_ping_echoes_exact_long_to_only_its_connection() {
|
||||
let (mut game, receivers) = fixture();
|
||||
for (turn, time) in [
|
||||
1,
|
||||
0,
|
||||
-1,
|
||||
i64::MIN,
|
||||
i64::MAX,
|
||||
0x0123_4567_89ab_cdef,
|
||||
0xfedc_ba98_7654_3210_u64 as i64,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let index = turn % 2;
|
||||
let conn = index as u32 + 1;
|
||||
pass(&mut game, conn, S::PingRequest(status::Ping { time }));
|
||||
let packets = output(&receivers[index]);
|
||||
let echoed: Vec<_> = packets
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
C::PongResponse(p) => Some(p.time),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
echoed,
|
||||
vec![time],
|
||||
"missing or incorrect registered Game play-Ping reply"
|
||||
);
|
||||
assert_eq!(packets.len(), 1, "one request produces exactly one reply");
|
||||
assert_eq!(
|
||||
game.lan
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.hub
|
||||
.guest(conn)
|
||||
.unwrap()
|
||||
.last_action_tick,
|
||||
7,
|
||||
"transport ping does not count as a player action"
|
||||
);
|
||||
assert!(
|
||||
output(&receivers[1 - index]).is_empty(),
|
||||
"no reply to the other connection"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
|
||||
fn dead_guest_play_ping_is_transport_not_an_action() {
|
||||
let (mut game, receivers) = fixture();
|
||||
game.lan.as_mut().unwrap().hub.guest_mut(1).unwrap().dead = true;
|
||||
pass(
|
||||
&mut game,
|
||||
1,
|
||||
S::PingRequest(status::Ping { time: i64::MIN }),
|
||||
);
|
||||
let packets = output(&receivers[0]);
|
||||
assert!(matches!(packets.as_slice(), [C::PongResponse(p)] if p.time == i64::MIN));
|
||||
assert!(output(&receivers[1]).is_empty());
|
||||
assert_eq!(
|
||||
game.lan
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.hub
|
||||
.guest(1)
|
||||
.unwrap()
|
||||
.last_action_tick,
|
||||
7
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
|
||||
fn server_ping_client_pong_keeps_the_existing_prediction_route() {
|
||||
let (mut game, receivers) = fixture();
|
||||
let hub = &mut game.lan.as_mut().unwrap().hub;
|
||||
let mut config = horizon_anticheat::Config::default();
|
||||
config.log.clear();
|
||||
config.strikes.clear();
|
||||
config.prediction.enabled = true;
|
||||
hub.set_anticheat(config);
|
||||
hub.ticks = 123;
|
||||
let holding = horizon_client::lan::Holding::start();
|
||||
hub.anticheat_ping();
|
||||
drop(holding);
|
||||
// Ending Holding is not delivery: independently admit the existing flush boundary first.
|
||||
for receiver in &receivers {
|
||||
assert!(
|
||||
output(receiver).is_empty(),
|
||||
"actual server Ping is unavailable before flush"
|
||||
);
|
||||
}
|
||||
println!("actual reverse Ping unavailable after Holding drop, before flush");
|
||||
horizon_client::lan::flush_all(hub.guests.iter().map(|g| &g.sender));
|
||||
for receiver in &receivers {
|
||||
assert!(matches!(output(receiver).as_slice(), [C::Ping(p)] if p.id == 123));
|
||||
}
|
||||
pass(
|
||||
&mut game,
|
||||
1,
|
||||
S::Pong(horizon_protocol::packets::configuration::Ping { id: 123 }),
|
||||
);
|
||||
let record = game
|
||||
.lan
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.hub
|
||||
.guest_mut(1)
|
||||
.unwrap()
|
||||
.anticheat
|
||||
.record
|
||||
.as_deref_mut()
|
||||
.expect("existing client Pong updates prediction");
|
||||
assert_eq!(record.tick_end(0, false).seen, Some(123));
|
||||
for receiver in &receivers {
|
||||
assert!(output(receiver).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
|
||||
fn held_guest_play_ping_survives_the_acknowledgement_filter() {
|
||||
let (mut game, receivers) = fixture();
|
||||
let guest = game.lan.as_mut().unwrap().hub.guest_mut(1).unwrap();
|
||||
guest.placed = false;
|
||||
guest.held = true;
|
||||
pass(&mut game, 1, S::PingRequest(status::Ping { time: -1 }));
|
||||
assert!(matches!(output(&receivers[0]).as_slice(), [C::PongResponse(p)] if p.time == -1));
|
||||
assert!(output(&receivers[1]).is_empty());
|
||||
assert_eq!(
|
||||
game.lan
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.hub
|
||||
.guest(1)
|
||||
.unwrap()
|
||||
.last_action_tick,
|
||||
7
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
|
||||
fn handover_guest_play_ping_survives_the_acknowledgement_filter() {
|
||||
let (mut game, receivers) = fixture();
|
||||
let handover = horizon_client::lan::arrivals::Handover {
|
||||
id: 0x1234,
|
||||
since: 123,
|
||||
record: serde_json::json!({"fixture": "handover-state-sentinel"}),
|
||||
};
|
||||
game.lan
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.hub
|
||||
.guest_mut(1)
|
||||
.unwrap()
|
||||
.handover = Some(handover.clone());
|
||||
pass(
|
||||
&mut game,
|
||||
1,
|
||||
S::PingRequest(status::Ping {
|
||||
time: 0xfedc_ba98_7654_3210_u64 as i64,
|
||||
}),
|
||||
);
|
||||
let packets = output(&receivers[0]);
|
||||
assert!(
|
||||
matches!(packets.as_slice(), [C::PongResponse(p)]
|
||||
if p.time == 0xfedc_ba98_7654_3210_u64 as i64),
|
||||
"missing matching handover play-Ping reply"
|
||||
);
|
||||
assert!(output(&receivers[1]).is_empty());
|
||||
let guest = game.lan.as_ref().unwrap().hub.guest(1).unwrap();
|
||||
assert_eq!(guest.last_action_tick, 7);
|
||||
assert_eq!(guest.handover.as_ref(), Some(&handover));
|
||||
}
|
||||
@@ -726,6 +726,37 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
|
||||
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
|
||||
/// moves only while the world runs.
|
||||
pub(super) fn visual_ticks(&self) -> u64 {
|
||||
if self.clock.still() {
|
||||
self.world.game_time.max(0) as u64
|
||||
} else {
|
||||
self.ticks
|
||||
}
|
||||
}
|
||||
|
||||
/// The clock of the animated textures: `Minecraft.runTick` ticks the `TextureManager`
|
||||
/// only while the level runs normally. Under `HORIZON_STILL` it is the world's ticks.
|
||||
pub(super) fn texture_clock(&self) -> u64 {
|
||||
if self.clock.still() {
|
||||
self.visual_ticks()
|
||||
} else {
|
||||
self.texture_ticks
|
||||
}
|
||||
}
|
||||
|
||||
/// `Util.getMillis` for what the frame shows (the world border's scroll, the hearts' blink,
|
||||
/// the glint's under the switch): under `HORIZON_STILL`, the world's ticks in milliseconds.
|
||||
pub(super) fn visual_millis(&self) -> i64 {
|
||||
if self.clock.still() {
|
||||
self.visual_ticks() as i64 * 50
|
||||
} else {
|
||||
self.started.elapsed().as_millis() as i64
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// What the packs' Polytone animation modes read this tick (wiki "Variant Textures"): the
|
||||
|
||||
@@ -292,10 +292,8 @@ fn pack_particles(
|
||||
}
|
||||
|
||||
/// The packs' custom colours; their dye and sheep colours go to the draw paths' table.
|
||||
fn read_colors(root: &std::path::Path) -> horizon_client::polytone_colors::PackColors {
|
||||
let colors = horizon_client::polytone_colors::PackColors::read(root);
|
||||
horizon_client::pack_palette::set(&colors);
|
||||
horizon_client::pack_palette::set_item_bars(root);
|
||||
horizon_client::brewing::set_pack_colors(colors.effects());
|
||||
fn read_colors(root: &std::path::Path) -> horizon_client::pack_palette::PackColors {
|
||||
let colors = horizon_client::pack_palette::read(root);
|
||||
horizon_client::brewing::set_pack_colors(colors.effects.clone());
|
||||
colors
|
||||
}
|
||||
@@ -141,11 +141,12 @@ fn pause_buttons(
|
||||
remote: bool,
|
||||
links: bool,
|
||||
additions: bool,
|
||||
split: bool,
|
||||
rows: &[(&'static str, u8)],
|
||||
) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
|
||||
let row3 = links || additions;
|
||||
let (x0, y0) = pause_grid(w, h, u8::from(row3) + u8::from(split));
|
||||
let down = 24.0 * f32::from(u8::from(row3) + u8::from(split));
|
||||
let extra = u8::from(row3) + rows.len() as u8;
|
||||
let (x0, y0) = pause_grid(w, h, extra);
|
||||
let down = 24.0 * f32::from(extra);
|
||||
let mut v = vec![
|
||||
(x0 + 4.0, y0 + 50.0, 204.0, "menu.returnToGame", 0, true),
|
||||
(x0 + 4.0, y0 + 74.0, 98.0, "gui.advancements", 4, true),
|
||||
@@ -159,9 +160,9 @@ fn pause_buttons(
|
||||
// CommonDialogData.computeExternalTitle of Dialogs.SERVER_LINKS.
|
||||
v.push((x0 + 4.0, y0 + 122.0, 204.0, "menu.server_links", 11, true));
|
||||
}
|
||||
if split {
|
||||
let y = y0 + 122.0 + if row3 { 24.0 } else { 0.0 };
|
||||
v.push((x0 + 4.0, y, 204.0, SPLIT_SETTINGS, 30, true));
|
||||
for (k, &(key, id)) in rows.iter().enumerate() {
|
||||
let y = y0 + 122.0 + 24.0 * (f32::from(u8::from(row3)) + k as f32);
|
||||
v.push((x0 + 4.0, y, 204.0, key, id, true));
|
||||
}
|
||||
v.extend([
|
||||
(x0 + 4.0, y0 + 122.0 + down, 98.0, "menu.options", 1, true),
|
||||
@@ -228,14 +229,17 @@ fn pause_icon_focus_ids(friends: bool) -> Vec<u8> {
|
||||
.collect()
|
||||
}
|
||||
const SPLIT_SETTINGS: &str = "horizon.pages.split_settings";
|
||||
/// The pause menu's first id for the pages crates registered there: id `ADDED_PAGES + i` opens
|
||||
/// registered page `i`.
|
||||
const ADDED_PAGES: u8 = 40;
|
||||
fn pause_icons(
|
||||
w: f32,
|
||||
h: f32,
|
||||
links: bool,
|
||||
additions: bool,
|
||||
split: bool,
|
||||
rows: &[(&'static str, u8)],
|
||||
) -> [(f32, f32, &'static str); 4] {
|
||||
let (x0, y0) = pause_grid(w, h, u8::from(links || additions) + u8::from(split));
|
||||
let (x0, y0) = pause_grid(w, h, u8::from(links || additions) + rows.len() as u8);
|
||||
let (x0, y) = (x0 + 60.0, y0 + 98.0);
|
||||
[
|
||||
(x0, y, "gui/sprites/pause_menu/bug"),
|
||||
@@ -307,7 +311,7 @@ impl Game<'_> {
|
||||
self.remote.is_some() && self.integrated.is_none()
|
||||
}
|
||||
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
|
||||
let friends = self.friends_view().shown;
|
||||
let friends = self.friends_shown();
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
@@ -331,7 +335,7 @@ impl Game<'_> {
|
||||
self.external_server(),
|
||||
links,
|
||||
additions.is_some(),
|
||||
self.options.split_guest || self.split_hud.menu,
|
||||
&self.pause_rows(),
|
||||
) {
|
||||
let is_focused = self.pause_focus == Some(id);
|
||||
let hover = active && (ui::hovered(self.cursor, x, y, bw, 20.0) || is_focused);
|
||||
@@ -358,15 +362,9 @@ impl Game<'_> {
|
||||
tooltip = Some(Text::translate("menu.custom_options.tooltip", &[]));
|
||||
}
|
||||
}
|
||||
for (i, (x, y, sprite)) in pause_icons(
|
||||
w,
|
||||
h,
|
||||
links,
|
||||
additions.is_some(),
|
||||
self.options.split_guest || self.split_hud.menu,
|
||||
)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
for (i, (x, y, sprite)) in pause_icons(w, h, links, additions.is_some(), &self.pause_rows())
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let active = icon_active(i, friends);
|
||||
let is_focused = (self.pause_focus == Some(10) && i == FRIENDS_ICON)
|
||||
@@ -375,11 +373,7 @@ impl Game<'_> {
|
||||
// The icon has no label: name it for controller focus and the
|
||||
// control socket's widget tree.
|
||||
if i == FRIENDS_ICON {
|
||||
horizon_client::widgets::record(
|
||||
horizon_client::friends_screen::TITLE,
|
||||
[x, y, 20.0, 20.0],
|
||||
active,
|
||||
);
|
||||
horizon_client::widgets::record(super::friends::TITLE, [x, y, 20.0, 20.0], active);
|
||||
}
|
||||
ui::button(
|
||||
batch,
|
||||
@@ -416,7 +410,7 @@ impl Game<'_> {
|
||||
batch.sprite_scaled(atlas, sprite, x + 3.0, y + 3.0, 15.0, 15.0, u32::MAX);
|
||||
if hover {
|
||||
tooltip = Some(if i == FRIENDS_ICON {
|
||||
Text::literal(horizon_client::friends_screen::title())
|
||||
Text::literal(super::friends::title())
|
||||
} else {
|
||||
Text::translate(ICON_LABELS[i], &[])
|
||||
});
|
||||
@@ -456,6 +450,10 @@ impl Game<'_> {
|
||||
// PauseScreen: `new SocialInteractionsScreen(this)`.
|
||||
15 => self.open_social(true),
|
||||
30 => self.open_split_settings(),
|
||||
id @ ADDED_PAGES.. => {
|
||||
let page = usize::from(id - ADDED_PAGES);
|
||||
self.open_options_at(false, horizon_client::options_ui::Page::Added(page));
|
||||
}
|
||||
// addCustomDialogButtons: DialogConnectionAccess.openDialog of the
|
||||
// additions dialog, from the pause menu.
|
||||
14 => {
|
||||
@@ -471,6 +469,23 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The pause menu's full-width rows above Options: Splitscreen's settings, then the pages
|
||||
/// crates registered for the pause menu (`horizon_hooks::pages`), by their title.
|
||||
fn pause_rows(&self) -> Vec<(&'static str, u8)> {
|
||||
let split = self.options.split_guest || self.split_hud.menu;
|
||||
let pages = horizon_hooks::pages::engine::all().iter().enumerate();
|
||||
let added = pages
|
||||
.filter(|(i, page)| {
|
||||
page.place() == horizon_hooks::pages::Place::Pause
|
||||
&& *i < usize::from(u8::MAX - ADDED_PAGES)
|
||||
})
|
||||
.map(|(i, page)| (page.title(), ADDED_PAGES + i as u8));
|
||||
split
|
||||
.then_some((SPLIT_SETTINGS, 30))
|
||||
.into_iter()
|
||||
.chain(added)
|
||||
.collect()
|
||||
}
|
||||
pub(super) fn pause_click(&mut self) -> Result<()> {
|
||||
let (w, h) = {
|
||||
let size = self
|
||||
@@ -482,13 +497,7 @@ impl Game<'_> {
|
||||
};
|
||||
let links = !self.server_links().is_empty();
|
||||
let additions = self.pause_screen_additions();
|
||||
let icons = pause_icons(
|
||||
w,
|
||||
h,
|
||||
links,
|
||||
additions.is_some(),
|
||||
self.options.split_guest || self.split_hud.menu,
|
||||
);
|
||||
let icons = pause_icons(w, h, links, additions.is_some(), &self.pause_rows());
|
||||
let friends = icons[FRIENDS_ICON];
|
||||
let hit = pause_buttons(
|
||||
w,
|
||||
@@ -496,7 +505,7 @@ impl Game<'_> {
|
||||
self.external_server(),
|
||||
links,
|
||||
additions.is_some(),
|
||||
self.options.split_guest || self.split_hud.menu,
|
||||
&self.pause_rows(),
|
||||
)
|
||||
.into_iter()
|
||||
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
|
||||
@@ -509,9 +518,8 @@ impl Game<'_> {
|
||||
.map(|i| 12 + i as u8)
|
||||
})
|
||||
.or_else(|| {
|
||||
(self.friends_view().shown
|
||||
&& ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
|
||||
.then_some(10)
|
||||
(self.friends_shown() && ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
|
||||
.then_some(10)
|
||||
});
|
||||
if let Some(id) = hit {
|
||||
self.pause_focus = Some(id);
|
||||
@@ -536,10 +544,10 @@ impl Game<'_> {
|
||||
self.external_server(),
|
||||
links,
|
||||
self.pause_screen_additions().is_some(),
|
||||
self.options.split_guest || self.split_hud.menu,
|
||||
&self.pause_rows(),
|
||||
);
|
||||
let mut active_ids: Vec<u8> = buttons.into_iter().filter(|b| b.5).map(|b| b.4).collect();
|
||||
let friends = self.friends_view().shown;
|
||||
let friends = self.friends_shown();
|
||||
active_ids.extend(pause_icon_focus_ids(friends));
|
||||
if active_ids.is_empty() {
|
||||
return Ok(());
|
||||
@@ -584,20 +592,20 @@ impl Game<'_> {
|
||||
self.external_server(),
|
||||
links,
|
||||
self.pause_screen_additions().is_some(),
|
||||
self.options.split_guest || self.split_hud.menu,
|
||||
&self.pause_rows(),
|
||||
);
|
||||
let mut rects: Vec<(u8, f32, f32, f32)> = buttons
|
||||
.into_iter()
|
||||
.filter(|b| b.5)
|
||||
.map(|b| (b.4, b.0, b.1, b.2))
|
||||
.collect();
|
||||
let friends = self.friends_view().shown;
|
||||
let friends = self.friends_shown();
|
||||
for (i, (x, y, _)) in pause_icons(
|
||||
w,
|
||||
h,
|
||||
links,
|
||||
self.pause_screen_additions().is_some(),
|
||||
self.options.split_guest || self.split_hud.menu,
|
||||
&self.pause_rows(),
|
||||
)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
@@ -915,7 +923,10 @@ impl Game<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Screen, death_buttons, pause_buttons, pause_icon_focus_ids, pause_icons};
|
||||
use super::{
|
||||
ADDED_PAGES, SPLIT_SETTINGS, Screen, death_buttons, pause_buttons, pause_icon_focus_ids,
|
||||
pause_icons,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn death_buttons_sit_on_whole_gui_pixels() {
|
||||
@@ -998,7 +1009,7 @@ mod tests {
|
||||
#[test]
|
||||
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
|
||||
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
|
||||
let buttons = pause_buttons(427.0, 240.0, remote, false, false, false);
|
||||
let buttons = pause_buttons(427.0, 240.0, remote, false, false, &[]);
|
||||
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
|
||||
assert_eq!(quit.3, label);
|
||||
assert!(quit.5);
|
||||
@@ -1023,14 +1034,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn server_links_adds_a_full_width_row_above_options() {
|
||||
let with = pause_buttons(427.0, 240.0, true, true, false, false);
|
||||
let without = pause_buttons(427.0, 240.0, true, false, false, false);
|
||||
let with = pause_buttons(427.0, 240.0, true, true, false, &[]);
|
||||
let without = pause_buttons(427.0, 240.0, true, false, false, &[]);
|
||||
let links = with.iter().find(|b| b.4 == 11).unwrap();
|
||||
assert_eq!(links.2, 204.0);
|
||||
assert_eq!(links.3, "menu.server_links");
|
||||
assert!(links.5);
|
||||
// The icon row sits above it; Options and Disconnect move down 24.
|
||||
let icons = pause_icons(427.0, 240.0, true, false, false);
|
||||
let icons = pause_icons(427.0, 240.0, true, false, &[]);
|
||||
let y0_icons = ((240.0f32 - 190.0) * 0.25).floor() + 98.0;
|
||||
assert_eq!(icons[0].1, y0_icons);
|
||||
assert!(links.1 > icons[0].1);
|
||||
@@ -1046,8 +1057,8 @@ mod tests {
|
||||
fn pause_screen_additions_takes_the_server_links_row() {
|
||||
// getCustomAdditions: one additions button in row 3, never next to the
|
||||
// links button, so the grid keeps one extra row either way.
|
||||
let with = pause_buttons(427.0, 240.0, true, true, true, false);
|
||||
let links = pause_buttons(427.0, 240.0, true, true, false, false);
|
||||
let with = pause_buttons(427.0, 240.0, true, true, true, &[]);
|
||||
let links = pause_buttons(427.0, 240.0, true, true, false, &[]);
|
||||
assert!(with.iter().all(|b| b.4 != 11));
|
||||
let button = with.iter().find(|b| b.4 == 14).unwrap();
|
||||
assert_eq!(
|
||||
@@ -1060,14 +1071,42 @@ mod tests {
|
||||
assert_eq!(add.1, link.1);
|
||||
}
|
||||
assert_eq!(
|
||||
pause_icons(427.0, 240.0, true, true, false)[0].1,
|
||||
pause_icons(427.0, 240.0, true, false, false)[0].1
|
||||
pause_icons(427.0, 240.0, true, true, &[])[0].1,
|
||||
pause_icons(427.0, 240.0, true, false, &[])[0].1
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn registered_pages_stack_above_options_in_the_pause_menu() {
|
||||
let rows = [
|
||||
(SPLIT_SETTINGS, 30),
|
||||
("test.page.a", ADDED_PAGES),
|
||||
("test.page.b", ADDED_PAGES + 2),
|
||||
];
|
||||
let buttons = pause_buttons(427.0, 300.0, true, false, false, &rows);
|
||||
let y = |id| buttons.iter().find(|b| b.4 == id).unwrap().1;
|
||||
assert_eq!(y(ADDED_PAGES), y(30) + 24.0);
|
||||
assert_eq!(y(ADDED_PAGES + 2), y(ADDED_PAGES) + 24.0);
|
||||
assert_eq!(
|
||||
y(1),
|
||||
y(ADDED_PAGES + 2) + 24.0,
|
||||
"Options under the last one"
|
||||
);
|
||||
// The grid stays centred: three more rows start it higher.
|
||||
let without = pause_buttons(427.0, 300.0, true, false, false, &[]);
|
||||
let back = |b: &[(f32, f32, f32, &str, u8, bool)]| b.iter().find(|b| b.4 == 0).unwrap().1;
|
||||
assert!(back(&buttons) < back(&without));
|
||||
}
|
||||
#[test]
|
||||
fn split_settings_preserves_dialog_rows_and_icon_actions() {
|
||||
for (links, additions) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
let buttons = pause_buttons(427.0, 300.0, true, links, additions, true);
|
||||
let buttons = pause_buttons(
|
||||
427.0,
|
||||
300.0,
|
||||
true,
|
||||
links,
|
||||
additions,
|
||||
&[(SPLIT_SETTINGS, 30)],
|
||||
);
|
||||
let split = buttons.iter().find(|b| b.4 == 30).unwrap();
|
||||
let options = buttons.iter().find(|b| b.4 == 1).unwrap();
|
||||
assert_eq!(options.1, split.1 + 24.0);
|
||||
@@ -1080,7 +1119,7 @@ mod tests {
|
||||
}
|
||||
assert!(buttons.iter().all(|b| b.4 != 12 && b.4 != 13));
|
||||
assert!(
|
||||
!pause_buttons(427.0, 300.0, true, links, additions, false)
|
||||
!pause_buttons(427.0, 300.0, true, links, additions, &[])
|
||||
.iter()
|
||||
.any(|b| b.4 == 30)
|
||||
);
|
||||
|
||||
@@ -359,12 +359,19 @@ impl Game<'_> {
|
||||
self.sculk.tracker = WardenSpawnTracker::from_nbt(tag);
|
||||
}
|
||||
}
|
||||
/// Write the tracker into the player data being saved.
|
||||
/// The tracker as saved, once it has been loaded.
|
||||
pub(super) fn warden_tracker_tag(&self) -> Option<horizon_world::nbt::Tag> {
|
||||
self.sculk
|
||||
.tracker_loaded
|
||||
.then(|| self.sculk.tracker.to_nbt())
|
||||
}
|
||||
/// Write the tracker into the player data being saved. With a LAN owner,
|
||||
/// `store_owner` rewrites that from the owner's guest, so it puts the
|
||||
/// tracker there as well (`playerdata::with_warden`).
|
||||
pub(super) fn save_warden_tracker(&mut self) {
|
||||
if !self.sculk.tracker_loaded {
|
||||
let Some(tag) = self.warden_tracker_tag() else {
|
||||
return;
|
||||
}
|
||||
let tag = self.sculk.tracker.to_nbt();
|
||||
};
|
||||
let extra = self
|
||||
.level
|
||||
.player_extra
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
//! the time left. Vanilla has no such commands: they reach operators' command trees (completions included)
|
||||
//! as a native mod's commands do (`moderation_commands`), and the game runs them itself.
|
||||
use super::{Game, chat_filter::Reply, dedicated, lan::LanHost};
|
||||
use horizon_chat_filter::{duration_text, now_millis, parse_duration};
|
||||
use horizon_client::{mod_api::NativeCommand, text::Text};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use horizon_protocol::text::translatable;
|
||||
use horizon_server_files::duration::{duration_text, now_millis, parse_duration};
|
||||
use horizon_server_files::user_lists::{BanEntry, BanList, NameAndId, format_date};
|
||||
|
||||
/// The operator level `/tempban` needs, as `/ban`'s.
|
||||
|
||||
@@ -273,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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -504,6 +504,69 @@ impl ServerAdvancements {
|
||||
w.f32(r["experience"].as_f64().unwrap_or(0.0) as f32);
|
||||
(cooking_category(kind, r), vec![&r["ingredient"]])
|
||||
}
|
||||
// DyeRecipe.display: the target, then the dyes kept to those with
|
||||
// the DYE component, shown as the demo of dyeing that target.
|
||||
// Its placement is the target and the dye.
|
||||
"minecraft:crafting_dye" => {
|
||||
let Some(dye_component) = sync.id("minecraft:data_component_type", "minecraft:dye")
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let (target, dye) = (&r["target"], &r["dye"]);
|
||||
w.varint(
|
||||
sync.id("minecraft:recipe_display", "minecraft:crafting_shapeless")
|
||||
.unwrap_or(0),
|
||||
);
|
||||
w.varint(2);
|
||||
w.varint(slot("minecraft:tag"));
|
||||
holder_set(&mut w, target, &item);
|
||||
w.varint(slot("minecraft:only_with_component"));
|
||||
w.varint(slot("minecraft:tag"));
|
||||
holder_set(&mut w, dye, &item);
|
||||
w.varint(dye_component);
|
||||
w.varint(slot("minecraft:dyed"));
|
||||
w.varint(slot("minecraft:only_with_component"));
|
||||
w.varint(slot("minecraft:tag"));
|
||||
holder_set(&mut w, dye, &item);
|
||||
w.varint(dye_component);
|
||||
w.varint(slot("minecraft:tag"));
|
||||
holder_set(&mut w, target, &item);
|
||||
station(&mut w, "minecraft:crafting_table");
|
||||
(
|
||||
crafting_category(r["category"].as_str().unwrap_or("")).name(),
|
||||
vec![target, dye],
|
||||
)
|
||||
}
|
||||
// ImbueRecipe.display: four materials, the source kept to any
|
||||
// potion, four materials again, and the potion-wrapped result.
|
||||
// Its placement names the source in the middle cell.
|
||||
"minecraft:crafting_imbue" => {
|
||||
let (source, material) = (&r["source"], &r["material"]);
|
||||
w.varint(
|
||||
sync.id("minecraft:recipe_display", "minecraft:crafting_shaped")
|
||||
.unwrap_or(0),
|
||||
);
|
||||
w.varint(3);
|
||||
w.varint(3);
|
||||
w.varint(9);
|
||||
for cell in 0..9 {
|
||||
if cell == 4 {
|
||||
w.varint(slot("minecraft:with_any_potion"));
|
||||
}
|
||||
w.varint(slot("minecraft:tag"));
|
||||
holder_set(&mut w, if cell == 4 { source } else { material }, &item);
|
||||
}
|
||||
w.varint(slot("minecraft:with_any_potion"));
|
||||
result(&mut w);
|
||||
station(&mut w, "minecraft:crafting_table");
|
||||
(
|
||||
crafting_category(r["category"].as_str().unwrap_or("")).name(),
|
||||
vec![
|
||||
material, material, material, material, source, material, material,
|
||||
material, material,
|
||||
],
|
||||
)
|
||||
}
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
self.entry_tail(&mut w, r, sync, category, &placed, flags);
|
||||
@@ -756,7 +819,15 @@ mod tests {
|
||||
"minecraft:redstone_ore": {"protocol_id": 2},
|
||||
"minecraft:redstone": {"protocol_id": 3},
|
||||
"minecraft:furnace": {"protocol_id": 4},
|
||||
"minecraft:blast_furnace": {"protocol_id": 5}
|
||||
"minecraft:blast_furnace": {"protocol_id": 5},
|
||||
"minecraft:leather_helmet": {"protocol_id": 6},
|
||||
"minecraft:crafting_table": {"protocol_id": 7},
|
||||
"minecraft:arrow": {"protocol_id": 8},
|
||||
"minecraft:lingering_potion": {"protocol_id": 9},
|
||||
"minecraft:tipped_arrow": {"protocol_id": 10}
|
||||
}},
|
||||
"minecraft:data_component_type": {"entries": {
|
||||
"minecraft:dye": {"protocol_id": 46}
|
||||
}},
|
||||
"minecraft:slot_display": {"entries": {
|
||||
"minecraft:empty": {"protocol_id": 0},
|
||||
@@ -962,6 +1033,105 @@ mod tests {
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
/// DyeRecipe.display: the target, then the dyes kept to what carries the
|
||||
/// DYE component, the demo of dyeing the target with one, a crafting
|
||||
/// table; its placement is the target and the dye.
|
||||
#[test]
|
||||
fn a_dye_recipe_reaches_the_book_as_its_dyed_demo() {
|
||||
let root = resources(
|
||||
"dye_recipe",
|
||||
&[(
|
||||
"leather_helmet_dyed",
|
||||
r##"{"type":"minecraft:crafting_dye","dye":"#minecraft:dyes",
|
||||
"group":"dyed_armor","result":{"id":"minecraft:leather_helmet"},
|
||||
"target":"minecraft:leather_helmet"}"##,
|
||||
)],
|
||||
);
|
||||
let (sync, e) = entry(&root, "minecraft:leather_helmet_dyed");
|
||||
let item = |n: &str| sync.id("minecraft:item", n).unwrap();
|
||||
let leather = item("minecraft:leather_helmet");
|
||||
let dye = SlotDisplay::OnlyWithComponent(
|
||||
Box::new(SlotDisplay::Tag(HolderSet::Tag("minecraft:dyes".into()))),
|
||||
sync.id("minecraft:data_component_type", "minecraft:dye")
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
e.display,
|
||||
RecipeDisplay::Shapeless {
|
||||
ingredients: vec![
|
||||
SlotDisplay::Tag(HolderSet::Items(vec![leather])),
|
||||
dye.clone(),
|
||||
],
|
||||
result: SlotDisplay::Dyed {
|
||||
dye: Box::new(dye),
|
||||
target: Box::new(SlotDisplay::Tag(HolderSet::Items(vec![leather]))),
|
||||
},
|
||||
station: SlotDisplay::Item(item("minecraft:crafting_table")),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
e.requirements,
|
||||
Some(vec![
|
||||
HolderSet::Items(vec![leather]),
|
||||
HolderSet::Tag("minecraft:dyes".into()),
|
||||
])
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// ImbueRecipe.display: a 3×3 of four materials, the source kept to any
|
||||
/// potion, four materials again; the result is potion-wrapped, and the
|
||||
/// source sits in the middle of the placement.
|
||||
#[test]
|
||||
fn an_imbue_recipe_reaches_the_book_with_any_potion() {
|
||||
let root = resources(
|
||||
"imbue_recipe",
|
||||
&[(
|
||||
"tipped_arrow",
|
||||
r#"{"type":"minecraft:crafting_imbue","material":"minecraft:arrow",
|
||||
"result":{"count":8,"id":"minecraft:tipped_arrow"},
|
||||
"source":"minecraft:lingering_potion"}"#,
|
||||
)],
|
||||
);
|
||||
let (sync, e) = entry(&root, "minecraft:tipped_arrow");
|
||||
let item = |n: &str| sync.id("minecraft:item", n).unwrap();
|
||||
let material = SlotDisplay::Tag(HolderSet::Items(vec![item("minecraft:arrow")]));
|
||||
let source = SlotDisplay::Tag(HolderSet::Items(vec![item("minecraft:lingering_potion")]));
|
||||
let mut cells = vec![material.clone(); 4];
|
||||
cells.push(SlotDisplay::WithAnyPotion(Box::new(source.clone())));
|
||||
cells.extend(std::iter::repeat_n(material, 4));
|
||||
assert_eq!(
|
||||
e.display,
|
||||
RecipeDisplay::Shaped {
|
||||
width: 3,
|
||||
height: 3,
|
||||
ingredients: cells,
|
||||
result: SlotDisplay::WithAnyPotion(Box::new(SlotDisplay::ItemStack(Stack::new(
|
||||
item("minecraft:tipped_arrow"),
|
||||
8,
|
||||
)))),
|
||||
station: SlotDisplay::Item(item("minecraft:crafting_table")),
|
||||
}
|
||||
);
|
||||
let arrow = HolderSet::Items(vec![item("minecraft:arrow")]);
|
||||
let potion = HolderSet::Items(vec![item("minecraft:lingering_potion")]);
|
||||
assert_eq!(
|
||||
e.requirements,
|
||||
Some(vec![
|
||||
arrow.clone(),
|
||||
arrow.clone(),
|
||||
arrow.clone(),
|
||||
arrow.clone(),
|
||||
potion,
|
||||
arrow.clone(),
|
||||
arrow.clone(),
|
||||
arrow.clone(),
|
||||
arrow,
|
||||
])
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recipe_with_an_unknown_ingredient_kind_stays_out_of_the_book() {
|
||||
let root = resources(
|
||||
|
||||
@@ -38,6 +38,7 @@ use horizon_world::{
|
||||
};
|
||||
use std::sync::{Arc, Mutex, atomic::Ordering};
|
||||
|
||||
mod acknowledgements;
|
||||
/// GameEvent types (ClientboundGameEventPacket).
|
||||
pub mod chat;
|
||||
pub mod chat_filter;
|
||||
@@ -2416,41 +2417,6 @@ impl Hub {
|
||||
events
|
||||
}
|
||||
|
||||
/// Keeps, in a frozen player's inbox, the packets that answer the server
|
||||
/// (teleport, chunk batch, loaded, keep-alive, ping, tick end, client
|
||||
/// information) and drops the rest. A chat message or signed command is
|
||||
/// dropped too, but the messages it says the client has seen are taken
|
||||
/// first (`unpackAndApplyLastSeen`), and a chat acknowledgement is handled
|
||||
/// here, in the order they came: the client counts them as acknowledged,
|
||||
/// and its next message after the freeze would not validate otherwise.
|
||||
pub(crate) fn keep_acknowledgements(&mut self, i: usize, events: &mut Vec<HostEvent>) {
|
||||
let inbox = std::mem::take(&mut self.guests[i].inbox);
|
||||
let mut bytes = 0;
|
||||
for (packet, size) in inbox {
|
||||
match &packet {
|
||||
S::Chat(c) => {
|
||||
self.apply_last_seen(i, &c.last_seen, events);
|
||||
}
|
||||
S::ChatCommandSigned(c) => {
|
||||
self.apply_last_seen(i, &c.last_seen, events);
|
||||
}
|
||||
S::ChatAck(c) => self.chat_ack(i, c.offset, events),
|
||||
S::AcceptTeleportation(_)
|
||||
| S::PlayerLoaded(_)
|
||||
| S::ChunkBatchReceived(_)
|
||||
| S::ClientInformation(_)
|
||||
| S::ClientTickEnd(_)
|
||||
| S::KeepAlive(_)
|
||||
| S::Pong(_) => {
|
||||
bytes += size;
|
||||
self.guests[i].inbox.push_back((packet, size));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.guests[i].inbox_bytes = bytes;
|
||||
}
|
||||
|
||||
/// Handle the players in this level's packets (`ServerGamePacketListenerImpl`
|
||||
/// for each), before the level's scheduled block ticks so a place landed in
|
||||
/// the same tick as a falling block's schedule still sees the support
|
||||
@@ -4069,7 +4035,8 @@ impl Hub {
|
||||
| S::RecipeBookSeenRecipe(_)
|
||||
| S::ClientTickEnd(_)
|
||||
| S::KeepAlive(_)
|
||||
| S::Pong(_) => false,
|
||||
| S::Pong(_)
|
||||
| S::PingRequest(_) => false,
|
||||
S::MovePlayerPos(p) => self.moved_far(i, [p.x, p.y, p.z]),
|
||||
S::MovePlayerPosRot(p) => self.moved_far(i, [p.x, p.y, p.z]),
|
||||
S::PlayerInput(_) => self.guests[i].client_loaded(),
|
||||
@@ -4167,6 +4134,8 @@ impl Hub {
|
||||
g.max_unacknowledged = 10;
|
||||
}
|
||||
S::Pong(p) => self.anticheat_pong(i, p.id),
|
||||
// ServerGamePacketListenerImpl.handlePingRequest: echo on this connection.
|
||||
S::PingRequest(p) => self.send_to(conn, &C::PongResponse(p)),
|
||||
// handleAttack is the game's too: a move queued behind an attack would move
|
||||
// the player (or land them on the ground, losing a falling attack's critical
|
||||
// hit) before the game checks their reach and state.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Transport acknowledgements retained while a guest is held or handed over.
|
||||
use super::*;
|
||||
|
||||
impl Hub {
|
||||
/// Keeps, in a frozen player's inbox, the packets that answer the server
|
||||
/// (teleport, chunk batch, loaded, keep-alive, ping, tick end, client
|
||||
/// information) and drops the rest. A chat message or signed command is
|
||||
/// dropped too, but the messages it says the client has seen are taken
|
||||
/// first (`unpackAndApplyLastSeen`), and a chat acknowledgement is handled
|
||||
/// here, in the order they came: the client counts them as acknowledged,
|
||||
/// and its next message after the freeze would not validate otherwise.
|
||||
pub(crate) fn keep_acknowledgements(&mut self, i: usize, events: &mut Vec<HostEvent>) {
|
||||
let inbox = std::mem::take(&mut self.guests[i].inbox);
|
||||
let mut bytes = 0;
|
||||
for (packet, size) in inbox {
|
||||
match &packet {
|
||||
S::Chat(c) => {
|
||||
self.apply_last_seen(i, &c.last_seen, events);
|
||||
}
|
||||
S::ChatCommandSigned(c) => {
|
||||
self.apply_last_seen(i, &c.last_seen, events);
|
||||
}
|
||||
S::ChatAck(c) => self.chat_ack(i, c.offset, events),
|
||||
S::AcceptTeleportation(_)
|
||||
| S::PlayerLoaded(_)
|
||||
| S::ChunkBatchReceived(_)
|
||||
| S::ClientInformation(_)
|
||||
| S::ClientTickEnd(_)
|
||||
| S::KeepAlive(_)
|
||||
| S::Pong(_)
|
||||
| S::PingRequest(_) => {
|
||||
bytes += size;
|
||||
self.guests[i].inbox.push_back((packet, size));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.guests[i].inbox_bytes = bytes;
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,27 @@ pub fn exists(world: &Path, uuid: u128) -> bool {
|
||||
path(world, uuid).is_file()
|
||||
}
|
||||
|
||||
/// The key `ServerPlayer.addAdditionalSaveData` stores the `WardenSpawnTracker` under.
|
||||
pub const WARDEN_TRACKER_KEY: &str = "warden_spawn_tracker";
|
||||
|
||||
/// Put a player's `warden_spawn_tracker` into the compound `store` writes from
|
||||
/// (`guest.saved`), so a save keeps it rather than the older one loaded with
|
||||
/// the file (the LAN owner's tracker lives in `Game`, `save_warden_tracker`).
|
||||
/// Other saved keys are left as they are; no tracker (not loaded yet) changes
|
||||
/// nothing.
|
||||
pub fn with_warden(guest: &mut Guest, tracker: Option<Tag>) -> &mut Guest {
|
||||
let Some(tracker) = tracker else {
|
||||
return guest;
|
||||
};
|
||||
let saved = guest
|
||||
.saved
|
||||
.get_or_insert_with(|| Tag::Compound(Default::default()));
|
||||
if let Tag::Compound(map) = saved {
|
||||
map.insert(WARDEN_TRACKER_KEY.into(), tracker);
|
||||
}
|
||||
guest
|
||||
}
|
||||
|
||||
/// `PlayerDataStorage.load`: a guest's saved state, if they have been here
|
||||
/// before. The player's file, else its `.dat_old` (what a save cut off
|
||||
/// between its two renames leaves, `write_dat`).
|
||||
@@ -651,6 +672,42 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// #9965: the owner's warden warning level and cooldown reach the file on
|
||||
/// a save, instead of the ones the file was loaded with.
|
||||
#[test]
|
||||
fn a_stored_warden_tracker_replaces_the_loaded_one() {
|
||||
let mut g = guest();
|
||||
g.saved = Some(Tag::compound(vec![
|
||||
(
|
||||
"warden_spawn_tracker",
|
||||
Tag::compound(vec![
|
||||
("warning_level", Tag::Int(2)),
|
||||
("cooldown_ticks", Tag::Int(77)),
|
||||
("ticks_since_last_warning", Tag::Int(5)),
|
||||
]),
|
||||
),
|
||||
("unrelated_key", Tag::Int(9)),
|
||||
]));
|
||||
let tracker = crate::sculk::WardenSpawnTracker {
|
||||
ticks_since_last_warning: 0,
|
||||
warning_level: 3,
|
||||
cooldown_ticks: 200,
|
||||
};
|
||||
with_warden(&mut g, Some(tracker.to_nbt()));
|
||||
let (tag, _) = round_trip(&g);
|
||||
let mut back = guest();
|
||||
let mut level = Level::default();
|
||||
level.read_player(&tag);
|
||||
level.player_extra = Some(tag);
|
||||
apply(&mut back, &level);
|
||||
let saved = back.saved.as_ref().unwrap();
|
||||
let read =
|
||||
crate::sculk::WardenSpawnTracker::from_nbt(saved.get("warden_spawn_tracker").unwrap());
|
||||
assert_eq!((read.warning_level, read.cooldown_ticks), (3, 200));
|
||||
assert_eq!(read.ticks_since_last_warning, 0);
|
||||
assert!(matches!(saved.get("unrelated_key"), Some(Tag::Int(9))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_player_faces_the_world_spawn_angle() {
|
||||
let mut g = guest();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -2362,19 +2362,13 @@ fn never_removed_when_far(s: Species) -> bool {
|
||||
| Species::Wither
|
||||
)
|
||||
}
|
||||
/// Movement speed bonus multiplier (baby zombies are 50% faster, etc.).
|
||||
/// The movement speed multiplier of the mob's transient `minecraft:baby`
|
||||
/// modifier (`Zombie.SPEED_MODIFIER_BABY` +0.5, `Piglin.SPEED_MODIFIER_BABY`
|
||||
/// +0.2, both `ADD_MULTIPLIED_BASE`): the same modifier `/attribute` reads, so
|
||||
/// one taken away with `modifier remove` no longer speeds the baby up.
|
||||
pub fn speed_bonus(m: &Mob) -> f64 {
|
||||
match m.species() {
|
||||
Species::Zombie
|
||||
| Species::Husk
|
||||
| Species::Drowned
|
||||
| Species::ZombieVillager
|
||||
| Species::ZombifiedPiglin
|
||||
| Species::Piglin
|
||||
if m.is_baby() =>
|
||||
{
|
||||
1.5
|
||||
}
|
||||
match super::finalize::transient(m, super::finalize::Attr::MovementSpeed) {
|
||||
Some((amount, super::finalize::Op::AddMultipliedBase)) => 1.0 + amount,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
@@ -6290,3 +6284,45 @@ mod hurt_teleport_contact_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod baby_speed_tests {
|
||||
use super::*;
|
||||
use crate::mobs::{finalize, sensor_scan_tests::kind};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn baby(species: Species, id: &str) -> Mob {
|
||||
let mut k = kind();
|
||||
let km = Arc::make_mut(&mut k);
|
||||
km.id = id.into();
|
||||
km.species = species;
|
||||
let mut m = Mob::new(1, k, Vec3::ZERO, 0.0);
|
||||
finalize::set_baby(&mut m, true);
|
||||
m
|
||||
}
|
||||
|
||||
/// #11994: movement reads the transient `minecraft:baby` modifier itself:
|
||||
/// `Zombie.SPEED_MODIFIER_BABY` +0.5 and `Piglin.SPEED_MODIFIER_BABY` +0.2
|
||||
/// (`ADD_MULTIPLIED_BASE`), and none once `/attribute ... modifier remove`
|
||||
/// took it away, until the next `setBaby`.
|
||||
#[test]
|
||||
fn a_babys_speed_follows_its_baby_modifier() {
|
||||
let mut zombie = baby(Species::Zombie, "minecraft:zombie");
|
||||
assert_eq!(speed_bonus(&zombie), 1.5);
|
||||
zombie
|
||||
.extra
|
||||
.attributes
|
||||
.get_or_insert_default()
|
||||
.baby_speed_removed = true;
|
||||
assert_eq!(speed_bonus(&zombie), 1.0, "removed with /attribute");
|
||||
finalize::set_baby(&mut zombie, true);
|
||||
assert_eq!(speed_bonus(&zombie), 1.5, "setBaby adds it back");
|
||||
// The piglin's is 0.2F widened to a double, not the zombie's 0.5.
|
||||
let piglin = baby(Species::Piglin, "minecraft:piglin");
|
||||
assert_eq!(speed_bonus(&piglin), 1.0 + f64::from(0.2f32));
|
||||
let mut adult = baby(Species::Zombie, "minecraft:zombie");
|
||||
finalize::set_baby(&mut adult, false);
|
||||
assert_eq!(speed_bonus(&adult), 1.0);
|
||||
}
|
||||
}
|
||||
@@ -1218,8 +1218,13 @@ impl Goal for Wander {
|
||||
pub fn stable_destination(w: &MobWorld, p: [i32; 3]) -> bool {
|
||||
!w.facts.is_air(w.world.block(p[0], p[1] - 1, p[2]))
|
||||
}
|
||||
/// `Bee.addAdditionalSaveData` (and NeutralMob's `anger_end_time`).
|
||||
pub fn to_nbt(m: &Mob, map: &mut horizon_world::nbt::Compound) {
|
||||
/// `Bee.addAdditionalSaveData` (and NeutralMob's `anger_end_time` and
|
||||
/// `angry_at`).
|
||||
pub fn to_nbt(
|
||||
m: &Mob,
|
||||
map: &mut horizon_world::nbt::Compound,
|
||||
uuid_of: &mut impl FnMut(u32) -> Option<[i32; 4]>,
|
||||
) {
|
||||
use horizon_world::nbt::Tag;
|
||||
let b = get(m).cloned().unwrap_or_default();
|
||||
for (key, pos) in [("hive_pos", b.hive), ("flower_pos", b.flower)] {
|
||||
@@ -1241,6 +1246,20 @@ pub fn to_nbt(m: &Mob, map: &mut horizon_world::nbt::Compound) {
|
||||
map.insert("CannotEnterHiveTicks".into(), Tag::Int(b.stay_out_of_hive));
|
||||
map.insert("CropsGrownSincePollination".into(), Tag::Int(b.crops_grown));
|
||||
map.insert("anger_end_time".into(), Tag::Long(b.anger_end));
|
||||
// `NeutralMob.addPersistentAngerSaveData`: `storeNullable`, nothing when
|
||||
// the bee has no target. A mob target read from the save but not in the
|
||||
// level (`Who::Mob(0)`) keeps the loaded UUID.
|
||||
match b.angry_at {
|
||||
Some(Who::Mob(0)) => {}
|
||||
target => match target.and_then(|who| crate::entity_nbt::who_uuid(who, uuid_of)) {
|
||||
Some(uuid) => {
|
||||
map.insert("angry_at".into(), Tag::IntArray(uuid.to_vec()));
|
||||
}
|
||||
None => {
|
||||
map.remove(&"angry_at".into());
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
/// `Bee.readAdditionalSaveData`.
|
||||
pub fn from_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
|
||||
@@ -1260,6 +1279,13 @@ pub fn from_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
|
||||
Some(Tag::Long(v)) => *v,
|
||||
_ => -1,
|
||||
};
|
||||
// `EntityReference.read`: the player's UUID is the player, any other one a
|
||||
// mob the level may not have loaded (`Who::Mob(0)`).
|
||||
let angry_at = match crate::save::uuid_from_tag(tag.get("angry_at")) {
|
||||
Some(uuid) if uuid == crate::entity_nbt::player_uuid() => Some(Who::Player),
|
||||
Some(_) => Some(Who::Mob(0)),
|
||||
None => None,
|
||||
};
|
||||
let b = st(m);
|
||||
b.ticks_without_nectar = int("TicksSincePollination").unwrap_or(0);
|
||||
b.stay_out_of_hive = int("CannotEnterHiveTicks").unwrap_or(0);
|
||||
@@ -1267,6 +1293,7 @@ pub fn from_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
|
||||
b.hive = pos("hive_pos");
|
||||
b.flower = pos("flower_pos");
|
||||
b.anger_end = anger_end;
|
||||
b.angry_at = angry_at;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1419,4 +1446,39 @@ mod tests {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `NeutralMob.addPersistentAngerSaveData` and
|
||||
/// `readPersistentAngerSaveData`: `anger_end_time` and `angry_at`, the
|
||||
/// reference to the player the bee is angry at (`Bee` keeps the anger
|
||||
/// itself). `mob_to_nbt`/`mob_from_nbt` are the pair `Game::mob_tree_nbt`
|
||||
/// and `Game::load_mob` go through. Horizon modelled the target in
|
||||
/// `Bee.angry_at` and wrote only the timer, so a bee that had stung a
|
||||
/// player came back with its anger still counting but no one to attack.
|
||||
#[test]
|
||||
fn a_bees_anger_target_survives_a_save() {
|
||||
use crate::entity_nbt::{mob_from_nbt, mob_to_nbt, player_uuid};
|
||||
use horizon_world::nbt::Tag;
|
||||
let mut m = bee();
|
||||
{
|
||||
let b = super::st(&mut m);
|
||||
b.anger_end = 500;
|
||||
b.angry_at = Some(Who::Player);
|
||||
}
|
||||
let saved = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(
|
||||
saved.get("angry_at"),
|
||||
Some(&Tag::IntArray(player_uuid().to_vec())),
|
||||
"the save names the player"
|
||||
);
|
||||
let types = std::collections::HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
|
||||
let back = mob_from_nbt(&saved, &types, 2, None).unwrap();
|
||||
let b = super::get(&back).unwrap();
|
||||
assert_eq!(b.angry_at, Some(Who::Player), "the reloaded target");
|
||||
assert_eq!(b.anger_end, 500, "the reloaded timer");
|
||||
// `storeNullable`: a calm bee writes no `angry_at`, even over a
|
||||
// compound that still names one.
|
||||
let calm = bee();
|
||||
let saved = mob_to_nbt(&calm, [1, 2, 3, 4], Some(&saved), None, |_| None);
|
||||
assert_eq!(saved.get("angry_at"), None, "no target, no key");
|
||||
}
|
||||
}
|
||||
@@ -151,20 +151,26 @@ pub fn damage_bonus(levels: &[(Arc<str>, u32)], target: Option<Species>) -> f32
|
||||
/// to the attribute map when the item is equipped.
|
||||
pub fn attack_damage(m: &Mob, items: Option<&crate::items::Items>) -> f32 {
|
||||
let base = f64::from(m.attrs.attack_damage);
|
||||
let modifiers = m
|
||||
.extra
|
||||
.main_hand
|
||||
.as_deref()
|
||||
.and_then(|id| items?.get(id))
|
||||
.map(|i| i.modifiers.as_slice());
|
||||
modifiers.map_or(base, |modifiers| {
|
||||
super::equip::attribute_with(
|
||||
modifiers,
|
||||
"minecraft:attack_damage",
|
||||
base,
|
||||
super::equip::Slot::MainHand,
|
||||
)
|
||||
}) as f32
|
||||
let modifiers = match items {
|
||||
Some(items) => m.extra.main_hand_stack().map_or_else(
|
||||
|| {
|
||||
m.extra
|
||||
.main_hand
|
||||
.as_deref()
|
||||
.and_then(|id| items.get(id))
|
||||
.map(|i| i.modifiers.clone())
|
||||
.unwrap_or_default()
|
||||
},
|
||||
|stack| items.attribute_modifiers(stack),
|
||||
),
|
||||
None => Vec::new(),
|
||||
};
|
||||
super::equip::attribute_with(
|
||||
&modifiers,
|
||||
"minecraft:attack_damage",
|
||||
base,
|
||||
super::equip::Slot::MainHand,
|
||||
) as f32
|
||||
}
|
||||
/// The extra damage of the mob's held weapon against a target species.
|
||||
pub fn melee_bonus(m: &Mob, target: Option<Species>) -> f32 {
|
||||
|
||||
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user