mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Compare commits
173
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16e29a2774 | ||
|
|
e2afd13715 | ||
|
|
00aba43d81 | ||
|
|
e893fd3eac | ||
|
|
ee986c3e62 | ||
|
|
19aabc08c2 | ||
|
|
d016678e24 | ||
|
|
ca3649534d | ||
|
|
a3bf6531e0 | ||
|
|
0ddd7ec41d | ||
|
|
cea1a54e31 | ||
|
|
23dd21e928 | ||
|
|
6cf95bb4d7 | ||
|
|
cf09a6610a | ||
|
|
094bb83dff | ||
|
|
dfc91826bd | ||
|
|
2782c5e810 | ||
|
|
86f1749664 | ||
|
|
d149cc1dca | ||
|
|
ae963b0a98 | ||
|
|
c30911cddf | ||
|
|
38eac394be | ||
|
|
5546650bd7 | ||
|
|
aa8592fbf9 | ||
|
|
c277636360 | ||
|
|
68a01ee7ca | ||
|
|
15e83578ee | ||
|
|
66e8c0d04d | ||
|
|
6bab737cae | ||
|
|
7093c79eeb | ||
|
|
a4da356144 | ||
|
|
6c2c706959 | ||
|
|
6769422c88 | ||
|
|
ebe7b4a4fd | ||
|
|
1cce1cbf31 | ||
|
|
ed5da43953 | ||
|
|
3295c54097 | ||
|
|
d0da7ec157 | ||
|
|
042d2ce46a | ||
|
|
8f756e8487 | ||
|
|
0c77cd5898 | ||
|
|
4848a9eff5 | ||
|
|
95b0cb05e7 | ||
|
|
b7b137a829 | ||
|
|
641a5859a2 | ||
|
|
725208d8de | ||
|
|
3516aa9641 | ||
|
|
cd362fb354 | ||
|
|
7d17f516e5 | ||
|
|
216eca2f51 | ||
|
|
7dccd1fa39 | ||
|
|
e84e5b2dd3 | ||
|
|
7b9f3f8e83 | ||
|
|
c9d73d5bea | ||
|
|
56b703899c | ||
|
|
7e6d2121b7 | ||
|
|
a89865b1c5 | ||
|
|
8902d94a19 | ||
|
|
ca6140d05e | ||
|
|
c51a5eec3f | ||
|
|
a7f07a08de | ||
|
|
c8e48f9cea | ||
|
|
bdb6452da9 | ||
|
|
2ddd9705d9 | ||
|
|
868d3b76a5 | ||
|
|
f2718e086d | ||
|
|
0e528f7287 | ||
|
|
dc382b898a | ||
|
|
b4f24319ac | ||
|
|
1d2dd56a06 | ||
|
|
22f3da10c0 | ||
|
|
7ca4fd68bd | ||
|
|
63dae1216c | ||
|
|
962f5949ac | ||
|
|
5e9024e0a1 | ||
|
|
559c2d4e81 | ||
|
|
ee449e0ef2 | ||
|
|
dc3d97ce25 | ||
|
|
cab5f23234 | ||
|
|
2c5d1990ce | ||
|
|
7383ff1908 | ||
|
|
4f4c87df64 | ||
|
|
e6b0a03270 | ||
|
|
53d1afc5b1 | ||
|
|
a94b5a1b2e | ||
|
|
b4c7f409d2 | ||
|
|
3365fd6bc4 | ||
|
|
8f198d9cbd | ||
|
|
c0da1ac2e1 | ||
|
|
45cefe3278 | ||
|
|
032ae31f34 | ||
|
|
47d55b5690 | ||
|
|
db4a145e6b | ||
|
|
c3c0e2d868 | ||
|
|
528d98bf36 | ||
|
|
7973f9935b | ||
|
|
d119d6da2d | ||
|
|
4bc93d9a5f | ||
|
|
671af567f4 | ||
|
|
1f19e94d74 | ||
|
|
fcbeaefd37 | ||
|
|
8bf0614d9d | ||
|
|
83b3c9306f | ||
|
|
e6184afbe2 | ||
|
|
96c5616505 | ||
|
|
7766619ecb | ||
|
|
ec3e4df6e3 | ||
|
|
a0f02b61ea | ||
|
|
7f2e64b603 | ||
|
|
6e21ce9476 | ||
|
|
861b9e7b16 | ||
|
|
365a44ff91 | ||
|
|
07068153c6 | ||
|
|
b75193985b | ||
|
|
1a07a02990 | ||
|
|
f5c6b7dfd1 | ||
|
|
8d5dd89421 | ||
|
|
6280d2630c | ||
|
|
c221aa9ec9 | ||
|
|
0568b2beff | ||
|
|
3e77ec425f | ||
|
|
0ccd2a4397 | ||
|
|
dd8214d3a3 | ||
|
|
2a018699ac | ||
|
|
74eaac0c21 | ||
|
|
5757de7e74 | ||
|
|
916ab6b8b1 | ||
|
|
b56e58fde3 | ||
|
|
0df07ea3cf | ||
|
|
e527784bc3 | ||
|
|
5dd06d27cb | ||
|
|
3c5a95239d | ||
|
|
678d6da047 | ||
|
|
24c9ad202b | ||
|
|
af593b6559 | ||
|
|
c587403015 | ||
|
|
3ebcf19eed | ||
|
|
f2f02e57bd | ||
|
|
c10aea9eb8 | ||
|
|
bf22beb825 | ||
|
|
10fb981317 | ||
|
|
7f67dc8333 | ||
|
|
3c710450fb | ||
|
|
6a6aa75cb9 | ||
|
|
9b72080c32 | ||
|
|
ed3fabb7b4 | ||
|
|
c93db2be5f | ||
|
|
83c50cf720 | ||
|
|
df6c3384fb | ||
|
|
987dc976d3 | ||
|
|
6187796e59 | ||
|
|
70db97e662 | ||
|
|
cf28dc7745 | ||
|
|
e8196d58c4 | ||
|
|
0ef952921d | ||
|
|
9698eed74f | ||
|
|
f9dfc692e5 | ||
|
|
233e09221d | ||
|
|
af9edec8b9 | ||
|
|
15da067a21 | ||
|
|
067ca7dfb3 | ||
|
|
382776e2ee | ||
|
|
b3dd537570 | ||
|
|
d2dbe9227c | ||
|
|
9163335028 | ||
|
|
d3392cb35b | ||
|
|
26d55e5d39 | ||
|
|
f82a1bd4fb | ||
|
|
60b7307b3f | ||
|
|
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);
|
||||
|
||||
@@ -670,6 +670,39 @@ pub fn with_horizon_icon(
|
||||
attributes
|
||||
}
|
||||
|
||||
/// The screen a window is on: its size in physical pixels, its refresh rate where the OS gives
|
||||
/// it, and its scale. For `horizon-ctl status` and the performance statistics' `system.display`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Display {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub refresh_hz: Option<u32>,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
/// The screen `window` is on, else the primary one, else the only one: right after the window
|
||||
/// is made, Wayland doesn't say yet which screen it is on.
|
||||
pub fn monitor(window: &winit::window::Window) -> Option<winit::monitor::MonitorHandle> {
|
||||
let only = || {
|
||||
let mut all = window.available_monitors();
|
||||
all.next().filter(|_| all.next().is_none())
|
||||
};
|
||||
window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor())
|
||||
.or_else(only)
|
||||
}
|
||||
|
||||
/// [`monitor`]'s size, refresh rate and scale.
|
||||
pub fn display(window: &winit::window::Window) -> Option<Display> {
|
||||
monitor(window).map(|m| Display {
|
||||
width: m.size().width,
|
||||
height: m.size().height,
|
||||
refresh_hz: m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
|
||||
scale: m.scale_factor(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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).
|
||||
@@ -111,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.
|
||||
|
||||
@@ -26,7 +26,6 @@ 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" }
|
||||
|
||||
@@ -121,8 +121,8 @@ impl CommandBlock {
|
||||
condition_met: flag("conditionMet", false),
|
||||
}
|
||||
}
|
||||
/// `CommandBlockEntity.saveAdditional`. The last output is written as a
|
||||
/// plain string component.
|
||||
/// `CommandBlockEntity.saveAdditional`. The last output is the component
|
||||
/// itself (`ComponentSerialization.CODEC`), styles and click events kept.
|
||||
pub fn save(&self) -> Vec<(&'static str, Tag)> {
|
||||
let mut out = vec![
|
||||
("Command", Tag::String(self.command.as_str().into())),
|
||||
@@ -135,7 +135,7 @@ impl CommandBlock {
|
||||
if self.track_output
|
||||
&& let Some(last) = &self.last_output
|
||||
{
|
||||
out.push(("LastOutput", Tag::String(last.plain().as_str().into())));
|
||||
out.push(("LastOutput", scoreboard::component_tag(&last.component())));
|
||||
}
|
||||
out.push((
|
||||
"UpdateLastExecution",
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
|
||||
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
|
||||
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
|
||||
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
|
||||
//! vanilla.
|
||||
//! Custom sky layers: skyboxes a pack draws over the vanilla sky, in a format vanilla doesn't read
|
||||
//! (OptiFine's), which the platform's pack formats fill ([`crate::pack_hooks`]). The layers'
|
||||
//! textures are read once with the pack; each frame the game asks for the layers to draw, their
|
||||
//! brightness and rotation ([`SkyLayers::draws`]), and `gfx::custom_sky` draws them over the
|
||||
//! vanilla sky. A pack without layers keeps the vanilla sky.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
|
||||
use glam::Mat4;
|
||||
|
||||
/// A layer's texture, RGBA8.
|
||||
/// A layer's texture, RGBA8: the skybox's six faces in a 3x2 grid, bottom, top and south in the
|
||||
/// top row, west, north and east below (`gfx::custom_sky`'s faces).
|
||||
pub struct Image {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
|
||||
/// over all worlds, in `worlds` order).
|
||||
#[derive(Default)]
|
||||
pub struct CustomSky {
|
||||
worlds: Vec<(i32, Vec<SkyLayer>)>,
|
||||
pub images: Vec<Image>,
|
||||
/// How a layer mixes with what is drawn below it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Blend {
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
Dodge,
|
||||
Burn,
|
||||
Screen,
|
||||
Replace,
|
||||
Overlay,
|
||||
Alpha,
|
||||
}
|
||||
|
||||
/// One layer of a frame: which texture, with which blend, how bright and turned how.
|
||||
@@ -34,62 +41,44 @@ pub struct Draw {
|
||||
pub rotation: Mat4,
|
||||
}
|
||||
|
||||
/// The weather of a frame: thunder is a storm's rain, so it is only `Thunder`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Weather {
|
||||
Clear,
|
||||
Rain,
|
||||
Thunder,
|
||||
}
|
||||
|
||||
/// What a frame's layers may depend on.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Conditions<'a> {
|
||||
/// Ticks of the day, counting the days.
|
||||
pub day_time: i64,
|
||||
pub weather: Weather,
|
||||
/// The biome at the camera, namespaced.
|
||||
pub biome: &'a str,
|
||||
/// The camera's block height.
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
/// The eased brightness of each layer, kept by the game between frames.
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
world: Option<i32>,
|
||||
eased: Vec<Transition>,
|
||||
/// The world the layers were last drawn for.
|
||||
pub world: Option<i32>,
|
||||
/// Each layer's eased visibility, 0 to 1.
|
||||
pub eased: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
|
||||
const TEXEL_BUDGET: u64 = 64 << 20;
|
||||
|
||||
impl CustomSky {
|
||||
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
|
||||
/// dropped with a warning.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let skies = Skies::scan(root);
|
||||
let mut out = Self::default();
|
||||
let mut texels = 0u64;
|
||||
for (world, sky) in skies.worlds {
|
||||
let mut layers = Vec::new();
|
||||
for layer in sky.layers {
|
||||
match crate::hud::decode(&root.join(&layer.source)) {
|
||||
Ok((width, height, rgba))
|
||||
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
|
||||
{
|
||||
texels += u64::from(width) * u64::from(height);
|
||||
out.images.push(Image {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
});
|
||||
layers.push(layer);
|
||||
}
|
||||
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
|
||||
Err(e) => log::warn!("{}: {e:#}", layer.source),
|
||||
}
|
||||
}
|
||||
if !layers.is_empty() {
|
||||
out.worlds.push((world, layers));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.worlds.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `world` has layers (`0` the Overworld, `1` the End).
|
||||
pub fn has(&self, world: i32) -> bool {
|
||||
self.worlds.iter().any(|(w, _)| *w == world)
|
||||
}
|
||||
|
||||
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
|
||||
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
|
||||
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
|
||||
pub fn draws(
|
||||
/// A pack's custom sky layers, by world (`0` the Overworld, `1` the End).
|
||||
pub trait SkyLayers: Send + Sync {
|
||||
/// Every layer's texture: [`Draw::image`] indexes it.
|
||||
fn images(&self) -> &[Image];
|
||||
/// Whether `world` has layers.
|
||||
fn has(&self, world: i32) -> bool;
|
||||
/// This frame's layers of `world` into `out` (cleared first), in drawing order, eased by
|
||||
/// `seconds` since the last frame. `sun_turns` is the sun's angle over 360° (`0` at noon).
|
||||
fn draws(
|
||||
&self,
|
||||
world: i32,
|
||||
conditions: &Conditions,
|
||||
@@ -97,147 +86,30 @@ impl CustomSky {
|
||||
seconds: f32,
|
||||
state: &mut State,
|
||||
out: &mut Vec<Draw>,
|
||||
) {
|
||||
);
|
||||
}
|
||||
|
||||
/// No layers: the vanilla sky.
|
||||
struct NoLayers;
|
||||
|
||||
impl SkyLayers for NoLayers {
|
||||
fn images(&self) -> &[Image] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn has(&self, _: i32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn draws(&self, _: i32, _: &Conditions, _: f32, _: f32, s: &mut State, out: &mut Vec<Draw>) {
|
||||
out.clear();
|
||||
let mut first = 0;
|
||||
let mut found = None;
|
||||
for (w, layers) in &self.worlds {
|
||||
if *w == world {
|
||||
found = Some(layers);
|
||||
break;
|
||||
}
|
||||
first += layers.len();
|
||||
}
|
||||
let Some(layers) = found else {
|
||||
state.world = None;
|
||||
return;
|
||||
};
|
||||
// A new world (or pack) starts each layer where its conditions put it: no fade in.
|
||||
let snap = state.world != Some(world) || state.eased.len() != layers.len();
|
||||
if snap {
|
||||
state.world = Some(world);
|
||||
state.eased.clear();
|
||||
state.eased.resize(layers.len(), Transition::default());
|
||||
}
|
||||
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
|
||||
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
|
||||
let shown = if snap {
|
||||
eased.value = target;
|
||||
target
|
||||
} else {
|
||||
eased.step(target, seconds, layer.transition)
|
||||
};
|
||||
let brightness = shown * layer.brightness(conditions.day_time);
|
||||
if brightness <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let turns = layer.turns(conditions.day_time, sun_turns);
|
||||
let rotation = if turns == 0.0 {
|
||||
Mat4::IDENTITY
|
||||
} else {
|
||||
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
|
||||
};
|
||||
out.push(Draw {
|
||||
image: (first + i) as u16,
|
||||
blend: layer.blend,
|
||||
brightness,
|
||||
rotation,
|
||||
});
|
||||
}
|
||||
s.world = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::sky::Now;
|
||||
|
||||
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-custom-sky-{}-{}",
|
||||
std::process::id(),
|
||||
files.len()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dir = root.join("assets/minecraft/optifine/sky/world0");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let png = {
|
||||
let mut bytes = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer
|
||||
.write_image_data(&[255, 0, 0, 255].repeat(24))
|
||||
.unwrap();
|
||||
writer.finish().unwrap();
|
||||
bytes
|
||||
};
|
||||
for (name, text) in files {
|
||||
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
|
||||
}
|
||||
let sky = CustomSky::load(&root);
|
||||
(root, sky)
|
||||
}
|
||||
|
||||
fn noon(biome: &str) -> Conditions<'_> {
|
||||
Conditions {
|
||||
day_time: 6000,
|
||||
weather: Now::Clear,
|
||||
biome,
|
||||
y: 64,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
|
||||
let (root, sky) = pack(&[
|
||||
("sky1", "biomes=desert\ntransition=2"),
|
||||
("sky2", "blend=alpha\nrotate=false"),
|
||||
]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
assert!(sky.has(0) && !sky.has(1));
|
||||
assert_eq!(sky.images[0].width, 6);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
sky.draws(
|
||||
0,
|
||||
&noon("minecraft:desert"),
|
||||
0.0,
|
||||
0.05,
|
||||
&mut state,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(
|
||||
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
|
||||
[1.0, 1.0]
|
||||
);
|
||||
// Out of the desert: the first layer fades over its two seconds.
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
|
||||
assert_eq!(out[0].brightness, 0.75);
|
||||
assert_eq!(out[1].blend, Blend::Alpha);
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 1, "a dark layer is not drawn");
|
||||
assert_eq!(out[0].image, 1);
|
||||
// Into the End, which has no layers, and back: no fade.
|
||||
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert!(out.is_empty());
|
||||
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_turns_west_like_the_sun() {
|
||||
let (root, sky) = pack(&[("sky1", "")]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
|
||||
let c = Conditions {
|
||||
day_time: 12000,
|
||||
..noon("minecraft:plains")
|
||||
};
|
||||
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
|
||||
let up = out[0].rotation.transform_vector3(Vec3::Y);
|
||||
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
|
||||
}
|
||||
/// The custom sky layers of the overlay at `root`, from the installed pack formats.
|
||||
pub fn load(root: &Path) -> Arc<dyn SkyLayers> {
|
||||
crate::pack_hooks::formats()
|
||||
.custom_sky(root)
|
||||
.unwrap_or_else(|| Arc::new(NoLayers))
|
||||
}
|
||||
@@ -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,121 @@ pub(crate) enum ExtraRow {
|
||||
/// It isn't installed: where to get it. A note, not a button: Options > Mods already has the
|
||||
/// one way into the Mods page (`every_setting_and_page_is_reached_from_one_place`).
|
||||
ZoomInstall,
|
||||
/// Row `.1` of the section registered page `.0` adds (`Place::Extras`).
|
||||
Added(usize, usize),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
|
||||
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
|
||||
const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// The pages crates registered, all places.
|
||||
fn pages() -> &'static [&'static dyn added::Page] {
|
||||
#[cfg(test)]
|
||||
if let Some(pages) = TEST_PAGES.get() {
|
||||
return pages;
|
||||
}
|
||||
added::engine::all()
|
||||
}
|
||||
|
||||
/// The sections of this page, by their index among the registered pages.
|
||||
fn sections() -> impl Iterator<Item = (usize, &'static dyn added::Page)> {
|
||||
let pages = pages().iter().copied().enumerate();
|
||||
pages.filter(|(_, page)| page.place() == added::Place::Extras)
|
||||
}
|
||||
|
||||
fn label_text(label: Label) -> String {
|
||||
match label {
|
||||
Label::Key(key) => lang::get(key),
|
||||
Label::Value(text) => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's group named `name`, made at the end if it has none.
|
||||
fn group_named<'a>(
|
||||
groups: &'a mut Vec<Group<ExtraRow>>,
|
||||
name: &'static str,
|
||||
) -> &'a mut Group<ExtraRow> {
|
||||
let at = groups.iter().position(|group| group.name == name);
|
||||
let at = at.unwrap_or_else(|| {
|
||||
groups.push(Group {
|
||||
name,
|
||||
rows: Vec::new(),
|
||||
});
|
||||
groups.len() - 1
|
||||
});
|
||||
&mut groups[at]
|
||||
}
|
||||
|
||||
/// The sections' rows, in the groups their titles and headings name.
|
||||
fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
|
||||
for (page, section) in sections() {
|
||||
let mut rows = Vec::new();
|
||||
section.rows(&mut rows);
|
||||
let mut name = section.title();
|
||||
for (r, row) in rows.into_iter().enumerate() {
|
||||
let id = ExtraRow::Added(page, r);
|
||||
let row = match row {
|
||||
added::Row::Heading(key) => {
|
||||
name = key;
|
||||
continue;
|
||||
}
|
||||
added::Row::Note(text) => Row::new(id, text, Value::Note).live(false),
|
||||
added::Row::Toggle { label, on } => {
|
||||
Row::new(id, lang::get(label), Value::Switch(on))
|
||||
}
|
||||
added::Row::Cycle { label, value } => {
|
||||
Row::new(id, lang::get(label), Value::Choice(label_text(value)))
|
||||
}
|
||||
added::Row::Slider { label, value, text } => {
|
||||
let position = value.clamp(0.0, 1.0);
|
||||
let text = label_text(text);
|
||||
let value = Value::Slider {
|
||||
position,
|
||||
steps: None,
|
||||
text,
|
||||
};
|
||||
Row::new(id, lang::get(label), value)
|
||||
}
|
||||
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Key { label, key } => {
|
||||
let key = crate::keybinds::display_name(&key).plain();
|
||||
Row::new(id, lang::get(label), Value::Read(key))
|
||||
}
|
||||
// A row kind newer than this engine.
|
||||
_ => continue,
|
||||
};
|
||||
let row = match section.tooltip(r) {
|
||||
Some(about) => row.about(label_text(about)),
|
||||
None => row,
|
||||
};
|
||||
group_named(groups, name).rows.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Row `r` of registered page `page` was acted on: the options screen saves when it closes.
|
||||
fn act(page: usize, r: usize, act: Act) -> Vec<Output> {
|
||||
let Some(section) = pages().get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
section.act(r, act);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
|
||||
/// Whether row `r` of registered page `page` steps (a switch or a cycle).
|
||||
fn steps(page: usize, r: usize) -> bool {
|
||||
let mut rows = Vec::new();
|
||||
if let Some(section) = pages().get(page) {
|
||||
section.rows(&mut rows);
|
||||
}
|
||||
matches!(
|
||||
rows.get(r),
|
||||
Some(added::Row::Toggle { .. } | added::Row::Cycle { .. })
|
||||
)
|
||||
}
|
||||
|
||||
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
|
||||
@@ -85,9 +202,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 +229,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 +248,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 +428,148 @@ mod tests {
|
||||
shell.key(&mut s, K::ArrowDown, None, false, size);
|
||||
assert_eq!(shell.focused_row(), Some(first));
|
||||
}
|
||||
|
||||
/// A section with a switch in the page's Multiplayer group, then a heading of its own with a
|
||||
/// number that steps either way, a note and a volume.
|
||||
struct Section {
|
||||
on: std::sync::Mutex<bool>,
|
||||
step: std::sync::Mutex<i32>,
|
||||
volume: std::sync::Mutex<f32>,
|
||||
}
|
||||
impl added::Page for Section {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.section"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.extras.header.multiplayer"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::Extras
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::Toggle {
|
||||
label: "test.section.switch",
|
||||
on: *self.on.lock().unwrap(),
|
||||
});
|
||||
rows.push(added::Row::Heading("test.section.heading"));
|
||||
rows.push(added::Row::Cycle {
|
||||
label: "test.section.step",
|
||||
value: Label::Value(self.step.lock().unwrap().to_string()),
|
||||
});
|
||||
rows.push(added::Row::Note("a note".into()));
|
||||
rows.push(added::Row::Slider {
|
||||
label: "test.section.volume",
|
||||
value: *self.volume.lock().unwrap(),
|
||||
text: Label::Value(String::new()),
|
||||
});
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
match (row, act) {
|
||||
(0, Act::Press | Act::PressBack) => {
|
||||
let mut on = self.on.lock().unwrap();
|
||||
*on = !*on;
|
||||
}
|
||||
(2, Act::Press) => *self.step.lock().unwrap() += 1,
|
||||
(2, Act::PressBack) => *self.step.lock().unwrap() -= 1,
|
||||
(4, Act::Slide(v)) => *self.volume.lock().unwrap() = v,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A page for Horizon Settings: never on Extras.
|
||||
struct Elsewhere;
|
||||
impl added::Page for Elsewhere {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.elsewhere"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.elsewhere"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::Button {
|
||||
label: "test.elsewhere.button",
|
||||
});
|
||||
}
|
||||
fn act(&self, _: usize, _: Act) {}
|
||||
}
|
||||
|
||||
fn with_section() -> &'static Section {
|
||||
let section: &'static Section = Box::leak(Box::new(Section {
|
||||
on: false.into(),
|
||||
step: 0.into(),
|
||||
volume: 0.5.into(),
|
||||
}));
|
||||
let pages: &'static [&'static dyn added::Page] = Box::leak(Box::new([
|
||||
&Elsewhere as &dyn added::Page,
|
||||
section as &dyn added::Page,
|
||||
]));
|
||||
TEST_PAGES.set(Some(pages));
|
||||
section
|
||||
}
|
||||
|
||||
/// The section's first row joins the page's own Multiplayer group, after its rows; its heading
|
||||
/// starts a group after the page's; a page for elsewhere adds nothing.
|
||||
#[test]
|
||||
fn a_section_joins_the_group_its_title_names_and_heads_its_own() {
|
||||
with_section();
|
||||
let s = Settings::default();
|
||||
let sheets = ExtrasScreen.sheets(&s);
|
||||
let groups = &sheets[0].groups;
|
||||
let ids = |name: &str| -> Vec<ExtraRow> {
|
||||
let group = groups.iter().find(|g| g.name == name).unwrap();
|
||||
group.rows.iter().map(|r| r.id).collect()
|
||||
};
|
||||
let multiplayer = ids("horizon.extras.header.multiplayer");
|
||||
assert_eq!(multiplayer.last(), Some(&ExtraRow::Added(1, 0)));
|
||||
assert_eq!(groups.last().unwrap().name, "test.section.heading");
|
||||
let own = [
|
||||
ExtraRow::Added(1, 2),
|
||||
ExtraRow::Added(1, 3),
|
||||
ExtraRow::Added(1, 4),
|
||||
];
|
||||
assert_eq!(ids("test.section.heading"), own);
|
||||
let all = rows_of(&s);
|
||||
assert!(!all.iter().any(|r| matches!(r.id, ExtraRow::Added(0, _))));
|
||||
}
|
||||
|
||||
/// A click flips the switch, a Shift click steps the number back, the wheel steps it, and the
|
||||
/// note takes no click; the rows show what the section holds after each.
|
||||
#[test]
|
||||
fn a_sections_rows_act_on_it() {
|
||||
let section = with_section();
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<ExtrasScreen>::default();
|
||||
let switch = ExtraRow::Added(1, 0);
|
||||
let at = shell.point_at(&s, SIZE, switch);
|
||||
assert!(
|
||||
!shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(*section.on.lock().unwrap());
|
||||
let value = |s: &Settings, id| rows_of(s).into_iter().find(|r| r.id == id).unwrap().value;
|
||||
assert_eq!(value(&s, switch), Value::Switch(true));
|
||||
let step = ExtraRow::Added(1, 2);
|
||||
let mut screen = ExtrasScreen;
|
||||
screen.press(&mut s, step, true);
|
||||
assert_eq!(value(&s, step), Value::Choice("-1".into()));
|
||||
assert!(screen.wheel_step(&mut s, step, false).is_some());
|
||||
assert_eq!(*section.step.lock().unwrap(), 0);
|
||||
assert_eq!(
|
||||
screen.wheel_step(&mut s, ExtraRow::Added(1, 3), false),
|
||||
None
|
||||
);
|
||||
let note = shell.point_at(&s, SIZE, ExtraRow::Added(1, 3));
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, note, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
screen.slide(&mut s, ExtraRow::Added(1, 4), 0.25);
|
||||
assert_eq!(*section.volume.lock().unwrap(), 0.25);
|
||||
}
|
||||
}
|
||||
@@ -1329,7 +1329,7 @@ pub struct Renderer {
|
||||
celestial_buffer: wgpu::Buffer,
|
||||
end_sky: end_sky::EndSky,
|
||||
stars: stars::Stars,
|
||||
/// OptiFine custom sky layers (`crate::custom_sky`), and the frame's from the game.
|
||||
/// Custom sky layers (`crate::custom_sky`), and the frame's from the game.
|
||||
custom_sky: custom_sky::CustomSky,
|
||||
custom_sky_draws: Vec<crate::custom_sky::Draw>,
|
||||
/// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`).
|
||||
@@ -5517,11 +5517,11 @@ impl Renderer {
|
||||
))
|
||||
});
|
||||
}
|
||||
/// The pack's OptiFine custom sky layer textures (`crate::custom_sky::CustomSky::load`).
|
||||
pub fn set_custom_sky(&mut self, pack: &crate::custom_sky::CustomSky) {
|
||||
/// The pack's custom sky layer textures (`crate::custom_sky::load`).
|
||||
pub fn set_custom_sky(&mut self, pack: &dyn crate::custom_sky::SkyLayers) {
|
||||
self.custom_sky.set_pack(&self.device, &self.queue, pack);
|
||||
}
|
||||
/// The custom sky layers the next frame draws (`CustomSky::draws`): the game refills it each
|
||||
/// The custom sky layers the next frame draws (`SkyLayers::draws`): the game refills it each
|
||||
/// frame, empty when the world's sky has none.
|
||||
pub fn custom_sky_draws(&mut self) -> &mut Vec<crate::custom_sky::Draw> {
|
||||
&mut self.custom_sky_draws
|
||||
@@ -15718,11 +15718,11 @@ mod shadow_caster_tests {
|
||||
#[test]
|
||||
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
|
||||
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
|
||||
let shadow = horizon_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 {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
|
||||
//! Custom sky layers (`crate::custom_sky`) on the GPU: one texture per layer, kept with the renderer (its pack),
|
||||
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
|
||||
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
|
||||
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
|
||||
use glam::Mat4;
|
||||
use horizon_optifine::sky::{Blend, Face};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::custom_sky::{CustomSky as Pack, Draw};
|
||||
use crate::custom_sky::{Blend, Draw, SkyLayers};
|
||||
|
||||
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
|
||||
const MODES: [Blend; 9] = [
|
||||
@@ -85,6 +84,76 @@ fn blend(mode: Blend) -> wgpu::BlendComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/// A face of the skybox, as the 3x2 grid of its texture holds them: `Bottom`, `Top`, `South` in
|
||||
/// the top row, `West`, `North`, `East` below.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Face {
|
||||
Bottom,
|
||||
Top,
|
||||
South,
|
||||
West,
|
||||
North,
|
||||
East,
|
||||
}
|
||||
|
||||
impl Face {
|
||||
const ALL: [Self; 6] = [
|
||||
Self::Bottom,
|
||||
Self::Top,
|
||||
Self::South,
|
||||
Self::West,
|
||||
Self::North,
|
||||
Self::East,
|
||||
];
|
||||
|
||||
/// The face's cell of the texture, `[u0, v0, u1, v1]` in 0..1.
|
||||
fn cell(self) -> [f32; 4] {
|
||||
let (col, row) = match self {
|
||||
Self::Bottom => (0, 0),
|
||||
Self::Top => (1, 0),
|
||||
Self::South => (2, 0),
|
||||
Self::West => (0, 1),
|
||||
Self::North => (1, 1),
|
||||
Self::East => (2, 1),
|
||||
};
|
||||
let (u, v) = (col as f32 / 3.0, row as f32 / 2.0);
|
||||
[u, v, u + 1.0 / 3.0, v + 0.5]
|
||||
}
|
||||
|
||||
/// The face's corners (on the unit cube around the camera; x east, y up, z south) in the
|
||||
/// order of its cell's `(u0, v0)`, `(u1, v0)`, `(u1, v1)`, `(u0, v1)`. The side faces are
|
||||
/// seen from inside with up at the top of their cell; the top face has north at the bottom of
|
||||
/// its cell, the bottom face north at the top (the template's `N` marks).
|
||||
fn corners(self) -> [[f32; 3]; 4] {
|
||||
match self {
|
||||
// Seen looking north: west on the left.
|
||||
Self::North => [
|
||||
[-1., 1., -1.],
|
||||
[1., 1., -1.],
|
||||
[1., -1., -1.],
|
||||
[-1., -1., -1.],
|
||||
],
|
||||
Self::South => [[1., 1., 1.], [-1., 1., 1.], [-1., -1., 1.], [1., -1., 1.]],
|
||||
Self::East => [[1., 1., -1.], [1., 1., 1.], [1., -1., 1.], [1., -1., -1.]],
|
||||
Self::West => [
|
||||
[-1., 1., 1.],
|
||||
[-1., 1., -1.],
|
||||
[-1., -1., -1.],
|
||||
[-1., -1., 1.],
|
||||
],
|
||||
// Looking up with north toward the cell's bottom edge: east on the right.
|
||||
Self::Top => [[-1., 1., 1.], [1., 1., 1.], [1., 1., -1.], [-1., 1., -1.]],
|
||||
// Looking down with north toward the cell's top edge: east on the right.
|
||||
Self::Bottom => [
|
||||
[-1., -1., -1.],
|
||||
[1., -1., -1.],
|
||||
[1., -1., 1.],
|
||||
[-1., -1., 1.],
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
|
||||
fn mesh() -> Vec<Vertex> {
|
||||
let mut out = Vec::with_capacity(36);
|
||||
@@ -256,10 +325,15 @@ impl CustomSky {
|
||||
}
|
||||
|
||||
/// The pack's layer textures, replacing any before.
|
||||
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
|
||||
pub(super) fn set_pack(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
pack: &dyn SkyLayers,
|
||||
) {
|
||||
let limit = device.limits().max_texture_dimension_2d;
|
||||
self.groups = pack
|
||||
.images
|
||||
.images()
|
||||
.iter()
|
||||
.map(|image| {
|
||||
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
|
||||
@@ -413,6 +487,38 @@ impl CustomSky {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_grid_holds_each_face_once_and_faces_point_out() {
|
||||
let mut cells: Vec<_> = Face::ALL
|
||||
.iter()
|
||||
.map(|f| f.cell().map(|x| (x * 6.0) as i32))
|
||||
.collect();
|
||||
cells.dedup();
|
||||
assert_eq!(cells.len(), 6);
|
||||
assert_eq!(Face::East.cell(), [2.0 / 3.0, 0.5, 1.0, 1.0]);
|
||||
for face in Face::ALL {
|
||||
let c = face.corners();
|
||||
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
let (a, b) = (sub(c[1], c[0]), sub(c[3], c[0]));
|
||||
let n = [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
let centre: [f32; 3] =
|
||||
std::array::from_fn(|i| c.iter().map(|p| p[i]).sum::<f32>() / 4.0);
|
||||
// Seen from inside, u runs right and v down, so u x v points away from the camera.
|
||||
let dot: f32 = (0..3).map(|i| n[i] * centre[i]).sum();
|
||||
assert!(dot > 0.0, "{face:?} is mirrored");
|
||||
}
|
||||
// The top cell's bottom edge (v1) is north: its corners 2 and 3 have z = -1.
|
||||
assert_eq!(Face::Top.corners()[2][2], -1.0);
|
||||
assert_eq!(Face::Top.corners()[3][2], -1.0);
|
||||
// The bottom cell's top edge (v0) is north.
|
||||
assert_eq!(Face::Bottom.corners()[0][2], -1.0);
|
||||
assert_eq!(Face::Bottom.corners()[1][2], -1.0);
|
||||
}
|
||||
|
||||
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
|
||||
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
|
||||
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
|
||||
|
||||
@@ -6,7 +6,7 @@ struct Layer {
|
||||
// Projection * view (camera-relative) * the layer's rotation.
|
||||
clip_from_sky: mat4x4<f32>,
|
||||
brightness: f32,
|
||||
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
|
||||
// crate::custom_sky::Blend, in gfx/custom_sky.rs's MODES order.
|
||||
mode: u32,
|
||||
pad: vec2<u32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,8 @@ pub mod options_fixes;
|
||||
pub mod options_ui;
|
||||
pub mod overlay;
|
||||
pub mod pack_browser;
|
||||
pub mod pack_formats;
|
||||
pub mod pack_hooks;
|
||||
pub mod pack_options_ui;
|
||||
pub mod pack_palette;
|
||||
pub mod pack_screen;
|
||||
|
||||
@@ -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() {
|
||||
@@ -1624,8 +1628,6 @@ pub struct OptionsUi {
|
||||
/// 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)>,
|
||||
/// The pages crates registered (`horizon_hooks::pages`).
|
||||
pages: &'static [&'static dyn horizon_hooks::pages::Page],
|
||||
/// Bindings: the action waiting for a pad button.
|
||||
pub selected_pad_action: Option<&'static str>,
|
||||
/// The glyphs menus show (`Glyphs::of`: the setting, else the pad's family, and a Nintendo
|
||||
@@ -1737,7 +1739,6 @@ impl Default for OptionsUi {
|
||||
scrollbar_drag_y: None,
|
||||
selected_key: None,
|
||||
selected_native_key: None,
|
||||
pages: horizon_hooks::pages::engine::all(),
|
||||
selected_pad_action: None,
|
||||
pad_glyphs: crate::pad_glyph::Glyphs::default(),
|
||||
pad_driving: false,
|
||||
@@ -1869,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),
|
||||
@@ -1907,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));
|
||||
}
|
||||
@@ -1934,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
|
||||
|
||||
@@ -8,9 +8,88 @@ 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 {
|
||||
added::engine::all().get(page).map_or("", |p| p.title())
|
||||
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 {
|
||||
@@ -66,8 +145,7 @@ impl OptionsUi {
|
||||
|
||||
/// 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 = self
|
||||
.pages
|
||||
let mut entries = registered()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, page)| page.place() == added::Place::HorizonSettings)
|
||||
@@ -79,7 +157,7 @@ impl OptionsUi {
|
||||
|
||||
/// 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) = self.pages.get(page) else {
|
||||
let Some(registered) = registered().get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
@@ -98,6 +176,7 @@ impl OptionsUi {
|
||||
added::Row::Note(text) => Row::Note(text),
|
||||
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
|
||||
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
|
||||
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
|
||||
added::Row::Button { label } => wide(tr(label)),
|
||||
added::Row::Key { label, key } => {
|
||||
let mut message = keybinds::display_name(&key);
|
||||
@@ -120,7 +199,7 @@ impl OptionsUi {
|
||||
|
||||
/// 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) = self.pages.get(page) else {
|
||||
let Some(registered) = registered().get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
@@ -199,7 +278,7 @@ impl OptionsUi {
|
||||
) -> Vec<Event> {
|
||||
let (i, k) = self.selected_native_key.take().unwrap_or_default();
|
||||
if name == PAGE_KEY {
|
||||
if let Some(page) = self.pages.get(i) {
|
||||
if let Some(page) = registered().get(i) {
|
||||
page.act(k, added::Act::Bind(value));
|
||||
}
|
||||
return vec![Event::Changed];
|
||||
@@ -221,11 +300,12 @@ mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A page with a heading, a switch, a number that steps either way and a key.
|
||||
/// 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 {
|
||||
@@ -252,6 +332,12 @@ mod tests {
|
||||
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) {
|
||||
@@ -262,15 +348,20 @@ mod tests {
|
||||
(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];
|
||||
|
||||
@@ -297,10 +388,8 @@ mod tests {
|
||||
#[test]
|
||||
fn a_registered_page_opens_from_horizon_settings_and_hears_its_rows() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi {
|
||||
pages: &PAGES,
|
||||
..OptionsUi::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));
|
||||
@@ -323,5 +412,53 @@ mod tests {
|
||||
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! OptiFine custom skies (`horizon_optifine::sky`) on the engine's sky layers
|
||||
//! ([`crate::custom_sky`]): a pack's layers with their decoded textures, read once with the pack,
|
||||
//! and each frame's choice of layers, their brightness and rotation. A pack without
|
||||
//! `optifine/sky` has none, and the sky stays vanilla.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_optifine::sky::{Blend, Conditions, Now, Skies, SkyLayer, Transition};
|
||||
|
||||
use crate::custom_sky::{self as engine, Draw, Image, SkyLayers, State};
|
||||
|
||||
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
|
||||
/// over all worlds, in `worlds` order).
|
||||
#[derive(Default)]
|
||||
pub struct CustomSky {
|
||||
worlds: Vec<(i32, Vec<SkyLayer>)>,
|
||||
pub images: Vec<Image>,
|
||||
}
|
||||
|
||||
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
|
||||
const TEXEL_BUDGET: u64 = 64 << 20;
|
||||
|
||||
impl CustomSky {
|
||||
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
|
||||
/// dropped with a warning.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let skies = Skies::scan(root);
|
||||
let mut out = Self::default();
|
||||
let mut texels = 0u64;
|
||||
for (world, sky) in skies.worlds {
|
||||
let mut layers = Vec::new();
|
||||
for layer in sky.layers {
|
||||
match crate::hud::decode(&root.join(&layer.source)) {
|
||||
Ok((width, height, rgba))
|
||||
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
|
||||
{
|
||||
texels += u64::from(width) * u64::from(height);
|
||||
out.images.push(Image {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
});
|
||||
layers.push(layer);
|
||||
}
|
||||
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
|
||||
Err(e) => log::warn!("{}: {e:#}", layer.source),
|
||||
}
|
||||
}
|
||||
if !layers.is_empty() {
|
||||
out.worlds.push((world, layers));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.worlds.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's sky blend for OptiFine's.
|
||||
fn blend(blend: Blend) -> engine::Blend {
|
||||
match blend {
|
||||
Blend::Add => engine::Blend::Add,
|
||||
Blend::Subtract => engine::Blend::Subtract,
|
||||
Blend::Multiply => engine::Blend::Multiply,
|
||||
Blend::Dodge => engine::Blend::Dodge,
|
||||
Blend::Burn => engine::Blend::Burn,
|
||||
Blend::Screen => engine::Blend::Screen,
|
||||
Blend::Replace => engine::Blend::Replace,
|
||||
Blend::Overlay => engine::Blend::Overlay,
|
||||
Blend::Alpha => engine::Blend::Alpha,
|
||||
}
|
||||
}
|
||||
|
||||
impl SkyLayers for CustomSky {
|
||||
fn images(&self) -> &[Image] {
|
||||
&self.images
|
||||
}
|
||||
|
||||
/// Whether `world` has layers (`0` the Overworld, `1` the End).
|
||||
fn has(&self, world: i32) -> bool {
|
||||
self.worlds.iter().any(|(w, _)| *w == world)
|
||||
}
|
||||
|
||||
/// The layers whose conditions hold, eased over their `transition`, at the brightness of
|
||||
/// their fade times.
|
||||
fn draws(
|
||||
&self,
|
||||
world: i32,
|
||||
conditions: &engine::Conditions,
|
||||
sun_turns: f32,
|
||||
seconds: f32,
|
||||
state: &mut State,
|
||||
out: &mut Vec<Draw>,
|
||||
) {
|
||||
let conditions = &Conditions {
|
||||
day_time: conditions.day_time,
|
||||
weather: match conditions.weather {
|
||||
engine::Weather::Clear => Now::Clear,
|
||||
engine::Weather::Rain => Now::Rain,
|
||||
engine::Weather::Thunder => Now::Thunder,
|
||||
},
|
||||
biome: conditions.biome,
|
||||
y: conditions.y,
|
||||
};
|
||||
out.clear();
|
||||
let mut first = 0;
|
||||
let mut found = None;
|
||||
for (w, layers) in &self.worlds {
|
||||
if *w == world {
|
||||
found = Some(layers);
|
||||
break;
|
||||
}
|
||||
first += layers.len();
|
||||
}
|
||||
let Some(layers) = found else {
|
||||
state.world = None;
|
||||
return;
|
||||
};
|
||||
// A new world (or pack) starts each layer where its conditions put it: no fade in.
|
||||
let snap = state.world != Some(world) || state.eased.len() != layers.len();
|
||||
if snap {
|
||||
state.world = Some(world);
|
||||
state.eased.clear();
|
||||
state.eased.resize(layers.len(), 0.0);
|
||||
}
|
||||
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
|
||||
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
|
||||
let shown = if snap {
|
||||
*eased = target;
|
||||
target
|
||||
} else {
|
||||
let mut t = Transition { value: *eased };
|
||||
*eased = t.step(target, seconds, layer.transition);
|
||||
*eased
|
||||
};
|
||||
let brightness = shown * layer.brightness(conditions.day_time);
|
||||
if brightness <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let turns = layer.turns(conditions.day_time, sun_turns);
|
||||
let rotation = if turns == 0.0 {
|
||||
Mat4::IDENTITY
|
||||
} else {
|
||||
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
|
||||
};
|
||||
out.push(Draw {
|
||||
image: (first + i) as u16,
|
||||
blend: blend(layer.blend),
|
||||
brightness,
|
||||
rotation,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::custom_sky::{Conditions, Weather};
|
||||
|
||||
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-custom-sky-{}-{}",
|
||||
std::process::id(),
|
||||
files.len()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dir = root.join("assets/minecraft/optifine/sky/world0");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let png = {
|
||||
let mut bytes = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer
|
||||
.write_image_data(&[255, 0, 0, 255].repeat(24))
|
||||
.unwrap();
|
||||
writer.finish().unwrap();
|
||||
bytes
|
||||
};
|
||||
for (name, text) in files {
|
||||
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
|
||||
}
|
||||
let sky = CustomSky::load(&root);
|
||||
(root, sky)
|
||||
}
|
||||
|
||||
fn noon(biome: &str) -> Conditions<'_> {
|
||||
Conditions {
|
||||
day_time: 6000,
|
||||
weather: Weather::Clear,
|
||||
biome,
|
||||
y: 64,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
|
||||
let (root, sky) = pack(&[
|
||||
("sky1", "biomes=desert\ntransition=2"),
|
||||
("sky2", "blend=alpha\nrotate=false"),
|
||||
]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
assert!(sky.has(0) && !sky.has(1));
|
||||
assert_eq!(sky.images[0].width, 6);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
sky.draws(
|
||||
0,
|
||||
&noon("minecraft:desert"),
|
||||
0.0,
|
||||
0.05,
|
||||
&mut state,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(
|
||||
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
|
||||
[1.0, 1.0]
|
||||
);
|
||||
// Out of the desert: the first layer fades over its two seconds.
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
|
||||
assert_eq!(out[0].brightness, 0.75);
|
||||
assert_eq!(out[1].blend, engine::Blend::Alpha);
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 1, "a dark layer is not drawn");
|
||||
assert_eq!(out[0].image, 1);
|
||||
// Into the End, which has no layers, and back: no fade.
|
||||
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert!(out.is_empty());
|
||||
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_turns_west_like_the_sun() {
|
||||
let (root, sky) = pack(&[("sky1", "")]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
|
||||
let c = Conditions {
|
||||
day_time: 12000,
|
||||
..noon("minecraft:plains")
|
||||
};
|
||||
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
|
||||
let up = out[0].rotation.transform_vector3(Vec3::Y);
|
||||
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! The resource pack formats vanilla doesn't read (OptiFine's, Polytone's) on the engine's
|
||||
//! extension points ([`crate::pack_hooks`]). This is platform code (docs/layers.md) kept in this
|
||||
//! 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 custom_sky;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::pack_hooks::PackFormats;
|
||||
|
||||
/// OptiFine's and Polytone's formats.
|
||||
pub struct Formats;
|
||||
|
||||
impl PackFormats for Formats {
|
||||
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 _)
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs these formats ([`crate::pack_hooks::install`]); the game does at startup.
|
||||
pub fn install() {
|
||||
crate::pack_hooks::install(&Formats);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! The engine's extension points for resource pack formats vanilla doesn't read (OptiFine's,
|
||||
//! Polytone's: platform code, docs/layers.md). The engine asks [`formats`] once per pack load and
|
||||
//! gets engine types back (tables, or an object it calls at most once a frame), never a format's
|
||||
//! own types. The platform installs its formats at startup ([`install`]); with none installed,
|
||||
//! packs load as vanilla reads them.
|
||||
|
||||
use std::path::Path;
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
/// No format beyond vanilla's.
|
||||
struct Vanilla;
|
||||
|
||||
impl PackFormats for Vanilla {}
|
||||
|
||||
static FORMATS: OnceLock<&'static dyn PackFormats> = OnceLock::new();
|
||||
|
||||
/// Installs the pack formats, before the first pack loads. The first call wins: later ones are
|
||||
/// ignored, so each test or probe that needs the formats may install them.
|
||||
pub fn install(formats: &'static dyn PackFormats) {
|
||||
let _ = FORMATS.set(formats);
|
||||
}
|
||||
|
||||
/// The installed pack formats, or vanilla's alone.
|
||||
pub fn formats() -> &'static dyn PackFormats {
|
||||
FORMATS.get().copied().unwrap_or(&Vanilla)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -1222,8 +1223,9 @@ pub struct ParticleLevel<'a> {
|
||||
pub collision: &'a BlockCollisionRegistry<'a>,
|
||||
/// Local player feet position and vertical velocity (`PlayerCloudParticle`).
|
||||
pub player: Option<([f64; 3], f64)>,
|
||||
pub cached_chunk: std::cell::Cell<Option<((i32, i32), Option<&'a crate::chunk::Chunk>)>>,
|
||||
}
|
||||
impl ParticleLevel<'_> {
|
||||
impl<'a> ParticleLevel<'a> {
|
||||
fn collide(&self, bb: Aabb, d: [f64; 3]) -> [f64; 3] {
|
||||
let mut scene = SceneCollisions {
|
||||
world: self.world,
|
||||
@@ -1231,9 +1233,6 @@ impl ParticleLevel<'_> {
|
||||
subject: None,
|
||||
};
|
||||
let movement = Vec3::new(d[0], d[1], d[2]);
|
||||
// `Entity.collideBoundingBox(null, ...)`: no entity shapes, no border. Where no
|
||||
// block of the swept box's cells can collide, it finds no shape and the
|
||||
// movement stands, as the resolution below would return it.
|
||||
if scene.cells_without_collision(bb.expand_towards(movement)) {
|
||||
return d;
|
||||
}
|
||||
@@ -1249,38 +1248,46 @@ impl ParticleLevel<'_> {
|
||||
p[2].floor() as i32,
|
||||
]
|
||||
}
|
||||
#[inline]
|
||||
pub fn block(&self, x: i32, y: i32, z: i32) -> horizon_world::registry::BlockStateId {
|
||||
let (cx, cz) = (x >> 4, z >> 4);
|
||||
let chunk = match self.cached_chunk.get() {
|
||||
Some(((ccx, ccz), c)) if ccx == cx && ccz == cz => c,
|
||||
_ => {
|
||||
let c = self.world.chunks.get(&(cx, cz)).map(|c| c.as_ref());
|
||||
self.cached_chunk.set(Some(((cx, cz), c)));
|
||||
c
|
||||
}
|
||||
};
|
||||
chunk.map_or(self.world.air, |c| c.block(x & 15, y, z & 15))
|
||||
}
|
||||
/// The fluid at a block and `FluidState.getHeight`.
|
||||
fn fluid(&self, b: [i32; 3]) -> (Fluid, f64) {
|
||||
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
|
||||
let s = self.facts.get(self.block(b[0], b[1], b[2]));
|
||||
if s.fluid == Fluid::None {
|
||||
return (Fluid::None, 0.0);
|
||||
}
|
||||
let above = self.facts.get(self.world.block(b[0], b[1] + 1, b[2]));
|
||||
let height = if above.fluid == s.fluid {
|
||||
let above = self.facts.get(self.block(b[0], b[1] + 1, b[2]));
|
||||
let h = if above.fluid == s.fluid {
|
||||
1.0
|
||||
} else {
|
||||
f64::from(s.fluid_level) / 9.0
|
||||
};
|
||||
(s.fluid, height)
|
||||
(s.fluid, h)
|
||||
}
|
||||
/// The fluid at a block and whether it is a source (`FluidState.isSource`).
|
||||
pub(crate) fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
|
||||
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
|
||||
let s = self.facts.get(self.block(b[0], b[1], b[2]));
|
||||
(s.fluid, s.fluid_level == 8 && !s.fluid_falling)
|
||||
}
|
||||
/// `getCollisionShape(...).max(Y, x, z)` for cached shapes.
|
||||
fn collision_top(&self, b: [i32; 3], fx: f64, fz: f64) -> f64 {
|
||||
let state = self.world.block(b[0], b[1], b[2]);
|
||||
let state = self.block(b[0], b[1], b[2]);
|
||||
match self.collision.cached_shapes().cached_shape(state) {
|
||||
Ok(Some(shape)) if !shape.is_empty() => shape.max_at(Axis::Y, fx, fz),
|
||||
Ok(Some(_)) => 0.0,
|
||||
_ => {
|
||||
if self.facts.get(state).full_collision {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
_ if self.facts.get(state).full_collision => 1.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2020,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
|
||||
@@ -4052,15 +3992,12 @@ impl ParticleEngine {
|
||||
}
|
||||
Behaviour::Lava => {
|
||||
p.base_tick(level);
|
||||
if !p.removed {
|
||||
let odds = p.age as f32 / p.lifetime as f32;
|
||||
if p.random.next_float() > odds {
|
||||
self.add(
|
||||
&ParticleOptions::simple(ParticleType::Smoke),
|
||||
p.position,
|
||||
p.velocity,
|
||||
);
|
||||
}
|
||||
if !p.removed && p.random.next_float() > p.age as f32 / p.lifetime as f32 {
|
||||
self.add(
|
||||
&ParticleOptions::simple(ParticleType::Smoke),
|
||||
p.position,
|
||||
p.velocity,
|
||||
);
|
||||
}
|
||||
}
|
||||
Behaviour::Crit => {
|
||||
@@ -4144,9 +4081,7 @@ impl ParticleEngine {
|
||||
*c *= f64::from(0.98f32);
|
||||
}
|
||||
if p.on_ground {
|
||||
if p.random.next_float() < 0.5 {
|
||||
p.removed = true;
|
||||
}
|
||||
p.removed |= p.random.next_float() < 0.5;
|
||||
p.velocity[0] *= f64::from(0.7f32);
|
||||
p.velocity[2] *= f64::from(0.7f32);
|
||||
}
|
||||
@@ -4186,13 +4121,10 @@ impl ParticleEngine {
|
||||
} => {
|
||||
p.base_tick(level);
|
||||
let (fluid, source) = level.fluid_source(ParticleLevel::block_at(p.position));
|
||||
if !(p.removed || (fluid == Fluid::Water && source)) {
|
||||
p.removed = true;
|
||||
}
|
||||
if !p.removed && p.position[1] >= y_end {
|
||||
p.removed = true;
|
||||
}
|
||||
if !p.removed && p.position[1] <= y_prev {
|
||||
if !(fluid == Fluid::Water && source)
|
||||
|| p.position[1] >= y_end
|
||||
|| p.position[1] <= y_prev
|
||||
{
|
||||
p.removed = true;
|
||||
}
|
||||
// randomHorizontalWiggling, then a second, horizontal move.
|
||||
@@ -4216,9 +4148,8 @@ impl ParticleEngine {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
if p.age as f32 > fade_start {
|
||||
let frames_since_fade_out_start = p.age as f32 - fade_start;
|
||||
p.rgba[3] =
|
||||
(p.lifetime as f32 - frames_since_fade_out_start) / p.lifetime as f32;
|
||||
(p.lifetime as f32 - (p.age as f32 - fade_start)) / p.lifetime as f32;
|
||||
}
|
||||
}
|
||||
Behaviour::NoxiousGasCloud => {
|
||||
@@ -4682,7 +4613,7 @@ impl ParticleEngine {
|
||||
self.sprite_from_age(p);
|
||||
half(p);
|
||||
let b = ParticleLevel::block_at(p.position);
|
||||
if level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
|
||||
if level.facts.is_air(level.block(b[0], b[1], b[2])) {
|
||||
p.velocity[1] -= f64::from(0.0074f32);
|
||||
}
|
||||
}
|
||||
@@ -4690,7 +4621,7 @@ impl ParticleEngine {
|
||||
Behaviour::Firefly => {
|
||||
p.base_tick(level);
|
||||
let b = ParticleLevel::block_at(p.position);
|
||||
if !level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
|
||||
if !level.facts.is_air(level.block(b[0], b[1], b[2])) {
|
||||
p.removed = true;
|
||||
} else {
|
||||
p.rgba[3] = fade_amount(p.lifetime_progress(p.age as f32), 0.3, 0.5);
|
||||
@@ -5154,11 +5085,12 @@ fn hdr_tier(ty: ParticleType) -> u8 {
|
||||
}
|
||||
/// `Particle.LifetimeAlpha.currentAlphaForAge`.
|
||||
fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 {
|
||||
if (a[0] - a[1]).abs() < 1.0e-5 {
|
||||
return a[0];
|
||||
}
|
||||
let t = ((age as f32 + partial) / lifetime as f32 - a[2]) / (a[3] - a[2]);
|
||||
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
|
||||
if (a[0] - a[1]).abs() < 1.0e-5 {
|
||||
a[0]
|
||||
} else {
|
||||
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
struct Starter {
|
||||
@@ -5589,6 +5521,7 @@ mod tests {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.set_sprites.push(vec![10, 11, 12]);
|
||||
@@ -5680,6 +5613,7 @@ mod tests {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
let mut particle = Particle::blank(ParticleType::Smoke, [0.0; 3]);
|
||||
|
||||
@@ -124,6 +124,7 @@ mod tests {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut e = engine();
|
||||
let mut p = e
|
||||
|
||||
@@ -490,14 +490,14 @@ enum Subject<'a> {
|
||||
}
|
||||
|
||||
/// What one expression runs against: its subject, the frame and the level.
|
||||
struct Ctx<'a> {
|
||||
struct Ctx<'a, 'l> {
|
||||
subject: Subject<'a>,
|
||||
random: &'a mut LegacyRandom,
|
||||
custom: &'a mut f64,
|
||||
customs: &'a mut BTreeMap<String, f64>,
|
||||
frame: &'a Frame,
|
||||
registry: &'a Registry,
|
||||
level: Option<&'a ParticleLevel<'a>>,
|
||||
level: Option<&'a ParticleLevel<'l>>,
|
||||
blocks: Vec<[i32; 3]>,
|
||||
}
|
||||
|
||||
@@ -515,7 +515,7 @@ fn axis(name: &str) -> usize {
|
||||
usize::from(name.as_bytes()[0] - b'x')
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
impl Ctx<'_, '_> {
|
||||
fn position(&self) -> [f64; 3] {
|
||||
match &self.subject {
|
||||
Subject::Particle(p) => p.position,
|
||||
@@ -683,7 +683,7 @@ impl Ctx<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Host for Ctx<'_> {
|
||||
impl Host for Ctx<'_, '_> {
|
||||
fn root(&mut self, root: Root) -> V {
|
||||
match root {
|
||||
Root::Object => V::Object(SELF),
|
||||
@@ -1662,6 +1662,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
// Air has no emitters; the torch fires its first emitter only.
|
||||
@@ -1701,6 +1702,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_animate(&level, [1, 64, 1]);
|
||||
@@ -1722,6 +1724,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_animate(&level, [1, 64, 1]);
|
||||
@@ -1767,6 +1770,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let registry = Registry::read(&root, Default::default(), blocks);
|
||||
let frame = registry.frame.clone();
|
||||
@@ -1820,6 +1824,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let registry = Registry::read(&root, Default::default(), blocks);
|
||||
let mut engine = engine(registry);
|
||||
@@ -1853,6 +1858,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
let rect = crate::hud::Rect {
|
||||
@@ -1905,6 +1911,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
let rect = crate::hud::Rect {
|
||||
@@ -1966,6 +1973,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.0, 70.0, 1.0]);
|
||||
@@ -2014,6 +2022,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
let rect = crate::hud::Rect {
|
||||
@@ -2080,6 +2089,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let registry = Arc::new(Registry::read(&root, Default::default(), blocks));
|
||||
// The chunk's biome is id 0: plains, whose foliage is #336699.
|
||||
@@ -2198,6 +2208,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.5, 70.0, 1.5]);
|
||||
@@ -2276,6 +2287,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
// The test chunk's biome is the table's first: a cold one first, then a warm one.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@ fn particle_tick_cost() {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let ty = |name: &str| ParticleType::from_name(name).unwrap();
|
||||
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
|
||||
|
||||
@@ -871,6 +871,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,
|
||||
|
||||
@@ -192,7 +192,12 @@ impl SignData {
|
||||
let side = |t: Option<&Tag>| -> SignText {
|
||||
let mut out = SignText::default();
|
||||
let Some(t) = t else { return out };
|
||||
if let Some(Tag::List(messages)) = t.get("messages") {
|
||||
if let Some(messages) = t.get("messages") {
|
||||
// `SignText.CODEC` reads a side all or nothing: a messages
|
||||
// field that is not a list leaves the side empty.
|
||||
let Tag::List(messages) = messages else {
|
||||
return SignText::default();
|
||||
};
|
||||
for (i, m) in messages.iter().take(4).enumerate() {
|
||||
out.lines[i] = component_text(m);
|
||||
out.click[i] = click_event(m);
|
||||
@@ -307,6 +312,27 @@ pub fn editor_sprite(folder: &str, wood: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// `SignText.CODEC` reads messages as a list: a string field fails the
|
||||
/// whole side, taking its colour and glow with it.
|
||||
#[test]
|
||||
fn messages_that_are_not_a_list_load_an_empty_side() {
|
||||
let tag = Tag::compound([(
|
||||
"front_text",
|
||||
Tag::compound([
|
||||
("messages", Tag::String("not a list".into())),
|
||||
("color", Tag::String("red".into())),
|
||||
("has_glowing_text", Tag::Byte(1)),
|
||||
]),
|
||||
)]);
|
||||
let mut decoded = SignData::default();
|
||||
decoded.decode(&tag);
|
||||
assert_eq!(
|
||||
decoded.front,
|
||||
SignText::default(),
|
||||
"a failed message list takes the side's colour and glow with it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waxed_and_glowing_text_take_any_number() {
|
||||
let tag = Tag::compound([
|
||||
|
||||
@@ -1238,6 +1238,9 @@ pub struct EditBox {
|
||||
/// Grey completion shown after the text (EditBox.setSuggestion).
|
||||
pub suggestion: Option<String>,
|
||||
pub text_color: u32,
|
||||
/// `EditBox.setCentered`: the text starts in the middle of the box's
|
||||
/// width, not at its left edge (`BookSignScreen`'s title).
|
||||
pub centered: bool,
|
||||
/// `EditBox.textColorUneditable`: the colour of a box `setEditable(false)`
|
||||
/// made read-only, which still shows its value and caret.
|
||||
pub text_color_uneditable: u32,
|
||||
@@ -1536,6 +1539,7 @@ impl EditBox {
|
||||
focused_at: Instant::now(),
|
||||
suggestion: None,
|
||||
text_color: rgba(224, 224, 224, 255),
|
||||
centered: false,
|
||||
text_color_uneditable: rgba(112, 112, 112, 255),
|
||||
editable: true,
|
||||
message: String::new(),
|
||||
@@ -1559,6 +1563,16 @@ impl EditBox {
|
||||
self.y
|
||||
}
|
||||
}
|
||||
/// `EditBox.updateTextPosition`: where the text starts, `(width - text
|
||||
/// width) / 2` in for a centred box (vanilla's `textX`), else at
|
||||
/// `text_x` (4 in from a border).
|
||||
pub fn text_start_x(&self, text_width: f32) -> f32 {
|
||||
if self.centered {
|
||||
self.x + ((self.width - text_width) / 2.0).floor()
|
||||
} else {
|
||||
self.text_x()
|
||||
}
|
||||
}
|
||||
pub fn reset_blink(&mut self) {
|
||||
self.focused_at = Instant::now();
|
||||
}
|
||||
@@ -1641,8 +1655,13 @@ impl EditBox {
|
||||
/// EditBox.findClickedPositionInText: the character boundary under GUI `x`.
|
||||
pub fn position_at(&mut self, atlas: &HudAtlas, x: f32) -> usize {
|
||||
self.layout(atlas);
|
||||
let room = (x.floor() - self.text_x()).min(self.inner_width());
|
||||
let rest: String = self.value.chars().skip(self.display).collect();
|
||||
let shown: String = rest
|
||||
.chars()
|
||||
.take(atlas.font.fit(&rest, self.inner_width()))
|
||||
.collect();
|
||||
let start = self.text_start_x(atlas.font.width_text(&Text::literal(shown.as_str())));
|
||||
let room = (x.floor() - start).min(self.inner_width());
|
||||
self.display + atlas.font.fit(&rest, room)
|
||||
}
|
||||
/// EditBox.onClick: the cursor to the pointer, extending the selection
|
||||
@@ -1849,7 +1868,12 @@ impl EditBox {
|
||||
),
|
||||
}
|
||||
};
|
||||
let (tx, ty) = (self.text_x(), self.text_y());
|
||||
let start = if self.centered {
|
||||
self.text_start_x(atlas.font.width_text(&Text::literal(displayed.as_str())))
|
||||
} else {
|
||||
self.text_x()
|
||||
};
|
||||
let (tx, ty) = (start, self.text_y());
|
||||
let mut draw_x = tx;
|
||||
if !displayed.is_empty() {
|
||||
let half = if on_screen {
|
||||
@@ -2650,4 +2674,19 @@ mod tests {
|
||||
assert!(edit.key(winit::keyboard::KeyCode::Backspace, false, false));
|
||||
assert_eq!(edit.value, "name");
|
||||
}
|
||||
|
||||
/// `EditBox.setCentered` (`BookSignScreen`'s title box): with the flag the
|
||||
/// text starts `(width - text width) / 2` in, which is also where a click
|
||||
/// maps; without it the text starts at the box's left edge.
|
||||
#[test]
|
||||
fn a_centred_box_starts_its_text_in_the_middle() {
|
||||
let mut edit = EditBox::new(10.0, 0.0, 114.0, 9.0);
|
||||
edit.bordered = false;
|
||||
assert_eq!(edit.text_start_x(0.0), 10.0);
|
||||
edit.centered = true;
|
||||
assert_eq!(edit.text_start_x(0.0), 67.0);
|
||||
assert_eq!(edit.text_start_x(18.0), 58.0);
|
||||
// (114 - 5) / 2 = 54.5, rounded down.
|
||||
assert_eq!(edit.text_start_x(5.0), 64.0);
|
||||
}
|
||||
}
|
||||
@@ -122,12 +122,13 @@ controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-duals
|
||||
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
|
||||
# client is best effort: CI builds only the default.
|
||||
horizon-extras = ["discord", "friends"]
|
||||
horizon-extras = ["horizon-client-lib/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
|
||||
|
||||
@@ -97,6 +97,7 @@ pub fn main() -> Result<()> {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = ParticleEngine::new(&root, &Default::default(), &Default::default());
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
|
||||
@@ -194,6 +194,7 @@ pub fn main() -> Result<()> {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let v = |key: &str| -> [f64; 3] {
|
||||
std::array::from_fn(|i| case[key][i].as_f64().unwrap_or(0.0))
|
||||
|
||||
@@ -612,6 +612,7 @@ fn do_animate_tick(
|
||||
facts: ctx.facts,
|
||||
collision: ctx.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
engine.polytone_animate(&level, pos);
|
||||
let facts = ctx.facts.get(state);
|
||||
@@ -1994,8 +1995,10 @@ pub fn campfire_particles<'a>(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
|
||||
/// `smoking` adds the plain smoke puff at the logs.
|
||||
/// `CampfireBlock.makeParticles`: the cosy or signal smoke through
|
||||
/// `addAlwaysVisibleParticle(.., true, ..)` (it overrides the limiter but still
|
||||
/// rolls the Particles option); `smoking` adds the plain smoke puff at the logs
|
||||
/// through `addParticle`, so the limiter can drop it.
|
||||
pub fn make_campfire_smoke(
|
||||
engine: &mut ParticleEngine,
|
||||
pos: [i32; 3],
|
||||
@@ -2014,7 +2017,13 @@ pub fn make_campfire_smoke(
|
||||
} else {
|
||||
T::CampfireCosySmoke
|
||||
};
|
||||
engine.create(&ParticleOptions::simple(ty), [x, y, z], [0.0, 0.07, 0.0]);
|
||||
engine.add_with(
|
||||
&ParticleOptions::simple(ty),
|
||||
[x, y, z],
|
||||
[0.0, 0.07, 0.0],
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if smoking {
|
||||
let r = engine.random();
|
||||
let dx = r.next_double() / 4.0 * sign(r);
|
||||
@@ -2024,13 +2033,49 @@ pub fn make_campfire_smoke(
|
||||
f64::from(pos[1]) + 0.4,
|
||||
f64::from(pos[2]) + 0.5 + dz,
|
||||
];
|
||||
engine.create(&ParticleOptions::simple(T::Smoke), at, [0.0, 0.005, 0.0]);
|
||||
add(engine, T::Smoke, at, [0.0, 0.005, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// `CampfireBlock.makeParticles` through `ClientLevel.doAddParticle`: the
|
||||
/// cosy smoke overrides the limiter, the plain smoke does not, so it is
|
||||
/// dropped beyond 32 blocks and at Particles: Minimal, and at Decreased
|
||||
/// each puff is dropped when `calculateParticleLevel`'s `nextInt(3)` is 0.
|
||||
#[test]
|
||||
fn doused_campfire_smoke_goes_through_the_particle_limiter() {
|
||||
use horizon_core::random::RandomSource as _;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
let run = |level: u8, camera: [f64; 3]| {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.seed_randoms(5, 9, 0);
|
||||
engine.created_requests = Some(Vec::new());
|
||||
engine.particle_level = level;
|
||||
engine.camera = camera;
|
||||
for _ in 0..20 {
|
||||
make_campfire_smoke(&mut engine, [0, 64, 0], false, true);
|
||||
}
|
||||
let made = engine.created_requests.take().unwrap();
|
||||
let count = |ty| made.iter().filter(|(o, ..)| o.ty == ty).count();
|
||||
(count(T::CampfireCosySmoke), count(T::Smoke))
|
||||
};
|
||||
let near = [0.5, 65.0, 0.5];
|
||||
assert_eq!(run(0, near), (20, 20), "All");
|
||||
assert_eq!(run(2, near), (20, 0), "Minimal");
|
||||
assert_eq!(run(0, [100.5, 65.0, 0.5]), (20, 0), "100 blocks away");
|
||||
// Decreased: per puff the cosy smoke's roll, then the plain smoke's.
|
||||
let mut level = LegacyRandom::new(9);
|
||||
let kept = (0..20)
|
||||
.filter(|_| {
|
||||
level.next_int_bound(3).unwrap();
|
||||
level.next_int_bound(3).unwrap() != 0
|
||||
})
|
||||
.count();
|
||||
assert!(0 < kept && kept < 20);
|
||||
assert_eq!(run(1, near), (20, kept), "Decreased");
|
||||
}
|
||||
#[test]
|
||||
fn classifies_animated_blocks() {
|
||||
let none = |_: &str| None;
|
||||
|
||||
@@ -19,13 +19,13 @@ pub use horizon_client_render::{
|
||||
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
|
||||
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
|
||||
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
|
||||
options_file, options_fixes, options_ui, overlay, pack_browser, 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,
|
||||
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,
|
||||
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));
|
||||
|
||||
@@ -13,6 +13,8 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
log::warn!("Trait mods already installed; ignoring the second list");
|
||||
}
|
||||
horizon_threads::configure_allocator();
|
||||
// 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.
|
||||
if !cfg!(feature = "horizon-extras") {
|
||||
horizon_client::options_ui::HORIZON_EXTRAS
|
||||
@@ -21,8 +23,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
horizon_client::telemetry::mark_start();
|
||||
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
|
||||
crash::on_report(horizon_client::telemetry::keep_crash);
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`);
|
||||
// none registers after this point.
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`),
|
||||
// then the Horizon parts still in this crate; none registers after this point.
|
||||
game::player_hooks::register();
|
||||
horizon_hooks::engine::seal();
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
|
||||
@@ -373,6 +373,7 @@ mod place_feature_tests;
|
||||
mod play_input;
|
||||
mod play_keys;
|
||||
mod player_equip;
|
||||
pub(crate) mod player_hooks;
|
||||
mod player_spawn;
|
||||
mod pointer_capture;
|
||||
#[cfg(test)]
|
||||
@@ -650,7 +651,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,
|
||||
@@ -2018,8 +2019,8 @@ pub struct PackResources {
|
||||
clouds: Arc<(u32, u32, Vec<bool>)>,
|
||||
/// misc/forcefield.png: (width, height, RGBA) for the world border wall.
|
||||
forcefield: Arc<(u32, u32, Vec<u8>)>,
|
||||
/// OptiFine custom sky layers (`optifine/sky`) with their textures; empty without them.
|
||||
custom_sky: Arc<horizon_client::custom_sky::CustomSky>,
|
||||
/// Custom sky layers (OptiFine's `optifine/sky`) with their textures; none without them.
|
||||
custom_sky: Arc<dyn horizon_client::custom_sky::SkyLayers>,
|
||||
/// The overworld's, Nether's and End's attribute stacks with the packs' Polytone biome and
|
||||
/// dimension modifiers (#11486), when a pack has some: what the frame draws.
|
||||
environments: Option<Arc<[Environment; 3]>>,
|
||||
@@ -5490,34 +5491,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 {
|
||||
@@ -7520,7 +7493,6 @@ impl Game<'_> {
|
||||
self.spawn_phantoms();
|
||||
self.tick_advancements();
|
||||
self.tick_player_model();
|
||||
self.tick_emotes();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -12720,16 +12692,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
|
||||
|
||||
@@ -33,6 +33,8 @@ fn book_title_box() -> ui::EditBox {
|
||||
edit.bordered = false;
|
||||
edit.max_length = 15;
|
||||
edit.text_color = BLACK;
|
||||
// BookSignScreen sets the title box centred.
|
||||
edit.centered = true;
|
||||
edit.focused = true;
|
||||
edit
|
||||
}
|
||||
@@ -68,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> {
|
||||
@@ -986,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() {
|
||||
@@ -1188,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);
|
||||
@@ -1618,6 +1637,19 @@ pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod title_box_tests {
|
||||
use super::book_title_box;
|
||||
|
||||
/// `BookSignScreen` sets its 15 character title box centred and unbordered.
|
||||
#[test]
|
||||
fn the_sign_title_box_is_centred() {
|
||||
let edit = book_title_box();
|
||||
assert!(edit.centered);
|
||||
assert!(!edit.bordered);
|
||||
assert_eq!(edit.max_length, 15);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod lectern_books_tag_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
@@ -1794,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()
|
||||
|
||||
@@ -227,16 +227,16 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
for m in self.mobs.iter() {
|
||||
if !m.alive() {
|
||||
// A mob with no effects swirls nothing: skipped before its size
|
||||
// is worked out, which every mob in a crowded world would pay
|
||||
// for (#14053).
|
||||
if !m.alive() || (m.extra.effect_particles.is_empty() && m.extra.effects.is_empty()) {
|
||||
continue;
|
||||
}
|
||||
let size = (f64::from(m.width()), f64::from(m.height()));
|
||||
let invisible = m.invisible || m.has_effect("minecraft:invisibility");
|
||||
if m.extra.effect_particles.is_empty() {
|
||||
// A mob of this game: its own effects, visible and not ambient.
|
||||
if m.extra.effects.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let list: Vec<_> = m
|
||||
.extra
|
||||
.effects
|
||||
|
||||
@@ -92,6 +92,7 @@ fn a_mods_fireworks_burst_and_bang_on_the_client_only() {
|
||||
facts: game.res.facts(),
|
||||
collision: game.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
game.particles.tick(&level);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,6 +10,7 @@ use horizon_client::mobs::Mob;
|
||||
use horizon_client::mobs::anim::BodyPose;
|
||||
use horizon_cosmetics::account::OwnedEmote;
|
||||
use horizon_cosmetics::{Item, Playing, Slot};
|
||||
use horizon_hooks::players::PlayerModels;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// How far the player may drift (blocks) before the emote stops: a nudge, not a step.
|
||||
@@ -37,10 +38,15 @@ pub(in crate::game) struct Emotes {
|
||||
|
||||
impl Emotes {
|
||||
/// The emote to tell the service about, while it plays: its id and whether it loops.
|
||||
pub(in crate::game) fn sending(&self) -> Option<(String, bool)> {
|
||||
pub(in crate::game) fn sending(&self) -> Option<(&str, bool)> {
|
||||
let p = self.playing.as_ref().filter(|p| p.stop.is_none())?;
|
||||
let looping = p.item.emote.as_ref().is_some_and(|e| e.looping);
|
||||
Some((p.item.id.clone(), looping))
|
||||
Some((&p.item.id, looping))
|
||||
}
|
||||
|
||||
/// When the emote that pulled the camera back started (the own model's age), while it plays.
|
||||
pub(in crate::game) fn pull(&self) -> Option<f32> {
|
||||
self.camera_from.and(self.playing.as_ref()).map(|p| p.start)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,23 +65,48 @@ impl BodyPose for EmotePose {
|
||||
}
|
||||
}
|
||||
|
||||
/// The emote `m` plays, if any.
|
||||
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
|
||||
let any: &dyn std::any::Any = &**m.emote.as_ref()?;
|
||||
/// The emote a model's pose plays, if it is one.
|
||||
pub(in crate::game) fn pose_playing(pose: Option<&Arc<dyn BodyPose>>) -> Option<&Playing> {
|
||||
let any: &dyn std::any::Any = &**pose?;
|
||||
any.downcast_ref::<EmotePose>().map(|e| &e.0)
|
||||
}
|
||||
|
||||
/// Makes `m` play `next`, unless it plays that already (a new one allocates).
|
||||
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
|
||||
let same = match (playing(m), &next) {
|
||||
/// The emote `m` plays, if any.
|
||||
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
|
||||
pose_playing(m.emote.as_ref())
|
||||
}
|
||||
|
||||
/// The pose that plays `next` where `now` is laid, unless it plays that already (None: keep
|
||||
/// it; a new one allocates).
|
||||
fn replaced(
|
||||
now: Option<&Arc<dyn BodyPose>>,
|
||||
next: Option<Playing>,
|
||||
) -> Option<Option<Arc<dyn BodyPose>>> {
|
||||
let same = match (pose_playing(now), &next) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => {
|
||||
Arc::ptr_eq(&a.item, &b.item) && a.start == b.start && a.stop == b.stop
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !same {
|
||||
m.emote = next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>);
|
||||
(!same).then(|| next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>))
|
||||
}
|
||||
|
||||
/// Makes `m` play `next`, unless it plays that already.
|
||||
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
|
||||
if let Some(pose) = replaced(m.emote.as_ref(), next) {
|
||||
m.emote = pose;
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes model `id` play `next`, unless it plays that already.
|
||||
pub(in crate::game) fn set_model_playing(
|
||||
models: &mut dyn PlayerModels,
|
||||
id: u32,
|
||||
next: Option<Playing>,
|
||||
) {
|
||||
if let Some(pose) = replaced(models.body_pose(id), next) {
|
||||
models.set_body_pose(id, pose);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +260,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a tick: my emote, then the other players'.
|
||||
/// Once a tick: my emote (the others' are `cosmetics::Seat::tick`'s, a hook's).
|
||||
pub(in crate::game) fn tick_emotes(&mut self) {
|
||||
if let Some(done) = self
|
||||
.cosmetics
|
||||
@@ -250,20 +281,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.tick_emote();
|
||||
self.tick_others_emotes();
|
||||
}
|
||||
|
||||
/// How far the camera stands back while an emote pulls it (1: the usual distance): it eases
|
||||
/// out over the emote's first quarter second.
|
||||
pub(in crate::game) fn emote_camera_pull(&self, partial: f32) -> f32 {
|
||||
let (Some(p), Some(m)) = (&self.cosmetics.emotes.playing, &self.player_model) else {
|
||||
return 1.0;
|
||||
};
|
||||
if self.cosmetics.emotes.camera_from.is_none() {
|
||||
return 1.0;
|
||||
}
|
||||
let t = ((m.age as f32 + partial - p.start) / 5.0).clamp(0.0, 1.0);
|
||||
0.35 + 0.65 * t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,622 @@
|
||||
//! 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);
|
||||
}
|
||||
// What crates draw on players and mannequins (`game/player_hooks.rs`: cosmetics),
|
||||
// posed on the GPU with the mob's own jobs: culled with it.
|
||||
if !layers_only
|
||||
&& horizon_hooks::engine::any()
|
||||
&& (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.player_layers_geometry(mob, base, 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);
|
||||
}
|
||||
}
|
||||
@@ -471,15 +471,46 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
self.cushions[i].check_interval = 0;
|
||||
let c = self.cushions[i].clone();
|
||||
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
|
||||
self.break_cushion(i, false);
|
||||
continue;
|
||||
if !self.check_cushion(i) {
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Cushion.tickAtCheckInterval` plus the survive check; true when the cushion broke.
|
||||
fn check_cushion(&mut self, i: usize) -> bool {
|
||||
let c = self.cushions[i].clone();
|
||||
let p = c.position;
|
||||
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
// collidedWithFluid: the fluid box of the cushion's own block against its box.
|
||||
if let Some((height, _)) =
|
||||
self.fluid_height_of(x, y, z, Some(horizon_client::blocks::Fluid::Lava))
|
||||
{
|
||||
let fluid = Aabb::new(
|
||||
Vec3::new(f64::from(x), f64::from(y), f64::from(z)),
|
||||
Vec3::new(
|
||||
f64::from(x) + 1.0,
|
||||
f64::from(y) + f64::from(height),
|
||||
f64::from(z) + 1.0,
|
||||
),
|
||||
);
|
||||
if aabb(c.bounds())
|
||||
.deflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7))
|
||||
.intersects(fluid)
|
||||
{
|
||||
// LAVA_IGNITE's lavaHurt deals 4 damage, which breaks the cushion.
|
||||
self.break_cushion(i, false);
|
||||
self.lava_hurt_sound([p.x, p.y, p.z]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
|
||||
self.break_cushion(i, false);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `LivingEntity.stopRiding` off a cushion: `dismountVehicle` beside it,
|
||||
/// and `Cushion.removePassenger`'s get-up sound for a rider that left
|
||||
/// rather than one whose cushion was killed.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! OptiFine custom skies (`horizon_client::custom_sky`): each frame, the layers of the world's sky
|
||||
//! whose day, weather, biome and height hold at the camera, handed to the renderer, which draws
|
||||
//! them over the vanilla sky. A pack without `optifine/sky` hands it none.
|
||||
//! Custom skies (`horizon_client::custom_sky`, OptiFine's format): each frame, the layers of the
|
||||
//! world's sky whose day, weather, biome and height hold at the camera, handed to the renderer,
|
||||
//! which draws them over the vanilla sky. A pack without layers hands it none.
|
||||
use super::*;
|
||||
use horizon_optifine::sky::{Conditions, Now};
|
||||
use horizon_client::custom_sky::{Conditions, Weather};
|
||||
|
||||
/// The layers' eased brightness between frames, and when the last frame was.
|
||||
#[derive(Default)]
|
||||
@@ -46,11 +46,11 @@ impl Game<'_> {
|
||||
let mut state = std::mem::take(&mut self.custom_sky.state);
|
||||
// Level.isThundering / isRaining.
|
||||
let weather = if thunder > 0.9 && rain > 0.2 {
|
||||
Now::Thunder
|
||||
Weather::Thunder
|
||||
} else if rain > 0.2 {
|
||||
Now::Rain
|
||||
Weather::Rain
|
||||
} else {
|
||||
Now::Clear
|
||||
Weather::Clear
|
||||
};
|
||||
let conditions = Conditions {
|
||||
day_time: self.view_day_time(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ...`.
|
||||
|
||||
@@ -152,6 +152,14 @@ impl WorldOptions {
|
||||
}
|
||||
self.port.value.parse::<u16>().ok().filter(|p| *p >= 1024)
|
||||
}
|
||||
|
||||
/// `portEdit.setTooltip(PORT_INFO_TEXT)`: the port box explains itself while
|
||||
/// the cursor is over it. The box is drawn even with LAN off, so the tooltip
|
||||
/// shows there too; a remote world has no such box.
|
||||
fn port_tooltip(port: &EditBox, cursor: (f32, f32), remote: bool) -> Option<Text> {
|
||||
(!remote && ui::hovered(cursor, port.x, port.y, port.width, port.height))
|
||||
.then(|| Text::translate("lanServer.port", &[]))
|
||||
}
|
||||
fn snapshot(&self) -> Snapshot {
|
||||
Snapshot {
|
||||
default_mode: self.default_mode,
|
||||
@@ -1003,6 +1011,10 @@ impl Game<'_> {
|
||||
hover,
|
||||
);
|
||||
}
|
||||
// The widgets are drawn; the port box's tooltip goes over them.
|
||||
if let Some(tip) = WorldOptions::port_tooltip(&o.port, cursor, o.remote) {
|
||||
ui::tooltip(batch, atlas, &[tip], cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// The mouse wheel over the screen (ScrollableLayout, 10 pixels a step).
|
||||
@@ -1899,4 +1911,15 @@ mod tests {
|
||||
assert!(o.locked);
|
||||
assert!(!o.active(Button::Difficulty));
|
||||
}
|
||||
|
||||
/// `portEdit.setTooltip(PORT_INFO_TEXT)`: hovering the port box shows its
|
||||
/// label; a remote world (`o.remote`) has no port box to hover.
|
||||
#[test]
|
||||
fn the_port_box_explains_itself_on_hover() {
|
||||
let port = EditBox::new(158.0, 100.0, 40.0, 20.0);
|
||||
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), false).is_some());
|
||||
assert!(WorldOptions::port_tooltip(&port, (157.0, 101.0), false).is_none());
|
||||
assert!(WorldOptions::port_tooltip(&port, (159.0, 121.0), false).is_none());
|
||||
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), true).is_none());
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ pub(super) fn build_renderer(
|
||||
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
|
||||
renderer.set_lines_shader(atlas.lines_shader.as_deref());
|
||||
renderer.set_sky_shaders(atlas.sky_shader.as_deref(), atlas.stars_shader.as_deref());
|
||||
renderer.set_custom_sky(&pack.custom_sky);
|
||||
renderer.set_custom_sky(&*pack.custom_sky);
|
||||
renderer.set_shared_material_maps(&atlas.material_maps);
|
||||
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
|
||||
renderer
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -300,11 +300,7 @@ impl Game<'_> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let marks = if player || self.cosmetics_local() {
|
||||
self.cosmetics_marks(m, Some(m.id) == own)
|
||||
} else {
|
||||
Marks::default()
|
||||
};
|
||||
let marks = self.hooks_name_marks(m, Some(m.id) == own);
|
||||
let at = match marks.top {
|
||||
Some(top) => p + glam::DVec3::Y * cosmetic_lift(top, m.model_scale()),
|
||||
None => p,
|
||||
@@ -770,10 +766,10 @@ impl Game<'_> {
|
||||
if background { BACKGROUND } else { 0 },
|
||||
),
|
||||
};
|
||||
if n == last && marks.horizon {
|
||||
if let Some(mark) = marks.mark.filter(|_| n == last) {
|
||||
let x = -self.gui.atlas.font.width_text(text) / 2.0 - 10.0;
|
||||
// 8 text pixels square, a pixel left of the background.
|
||||
for &(mx, my, w, h, argb) in super::cosmetics::MARK {
|
||||
for &(mx, my, w, h, argb) in mark {
|
||||
let a = text_alpha * (argb >> 24) / 255;
|
||||
batch.fill(
|
||||
&self.gui.atlas,
|
||||
@@ -937,10 +933,11 @@ struct NameTag {
|
||||
marks: Marks,
|
||||
}
|
||||
|
||||
/// What Horizon draws by a player's name: its mark (they play Horizon) and their name tag style.
|
||||
/// What a hook draws by a player's name (`horizon_hooks::players::NameDecoration`): a mark
|
||||
/// before it (Horizon's: they play Horizon) and a name tag style.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(super) struct Marks {
|
||||
pub horizon: bool,
|
||||
pub mark: Option<horizon_hooks::players::Mark>,
|
||||
pub style: Option<TagStyle>,
|
||||
/// How high the cosmetics drawn on their body get above the neck, in model pixels: the tag
|
||||
/// goes up over a tall one (`cosmetic_lift`).
|
||||
|
||||
@@ -25,6 +25,7 @@ impl Game<'_> {
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
self.particles.tick(&level);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -311,8 +311,8 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let mut view = self.view;
|
||||
// An emote pulls the camera back from the player's face (`game/cosmetics/emotes.rs`).
|
||||
let pull = self.emote_camera_pull(partial);
|
||||
// A hook may pull the camera back (an emote, from the player's face).
|
||||
let pull = self.hooks_camera_distance(partial);
|
||||
let kind = match self.camera_type {
|
||||
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
|
||||
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
//! Where the game hands the player models to crates above the engine (`horizon_hooks::Hooks`'s
|
||||
//! `frame`, `tick`, `name_decoration`, `player_mark`, `camera`; spec #14091): only a window's
|
||||
//! game calls them, never the integrated server's thread or a dedicated server. With no hook
|
||||
//! registered nothing is built.
|
||||
|
||||
use super::Game;
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::join_seq::JoinList;
|
||||
use horizon_client::mesher::Vertex;
|
||||
use horizon_client::mobs::anim::{self, BodyPose, PooledPoses, Poses};
|
||||
use horizon_client::mobs::{Mob, Part, Polygon, render};
|
||||
use horizon_hooks::engine;
|
||||
use horizon_hooks::players::{
|
||||
Bone, CameraView, Frame, Gear, Kind, Mesh, PartPoses, Pass, PlayerDraw, PlayerModel,
|
||||
PlayerModels, SpriteStore, Sprites,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Registers the hooks of the Horizon parts still in this crate (cosmetics: spec #14091 step 5
|
||||
/// moves them out). Before the hooks are sealed.
|
||||
pub(crate) fn register() {
|
||||
if super::cosmetics::enabled()
|
||||
&& let Err(e) = horizon_hooks::register(&super::cosmetics::HOOKS)
|
||||
{
|
||||
log::error!("Cosmetics: their hooks came too late: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The models of the world a window shows: its own first, then the other players and mannequins.
|
||||
struct Models<'a> {
|
||||
own: Option<&'a mut Mob>,
|
||||
mobs: &'a mut JoinList<Mob>,
|
||||
remote: &'a super::remote::Remote,
|
||||
}
|
||||
|
||||
impl Models<'_> {
|
||||
fn find(&mut self, id: u32) -> Option<&mut Mob> {
|
||||
match self.own.as_deref_mut() {
|
||||
Some(m) if m.id == id => Some(m),
|
||||
_ => self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id && kind(m).is_some()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of player model `m` is, if it is one (the window's own model aside).
|
||||
fn kind(m: &Mob) -> Option<Kind> {
|
||||
match &*m.kind.id {
|
||||
"minecraft:player" => Some(Kind::Player),
|
||||
"minecraft:mannequin" => Some(Kind::Mannequin),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What `m` wears where a crate may draw: a helmet or head item, a chestplate, a cape or elytra.
|
||||
pub(super) fn gear(m: &Mob) -> Gear {
|
||||
let elytra = m.extra.chest.as_deref() == Some("minecraft:elytra");
|
||||
Gear::new(
|
||||
m.extra.head.is_some(),
|
||||
m.extra.chest.is_some() && !elytra,
|
||||
m.cape.is_some() || elytra,
|
||||
)
|
||||
}
|
||||
|
||||
fn model(m: &Mob, kind: Kind, uuid: Option<u128>) -> PlayerModel {
|
||||
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
|
||||
PlayerModel::new(m.id, kind, uuid, m.age, dx * dx + dz * dz > 1e-4)
|
||||
.looks(gear(m), horizon_client::mobs::trader::is_invisible(m))
|
||||
}
|
||||
|
||||
impl PlayerModels for Models<'_> {
|
||||
fn connection(&self) -> u64 {
|
||||
self.remote.session.serial()
|
||||
}
|
||||
|
||||
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
|
||||
if let Some(m) = self.own.as_deref() {
|
||||
f(model(m, Kind::Own, Some(self.remote.profile())));
|
||||
}
|
||||
for m in self.mobs.iter() {
|
||||
if let Some(kind) = kind(m) {
|
||||
let uuid = (kind == Kind::Player)
|
||||
.then(|| self.remote.player_uuid(m.id))
|
||||
.flatten();
|
||||
f(model(m, kind, uuid));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
|
||||
match self.own.as_deref() {
|
||||
Some(m) if m.id == id => m.emote.as_ref(),
|
||||
_ => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && kind(m).is_some())?
|
||||
.emote
|
||||
.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
|
||||
if let Some(m) = self.find(id) {
|
||||
m.emote = pose;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sprite `id`'s place in the atlas's names (`hooks/<hex>`), written into `buf`: no allocation.
|
||||
fn location(id: u64, buf: &mut [u8; 22]) -> &str {
|
||||
use std::io::Write;
|
||||
let mut out = std::io::Cursor::new(&mut buf[..]);
|
||||
let _ = write!(out, "hooks/{id:x}");
|
||||
let len = out.position() as usize;
|
||||
std::str::from_utf8(&buf[..len]).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The crates' sprites in the GUI atlas, in the skin slots: read while drawing.
|
||||
struct HookSprites<'a, 'g>(&'a Game<'g>);
|
||||
|
||||
impl Sprites for HookSprites<'_, '_> {
|
||||
fn get(&self, id: u64) -> Option<[f32; 4]> {
|
||||
let mut buf = [0; 22];
|
||||
let at = location(id, &mut buf);
|
||||
let r = self.0.gui.atlas.sprites.get(at)?;
|
||||
// Kept this frame (`skin_slots.next_frame`).
|
||||
self.0.skin_downloads.borrow_mut().drawn(at);
|
||||
Some([r.u0, r.v0, r.u1, r.v1])
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a crate's frame hook puts its sprites.
|
||||
struct HookStore<'a, 'g>(&'a mut Game<'g>);
|
||||
|
||||
impl SpriteStore for HookStore<'_, '_> {
|
||||
fn has(&self, id: u64) -> bool {
|
||||
self.0
|
||||
.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.contains_key(location(id, &mut [0; 22]))
|
||||
}
|
||||
|
||||
fn put(&mut self, id: u64, rgba: Vec<u8>) {
|
||||
let at = location(id, &mut [0; 22]).to_owned();
|
||||
self.0.store_skin(at, rgba);
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The crates' meshes as the renderer takes them, by the mesh's address (each entry holds its
|
||||
/// mesh, so the address isn't reused); render thread only.
|
||||
static MESHES: std::cell::RefCell<FxHashMap<usize, (Arc<Mesh>, Arc<Part>)>> =
|
||||
Default::default();
|
||||
}
|
||||
|
||||
/// The game's model of `mesh`, made once.
|
||||
fn part_of(mesh: &Arc<Mesh>) -> Arc<Part> {
|
||||
fn part(m: &Mesh) -> Part {
|
||||
Part {
|
||||
name: m.name.clone(),
|
||||
pose: m.pose,
|
||||
polygons: m
|
||||
.quads
|
||||
.iter()
|
||||
.map(|q| Polygon {
|
||||
normal: q.normal,
|
||||
vertices: q.vertices,
|
||||
cull_back: false,
|
||||
})
|
||||
.collect(),
|
||||
children: m.children.iter().map(part).collect(),
|
||||
}
|
||||
}
|
||||
MESHES.with_borrow_mut(|meshes| {
|
||||
let (_, part_of) = meshes
|
||||
.entry(Arc::as_ptr(mesh) as usize)
|
||||
.or_insert_with(|| (mesh.clone(), Arc::new(part(mesh))));
|
||||
part_of.clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// Forgets the meshes no crate holds any more (once a frame).
|
||||
fn forget_meshes() {
|
||||
let _ = MESHES.try_with(|m| {
|
||||
m.borrow_mut()
|
||||
.retain(|_, (mesh, _)| Arc::strong_count(mesh) > 1)
|
||||
});
|
||||
}
|
||||
|
||||
/// `Poses` as a crate poses a mesh's parts.
|
||||
struct PosesOf<'a>(&'a mut Poses);
|
||||
|
||||
impl PartPoses for PosesOf<'_> {
|
||||
fn pose(&mut self, name: &str) -> &mut [f32; 9] {
|
||||
self.0.get(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A player model drawn in the world pass, as a crate draws on it: posed on the GPU's skin path
|
||||
/// when `gpu` (the mob pass's skin frame, begun; the bounds of what went there grow, for
|
||||
/// culling), else its vertices go to `out`.
|
||||
struct DrawPlayer<'a, 'g> {
|
||||
game: &'a Game<'g>,
|
||||
m: &'a Mob,
|
||||
pass: Pass,
|
||||
/// The model's matrix (`render::base_transform`).
|
||||
base: glam::Mat4,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
gpu: bool,
|
||||
out: &'a mut Vec<Vertex>,
|
||||
bounds: Option<(glam::Vec3, glam::Vec3)>,
|
||||
/// The body as it is drawn, posed on the first `bone`.
|
||||
body: Option<PooledPoses>,
|
||||
poses: PooledPoses,
|
||||
}
|
||||
|
||||
impl DrawPlayer<'_, '_> {
|
||||
fn body(&self) -> &Part {
|
||||
let (key, _) = render::body(self.m);
|
||||
self.m.kind.layer(&key).unwrap_or(&self.m.kind.model)
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerDraw for DrawPlayer<'_, '_> {
|
||||
fn pass(&self) -> Pass {
|
||||
self.pass
|
||||
}
|
||||
|
||||
fn ticks(&self) -> f32 {
|
||||
self.m.age as f32 + self.alpha
|
||||
}
|
||||
|
||||
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
|
||||
if self.body.is_none() {
|
||||
let mut poses = Poses::pooled();
|
||||
poses.reset(self.body());
|
||||
let data = self.game.res.mob_data();
|
||||
let state = anim::state(self.m, self.alpha);
|
||||
anim::setup(self.m, &state, &mut poses, &data.animations);
|
||||
self.body = Some(poses);
|
||||
}
|
||||
let poses = self.body.as_ref()?;
|
||||
let at = render::part_matrix(self.body(), self.base, poses, bone.part(), 0.0)?;
|
||||
Some(at.to_cols_array_2d())
|
||||
}
|
||||
|
||||
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>> {
|
||||
self.m.emote.as_ref()
|
||||
}
|
||||
|
||||
fn sprite(&self, id: u64) -> Option<[f32; 4]> {
|
||||
HookSprites(self.game).get(id)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&mut self,
|
||||
mesh: &Arc<Mesh>,
|
||||
matrix: [[f32; 4]; 4],
|
||||
uv: [f32; 4],
|
||||
pose: &mut dyn FnMut(&mut dyn PartPoses),
|
||||
) {
|
||||
let model = part_of(mesh);
|
||||
let matrix = glam::Mat4::from_cols_array_2d(&matrix);
|
||||
self.poses.reset(&model);
|
||||
pose(&mut PosesOf(&mut self.poses));
|
||||
if self.gpu {
|
||||
let mut models = self.game.skin_models.borrow_mut();
|
||||
let mut frame = self.game.skin_frame.borrow_mut();
|
||||
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
|
||||
skin.bounds = self.bounds;
|
||||
let paint = horizon_client::mobs::skin::Paint {
|
||||
uv,
|
||||
scroll: 0.0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: self.light,
|
||||
overlay: 0,
|
||||
};
|
||||
if skin.draw(&model, matrix, &self.poses, paint) {
|
||||
self.bounds = skin.bounds;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let poses = &self.poses;
|
||||
horizon_client::mobs::render_model(
|
||||
&model,
|
||||
matrix,
|
||||
&|p: &Part| poses.pose(p),
|
||||
uv,
|
||||
self.light,
|
||||
0,
|
||||
self.out,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// What crates draw on player model `m` in the world pass (`creatures.rs`), then the
|
||||
/// cosmetics still drawn here: see [`DrawPlayer`]. The bounds of what went to the GPU.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn player_layers_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
base: glam::Mat4,
|
||||
camera: glam::DVec3,
|
||||
alpha: f32,
|
||||
light: u32,
|
||||
gpu: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> Option<(glam::Vec3, glam::Vec3)> {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
let kind = kind(m)?;
|
||||
let mut bounds = None;
|
||||
if engine::any() {
|
||||
let model = model(m, kind, self.remote_player_uuid(m.id));
|
||||
let mut draw = DrawPlayer {
|
||||
game: self,
|
||||
m,
|
||||
pass: Pass::World,
|
||||
base,
|
||||
alpha,
|
||||
light,
|
||||
gpu,
|
||||
out: &mut *out,
|
||||
bounds: None,
|
||||
body: None,
|
||||
poses: Poses::pooled(),
|
||||
};
|
||||
engine::player_layers(&model, &mut draw);
|
||||
bounds = draw.bounds;
|
||||
}
|
||||
let worn = super::cosmetics::enabled()
|
||||
.then(|| self.cosmetics_geometry(m, false, base, Some(camera), alpha, light, gpu, out))
|
||||
.flatten();
|
||||
match (bounds, worn) {
|
||||
(Some((l, h)), Some((lo, hi))) => Some((l.min(lo), h.max(hi))),
|
||||
(b, w) => b.or(w),
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a frame, before drawing.
|
||||
pub(super) fn client_hooks_frame(&mut self) {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
if !engine::any() {
|
||||
return;
|
||||
}
|
||||
self.cosmetics_say();
|
||||
let connection = self.remote.as_ref().map_or(0, |r| r.session.serial());
|
||||
let frame = Frame::new(connection, self.integrated.is_some());
|
||||
engine::frame(&frame, &mut HookStore(self));
|
||||
forget_meshes();
|
||||
self.cosmetics_frame();
|
||||
}
|
||||
|
||||
/// Once a client tick in a world: the player's own emote, then the hooks.
|
||||
pub(super) fn client_hooks_tick(&mut self) {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
self.tick_emotes();
|
||||
if !engine::any() {
|
||||
return;
|
||||
}
|
||||
self.cosmetics_say();
|
||||
let Some(remote) = self.remote.as_ref() else {
|
||||
return;
|
||||
};
|
||||
engine::tick(&mut Models {
|
||||
own: self.player_model.as_mut(),
|
||||
mobs: &mut self.mobs,
|
||||
remote,
|
||||
});
|
||||
}
|
||||
|
||||
/// What goes by `m`'s name (`own`: the window's own player), from the hooks.
|
||||
pub(super) fn hooks_name_marks(&self, m: &Mob, own: bool) -> super::names::Marks {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
let mut marks = super::names::Marks::default();
|
||||
if !engine::any() {
|
||||
return marks;
|
||||
}
|
||||
let model = match (own, kind(m)) {
|
||||
(true, _) => model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile())),
|
||||
(false, Some(kind)) => model(m, kind, self.remote_player_uuid(m.id)),
|
||||
(false, None) => return marks,
|
||||
};
|
||||
if let Some(d) = engine::name_decoration(&model, &HookSprites(self)) {
|
||||
marks.mark = d.mark;
|
||||
marks.top = d.top;
|
||||
marks.style = d.badge.map(|b| {
|
||||
let [u0, v0, u1, v1] = b.uv;
|
||||
super::names::TagStyle {
|
||||
mark: horizon_client::hud::Rect {
|
||||
u0,
|
||||
v0,
|
||||
u1,
|
||||
v1,
|
||||
w: 8,
|
||||
h: 8,
|
||||
},
|
||||
line: b.line,
|
||||
}
|
||||
});
|
||||
}
|
||||
marks
|
||||
}
|
||||
|
||||
/// How far the third-person camera stands back, as a share of the usual distance: a hook's
|
||||
/// (an emote pulls it back), else 1.
|
||||
pub(super) fn hooks_camera_distance(&self, partial: f32) -> f32 {
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
match &self.player_model {
|
||||
Some(m) if engine::any() => {
|
||||
engine::camera(&CameraView::new(m.age as f32 + partial)).map_or(1.0, |c| c.distance)
|
||||
}
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sprite_locations_fit_the_buffer_and_differ_by_id() {
|
||||
let mut a = [0; 22];
|
||||
let mut b = [0; 22];
|
||||
assert_eq!(location(u64::MAX, &mut a), "hooks/ffffffffffffffff");
|
||||
assert_eq!(location(10, &mut b), "hooks/a");
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,7 @@ impl Game<'_> {
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
find_noxious_gas_source_block(&level, [pos.0, pos.1, pos.2])
|
||||
}
|
||||
@@ -198,6 +199,7 @@ impl Game<'_> {
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
// The gas reads blocks only, so the mobs it reaches can be found
|
||||
// before any gets its effect.
|
||||
@@ -264,6 +266,7 @@ impl Game<'_> {
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
can_be_reached_by_noxious_gas(&level, source, eye)
|
||||
};
|
||||
@@ -356,6 +359,7 @@ impl Game<'_> {
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let height = unobstructed_block_count(&level, [pos.0, pos.1 + 1, pos.2], water_blocks);
|
||||
let area = launch_area(pos, height);
|
||||
@@ -419,6 +423,7 @@ impl Game<'_> {
|
||||
facts: self.res.facts(),
|
||||
collision: self.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
unobstructed_block_count(&level, [pos.0, pos.1 + 1, pos.2], water_blocks)
|
||||
};
|
||||
|
||||
@@ -60,6 +60,9 @@ use std::{
|
||||
};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
#[path = "remote_poll.rs"]
|
||||
mod poll;
|
||||
|
||||
type BuiltinCache = Vec<(std::path::PathBuf, Arc<Builtin>)>;
|
||||
static BUILTIN: std::sync::Mutex<BuiltinCache> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
@@ -2174,288 +2177,6 @@ impl Game<'_> {
|
||||
pub(super) fn remote_backlog(&self) -> usize {
|
||||
self.remote.as_ref().map_or(0, |r| r.backlog.len())
|
||||
}
|
||||
/// A headless command oracle has a real connection but no movement tick.
|
||||
/// Acknowledge loaded terrain without predicting or sending movement.
|
||||
pub(super) fn poll_stationary_client(&mut self) -> Result<()> {
|
||||
self.poll_remote()?;
|
||||
let loaded = self.player.as_ref().is_some_and(|p| {
|
||||
self.streaming.is_lit(
|
||||
(p.position.x.floor() as i32) >> 4,
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
)
|
||||
});
|
||||
if loaded && self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
|
||||
self.send(S::PlayerLoaded(Empty));
|
||||
let r = self.remote.as_mut().unwrap();
|
||||
r.loaded_sent = true;
|
||||
r.level_load.finish();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process what the network thread received (once per frame, like the
|
||||
/// client's packet processor), in order. Chunk packets get
|
||||
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
|
||||
/// the next chunk packet on wait for the next frame, unless they have
|
||||
/// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds
|
||||
/// back what follows it.
|
||||
pub(super) fn poll_remote(&mut self) -> Result<()> {
|
||||
let polled = self.poll_remote_events();
|
||||
// The block events and block changes of a piston clock start and end
|
||||
// thousands of moves at once: the collision view and the renderer
|
||||
// take them all in one sync, before the tick or frame reads them.
|
||||
if std::mem::take(&mut self.moving_pistons_stale) {
|
||||
self.sync_moving_pistons();
|
||||
}
|
||||
polled
|
||||
}
|
||||
fn poll_remote_events(&mut self) -> Result<()> {
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut events = std::mem::take(&mut r.backlog);
|
||||
// Chunk packets start decoding now; the frame that reaches one in
|
||||
// order inserts its column.
|
||||
for event in r.session.poll() {
|
||||
events.push_back(match event {
|
||||
Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => {
|
||||
match r.decoder.submit(r.chunk_format.clone(), p) {
|
||||
None => Received::Chunk(r.chunk_format.clone()),
|
||||
Some(p) => Received::Event(Event::Packet(p)),
|
||||
}
|
||||
}
|
||||
event => Received::Event(event),
|
||||
});
|
||||
}
|
||||
let overdue = r
|
||||
.backlog_since
|
||||
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
|
||||
let mut chunks = Duration::ZERO;
|
||||
// A run of ForgetLevelChunk (thousands when the server's view moves far: a
|
||||
// teleport at 32 chunks) is forgotten at once, before whatever follows it.
|
||||
let mut forgotten = horizon_client::fast_hash::FxHashSet::default();
|
||||
while let Some(item) = events.pop_front() {
|
||||
if let Received::Event(Event::Packet(packet)) = &item
|
||||
&& let C::ForgetLevelChunk(p) = &**packet
|
||||
{
|
||||
self.native_client_packet(packet);
|
||||
forgotten.insert((p.pos[0], p.pos[1]));
|
||||
continue;
|
||||
}
|
||||
self.forget_remote_columns(&mut forgotten);
|
||||
let chunk = matches!(item, Received::Chunk(_));
|
||||
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
|
||||
events.push_front(item);
|
||||
break;
|
||||
}
|
||||
let started = chunk.then(Instant::now);
|
||||
let event = match item {
|
||||
Received::Event(event) => event,
|
||||
Received::Chunk(format) => {
|
||||
// A column still decoding waits for the next frame, with
|
||||
// what came after it, overdue or not.
|
||||
let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else {
|
||||
break;
|
||||
};
|
||||
let (packet, column) = match next {
|
||||
Next::Ready(decoded) => decoded,
|
||||
Next::Decoding => {
|
||||
events.push_front(Received::Chunk(format));
|
||||
break;
|
||||
}
|
||||
Next::Lost => {
|
||||
log::warn!("Horizon: the chunk decoder stopped; a column is missing");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
self.native_client_packet(&packet);
|
||||
if let C::LevelChunkWithLight(p) = *packet {
|
||||
self.remote_chunk(&p, Some((&format, column)));
|
||||
}
|
||||
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match event {
|
||||
Event::Status(s) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.status = s;
|
||||
}
|
||||
}
|
||||
Event::Joined {
|
||||
registries,
|
||||
name,
|
||||
uuid,
|
||||
} => {
|
||||
self.set_registries(registries);
|
||||
self.extras_joined();
|
||||
// handleLogin: the level loading screen replaces any dialog.
|
||||
self.close_dialogs();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.joined = true;
|
||||
// handleLogin: startWaitingForNewLevel with Reason.OTHER.
|
||||
r.profile = uuid;
|
||||
r.profile_name = name.clone();
|
||||
// LevelLoadingScreen's line: no player name.
|
||||
r.status = horizon_client::lang::get_or(
|
||||
"multiplayer.downloadingTerrain",
|
||||
"Loading terrain...",
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Features(features) => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.features = features;
|
||||
}
|
||||
}
|
||||
Event::Brand(brand) => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.brand = brand;
|
||||
}
|
||||
}
|
||||
Event::QuietCommands => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.quiet_commands = true;
|
||||
}
|
||||
}
|
||||
Event::ModNet => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.mod_net = true;
|
||||
}
|
||||
}
|
||||
Event::FarTerrain(offer) => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.far_terrain = Some(offer);
|
||||
}
|
||||
}
|
||||
Event::Reconfiguring => {
|
||||
// handleConfigurationStart: the held-back chat shows, then
|
||||
// clearClientLevel opens ServerReconfigScreen over no level.
|
||||
self.flush_chat_queue();
|
||||
self.send_chat_acknowledgement();
|
||||
self.close_dialogs();
|
||||
// The server configuring us now (a proxy's next one)
|
||||
// offers its own far terrain, or none, and announces its
|
||||
// own features again (a Horizon server's quiet commands).
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.far_terrain = None;
|
||||
remote.quiet_commands = false;
|
||||
remote.mod_net = false;
|
||||
}
|
||||
self.clear_lod();
|
||||
if let Some(remote) = &self.remote {
|
||||
remote.session.reset_far_terrain();
|
||||
}
|
||||
self.player = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(false);
|
||||
// clearClientLevel drops the client level now, so its
|
||||
// unload comes before the configuration screen, not
|
||||
// after the next login's load.
|
||||
if let Some(old) = self.remote.as_mut().and_then(|r| r.heard_level.take()) {
|
||||
self.native_client_level_unload(&old);
|
||||
}
|
||||
let ticks = self.ticks;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.reconfiguring = Some(ticks);
|
||||
r.chat_tree_received = false;
|
||||
}
|
||||
}
|
||||
Event::Reconfigured(registries) => {
|
||||
// handleConfigurationStart → clearClientLevel, and the next
|
||||
// ClientPacketListener starts with no players or scoreboard,
|
||||
// no waypoints (its own ClientWaypointManager: a proxy's next
|
||||
// server never untracks the last one's), and its new
|
||||
// ClientSuggestionProvider with no custom chat completions.
|
||||
// clearClientLevel clears the narrator.
|
||||
self.clear_narrator();
|
||||
self.hud_disconnected();
|
||||
self.score.board = Default::default();
|
||||
self.clear_waypoints();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.attachments.clear();
|
||||
r.players.clear();
|
||||
r.tab_players.clear();
|
||||
r.listed.clear();
|
||||
r.signatures = Default::default();
|
||||
r.custom_completions.clear();
|
||||
}
|
||||
self.ui.registries.custom_completions.clear();
|
||||
self.set_registries(registries);
|
||||
}
|
||||
// handleTransfer: "Cannot transfer to server from
|
||||
// singleplayer" (no ServerData), so the world just closes.
|
||||
Event::Transfer { host, port } => {
|
||||
if let Some(r) = self.remote.as_mut()
|
||||
&& r.session.address != "local"
|
||||
{
|
||||
r.transfer = Some(format!("{host}:{port}"));
|
||||
}
|
||||
}
|
||||
Event::PackPush(p) => self.handle_pack_push(p),
|
||||
Event::PackPop(id) => self.handle_pack_pop(id),
|
||||
Event::ServerLinks(links) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.server_links = super::server_links::trusted(links);
|
||||
}
|
||||
}
|
||||
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
|
||||
Event::ClearDialog => self.clear_dialog(),
|
||||
Event::CodeOfConduct(text) => self.handle_code_of_conduct(text),
|
||||
Event::DecodeError(e) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.decode_errors += 1;
|
||||
if r.decode_errors <= 20 {
|
||||
log::warn!("Horizon: skipped undecodable packet {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Disconnected {
|
||||
reason,
|
||||
key,
|
||||
from_server,
|
||||
} => {
|
||||
log::info!("Horizon: disconnected: {reason}");
|
||||
// ClientboundTransferPacket left a target: connect there
|
||||
// instead of showing the disconnect, the stored cookies
|
||||
// carried over (ClientCommonPacketListenerImpl.
|
||||
// handleTransfer → ConnectScreen.startConnecting).
|
||||
let transfer = self.remote.as_mut().and_then(|r| {
|
||||
r.transfer.take().map(|address| {
|
||||
(address, r.session.name.clone(), r.session.cookies.clone())
|
||||
})
|
||||
});
|
||||
if let Some((address, name, cookies)) = transfer {
|
||||
self.leave_server(None)?;
|
||||
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
|
||||
return Ok(());
|
||||
}
|
||||
let retry = self.reconnect_after(&reason, key.as_deref(), from_server);
|
||||
self.leave_server(Some(reason))?;
|
||||
self.offer_reconnect(retry);
|
||||
return Ok(());
|
||||
}
|
||||
Event::Packet(packet) => self.handle_packet(*packet)?,
|
||||
}
|
||||
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
|
||||
if self.remote.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.forget_remote_columns(&mut forgotten);
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.backlog_since = if events.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(r.backlog_since.unwrap_or_else(Instant::now))
|
||||
};
|
||||
r.backlog = events;
|
||||
}
|
||||
self.send_suggestion_request();
|
||||
Ok(())
|
||||
}
|
||||
/// ClientSuggestionProvider.customSuggestion: the chat's completion
|
||||
/// request for an `ask_server` node goes to the server.
|
||||
fn send_suggestion_request(&mut self) {
|
||||
@@ -7010,7 +6731,7 @@ impl Game<'_> {
|
||||
if self.studio_replay.is_none() {
|
||||
self.tick_player_model();
|
||||
}
|
||||
self.tick_emotes();
|
||||
self.client_hooks_tick();
|
||||
if !dead {
|
||||
self.remote_tick_using();
|
||||
}
|
||||
@@ -7083,24 +6804,24 @@ impl Game<'_> {
|
||||
let mut push = Vec3::ZERO;
|
||||
let order = |id: u32| r.ticked_before_player.contains(&id) == before_player;
|
||||
for m in self.mobs.iter().filter(|m| order(m.id)) {
|
||||
let listed = r
|
||||
.level_players
|
||||
.get(&(m.id as i32))
|
||||
.and_then(|uuid| r.tab_players.get(uuid));
|
||||
let spectator = listed.is_some_and(|t| t.game_mode == SPECTATOR);
|
||||
let mob_team = match listed {
|
||||
Some(t) => board.players_team(&t.name),
|
||||
None => teams.of(Who::Mob(m.id)),
|
||||
};
|
||||
// Bats never push; a spectator has no physics; passengers of the
|
||||
// same vehicle never push each other.
|
||||
// Bats never push, nor passengers of one vehicle, nor a spectator; the
|
||||
// box test comes before the lookups that far mobs never need (#14053).
|
||||
if !m.alive()
|
||||
|| spectator
|
||||
|| m.species() == horizon_client::mobs::Species::Bat
|
||||
|| self
|
||||
.riding
|
||||
.is_some_and(|v| v == m.id || m.extra.mount == Some(v))
|
||||
|| !m.bounds().intersects(bounds)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let listed = r
|
||||
.level_players
|
||||
.get(&(m.id as i32))
|
||||
.and_then(|uuid| r.tab_players.get(uuid));
|
||||
let mob_team =
|
||||
listed.map_or_else(|| teams.of(Who::Mob(m.id)), |t| board.players_team(&t.name));
|
||||
if listed.is_some_and(|t| t.game_mode == SPECTATOR)
|
||||
|| !horizon_client::scoreboard::pushable_by(mob_team, player_team, true)
|
||||
{
|
||||
continue;
|
||||
@@ -10993,17 +10714,11 @@ impl Game<'_> {
|
||||
/// The profile name of another player the server sent, by entity id.
|
||||
/// The UUID of another player's entity in the level.
|
||||
pub(super) fn remote_player_uuid(&self, id: u32) -> Option<u128> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let uuid = *r.level_players.get(&(id as i32))?;
|
||||
(uuid != r.profile).then_some(uuid)
|
||||
self.remote.as_ref()?.player_uuid(id)
|
||||
}
|
||||
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let uuid = r.level_players.get(&(id as i32))?;
|
||||
if *uuid == r.profile {
|
||||
return None;
|
||||
}
|
||||
r.level_player_name(id as i32, uuid)
|
||||
r.level_player_name(id as i32, &r.player_uuid(id)?)
|
||||
}
|
||||
/// Status for `horizon-ctl status`.
|
||||
/// The other players' entities in the level and their names (their
|
||||
@@ -11784,6 +11499,11 @@ impl Remote {
|
||||
pub(super) fn profile(&self) -> u128 {
|
||||
self.profile
|
||||
}
|
||||
/// Another player's profile UUID, by their entity id (None for the local player).
|
||||
pub(super) fn player_uuid(&self, id: u32) -> Option<u128> {
|
||||
let uuid = *self.level_players.get(&(id as i32))?;
|
||||
(uuid != self.profile).then_some(uuid)
|
||||
}
|
||||
/// The entity id of a player in the level, by profile UUID.
|
||||
pub(super) fn player_entity(&self, uuid: u128) -> Option<u32> {
|
||||
self.level_players
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
//! The server connection's received events, once a frame (`poll_remote`): chunk packets
|
||||
//! within their per-frame budget, then every other packet in order (`handle_packet`).
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// A headless command oracle has a real connection but no movement tick.
|
||||
/// Acknowledge loaded terrain without predicting or sending movement.
|
||||
pub(in crate::game) fn poll_stationary_client(&mut self) -> Result<()> {
|
||||
self.poll_remote()?;
|
||||
let loaded = self.player.as_ref().is_some_and(|p| {
|
||||
self.streaming.is_lit(
|
||||
(p.position.x.floor() as i32) >> 4,
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
)
|
||||
});
|
||||
if loaded && self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
|
||||
self.send(S::PlayerLoaded(Empty));
|
||||
let r = self.remote.as_mut().unwrap();
|
||||
r.loaded_sent = true;
|
||||
r.level_load.finish();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process what the network thread received (once per frame, like the
|
||||
/// client's packet processor), in order. Chunk packets get
|
||||
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
|
||||
/// the next chunk packet on wait for the next frame, unless they have
|
||||
/// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds
|
||||
/// back what follows it.
|
||||
pub(in crate::game) fn poll_remote(&mut self) -> Result<()> {
|
||||
let polled = self.poll_remote_events();
|
||||
// The block events and block changes of a piston clock start and end
|
||||
// thousands of moves at once: the collision view and the renderer
|
||||
// take them all in one sync, before the tick or frame reads them.
|
||||
if std::mem::take(&mut self.moving_pistons_stale) {
|
||||
self.sync_moving_pistons();
|
||||
}
|
||||
polled
|
||||
}
|
||||
fn poll_remote_events(&mut self) -> Result<()> {
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut events = std::mem::take(&mut r.backlog);
|
||||
// Chunk packets start decoding now; the frame that reaches one in
|
||||
// order inserts its column.
|
||||
for event in r.session.poll() {
|
||||
events.push_back(match event {
|
||||
Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => {
|
||||
match r.decoder.submit(r.chunk_format.clone(), p) {
|
||||
None => Received::Chunk(r.chunk_format.clone()),
|
||||
Some(p) => Received::Event(Event::Packet(p)),
|
||||
}
|
||||
}
|
||||
event => Received::Event(event),
|
||||
});
|
||||
}
|
||||
let overdue = r
|
||||
.backlog_since
|
||||
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
|
||||
let mut chunks = Duration::ZERO;
|
||||
// A run of ForgetLevelChunk (thousands when the server's view moves far: a
|
||||
// teleport at 32 chunks) is forgotten at once, before whatever follows it.
|
||||
let mut forgotten = horizon_client::fast_hash::FxHashSet::default();
|
||||
while let Some(item) = events.pop_front() {
|
||||
if let Received::Event(Event::Packet(packet)) = &item
|
||||
&& let C::ForgetLevelChunk(p) = &**packet
|
||||
{
|
||||
self.native_client_packet(packet);
|
||||
forgotten.insert((p.pos[0], p.pos[1]));
|
||||
continue;
|
||||
}
|
||||
self.forget_remote_columns(&mut forgotten);
|
||||
let chunk = matches!(item, Received::Chunk(_));
|
||||
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
|
||||
events.push_front(item);
|
||||
break;
|
||||
}
|
||||
let started = chunk.then(Instant::now);
|
||||
let event = match item {
|
||||
Received::Event(event) => event,
|
||||
Received::Chunk(format) => {
|
||||
// A column still decoding waits for the next frame, with
|
||||
// what came after it, overdue or not.
|
||||
let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else {
|
||||
break;
|
||||
};
|
||||
let (packet, column) = match next {
|
||||
Next::Ready(decoded) => decoded,
|
||||
Next::Decoding => {
|
||||
events.push_front(Received::Chunk(format));
|
||||
break;
|
||||
}
|
||||
Next::Lost => {
|
||||
log::warn!("Horizon: the chunk decoder stopped; a column is missing");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
self.native_client_packet(&packet);
|
||||
if let C::LevelChunkWithLight(p) = *packet {
|
||||
self.remote_chunk(&p, Some((&format, column)));
|
||||
}
|
||||
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match event {
|
||||
Event::Status(s) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.status = s;
|
||||
}
|
||||
}
|
||||
Event::Joined {
|
||||
registries,
|
||||
name,
|
||||
uuid,
|
||||
} => {
|
||||
self.set_registries(registries);
|
||||
self.extras_joined();
|
||||
// handleLogin: the level loading screen replaces any dialog.
|
||||
self.close_dialogs();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.joined = true;
|
||||
// handleLogin: startWaitingForNewLevel with Reason.OTHER.
|
||||
r.profile = uuid;
|
||||
r.profile_name = name.clone();
|
||||
// LevelLoadingScreen's line: no player name.
|
||||
r.status = horizon_client::lang::get_or(
|
||||
"multiplayer.downloadingTerrain",
|
||||
"Loading terrain...",
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Features(features) => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.features = features;
|
||||
}
|
||||
}
|
||||
Event::Brand(brand) => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.brand = brand;
|
||||
}
|
||||
}
|
||||
Event::QuietCommands => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.quiet_commands = true;
|
||||
}
|
||||
}
|
||||
Event::ModNet => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.mod_net = true;
|
||||
}
|
||||
}
|
||||
Event::FarTerrain(offer) => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.far_terrain = Some(offer);
|
||||
}
|
||||
}
|
||||
Event::Reconfiguring => {
|
||||
// handleConfigurationStart: the held-back chat shows, then
|
||||
// clearClientLevel opens ServerReconfigScreen over no level.
|
||||
self.flush_chat_queue();
|
||||
self.send_chat_acknowledgement();
|
||||
self.close_dialogs();
|
||||
// The server configuring us now (a proxy's next one)
|
||||
// offers its own far terrain, or none, and announces its
|
||||
// own features again (a Horizon server's quiet commands).
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.far_terrain = None;
|
||||
remote.quiet_commands = false;
|
||||
remote.mod_net = false;
|
||||
}
|
||||
self.clear_lod();
|
||||
if let Some(remote) = &self.remote {
|
||||
remote.session.reset_far_terrain();
|
||||
}
|
||||
self.player = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(false);
|
||||
// clearClientLevel drops the client level now, so its
|
||||
// unload comes before the configuration screen, not
|
||||
// after the next login's load.
|
||||
if let Some(old) = self.remote.as_mut().and_then(|r| r.heard_level.take()) {
|
||||
self.native_client_level_unload(&old);
|
||||
}
|
||||
let ticks = self.ticks;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.reconfiguring = Some(ticks);
|
||||
r.chat_tree_received = false;
|
||||
}
|
||||
}
|
||||
Event::Reconfigured(registries) => {
|
||||
// handleConfigurationStart → clearClientLevel, and the next
|
||||
// ClientPacketListener starts with no players or scoreboard,
|
||||
// no waypoints (its own ClientWaypointManager: a proxy's next
|
||||
// server never untracks the last one's), and its new
|
||||
// ClientSuggestionProvider with no custom chat completions.
|
||||
// clearClientLevel clears the narrator.
|
||||
self.clear_narrator();
|
||||
self.hud_disconnected();
|
||||
self.score.board = Default::default();
|
||||
self.clear_waypoints();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.attachments.clear();
|
||||
r.players.clear();
|
||||
r.tab_players.clear();
|
||||
r.listed.clear();
|
||||
r.signatures = Default::default();
|
||||
r.custom_completions.clear();
|
||||
}
|
||||
self.ui.registries.custom_completions.clear();
|
||||
self.set_registries(registries);
|
||||
}
|
||||
// handleTransfer: "Cannot transfer to server from
|
||||
// singleplayer" (no ServerData), so the world just closes.
|
||||
Event::Transfer { host, port } => {
|
||||
if let Some(r) = self.remote.as_mut()
|
||||
&& r.session.address != "local"
|
||||
{
|
||||
r.transfer = Some(format!("{host}:{port}"));
|
||||
}
|
||||
}
|
||||
Event::PackPush(p) => self.handle_pack_push(p),
|
||||
Event::PackPop(id) => self.handle_pack_pop(id),
|
||||
Event::ServerLinks(links) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.server_links = super::super::server_links::trusted(links);
|
||||
}
|
||||
}
|
||||
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
|
||||
Event::ClearDialog => self.clear_dialog(),
|
||||
Event::CodeOfConduct(text) => self.handle_code_of_conduct(text),
|
||||
Event::DecodeError(e) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.decode_errors += 1;
|
||||
if r.decode_errors <= 20 {
|
||||
log::warn!("Horizon: skipped undecodable packet {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Disconnected {
|
||||
reason,
|
||||
key,
|
||||
from_server,
|
||||
} => {
|
||||
log::info!("Horizon: disconnected: {reason}");
|
||||
// ClientboundTransferPacket left a target: connect there
|
||||
// instead of showing the disconnect, the stored cookies
|
||||
// carried over (ClientCommonPacketListenerImpl.
|
||||
// handleTransfer → ConnectScreen.startConnecting).
|
||||
let transfer = self.remote.as_mut().and_then(|r| {
|
||||
r.transfer.take().map(|address| {
|
||||
(address, r.session.name.clone(), r.session.cookies.clone())
|
||||
})
|
||||
});
|
||||
if let Some((address, name, cookies)) = transfer {
|
||||
self.leave_server(None)?;
|
||||
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
|
||||
return Ok(());
|
||||
}
|
||||
let retry = self.reconnect_after(&reason, key.as_deref(), from_server);
|
||||
self.leave_server(Some(reason))?;
|
||||
self.offer_reconnect(retry);
|
||||
return Ok(());
|
||||
}
|
||||
Event::Packet(packet) => self.handle_packet(*packet)?,
|
||||
}
|
||||
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
|
||||
if self.remote.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.forget_remote_columns(&mut forgotten);
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.backlog_since = if events.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(r.backlog_since.unwrap_or_else(Instant::now))
|
||||
};
|
||||
r.backlog = events;
|
||||
}
|
||||
self.send_suggestion_request();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -679,6 +679,7 @@ impl Game<'_> {
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| ([p.position.x, p.position.y, p.position.z], p.velocity.y)),
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
laps.lap("tp_level");
|
||||
// Resource packs' Polytone entity emitters on the player (#11486): fog, dust and
|
||||
@@ -725,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
|
||||
|
||||
@@ -190,7 +190,7 @@ impl PackResources {
|
||||
let clouds = named_scope(scope, "pack.clouds", || load_clouds(root));
|
||||
let forcefield = named_scope(scope, "pack.forcefield", || load_forcefield(root));
|
||||
let custom_sky = named_scope(scope, "pack.custom_sky", || {
|
||||
horizon_client::custom_sky::CustomSky::load(root)
|
||||
horizon_client::custom_sky::load(root)
|
||||
});
|
||||
let environments =
|
||||
named_scope(scope, "pack.environments", || pack_environments(root));
|
||||
@@ -246,7 +246,7 @@ impl PackResources {
|
||||
names: Arc::new(names),
|
||||
clouds: Arc::new(clouds),
|
||||
forcefield: Arc::new(forcefield),
|
||||
custom_sky: Arc::new(custom_sky),
|
||||
custom_sky,
|
||||
environments,
|
||||
polytone_particles,
|
||||
polytone_post,
|
||||
|
||||
@@ -823,7 +823,7 @@ impl Game<'_> {
|
||||
for (location, rgba) in ready {
|
||||
self.store_skin(location, rgba);
|
||||
}
|
||||
self.cosmetics_frame();
|
||||
self.client_hooks_frame();
|
||||
}
|
||||
/// `Hud.extractTabList`: the player list while the player-list key is
|
||||
/// held. A local world shows it with a joined LAN guest or a `list`
|
||||
@@ -900,10 +900,12 @@ impl Game<'_> {
|
||||
let board = &self.score.board;
|
||||
let objective = board.display_objective(sb::SLOT_LIST);
|
||||
let up = |id: u128| upside_down.contains(&id);
|
||||
// Horizon's mark before the names of Horizon players (the cosmetics service's cache).
|
||||
let horizon = self.horizon_players(players.iter().map(|p| p.uuid));
|
||||
let horizon_mark = super::cosmetics::MARK;
|
||||
let marked = |id: u128| horizon.contains(&id).then_some(horizon_mark);
|
||||
// A hook's mark before a name (Horizon's: they play Horizon).
|
||||
debug_assert!(
|
||||
self.serving.is_none(),
|
||||
"a server thread calls no client hook"
|
||||
);
|
||||
let marked = horizon_hooks::engine::player_mark;
|
||||
let inputs = TabInputs {
|
||||
players: &players,
|
||||
board,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -24,6 +24,8 @@ pub(super) struct Social {
|
||||
names: HashMap<u128, String>,
|
||||
page: Page,
|
||||
pub(super) search: EditBox,
|
||||
/// `PlayerEntry.TOOLTIP_DELAY`: the row buttons' tooltips wait 500 ms.
|
||||
tooltip: social_screen::TooltipDelay,
|
||||
pub(super) scroll: f32,
|
||||
/// Opened from the pause menu: Done goes back there.
|
||||
from_pause: bool,
|
||||
@@ -38,6 +40,7 @@ impl Default for Social {
|
||||
names: HashMap::new(),
|
||||
page: Page::All,
|
||||
search,
|
||||
tooltip: Default::default(),
|
||||
scroll: 0.0,
|
||||
from_pause: false,
|
||||
}
|
||||
@@ -77,6 +80,7 @@ impl Game<'_> {
|
||||
self.hide_social_tutorial();
|
||||
self.social.page = Page::All;
|
||||
self.social.search.set_value("");
|
||||
self.social.tooltip = Default::default();
|
||||
self.social.scroll = 0.0;
|
||||
self.social.from_pause = from_pause;
|
||||
self.screen = Screen::Social;
|
||||
@@ -168,6 +172,7 @@ impl Game<'_> {
|
||||
self.cursor,
|
||||
&view,
|
||||
&mut self.social.search,
|
||||
&mut self.social.tooltip,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::{Game, commands::Target};
|
||||
use horizon_client::{
|
||||
advancements as adv,
|
||||
gossip::GossipType,
|
||||
items::ItemStack,
|
||||
lan::playerdata,
|
||||
mobs::{Mob, Species, villager, zombie_villager},
|
||||
};
|
||||
@@ -143,7 +144,10 @@ impl Game<'_> {
|
||||
let Some(v) = self.mobs.iter_mut().find(|m| m.id == vid) else {
|
||||
return;
|
||||
};
|
||||
convert_to_villager(&old, v);
|
||||
let drops = convert_to_villager(&old, v);
|
||||
for stack in drops {
|
||||
self.spawn_at_location(old.position, 0.0, stack);
|
||||
}
|
||||
// `level.getPlayerByUUID(conversionStarter)`: the player who fed the
|
||||
// apple, if still in this level (the host or a server's guest).
|
||||
let starter = zombie_villager::cure(&old).and_then(|c| c.starter);
|
||||
@@ -165,6 +169,10 @@ impl Game<'_> {
|
||||
g.trigger_cured_zombie_villager(&zombie_view, &villager_view)
|
||||
});
|
||||
}
|
||||
// The nausea comes after the advancement criterion (finishConversion).
|
||||
if let Some(v) = self.mobs.iter_mut().find(|m| m.id == vid) {
|
||||
v.add_effect("minecraft:nausea", 200, 0);
|
||||
}
|
||||
if !old.silent {
|
||||
self.level_event_at(
|
||||
1027,
|
||||
@@ -278,11 +286,38 @@ pub(super) fn convert_to_zombie_villager(from: &Mob, to: &mut Mob) {
|
||||
carry_over(from, to);
|
||||
to.extra.age = if from.is_baby() { i32::MIN / 2 } else { 0 };
|
||||
}
|
||||
/// A cured zombie villager becomes a villager again, queasy for 10 seconds.
|
||||
pub(super) fn convert_to_villager(from: &Mob, to: &mut Mob) {
|
||||
/// A cured zombie villager becomes a villager again (the caller adds the
|
||||
/// 10 second nausea after the cure criterion).
|
||||
/// `convertTo(.., keepEquipment = false)` leaves the villager bare, then
|
||||
/// `ZombieVillager.finishConversion` runs `Mob.dropPreservedEquipment` with
|
||||
/// "no `PREVENT_ARMOR_CHANGE`": kept (cursed) stacks go to the villager's
|
||||
/// slot `EquipmentSlot.getIndex() + 300` (its inventory), picked-up gear
|
||||
/// (drop chance > 1) is returned for the caller to drop, the rest vanishes.
|
||||
pub(super) fn convert_to_villager(from: &Mob, to: &mut Mob) -> Vec<ItemStack> {
|
||||
use horizon_client::mobs::{drops, finalize};
|
||||
carry_over(from, to);
|
||||
let mut drops_out = Vec::new();
|
||||
// `EquipmentSlot.VALUES` order: main hand, off hand, feet, legs, chest, head.
|
||||
for (slot, name) in drops::SLOTS.iter().take(6).enumerate() {
|
||||
let Some(stack) = finalize::item_stack(from, slot) else {
|
||||
continue;
|
||||
};
|
||||
drops::clear_slot(to, name);
|
||||
if stack.enchantment_level("minecraft:binding_curse") > 0 {
|
||||
// `getIndex() + 300`: hands 0..1, then feet, legs, chest, head 0..3.
|
||||
let index = if slot < 2 { slot } else { slot - 2 };
|
||||
let inventory = &mut villager::ext_mut(to).inventory;
|
||||
if index < inventory.len() {
|
||||
inventory[index] = stack;
|
||||
} else {
|
||||
inventory.push(stack);
|
||||
}
|
||||
} else if drops::drop_chance(from, name) > 1.0 {
|
||||
drops_out.push(stack);
|
||||
}
|
||||
}
|
||||
to.extra.age = if from.is_baby() { -24000 } else { 0 };
|
||||
to.add_effect("minecraft:nausea", 200, 0);
|
||||
drops_out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -384,11 +419,34 @@ mod tests {
|
||||
assert!(zombie.invulnerable_flag);
|
||||
assert!(zombie.extra.left_handed);
|
||||
let mut cured = mob(3, Species::Villager, "minecraft:villager");
|
||||
convert_to_villager(&zombie, &mut cured);
|
||||
let drops = convert_to_villager(&zombie, &mut cured);
|
||||
assert!(drops.is_empty());
|
||||
assert!(cured.silent);
|
||||
assert!(cured.no_ai);
|
||||
assert!(cured.no_gravity);
|
||||
assert!(cured.invulnerable_flag);
|
||||
assert!(cured.extra.left_handed);
|
||||
// The cure keeps no equipment: picked-up gear drops, cursed gear goes
|
||||
// to the inventory, the rest vanishes.
|
||||
use horizon_client::mobs::finalize::{
|
||||
FEET, HEAD, MAINHAND, item_stack, set_drop_chance, set_item, set_item_stack,
|
||||
};
|
||||
let mut gear = mob(4, Species::ZombieVillager, "minecraft:zombie_villager");
|
||||
set_item(&mut gear, MAINHAND, "minecraft:iron_sword");
|
||||
set_drop_chance(&mut gear, MAINHAND, 2.0);
|
||||
set_item(&mut gear, HEAD, "minecraft:iron_helmet");
|
||||
let mut boots = ItemStack::new("minecraft:iron_boots", 1);
|
||||
boots.set_crafting_enchantments(vec![("minecraft:binding_curse".into(), 1)]);
|
||||
set_item_stack(&mut gear, FEET, boots);
|
||||
let mut bare = mob(5, Species::Villager, "minecraft:villager");
|
||||
let drops = convert_to_villager(&gear, &mut bare);
|
||||
assert_eq!(drops.len(), 1);
|
||||
assert_eq!(drops[0].item.as_ref(), "minecraft:iron_sword");
|
||||
for slot in 0..=5 {
|
||||
assert!(item_stack(&bare, slot).is_none(), "slot {slot}");
|
||||
}
|
||||
let kept = &villager::ext(&bare).unwrap().inventory;
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept[0].item.as_ref(), "minecraft:iron_boots");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -771,26 +771,9 @@ fn swim(p: &mut Poses, pos: f32, h: &Hum) {
|
||||
lerp(amount, x, 0.3 * (pos * 0.333_333_34).mcos()),
|
||||
);
|
||||
}
|
||||
/// The player model's parts a [`BodyPose`] poses, in its order.
|
||||
pub const BODY_PARTS: [&str; 6] = [
|
||||
"head",
|
||||
"body",
|
||||
"right_arm",
|
||||
"left_arm",
|
||||
"right_leg",
|
||||
"left_leg",
|
||||
];
|
||||
|
||||
/// A pose a higher layer lays over the player model's body after its vanilla animation (Horizon's
|
||||
/// emotes, docs/cosmetics.md), handed to the model through [`Mob::emote`]. The engine names no
|
||||
/// type of that layer (docs/layers.md); it downcasts its own back through [`std::any::Any`].
|
||||
pub trait BodyPose: std::any::Any + std::fmt::Debug + Send + Sync {
|
||||
/// Poses the [`BODY_PARTS`] at `age` (the mob's ticks): `at_rest` holds each part before the
|
||||
/// animation, `poses` as the animation left it; a part the model lacks holds the identity in
|
||||
/// both and is not written back. False when it poses nothing at `age` (`poses` is then
|
||||
/// ignored).
|
||||
fn pose(&self, age: f32, at_rest: &[[f32; 9]; 6], poses: &mut [[f32; 9]; 6]) -> bool;
|
||||
}
|
||||
/// A pose laid over the player model's body after its vanilla animation, and the parts it poses:
|
||||
/// the hook's own types (`horizon_hooks::players`), kept here under their old path.
|
||||
pub use horizon_hooks::players::{BODY_PARTS, BodyPose};
|
||||
|
||||
/// Apply the mob's model animation to `p` (at rest), then the pose laid over it, if any.
|
||||
pub fn setup(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -830,6 +830,12 @@ impl Mob {
|
||||
let length = moved.x * moved.x + moved.y * moved.y + moved.z * moved.z;
|
||||
let wanted_length = wanted.x * wanted.x + wanted.y * wanted.y + wanted.z * wanted.z;
|
||||
if length > 1.0e-7 || wanted_length - length < 1.0e-7 {
|
||||
// A move of a block or more through water or a reset block (a
|
||||
// vine, a ladder) resets the fall before the move's drop counts.
|
||||
if self.fall_distance != 0.0 && length >= 1.0 && fall_reset_hit(w, self.position, moved)
|
||||
{
|
||||
self.fall_distance = 0.0;
|
||||
}
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
@@ -2189,3 +2195,18 @@ impl RestitutionWorld for MobRestitution<'_, '_> {
|
||||
#[cfg(test)]
|
||||
#[path = "living_tests.rs"]
|
||||
mod tests;
|
||||
/// `Entity.move`'s fall-reset ray for a mob, with the world's
|
||||
/// `minecraft:fall_damage_resetting` blocks.
|
||||
fn fall_reset_hit(w: &MobWorld, from: Vec3, moved: Vec3) -> bool {
|
||||
let reset = w.block_tags.get("minecraft:fall_damage_resetting");
|
||||
crate::entities::fall_reset_hit(
|
||||
w.world,
|
||||
w.fluid_tables,
|
||||
|p| {
|
||||
let name = &w.facts.get(w.world.block(p.x, p.y, p.z)).name;
|
||||
reset.is_some_and(|t| t.contains(&**name))
|
||||
},
|
||||
from,
|
||||
moved,
|
||||
)
|
||||
}
|
||||
@@ -90,6 +90,11 @@ pub fn is_food(m: &Mob, item: &str, taming: &[String]) -> bool {
|
||||
m.kind.food.iter().any(|t| t == item)
|
||||
}
|
||||
}
|
||||
/// `TamableAnimal.feed`'s heal: twice the food's nutrition, or 1 for an item
|
||||
/// without a food component (a fish bucket).
|
||||
pub fn feed_heal(nutrition: Option<i32>) -> f32 {
|
||||
nutrition.map_or(1.0, |n| 2.0 * n as f32)
|
||||
}
|
||||
/// The ambient, hurt and death sounds by age and whether it is under water.
|
||||
pub fn sounds(m: &Mob) -> [&'static str; 3] {
|
||||
let under = m.fluids.eye_in_water;
|
||||
@@ -1122,3 +1127,22 @@ mod cooldown_memory_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod feed_heal_tests {
|
||||
use super::feed_heal;
|
||||
|
||||
#[test]
|
||||
fn heals_twice_the_nutrition_of_vanilla_foods() {
|
||||
// Foods.java: cod 2, pufferfish 1, cooked cod 5, cooked salmon 6.
|
||||
assert_eq!(feed_heal(Some(2)), 4.0);
|
||||
assert_eq!(feed_heal(Some(1)), 2.0);
|
||||
assert_eq!(feed_heal(Some(5)), 10.0);
|
||||
assert_eq!(feed_heal(Some(6)), 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_without_food_component_heals_one() {
|
||||
assert_eq!(feed_heal(None), 1.0);
|
||||
}
|
||||
}
|
||||
@@ -48,39 +48,144 @@ impl SeenGrid {
|
||||
return;
|
||||
}
|
||||
if let Some(list) = self.cells.get_mut(&old)
|
||||
&& let Some(k) = list.iter().position(|&j| j == i as u32)
|
||||
&& let Ok(k) = list.binary_search(&(i as u32))
|
||||
{
|
||||
list.swap_remove(k);
|
||||
list.remove(k);
|
||||
}
|
||||
self.cells.entry(at).or_default().push(i as u32);
|
||||
let list = self.cells.entry(at).or_default();
|
||||
let idx = match list.binary_search(&(i as u32)) {
|
||||
Ok(k) | Err(k) => k,
|
||||
};
|
||||
list.insert(idx, i as u32);
|
||||
}
|
||||
/// The indices, ascending, of every entry whose box can meet `area`
|
||||
/// (a superset: callers still test it). False when the area spans more
|
||||
/// columns than the grid holds (or is not finite): scan them all.
|
||||
/// columns than the grid has entries (at least 64), or is not finite:
|
||||
/// scan them all.
|
||||
pub fn candidates(&self, area: Aabb, out: &mut Vec<u32>) -> bool {
|
||||
out.clear();
|
||||
// A box meets `area` only if its minimum corner is below area.max
|
||||
// and within `reach` (plus a margin for rounding) of area.min.
|
||||
let margin = self.reach + 1.0;
|
||||
let lo = column(area.min(Axis::X) - margin, area.min(Axis::Z) - margin);
|
||||
let hi = column(area.max(Axis::X), area.max(Axis::Z));
|
||||
let span = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
|
||||
let (nx, nz) = (span(lo.0, hi.0), span(lo.1, hi.1));
|
||||
if nx < 1 || nz < 1 || nx.saturating_mul(nz) > self.cells.len() as i64 {
|
||||
let min_x = area.min(Axis::X) - margin;
|
||||
let min_z = area.min(Axis::Z) - margin;
|
||||
let max_x = area.max(Axis::X);
|
||||
let max_z = area.max(Axis::Z);
|
||||
if !min_x.is_finite() || !min_z.is_finite() || !max_x.is_finite() || !max_z.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let lo = column(min_x, min_z);
|
||||
let hi = column(max_x, max_z);
|
||||
let span = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
|
||||
let (nx, nz) = (span(lo.0, hi.0), span(lo.1, hi.1));
|
||||
let max_cells = (self.cell_of.len() as i64).max(64);
|
||||
if nx < 1 || nz < 1 || nx.saturating_mul(nz) > max_cells {
|
||||
return false;
|
||||
}
|
||||
let mut slices = [&[][..]; 16];
|
||||
let mut num_slices = 0;
|
||||
let mut overflow = false;
|
||||
for x in lo.0..=hi.0 {
|
||||
for z in lo.1..=hi.1 {
|
||||
if let Some(list) = self.cells.get(&(x, z)) {
|
||||
out.extend_from_slice(list);
|
||||
if let Some(list) = self.cells.get(&(x, z))
|
||||
&& !list.is_empty()
|
||||
{
|
||||
if num_slices < slices.len() {
|
||||
slices[num_slices] = list.as_slice();
|
||||
num_slices += 1;
|
||||
} else {
|
||||
overflow = true;
|
||||
out.extend_from_slice(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
if overflow {
|
||||
for s in &slices[..num_slices] {
|
||||
out.extend_from_slice(s);
|
||||
}
|
||||
out.sort_unstable();
|
||||
return true;
|
||||
}
|
||||
merge_slices(&slices[..num_slices], out);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_2(s0: &[u32], s1: &[u32], out: &mut Vec<u32>) {
|
||||
out.reserve(s0.len() + s1.len());
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while i < s0.len() && j < s1.len() {
|
||||
if s0[i] < s1[j] {
|
||||
out.push(s0[i]);
|
||||
i += 1;
|
||||
} else {
|
||||
out.push(s1[j]);
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&s0[i..]);
|
||||
out.extend_from_slice(&s1[j..]);
|
||||
}
|
||||
|
||||
fn merge_slices(slices: &[&[u32]], out: &mut Vec<u32>) {
|
||||
match slices.len() {
|
||||
0 => {}
|
||||
1 => out.extend_from_slice(slices[0]),
|
||||
2 => merge_2(slices[0], slices[1], out),
|
||||
3 => {
|
||||
let (mut i, mut j, mut k) = (0, 0, 0);
|
||||
let (s0, s1, s2) = (slices[0], slices[1], slices[2]);
|
||||
out.reserve(s0.len() + s1.len() + s2.len());
|
||||
while i < s0.len() || j < s1.len() || k < s2.len() {
|
||||
let v0 = if i < s0.len() { s0[i] } else { u32::MAX };
|
||||
let v1 = if j < s1.len() { s1[j] } else { u32::MAX };
|
||||
let v2 = if k < s2.len() { s2[k] } else { u32::MAX };
|
||||
if v0 <= v1 && v0 <= v2 {
|
||||
out.push(v0);
|
||||
i += 1;
|
||||
} else if v1 <= v2 {
|
||||
out.push(v1);
|
||||
j += 1;
|
||||
} else {
|
||||
out.push(v2);
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
4 => {
|
||||
let (mut i, mut j, mut k, mut l) = (0, 0, 0, 0);
|
||||
let (s0, s1, s2, s3) = (slices[0], slices[1], slices[2], slices[3]);
|
||||
out.reserve(s0.len() + s1.len() + s2.len() + s3.len());
|
||||
while i < s0.len() || j < s1.len() || k < s2.len() || l < s3.len() {
|
||||
let v0 = if i < s0.len() { s0[i] } else { u32::MAX };
|
||||
let v1 = if j < s1.len() { s1[j] } else { u32::MAX };
|
||||
let v2 = if k < s2.len() { s2[k] } else { u32::MAX };
|
||||
let v3 = if l < s3.len() { s3[l] } else { u32::MAX };
|
||||
if v0 <= v1 && v0 <= v2 && v0 <= v3 {
|
||||
out.push(v0);
|
||||
i += 1;
|
||||
} else if v1 <= v2 && v1 <= v3 {
|
||||
out.push(v1);
|
||||
j += 1;
|
||||
} else if v2 <= v3 {
|
||||
out.push(v2);
|
||||
k += 1;
|
||||
} else {
|
||||
out.push(v3);
|
||||
l += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for s in slices {
|
||||
out.extend_from_slice(s);
|
||||
}
|
||||
out.sort_unstable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -145,4 +250,91 @@ mod tests {
|
||||
);
|
||||
assert!(!grid.candidates(huge, &mut out));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_merges_multiple_cells_in_strict_order() {
|
||||
// Place boxes across 4 adjacent grid cells: (0, 0), (4, 0), (0, 4), (4, 4)
|
||||
// With interleaving snapshot indices to test 1, 2, 3, and 4-way merges.
|
||||
let boxes = vec![
|
||||
boxed(0.5, 64.0, 0.5, 0.6), // 0: cell (0, 0)
|
||||
boxed(4.5, 64.0, 0.5, 0.6), // 1: cell (1, 0)
|
||||
boxed(0.5, 64.0, 4.5, 0.6), // 2: cell (0, 1)
|
||||
boxed(4.5, 64.0, 4.5, 0.6), // 3: cell (1, 1)
|
||||
boxed(0.8, 64.0, 0.8, 0.6), // 4: cell (0, 0)
|
||||
boxed(4.8, 64.0, 0.8, 0.6), // 5: cell (1, 0)
|
||||
boxed(0.8, 64.0, 4.8, 0.6), // 6: cell (0, 1)
|
||||
boxed(4.8, 64.0, 4.8, 0.6), // 7: cell (1, 1)
|
||||
];
|
||||
let grid = SeenGrid::new(boxes.into_iter());
|
||||
let mut out = Vec::new();
|
||||
|
||||
// Query covering only cell (0, 0): indices 0, 4
|
||||
let q1 = boxed(0.1, 64.0, 0.1, 1.5);
|
||||
assert!(grid.candidates(q1, &mut out));
|
||||
assert_eq!(out, vec![0, 4]);
|
||||
|
||||
// Query covering cells (0, 0) and (1, 0): indices 0, 1, 4, 5
|
||||
let q2 = Aabb::new(Vec3::new(0.0, 64.0, 0.0), Vec3::new(6.0, 66.0, 1.5));
|
||||
assert!(grid.candidates(q2, &mut out));
|
||||
assert_eq!(out, vec![0, 1, 4, 5]);
|
||||
|
||||
// Query covering all 4 cells: indices 0..8 in order
|
||||
let q4 = Aabb::new(Vec3::new(0.0, 64.0, 0.0), Vec3::new(6.0, 66.0, 6.0));
|
||||
assert!(grid.candidates(q4, &mut out));
|
||||
assert_eq!(out, vec![0, 1, 2, 3, 4, 5, 6, 7]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_dense_farm() {
|
||||
let n = 300;
|
||||
let mut seed = 0x1234_5678_9abc_def0_u64;
|
||||
let mut next = |range: f64| {
|
||||
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64 * range
|
||||
};
|
||||
// 300 mobs in a 1.5 x 1.5 block pen around (3.5, 64.0, 3.5) crossing cell boundary at 4.0
|
||||
let mut boxes: Vec<Aabb> = (0..n)
|
||||
.map(|_| {
|
||||
let x = 3.5 + next(1.5);
|
||||
let z = 3.5 + next(1.5);
|
||||
boxed(x, 64.0, z, 0.6)
|
||||
})
|
||||
.collect();
|
||||
let mut grid = SeenGrid::new(boxes.iter().copied());
|
||||
let mut out = Vec::new();
|
||||
|
||||
let t = std::time::Instant::now();
|
||||
let rounds = 100;
|
||||
let mut fallback_count = 0;
|
||||
let mut query_dur = std::time::Duration::ZERO;
|
||||
let mut update_dur = std::time::Duration::ZERO;
|
||||
for _ in 0..rounds {
|
||||
for i in 0..n {
|
||||
let area = boxes[i];
|
||||
let t0 = std::time::Instant::now();
|
||||
if !grid.candidates(area, &mut out) {
|
||||
fallback_count += 1;
|
||||
out.clear();
|
||||
out.extend(0..n as u32);
|
||||
}
|
||||
query_dur += t0.elapsed();
|
||||
// small jitter
|
||||
let x = 3.5 + next(1.5);
|
||||
let z = 3.5 + next(1.5);
|
||||
boxes[i] = boxed(x, 64.0, z, 0.6);
|
||||
let t1 = std::time::Instant::now();
|
||||
grid.update(i, boxes[i]);
|
||||
update_dur += t1.elapsed();
|
||||
}
|
||||
}
|
||||
let elapsed = t.elapsed();
|
||||
println!(
|
||||
"{rounds} ticks of {n} mobs in 1.5x1.5 pen: {elapsed:?} (query: {:.3} ms, update: {:.3} ms, per tick: {:.3} ms, fallback: {fallback_count}/{})",
|
||||
query_dur.as_secs_f64() * 1e3 / rounds as f64,
|
||||
update_dur.as_secs_f64() * 1e3 / rounds as f64,
|
||||
elapsed.as_secs_f64() * 1e3 / rounds as f64,
|
||||
rounds * n
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,63 @@ pub struct TargetInfo {
|
||||
/// `isPassenger`.
|
||||
pub passenger: bool,
|
||||
}
|
||||
thread_local! {
|
||||
static NEAR_BUF_POOL: std::cell::RefCell<Vec<Vec<u32>>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
pub struct NearIter<'a> {
|
||||
others: &'a [Seen],
|
||||
indices: Option<Vec<u32>>,
|
||||
pos: usize,
|
||||
fallback: Option<std::slice::Iter<'a, Seen>>,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for NearIter<'a> {
|
||||
type Item = &'a Seen;
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if let Some(indices) = &self.indices
|
||||
&& self.pos < indices.len()
|
||||
{
|
||||
let idx = indices[self.pos];
|
||||
self.pos += 1;
|
||||
return Some(&self.others[idx as usize]);
|
||||
}
|
||||
if let Some(all) = &mut self.fallback {
|
||||
return all.next();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
if let Some(indices) = &self.indices {
|
||||
let rem = indices.len().saturating_sub(self.pos);
|
||||
(rem, Some(rem))
|
||||
} else if let Some(all) = &self.fallback {
|
||||
all.size_hint()
|
||||
} else {
|
||||
(0, Some(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NearIter<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut buf) = self.indices.take() {
|
||||
buf.clear();
|
||||
NEAR_BUF_POOL.with(|pool| {
|
||||
if let Ok(mut pool) = pool.try_borrow_mut()
|
||||
&& pool.len() < 8
|
||||
{
|
||||
pool.push(buf);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Chunk and section cache for raycast block traversals.
|
||||
/// Caches the current section to avoid section index checks and hashmap lookups
|
||||
/// for consecutive voxels in the same 16x16x16 section.
|
||||
@@ -512,15 +569,39 @@ impl MobWorld<'_> {
|
||||
}
|
||||
/// The other mobs whose boxes may meet `area` (callers test the boxes),
|
||||
/// in snapshot order: through the grid when there is one, else all.
|
||||
pub fn others_near(&self, area: Aabb) -> impl Iterator<Item = &Seen> {
|
||||
let mut near = Vec::new();
|
||||
pub fn others_near(&self, area: Aabb) -> NearIter<'_> {
|
||||
let mut buf = NEAR_BUF_POOL.with(|pool| {
|
||||
pool.try_borrow_mut()
|
||||
.ok()
|
||||
.and_then(|mut p| p.pop())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let indexed = self
|
||||
.others_grid
|
||||
.is_some_and(|grid| grid.candidates(area, &mut near));
|
||||
let all = (!indexed).then(|| self.others.iter());
|
||||
near.into_iter()
|
||||
.map(|i| &self.others[i as usize])
|
||||
.chain(all.into_iter().flatten())
|
||||
.is_some_and(|grid| grid.candidates(area, &mut buf));
|
||||
if indexed {
|
||||
NearIter {
|
||||
others: self.others,
|
||||
indices: Some(buf),
|
||||
pos: 0,
|
||||
fallback: None,
|
||||
}
|
||||
} else {
|
||||
buf.clear();
|
||||
NEAR_BUF_POOL.with(|pool| {
|
||||
if let Ok(mut pool) = pool.try_borrow_mut()
|
||||
&& pool.len() < 8
|
||||
{
|
||||
pool.push(buf);
|
||||
}
|
||||
});
|
||||
NearIter {
|
||||
others: self.others,
|
||||
indices: None,
|
||||
pos: 0,
|
||||
fallback: Some(self.others.iter()),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn seen(&self, id: u32) -> Option<&Seen> {
|
||||
match self.others_index {
|
||||
|
||||
@@ -330,7 +330,7 @@ fn ping_all(servers: &[ServerEntry], pings: &Pings, icons: &Icons, file: &Path)
|
||||
/// Save the list after a move or delete, keeping the icons pings stored in
|
||||
/// servers.dat since it was loaded, and the Code of Conduct accepted on a server.
|
||||
fn save_servers(file: &Path, servers: &mut [ServerEntry]) {
|
||||
let _guard = SAVING.lock();
|
||||
let _guard = servers::lock();
|
||||
let saved = servers::load(file);
|
||||
for entry in servers.iter_mut() {
|
||||
if let Some(s) = saved.iter().find(|s| s.ip == entry.ip) {
|
||||
@@ -340,11 +340,9 @@ fn save_servers(file: &Path, servers: &mut [ServerEntry]) {
|
||||
}
|
||||
let _ = servers::save(file, servers);
|
||||
}
|
||||
/// Held while servers.dat is read and written back.
|
||||
static SAVING: Mutex<()> = Mutex::new(());
|
||||
/// Store `icon` on the entries for `address` (OnlineServerEntry.updateServerList).
|
||||
fn save_icon(file: &Path, address: &str, icon: Option<String>) {
|
||||
let _guard = SAVING.lock();
|
||||
let _guard = servers::lock();
|
||||
let mut list = servers::load(file);
|
||||
let mut changed = false;
|
||||
for entry in list.iter_mut().filter(|e| e.ip == address) {
|
||||
@@ -364,113 +362,6 @@ pub fn save_server_icon(saves: &Path, address: &str, png: &[u8]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's official survival server: a name and an address like any entry. The address is
|
||||
/// `horizon_telemetry::server_status::OFFICIAL_ADDRESS`, the one place it is kept.
|
||||
pub const OFFICIAL_SERVER: (&str, &str) = (
|
||||
"Horizon Official",
|
||||
horizon_telemetry::server_status::OFFICIAL_ADDRESS,
|
||||
);
|
||||
/// Beside servers.dat once the official server was offered, so a player who
|
||||
/// deletes it never gets it back: the address offered, so a new one moves the
|
||||
/// list once. The launcher writes the same file.
|
||||
const OFFICIAL_MARKER: &str = "horizon_official_server.txt";
|
||||
|
||||
/// An address as the game connects to it: any case, 25565 when no port is given.
|
||||
fn address_key(ip: &str) -> Option<(String, u16)> {
|
||||
servers::parse_address(ip).map(|(h, p)| (h.to_ascii_lowercase(), p))
|
||||
}
|
||||
|
||||
/// Puts the official server at the top of the list in `game_dir`, once: not
|
||||
/// again after the player deletes it, and not when the list has its address.
|
||||
/// When it moved (the marker names another address), the entries for its
|
||||
/// earlier addresses move to the new one, once (`move_official`).
|
||||
pub fn add_official_server(game_dir: &Path) -> std::io::Result<()> {
|
||||
let (name, address) = OFFICIAL_SERVER;
|
||||
let marker = game_dir.join(OFFICIAL_MARKER);
|
||||
// A marker that is there but can't be read: offered already, at an address not known.
|
||||
let offered = match std::fs::read_to_string(&marker) {
|
||||
Ok(text) => Some(text),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(_) => Some(String::new()),
|
||||
};
|
||||
if offered
|
||||
.as_deref()
|
||||
.is_some_and(|a| address_key(a) == address_key(address))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let file = game_dir.join("servers.dat");
|
||||
let _guard = SAVING.lock();
|
||||
let mut list = servers::load(&file);
|
||||
let previous = horizon_telemetry::server_status::OFFICIAL_PREVIOUS;
|
||||
if move_official(&mut list, address, previous) {
|
||||
servers::save(&file, &list)?;
|
||||
}
|
||||
if offered.is_none()
|
||||
&& !list
|
||||
.iter()
|
||||
.any(|s| address_key(&s.ip) == address_key(address))
|
||||
{
|
||||
servers::prepend(&file, &ServerEntry::new(name, address))?;
|
||||
}
|
||||
std::fs::write(marker, format!("{address}\n"))
|
||||
}
|
||||
|
||||
/// Afri-Network, which the game added once below the official server (#9299) until the
|
||||
/// partnership ended: the name and address the game gave its entry.
|
||||
const WITHDRAWN_PARTNER: (&str, &str) = ("Afri-Network (South Africa)", "afri-network.co.za");
|
||||
/// Beside servers.dat: the partner addresses the game offered, one per line (#9299).
|
||||
const PARTNER_MARKER: &str = "horizon_partner_servers.txt";
|
||||
|
||||
/// Takes the withdrawn partner server back out of the list in `game_dir`, once, where the game
|
||||
/// added it: the marker says it was offered and the entry still has the name the game gave it
|
||||
/// (an entry the player made or renamed keeps its place). Then the marker goes.
|
||||
pub fn withdraw_partner_servers(game_dir: &Path) -> std::io::Result<()> {
|
||||
let marker = game_dir.join(PARTNER_MARKER);
|
||||
let offered = match std::fs::read_to_string(&marker) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let (name, address) = WITHDRAWN_PARTNER;
|
||||
if offered
|
||||
.lines()
|
||||
.any(|l| address_key(l) == address_key(address))
|
||||
{
|
||||
let _guard = SAVING.lock();
|
||||
servers::remove_where(&game_dir.join("servers.dat"), |n, ip| {
|
||||
n == name && address_key(ip) == address_key(address)
|
||||
})?;
|
||||
}
|
||||
std::fs::remove_file(marker)
|
||||
}
|
||||
|
||||
/// Points the entries for an earlier official address at `address`, each
|
||||
/// keeping the player's name and fields. One entry for the server at most: an
|
||||
/// old one goes when the list already has `address`. Whether anything changed.
|
||||
fn move_official(list: &mut Vec<ServerEntry>, address: &str, previous: &[&str]) -> bool {
|
||||
let new = address_key(address);
|
||||
let old = |ip: &str| {
|
||||
let ip = address_key(ip);
|
||||
ip.is_some() && ip != new && previous.iter().any(|p| address_key(p) == ip)
|
||||
};
|
||||
let mut present = list.iter().any(|s| address_key(&s.ip) == new);
|
||||
let mut changed = false;
|
||||
list.retain_mut(|s| {
|
||||
if !old(&s.ip) {
|
||||
return true;
|
||||
}
|
||||
changed = true;
|
||||
if present {
|
||||
return false;
|
||||
}
|
||||
present = true;
|
||||
s.ip = address.to_owned();
|
||||
true
|
||||
});
|
||||
changed
|
||||
}
|
||||
|
||||
/// The address and kind of list row `index`: saved servers, then discovered LAN games.
|
||||
fn entry_address(servers: &[ServerEntry], lan: &Scanner, index: usize) -> Option<(String, bool)> {
|
||||
match servers.get(index) {
|
||||
@@ -2100,7 +1991,7 @@ pub fn remember_direct_join(saves: &Path, address: &str) {
|
||||
return;
|
||||
}
|
||||
let file = servers_file(saves);
|
||||
let _guard = SAVING.lock();
|
||||
let _guard = servers::lock();
|
||||
let (shown, hidden) = servers::load_all(&file);
|
||||
if shown.iter().chain(&hidden).any(|s| s.ip == address) {
|
||||
return;
|
||||
@@ -2297,22 +2188,10 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(feature = "friends")]
|
||||
#[test]
|
||||
fn direct_connection_joins_a_pasted_invite_and_keeps_host_names() {
|
||||
let invite = horizon_friends::invite::Invite {
|
||||
secret: *b"0123456789",
|
||||
port: 25580,
|
||||
endpoints: vec!["192.168.1.20:25580".parse().unwrap()],
|
||||
relay: None,
|
||||
}
|
||||
.encode();
|
||||
assert_eq!(
|
||||
direct_address(&format!(" {invite} ")),
|
||||
format!("{}{invite}", crate::net::FRIEND)
|
||||
);
|
||||
fn direct_connection_keeps_host_names() {
|
||||
for host in ["localhost", "example.org:25566", "abcdefgh", "[::1]:25568"] {
|
||||
assert_eq!(direct_address(host), host);
|
||||
assert_eq!(direct_address(&format!(" {host} ")), host);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2546,138 +2425,6 @@ mod tests {
|
||||
assert!(favicon_bytes(&serde_json::json!({ "favicon": encoded })).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_official_server_is_added_once_at_the_top() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mp-official-{}", std::process::id()));
|
||||
let (name, address) = OFFICIAL_SERVER;
|
||||
let file = dir.join("servers.dat");
|
||||
let ips = || -> Vec<String> { servers::load(&file).into_iter().map(|s| s.ip).collect() };
|
||||
let fresh = |list: &[ServerEntry]| {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
if !list.is_empty() {
|
||||
servers::save(&file, list).unwrap();
|
||||
}
|
||||
};
|
||||
// A fresh list: just the official server, named like it.
|
||||
fresh(&[]);
|
||||
add_official_server(&dir).unwrap();
|
||||
assert_eq!(servers::load(&file), [ServerEntry::new(name, address)]);
|
||||
// An existing list: on top, the player's servers kept in their order.
|
||||
let mine = [
|
||||
ServerEntry::new("Mine", "127.0.0.1:2"),
|
||||
ServerEntry::new("Other", "example.org"),
|
||||
];
|
||||
fresh(&mine);
|
||||
add_official_server(&dir).unwrap();
|
||||
assert_eq!(ips(), [address, "127.0.0.1:2", "example.org"]);
|
||||
// Once only: a second start adds nothing.
|
||||
add_official_server(&dir).unwrap();
|
||||
assert_eq!(ips(), [address, "127.0.0.1:2", "example.org"]);
|
||||
// Already there under the player's own name and spelling: not duplicated.
|
||||
fresh(&[
|
||||
ServerEntry::new("SMP", "Play.PlayHorizon.dev:25565"),
|
||||
mine[0].clone(),
|
||||
]);
|
||||
add_official_server(&dir).unwrap();
|
||||
assert_eq!(ips(), ["Play.PlayHorizon.dev:25565", "127.0.0.1:2"]);
|
||||
// Deleted by the player: never back.
|
||||
fresh(&mine);
|
||||
add_official_server(&dir).unwrap();
|
||||
servers::save(&file, &mine).unwrap();
|
||||
add_official_server(&dir).unwrap();
|
||||
assert_eq!(ips(), ["127.0.0.1:2", "example.org"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_withdrawn_partner_server_leaves_lists_the_game_put_it_in_once() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mp-partner-{}", std::process::id()));
|
||||
let official = OFFICIAL_SERVER.1;
|
||||
let file = dir.join("servers.dat");
|
||||
let marker = dir.join(PARTNER_MARKER);
|
||||
let ips = || -> Vec<String> { servers::load(&file).into_iter().map(|s| s.ip).collect() };
|
||||
let fresh = |list: &[ServerEntry], offered: bool| {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
servers::save(&file, list).unwrap();
|
||||
if offered {
|
||||
std::fs::write(&marker, "afri-network.co.za\n").unwrap();
|
||||
}
|
||||
};
|
||||
let added = ServerEntry::new("Afri-Network (South Africa)", "afri-network.co.za");
|
||||
// The game added it: it goes, the rest stays in order, and only once.
|
||||
fresh(
|
||||
&[
|
||||
ServerEntry::new("Horizon Official", official),
|
||||
added.clone(),
|
||||
ServerEntry::new("Mine", "127.0.0.1:2"),
|
||||
],
|
||||
true,
|
||||
);
|
||||
withdraw_partner_servers(&dir).unwrap();
|
||||
assert_eq!(ips(), [official, "127.0.0.1:2"]);
|
||||
assert!(!marker.exists());
|
||||
servers::save(&file, std::slice::from_ref(&added)).unwrap();
|
||||
withdraw_partner_servers(&dir).unwrap();
|
||||
assert_eq!(ips(), ["afri-network.co.za"]);
|
||||
// The player's own entry for that address (their name) stays.
|
||||
fresh(&[ServerEntry::new("ZA", "Afri-Network.co.za:25565")], true);
|
||||
withdraw_partner_servers(&dir).unwrap();
|
||||
assert_eq!(ips(), ["Afri-Network.co.za:25565"]);
|
||||
// Never offered (no marker): nothing changes.
|
||||
fresh(&[added], false);
|
||||
withdraw_partner_servers(&dir).unwrap();
|
||||
assert_eq!(ips(), ["afri-network.co.za"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_official_server_moves_the_old_entry_once() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mp-moved-{}", std::process::id()));
|
||||
let address = OFFICIAL_SERVER.1;
|
||||
let file = dir.join("servers.dat");
|
||||
let marker = dir.join(OFFICIAL_MARKER);
|
||||
let ips = || -> Vec<String> { servers::load(&file).into_iter().map(|s| s.ip).collect() };
|
||||
let offered_before = |list: &[ServerEntry]| {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
servers::save(&file, list).unwrap();
|
||||
std::fs::write(&marker, "horizon.bunchy7s.dev\n").unwrap();
|
||||
};
|
||||
// The old address under the player's own name and spelling: moved, name and place kept.
|
||||
let mut old = ServerEntry::new("Our SMP", "Horizon.Bunchy7s.dev:25565");
|
||||
old.icon = Some("AAAA".into());
|
||||
offered_before(&[ServerEntry::new("Mine", "127.0.0.1:2"), old]);
|
||||
add_official_server(&dir).unwrap();
|
||||
let list = servers::load(&file);
|
||||
assert_eq!(ips(), ["127.0.0.1:2", address]);
|
||||
assert_eq!(
|
||||
(list[1].name.as_str(), list[1].icon.as_deref()),
|
||||
("Our SMP", Some("AAAA"))
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&marker).unwrap(),
|
||||
format!("{address}\n")
|
||||
);
|
||||
// Once: the old address added back afterwards is the player's.
|
||||
servers::prepend(&file, &ServerEntry::new("Old", "horizon.bunchy7s.dev")).unwrap();
|
||||
add_official_server(&dir).unwrap();
|
||||
assert_eq!(ips(), ["horizon.bunchy7s.dev", "127.0.0.1:2", address]);
|
||||
// The new address already listed: no second entry.
|
||||
offered_before(&[
|
||||
ServerEntry::new("Old", "horizon.bunchy7s.dev"),
|
||||
ServerEntry::new("New", address),
|
||||
]);
|
||||
add_official_server(&dir).unwrap();
|
||||
assert_eq!(ips(), [address]);
|
||||
// Deleted by the player before the move: not offered again; another port is left alone.
|
||||
offered_before(&[ServerEntry::new("Test", "horizon.bunchy7s.dev:25570")]);
|
||||
add_official_server(&dir).unwrap();
|
||||
assert_eq!(ips(), ["horizon.bunchy7s.dev:25570"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_select_servers_and_shift_moves_them() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mp-keys-{}", std::process::id()));
|
||||
|
||||
@@ -30,6 +30,8 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use horizon_hooks::tunnel;
|
||||
|
||||
/// `CommonListenerCookie.serverCookies`: cookies the server asked the client
|
||||
/// to store (`ClientboundStoreCookiePacket`), answered to
|
||||
/// `ClientboundCookieRequestPacket`. A `transfer` carries them to the next
|
||||
@@ -147,8 +149,20 @@ pub struct Session {
|
||||
far: Arc<crate::lod_net::Inbox>,
|
||||
/// The recording running, if any.
|
||||
recorder: RecorderSlot,
|
||||
/// This connection's number, new for each one ([`Session::serial`]).
|
||||
serial: u64,
|
||||
}
|
||||
/// The next [`Session::serial`].
|
||||
static NEXT_SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
fn next_serial() -> u64 {
|
||||
NEXT_SERIAL.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
impl Session {
|
||||
/// A number of its own for each connection, never 0: entity ids are per connection, so what
|
||||
/// is kept by id goes when it changes (`horizon_hooks::players::PlayerModels::connection`).
|
||||
pub fn serial(&self) -> u64 {
|
||||
self.serial
|
||||
}
|
||||
/// The negotiated wire release, once the connection has been established.
|
||||
pub fn server_version(&self) -> horizon_protocol::adapter::ServerVersion {
|
||||
self.writer.lock().unwrap().as_ref().map_or(
|
||||
@@ -254,6 +268,7 @@ impl Session {
|
||||
debug: false,
|
||||
far: Arc::new(crate::lod_net::Inbox::default()),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
}
|
||||
}
|
||||
fn start(
|
||||
@@ -331,6 +346,7 @@ impl Session {
|
||||
debug,
|
||||
far,
|
||||
recorder,
|
||||
serial: next_serial(),
|
||||
}
|
||||
}
|
||||
/// Everything received since the last poll.
|
||||
@@ -496,62 +512,36 @@ impl Drop for Session {
|
||||
}
|
||||
|
||||
/// An address of the form `friend:K7QM-3XPD` joins a friend's world through
|
||||
/// its invite code (horizon-friends): the session goes through a tunnel the
|
||||
/// client reaches on a loopback port, so everything after is a plain join.
|
||||
#[cfg(feature = "friends")]
|
||||
pub const FRIEND: &str = horizon_friends::ADDRESS_PREFIX;
|
||||
/// Without the `friends` feature such an address is refused (`join_friend`).
|
||||
#[cfg(not(feature = "friends"))]
|
||||
pub const FRIEND: &str = "friend:";
|
||||
/// its invite code (Play with Friends, through a hook's [`tunnel::Resolver`]):
|
||||
/// the session goes through a tunnel the client reaches on a loopback port, so
|
||||
/// everything after is a plain join.
|
||||
pub const FRIEND: &str = tunnel::PREFIX;
|
||||
|
||||
/// Whether `code` is an invite code; with `full`, only a full one (a short
|
||||
/// relay code could also be a host name).
|
||||
#[cfg(feature = "friends")]
|
||||
/// relay code could also be a host name). Without a resolver nothing is one.
|
||||
pub fn is_invite(code: &str, full: bool) -> bool {
|
||||
use horizon_friends::invite::{Parsed, parse};
|
||||
match parse(code) {
|
||||
Some(Parsed::Long(_)) => true,
|
||||
Some(Parsed::Short(_)) => !full,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
/// Without the `friends` feature nothing is one.
|
||||
#[cfg(not(feature = "friends"))]
|
||||
pub fn is_invite(_code: &str, _full: bool) -> bool {
|
||||
false
|
||||
horizon_hooks::engine::tunnel().is_some_and(|r| r.is_code(code, full))
|
||||
}
|
||||
|
||||
/// An address as players and logs see it: a friend's invite code stays
|
||||
/// out of it (its secret is in there).
|
||||
pub fn shown_address(address: &str) -> String {
|
||||
match address.strip_prefix(FRIEND) {
|
||||
#[cfg(feature = "friends")]
|
||||
Some(code) => match horizon_friends::invite::parse(code) {
|
||||
Some(horizon_friends::invite::Parsed::Short(c)) => {
|
||||
format!("Friend's world ({}-****)", c.nameplate)
|
||||
}
|
||||
_ => "Friend's world".to_owned(),
|
||||
Some(code) => match horizon_hooks::engine::tunnel().and_then(|r| r.nameplate(code)) {
|
||||
Some(nameplate) => format!("Friend's world ({nameplate}-****)"),
|
||||
None => "Friend's world".to_owned(),
|
||||
},
|
||||
#[cfg(not(feature = "friends"))]
|
||||
Some(_) => "Friend's world".to_owned(),
|
||||
None => address.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The relay the player set (`friendsRelay` in horizon.txt, beside
|
||||
/// options.txt), else `HORIZON_RELAY_URL`. None by default.
|
||||
/// options.txt), else the resolver's (`HORIZON_RELAY_URL`). None by default.
|
||||
pub fn friends_relay_setting() -> Option<String> {
|
||||
let dir = std::env::current_dir().ok()?;
|
||||
crate::ui_theme::preference(&dir, "friendsRelay")
|
||||
.filter(|r| !r.is_empty())
|
||||
.or_else(relay_from_env)
|
||||
}
|
||||
#[cfg(feature = "friends")]
|
||||
use horizon_friends::relay_url as relay_from_env;
|
||||
/// `HORIZON_RELAY_URL` is the friends feature's: none without it.
|
||||
#[cfg(not(feature = "friends"))]
|
||||
fn relay_from_env() -> Option<String> {
|
||||
None
|
||||
.or_else(|| horizon_hooks::engine::tunnel().and_then(|r| r.relay_from_env()))
|
||||
}
|
||||
/// Remember the relay typed on Join a friend (empty forgets it).
|
||||
pub fn save_friends_relay(relay: &str) {
|
||||
@@ -562,55 +552,35 @@ pub fn save_friends_relay(relay: &str) {
|
||||
|
||||
/// Reach the friend's world from the code (directly, or through a relay)
|
||||
/// and open the tunnel: the loopback address to connect to, and the token to send on it first.
|
||||
#[cfg(feature = "friends")]
|
||||
/// Without a resolver (a build without Play with Friends) the address is refused, never retried.
|
||||
fn join_friend(
|
||||
code: &str,
|
||||
lang: &Language,
|
||||
send: &Sender<Event>,
|
||||
) -> Result<(String, u16, Vec<u8>), Reason> {
|
||||
use horizon_friends::{
|
||||
PathMode,
|
||||
guest::{self, GuestConfig},
|
||||
let Some(resolver) = horizon_hooks::engine::tunnel() else {
|
||||
return Err(Reason::keyed_arg(
|
||||
"horizon.multiplayer.invalid_address",
|
||||
lang,
|
||||
&shown_address(&format!("{FRIEND}{code}")),
|
||||
));
|
||||
};
|
||||
status(send, &tr(lang, "horizon.multiplayer.finding_friend"));
|
||||
let tunnel = guest::join(&GuestConfig {
|
||||
code: code.to_owned(),
|
||||
relay: friends_relay_setting(),
|
||||
protocol: PROTOCOL_VERSION,
|
||||
game: horizon_protocol::GAME_VERSION.to_owned(),
|
||||
path: PathMode::from_env(),
|
||||
})
|
||||
.map_err(|e| Reason::plain(e.to_string()))?;
|
||||
status(
|
||||
send,
|
||||
&if tunnel.direct {
|
||||
tr(lang, "horizon.multiplayer.connected_friend_direct")
|
||||
} else {
|
||||
tr(lang, "horizon.multiplayer.connected_friend_relay")
|
||||
},
|
||||
);
|
||||
let loopback = tunnel
|
||||
.serve_loopback()
|
||||
.map_err(|e| Reason::plain(e.to_string()))?;
|
||||
Ok((
|
||||
"127.0.0.1".to_owned(),
|
||||
loopback.port,
|
||||
loopback.token.to_vec(),
|
||||
))
|
||||
}
|
||||
|
||||
/// A build without Play with Friends: the address is refused, never retried.
|
||||
#[cfg(not(feature = "friends"))]
|
||||
fn join_friend(
|
||||
code: &str,
|
||||
lang: &Language,
|
||||
_: &Sender<Event>,
|
||||
) -> Result<(String, u16, Vec<u8>), Reason> {
|
||||
Err(Reason::keyed_arg(
|
||||
"horizon.multiplayer.invalid_address",
|
||||
lang,
|
||||
&shown_address(&format!("{FRIEND}{code}")),
|
||||
))
|
||||
let mut progress = |p: tunnel::Progress| {
|
||||
let key = match p {
|
||||
tunnel::Progress::Finding => "horizon.multiplayer.finding_friend",
|
||||
tunnel::Progress::Connected { direct: true } => {
|
||||
"horizon.multiplayer.connected_friend_direct"
|
||||
}
|
||||
tunnel::Progress::Connected { direct: false } => {
|
||||
"horizon.multiplayer.connected_friend_relay"
|
||||
}
|
||||
};
|
||||
status(send, &tr(lang, key));
|
||||
};
|
||||
let opened = resolver
|
||||
.open(code, friends_relay_setting(), &mut progress)
|
||||
.map_err(Reason::plain)?;
|
||||
Ok(("127.0.0.1".to_owned(), opened.port, opened.token))
|
||||
}
|
||||
|
||||
fn status(send: &Sender<Event>, text: &str) {
|
||||
@@ -1873,6 +1843,7 @@ mod tests {
|
||||
debug: false,
|
||||
far: Arc::default(),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
};
|
||||
let started = std::time::Instant::now();
|
||||
if explicit_disconnect {
|
||||
@@ -1976,6 +1947,7 @@ mod tests {
|
||||
debug: false,
|
||||
far: Arc::default(),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
};
|
||||
let (thread_writer, thread_configuring, thread_recorder) = (
|
||||
session.writer.clone(),
|
||||
@@ -2259,6 +2231,7 @@ mod tests {
|
||||
debug: false,
|
||||
far: Arc::default(),
|
||||
recorder: Arc::new(Mutex::new(None)),
|
||||
serial: next_serial(),
|
||||
};
|
||||
let recorder = TestRecorder::default();
|
||||
session.start_recording(recorder.clone(), |_| {});
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Horizon's own entries in the multiplayer list (docs/layers.md: Horizon layer): the official
|
||||
//! server, added once at the top, and the withdrawn partner server, taken back out once. Behind
|
||||
//! `horizon-extras` (docs/forking.md), so a plain build adds no server and holds no Horizon
|
||||
//! address. It reads and writes servers.dat only through `horizon_protocol::servers`, ready to
|
||||
//! leave the engine crates for a Horizon crate.
|
||||
use horizon_protocol::servers::{self, ServerEntry};
|
||||
use std::path::Path;
|
||||
|
||||
/// At startup of a player's game: the official server into the list in `game_dir` and the
|
||||
/// withdrawn partner server out of it, a failure only logged.
|
||||
pub fn update(game_dir: &Path) {
|
||||
if let Err(e) = add_official_server(game_dir) {
|
||||
log::warn!("could not add the official server to servers.dat: {e}");
|
||||
}
|
||||
if let Err(e) = withdraw_partner_servers(game_dir) {
|
||||
log::warn!("could not take the withdrawn partner server out of servers.dat: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's official survival server: a name and an address like any entry. The address is
|
||||
/// `horizon_telemetry::server_status::OFFICIAL_ADDRESS`, the one place it is kept.
|
||||
pub const OFFICIAL_SERVER: (&str, &str) = (
|
||||
"Horizon Official",
|
||||
horizon_telemetry::server_status::OFFICIAL_ADDRESS,
|
||||
);
|
||||
/// Beside servers.dat once the official server was offered, so a player who
|
||||
/// deletes it never gets it back: the address offered, so a new one moves the
|
||||
/// list once. The launcher writes the same file.
|
||||
const OFFICIAL_MARKER: &str = "horizon_official_server.txt";
|
||||
|
||||
/// An address as the game connects to it: any case, 25565 when no port is given.
|
||||
fn address_key(ip: &str) -> Option<(String, u16)> {
|
||||
servers::parse_address(ip).map(|(h, p)| (h.to_ascii_lowercase(), p))
|
||||
}
|
||||
|
||||
/// Puts the official server at the top of the list in `game_dir`, once: not
|
||||
/// again after the player deletes it, and not when the list has its address.
|
||||
/// When it moved (the marker names another address), the entries for its
|
||||
/// earlier addresses move to the new one, once (`move_official`).
|
||||
pub fn add_official_server(game_dir: &Path) -> std::io::Result<()> {
|
||||
let (name, address) = OFFICIAL_SERVER;
|
||||
let marker = game_dir.join(OFFICIAL_MARKER);
|
||||
// A marker that is there but can't be read: offered already, at an address not known.
|
||||
let offered = match std::fs::read_to_string(&marker) {
|
||||
Ok(text) => Some(text),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(_) => Some(String::new()),
|
||||
};
|
||||
if offered
|
||||
.as_deref()
|
||||
.is_some_and(|a| address_key(a) == address_key(address))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let file = game_dir.join("servers.dat");
|
||||
let _guard = servers::lock();
|
||||
let mut list = servers::load(&file);
|
||||
let previous = horizon_telemetry::server_status::OFFICIAL_PREVIOUS;
|
||||
if move_official(&mut list, address, previous) {
|
||||
servers::save(&file, &list)?;
|
||||
}
|
||||
if offered.is_none()
|
||||
&& !list
|
||||
.iter()
|
||||
.any(|s| address_key(&s.ip) == address_key(address))
|
||||
{
|
||||
servers::prepend(&file, &ServerEntry::new(name, address))?;
|
||||
}
|
||||
std::fs::write(marker, format!("{address}\n"))
|
||||
}
|
||||
|
||||
/// Afri-Network, which the game added once below the official server (#9299) until the
|
||||
/// partnership ended: the name and address the game gave its entry.
|
||||
const WITHDRAWN_PARTNER: (&str, &str) = ("Afri-Network (South Africa)", "afri-network.co.za");
|
||||
/// Beside servers.dat: the partner addresses the game offered, one per line (#9299).
|
||||
const PARTNER_MARKER: &str = "horizon_partner_servers.txt";
|
||||
|
||||
/// Takes the withdrawn partner server back out of the list in `game_dir`, once, where the game
|
||||
/// added it: the marker says it was offered and the entry still has the name the game gave it
|
||||
/// (an entry the player made or renamed keeps its place). Then the marker goes.
|
||||
pub fn withdraw_partner_servers(game_dir: &Path) -> std::io::Result<()> {
|
||||
let marker = game_dir.join(PARTNER_MARKER);
|
||||
let offered = match std::fs::read_to_string(&marker) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let (name, address) = WITHDRAWN_PARTNER;
|
||||
if offered
|
||||
.lines()
|
||||
.any(|l| address_key(l) == address_key(address))
|
||||
{
|
||||
let _guard = servers::lock();
|
||||
servers::remove_where(&game_dir.join("servers.dat"), |n, ip| {
|
||||
n == name && address_key(ip) == address_key(address)
|
||||
})?;
|
||||
}
|
||||
std::fs::remove_file(marker)
|
||||
}
|
||||
|
||||
/// Points the entries for an earlier official address at `address`, each
|
||||
/// keeping the player's name and fields. One entry for the server at most: an
|
||||
/// old one goes when the list already has `address`. Whether anything changed.
|
||||
fn move_official(list: &mut Vec<ServerEntry>, address: &str, previous: &[&str]) -> bool {
|
||||
let new = address_key(address);
|
||||
let old = |ip: &str| {
|
||||
let ip = address_key(ip);
|
||||
ip.is_some() && ip != new && previous.iter().any(|p| address_key(p) == ip)
|
||||
};
|
||||
let mut present = list.iter().any(|s| address_key(&s.ip) == new);
|
||||
let mut changed = false;
|
||||
list.retain_mut(|s| {
|
||||
if !old(&s.ip) {
|
||||
return true;
|
||||
}
|
||||
changed = true;
|
||||
if present {
|
||||
return false;
|
||||
}
|
||||
present = true;
|
||||
s.ip = address.to_owned();
|
||||
true
|
||||
});
|
||||
changed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_official_server_is_added_once_at_the_top() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mp-official-{}", std::process::id()));
|
||||
let (name, address) = OFFICIAL_SERVER;
|
||||
let file = dir.join("servers.dat");
|
||||
let ips = || -> Vec<String> { servers::load(&file).into_iter().map(|s| s.ip).collect() };
|
||||
let fresh = |list: &[ServerEntry]| {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
if !list.is_empty() {
|
||||
servers::save(&file, list).unwrap();
|
||||
}
|
||||
};
|
||||
// A fresh list: just the official server, named like it.
|
||||
fresh(&[]);
|
||||
add_official_server(&dir).unwrap();
|
||||
assert_eq!(servers::load(&file), [ServerEntry::new(name, address)]);
|
||||
// An existing list: on top, the player's servers kept in their order.
|
||||
let mine = [
|
||||
ServerEntry::new("Mine", "127.0.0.1:2"),
|
||||
ServerEntry::new("Other", "example.org"),
|
||||
];
|
||||
fresh(&mine);
|
||||
add_official_server(&dir).unwrap();
|
||||
assert_eq!(ips(), [address, "127.0.0.1:2", "example.org"]);
|
||||
// Once only: a second start adds nothing.
|
||||
add_official_server(&dir).unwrap();
|
||||
assert_eq!(ips(), [address, "127.0.0.1:2", "example.org"]);
|
||||
// Already there under the player's own name and spelling: not duplicated.
|
||||
fresh(&[
|
||||
ServerEntry::new("SMP", "Play.PlayHorizon.dev:25565"),
|
||||
mine[0].clone(),
|
||||
]);
|
||||
add_official_server(&dir).unwrap();
|
||||
assert_eq!(ips(), ["Play.PlayHorizon.dev:25565", "127.0.0.1:2"]);
|
||||
// Deleted by the player: never back.
|
||||
fresh(&mine);
|
||||
add_official_server(&dir).unwrap();
|
||||
servers::save(&file, &mine).unwrap();
|
||||
add_official_server(&dir).unwrap();
|
||||
assert_eq!(ips(), ["127.0.0.1:2", "example.org"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_withdrawn_partner_server_leaves_lists_the_game_put_it_in_once() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mp-partner-{}", std::process::id()));
|
||||
let official = OFFICIAL_SERVER.1;
|
||||
let file = dir.join("servers.dat");
|
||||
let marker = dir.join(PARTNER_MARKER);
|
||||
let ips = || -> Vec<String> { servers::load(&file).into_iter().map(|s| s.ip).collect() };
|
||||
let fresh = |list: &[ServerEntry], offered: bool| {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
servers::save(&file, list).unwrap();
|
||||
if offered {
|
||||
std::fs::write(&marker, "afri-network.co.za\n").unwrap();
|
||||
}
|
||||
};
|
||||
let added = ServerEntry::new("Afri-Network (South Africa)", "afri-network.co.za");
|
||||
// The game added it: it goes, the rest stays in order, and only once.
|
||||
fresh(
|
||||
&[
|
||||
ServerEntry::new("Horizon Official", official),
|
||||
added.clone(),
|
||||
ServerEntry::new("Mine", "127.0.0.1:2"),
|
||||
],
|
||||
true,
|
||||
);
|
||||
withdraw_partner_servers(&dir).unwrap();
|
||||
assert_eq!(ips(), [official, "127.0.0.1:2"]);
|
||||
assert!(!marker.exists());
|
||||
servers::save(&file, std::slice::from_ref(&added)).unwrap();
|
||||
withdraw_partner_servers(&dir).unwrap();
|
||||
assert_eq!(ips(), ["afri-network.co.za"]);
|
||||
// The player's own entry for that address (their name) stays.
|
||||
fresh(&[ServerEntry::new("ZA", "Afri-Network.co.za:25565")], true);
|
||||
withdraw_partner_servers(&dir).unwrap();
|
||||
assert_eq!(ips(), ["Afri-Network.co.za:25565"]);
|
||||
// Never offered (no marker): nothing changes.
|
||||
fresh(&[added], false);
|
||||
withdraw_partner_servers(&dir).unwrap();
|
||||
assert_eq!(ips(), ["afri-network.co.za"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_official_server_moves_the_old_entry_once() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mp-moved-{}", std::process::id()));
|
||||
let address = OFFICIAL_SERVER.1;
|
||||
let file = dir.join("servers.dat");
|
||||
let marker = dir.join(OFFICIAL_MARKER);
|
||||
let ips = || -> Vec<String> { servers::load(&file).into_iter().map(|s| s.ip).collect() };
|
||||
let offered_before = |list: &[ServerEntry]| {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
servers::save(&file, list).unwrap();
|
||||
std::fs::write(&marker, "horizon.bunchy7s.dev\n").unwrap();
|
||||
};
|
||||
// The old address under the player's own name and spelling: moved, name and place kept.
|
||||
let mut old = ServerEntry::new("Our SMP", "Horizon.Bunchy7s.dev:25565");
|
||||
old.icon = Some("AAAA".into());
|
||||
offered_before(&[ServerEntry::new("Mine", "127.0.0.1:2"), old]);
|
||||
add_official_server(&dir).unwrap();
|
||||
let list = servers::load(&file);
|
||||
assert_eq!(ips(), ["127.0.0.1:2", address]);
|
||||
assert_eq!(
|
||||
(list[1].name.as_str(), list[1].icon.as_deref()),
|
||||
("Our SMP", Some("AAAA"))
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&marker).unwrap(),
|
||||
format!("{address}\n")
|
||||
);
|
||||
// Once: the old address added back afterwards is the player's.
|
||||
servers::prepend(&file, &ServerEntry::new("Old", "horizon.bunchy7s.dev")).unwrap();
|
||||
add_official_server(&dir).unwrap();
|
||||
assert_eq!(ips(), ["horizon.bunchy7s.dev", "127.0.0.1:2", address]);
|
||||
// The new address already listed: no second entry.
|
||||
offered_before(&[
|
||||
ServerEntry::new("Old", "horizon.bunchy7s.dev"),
|
||||
ServerEntry::new("New", address),
|
||||
]);
|
||||
add_official_server(&dir).unwrap();
|
||||
assert_eq!(ips(), [address]);
|
||||
// Deleted by the player before the move: not offered again; another port is left alone.
|
||||
offered_before(&[ServerEntry::new("Test", "horizon.bunchy7s.dev:25570")]);
|
||||
add_official_server(&dir).unwrap();
|
||||
assert_eq!(ips(), ["horizon.bunchy7s.dev:25570"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const TITLE: &str = "gui.socialInteractions.title";
|
||||
/// `SocialInteractionsScreen`'s page.
|
||||
@@ -185,8 +186,35 @@ pub fn click(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<
|
||||
}
|
||||
None
|
||||
}
|
||||
/// The tooltip of what the cursor is over.
|
||||
fn tooltip(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<Text> {
|
||||
/// `PlayerEntry.TOOLTIP_DELAY`: a row button's tooltip shows once the cursor
|
||||
/// has rested on that same button for half a second.
|
||||
pub const TOOLTIP_DELAY: Duration = Duration::from_millis(500);
|
||||
|
||||
/// The row button whose tooltip the cursor is over.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Tip {
|
||||
Report(u128),
|
||||
Toggle { id: u128, hidden: bool },
|
||||
}
|
||||
|
||||
impl Tip {
|
||||
fn text(self) -> Text {
|
||||
match self {
|
||||
Tip::Report(_) => {
|
||||
Text::translate("gui.socialInteractions.tooltip.report.disabled", &[])
|
||||
}
|
||||
Tip::Toggle { hidden: true, .. } => {
|
||||
Text::translate("gui.socialInteractions.tooltip.show", &[])
|
||||
}
|
||||
Tip::Toggle { hidden: false, .. } => {
|
||||
Text::translate("gui.socialInteractions.tooltip.hide", &[])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tooltip of what the cursor is over: the row button it names.
|
||||
fn tip_at(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<Tip> {
|
||||
let (w, h) = size;
|
||||
if cursor.1 < LIST_START || cursor.1 >= list_end(h) {
|
||||
return None;
|
||||
@@ -198,28 +226,49 @@ fn tooltip(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<Te
|
||||
}
|
||||
let (hide, report) = row_buttons(w, y);
|
||||
if ui::hovered(cursor, hide[0], hide[1], 20.0, 20.0) {
|
||||
let key = if e.hidden {
|
||||
"gui.socialInteractions.tooltip.show"
|
||||
} else {
|
||||
"gui.socialInteractions.tooltip.hide"
|
||||
};
|
||||
return Some(Text::translate(key, &[]));
|
||||
return Some(Tip::Toggle {
|
||||
id: e.id,
|
||||
hidden: e.hidden,
|
||||
});
|
||||
}
|
||||
if ui::hovered(cursor, report[0], report[1], 20.0, 20.0) {
|
||||
return Some(Text::translate(
|
||||
"gui.socialInteractions.tooltip.report.disabled",
|
||||
&[],
|
||||
));
|
||||
return Some(Tip::Report(e.id));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `WidgetTooltipHolder`'s delay: the tooltip of the control the cursor has
|
||||
/// rested on for `TOOLTIP_DELAY`, and nothing while it moves between them.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct TooltipDelay {
|
||||
hovered: Option<(Tip, Instant)>,
|
||||
}
|
||||
|
||||
impl TooltipDelay {
|
||||
/// The tip to draw at `now`, starting the wait when the cursor reaches a
|
||||
/// new button (vanilla's `displayStartTime`).
|
||||
pub fn shown(&mut self, hovered: Option<Tip>, now: Instant) -> Option<Tip> {
|
||||
let Some(tip) = hovered else {
|
||||
self.hovered = None;
|
||||
return None;
|
||||
};
|
||||
match self.hovered {
|
||||
Some((t, at)) if t == tip => (now.duration_since(at) > TOOLTIP_DELAY).then_some(tip),
|
||||
_ => {
|
||||
self.hovered = Some((tip, now));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn draw(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
view: &SocialView,
|
||||
search: &mut ui::EditBox,
|
||||
tooltip: &mut TooltipDelay,
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
@@ -419,8 +468,8 @@ pub fn draw(
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(text) = tooltip((w, h), cursor, view) {
|
||||
ui::tooltip(batch, atlas, &[text], cursor);
|
||||
if let Some(tip) = tooltip.shown(tip_at((w, h), cursor, view), Instant::now()) {
|
||||
ui::tooltip(batch, atlas, &[tip.text()], cursor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,4 +566,35 @@ mod tests {
|
||||
let names: Vec<_> = rows.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(names, ["me", "_x", "Alpha", "zed", "§odd"]);
|
||||
}
|
||||
|
||||
/// `PlayerEntry.TOOLTIP_DELAY` (500 ms): the row buttons' tooltips wait
|
||||
/// while container item tooltips do not. Moving to another button, or
|
||||
/// away and back, starts the wait again.
|
||||
#[test]
|
||||
fn a_row_buttons_tooltip_waits_half_a_second() {
|
||||
let start = Instant::now();
|
||||
let at = |ms| start + Duration::from_millis(ms);
|
||||
let mut delay = TooltipDelay::default();
|
||||
let tip = Tip::Toggle {
|
||||
id: 7,
|
||||
hidden: false,
|
||||
};
|
||||
assert_eq!(delay.shown(Some(tip), at(0)), None);
|
||||
assert_eq!(delay.shown(Some(tip), at(499)), None);
|
||||
assert_eq!(delay.shown(Some(tip), at(500)), None, "500 ms is not > 500");
|
||||
assert_eq!(delay.shown(Some(tip), at(501)), Some(tip));
|
||||
// Another row button is another widget: its own wait.
|
||||
let other = Tip::Toggle {
|
||||
id: 8,
|
||||
hidden: true,
|
||||
};
|
||||
assert_eq!(delay.shown(Some(other), at(502)), None);
|
||||
assert_eq!(delay.shown(Some(other), at(1002)), None);
|
||||
assert_eq!(delay.shown(Some(other), at(1003)), Some(other));
|
||||
// Leaving hides it at once; coming back starts over.
|
||||
assert_eq!(delay.shown(None, at(1004)), None);
|
||||
assert_eq!(delay.shown(Some(tip), at(1005)), None);
|
||||
assert_eq!(delay.shown(Some(tip), at(1505)), None);
|
||||
assert_eq!(delay.shown(Some(tip), at(1506)), Some(tip));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user