mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
190
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10b0942e4f | ||
|
|
81aeeacf55 | ||
|
|
a0261a708a | ||
|
|
894f7a0684 | ||
|
|
d5b1c59f67 | ||
|
|
b0ea840d13 | ||
|
|
479d2082a8 | ||
|
|
d4b24f27d6 | ||
|
|
3ad30a2021 | ||
|
|
f80bf5d7f3 | ||
|
|
0d21d5520e | ||
|
|
02bdb03444 | ||
|
|
c7183d6232 | ||
|
|
98af9399cd | ||
|
|
bebf670623 | ||
|
|
5daf0853e3 | ||
|
|
bb8a9d9524 | ||
|
|
3a81751180 | ||
|
|
ec72efe92d | ||
|
|
87c51ab9c9 | ||
|
|
108c130237 | ||
|
|
73fdce3c01 | ||
|
|
bf22a35ab3 | ||
|
|
e41b6599ec | ||
|
|
0c2aec59f6 | ||
|
|
50c2ade6c6 | ||
|
|
3281781334 | ||
|
|
5871fa922f | ||
|
|
16bb8b3e65 | ||
|
|
1e35996644 | ||
|
|
499b049803 | ||
|
|
29825eb774 | ||
|
|
ded0d1a283 | ||
|
|
1c8729793f | ||
|
|
69c1400e58 | ||
|
|
948c6d6a88 | ||
|
|
b8c058a249 | ||
|
|
5a0c5d522f | ||
|
|
ff27525aaf | ||
|
|
54112649da | ||
|
|
ef207a7c92 | ||
|
|
c86e080406 | ||
|
|
26243ba3b7 | ||
|
|
a4f021ec67 | ||
|
|
a7bf97e67d | ||
|
|
11e2482ae5 | ||
|
|
363906684c | ||
|
|
a567b64f20 | ||
|
|
faa7e2746f | ||
|
|
b4197ddd94 | ||
|
|
ad8663977c | ||
|
|
0b3a4bda9c | ||
|
|
5fb8f59ea0 | ||
|
|
62d2b18c16 | ||
|
|
17541e8bff | ||
|
|
927b546c9a | ||
|
|
fff2a81e41 | ||
|
|
1f883e7a79 | ||
|
|
c956e11ff8 | ||
|
|
f6a9444deb | ||
|
|
0610b830ca | ||
|
|
8a21328406 | ||
|
|
d165254d41 | ||
|
|
0125ca5281 | ||
|
|
743d6229fe | ||
|
|
6df8d31833 | ||
|
|
cc4911fc2b | ||
|
|
8d1a46ef24 | ||
|
|
a6a228a193 | ||
|
|
57140bb20f | ||
|
|
0c7ddd45c5 | ||
|
|
c9b0011952 | ||
|
|
a25a11fbf7 | ||
|
|
9f9daee867 | ||
|
|
468a73f128 | ||
|
|
c542322582 | ||
|
|
264d5f38c7 | ||
|
|
dd23018283 | ||
|
|
3b575a0016 | ||
|
|
94d295d20c | ||
|
|
ec080faa9f | ||
|
|
1d1b060e1a | ||
|
|
5e8d9a5123 | ||
|
|
3649dfb9eb | ||
|
|
94d8386027 | ||
|
|
c9bc6104b3 | ||
|
|
02c05de455 | ||
|
|
6a737285e8 | ||
|
|
1354db6f08 | ||
|
|
d503874bff | ||
|
|
f97e4b9527 | ||
|
|
504b987937 | ||
|
|
c81874d772 | ||
|
|
a589a3dd9b | ||
|
|
8d38356bf4 | ||
|
|
4c0ef1d7df | ||
|
|
4b24e21a8a | ||
|
|
ebafc91999 | ||
|
|
1fe1d5e860 | ||
|
|
382f264b6a | ||
|
|
679f8c6e3f | ||
|
|
c6d5769e4f | ||
|
|
1ad899c7b5 | ||
|
|
b9439b3508 | ||
|
|
6a8ea6fc18 | ||
|
|
1e1c6a900f | ||
|
|
ff381b5dc4 | ||
|
|
7dc8ba568f | ||
|
|
a1710b6962 | ||
|
|
e7956591f3 | ||
|
|
4b352e89c1 | ||
|
|
2726d8040b | ||
|
|
6da74ac228 | ||
|
|
6716cb7407 | ||
|
|
8181ce0222 | ||
|
|
76b8b4eb83 | ||
|
|
6303017b03 | ||
|
|
e16a5ef0da | ||
|
|
76addba6cc | ||
|
|
cc643f4af4 | ||
|
|
c843c1aa05 | ||
|
|
283c6723c0 | ||
|
|
1532c9c153 | ||
|
|
23a880843d | ||
|
|
7b4634a912 | ||
|
|
2daa740748 | ||
|
|
cd547654d9 | ||
|
|
0ed3868329 | ||
|
|
7b80ab60c7 | ||
|
|
6beb50ebcd | ||
|
|
c1d16aabde | ||
|
|
c0c3096e85 | ||
|
|
74150d5ef8 | ||
|
|
dfb9757d97 | ||
|
|
a58d5700f3 | ||
|
|
9cdcf66e69 | ||
|
|
c442edabd9 | ||
|
|
29f04e40ed | ||
|
|
04f6bd1214 | ||
|
|
27d093c94f | ||
|
|
36c0d3f938 | ||
|
|
6bd507f89d | ||
|
|
7a846eebc6 | ||
|
|
bd152b1ecf | ||
|
|
6f9981bfbf | ||
|
|
6cf1b6b74c | ||
|
|
12454e54bd | ||
|
|
6b2c83656c | ||
|
|
d7e1eaf955 | ||
|
|
7c57f5f906 | ||
|
|
69fab597a6 | ||
|
|
790e5f14d4 | ||
|
|
3c01dce30d | ||
|
|
66dc3229a0 | ||
|
|
2948fb86ed | ||
|
|
9f8f6842db | ||
|
|
1525ae0e3b | ||
|
|
09450fb6f9 | ||
|
|
53bc816fa1 | ||
|
|
1411281eea | ||
|
|
92bc3bc40f | ||
|
|
d6d7cc3e29 | ||
|
|
b08f1074b1 | ||
|
|
9a076b6a24 | ||
|
|
b40c996fb9 | ||
|
|
99e1caf4db | ||
|
|
400335608e | ||
|
|
89ca6821b8 | ||
|
|
4eafef1180 | ||
|
|
e82ac45192 | ||
|
|
153044c095 | ||
|
|
1d8a1699ad | ||
|
|
4f3355c151 | ||
|
|
7dcc5b46f3 | ||
|
|
9027fcd390 | ||
|
|
009378de29 | ||
|
|
b9607ce998 | ||
|
|
cb7babf1cf | ||
|
|
a13106bc0b | ||
|
|
878342f98e | ||
|
|
6e083af988 | ||
|
|
7fcf18b6df | ||
|
|
ed01ae3029 | ||
|
|
a9c7b82772 | ||
|
|
2f9ec5be67 | ||
|
|
cf31690cae | ||
|
|
5062da5567 | ||
|
|
69ecedfc01 | ||
|
|
f2dfddea11 | ||
|
|
4272d9ae41 |
No files matched your search
Generated
+203
@@ -147,6 +147,12 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arcstr"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -303,6 +309,21 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "btoi"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b5ab9db53bcda568284df0fd39f6eac24ad6f7ba7ff1168b9e76eba6576b976"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bufstream"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -657,6 +678,15 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
@@ -774,6 +804,17 @@ version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
|
||||
|
||||
[[package]]
|
||||
name = "derive_utils"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc05a5d33db20c784f873e84934ad94bb209a090987ac5f62fede2c178234f23"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "des"
|
||||
version = "0.8.1"
|
||||
@@ -1615,6 +1656,16 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-deps"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"mysql",
|
||||
"redis",
|
||||
"rusty-opus",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
@@ -2010,6 +2061,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-enum"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f859dc81e05e585e93cfc84dffa4fd068d0651ca229c51bca6101c056b63b93a"
|
||||
dependencies = [
|
||||
"derive_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipconfig"
|
||||
version = "0.3.4"
|
||||
@@ -2257,6 +2317,12 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.14.0"
|
||||
@@ -2346,6 +2412,59 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "mysql"
|
||||
version = "27.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "090068bc0a24762663338da78414148333d1ee246a7293c10a517ddf1548b291"
|
||||
dependencies = [
|
||||
"bufstream",
|
||||
"bytes",
|
||||
"crossbeam-queue",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
"io-enum",
|
||||
"libc",
|
||||
"lru",
|
||||
"mysql_common",
|
||||
"named_pipe",
|
||||
"pem",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"socket2 0.6.5",
|
||||
"twox-hash",
|
||||
"url",
|
||||
"webpki",
|
||||
"webpki-roots 1.0.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mysql_common"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20e7f6a15265801679ddf1ddb38fd74b4555a677a78378d4561fbe5f0b1d4d72"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bitflags 2.13.2",
|
||||
"btoi",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"regex",
|
||||
"saturating",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "naga"
|
||||
version = "30.0.1"
|
||||
@@ -2383,6 +2502,15 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "named_pipe"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad9c443cce91fc3e12f017290db75dde490d685cdaaf508d7159d7cf41f0eb2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -2982,6 +3110,16 @@ dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@@ -3309,6 +3447,22 @@ dependencies = [
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db7f3d75b5a3301746bff39a767bf4c5c42506b08513527308b3875bfb64a031"
|
||||
dependencies = [
|
||||
"arcstr",
|
||||
"combine",
|
||||
"itoa",
|
||||
"percent-encoding",
|
||||
"ryu",
|
||||
"socket2 0.6.5",
|
||||
"url",
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.4.1"
|
||||
@@ -3450,6 +3604,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
@@ -3476,6 +3639,12 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-opus"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46604e6809c06441da1cfdde1f0cc25a4f2bf42c7d005aa387c2875c02d78762"
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.9.1"
|
||||
@@ -3485,6 +3654,12 @@ dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "salsa20"
|
||||
version = "0.10.2"
|
||||
@@ -3503,6 +3678,12 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "saturating"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71"
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
@@ -4111,6 +4292,18 @@ version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"log",
|
||||
"rand",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "twox-hash"
|
||||
version = "2.1.5"
|
||||
@@ -4454,6 +4647,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
|
||||
@@ -39,6 +39,7 @@ members = [
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-deps",
|
||||
"xtask",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -11,7 +11,7 @@ macro_rules! actions {
|
||||
$($variant,)*
|
||||
}
|
||||
impl Action {
|
||||
/// In `Options.keyMappings` order, the pad-only actions last.
|
||||
/// In `Options.keyMappings` order, then Horizon's own mappings, the pad-only actions last.
|
||||
pub const ALL: &[Action] = &[$(Action::$variant,)*];
|
||||
/// The saved name: vanilla's for a mapping, `pad.*` for Horizon's own.
|
||||
pub fn name(self) -> &'static str {
|
||||
@@ -49,7 +49,6 @@ actions! {
|
||||
PickItem, "key.pickItem", None;
|
||||
Command, "key.command", Some(KeyCode::Slash);
|
||||
Friends, "key.friends", Some(KeyCode::KeyO);
|
||||
Emote, "key.emote", Some(KeyCode::KeyB);
|
||||
SocialInteractions, "key.socialInteractions", Some(KeyCode::KeyP);
|
||||
ToggleGui, "key.toggleGui", Some(KeyCode::F1);
|
||||
ToggleSpectatorShaderEffects, "key.toggleSpectatorShaderEffects", Some(KeyCode::F4);
|
||||
@@ -96,6 +95,7 @@ actions! {
|
||||
DebugNetworkCharts, "key.debug.networkCharts", None;
|
||||
DebugLightmapTexture, "key.debug.lightmapTexture", None;
|
||||
DebugImprovedTransparency, "key.debug.improvedTransparency", None;
|
||||
Emote, "key.emote", Some(KeyCode::KeyB);
|
||||
HotbarPrevious, "pad.hotbarPrevious", None;
|
||||
HotbarNext, "pad.hotbarNext", None;
|
||||
}
|
||||
@@ -109,21 +109,24 @@ impl Action {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::keybinds::{self, KeyBinds, MAPPINGS};
|
||||
use crate::keybinds::{self, HORIZON_MAPPINGS, KeyBinds, MAPPINGS, all_mappings};
|
||||
|
||||
#[test]
|
||||
fn every_mapping_is_an_action_in_order() {
|
||||
let names: Vec<&str> = Action::ALL.iter().map(|a| a.name()).collect();
|
||||
let mappings: Vec<&str> = MAPPINGS.iter().map(|m| m.0).collect();
|
||||
assert_eq!(names[..mappings.len()], mappings[..]);
|
||||
for action in &names[mappings.len()..] {
|
||||
let horizon: Vec<&str> = HORIZON_MAPPINGS.iter().map(|m| m.0).collect();
|
||||
let end = mappings.len() + horizon.len();
|
||||
assert_eq!(names[mappings.len()..end], horizon[..]);
|
||||
for action in &names[end..] {
|
||||
assert!(action.starts_with("pad."), "{action}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_actions_key_is_its_mappings_default_key() {
|
||||
for (name, _, category) in MAPPINGS {
|
||||
for (name, _, category) in all_mappings() {
|
||||
let action = Action::parse(name).unwrap();
|
||||
let want = if *category == "debug" {
|
||||
None
|
||||
|
||||
@@ -1742,11 +1742,11 @@ bind_key.hotbar.2:north
|
||||
assert_eq!(s.actions_of(Button::North), ["key.inventory"]);
|
||||
assert_eq!(s.actions_of(Button::RightBumper), [HOTBAR_NEXT]);
|
||||
assert!(s.actions_of(Button::Start).is_empty());
|
||||
// Every default action names a vanilla mapping or a pad action.
|
||||
// Every default action names a key mapping or a pad action.
|
||||
for (action, _) in ACTIONS {
|
||||
assert!(
|
||||
action.starts_with("pad.")
|
||||
|| crate::keybinds::MAPPINGS.iter().any(|m| m.0 == *action),
|
||||
|| crate::keybinds::all_mappings().any(|m| m.0 == *action),
|
||||
"{action}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,8 +23,6 @@ pub const MAPPINGS: &[(&str, &str, &str)] = &[
|
||||
("key.pickItem", "key.mouse.middle", "gameplay"),
|
||||
("key.command", "key.keyboard.slash", "multiplayer"),
|
||||
("key.friends", "key.keyboard.o", "multiplayer"),
|
||||
// Horizon's emote wheel (docs/cosmetics.md), on a key vanilla leaves free.
|
||||
("key.emote", "key.keyboard.b", "multiplayer"),
|
||||
("key.socialInteractions", "key.keyboard.p", "multiplayer"),
|
||||
("key.toggleGui", "key.keyboard.f1", "misc"),
|
||||
(
|
||||
@@ -76,6 +74,17 @@ pub const MAPPINGS: &[(&str, &str, &str)] = &[
|
||||
("key.debug.lightmapTexture", "key.keyboard.4", "debug"),
|
||||
("key.debug.improvedTransparency", "key.keyboard.x", "debug"),
|
||||
];
|
||||
/// Horizon's own key mappings: bound in Controls like the vanilla ones, but kept in
|
||||
/// `horizon-extras.txt` (`emoteKey`), never in `options.txt`, which stays exactly vanilla's
|
||||
/// (`MAPPINGS` is what the options parity harness compares with the pinned client).
|
||||
pub const HORIZON_MAPPINGS: &[(&str, &str, &str)] = &[
|
||||
// The emote wheel (docs/cosmetics.md), on a key vanilla leaves free.
|
||||
("key.emote", "key.keyboard.b", "multiplayer"),
|
||||
];
|
||||
/// Every mapping the game binds: vanilla's in `Options.keyMappings` order, then Horizon's.
|
||||
pub fn all_mappings() -> impl Iterator<Item = &'static (&'static str, &'static str, &'static str)> {
|
||||
MAPPINGS.iter().chain(HORIZON_MAPPINGS)
|
||||
}
|
||||
/// Mappings Options builds as `ToggleKeyMapping` (hold or toggle by option).
|
||||
pub fn is_toggle(name: &str) -> bool {
|
||||
matches!(name, "key.sneak" | "key.sprint" | "key.use" | "key.attack")
|
||||
@@ -314,8 +323,7 @@ pub struct KeyBinds {
|
||||
impl Default for KeyBinds {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
keys: MAPPINGS
|
||||
.iter()
|
||||
keys: all_mappings()
|
||||
.map(|(n, d, _)| (*n, (*d).to_owned()))
|
||||
.collect(),
|
||||
}
|
||||
@@ -341,8 +349,7 @@ impl KeyBinds {
|
||||
.map_or("key.keyboard.unknown", String::as_str)
|
||||
}
|
||||
pub fn default_of(name: &str) -> &'static str {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
all_mappings()
|
||||
.find(|m| m.0 == name)
|
||||
.map_or("key.keyboard.unknown", |m| m.1)
|
||||
}
|
||||
@@ -350,16 +357,24 @@ impl KeyBinds {
|
||||
self.get(name) == Self::default_of(name)
|
||||
}
|
||||
pub fn set(&mut self, name: &str, key: &str) {
|
||||
if let Some((n, _, _)) = MAPPINGS.iter().find(|m| m.0 == name) {
|
||||
if let Some((n, _, _)) = all_mappings().find(|m| m.0 == name) {
|
||||
self.keys.insert(n, key.to_owned());
|
||||
}
|
||||
}
|
||||
/// The bindings `options.txt` holds: vanilla's mappings only.
|
||||
pub fn saved(&self) -> Vec<(&'static str, String)> {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
.map(|(n, _, _)| (*n, self.get(n).to_owned()))
|
||||
.collect()
|
||||
}
|
||||
/// Horizon's own bindings, for `horizon-extras.txt`.
|
||||
pub fn horizon_saved(&self) -> Vec<(&'static str, String)> {
|
||||
HORIZON_MAPPINGS
|
||||
.iter()
|
||||
.map(|(n, _, _)| (*n, self.get(n).to_owned()))
|
||||
.collect()
|
||||
}
|
||||
pub fn reset_all(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
@@ -369,8 +384,7 @@ impl KeyBinds {
|
||||
if key == "key.keyboard.unknown" {
|
||||
return Vec::new();
|
||||
}
|
||||
MAPPINGS
|
||||
.iter()
|
||||
all_mappings()
|
||||
.filter(|(n, _, _)| {
|
||||
*n != name && self.get(n) == key && (!self.is_default(n) || !self.is_default(name))
|
||||
})
|
||||
@@ -384,8 +398,7 @@ impl KeyBinds {
|
||||
/// Every mapping a physical input triggers, in list order: like
|
||||
/// `KeyMapping.set`/`click`, one key drives all the mappings bound to it.
|
||||
pub fn mappings_for<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'static str> + 'a {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
all_mappings()
|
||||
.map(|m| m.0)
|
||||
.filter(move |n| self.get(n) == key)
|
||||
}
|
||||
@@ -395,8 +408,7 @@ impl KeyBinds {
|
||||
/// key and the binding itself); their defaults (A, S, D, T, L, 1 to 4...)
|
||||
/// would otherwise turn the key back into its old gameplay meaning.
|
||||
fn translated_mappings<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'static str> + 'a {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
all_mappings()
|
||||
.filter(|m| m.2 != "debug")
|
||||
.map(|m| m.0)
|
||||
.filter(move |n| self.get(n) == key)
|
||||
@@ -404,9 +416,7 @@ impl KeyBinds {
|
||||
/// Whether a mapping (other than a debug key) whose default is `key` was
|
||||
/// bound elsewhere.
|
||||
fn default_moved(&self, key: &str) -> bool {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
.any(|(n, d, c)| *d == key && *c != "debug" && !self.is_default(n))
|
||||
all_mappings().any(|(n, d, c)| *d == key && *c != "debug" && !self.is_default(n))
|
||||
}
|
||||
/// Translate a physical key into the default key of the mapping it is
|
||||
/// bound to, so game code written against vanilla defaults follows the
|
||||
|
||||
@@ -6,13 +6,7 @@ use horizon_world::{
|
||||
worldgen::{tags::TagDefinitions, terrain::TerrainBlockStates},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
fs::File,
|
||||
io::{self, BufReader},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{collections::BTreeSet, io, path::Path, sync::Arc};
|
||||
|
||||
pub const MATERIALS: [&str; 6] = ["bedrock", "dirt", "stone", "sand", "oak_planks", "bricks"];
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -190,15 +184,12 @@ fn invalid(message: &str) -> io::Error {
|
||||
}
|
||||
impl Assets {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
let manifest: Value = serde_json::from_reader(BufReader::new(File::open(
|
||||
root.join("horizon-resources.json"),
|
||||
)?))?;
|
||||
let manifest: Value = horizon_world::json_file::read(&root.join("horizon-resources.json"))?;
|
||||
if manifest["data_version"].as_i64() != Some(i64::from(DATA_VERSION)) {
|
||||
return Err(invalid("resources do not match Horizon's pinned data version").into());
|
||||
}
|
||||
let (registry, flags) = horizon_world::level::data::load_registry_with_flags(root)?;
|
||||
let bundle: Value =
|
||||
serde_json::from_reader(BufReader::new(File::open(root.join("block-tags.json"))?))?;
|
||||
let bundle: Value = horizon_world::json_file::read(&root.join("block-tags.json"))?;
|
||||
let mut definitions = TagDefinitions::default();
|
||||
for (name, text) in bundle
|
||||
.as_object()
|
||||
|
||||
@@ -288,7 +288,7 @@ impl Biomes {
|
||||
.collect();
|
||||
files.sort_by_cached_key(|f| format!("{}.json", f.name));
|
||||
for file in files {
|
||||
let v: Value = serde_json::from_slice(file.bytes()?)?;
|
||||
let v: Value = horizon_world::json_file::parse(&file.path, file.bytes()?)?;
|
||||
list.push(BiomeInfo::from_json(
|
||||
format!("minecraft:{}", file.name),
|
||||
&v,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! celestial angles and star brightness for the overworld day cycle.
|
||||
use crate::Result;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Easing {
|
||||
@@ -151,13 +151,13 @@ impl Environment {
|
||||
/// A dimension's base attributes, optionally driven by the day timeline.
|
||||
pub fn load_dimension(root: &Path, dimension: &str, day_cycle: bool) -> Result<Self> {
|
||||
let timeline: Value = if day_cycle {
|
||||
serde_json::from_slice(&fs::read(root.join("data/minecraft/timeline/day.json"))?)?
|
||||
horizon_world::json_file::read(&root.join("data/minecraft/timeline/day.json"))?
|
||||
} else {
|
||||
serde_json::json!({"period_ticks": 24000, "tracks": {}})
|
||||
};
|
||||
let dimension: Value = serde_json::from_slice(&fs::read(
|
||||
root.join(format!("data/minecraft/dimension_type/{dimension}.json")),
|
||||
)?)?;
|
||||
let dimension: Value = horizon_world::json_file::read(
|
||||
&root.join(format!("data/minecraft/dimension_type/{dimension}.json")),
|
||||
)?;
|
||||
let mut tracks = HashMap::new();
|
||||
for (name, track) in timeline["tracks"].as_object().ok_or("invalid timeline")? {
|
||||
let easing = match &track["ease"] {
|
||||
|
||||
@@ -10,6 +10,9 @@ pub const FILE: &str = "horizon-extras.txt";
|
||||
pub const SORT_KEY: &str = "horizon.sort";
|
||||
/// The 3D skin layers line, here and in the `options.txt` that held it first.
|
||||
const SKIN_LAYERS_KEY: &str = "threeDSkinLayers";
|
||||
/// The emote wheel's key, here and in the `key_key.emote` line #10092 wrote to `options.txt`.
|
||||
const EMOTE_KEY: &str = "emoteKey";
|
||||
const OLD_EMOTE_LINE: &str = "key_key.emote";
|
||||
|
||||
/// The minimap's sizes in GUI pixels, and its zooms in blocks per pixel.
|
||||
pub const MINIMAP_SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
|
||||
@@ -126,6 +129,10 @@ pub struct Extras {
|
||||
pub sort: bool,
|
||||
/// A vanilla key name: sorts the part under the cursor.
|
||||
pub sort_key: String,
|
||||
/// The emote wheel's key (`key.emote`), as the game last saved it. The live binding is
|
||||
/// `Settings::keys`, bound in Controls with the vanilla ones; it is only kept in this file
|
||||
/// so that `options.txt` stays vanilla's (`Settings::load` hands it over, `save` takes it back).
|
||||
pub emote_key: String,
|
||||
/// A new chat line slides in from the left while it fades in (`Chat::layout`).
|
||||
pub chat_animation: bool,
|
||||
/// The indicator bar at the left of a chat line (on by default, as vanilla).
|
||||
@@ -220,6 +227,7 @@ impl Default for Extras {
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
emote_key: crate::keybinds::KeyBinds::default_of("key.emote").into(),
|
||||
chat_animation: false,
|
||||
chat_indicator: true,
|
||||
no_ai: false,
|
||||
@@ -324,6 +332,7 @@ impl Extras {
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
|
||||
EMOTE_KEY if value.starts_with("key.") => e.emote_key = value.to_owned(),
|
||||
"chatAnimation" => e.chat_animation = flag(e.chat_animation),
|
||||
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
@@ -430,26 +439,37 @@ impl Extras {
|
||||
SKIN_LAYERS_KEY,
|
||||
bool_text(self.three_d_skin_layers),
|
||||
);
|
||||
text.push_str(&format!("{EMOTE_KEY}:{}\n", self.emote_key));
|
||||
self.pvp.serialize(&mut text);
|
||||
text
|
||||
}
|
||||
/// Adopts the settings `options.txt` held before they moved here, while
|
||||
/// this file (`own`) has no line of its own: 3D skin layers was an
|
||||
/// `options.txt` line. Vanilla's `Options.save` drops lines it doesn't
|
||||
/// this file (`own`) has no line of its own: 3D skin layers and the emote key were
|
||||
/// `options.txt` lines. Vanilla's `Options.save` drops lines it doesn't
|
||||
/// know, so it is read there once, the first start after the update.
|
||||
pub fn read_old_options(&mut self, own: &str, options_txt: &str) {
|
||||
fn line(text: &str) -> Option<&str> {
|
||||
fn line<'a>(text: &'a str, name: &str) -> Option<&'a str> {
|
||||
text.lines().find_map(|l| {
|
||||
l.split_once(':')
|
||||
.filter(|(key, _)| *key == SKIN_LAYERS_KEY)
|
||||
.filter(|(key, _)| *key == name)
|
||||
.map(|(_, value)| value)
|
||||
})
|
||||
}
|
||||
if line(own).is_none()
|
||||
&& let Some(value) = line(options_txt)
|
||||
if line(own, SKIN_LAYERS_KEY).is_none()
|
||||
&& let Some(value) = line(options_txt, SKIN_LAYERS_KEY)
|
||||
{
|
||||
self.three_d_skin_layers = value == "true";
|
||||
}
|
||||
// The emote key was a `key_key.emote` line of options.txt (#10092): a rebound key is kept.
|
||||
if line(own, EMOTE_KEY).is_none()
|
||||
&& let Some(value) = line(options_txt, OLD_EMOTE_LINE).map(str::trim)
|
||||
&& value.starts_with("key.")
|
||||
&& (value == "key.keyboard.unknown"
|
||||
|| crate::keybinds::key_code(value).is_some()
|
||||
|| crate::keybinds::mouse_button(value).is_some())
|
||||
{
|
||||
self.emote_key = value.to_owned();
|
||||
}
|
||||
}
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
/// another name (a vanilla mapping).
|
||||
|
||||
@@ -5795,6 +5795,31 @@ impl Renderer {
|
||||
blocks.iter().map(move |block| (block, fully_inside))
|
||||
})
|
||||
}
|
||||
/// The block entities of the sections a shader pack's shadow pass drew in the last frame
|
||||
/// (`shadow_visible`), within the 64 blocks `visible_block_entities_in_frustum` keeps.
|
||||
/// Our probe packs' Iris 1.11.7 shadow maps hold a block entity wherever the shadow pass
|
||||
/// draws its section's terrain, whether or not the camera's view takes it in (#8464).
|
||||
pub fn shadow_pass_block_entities(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
) -> impl Iterator<Item = &([i32; 3], u32)> + '_ {
|
||||
block_entities_within_reach(
|
||||
self.shadow_visible.iter().map(|v| v.key),
|
||||
&self.block_entities,
|
||||
camera,
|
||||
self.layout.min_y(),
|
||||
)
|
||||
}
|
||||
/// Whether a shader pack's shadow pass drew the section holding block `pos` in the last
|
||||
/// frame (`shadow_pass_block_entities`).
|
||||
pub fn shadow_pass_drew(&self, pos: [i32; 3]) -> bool {
|
||||
let key = (
|
||||
pos[0] >> 4,
|
||||
(pos[1] - self.layout.min_y()) >> 4,
|
||||
pos[2] >> 4,
|
||||
);
|
||||
self.shadow_visible.iter().any(|v| v.key == key)
|
||||
}
|
||||
/// Milliseconds since `key` was uploaded; a section not in `section_uploaded` is old.
|
||||
fn section_elapsed_ms(&self, key: SectionKey, now: std::time::Instant) -> u64 {
|
||||
self.section_uploaded.get(&key).map_or(u64::MAX, |t| {
|
||||
@@ -7636,9 +7661,7 @@ impl Renderer {
|
||||
if let Some(v) = &shadow_view {
|
||||
let radius = (frame.detail_distance / 16.0) as i32;
|
||||
let light = ShadowCull {
|
||||
light: Frustum {
|
||||
planes: frustum(v.cull),
|
||||
},
|
||||
light: shadow_box(v),
|
||||
casters: v.view_casters.then(|| shadow_casters(cull_proj, v.light)),
|
||||
reach: v.distance,
|
||||
};
|
||||
@@ -11441,6 +11464,39 @@ impl Cull for ShadowCull {
|
||||
self.casters.is_none()
|
||||
}
|
||||
}
|
||||
/// The block entities of `sections` whose box, a block wider on every side, comes within the
|
||||
/// 64 blocks block entities are drawn to (`Renderer::visible_block_entities_in_frustum`).
|
||||
fn block_entities_within_reach<'a>(
|
||||
sections: impl Iterator<Item = SectionKey> + 'a,
|
||||
block_entities: &'a FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
camera: DVec3,
|
||||
min_y: i32,
|
||||
) -> impl Iterator<Item = &'a ([i32; 3], u32)> + 'a {
|
||||
sections
|
||||
.filter_map(move |key| {
|
||||
let min = (DVec3::new(
|
||||
f64::from(key.0 * 16 - 1),
|
||||
f64::from(min_y + key.1 * 16 - 1),
|
||||
f64::from(key.2 * 16 - 1),
|
||||
) - camera)
|
||||
.as_vec3();
|
||||
let max = min + Vec3::splat(18.0);
|
||||
let near = Vec3::ZERO.max(min).min(max).length_squared() <= 64.0 * 64.0;
|
||||
near.then(|| block_entities.get(&key)).flatten()
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
|
||||
/// its near and far planes only with `depth_planes`.
|
||||
fn shadow_box(view: &pack_post::ShadowView) -> Frustum {
|
||||
let mut planes = frustum(view.cull);
|
||||
if !view.depth_planes {
|
||||
// `frustum`'s faces at depth 0 and 1 take in everything.
|
||||
planes[4] = Vec4::W;
|
||||
planes[5] = Vec4::W;
|
||||
}
|
||||
Frustum { planes }
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
@@ -13412,6 +13468,7 @@ mod shadow_face_tests {
|
||||
unculled: None,
|
||||
every_face,
|
||||
view_casters: true,
|
||||
depth_planes: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13431,6 +13488,24 @@ mod shadow_face_tests {
|
||||
mod shadow_caster_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_shadow_pass_takes_the_block_entities_of_its_sections_within_64_blocks() {
|
||||
// Sections 64 to 79 high (the world from -64): a chest in each of three. The shadow
|
||||
// pass drew `near` and `far`; `left_out` lies as near but the pass didn't draw it, and
|
||||
// `far`'s nearest block is 78.5 blocks away.
|
||||
let (near, left_out, far) = ((0, 8, 0), (1, 8, 0), (5, 8, 0));
|
||||
let mut block_entities = FxHashMap::default();
|
||||
block_entities.insert(near, vec![([3, 70, 3], 7)]);
|
||||
block_entities.insert(left_out, vec![([20, 70, 3], 7)]);
|
||||
block_entities.insert(far, vec![([85, 70, 3], 7)]);
|
||||
let camera = DVec3::new(0.5, 70.0, 0.5);
|
||||
let drawn = [near, far].into_iter();
|
||||
let taken: Vec<_> = block_entities_within_reach(drawn, &block_entities, camera, -64)
|
||||
.copied()
|
||||
.collect();
|
||||
assert_eq!(taken, vec![([3, 70, 3], 7)]);
|
||||
}
|
||||
|
||||
/// What the shadow pass keeps of a camera-relative section at `min`, the camera at the
|
||||
/// origin looking along -X (yaw 90) with the scenes' 70 degree, 16:9 view, and the light in
|
||||
/// direction `light`; the light's own box takes in everything.
|
||||
@@ -13585,6 +13660,52 @@ mod shadow_caster_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Our probe pack's Iris 1.11.7 shadow maps, under a noon sun with `shadowDistance` 128, hold
|
||||
/// the terrain at every depth along the light whatever planes the pack gives (#10403): from
|
||||
/// a cave at y -7, the surface's sections ~70 blocks toward the light with `shadowNearPlane`
|
||||
/// 0.05 and `shadowFarPlane` 256; from y 600, the ground ~535 blocks away with no planes set;
|
||||
/// and both with the planes at 200 and 210. Only the box's sides cull.
|
||||
#[test]
|
||||
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
|
||||
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
|
||||
let shadow = horizon_shaders::Shadow {
|
||||
distance: 128.0,
|
||||
near,
|
||||
far,
|
||||
..horizon_shaders::Shadow::default()
|
||||
};
|
||||
let camera = DVec3::new(13.5, y + 1.62, -13.5);
|
||||
let view = pack_post::ShadowView {
|
||||
cull: pack_uniforms::shadow_culling_at(Vec3::Y, camera, &shadow),
|
||||
light: Vec3::Y,
|
||||
distance: None,
|
||||
unculled: None,
|
||||
every_face: true,
|
||||
view_casters: false,
|
||||
depth_planes: depth,
|
||||
};
|
||||
let cull = ShadowCull {
|
||||
light: shadow_box(&view),
|
||||
casters: None,
|
||||
reach: None,
|
||||
};
|
||||
let min = Vec3::from(min);
|
||||
cull.intersects(min, min + 16.0)
|
||||
};
|
||||
// The section holding the surface (y 64..80) above the cave, and the ground under y 600.
|
||||
let above = [-8.0, 64.0 - (-7.0 + 1.62), -8.0];
|
||||
let below = [-8.0, 64.0 - (600.0 + 1.62), -8.0];
|
||||
assert!(kept(Some(0.05), Some(256.0), -7.0, above, false));
|
||||
assert!(kept(None, None, 600.0, below, false));
|
||||
assert!(kept(Some(200.0), Some(210.0), -7.0, above, false));
|
||||
assert!(kept(Some(200.0), Some(210.0), 600.0, below, false));
|
||||
// A section 200 blocks off to the side is still outside the box.
|
||||
let side = [200.0, 0.0, -8.0];
|
||||
assert!(!kept(Some(0.05), Some(256.0), -7.0, side, false));
|
||||
// Horizon's own packs keep culling by their planes.
|
||||
assert!(!kept(Some(0.05), Some(256.0), -7.0, above, true));
|
||||
}
|
||||
|
||||
/// The margin doesn't follow the field of view: from the same camera under a noon sun, our
|
||||
/// probe's Iris 1.11.7 maps keep a section that needs a growth of 1.1224 blocks at a field of
|
||||
/// view of 70 (77° flying) and drop ones that need 1.1252 to 1.1422 at 50 (55° flying) and
|
||||
|
||||
@@ -368,6 +368,7 @@ impl NativeFrame {
|
||||
unculled: None,
|
||||
every_face: false,
|
||||
view_casters: false,
|
||||
depth_planes: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -610,13 +610,17 @@ impl PackAtlas {
|
||||
// A vertex's place in its section goes down to the 32768th of a block below it: read back
|
||||
// with a probe pack of ours, Iris 1.11.7's terrain `gl_Vertex` has that for every position
|
||||
// we read (water tops at 8/9 - 0.001, plants' offsets), on all three axes (#8464). A fit,
|
||||
// not Iris's code. Whole and half blocks, and every 1/16, are on that grid already.
|
||||
// not Iris's code. Whole and half blocks, and every 1/16, are on that grid already. A place
|
||||
// up to 1/32 of a 32768th (one float step below 16) under a grid line is on it: plants whose
|
||||
// random offset is 9/15 of a block read exactly on a half block in Iris, and a 32768th
|
||||
// under it in Horizon (our guess, not tested: Horizon's float sum lands just under the
|
||||
// line) (#8464). Also a fit.
|
||||
let body = format!(
|
||||
" vec4 hz_origin = {origin};\n \
|
||||
uint hz_layer = hz_v_layer & 32767u;\n \
|
||||
{cell}\
|
||||
hz_scale = vec2({:?}, {:?}) * {side};\n \
|
||||
hz_Vertex = vec4(floor(hz_v_pos * 32768.0) / 32768.0 + hz_origin.xyz, 1.0);\n \
|
||||
hz_Vertex = vec4(floor(hz_v_pos * 32768.0 + 0.03125) / 32768.0 + hz_origin.xyz, 1.0);\n \
|
||||
hz_uv_scale = hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w));\n \
|
||||
hz_mid = vec2(0.5);\n \
|
||||
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_uv_scale, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
|
||||
|
||||
@@ -174,7 +174,8 @@ fn flat_draws_map_uvs_onto_a_stitched_sprite() {
|
||||
/// Read back with a probe pack of ours, Iris 1.11.7's terrain `gl_Vertex` (camera on the block
|
||||
/// grid) puts a water top, 8/9 - 0.001 of a block up, at 29094/32768, and plants offset by 13/15
|
||||
/// and 0.1 of a block at 28398/32768 and 3276/32768 (#8464): each 32768th below the position.
|
||||
/// Whole blocks and sixteenths stay where they are.
|
||||
/// Whole blocks and sixteenths stay where they are, and so does a place float rounding put a hair
|
||||
/// below a grid line: Iris reads plants offset by 9/15 exactly on a half block.
|
||||
#[test]
|
||||
fn terrain_vertices_go_down_to_the_32768th_below_them() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
@@ -204,6 +205,15 @@ fn terrain_vertices_go_down_to_the_32768th_below_them() {
|
||||
([0.1, water, 13.0 / 15.0], [3276.0, 29094.0, 28398.0]),
|
||||
([13.0 / 15.0, 0.1, water], [28398.0, 3276.0, 29094.0]),
|
||||
([1.0, 0.5, 15.9375], [32768.0, 16384.0, 522240.0]),
|
||||
// One float step below 1, 0.5 and 9.5.
|
||||
(
|
||||
[
|
||||
1.0 - f32::EPSILON / 2.0,
|
||||
0.5 - f32::EPSILON / 4.0,
|
||||
9.5 - f32::EPSILON * 8.0,
|
||||
],
|
||||
[32768.0, 16384.0, 311296.0],
|
||||
),
|
||||
];
|
||||
let vertices: Vec<_> = cases
|
||||
.iter()
|
||||
|
||||
@@ -113,6 +113,8 @@ enum SamplerKind {
|
||||
NearestRepeat,
|
||||
/// The block atlas: nearest texels, blended between the two nearest levels.
|
||||
Atlas,
|
||||
/// colortex buffers: linear, blended between the two nearest levels.
|
||||
Buffer,
|
||||
Comparison,
|
||||
}
|
||||
|
||||
@@ -140,15 +142,18 @@ impl SamplerKind {
|
||||
(true, false) => Self::Repeat,
|
||||
(false, false) => Self::NearestRepeat,
|
||||
},
|
||||
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
|
||||
Self::Linear
|
||||
}
|
||||
// Iris 1.11.7 blends a buffer's two nearest mipmap levels (our probe packs, #9307:
|
||||
// every colortex read at levels 1.25 and 2.5 in composite and final).
|
||||
Source::Color(_) => Self::Buffer,
|
||||
Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => Self::Linear,
|
||||
}
|
||||
}
|
||||
|
||||
fn binding_type(self) -> wgpu::SamplerBindingType {
|
||||
match self {
|
||||
Self::Linear | Self::Repeat | Self::Atlas => wgpu::SamplerBindingType::Filtering,
|
||||
Self::Linear | Self::Repeat | Self::Atlas | Self::Buffer => {
|
||||
wgpu::SamplerBindingType::Filtering
|
||||
}
|
||||
Self::Nearest | Self::NearestRepeat => wgpu::SamplerBindingType::NonFiltering,
|
||||
Self::Comparison => wgpu::SamplerBindingType::Comparison,
|
||||
}
|
||||
@@ -660,6 +665,8 @@ pub struct PackPost {
|
||||
repeat: wgpu::Sampler,
|
||||
nearest_repeat: wgpu::Sampler,
|
||||
atlas_sampler: wgpu::Sampler,
|
||||
/// colortex buffers' (`SamplerKind::Buffer`).
|
||||
buffer_sampler: wgpu::Sampler,
|
||||
/// None when it would write nothing: colortex0 holds integers and there is no depth to copy
|
||||
/// nor buffer to clear.
|
||||
blit: Option<(wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
|
||||
@@ -1131,6 +1138,11 @@ pub(super) struct ShadowView {
|
||||
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
|
||||
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
|
||||
pub view_casters: bool,
|
||||
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
|
||||
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
|
||||
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
|
||||
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
|
||||
pub depth_planes: bool,
|
||||
}
|
||||
|
||||
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
|
||||
@@ -2978,6 +2990,13 @@ impl PackPost {
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Linear,
|
||||
..Default::default()
|
||||
}),
|
||||
buffer_sampler: device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Shader pack buffers"),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Linear,
|
||||
..Default::default()
|
||||
}),
|
||||
blit,
|
||||
copy,
|
||||
upscale,
|
||||
@@ -3189,6 +3208,7 @@ impl PackPost {
|
||||
SamplerKind::Repeat => &self.repeat,
|
||||
SamplerKind::NearestRepeat => &self.nearest_repeat,
|
||||
SamplerKind::Atlas => &self.atlas_sampler,
|
||||
SamplerKind::Buffer => &self.buffer_sampler,
|
||||
SamplerKind::Comparison => shadow.map_or(&self.shadow.1, |t| &t.comparison),
|
||||
};
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
@@ -4037,6 +4057,7 @@ impl PackPost {
|
||||
},
|
||||
every_face: true,
|
||||
view_casters: true,
|
||||
depth_planes: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4122,6 +4143,14 @@ impl PackPost {
|
||||
}
|
||||
self.setup_pending = true;
|
||||
self.rgb_unset = true;
|
||||
// Free the old targets before making the new ones: a pack with large buffers can't
|
||||
// hold both sets at once on a card it otherwise fits (a window resize ran out of video
|
||||
// memory). The bind groups hold the old views too, and the GPU may still be using
|
||||
// them, so wait for it; a resize is rare.
|
||||
if self.targets.take().is_some() {
|
||||
self.forget_groups();
|
||||
let _ = device.poll(wgpu::PollType::wait_indefinitely());
|
||||
}
|
||||
let sizes: [[u32; 2]; BUFFERS] =
|
||||
std::array::from_fn(|i| self.sizes[i].map_or(size, |s| s.resolve(size)));
|
||||
let full_chain = |size: [u32; 2]| 32 - size[0].max(size[1]).max(1).leading_zeros();
|
||||
@@ -4580,10 +4609,8 @@ impl PackPost {
|
||||
.get_or_insert_with(|| super::pack_center_depth::CenterDepth::new(device))
|
||||
.collect(device);
|
||||
self.uniforms.update(frame, inputs, size);
|
||||
// Until the camera's biome is known, `smooth` waits for the world's values.
|
||||
if !inputs.biome_known {
|
||||
self.custom.restart_smoothing();
|
||||
}
|
||||
// `smooth` starts on the first frame that knows the camera's biome.
|
||||
self.custom.world_frame(inputs.biome_known);
|
||||
self.uniforms.eval_custom(&mut self.custom);
|
||||
if self.targets.is_none() {
|
||||
return;
|
||||
@@ -5679,3 +5706,6 @@ mod unknown_sampler_terrain_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod mipmap_box_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod colortex_mip_blend_tests;
|
||||
@@ -0,0 +1,61 @@
|
||||
//! A buffer's mipmaps are read blended between the two nearest levels: our probe packs (#9307)
|
||||
//! read every colortex at levels 2.5 and 1.25 in composite and final, in Iris 1.11.7, and got the
|
||||
//! two nearest levels mixed.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
/// composite writes 1.0 into the left half of a 4x4 colortex1 and 0 into the right half, so at
|
||||
/// (0.25, 0.5) level 1 reads 1.0 and level 2 (1x1) the mean, 0.5. final shows the read at
|
||||
/// level 1.5 in red and at level 1.25 in green.
|
||||
fn pack() -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
add("composite.vsh", VERTEX);
|
||||
add(
|
||||
"composite.fsh",
|
||||
"#version 330 compatibility\n/*\nconst int colortex1Format = RGBA16F;\n*/\n\
|
||||
/* RENDERTARGETS: 1 */\nlayout(location = 0) out vec4 o;\n\
|
||||
void main() {\n o = vec4(gl_FragCoord.x < 2.0 ? 1.0 : 0.0, 0.0, 0.0, 1.0);\n}\n",
|
||||
);
|
||||
add("final.vsh", VERTEX);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\n/*\nconst bool colortex1MipmapEnabled = true;\n*/\n\
|
||||
uniform sampler2D colortex1;\n\
|
||||
void main() {\n vec2 at = vec2(0.25, 0.5);\n\
|
||||
gl_FragColor = vec4(textureLod(colortex1, at, 1.5).r, textureLod(colortex1, at, 1.25).r, 0.0, 1.0);\n}\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-colortex-mip-blend",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
|
||||
post
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_buffer_blends_its_two_nearest_levels() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack());
|
||||
let pixels = gpu.render(&mut post, [4, 4]);
|
||||
// Level 1.5: 1.0 and 0.5 mixed 1:1, 0.75; level 1.25: mixed 3:1, 0.875.
|
||||
let [r, g, ..] = pixels[0];
|
||||
assert!(
|
||||
r.abs_diff(191) <= 2 && g.abs_diff(223) <= 2,
|
||||
"levels 1.5 and 1.25: got red {r}, green {g}, expected 191 and 223"
|
||||
);
|
||||
}
|
||||
@@ -669,6 +669,31 @@ fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
|
||||
/// `shadow_depth_filtering`'s shadow map read by `reads`: `final`'s code after its declarations
|
||||
/// of shadowtex0 and shadowtex1.
|
||||
fn shadow_depth_reads(nearest: &str, reads: &str) -> Option<Vec<[u8; 4]>> {
|
||||
// The shadow map's depth runs from 0 at its left edge to 1 at its right.
|
||||
let ramp = [
|
||||
[-1., -1., 0.],
|
||||
[1., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[-1., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[-1., 1., 0.],
|
||||
];
|
||||
shadow_depth_map_reads(
|
||||
nearest,
|
||||
reads,
|
||||
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,gl_Vertex.x,1.0);}",
|
||||
ramp,
|
||||
)
|
||||
}
|
||||
|
||||
/// A shadow map drawn by `shadow_vsh` from the two triangles `positions` (the rest keeps its
|
||||
/// clear depth, the far plane), read by `reads` as in `shadow_depth_reads`.
|
||||
fn shadow_depth_map_reads(
|
||||
nearest: &str,
|
||||
reads: &str,
|
||||
shadow_vsh: &str,
|
||||
positions: [[f32; 3]; 6],
|
||||
) -> Option<Vec<[u8; 4]>> {
|
||||
let gpu = Gpu::new()?;
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
@@ -704,11 +729,7 @@ fn shadow_depth_reads(nearest: &str, reads: &str) -> Option<Vec<[u8; 4]>> {
|
||||
uniform sampler2D shadowtex0;\nuniform sampler2D shadowtex1;\n{reads}"
|
||||
),
|
||||
),
|
||||
// The shadow map's depth runs from 0 at its left edge to 1 at its right.
|
||||
(
|
||||
"shadow.vsh".into(),
|
||||
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,gl_Vertex.x,1.0);}".into(),
|
||||
),
|
||||
("shadow.vsh".into(), shadow_vsh.into()),
|
||||
(
|
||||
"shadow.fsh".into(),
|
||||
format!("#version 120\n{settings}void main(){{gl_FragData[0]=vec4(1.0);}}"),
|
||||
@@ -740,14 +761,6 @@ fn shadow_depth_reads(nearest: &str, reads: &str) -> Option<Vec<[u8; 4]>> {
|
||||
inputs,
|
||||
);
|
||||
assert!(post.has_shadow(), "{:?}", post.notes);
|
||||
let positions: [[f32; 3]; 6] = [
|
||||
[-1., -1., 0.],
|
||||
[1., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[-1., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[-1., 1., 0.],
|
||||
];
|
||||
let vertices = gpu
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
@@ -793,6 +806,40 @@ fn shadow_depth_reads_filter_directly_and_with_a_texel_offset() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_depth_filter_weights_are_256ths_as_in_our_iris_readings() {
|
||||
// The map is at the far plane but for texel (8, 8), which is nearer. Reads between texels
|
||||
// (7, 7) and (8, 8) give (8, 8)'s weight: Iris 1.11.7's filtered reads on our NVIDIA GPU
|
||||
// fit weights of 1/256 along each axis, rounded half up, and a corner's product weight
|
||||
// rounded half up to 1/256 (#8464). Red: a read a + 0.25/256 of the way along x only
|
||||
// moves a/256 (77). Green: at (77.25, 77.25)/256 along both, (8, 8) weighs 23/256
|
||||
// (77 × 77 / 256 = 23.16; exact weights would give 23.31).
|
||||
let reads = "void main() {\n\
|
||||
float far = texelFetch(shadowtex0, ivec2(7, 7), 0).r;\n\
|
||||
float near = texelFetch(shadowtex0, ivec2(8, 8), 0).r;\n\
|
||||
float f = 77.25 / 256.0;\n\
|
||||
float along = texture(shadowtex0, vec2(7.5 + f, 8.5) / 16.0).r;\n\
|
||||
float corner = texture(shadowtex0, vec2(7.5 + f, 7.5 + f) / 16.0).r;\n\
|
||||
float x = (far - along) / (far - near) * 256.0;\n\
|
||||
float w = (far - corner) / (far - near) * 256.0;\n\
|
||||
gl_FragColor = vec4(abs(x - 77.0) < 0.01 ? 1.0 : 0.0,\n\
|
||||
abs(w - 23.0) < 0.01 ? 1.0 : 0.0, 0.0, 1.0);\n\
|
||||
}\n";
|
||||
// Texel (8, 8) of the 16² map: x and y from 0 to 1/8 in clip space, depth 0.
|
||||
let bump = [
|
||||
[0., 0., -1.],
|
||||
[0.125, 0., -1.],
|
||||
[0.125, 0.125, -1.],
|
||||
[0., 0., -1.],
|
||||
[0.125, 0.125, -1.],
|
||||
[0., 0.125, -1.],
|
||||
];
|
||||
let vsh = "#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xyz,1.0);}";
|
||||
if let Some(pixels) = shadow_depth_map_reads("", reads, vsh, bump) {
|
||||
all_pixels(&pixels, [255, 255, 0, 255], "shadowtex filter weights");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadowtex_nearest_settings_read_the_nearest_texel() {
|
||||
for (line, expected) in [
|
||||
|
||||
@@ -552,7 +552,12 @@ fn invert_generic(m: Mat4) -> Mat4 {
|
||||
|
||||
/// `shadow_camera`'s view and projection in wgpu's clip space (depth 0..1), for culling.
|
||||
pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
|
||||
let (view, gl) = shadow_camera(shadow_light(frame, shadow), frame.camera, shadow);
|
||||
shadow_culling_at(shadow_light(frame, shadow), frame.camera, shadow)
|
||||
}
|
||||
|
||||
/// `shadow_culling` for the direction toward the light and the camera's position.
|
||||
pub fn shadow_culling_at(light: Vec3, camera: DVec3, shadow: &Shadow) -> Mat4 {
|
||||
let (view, gl) = shadow_camera(light, camera, shadow);
|
||||
// GL's depth -w..w to wgpu's 0..w.
|
||||
let depth = Mat4::from_cols(
|
||||
Vec4::X,
|
||||
|
||||
@@ -93,7 +93,7 @@ pub(crate) fn prepare_with_models(
|
||||
models: &crate::model_library::ParsedModels,
|
||||
) -> Result<PreparedModels> {
|
||||
let facts: std::collections::BTreeMap<String, Value> =
|
||||
serde_json::from_slice(&std::fs::read(root.join("model-states.json"))?)?;
|
||||
horizon_world::json_file::read(&root.join("model-states.json"))?;
|
||||
let blocks: Vec<&str> = facts
|
||||
.keys()
|
||||
.filter(|name| registry.default_state(name).is_some())
|
||||
@@ -158,11 +158,11 @@ pub fn load_visibility(
|
||||
collision::CachedCollisionRegistry,
|
||||
registry::BlockTableFile,
|
||||
};
|
||||
use std::{collections::BTreeMap, fs};
|
||||
use std::collections::BTreeMap;
|
||||
let file = BlockTableFile::open(&root.join("occlusion.nbt"), registry)?;
|
||||
let occlusion = CachedCollisionRegistry::from_table(file.table("occlusion")?)?;
|
||||
let raw: BTreeMap<String, Value> =
|
||||
serde_json::from_slice(&fs::read(root.join("render-definitions.json"))?)?;
|
||||
horizon_world::json_file::read(&root.join("render-definitions.json"))?;
|
||||
let definitions = raw
|
||||
.into_iter()
|
||||
.map(|(name, v)| {
|
||||
|
||||
@@ -9,7 +9,7 @@ pub use crate::settings::Opt;
|
||||
use crate::{
|
||||
gamepad::{self, GlyphStyle},
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
keybinds::{self, CATEGORIES, KeyBinds, MAPPINGS},
|
||||
keybinds::{self, CATEGORIES, KeyBinds, all_mappings},
|
||||
lang::{self, LanguageInfo},
|
||||
settings::{SOUND_SOURCES, Settings},
|
||||
text::{self, Style, Text},
|
||||
@@ -3339,7 +3339,7 @@ impl OptionsUi {
|
||||
row: 0,
|
||||
});
|
||||
let mut mappings: Vec<(&str, &str)> =
|
||||
MAPPINGS.iter().map(|(n, _, c)| (*n, *c)).collect();
|
||||
all_mappings().map(|(n, _, c)| (*n, *c)).collect();
|
||||
let query = self.settings_search.value.to_lowercase();
|
||||
mappings.retain(|(name, _)| {
|
||||
query.is_empty() || lang::get(name).to_lowercase().contains(&query)
|
||||
@@ -3433,7 +3433,7 @@ impl OptionsUi {
|
||||
}
|
||||
let reset_x = scrollbar_x - 50.0 - 10.0;
|
||||
let change_x = reset_x - 5.0 - 75.0;
|
||||
let name_static = MAPPINGS.iter().find(|m| m.0 == name).map_or("", |m| m.0);
|
||||
let name_static = all_mappings().find(|m| m.0 == name).map_or("", |m| m.0);
|
||||
out.push(button(change_x, y, 75.0, message, Act::Key(name_static)));
|
||||
if !conflicts.is_empty() {
|
||||
out.push(Widget {
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn set_source_volumes(volumes: [f32; 11]) {
|
||||
pub fn sound_source(event: &str) -> usize {
|
||||
let e = event.trim_start_matches("minecraft:");
|
||||
let index = |name: &str| SOUND_SOURCES.iter().position(|n| *n == name).unwrap_or(0);
|
||||
const HOSTILE: [&str; 25] = [
|
||||
const HOSTILE: [&str; 34] = [
|
||||
"zombie",
|
||||
"skeleton",
|
||||
"creeper",
|
||||
@@ -44,6 +44,16 @@ pub fn sound_source(event: &str) -> usize {
|
||||
"silverfish",
|
||||
"zombified_piglin",
|
||||
"wither_skeleton",
|
||||
"bogged",
|
||||
"breeze",
|
||||
"endermite",
|
||||
"parched",
|
||||
"warden",
|
||||
"zombie_villager",
|
||||
"vex",
|
||||
"wither",
|
||||
// Monster's common hurt/death (e.g. Giant) and other shared sounds.
|
||||
"hostile",
|
||||
];
|
||||
if e == "block.potent_sulfur.noxious_gas" {
|
||||
// PotentSulfurBlock.animateTick, its only caller, plays it as AMBIENT.
|
||||
@@ -347,7 +357,7 @@ impl Settings {
|
||||
.parent()
|
||||
.map(|dir| crate::mod_list::ModList::load(&crate::logging::shared_dir(dir)))
|
||||
.unwrap_or_default();
|
||||
let settings = match fs::read(path) {
|
||||
let mut settings = match fs::read(path) {
|
||||
Ok(bytes) => {
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
extras.read_old_options(&extras_text, &text);
|
||||
@@ -367,6 +377,9 @@ impl Settings {
|
||||
..Self::default()
|
||||
},
|
||||
};
|
||||
// Horizon's own key bindings live in horizon-extras.txt, not in options.txt.
|
||||
let emote = settings.extras.emote_key.clone();
|
||||
settings.keys.set("key.emote", &emote);
|
||||
settings.describe_for_crashes();
|
||||
// Options.load: the backend asked for at startup, what a restart is measured from.
|
||||
Self {
|
||||
@@ -636,14 +649,17 @@ impl Settings {
|
||||
pub fn with_option(&self, key: &str, value: &str) -> Result<Self, String> {
|
||||
let mut file = self.to_file();
|
||||
file.set_text(key, value)?;
|
||||
Ok(Self {
|
||||
let mut next = Self {
|
||||
horizon: self.horizon.clone(),
|
||||
controller: self.controller.clone(),
|
||||
extras: self.extras.clone(),
|
||||
mods: self.mods.clone(),
|
||||
video_modes: self.video_modes.clone(),
|
||||
..Self::from_file(file)
|
||||
})
|
||||
};
|
||||
// Horizon's own bindings aren't in options.txt.
|
||||
next.keys.set("key.emote", self.keys.get("key.emote"));
|
||||
Ok(next)
|
||||
}
|
||||
/// The `options.txt` text (Options.save).
|
||||
pub fn serialize(&self) -> String {
|
||||
@@ -657,7 +673,9 @@ impl Settings {
|
||||
if let Some(dir) = path.parent() {
|
||||
self.horizon.save(dir);
|
||||
self.controller.save(dir);
|
||||
self.extras.save(dir);
|
||||
let mut extras = self.extras.clone();
|
||||
extras.emote_key = self.keys.get("key.emote").to_owned();
|
||||
extras.save(dir);
|
||||
self.mods.save(&crate::logging::shared_dir(dir));
|
||||
}
|
||||
}
|
||||
@@ -856,6 +874,57 @@ mod tests {
|
||||
horizon_narrator::Status::Off
|
||||
);
|
||||
}
|
||||
/// `Monster.getSoundSource`: the verified families and inherited common sounds.
|
||||
#[test]
|
||||
fn hostile_families_keep_their_sound_category() {
|
||||
for (event, source) in [
|
||||
("entity.zombie.hurt", 5),
|
||||
("entity.cow.hurt", 6),
|
||||
("block.stone.break", 4),
|
||||
("entity.player.hurt", 7),
|
||||
("entity.arrow.hit", 7),
|
||||
("music_disc.13", 2),
|
||||
("item.goat_horn.sound.0", 2),
|
||||
("weather.rain", 3),
|
||||
("ambient.cave", 8),
|
||||
] {
|
||||
assert_eq!(sound_source(event), source, "{event}");
|
||||
}
|
||||
// Explicit sources still take precedence, including H331's arrow route.
|
||||
let mut audio = crate::audio::Audio::silent(Path::new("/nonexistent/h068-audio"));
|
||||
audio.capture = Some(Vec::new());
|
||||
audio.play_on(6, "minecraft:entity.arrow.hit", Some([1.0; 3]), 1.0, 1.0);
|
||||
audio.play_on(4, "minecraft:entity.warden.hurt", Some([1.0; 3]), 1.0, 1.0);
|
||||
assert_eq!(
|
||||
audio
|
||||
.capture
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|s| s.source)
|
||||
.collect::<Vec<_>>(),
|
||||
[6, 4]
|
||||
);
|
||||
for family in [
|
||||
"bogged",
|
||||
"breeze",
|
||||
"endermite",
|
||||
"parched",
|
||||
"warden",
|
||||
"zombie_villager",
|
||||
"vex",
|
||||
"wither",
|
||||
"hostile",
|
||||
] {
|
||||
for suffix in ["hurt", "death"] {
|
||||
for prefix in ["", "minecraft:"] {
|
||||
let event = format!("{prefix}entity.{family}.{suffix}");
|
||||
assert_eq!(sound_source(&event), 5, "{event}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_d_skin_layers_move_from_options_txt_to_the_extras_file() {
|
||||
// #8091 wrote the switch into options.txt; a player who had it on keeps
|
||||
@@ -881,6 +950,119 @@ mod tests {
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-{name}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_txt_lists_only_vanillas_key_mappings() {
|
||||
// Options.keyMappings has the friends key, then social interactions; Horizon's emote key
|
||||
// (#10092 put it between them) is not in the file.
|
||||
let out = Settings::default().serialize();
|
||||
assert!(!out.contains("emote"), "{out}");
|
||||
assert!(out.contains(
|
||||
"\nkey_key.friends:key.keyboard.o\nkey_key.socialInteractions:key.keyboard.p\n"
|
||||
));
|
||||
let mut s = Settings::default();
|
||||
s.keys.set("key.emote", "key.keyboard.g");
|
||||
assert!(!s.serialize().contains("emote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_emote_key_is_kept_in_the_extras_file() {
|
||||
let dir = scratch("emote-key");
|
||||
let options = dir.join("options.txt");
|
||||
// Untouched: no extras file is made for it.
|
||||
Settings::default().save(&options);
|
||||
assert!(!dir.join(crate::extras::FILE).exists());
|
||||
assert_eq!(
|
||||
Settings::load(&options).keys.get("key.emote"),
|
||||
"key.keyboard.b"
|
||||
);
|
||||
// Rebound in Controls: saved to horizon-extras.txt, and options.txt stays vanilla's.
|
||||
let mut s = Settings::load(&options);
|
||||
s.keys.set("key.emote", "key.keyboard.g");
|
||||
s.save(&options);
|
||||
assert!(!fs::read_to_string(&options).unwrap().contains("emote"));
|
||||
let extras = fs::read_to_string(dir.join(crate::extras::FILE)).unwrap();
|
||||
assert!(
|
||||
extras.lines().any(|l| l == "emoteKey:key.keyboard.g"),
|
||||
"{extras}"
|
||||
);
|
||||
let back = Settings::load(&options);
|
||||
assert_eq!(back.keys.get("key.emote"), "key.keyboard.g");
|
||||
// horizon-ctl options-set rebuilds from options.txt text and keeps it.
|
||||
assert_eq!(
|
||||
back.with_option("fov", "90").unwrap().keys.get("key.emote"),
|
||||
"key.keyboard.g"
|
||||
);
|
||||
// Reset to B and back to the default: the file says so.
|
||||
let mut s = back;
|
||||
s.keys.set(
|
||||
"key.emote",
|
||||
crate::keybinds::KeyBinds::default_of("key.emote"),
|
||||
);
|
||||
s.save(&options);
|
||||
assert_eq!(
|
||||
Settings::load(&options).keys.get("key.emote"),
|
||||
"key.keyboard.b"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_emote_key_moves_from_options_txt_to_the_extras_file() {
|
||||
// #10092 wrote `key_key.emote` into options.txt; a rebound key is kept, the next
|
||||
// options.txt is vanilla's again.
|
||||
let dir = scratch("emote-key-move");
|
||||
let options = dir.join("options.txt");
|
||||
fs::write(
|
||||
&options,
|
||||
"version:4000\nfov:0.5\nkey_key.emote:key.keyboard.h\nkey_key.jump:key.keyboard.j\n",
|
||||
)
|
||||
.unwrap();
|
||||
let s = Settings::load(&options);
|
||||
assert_eq!(s.keys.get("key.emote"), "key.keyboard.h");
|
||||
assert_eq!(s.keys.get("key.jump"), "key.keyboard.j");
|
||||
s.save(&options);
|
||||
assert!(!fs::read_to_string(&options).unwrap().contains("emote"));
|
||||
assert_eq!(
|
||||
Settings::load(&options).keys.get("key.emote"),
|
||||
"key.keyboard.h"
|
||||
);
|
||||
// Once horizon-extras.txt has its own line, a stale options.txt one is ignored.
|
||||
fs::write(dir.join(crate::extras::FILE), "emoteKey:key.keyboard.k\n").unwrap();
|
||||
fs::write(&options, "version:4000\nkey_key.emote:key.keyboard.h\n").unwrap();
|
||||
assert_eq!(
|
||||
Settings::load(&options).keys.get("key.emote"),
|
||||
"key.keyboard.k"
|
||||
);
|
||||
// A value that is no key is not adopted; an unbound emote key is.
|
||||
fs::remove_file(dir.join(crate::extras::FILE)).unwrap();
|
||||
fs::write(&options, "version:4000\nkey_key.emote:banana\n").unwrap();
|
||||
assert_eq!(
|
||||
Settings::load(&options).keys.get("key.emote"),
|
||||
"key.keyboard.b"
|
||||
);
|
||||
fs::write(
|
||||
&options,
|
||||
"version:4000\nkey_key.emote:key.keyboard.unknown\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
Settings::load(&options).keys.get("key.emote"),
|
||||
"key.keyboard.unknown"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_option_set_keeps_horizon_files() {
|
||||
let mut s = Settings::parse("fov:70\n");
|
||||
|
||||
@@ -22,6 +22,11 @@ thread_local! {
|
||||
static NOTES: RefCell<Vec<(f32, String)>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// The most spans a thread holds before they are taken ([`take_raw`]): a worker thread
|
||||
/// that records spans (`lod_reduce`, `lt_*`) while they are on for every thread is never
|
||||
/// taken from, so it drops what it held instead of growing for as long as they stay on.
|
||||
const UNTAKEN_MAX: usize = 1 << 14;
|
||||
|
||||
/// The shortest step a [`note`] keeps, in milliseconds.
|
||||
pub const NOTE_MS: f32 = 10.0;
|
||||
/// The notes a thread keeps between two [`take_notes`]: its slowest steps.
|
||||
@@ -53,7 +58,7 @@ pub fn record_here(on: bool) {
|
||||
/// Whether this thread records spans: all of them on, or [`record_here`].
|
||||
#[inline]
|
||||
pub fn recording_here() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get)
|
||||
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get) || trace::on()
|
||||
}
|
||||
/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): only they
|
||||
/// print it.
|
||||
@@ -101,16 +106,41 @@ impl Drop for Guard {
|
||||
.and_then(|start| Some((thread_cpu()?, start)))
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
DEPTH.with(|d| d.set(depth));
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
trace::span(name, started, ms);
|
||||
push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Keeps a finished span for the thread's next [`take_raw`].
|
||||
fn push(span: Span) {
|
||||
SPANS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
if s.len() >= UNTAKEN_MAX {
|
||||
s.clear();
|
||||
}
|
||||
s.push(span);
|
||||
});
|
||||
}
|
||||
/// Attach per-tick counts without interpreting them as durations.
|
||||
pub fn counters(counts: &[(&str, u64)]) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
NOTES.with(|n| {
|
||||
n.borrow_mut().push((
|
||||
0.0,
|
||||
counts
|
||||
.iter()
|
||||
.map(|(name, count)| format!("{name}={count}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
))
|
||||
});
|
||||
}
|
||||
/// Notes that one step inside a span took `ms` milliseconds doing `what` (one player's
|
||||
/// packets, say), when this thread records spans and the step took at least [`NOTE_MS`]: a
|
||||
/// span says where a slow tick went, its notes whose doing it was. Only the slowest few are
|
||||
@@ -163,13 +193,12 @@ impl Laps {
|
||||
.zip(*cpu)
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
let depth = DEPTH.with(Cell::get);
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
trace::span(name, *started, ms);
|
||||
push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
});
|
||||
(*started, *cpu) = (now, now_cpu);
|
||||
}
|
||||
@@ -271,6 +300,159 @@ pub fn describe(spans: &[Span]) -> String {
|
||||
list.join(", ")
|
||||
}
|
||||
|
||||
/// The run's timeline: every span ([`time`], [`guard`], [`Laps::lap`]) on every thread, as
|
||||
/// Chrome trace events that Perfetto (ui.perfetto.dev) opens as they are. `HORIZON_TRACE=FILE`
|
||||
/// writes it from the start; [`start`](trace::start) and [`stop`](trace::stop) write one for a
|
||||
/// while (`horizon-trace` on a dedicated server's console, `trace` on the control socket). Spans
|
||||
/// show on their threads where they ran, nested by time, so what ran beside what, and what a
|
||||
/// thread waited for, can be read off. Off, a span costs one relaxed load more.
|
||||
pub mod trace {
|
||||
use std::{
|
||||
cell::Cell,
|
||||
fs::File,
|
||||
io::{BufWriter, Write as _},
|
||||
path::Path,
|
||||
sync::{
|
||||
Mutex, PoisonError,
|
||||
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
static ON: AtomicBool = AtomicBool::new(false);
|
||||
static SINK: Mutex<Option<Sink>> = Mutex::new(None);
|
||||
/// Counts the traces started: a thread names itself again in each one.
|
||||
static TRACES: AtomicU32 = AtomicU32::new(0);
|
||||
static NEXT_THREAD: AtomicU32 = AtomicU32::new(1);
|
||||
thread_local! {
|
||||
/// This thread's id in traces (0: none yet).
|
||||
static THREAD: Cell<u32> = const { Cell::new(0) };
|
||||
/// The trace this thread last named itself in.
|
||||
static NAMED_IN: Cell<u32> = const { Cell::new(0) };
|
||||
}
|
||||
/// How often a trace's buffer goes to the file, at most: a run that ends without
|
||||
/// [`stop`] loses no more than this.
|
||||
const FLUSH_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
struct Sink {
|
||||
file: BufWriter<File>,
|
||||
origin: Instant,
|
||||
/// Until when it records ([`start`]'s `seconds`).
|
||||
until: Option<Instant>,
|
||||
flushed: Instant,
|
||||
/// Events written: the next one is preceded by a comma.
|
||||
events: u64,
|
||||
}
|
||||
impl Sink {
|
||||
fn event(&mut self, event: std::fmt::Arguments) {
|
||||
let comma = if self.events == 0 { "" } else { ",\n" };
|
||||
let _ = write!(self.file, "{comma}{{\"pid\":1,{event}}}");
|
||||
self.events += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a trace is being written.
|
||||
#[inline]
|
||||
pub fn on() -> bool {
|
||||
ON.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Starts a trace written to `path` (replacing a trace being written), for `seconds` or
|
||||
/// until [`stop`].
|
||||
pub fn start(path: &Path, seconds: Option<u32>) -> std::io::Result<()> {
|
||||
let mut file = BufWriter::with_capacity(1 << 18, File::create(path)?);
|
||||
file.write_all(b"[\n")?;
|
||||
let now = Instant::now();
|
||||
let sink = Sink {
|
||||
file,
|
||||
origin: now,
|
||||
until: seconds.map(|s| now + std::time::Duration::from_secs(u64::from(s))),
|
||||
flushed: now,
|
||||
events: 0,
|
||||
};
|
||||
let mut slot = SINK.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(old) = slot.take() {
|
||||
close(old);
|
||||
}
|
||||
*slot = Some(sink);
|
||||
TRACES.fetch_add(1, Ordering::Relaxed);
|
||||
ON.store(true, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Starts the trace `HORIZON_TRACE` names, if it names one.
|
||||
pub fn start_from_env() {
|
||||
if let Some(path) = std::env::var_os("HORIZON_TRACE")
|
||||
&& let Err(e) = start(Path::new(&path), None)
|
||||
{
|
||||
log::warn!("Horizon: HORIZON_TRACE: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends the trace being written: the file is complete JSON. The events written so far
|
||||
/// and whether one was being written.
|
||||
pub fn stop() -> Option<u64> {
|
||||
ON.store(false, Ordering::Relaxed);
|
||||
let sink = SINK.lock().unwrap_or_else(PoisonError::into_inner).take()?;
|
||||
let events = sink.events;
|
||||
close(sink);
|
||||
Some(events)
|
||||
}
|
||||
|
||||
/// Whether the trace being written has run its time ([`start`]'s `seconds`): the caller
|
||||
/// then [`stop`]s it.
|
||||
pub fn due() -> bool {
|
||||
on() && SINK
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.as_ref()
|
||||
.and_then(|s| s.until)
|
||||
.is_some_and(|until| Instant::now() >= until)
|
||||
}
|
||||
|
||||
fn close(mut sink: Sink) {
|
||||
let _ = sink.file.write_all(b"\n]\n");
|
||||
let _ = sink.file.flush();
|
||||
}
|
||||
|
||||
/// A span of this thread from `started`, `ms` long.
|
||||
pub(super) fn span(name: &'static str, started: Instant, ms: f32) {
|
||||
if !on() {
|
||||
return;
|
||||
}
|
||||
let tid = THREAD.with(|t| {
|
||||
if t.get() == 0 {
|
||||
t.set(NEXT_THREAD.fetch_add(1, Ordering::Relaxed));
|
||||
}
|
||||
t.get()
|
||||
});
|
||||
let trace = TRACES.load(Ordering::Relaxed);
|
||||
let mut slot = SINK.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let Some(sink) = slot.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if NAMED_IN.with(|n| n.replace(trace)) != trace {
|
||||
let name = std::thread::current()
|
||||
.name()
|
||||
.unwrap_or("unnamed")
|
||||
.replace(['"', '\\'], "_");
|
||||
sink.event(format_args!(
|
||||
r#""ph":"M","name":"thread_name","tid":{tid},"args":{{"name":"{name}"}}"#
|
||||
));
|
||||
}
|
||||
let ts = started.saturating_duration_since(sink.origin).as_secs_f64() * 1e6;
|
||||
sink.event(format_args!(
|
||||
r#""ph":"X","name":"{name}","tid":{tid},"ts":{ts:.3},"dur":{:.3}"#,
|
||||
f64::from(ms) * 1e3
|
||||
));
|
||||
let now = Instant::now();
|
||||
if now.duration_since(sink.flushed) >= FLUSH_EVERY {
|
||||
let _ = sink.file.flush();
|
||||
sink.flushed = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The start's timeline: `HORIZON_STARTUP_TRACE=FILE` writes every named step of
|
||||
/// the start (resource tables, the renderer, the title, a world's open, a
|
||||
/// server's join, a dimension change) on whichever thread ran it, one Chrome
|
||||
|
||||
@@ -254,7 +254,11 @@ fn normalize_end(data: &mut [u8]) {
|
||||
}
|
||||
let disk = u16_at(data, end + 4);
|
||||
let with_directory = u16_at(data, end + 6);
|
||||
if disk == with_directory && u16_at(data, end + 8) == count && u16_at(data, end + 10) == count {
|
||||
if disk == 0
|
||||
&& with_directory == 0
|
||||
&& u16_at(data, end + 8) == count
|
||||
&& u16_at(data, end + 10) == count
|
||||
{
|
||||
return;
|
||||
}
|
||||
data[end + 4..end + 10].fill(0);
|
||||
@@ -311,6 +315,14 @@ mod tests {
|
||||
assert_eq!(zip.read("a/b.txt").unwrap(), b"hello hello hello hello");
|
||||
}
|
||||
#[test]
|
||||
fn a_file_is_opened_with_equal_nonzero_disk_numbers() {
|
||||
let mut bytes = archive();
|
||||
let end = bytes.len() - END_HEADER;
|
||||
bytes[end + 4..end + 8].fill(0xFF);
|
||||
let zip = opened("end-disks", &bytes).unwrap();
|
||||
assert_eq!(zip.read("a/b.txt").unwrap(), b"hello hello hello hello");
|
||||
}
|
||||
#[test]
|
||||
fn a_file_that_is_no_archive_does_not_open() {
|
||||
assert!(opened("empty", b"").is_err());
|
||||
assert!(opened("garbage", b"PK not really").is_err());
|
||||
|
||||
@@ -106,10 +106,6 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
# DirectX 12 driver (`graphicsApi`, graphics.rs).
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
# The icon resource in the .exe (build.rs).
|
||||
winresource = "0.1"
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
@@ -152,6 +148,8 @@ path = "examples/native_gui_overlay_probe.rs"
|
||||
|
||||
[build-dependencies]
|
||||
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
|
||||
# The build script runs on the host, but embeds resources based on the target OS.
|
||||
winresource = "0.1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -9,18 +9,17 @@ fn main() {
|
||||
.expect("generating native mod API");
|
||||
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
|
||||
let target_os =
|
||||
std::env::var("CARGO_CFG_TARGET_OS").expect("Cargo must provide the build target OS");
|
||||
println!("cargo:rerun-if-changed={ico}");
|
||||
// The game's main thread gets 8 MB of stack on Windows as on macOS and Linux (Windows gives 1
|
||||
// MB): splitscreen builds a joining player's `Game`, ~67 KB, deep in the event loop.
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
if target_os == "windows" {
|
||||
let arg = match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() {
|
||||
Ok("msvc") => "/STACK:8388608",
|
||||
_ => "-Wl,--stack,8388608",
|
||||
};
|
||||
println!("cargo:rustc-link-arg-bin=horizon-client={arg}");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
let mut resource = winresource::WindowsResource::new();
|
||||
resource.set_icon(&ico);
|
||||
if std::env::var_os("CARGO_FEATURE_WINDOWS_SEGMENT_HEAP").is_some() {
|
||||
@@ -30,8 +29,9 @@ fn main() {
|
||||
}
|
||||
if let Err(e) = resource.compile() {
|
||||
// The GNU toolchain's `windres` (the launcher's mod builds use it) can't take a path
|
||||
// with a space, and a Windows user name often has one: such a build goes on without
|
||||
// the icon. Release builds run in CI, where the icon stays required.
|
||||
// with a space, and a Windows user name often has one; a cross build may have no
|
||||
// `windres` at all: such a build goes on without the icon. Release builds run in CI,
|
||||
// where the icon stays required.
|
||||
if std::env::var_os("CI").is_some() {
|
||||
panic!("embedding Windows resources: {e}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Command-tag data carried to a converted mob's new entity ID.
|
||||
use horizon_client::entity_nbt;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::io::Write;
|
||||
|
||||
pub fn main() {
|
||||
let args: Vec<_> = super::args().collect();
|
||||
let input = std::fs::read_to_string(&args[1]).unwrap();
|
||||
let mut out = std::fs::File::create(&args[2]).unwrap();
|
||||
for line in input.lines() {
|
||||
let values: Vec<String> = serde_json::from_str(line).unwrap();
|
||||
let raw = Tag::compound([
|
||||
(
|
||||
"Tags",
|
||||
Tag::List(
|
||||
values
|
||||
.iter()
|
||||
.map(|s| Tag::String(s.as_str().into()))
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
("UUID", Tag::IntArray(vec![1, 2, 3, 4])),
|
||||
("id", Tag::String("minecraft:husk".into())),
|
||||
]);
|
||||
let converted = entity_nbt::conversion_tags(Some(&raw))
|
||||
.unwrap_or_else(|| Tag::compound([("Tags", Tag::List(Vec::new()))]));
|
||||
assert!(converted.get("UUID").is_none() && converted.get("id").is_none());
|
||||
let Tag::List(tags) = converted.get("Tags").unwrap() else {
|
||||
panic!("missing tags")
|
||||
};
|
||||
let mut encoded: Vec<String> = tags
|
||||
.iter()
|
||||
.map(|tag| {
|
||||
let Tag::String(tag) = tag else {
|
||||
panic!("not a string")
|
||||
};
|
||||
tag.0.iter().map(|unit| format!("{unit:04x}")).collect()
|
||||
})
|
||||
.collect();
|
||||
encoded.sort();
|
||||
writeln!(out, "{}", serde_json::to_string(&encoded).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ mod cloud_fixture;
|
||||
mod command_parse_fixture;
|
||||
mod container_screen_probe;
|
||||
mod container_title_fixture;
|
||||
mod conversion_tags_fixture;
|
||||
mod creative_mining_probe;
|
||||
mod creative_search_fixture;
|
||||
mod cushion_placement_probe;
|
||||
@@ -98,6 +99,7 @@ mod sound_event_fixture;
|
||||
mod spawn_rules_fixture;
|
||||
mod sprint_probe;
|
||||
mod squid_client_probe;
|
||||
mod stack_foil_fixture;
|
||||
mod storage_menu_fixture;
|
||||
mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
@@ -139,6 +141,7 @@ probes! {
|
||||
command_parse_fixture,
|
||||
container_screen_probe,
|
||||
container_title_fixture,
|
||||
conversion_tags_fixture,
|
||||
creative_search_fixture,
|
||||
emitter_particles_probe,
|
||||
enchanting_fixture,
|
||||
@@ -214,6 +217,7 @@ probes! {
|
||||
trading_fixture,
|
||||
trident_probe,
|
||||
translucency_fixture,
|
||||
stack_foil_fixture,
|
||||
vehicle_container_fixture,
|
||||
weather_fixture,
|
||||
world_border_render_fixture,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Actual stack decoding -> render view -> item-model foil selection, without a GPU.
|
||||
use horizon_assets::item_model::{
|
||||
ClientItem, Context, DisplayContext, FoilType, ItemModels, LayerContent, NoEnv,
|
||||
};
|
||||
use horizon_client::{item_data::decode_components, item_render::stack_view, items::ItemStack};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, fs};
|
||||
|
||||
fn model_flags(input: &Value) -> horizon_client::Result<Value> {
|
||||
let text = input["nbt"].as_str().ok_or("missing NBT text")?;
|
||||
let (tag, used) = horizon_world::nbt::parse_snbt(text)?;
|
||||
if used != text.len() {
|
||||
return Err("trailing NBT".into());
|
||||
}
|
||||
let mut stack = ItemStack::new("minecraft:diamond", 1);
|
||||
(stack.damage, stack.extra) =
|
||||
decode_components(Some(&Tag::compound([("minecraft:custom_model_data", tag)])));
|
||||
let view = stack_view(&stack);
|
||||
let mut models = ItemModels::default();
|
||||
models.prototypes.insert(
|
||||
"minecraft:diamond".into(),
|
||||
BTreeMap::from([("minecraft:item_model".into(), json!("minecraft:diamond"))]),
|
||||
);
|
||||
models.definitions.insert("minecraft:diamond".into(), ClientItem::decode(&json!({
|
||||
"model": {"type": "minecraft:condition", "property": "minecraft:custom_model_data", "index": input["index"],
|
||||
"on_true": {"type": "minecraft:model", "model": "horizon:item/yes"},
|
||||
"on_false": {"type": "minecraft:model", "model": "horizon:item/no"}}
|
||||
}))?);
|
||||
let resolved = models.resolve(
|
||||
&view,
|
||||
&Context {
|
||||
display: DisplayContext::Gui,
|
||||
owner: None,
|
||||
level: None,
|
||||
seed: 0,
|
||||
},
|
||||
&NoEnv,
|
||||
);
|
||||
if resolved.layers.len() != 1 {
|
||||
return Err("expected one conditional model layer".into());
|
||||
}
|
||||
let LayerContent::Model { model } = &resolved.layers[0].content else {
|
||||
return Err("expected model content".into());
|
||||
};
|
||||
Ok(
|
||||
json!({"id": input["id"], "component": view.patch["minecraft:custom_model_data"], "selected": model}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let mut models = ItemModels::default();
|
||||
models.definitions.insert(
|
||||
"horizon:fixture".into(),
|
||||
ClientItem::from_json(
|
||||
r#"{"model":{"type":"minecraft:model","model":"horizon:item/fixture"}}"#,
|
||||
)?,
|
||||
);
|
||||
let mut output = String::new();
|
||||
for line in fs::read_to_string(args.get(1).ok_or("missing INPUT")?)?.lines() {
|
||||
let input: Value = serde_json::from_str(line)?;
|
||||
if input["kind"] == "flags" {
|
||||
output.push_str(&serde_json::to_string(&model_flags(&input)?)?);
|
||||
output.push('\n');
|
||||
continue;
|
||||
}
|
||||
let item = input["item"].as_str().ok_or("missing item")?;
|
||||
models.prototypes.insert(
|
||||
item.into(),
|
||||
BTreeMap::from([
|
||||
("minecraft:item_model".into(), json!("horizon:fixture")),
|
||||
(
|
||||
"minecraft:enchantment_glint_override".into(),
|
||||
input["default_glint"].clone(),
|
||||
),
|
||||
]),
|
||||
);
|
||||
let mut stack = ItemStack::new(item, 1);
|
||||
if let Some(nbt) = input.get("nbt") {
|
||||
let text = nbt.as_str().ok_or("missing NBT text")?;
|
||||
let (tag, used) = horizon_world::nbt::parse_snbt(text)?;
|
||||
if used != text.len() {
|
||||
return Err("trailing NBT".into());
|
||||
}
|
||||
(stack.damage, stack.extra) = decode_components(Some(&Tag::compound([(
|
||||
"minecraft:enchantment_glint_override",
|
||||
tag,
|
||||
)])));
|
||||
}
|
||||
let view = stack_view(&stack);
|
||||
let resolved = models.resolve(
|
||||
&view,
|
||||
&Context {
|
||||
display: DisplayContext::Gui,
|
||||
owner: None,
|
||||
level: None,
|
||||
seed: 0,
|
||||
},
|
||||
&NoEnv,
|
||||
);
|
||||
if resolved.layers.len() != 1 {
|
||||
return Err("expected one fixture model layer".into());
|
||||
}
|
||||
output.push_str(&serde_json::to_string(&json!({
|
||||
"id": input["id"],
|
||||
"component": view.patch.get("minecraft:enchantment_glint_override").and_then(Option::as_ref),
|
||||
"foil": resolved.layers[0].foil != FoilType::None,
|
||||
}))?);
|
||||
output.push('\n');
|
||||
}
|
||||
fs::write(args.get(2).ok_or("missing OUTPUT")?, output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -584,6 +584,9 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset] [raw]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms)")], mcp: true, local: false),
|
||||
cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false),
|
||||
cmd!("trace", Scripting, "trace [FILE [SECONDS] | stop]", "write every thread's timed spans to FILE as a timeline that ui.perfetto.dev opens, for SECONDS or until stop",
|
||||
"Each span (ticks by system, streaming, uploads, far terrain, lighting, the renderer) shows on the thread that ran it, nested by time. Without arguments, only reports whether a trace is being written. HORIZON_TRACE=FILE writes one from the start; horizon-trace on a dedicated server's console writes one to reports/. See docs/graphics.md.",
|
||||
["horizon-ctl trace /tmp/frame.json 10", "horizon-ctl trace stop"], &[], mcp: false, local: false),
|
||||
cmd!("render-capture", Scripting, "render-capture on|off|DIR", "keep CPU copies of the meshes the renderer draws (on), or write the next frame's scene to DIR for the scene_replay example",
|
||||
"on meshes every section and far tile again so the copies are complete (HORIZON_RENDER_CAPTURE=1 keeps them from the start). DIR gets the camera, frame inputs, settings and every resident near section and far tile mesh; entities, particles, weather, the hand and the HUD are left out. See docs/graphics.md.",
|
||||
["horizon-ctl render-capture on", "horizon-ctl render-capture /tmp/scene"], &[], mcp: false, local: false),
|
||||
|
||||
@@ -1721,6 +1721,8 @@ impl ItemFrame {
|
||||
/// An armor stand (`ArmorStand`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArmorStand {
|
||||
/// `Entity.firstTick`: command equipment events wait for an actual entity tick.
|
||||
pub first_tick: bool,
|
||||
/// `Entity.xRot`: kept for `/tp` and `Rotation`; the kind turns only in yaw.
|
||||
pub pitch: f32,
|
||||
/// When it joined its list ([`crate::join_seq`]); set by the list, not by the spawn site.
|
||||
@@ -1767,7 +1769,8 @@ pub struct ArmorStand {
|
||||
pub disabled_slots: i32,
|
||||
/// Server-only `LivingEntity.lastPos` for location-based enchantments.
|
||||
pub enchantment_block: Option<BlockPos>,
|
||||
/// Travel accumulated for `LivingEntity.checkFallDamage`'s landing callback.
|
||||
/// `Entity.fallDistance`, also used by `LivingEntity.checkFallDamage`'s landing callback.
|
||||
/// Each downward movement is narrowed to float before adding to this double.
|
||||
pub enchantment_fall_distance: f64,
|
||||
/// Last equipment seen by `collectEquipmentChanges`; not saved to NBT.
|
||||
pub enchantment_equipment: [Option<ItemStack>; 6],
|
||||
@@ -1923,6 +1926,7 @@ impl ArmorStand {
|
||||
}
|
||||
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
first_tick: true,
|
||||
pitch: 0.0,
|
||||
join: Default::default(),
|
||||
fire_ticks: 0,
|
||||
|
||||
@@ -22,6 +22,16 @@ use horizon_world::{
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// Saved data carried by `ConversionType.convertCommon` outside the modelled
|
||||
/// mob fields. The converted entity has its own identity and type-specific data;
|
||||
/// only its command tags are inherited from the old entity's raw compound.
|
||||
pub fn conversion_tags(raw: Option<&Tag>) -> Option<Tag> {
|
||||
let Tag::List(tags) = raw?.get("Tags")? else {
|
||||
return None;
|
||||
};
|
||||
(!tags.is_empty()).then(|| Tag::compound([("Tags", Tag::List(tags.clone()))]))
|
||||
}
|
||||
|
||||
fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
@@ -366,6 +376,8 @@ pub fn mob_to_nbt(
|
||||
"DeathTime",
|
||||
Tag::Short(m.death_time.clamp(0, i32::from(i16::MAX)) as i16),
|
||||
),
|
||||
// Mob.addAdditionalSaveData writes the current virtual getter, not raw input.
|
||||
("CanPickUpLoot", Tag::Byte(i8::from(m.can_pick_up_loot()))),
|
||||
("PersistenceRequired", Tag::Byte(i8::from(m.persistent))),
|
||||
] {
|
||||
put(&mut tag, key, value);
|
||||
@@ -527,7 +539,6 @@ pub fn mob_to_nbt(
|
||||
for (key, value) in [
|
||||
("AbsorptionAmount", Tag::Float(0)),
|
||||
("FallFlying", Tag::Byte(0)),
|
||||
("CanPickUpLoot", Tag::Byte(i8::from(m.can_pick_up_loot()))),
|
||||
("current_impulse_context_reset_grace_time", Tag::Int(0)),
|
||||
(
|
||||
"Brain",
|
||||
@@ -3242,7 +3253,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
motion: a.velocity,
|
||||
rotation: [a.yaw, a.pitch],
|
||||
on_ground: a.on_ground,
|
||||
fall_distance: 0.0,
|
||||
fall_distance: a.enchantment_fall_distance,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
let equipment: Vec<(&str, Tag)> = ArmorStand::SLOTS
|
||||
@@ -3341,6 +3352,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
const SPEED_ATTRIBUTE: &str = "minecraft:movement_speed";
|
||||
pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
let mut a = ArmorStand::new(id, position(tag)?, rotation(tag)[0]);
|
||||
a.enchantment_fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
// AttributeMap.load makes an instance for each saved attribute.
|
||||
a.speed_instance = matches!(tag.get("attributes"), Some(Tag::List(list))
|
||||
if list.iter().any(|attr| text(attr.get("id")).as_deref() == Some(SPEED_ATTRIBUTE)));
|
||||
@@ -3671,6 +3683,35 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::items::ItemStack;
|
||||
|
||||
#[test]
|
||||
fn conversion_keeps_only_the_old_mobs_command_tags() {
|
||||
let tags = Tag::List(vec![
|
||||
Tag::String("subject".into()),
|
||||
Tag::String("quest".into()),
|
||||
]);
|
||||
let raw = Tag::compound([
|
||||
("id", Tag::String("minecraft:husk".into())),
|
||||
("UUID", Tag::IntArray(vec![1, 2, 3, 4])),
|
||||
("HuskConversionTime", Tag::Int(1)),
|
||||
("Tags", tags.clone()),
|
||||
]);
|
||||
let original = raw.clone();
|
||||
assert_eq!(
|
||||
conversion_tags(Some(&raw)),
|
||||
Some(Tag::compound([("Tags", tags)]))
|
||||
);
|
||||
assert_eq!(raw, original);
|
||||
assert_eq!(conversion_tags(None), None);
|
||||
assert_eq!(
|
||||
conversion_tags(Some(&Tag::compound([("Tags", Tag::List(Vec::new()))]))),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
conversion_tags(Some(&Tag::Compound(Default::default()))),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubes_omit_supplied_age_and_pigs_keep_modeled_age() {
|
||||
use crate::mobs::Species;
|
||||
@@ -7026,3 +7067,88 @@ mod piglin_live_anger_memory_tests {
|
||||
assert_unrelated(&saved);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pickup_loot_save_tests {
|
||||
use super::*;
|
||||
use crate::mobs::Species;
|
||||
|
||||
#[test]
|
||||
fn pickup_save_uses_live_state_instead_of_raw_and_preserves_overrides() {
|
||||
for (species, id) in [
|
||||
(Species::Pig, "minecraft:pig"),
|
||||
(Species::Allay, "minecraft:allay"),
|
||||
(Species::SulfurCube, "minecraft:sulfur_cube"),
|
||||
] {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.id = id.into();
|
||||
ty.species = species;
|
||||
let types = HashMap::from([(id.to_string(), kind)]);
|
||||
for supplied in [None, Some(false), Some(true)] {
|
||||
let mut raw = Tag::compound([
|
||||
("id", string(id)),
|
||||
("Pos", doubles(&[4.5, 200.0, 4.5])),
|
||||
("audit:unrelated", Tag::Int(7)),
|
||||
]);
|
||||
if let Some(on) = supplied {
|
||||
put(&mut raw, "CanPickUpLoot", Tag::Byte(i8::from(on)));
|
||||
}
|
||||
let mut mob = mob_from_nbt(&raw, &types, 1, None).unwrap();
|
||||
for live in [false, true] {
|
||||
mob.extra.pick_up_loot = live;
|
||||
mob.extra.main_hand = live.then(|| "minecraft:stone".into());
|
||||
mob.extra.body = live.then(|| "minecraft:stone".into());
|
||||
let expected = if species == Species::SulfurCube {
|
||||
!live
|
||||
} else {
|
||||
live
|
||||
};
|
||||
for input in [None, Some(&raw)] {
|
||||
let saved = mob_to_nbt(&mob, [46, 0, 0, 1], input, None);
|
||||
assert_eq!(
|
||||
saved.get("CanPickUpLoot"),
|
||||
Some(&Tag::Byte(i8::from(expected))),
|
||||
"{id} supplied={supplied:?} live={live} raw={}",
|
||||
input.is_some()
|
||||
);
|
||||
if input.is_some() {
|
||||
assert_eq!(saved.get("audit:unrelated"), Some(&Tag::Int(7)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stand_fall_save_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decoded_stand_fall_distance_survives_save_without_raw_or_with_stale_raw() {
|
||||
let stale = Tag::compound([("fall_distance", Tag::Double(99.0_f64.to_bits()))]);
|
||||
for no_gravity in [false, true] {
|
||||
for distance in [0.0_f64, 1.25, 17.5] {
|
||||
let input = Tag::compound([
|
||||
("Pos", doubles(&[4.5, 205.0, 4.5])),
|
||||
("NoGravity", Tag::Byte(i8::from(no_gravity))),
|
||||
("fall_distance", Tag::Double(distance.to_bits())),
|
||||
]);
|
||||
let stand = armor_stand_from_nbt(&input, 1).unwrap();
|
||||
for raw in [None, Some(&stale)] {
|
||||
let saved = armor_stand_to_nbt(&stand, [28, 0, 0, 1], raw);
|
||||
assert_eq!(
|
||||
saved.get("fall_distance"),
|
||||
Some(&Tag::Double(distance.to_bits())),
|
||||
"distance={distance} no_gravity={no_gravity} raw={}",
|
||||
raw.is_some()
|
||||
);
|
||||
assert_eq!(saved.get("Pos"), input.get("Pos"));
|
||||
assert_eq!(stand.no_gravity, no_gravity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
horizon_threads::configure_allocator();
|
||||
horizon_client::telemetry::mark_start();
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let game_args = args.iter().take_while(|a| *a != "--dedicated");
|
||||
let help = game_args
|
||||
|
||||
@@ -173,6 +173,7 @@ mod lan_chat;
|
||||
mod lan_crafting;
|
||||
mod lan_entities;
|
||||
mod lan_far;
|
||||
mod lan_idle;
|
||||
mod lan_mobs;
|
||||
mod lan_mount_menu;
|
||||
mod lan_particles;
|
||||
@@ -1909,9 +1910,12 @@ impl LevelAccess for GameLevel<'_> {
|
||||
for (k, v) in [("x", pos.x), ("y", pos.y), ("z", pos.z)] {
|
||||
nbt.insert(k.into(), Tag::Int(v));
|
||||
}
|
||||
if let Some((key, entity)) = horizon_client::save::decode_block_entity(&Tag::Compound(nbt))
|
||||
{
|
||||
let tag = Tag::Compound(nbt);
|
||||
if let Some((key, entity)) = horizon_client::save::decode_block_entity(&tag) {
|
||||
self.world.block_entities.insert(key, entity);
|
||||
if tag.get("LootTable").is_some() {
|
||||
self.emit(LevelEvent::ContainerLootTable { pos, tag });
|
||||
}
|
||||
}
|
||||
}
|
||||
fn set_command_block_powered(&mut self, pos: BlockPos, powered: bool) -> Option<(bool, bool)> {
|
||||
@@ -3109,6 +3113,9 @@ struct Game<'a> {
|
||||
water_plane_memo: std::cell::Cell<Option<f32>>,
|
||||
/// The camera's block and the second `water_plane` last scanned at.
|
||||
water_plane_scan: std::cell::Cell<Option<([i32; 3], i64)>>,
|
||||
/// The mobs' box reach while the boats and minecarts tick, with the mob count it was
|
||||
/// worked out for (`vehicle_mob_reach`).
|
||||
vehicle_mob_reach: std::cell::Cell<Option<(usize, horizon_client::mobs::BoxReach)>>,
|
||||
persist: persist::EntityMemory,
|
||||
entity_attachments: HashMap<[i32; 4], horizon_world::attachments::Attachments>,
|
||||
/// Connected to a server: the world comes from the network (game/remote.rs).
|
||||
@@ -5064,10 +5071,12 @@ impl Game<'_> {
|
||||
/// (`ServerChunkCache.tick`'s broadcast), before the block events and the
|
||||
/// block entities ran.
|
||||
fn tick_blocks(&mut self) -> Result<Option<usize>> {
|
||||
let mut laps = horizon_client::spans::laps();
|
||||
// ServerChunkCache.tick: the tickets that time out.
|
||||
self.tickets.tick_portal();
|
||||
self.sync_tick_chunks();
|
||||
self.start_ticking_chunks();
|
||||
laps.lap("tb_tickets");
|
||||
let levels = self.tickets.levels();
|
||||
let bounds = levels.random_tick_bounds();
|
||||
// Keep the world map's chunk order, including empty chunks
|
||||
@@ -5086,6 +5095,7 @@ impl Game<'_> {
|
||||
levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z)
|
||||
})
|
||||
.collect();
|
||||
laps.lap("tb_chunks");
|
||||
// Entity.checkInsideBlocks for the plates, tripwires, detector rails and
|
||||
// buttons under the players (the host and the LAN guests), the minecarts
|
||||
// and the arrows (the mobs and other entities are added in `with_level`
|
||||
@@ -5259,6 +5269,7 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
self.process_level_events()?;
|
||||
laps.lap("tb_entity_inside");
|
||||
// ServerLevel.tick: a Debug world runs no scheduled block or fluid ticks, and
|
||||
// ServerChunkCache.tickChunks no random ticks.
|
||||
let debug = self.world_debug;
|
||||
@@ -5299,12 +5310,17 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
});
|
||||
laps = horizon_client::spans::laps();
|
||||
let sent = self.lan_broadcast_changes();
|
||||
laps.lap("tb_broadcast");
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.run_block_events(level, ticks);
|
||||
});
|
||||
laps.lap("tb_block_events");
|
||||
self.process_level_events()?;
|
||||
laps.lap("tb_level_events");
|
||||
self.sync_moving_pistons();
|
||||
laps.lap("tb_pistons");
|
||||
Ok(sent)
|
||||
}
|
||||
/// `ServerLevel.tick`: moving block entities push after entity travel.
|
||||
@@ -5347,7 +5363,10 @@ impl Game<'_> {
|
||||
/// events) happen before the next tick runs, as they do inline in vanilla.
|
||||
pub(super) fn tick_scheduled(&mut self) -> Result<()> {
|
||||
for fluids in [false, true] {
|
||||
let mut laps = horizon_client::spans::laps();
|
||||
let name = if fluids { "tb_fluids" } else { "tb_scheduled" };
|
||||
if !self.with_level(|b, level, ticks| b.scheduled_begin(level, ticks, fluids)) {
|
||||
laps.lap(name);
|
||||
continue;
|
||||
}
|
||||
loop {
|
||||
@@ -5370,6 +5389,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.with_level(|b, _, ticks| b.scheduled_finish(ticks, fluids));
|
||||
laps.lap(name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -5677,6 +5697,14 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
LevelEvent::ContainerLootTable { pos, tag } => {
|
||||
let key = (pos.x, pos.y, pos.z);
|
||||
if let Some(mut entity) = self.world.block_entities.remove(&key) {
|
||||
self.take_loot_table(key, &tag, &mut entity);
|
||||
self.world.block_entities.insert(key, entity);
|
||||
self.world.modified.insert((pos.x >> 4, pos.z >> 4));
|
||||
}
|
||||
}
|
||||
// A prime the mods refused is silent: no fuse sound, no game event.
|
||||
LevelEvent::Sound { pos, ref sound, .. }
|
||||
if self.tnt_refused == Some(pos)
|
||||
@@ -11478,8 +11506,38 @@ impl Game<'_> {
|
||||
v.intersects(o - glam::Vec3::ONE, o + glam::Vec3::splat(2.0))
|
||||
})
|
||||
};
|
||||
let dynamic_visible = |_: [i32; 3], state: u32, lo: glam::DVec3, hi: glam::DVec3| {
|
||||
if off_screen(state) {
|
||||
let camera_list: Vec<&([i32; 3], u32)> = r
|
||||
.visible_block_entities_in_frustum(
|
||||
self.settings.chunk_fade,
|
||||
camera,
|
||||
view,
|
||||
&off_screen,
|
||||
long_range,
|
||||
)
|
||||
.filter(|(block, section_inside)| {
|
||||
let (pos, state) = **block;
|
||||
self.res.block_entity_render().renders(state)
|
||||
&& (off_screen(state) || *section_inside || in_view(&pos))
|
||||
})
|
||||
.map(|(block, _)| block)
|
||||
.collect();
|
||||
// A shader pack's shadow pass draws the block entities of every section it draws,
|
||||
// in the view or not (`Renderer::shadow_pass_block_entities`, #8464): those the
|
||||
// view leaves out go in hidden from the camera.
|
||||
let shadow_only: Vec<&([i32; 3], u32)> =
|
||||
if self.pack_ids.is_some() && self.pack_shadow_casters.0 {
|
||||
let listed: HashSet<[i32; 3]> = camera_list.iter().map(|b| b.0).collect();
|
||||
r.shadow_pass_block_entities(camera)
|
||||
.filter(|(pos, state)| {
|
||||
!listed.contains(pos) && self.res.block_entity_render().renders(*state)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let shadow_only_at: HashSet<[i32; 3]> = shadow_only.iter().map(|b| b.0).collect();
|
||||
let dynamic_visible = |pos: [i32; 3], state: u32, lo: glam::DVec3, hi: glam::DVec3| {
|
||||
if off_screen(state) || shadow_only_at.contains(&pos) {
|
||||
return true;
|
||||
}
|
||||
if let Some(view) = view
|
||||
@@ -11534,20 +11592,7 @@ impl Game<'_> {
|
||||
glyphs: self.gui.atlas.font.epoch(),
|
||||
dynamic_visible: Some(&dynamic_visible),
|
||||
};
|
||||
let visible = r
|
||||
.visible_block_entities_in_frustum(
|
||||
self.settings.chunk_fade,
|
||||
camera,
|
||||
view,
|
||||
&off_screen,
|
||||
long_range,
|
||||
)
|
||||
.filter(|(block, section_inside)| {
|
||||
let (pos, state) = **block;
|
||||
self.res.block_entity_render().renders(state)
|
||||
&& (off_screen(state) || *section_inside || in_view(&pos))
|
||||
})
|
||||
.map(|(block, _)| block);
|
||||
let visible = camera_list.into_iter().chain(shadow_only.iter().copied());
|
||||
let block_entity_cache = CacheOptions {
|
||||
shulker_boxes: self.settings.horizon.cache_shulker_boxes,
|
||||
signs: self.settings.horizon.cache_signs,
|
||||
@@ -11575,7 +11620,11 @@ impl Game<'_> {
|
||||
Some(§ion_revision),
|
||||
&mut out,
|
||||
|_, state, start| self.mark_block_entity(start, state),
|
||||
|_, _, _, _| {},
|
||||
|pos, _, start, out| {
|
||||
if shadow_only_at.contains(&pos) {
|
||||
self.hide_spans_from(start, out.len());
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
let cache_candidates = r
|
||||
@@ -12210,7 +12259,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
/// `Player.canCriticalAttack` for a living target at attack strength
|
||||
/// `scale`: falling, off the ground, not in water, not blind (mobility
|
||||
/// `scale`: falling, off the ground, not in water, not on a ladder, not blind (mobility
|
||||
/// restricted), not riding, not sprinting.
|
||||
pub(super) fn critical_attack(&self, scale: f32) -> bool {
|
||||
let player = self.player.as_ref();
|
||||
@@ -12218,6 +12267,7 @@ impl Game<'_> {
|
||||
!p.ground.on_ground
|
||||
&& p.fall_distance > 0.0
|
||||
&& !p.fluids.was_touching_water
|
||||
&& !p.on_climbable
|
||||
&& !p.sprinting
|
||||
});
|
||||
let restricted = self.potions.effects.get("blindness").is_some() || self.riding.is_some();
|
||||
@@ -14432,11 +14482,7 @@ impl Game<'_> {
|
||||
a.fired_from_weapon = weapon;
|
||||
}
|
||||
// Flame: the arrow is spawned burning (EnchantmentHelper.onProjectileSpawned).
|
||||
if let Some(seconds) = self.bow_projectile_ignite()
|
||||
&& let Some(a) = arrow.arrow.as_mut()
|
||||
{
|
||||
a.ignite_for_seconds(seconds);
|
||||
}
|
||||
self.ignite_spawned_arrow(stack, &mut arrow);
|
||||
let (velocity, on_ground) = self.player.as_ref().map_or((Vec3::ZERO, true), |pl| {
|
||||
(self.known_player_movement(), pl.ground.on_ground)
|
||||
});
|
||||
@@ -19267,6 +19313,9 @@ impl Game<'_> {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if horizon_client::spans::trace::due() {
|
||||
horizon_client::spans::trace::stop();
|
||||
}
|
||||
if let Some(threshold) = self.options.spikes
|
||||
&& let Some(r) = self.renderer.as_ref()
|
||||
{
|
||||
@@ -20012,6 +20061,7 @@ impl<'a> Game<'a> {
|
||||
sky_darken_memo: Default::default(),
|
||||
water_plane_memo: Default::default(),
|
||||
water_plane_scan: Default::default(),
|
||||
vehicle_mob_reach: Default::default(),
|
||||
persist: Default::default(),
|
||||
entity_attachments: Default::default(),
|
||||
remote: None,
|
||||
@@ -20943,11 +20993,9 @@ fn gameplay_tables(
|
||||
// pulls through a `BufReader` and is measurably slower on a file this
|
||||
// size than one read plus `from_slice`, which is how `BlockFacts` next
|
||||
// door already reads its own. Same value either way.
|
||||
let behaviour_bytes =
|
||||
std::fs::read(root.join("block-behaviours.json")).map_err(|e| e.to_string())?;
|
||||
let behaviour_json: serde_json::Value =
|
||||
serde_json::from_slice(&behaviour_bytes).map_err(|e| e.to_string())?;
|
||||
drop(behaviour_bytes);
|
||||
horizon_world::json_file::read(&root.join("block-behaviours.json"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (facts, gameplay_projection) =
|
||||
BlockFacts::load_with_projection(root, assets.registry, &behaviour_json, parallel)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -22311,18 +22359,48 @@ impl ApplicationHandler for App {
|
||||
/// manifest is `horizon-resources.json`): without them, say where they were
|
||||
/// looked for and how to get them, before a window or a loader starts.
|
||||
fn missing_resources(root: &std::path::Path) -> Option<String> {
|
||||
if root.join("horizon-resources.json").is_file() {
|
||||
return None;
|
||||
}
|
||||
let shown = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
Some(format!(
|
||||
"Horizon's prepared game files are missing: no horizon-resources.json in {}\n\
|
||||
Horizon is started from its launcher: download it at {} (Download). It needs your \
|
||||
own Minecraft: Java Edition; the launcher signs you in and prepares the game files.\n\
|
||||
Developers: horizon-prepare --prepare, or pass --resources <folder>",
|
||||
shown.display(),
|
||||
horizon_client::no_resources::DOWNLOAD_URL
|
||||
))
|
||||
if !root.join("horizon-resources.json").is_file() {
|
||||
return Some(format!(
|
||||
"Horizon's prepared game files are missing: no horizon-resources.json in {}\n\
|
||||
Horizon is started from its launcher: download it at {} (Download). It needs your \
|
||||
own Minecraft: Java Edition; the launcher signs you in and prepares the game files.\n\
|
||||
Developers: horizon-prepare --prepare, or pass --resources <folder>",
|
||||
shown.display(),
|
||||
horizon_client::no_resources::DOWNLOAD_URL
|
||||
));
|
||||
}
|
||||
damaged_prepared(root).map(|problem| {
|
||||
format!(
|
||||
"Horizon's prepared game files are damaged: {problem} in {}\n\
|
||||
Prepare them again with the launcher, or: horizon-prepare --prepare",
|
||||
shown.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Which top-level prepared file a killed preparation or an interrupted copy
|
||||
/// left unusable. The loader parses these before it can start; naming one
|
||||
/// beats serde's bare "expected value at line 1 column 1".
|
||||
fn damaged_prepared(root: &std::path::Path) -> Option<String> {
|
||||
let manifest = root.join("horizon-resources.json");
|
||||
let bytes = std::fs::read(&manifest).ok()?;
|
||||
if bytes.iter().all(u8::is_ascii_whitespace) {
|
||||
return Some("horizon-resources.json is empty".into());
|
||||
}
|
||||
if serde_json::from_slice::<serde_json::Value>(&bytes).is_err() {
|
||||
return Some("horizon-resources.json is not valid JSON".into());
|
||||
}
|
||||
for entry in std::fs::read_dir(root).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension() != Some(std::ffi::OsStr::new("json")) {
|
||||
continue;
|
||||
}
|
||||
if std::fs::metadata(&path).is_ok_and(|meta| meta.len() == 0) {
|
||||
return Some(format!("{} is empty", entry.file_name().to_string_lossy()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `Minecraft.<init>`: `startedCleanly` left false means the previous start
|
||||
@@ -22485,6 +22563,7 @@ pub fn run_with() -> Result<()> {
|
||||
}
|
||||
mods::register_components(&vanilla_root)?;
|
||||
mods::register_standalone_models();
|
||||
mods::register_layer_definitions();
|
||||
let mut early_settings = Settings::load(&Game::options_path());
|
||||
// Minecraft.<init>: read the last start's flag, then mark this one as not
|
||||
// finished until onGameLoadFinished. A dedicated server has no options.txt.
|
||||
@@ -22730,6 +22809,19 @@ mod startup_cpu_tests {
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("horizon-resources.json"), "{}").unwrap();
|
||||
assert_eq!(missing_resources(&root), None);
|
||||
|
||||
// A killed export leaves an empty file behind; the error names it
|
||||
// instead of serde's "expected value at line 1 column 1".
|
||||
std::fs::write(root.join("gameplay-facts.json"), "").unwrap();
|
||||
let damaged = missing_resources(&root).expect("an empty prepared file is reported");
|
||||
assert!(damaged.contains("gameplay-facts.json"), "{damaged}");
|
||||
assert!(damaged.contains("damaged"), "{damaged}");
|
||||
std::fs::write(root.join("gameplay-facts.json"), "{}").unwrap();
|
||||
assert_eq!(missing_resources(&root), None);
|
||||
|
||||
std::fs::write(root.join("horizon-resources.json"), " \n").unwrap();
|
||||
let damaged = missing_resources(&root).expect("an empty manifest is reported");
|
||||
assert!(damaged.contains("horizon-resources.json"), "{damaged}");
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,10 @@ impl Game<'_> {
|
||||
// or stops one, and the block entities run into the thousands.
|
||||
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
|
||||
for i in 0..self.mobs.len() {
|
||||
if self.mobs[i].species() != Species::Allay || !self.mobs[i].alive() {
|
||||
if self.mobs[i].species() != Species::Allay
|
||||
|| !self.mobs[i].alive()
|
||||
|| !self.creatures.ticked.contains(&self.mobs[i].id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let pos = self.mobs[i].position;
|
||||
|
||||
@@ -869,6 +869,8 @@ impl Game<'_> {
|
||||
let a = &mut self.armor_stands[i];
|
||||
a.previous = a.position;
|
||||
a.previous_yaw = a.yaw;
|
||||
// Entity.baseTick clears firstTick only when the entity actually ticks.
|
||||
a.first_tick = false;
|
||||
// aiStep's freezing step (removeFrost) makes the speed instance.
|
||||
a.speed_instance = true;
|
||||
let (water_height, lava_height) =
|
||||
@@ -908,7 +910,9 @@ impl Game<'_> {
|
||||
if in_water {
|
||||
a.enchantment_fall_distance = 0.0;
|
||||
} else {
|
||||
a.enchantment_fall_distance += (a.previous.y - a.position.y).max(0.0);
|
||||
// Entity.checkFallDamage narrows each movement before adding to the double.
|
||||
a.enchantment_fall_distance +=
|
||||
f64::from((a.previous.y - a.position.y).max(0.0) as f32);
|
||||
}
|
||||
let landed = a.on_ground && a.enchantment_fall_distance > 0.0;
|
||||
if a.on_ground {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The native parity harness runs the actual client boat ticker without a
|
||||
//! renderer, on an empty world; movement ownership changes between ticks.
|
||||
//! The native parity harness runs the client boat ticker and authoritative
|
||||
//! boat attacks without a renderer, on an empty world.
|
||||
use super::{Game, boats::Boat};
|
||||
use horizon_client::Result;
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -12,7 +12,43 @@ struct Case {
|
||||
name: String,
|
||||
velocity: [f64; 3],
|
||||
spin: f32,
|
||||
#[serde(default)]
|
||||
owned: Vec<bool>,
|
||||
#[serde(default)]
|
||||
paddles: Option<PaddleCase>,
|
||||
#[serde(default)]
|
||||
attack: Option<Attack>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Attack {
|
||||
yaw: f32,
|
||||
sprint: bool,
|
||||
ticks: u32,
|
||||
player_velocity: [f64; 3],
|
||||
#[serde(default)]
|
||||
knockback: u8,
|
||||
#[serde(default)]
|
||||
creative: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PaddleCase {
|
||||
initial: [f32; 2],
|
||||
steps: Vec<PaddleStep>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct PaddleStep {
|
||||
occupied: bool,
|
||||
#[serde(default)]
|
||||
owned: bool,
|
||||
#[serde(default = "is_player")]
|
||||
first_player: bool,
|
||||
flags: [bool; 2],
|
||||
}
|
||||
|
||||
fn is_player() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -27,7 +63,69 @@ impl Game<'_> {
|
||||
boat.velocity = Vec3::new(x, y, z);
|
||||
boat.delta_rotation = case.spin;
|
||||
self.boats.push(boat);
|
||||
if let Some(attack) = case.attack {
|
||||
let mut player =
|
||||
horizon_client::motion::PlayerMotion::new(self.motion, Vec3::ZERO)?;
|
||||
player.velocity = Vec3::new(
|
||||
attack.player_velocity[0],
|
||||
attack.player_velocity[1],
|
||||
attack.player_velocity[2],
|
||||
);
|
||||
player.set_sprinting(attack.sprint)?;
|
||||
self.player = Some(player);
|
||||
self.inventory.fill(None);
|
||||
self.selected = 0;
|
||||
if attack.knockback > 0 {
|
||||
self.inventory[0] = horizon_client::item_data::parse_stack(&format!(
|
||||
r#"{{id:"minecraft:stick",count:1,components:{{"minecraft:enchantments":{{"minecraft:knockback":{}}}}}}}"#,
|
||||
attack.knockback
|
||||
))?;
|
||||
}
|
||||
self.yaw = attack.yaw;
|
||||
self.attack_ticks = attack.ticks;
|
||||
self.creative = attack.creative;
|
||||
self.strike_boat(0);
|
||||
let player = self.player.as_ref().unwrap();
|
||||
output.push(json!({
|
||||
"name": case.name,
|
||||
"velocity": self.boats.first().map(|boat| boat.velocity.components().map(|v| format!("{:016x}", v.to_bits()))),
|
||||
"destroyed": self.boats.is_empty(),
|
||||
"player_velocity": player.velocity.components().map(|v| format!("{:016x}", v.to_bits())),
|
||||
"sprint": player.sprinting,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
let mut steps = Vec::new();
|
||||
if let Some(paddles) = case.paddles {
|
||||
self.riding = None;
|
||||
self.boats[0].paddles = paddles.initial;
|
||||
for step in paddles.steps {
|
||||
self.boats[0].paddling = step.flags;
|
||||
self.boats[0].paddle_controller = step.occupied && step.first_player;
|
||||
self.riding = step.owned.then_some(1);
|
||||
if step.owned {
|
||||
self.boats[0].input = [
|
||||
step.flags[1] && !step.flags[0],
|
||||
step.flags[0] && !step.flags[1],
|
||||
step.flags[0] && step.flags[1],
|
||||
false,
|
||||
];
|
||||
let boat = self.tick_boat(self.boats[0].clone(), true);
|
||||
self.boats[0] = boat;
|
||||
}
|
||||
self.tick_remote_boat_bubbles();
|
||||
let boat = &self.boats[0];
|
||||
let rowing = [0.0, 0.25, 0.5, 1.0].map(|a| {
|
||||
[0, 1].map(|side| format!("{:08x}", boat.rowing_time(side, a).to_bits()))
|
||||
});
|
||||
steps.push(json!({
|
||||
"phase": boat.paddles.map(|p| format!("{:08x}", p.to_bits())),
|
||||
"rowing": rowing,
|
||||
}));
|
||||
}
|
||||
output.push(json!({"name": case.name, "steps": steps}));
|
||||
continue;
|
||||
}
|
||||
for owned in case.owned {
|
||||
self.riding = owned.then_some(1);
|
||||
if owned {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use super::{Game, boats::Boat, lan_mobs::guest_bounds};
|
||||
use horizon_client::{
|
||||
camera::wrap_degrees,
|
||||
mobs::{Mob, Species, Who, drops::is_animal, equine, riders},
|
||||
mobs::{BoxReach, Mob, Species, Who, drops::is_animal, equine, riders},
|
||||
scoreboard::pushable_by,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
@@ -68,6 +68,23 @@ pub(super) fn boat_pushes(boat: Aabb, other: Aabb, other_is_boat: bool) -> bool
|
||||
impl Game<'_> {
|
||||
/// The players in boat `id`: the window's player (or the server player
|
||||
/// acting as it) and the server's other players.
|
||||
/// Starts or ends a stretch of vehicle ticks (`tick_boats`, `tick_carts`): while one runs,
|
||||
/// a vehicle's box query tests the mobs' positions against their [`BoxReach`] first.
|
||||
/// Nothing a vehicle does resizes a mob, and a mob joining or leaving works it out again.
|
||||
pub(super) fn vehicles_ticking(&self, on: bool) {
|
||||
self.vehicle_mob_reach
|
||||
.set(on.then(|| (self.mobs.len(), BoxReach::of(&self.mobs))));
|
||||
}
|
||||
/// The mobs' [`BoxReach`] while vehicles tick, none otherwise.
|
||||
pub(super) fn vehicle_mob_reach(&self) -> Option<BoxReach> {
|
||||
let (count, reach) = self.vehicle_mob_reach.get()?;
|
||||
if count == self.mobs.len() {
|
||||
return Some(reach);
|
||||
}
|
||||
let reach = BoxReach::of(&self.mobs);
|
||||
self.vehicle_mob_reach.set(Some((self.mobs.len(), reach)));
|
||||
Some(reach)
|
||||
}
|
||||
pub(super) fn boat_players(&self, id: u32) -> usize {
|
||||
let guests = self.lan.as_ref().map_or(0, |h| {
|
||||
h.hub
|
||||
@@ -150,11 +167,14 @@ impl Game<'_> {
|
||||
/// `AbstractBoat.canVehicleCollide`: the other boats, minecarts,
|
||||
/// shulkers and pushable mobs and players, but not its own passengers.
|
||||
pub(super) fn boat_obstacles(&self, id: u32, area: Aabb) -> Vec<Aabb> {
|
||||
let reach = self.vehicle_mob_reach();
|
||||
let mut out: Vec<Aabb> = (0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
pushable(m)
|
||||
// The box first: it turns away nearly every mob of the level.
|
||||
reach.is_none_or(|r| r.may_meet(m, area))
|
||||
&& m.bounds().intersects(area)
|
||||
&& pushable(m)
|
||||
&& (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id))
|
||||
})
|
||||
.map(|j| self.mobs[j].bounds())
|
||||
@@ -203,9 +223,32 @@ impl Game<'_> {
|
||||
let pick_up = self.boat_players(id) == 0;
|
||||
// canAddPassenger, which startRiding asks after the checks below.
|
||||
let room = !self.boats[i].under_water();
|
||||
// The box first: it turns away nearly every mob of the level, and the
|
||||
// teams are only worked out when something touches the boat.
|
||||
let reach = self.vehicle_mob_reach();
|
||||
let sweep = area.union_bounds(bb);
|
||||
let near: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
if reach.is_some_and(|r| !r.may_meet(&self.mobs[j], sweep)) {
|
||||
return false;
|
||||
}
|
||||
let mb = self.mobs[j].bounds();
|
||||
mb.intersects(area) || mb.intersects(bb)
|
||||
})
|
||||
.collect();
|
||||
let outside = self.players_outside(id);
|
||||
let touching = !near.is_empty()
|
||||
|| outside
|
||||
.iter()
|
||||
.any(|(pb, _)| pb.intersects(area) || pb.intersects(bb));
|
||||
// (mob, touched by the boat's sweep, its own pushEntities reaches the boat)
|
||||
let teams = self.mob_teams();
|
||||
let mobs: Vec<(u32, bool, bool)> = (0..self.mobs.len())
|
||||
let teams = if touching {
|
||||
self.mob_teams()
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let mobs: Vec<(u32, bool, bool)> = near
|
||||
.into_iter()
|
||||
.filter_map(|j| {
|
||||
let m = &self.mobs[j];
|
||||
if !pushable(m) || m.extra.mount.is_some() && self.mob_root_vehicle(j) == Some(id) {
|
||||
@@ -222,8 +265,7 @@ impl Game<'_> {
|
||||
})
|
||||
.collect();
|
||||
let player_team = teams.of(Who::Player);
|
||||
let players: Vec<(Aabb, bool, bool, bool)> = self
|
||||
.players_outside(id)
|
||||
let players: Vec<(Aabb, bool, bool, bool)> = outside
|
||||
.into_iter()
|
||||
.map(|(pb, own)| {
|
||||
let team = if own { player_team } else { None };
|
||||
@@ -354,10 +396,17 @@ impl Game<'_> {
|
||||
/// `positionRider` and `clampRotation` for the mobs in boats: each sits
|
||||
/// in its seat facing the way the boat does, an animal in a full boat
|
||||
/// sideways, its head kept within its reach of its body.
|
||||
pub(super) fn seat_boat_mobs(&mut self) {
|
||||
pub(super) fn seat_boat_mobs(&mut self, riding: &super::passengers::Riders) {
|
||||
for i in 0..self.boats.len() {
|
||||
let id = self.boats[i].id;
|
||||
let mobs = self.boat_mobs(id);
|
||||
// `boat_mobs`, from the riders found in one pass.
|
||||
let mobs: Vec<usize> = riding
|
||||
.get(&id)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.copied()
|
||||
.filter(|&j| !self.mobs[j].removed && self.mobs[j].alive())
|
||||
.collect();
|
||||
if mobs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ pub(super) struct Boat {
|
||||
pub no_gravity: bool,
|
||||
pub paddles: [f32; 2],
|
||||
pub paddling: [bool; 2],
|
||||
/// Whether this boat has a player controlling its paddles.
|
||||
pub paddle_controller: bool,
|
||||
/// left, right, up, down
|
||||
pub input: [bool; 4],
|
||||
pub on_ground: bool,
|
||||
@@ -192,6 +194,7 @@ impl Boat {
|
||||
no_gravity: false,
|
||||
paddles: [0.0; 2],
|
||||
paddling: [false; 2],
|
||||
paddle_controller: false,
|
||||
input: [false; 4],
|
||||
on_ground: false,
|
||||
stuck: Vec3::ZERO,
|
||||
@@ -324,9 +327,14 @@ impl Boat {
|
||||
fired
|
||||
}
|
||||
/// `getRowingTime`.
|
||||
pub fn paddle_state(&self, side: usize) -> bool {
|
||||
self.paddling[side] && self.paddle_controller
|
||||
}
|
||||
pub fn rowing_time(&self, side: usize, a: f32) -> f32 {
|
||||
if self.paddling[side] {
|
||||
self.paddles[side] - PI / 8.0 + a * PI / 8.0
|
||||
if self.paddle_state(side) {
|
||||
let current = self.paddles[side];
|
||||
let previous = current - PI / 8.0;
|
||||
previous + a.clamp(0.0, 1.0) * (current - previous)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
@@ -642,6 +650,11 @@ impl Game<'_> {
|
||||
b.tick_submersion();
|
||||
b.hurt_time = (b.hurt_time - 1).max(0);
|
||||
b.damage = (b.damage - 1.0).max(0.0);
|
||||
// A remote client gets the controller from the passenger packet, even when
|
||||
// this window occupies the second seat. Local worlds derive it here.
|
||||
if self.remote.is_none() {
|
||||
b.paddle_controller = controlled;
|
||||
}
|
||||
if !controlled {
|
||||
b.paddling = [false; 2];
|
||||
b.input = [false; 4];
|
||||
@@ -776,9 +789,18 @@ impl Game<'_> {
|
||||
None,
|
||||
);
|
||||
b.velocity = horizon_world::entity_move::scale_by_block_speed(b.velocity, factor);
|
||||
Self::tick_boat_paddles(&mut b, &mut self.audio, &mut self.world.random);
|
||||
b
|
||||
}
|
||||
/// `AbstractBoat.tick` advances the oars even when this client does not move the hull.
|
||||
pub(super) fn tick_boat_paddles(
|
||||
b: &mut Boat,
|
||||
audio: &mut super::Audio,
|
||||
random: &mut horizon_core::random::LegacyRandom,
|
||||
) {
|
||||
// Paddle sounds at the top of each stroke.
|
||||
for i in 0..2 {
|
||||
if b.paddling[i] {
|
||||
if b.paddle_state(i) {
|
||||
let p = b.paddles[i] % (2.0 * PI);
|
||||
if p <= PI / 4.0 && (b.paddles[i] + PI / 8.0) % (2.0 * PI) >= PI / 4.0 {
|
||||
let sound = match b.status {
|
||||
@@ -791,8 +813,8 @@ impl Game<'_> {
|
||||
if let Some(sound) = sound {
|
||||
let v = view_vector(b.yaw, 0.0);
|
||||
let (dx, dz) = if i == 1 { (-v[2], v[0]) } else { (v[2], -v[0]) };
|
||||
let pitch = 0.8 + 0.4 * self.world.random.next_float();
|
||||
self.audio.play(
|
||||
let pitch = 0.8 + 0.4 * random.next_float();
|
||||
audio.play(
|
||||
sound,
|
||||
Some([b.position.x + dx, b.position.y, b.position.z + dz]),
|
||||
1.0,
|
||||
@@ -805,10 +827,14 @@ impl Game<'_> {
|
||||
b.paddles[i] = 0.0;
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
/// Every boat but the ridden one (which ticks with its rider).
|
||||
pub(super) fn tick_boats(&mut self) {
|
||||
self.vehicles_ticking(true);
|
||||
self.tick_boats_now();
|
||||
self.vehicles_ticking(false);
|
||||
}
|
||||
fn tick_boats_now(&mut self) {
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
let mut round = self.native_tick_round();
|
||||
@@ -1418,16 +1444,40 @@ impl Game<'_> {
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
self.strike_boat(i);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// The accepted boat target, shared by aimed attacks and headless parity.
|
||||
pub(super) fn strike_boat(&mut self, i: usize) {
|
||||
let area = self.boats[i].bounds();
|
||||
// Player.attack's full damage against the boat's entity type.
|
||||
let (kind, id) = (self.boats[i].entity_type(), self.boats[i].id as i32);
|
||||
let (damage, _, _, magic) = self.melee_damage(&kind, false);
|
||||
self.hurt_boat(i, damage, self.creative);
|
||||
let (damage, scale, _, magic) = self.melee_damage(&kind, false);
|
||||
let hurt = self.hurt_boat_result(i, damage, self.creative);
|
||||
self.vehicle_attack_sounds(area, magic.then_some(id));
|
||||
if damage > 0.0
|
||||
&& let Some(destroyed) = hurt
|
||||
{
|
||||
let (knockback, _) = self.extra_knockback(&kind, scale);
|
||||
if knockback > 0.0 {
|
||||
// Player.causeExtraKnockback uses Entity.push for a boat,
|
||||
// not LivingEntity.knockback (no resistance or velocity halving).
|
||||
if !destroyed {
|
||||
let (x, z) = self.extra_knockback_direction();
|
||||
self.boats[i].velocity.x += f64::from(-(x as f32) * knockback);
|
||||
self.boats[i].velocity.y += 0.1;
|
||||
self.boats[i].velocity.z += f64::from(-(z as f32) * knockback);
|
||||
}
|
||||
if let Some(player) = self.player.as_mut() {
|
||||
player.velocity.x *= horizon_client::attack_strength::ATTACKER_SLOWDOWN;
|
||||
player.velocity.z *= horizon_client::attack_strength::ATTACKER_SLOWDOWN;
|
||||
}
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
}
|
||||
// Player.onAttack: the hit starts the attack strength again.
|
||||
self.attack_ticks = 0;
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `VehicleEntity.hurtServer`: wobble, add the damage and break past 40
|
||||
/// (`creative`: a creative player's hit discards it straight away).
|
||||
@@ -1435,11 +1485,15 @@ impl Game<'_> {
|
||||
/// reason that destroys the boat (DISCARDED included), while the boat item
|
||||
/// itself only drops on the death path (`VehicleEntity.destroy`), not when
|
||||
/// a creative hit discards it. True when the boat was removed.
|
||||
pub(super) fn hurt_boat(&mut self, i: usize, mut damage: f32, creative: bool) -> bool {
|
||||
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
|
||||
self.hurt_boat_result(i, damage, creative).unwrap_or(false)
|
||||
}
|
||||
/// None means a canceled hit; Some records whether an accepted hit destroyed it.
|
||||
fn hurt_boat_result(&mut self, i: usize, mut damage: f32, creative: bool) -> Option<bool> {
|
||||
let mut cancel = false;
|
||||
self.native_vehicle_damage("boat", &mut damage, &mut cancel);
|
||||
if cancel {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
let b = &mut self.boats[i];
|
||||
b.hurt_dir = -b.hurt_dir;
|
||||
@@ -1478,7 +1532,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
destroyed
|
||||
Some(destroyed)
|
||||
}
|
||||
/// AbstractBoatRenderer.
|
||||
pub(super) fn boat_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
|
||||
@@ -163,8 +163,8 @@ impl Game<'_> {
|
||||
self.other_to_nbt(&o, uuid)
|
||||
}
|
||||
Target::Orb(id) => {
|
||||
let tag =
|
||||
entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, random_uuid());
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, uuid);
|
||||
crate::game::persist::with_raw(tag, self.persist.raw.get(&id))
|
||||
}
|
||||
Target::Projectile(id) => {
|
||||
@@ -394,8 +394,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Target::Stand(id) => {
|
||||
let new = entity_nbt::armor_stand_from_nbt(&tag, id).ok_or_else(stale)?;
|
||||
let mut new = entity_nbt::armor_stand_from_nbt(&tag, id).ok_or_else(stale)?;
|
||||
if let Some(slot) = self.armor_stands.iter_mut().find(|a| a.id == id) {
|
||||
// Entity.load keeps the existing entity's firstTick state.
|
||||
new.first_tick = slot.first_tick;
|
||||
*slot = new;
|
||||
}
|
||||
}
|
||||
@@ -1208,6 +1210,84 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod orb_uuid_tests {
|
||||
use super::Target;
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn saved_orb_uuid_comes_from_registered_identity_without_or_despite_raw() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
let uuid = [0, 0, 0, 2];
|
||||
let tag = Tag::compound([
|
||||
("UUID", Tag::IntArray(uuid.to_vec())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(4.5_f64.to_bits()),
|
||||
Tag::Double(200.0_f64.to_bits()),
|
||||
Tag::Double(4.5_f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("Value", Tag::Short(1)),
|
||||
("Age", Tag::Short(0)),
|
||||
("Health", Tag::Short(5)),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
]);
|
||||
game.load_orb(tag, Some(uuid));
|
||||
assert_eq!(game.orbs.len(), 1);
|
||||
let id = game.orbs[0].id;
|
||||
assert_eq!(game.persist.uuids.get(&id), Some(&uuid));
|
||||
let time = game.world.game_time;
|
||||
for raw in [
|
||||
None,
|
||||
Some(Tag::compound([("UUID", Tag::IntArray(vec![9, 9, 9, 9]))])),
|
||||
] {
|
||||
if let Some(raw) = &raw {
|
||||
game.persist.raw.insert(id, raw.clone());
|
||||
} else {
|
||||
game.persist.raw.remove(&id);
|
||||
}
|
||||
for _ in 0..3 {
|
||||
let saved = game.entity_nbt_in_level(Target::Orb(id)).unwrap();
|
||||
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(uuid.to_vec())));
|
||||
assert_eq!(game.persist.uuids.get(&id), Some(&uuid));
|
||||
assert_eq!(game.persist.raw.get(&id), raw.as_ref());
|
||||
assert_eq!(game.world.game_time, time);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod wolf_live_anger_tests {
|
||||
use super::Target;
|
||||
|
||||
@@ -592,18 +592,49 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
Slot::Stand(id, i) => match self
|
||||
.armor_stands
|
||||
.iter_mut()
|
||||
.find(|a| a.id == *id)
|
||||
.and_then(|a| a.equipment.get_mut(*i))
|
||||
{
|
||||
Some(slot) => {
|
||||
*slot = stack;
|
||||
true
|
||||
Slot::Stand(id, i) => {
|
||||
// LivingEntity.onEquipItem: all stand slots emit; the new
|
||||
// stack's EQUIPPABLE presence chooses equip versus unequip.
|
||||
let equippable = stack.as_ref().is_some_and(|s| {
|
||||
let data = s.data();
|
||||
!data.is_some_and(|d| d.other("!minecraft:equippable").is_some())
|
||||
&& (data.is_some_and(|d| d.other("minecraft:equippable").is_some())
|
||||
|| self
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.is_some_and(|i| i.equip_slot.is_some()))
|
||||
});
|
||||
let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == *id) else {
|
||||
return false;
|
||||
};
|
||||
let Some(slot) = a.equipment.get_mut(*i) else {
|
||||
return false;
|
||||
};
|
||||
let same = match (slot.as_ref(), stack.as_ref()) {
|
||||
(None, None) => true,
|
||||
(Some(old), Some(new)) => old.same_item_same_components(new),
|
||||
_ => false,
|
||||
};
|
||||
let emit = !a.first_tick && !same;
|
||||
let at = a.position;
|
||||
*slot = stack;
|
||||
if emit && self.remote.is_none() {
|
||||
self.post_game_event(
|
||||
if equippable {
|
||||
"minecraft:equip"
|
||||
} else {
|
||||
"minecraft:unequip"
|
||||
},
|
||||
[at.x, at.y, at.z],
|
||||
horizon_client::sculk::Context {
|
||||
source: Some(horizon_client::mobs::Who::Mob(*id)),
|
||||
living: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
true
|
||||
}
|
||||
Slot::Frame(id) => match self.item_frames.iter_mut().find(|f| f.id == *id) {
|
||||
Some(f) => {
|
||||
f.item = stack;
|
||||
@@ -1390,3 +1421,105 @@ fn list_operation(m: &serde_json::Value, old: Vec<Tag>, new: Vec<Tag>) -> Vec<Ta
|
||||
out.truncate(256);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stand_equip_event_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{chunk::Chunk, containers::BlockEntity, mobs::Who, sculk::Info};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn candidate(game: &Game<'_>) -> Option<Info> {
|
||||
let Some(BlockEntity::Sculk(sensor)) = game.world.block_entities.get(&(9, 200, 10)) else {
|
||||
panic!("actual sensor must be registered")
|
||||
};
|
||||
sensor.vibration.selector.current.map(|(info, _)| info)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn commands_post_stand_equipment_events_after_its_first_tick() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.game_dir =
|
||||
Some(std::env::temp_dir().join(format!("horizon-h130-unit-{}", std::process::id())));
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
let registry = game.pack.assets.registry;
|
||||
let sensor = registry.default_state("minecraft:sculk_sensor").unwrap();
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
chunk.set(9, 200, 10, sensor.0);
|
||||
game.world.chunks.insert((0, 0), Arc::new(chunk));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("summon armor_stand 6.5 200 10.5 {UUID:[I;130,0,0,1],NoGravity:1b,attributes:[{id:\"minecraft:movement_speed\",base:0.1d}]}"));
|
||||
let id = game.armor_stands[0].id;
|
||||
assert_eq!(game.uuid_for(id), [130, 0, 0, 1]);
|
||||
assert!(
|
||||
game.armor_stands[0].speed_instance,
|
||||
"a loaded attribute is not firstTick"
|
||||
);
|
||||
game.sculk_prepare();
|
||||
game.sculk_sync_index();
|
||||
assert!(candidate(&game).is_none());
|
||||
let prefix = "item replace entity 00000082-0000-0000-0000-000000000001 ";
|
||||
for item in ["iron_helmet", "air"] {
|
||||
assert!(game.run_command(&format!("{prefix}armor.head with {item}")));
|
||||
assert!(candidate(&game).is_none(), "first-tick suppression");
|
||||
}
|
||||
game.tick_armor_stands().unwrap();
|
||||
assert!(
|
||||
game.run_command(
|
||||
"data merge entity 00000082-0000-0000-0000-000000000001 {ShowArms:1b}"
|
||||
)
|
||||
);
|
||||
for (slot, item, event) in [
|
||||
("armor.head", "iron_helmet", Some("minecraft:equip")),
|
||||
("armor.head", "iron_helmet", None),
|
||||
("armor.head", "air", Some("minecraft:unequip")),
|
||||
("armor.head", "air", None),
|
||||
("weapon.mainhand", "stick", Some("minecraft:unequip")),
|
||||
] {
|
||||
let Some(BlockEntity::Sculk(sensor)) = game.world.block_entities.get_mut(&(9, 200, 10))
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
sensor.vibration = Default::default();
|
||||
assert!(game.run_command(&format!("{prefix}{slot} with {item}")));
|
||||
let heard = candidate(&game);
|
||||
assert_eq!(heard.map(|info| info.event), event, "{slot} -> {item}");
|
||||
if let Some(info) = heard {
|
||||
assert_eq!(info.source, Some(Who::Mob(id)));
|
||||
assert_eq!(info.pos, [6.5, 200.0, 10.5]);
|
||||
assert!(info.owner.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{position::ChunkPos, random::RandomSource};
|
||||
|
||||
/// How long `/forceload add` waits for its chunks, as the game tests' loads do.
|
||||
const FORCED_CHUNK_WAIT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
|
||||
fn num(n: impl std::fmt::Display) -> Text {
|
||||
Text::literal(n.to_string())
|
||||
}
|
||||
@@ -42,6 +45,23 @@ impl Game<'_> {
|
||||
let p = self.resolve_coords(&c);
|
||||
Ok([p[0].floor() as i32, p[2].floor() as i32])
|
||||
}
|
||||
/// `ServerLevel.setChunkForced`'s `getChunk`: a command right after
|
||||
/// `/forceload add` (`/place`, `/setblock`) finds the chunks loaded.
|
||||
fn wait_for_forced_chunks(&mut self, min: [i32; 2], max: [i32; 2]) {
|
||||
let missing = |g: &Self| {
|
||||
(min[0]..=max[0])
|
||||
.any(|x| (min[1]..=max[1]).any(|z| !g.world.chunks.contains_key(&(x, z))))
|
||||
};
|
||||
let deadline = std::time::Instant::now() + FORCED_CHUNK_WAIT;
|
||||
while missing(self) {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
log::warn!("Horizon: /forceload: chunks {min:?}..{max:?} are not loaded yet");
|
||||
return;
|
||||
}
|
||||
let _ = self.streaming.update(&mut self.world);
|
||||
self.streaming.wait(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
pub(super) fn cmd_forceload(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let dim = Text::literal(self.dim.id());
|
||||
match id {
|
||||
@@ -128,6 +148,7 @@ impl Game<'_> {
|
||||
}
|
||||
if add {
|
||||
self.streaming.rank_forced(cx0, cz0, cx1, cz1);
|
||||
self.wait_for_forced_chunks([cx0, cz0], [cx1, cz1]);
|
||||
}
|
||||
let (lo, hi) = (chunk_text(cx0, cz0), chunk_text(cx1, cz1));
|
||||
let verb = if add { "added" } else { "removed" };
|
||||
@@ -518,12 +539,28 @@ impl Game<'_> {
|
||||
let (tag, _) = horizon_world::nbt::parse_snbt(&table)
|
||||
.map_err(|e| err("argument.nbt.invalid", &[&e]))?;
|
||||
let json = horizon_client::scoreboard::tag_to_json(&tag);
|
||||
// LootCommand's omitted fishing tool is EMPTY, not mainhand.
|
||||
let held = if id == "loot.fish"
|
||||
&& (ctx.get("tool").is_some() || ctx.has("mainhand") || ctx.has("offhand"))
|
||||
{
|
||||
self.tool_stack(ctx)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let drops = match json["random_sequence"].as_str() {
|
||||
Some(sequence) => {
|
||||
let random = self.rand_sequences.get(sequence, self.level.seed);
|
||||
world_items!(self).inline_loot_table(&json, random)
|
||||
world_items!(self).inline_loot_table_with_tool(
|
||||
&json,
|
||||
held.as_ref(),
|
||||
random,
|
||||
)
|
||||
}
|
||||
None => world_items!(self).inline_loot_table(&json, &mut self.world.random),
|
||||
None => world_items!(self).inline_loot_table_with_tool(
|
||||
&json,
|
||||
held.as_ref(),
|
||||
&mut self.world.random,
|
||||
),
|
||||
};
|
||||
(drops, None)
|
||||
} else {
|
||||
|
||||
@@ -435,6 +435,27 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "gpu_passes": self.gpu_passes })))
|
||||
}
|
||||
// Every thread's spans as a timeline Perfetto opens (`spans::trace`).
|
||||
"trace" => match words.get(1).copied() {
|
||||
None => Ok(Some(
|
||||
json!({ "ok": true, "tracing": horizon_client::spans::trace::on() }),
|
||||
)),
|
||||
Some("stop") => Ok(Some(
|
||||
json!({ "ok": true, "events": horizon_client::spans::trace::stop() }),
|
||||
)),
|
||||
Some(file) => {
|
||||
let seconds = match words.get(2) {
|
||||
None => None,
|
||||
Some(s) => Some(
|
||||
s.parse::<u32>()
|
||||
.map_err(|_| "trace [FILE [SECONDS] | stop]")?,
|
||||
),
|
||||
};
|
||||
horizon_client::spans::trace::start(std::path::Path::new(file), seconds)
|
||||
.map_err(|e| format!("trace {file}: {e}"))?;
|
||||
Ok(Some(json!({ "ok": true, "tracing": true, "file": file })))
|
||||
}
|
||||
},
|
||||
"render-capture" => match words.get(1).copied() {
|
||||
None => {
|
||||
let r = self.renderer.as_ref().ok_or("no renderer")?;
|
||||
|
||||
@@ -105,6 +105,14 @@ pub(super) struct Creatures {
|
||||
pub lan_explosions: Vec<([f64; 3], bool, f32, usize, Vec<(u32, [f64; 3])>)>,
|
||||
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
|
||||
pub elder_apparition: i32,
|
||||
/// The snapshot's buffer between ticks, empty: ~400 bytes a mob, so
|
||||
/// thousands of mobs would allocate and free megabytes every tick.
|
||||
seen_buf: Vec<Seen>,
|
||||
/// The mobs whose own tick ran in the last `tick_creature_entities`:
|
||||
/// `ServerLevel.tick` ticks only the entities in entity-ticking range, so
|
||||
/// what the passes after the loop do for a mob's `aiStep`, goals or brain
|
||||
/// (pickups, item searches) is for these alone.
|
||||
pub ticked: horizon_client::fast_hash::FxHashSet<u32>,
|
||||
/// The wandering trader spawner's clock.
|
||||
pub trader: super::wandering_trader::Spawner,
|
||||
/// When each player last put a parrot on a shoulder (`timeEntitySatOnShoulder`).
|
||||
@@ -702,6 +710,9 @@ impl Game<'_> {
|
||||
rider.extra.mount = Some(id);
|
||||
}
|
||||
}
|
||||
if let Some(raw) = horizon_client::entity_nbt::conversion_tags(self.persist_raw(from.id)) {
|
||||
self.persist.raw.insert(id, raw);
|
||||
}
|
||||
self.mobs.push(mob);
|
||||
self.native_mobs_joined(self.mobs.len() - 1, false);
|
||||
let transformed_kind = self
|
||||
@@ -964,6 +975,8 @@ impl Game<'_> {
|
||||
/// The entity phase of `tick_creatures`: every mob's tick, deaths, and the
|
||||
/// projectiles and effects that follow from them.
|
||||
pub(super) fn tick_creature_entities(&mut self) -> Result<()> {
|
||||
// Its steps as spans for `horizon-report` (nothing is read while spans are off).
|
||||
let mut laps = horizon_client::spans::laps();
|
||||
// LivingEntity.die runs when the killing blow lands: mobs killed
|
||||
// since the last tick (by the player, explosions...) die first.
|
||||
let killed: Vec<Mob> = self
|
||||
@@ -996,15 +1009,21 @@ impl Game<'_> {
|
||||
}
|
||||
let uuid = self.level.player_uuid;
|
||||
let level_id = self.dim.id();
|
||||
let mut snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
|
||||
let mut snapshot = std::mem::take(&mut self.creatures.seen_buf);
|
||||
snapshot.extend(self.mobs.iter().map(|m| seen(m, uuid)));
|
||||
let mut events = Vec::new();
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
let mut dead = Vec::new();
|
||||
let mut corpses = Vec::new();
|
||||
let mut died = std::mem::take(&mut self.died);
|
||||
let mut ticked = std::mem::take(&mut self.creatures.ticked);
|
||||
ticked.clear();
|
||||
let ticking = self.entity_ticking_area();
|
||||
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
|
||||
let guests = self.lan_guest_views();
|
||||
let mut guests = self.lan_guest_views();
|
||||
// What stands in a guest's slot while a mob holds that guest's view for
|
||||
// its tick (see `lent` below).
|
||||
let mut spare = guests.first().map(|g| g.1.clone());
|
||||
let players = self.level_players();
|
||||
let nearest = super::natural_spawning::NearestPlayers::new(&players);
|
||||
let mut bindings = match self.lan.as_mut() {
|
||||
@@ -1059,6 +1078,7 @@ impl Game<'_> {
|
||||
villager_slots.push(i as u32);
|
||||
}
|
||||
}
|
||||
laps.lap("creature_prepare");
|
||||
let mut cached_difficulty: Option<((i32, i32), spawning::LocalDifficulty)> = None;
|
||||
for i in 0..mobs.len() {
|
||||
let m = &mut mobs[i];
|
||||
@@ -1085,6 +1105,29 @@ impl Game<'_> {
|
||||
let nearest_player = nearest
|
||||
.nearest(m.position.x, m.position.y, m.position.z)
|
||||
.map(|p| p.position);
|
||||
// ServerLevel.tick: every loaded entity checks despawning, but
|
||||
// only those in entity-ticking chunks (within the simulation
|
||||
// distance) tick. The despawn check reads the nearest player
|
||||
// and the difficulty, not the player the mob sees.
|
||||
if !ticking(bx, bz) {
|
||||
let w = MobWorld {
|
||||
others: &snapshot,
|
||||
others_grid: Some(&grid),
|
||||
others_index: Some(&index),
|
||||
others_villagers: Some(&villager_slots),
|
||||
nearest_player,
|
||||
player: None,
|
||||
teams: std::sync::Arc::clone(&base.teams),
|
||||
..base
|
||||
};
|
||||
let kept = horizon_client::mobs::ai::despawn_check(m, &w);
|
||||
if !kept {
|
||||
died.remove(&m.id);
|
||||
snapshot[i].alive = false;
|
||||
}
|
||||
keep.push(kept);
|
||||
continue;
|
||||
}
|
||||
let controlling_player_movement = horizon_client::mobs::equine::controlled(m)
|
||||
.then(|| {
|
||||
base.player
|
||||
@@ -1099,36 +1142,6 @@ impl Game<'_> {
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
// ServerLevel.tick: every loaded entity checks despawning, but
|
||||
// only those in entity-ticking chunks (within the simulation
|
||||
// distance) tick.
|
||||
if !ticking(bx, bz) {
|
||||
let active_player = match seen_guest {
|
||||
Some(conn) => guests.iter().find(|g| g.0 == conn).map(|g| g.1.clone()),
|
||||
None => base.player.take(),
|
||||
};
|
||||
let mut w = MobWorld {
|
||||
others: &snapshot,
|
||||
others_grid: Some(&grid),
|
||||
others_index: Some(&index),
|
||||
others_villagers: Some(&villager_slots),
|
||||
nearest_player,
|
||||
player: active_player,
|
||||
teams: std::sync::Arc::clone(&base.teams),
|
||||
..base
|
||||
};
|
||||
w.controlling_player_movement = controlling_player_movement;
|
||||
let kept = horizon_client::mobs::ai::despawn_check(m, &w);
|
||||
if seen_guest.is_none() {
|
||||
base.player = w.player;
|
||||
}
|
||||
if !kept {
|
||||
died.remove(&m.id);
|
||||
snapshot[i].alive = false;
|
||||
}
|
||||
keep.push(kept);
|
||||
continue;
|
||||
}
|
||||
// A mod canceled its tick: it stays as it is.
|
||||
if round.canceled(m.id) {
|
||||
keep.push(true);
|
||||
@@ -1149,9 +1162,16 @@ impl Game<'_> {
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
horizon_client::mobs::blocks_changed(m, &path_changes);
|
||||
let from = events.len();
|
||||
let active_player = match seen_guest {
|
||||
Some(conn) => guests.iter().find(|g| g.0 == conn).map(|g| g.1.clone()),
|
||||
None => base.player.take(),
|
||||
// The guest's view is lent to the mob for its tick, not copied:
|
||||
// the tick only reads it (`&MobWorld`), and it goes back to the
|
||||
// guest's slot straight after.
|
||||
let lent = seen_guest.and_then(|conn| guests.iter().position(|g| g.0 == conn));
|
||||
let active_player = match (seen_guest, lent) {
|
||||
(None, _) => base.player.take(),
|
||||
(Some(_), Some(k)) => spare
|
||||
.take()
|
||||
.map(|stand_in| std::mem::replace(&mut guests[k].1, stand_in)),
|
||||
(Some(_), None) => None,
|
||||
};
|
||||
let mut w = MobWorld {
|
||||
others: &snapshot,
|
||||
@@ -1165,6 +1185,7 @@ impl Game<'_> {
|
||||
..base
|
||||
};
|
||||
w.controlling_player_movement = controlling_player_movement;
|
||||
ticked.insert(mobs[i].id);
|
||||
// `horizon-report`'s cost by mob type (tick_profile.rs).
|
||||
let sampled = super::tick_profile::start().map(|t| (t, mobs[i].kind.clone()));
|
||||
let kept =
|
||||
@@ -1175,6 +1196,8 @@ impl Game<'_> {
|
||||
let m = &mut mobs[i];
|
||||
if seen_guest.is_none() {
|
||||
base.player = w.player;
|
||||
} else if let Some(k) = lent {
|
||||
spare = w.player.map(|v| std::mem::replace(&mut guests[k].1, v));
|
||||
}
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
// death run (`LivingEntity.remove` with KILLED).
|
||||
@@ -1225,6 +1248,7 @@ impl Game<'_> {
|
||||
grid.update(i, snapshot[i].bounds);
|
||||
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
|
||||
}
|
||||
laps.lap("creature_loop");
|
||||
let mut flags = keep.into_iter();
|
||||
mobs.retain(|_| flags.next().unwrap_or(true));
|
||||
for m in &mut mobs {
|
||||
@@ -1252,6 +1276,9 @@ impl Game<'_> {
|
||||
self.mobs.append(&mut mobs);
|
||||
}
|
||||
self.died = died;
|
||||
snapshot.clear();
|
||||
self.creatures.seen_buf = snapshot;
|
||||
self.creatures.ticked = ticked;
|
||||
self.native_tick_post_batch(round);
|
||||
// ... and entity event 60 goes out as it is: the poof.
|
||||
for (id, at, width, height) in gone {
|
||||
@@ -1259,6 +1286,7 @@ impl Game<'_> {
|
||||
self.lan_entity_event(id as i32, 60, at);
|
||||
self.death_poof(at, width, height);
|
||||
}
|
||||
laps.lap("creature_retain");
|
||||
self.tick_boarding();
|
||||
self.sync_riders();
|
||||
self.tick_skeleton_traps();
|
||||
@@ -1267,6 +1295,7 @@ impl Game<'_> {
|
||||
self.villager_reputation_tick();
|
||||
self.villager_item_sense();
|
||||
self.villager_pickups();
|
||||
laps.lap("creature_villagers");
|
||||
for m in dead {
|
||||
self.mob_died(&m);
|
||||
}
|
||||
@@ -1282,9 +1311,11 @@ impl Game<'_> {
|
||||
}
|
||||
self.handle_mob_events(events)?;
|
||||
self.lan_guest_mob_events(guest_events);
|
||||
laps.lap("creature_events");
|
||||
self.ordered_before(super::ordered_tick::Batch::Projectiles)?;
|
||||
self.tick_projectiles()?;
|
||||
self.tick_fangs();
|
||||
laps.lap("creature_projectiles");
|
||||
self.tick_allays();
|
||||
self.tick_pandas();
|
||||
self.tick_foxes();
|
||||
@@ -1295,12 +1326,14 @@ impl Game<'_> {
|
||||
self.piglin_pickups();
|
||||
self.sulfur_cube_pickups();
|
||||
self.sulfur_cube_power_priming();
|
||||
laps.lap("creature_systems");
|
||||
// Hurt and death sounds of damage dealt outside the mob ticks.
|
||||
let mut queued = Vec::new();
|
||||
for m in &mut self.mobs {
|
||||
queued.append(&mut m.outbox);
|
||||
}
|
||||
self.handle_mob_events(queued)?;
|
||||
laps.lap("creature_outbox");
|
||||
Ok(())
|
||||
}
|
||||
/// `LivingEntity.makePoofParticles` (entity event 60) about a mob's box.
|
||||
@@ -8454,3 +8487,104 @@ mod dye_mix_tests {
|
||||
assert!(kept == orange || kept == cyan, "kept {kept}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod conversion_tag_tests {
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, commands::Target, load_resources};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn converted_mobs_keep_command_tags_at_the_new_id_and_on_save() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
for (number, (from, to)) in [
|
||||
("husk", "zombie"),
|
||||
("zombie", "drowned"),
|
||||
("skeleton", "stray"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let uuid = [10374, 1, 0, number as i32];
|
||||
let tags = Tag::List(vec![
|
||||
Tag::String("subject".into()),
|
||||
Tag::String(from.into()),
|
||||
]);
|
||||
let raw = Tag::compound([
|
||||
(
|
||||
"id",
|
||||
Tag::String(format!("minecraft:{from}").as_str().into()),
|
||||
),
|
||||
("UUID", Tag::IntArray(uuid.to_vec())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(0.5_f64.to_bits()),
|
||||
Tag::Double(70.0_f64.to_bits()),
|
||||
Tag::Double(0.5_f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("NoAI", Tag::Byte(1)),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
("Tags", tags.clone()),
|
||||
("horizon:old_entity_only", Tag::Byte(1)),
|
||||
]);
|
||||
let old_id = game.load_mob(raw, None).unwrap();
|
||||
assert_eq!(game.tags_of(Target::Mob(old_id)), ["subject", from]);
|
||||
let source = game.mobs.iter().find(|m| m.id == old_id).unwrap().clone();
|
||||
let new_id = game
|
||||
.convert_to(
|
||||
&source,
|
||||
&format!("minecraft:{to}"),
|
||||
horizon_client::mobs::conversion::Params::KEEP_ALL,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(old_id, new_id);
|
||||
assert_eq!(
|
||||
game.tags_of(Target::Mob(new_id)),
|
||||
["subject", from],
|
||||
"converted ID must keep tags"
|
||||
);
|
||||
let index = game.mobs.iter().position(|m| m.id == new_id).unwrap();
|
||||
let saved = game.mob_tree_nbt(index);
|
||||
assert_eq!(saved.get("Tags"), Some(&tags));
|
||||
assert_eq!(
|
||||
horizon_client::entity_nbt::kind(&saved).as_deref(),
|
||||
Some(format!("minecraft:{to}").as_str())
|
||||
);
|
||||
assert_ne!(horizon_client::entity_nbt::uuid(&saved), Some(uuid));
|
||||
assert!(saved.get("horizon:old_entity_only").is_none());
|
||||
game.mobs.remove(index);
|
||||
let reloaded = game.load_mob(saved, None).unwrap();
|
||||
assert_eq!(game.tags_of(Target::Mob(reloaded)), ["subject", from]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -371,6 +371,8 @@ impl Game<'_> {
|
||||
a.fired_from_weapon = Some(weapon.clone());
|
||||
a.pierce = pierce;
|
||||
}
|
||||
// Flame: the arrow is spawned burning (EnchantmentHelper.onProjectileSpawned).
|
||||
self.ignite_spawned_arrow(&weapon, &mut arrow);
|
||||
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
|
||||
self.add_projectile(arrow);
|
||||
let (player, name, projectile) = (
|
||||
@@ -488,6 +490,39 @@ mod tests {
|
||||
assert_eq!(at, vec![feet]);
|
||||
}
|
||||
|
||||
/// `AbstractArrow`'s constructor runs `EnchantmentHelper.onProjectileSpawned`
|
||||
/// for the weapon the arrow is fired from, bow or crossbow, so a Flame
|
||||
/// crossbow shoots burning arrows: Flame's 5 second ignite, 100 ticks, on
|
||||
/// every arrow of a Multishot volley (#10482).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_flame_crossbow_shoots_burning_arrows() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let (mut game, _) = game_with_player(&root);
|
||||
// Shoots a crossbow with `enchantments` and one arrow loaded per shot,
|
||||
// and returns how long each new arrow burns for.
|
||||
let mut shoot = |enchantments: &[&str], shots: usize| -> Vec<i32> {
|
||||
let mut crossbow = ItemStack::new(CROSSBOW, 1);
|
||||
for id in enchantments {
|
||||
crossbow.enchant(id, 1);
|
||||
}
|
||||
crossbow.data_mut().charged = vec![ItemStack::new(ARROW, 1); shots];
|
||||
*game.held_mut() = Some(crossbow);
|
||||
let before = game.creatures.projectiles.len();
|
||||
game.shoot_crossbow();
|
||||
game.creatures.projectiles[before..]
|
||||
.iter()
|
||||
.map(|p| p.arrow.as_ref().expect("an arrow").fire_ticks)
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(shoot(&[], 1), vec![0]);
|
||||
assert_eq!(shoot(&["minecraft:flame"], 1), vec![100]);
|
||||
assert_eq!(
|
||||
shoot(&["minecraft:flame", "minecraft:multishot"], 3),
|
||||
vec![100; 3]
|
||||
);
|
||||
}
|
||||
|
||||
/// A charge that crosses a point between two ticks plays that point's
|
||||
/// sound, and one a mod's tick jumps over (`native_living_entity_use_item_tick`
|
||||
/// cutting the ticks left) still plays every point it passed, as
|
||||
|
||||
@@ -67,9 +67,6 @@ impl Game<'_> {
|
||||
/// own handling. Returns true when it is used up (a debug combination,
|
||||
/// F3 itself, F3+Escape).
|
||||
pub(super) fn debug_key(&mut self, key: KeyCode, action: KeyAction) -> bool {
|
||||
if self.title.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(name) = keybinds::key_name(key) else {
|
||||
return false;
|
||||
};
|
||||
@@ -713,6 +710,9 @@ impl Game<'_> {
|
||||
self.debug_charts.toggle_lightmap(&mut self.debug);
|
||||
true
|
||||
}
|
||||
// The debug options screen is not wired over the title screen yet:
|
||||
// leave F6 unhandled there rather than half-open it.
|
||||
DebugAction::DebugOptions if self.title.is_some() => false,
|
||||
DebugAction::DebugOptions => {
|
||||
self.toggle_debug_options();
|
||||
true
|
||||
|
||||
@@ -1119,6 +1119,11 @@ impl Game<'_> {
|
||||
.raw
|
||||
.get(horizon_client::lan::HORIZON_CLIENTS_ONLY)
|
||||
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true")),
|
||||
accept_older_clients: props
|
||||
.raw
|
||||
.get(horizon_client::lan::ACCEPT_OLDER_CLIENTS)
|
||||
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true")),
|
||||
older_clients_play: false,
|
||||
lod_distance: horizon_client::lod_net::distance_property(
|
||||
props.raw.get(horizon_client::lod_net::PROPERTY),
|
||||
),
|
||||
@@ -1338,6 +1343,14 @@ impl Game<'_> {
|
||||
{
|
||||
self.finish_report(recording, state.view, state.max_players);
|
||||
}
|
||||
if horizon_client::spans::trace::due()
|
||||
&& let Some(events) = horizon_client::spans::trace::stop()
|
||||
{
|
||||
log(
|
||||
"INFO",
|
||||
&format!("Trace written: {events} events (reports/)"),
|
||||
);
|
||||
}
|
||||
// DedicatedServer.sendLowDiskSpaceWarning: the operators hear of it
|
||||
// (no window drains the toasts here).
|
||||
if horizon_client::chunk_io::take().low_disk_space
|
||||
@@ -1640,6 +1653,38 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Horizon's own, on the console only: every thread's spans as a timeline (report.rs).
|
||||
"horizon-trace" if rest.first() == Some(&"stop") => {
|
||||
match horizon_client::spans::trace::stop() {
|
||||
Some(events) => log("INFO", &format!("Trace stopped: {events} events written")),
|
||||
None => log("INFO", "No trace is being written"),
|
||||
}
|
||||
}
|
||||
"horizon-trace" => {
|
||||
let seconds = match rest.first().map(|s| s.parse::<u32>()) {
|
||||
None => Some(30),
|
||||
Some(Ok(s)) if (1..=report::TRACE_MAX_SECONDS).contains(&s) => Some(s),
|
||||
Some(_) => None,
|
||||
};
|
||||
match seconds.map(report::start_trace) {
|
||||
None => log(
|
||||
"INFO",
|
||||
&format!(
|
||||
"horizon-trace [SECONDS | stop]: 1 to {} seconds, 30 by default",
|
||||
report::TRACE_MAX_SECONDS
|
||||
),
|
||||
),
|
||||
Some(Ok(path)) => log(
|
||||
"INFO",
|
||||
&format!(
|
||||
"Writing a trace for {} s to {} (open it in ui.perfetto.dev)",
|
||||
seconds.unwrap_or_default(),
|
||||
path.display()
|
||||
),
|
||||
),
|
||||
Some(Err(e)) => log("ERROR", &format!("Could not start a trace: {e}")),
|
||||
}
|
||||
}
|
||||
"save-all" => {
|
||||
self.console_reply(Text::translate("commands.save.saving", &[]));
|
||||
let saved = self.save_game();
|
||||
|
||||
@@ -560,6 +560,20 @@ fn process_limits() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The longest `horizon-trace` writes for.
|
||||
pub(super) const TRACE_MAX_SECONDS: u32 = 300;
|
||||
|
||||
/// `horizon-trace SECONDS`: every thread's spans for `seconds`, to `reports/trace-<time>.json`
|
||||
/// (Perfetto opens it).
|
||||
pub(super) fn start_trace(seconds: u32) -> std::io::Result<PathBuf> {
|
||||
let dir = Path::new("reports");
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let stamp = chrono::Utc::now().format("%Y-%m-%d_%H-%M-%SZ");
|
||||
let path = dir.join(format!("trace-{stamp}.json"));
|
||||
spans::trace::start(&path, Some(seconds))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn write_report(dir: &Path, report: &Value, text: &str) -> std::io::Result<PathBuf> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let stamp = chrono::Utc::now().format("%Y-%m-%d_%H-%M-%SZ");
|
||||
|
||||
@@ -3580,6 +3580,8 @@ impl Game<'_> {
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
needs_sync: false,
|
||||
idle: false,
|
||||
bare: false,
|
||||
leash: None,
|
||||
spectator: false,
|
||||
watching: false,
|
||||
|
||||
@@ -11,6 +11,7 @@ use horizon_client::{
|
||||
enchant::{EffectContext, Enchantments, level_value},
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
projectiles::Projectile,
|
||||
};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_world::{fluid::FluidKind, level::LevelAccess, registry::BlockStateId};
|
||||
@@ -654,17 +655,27 @@ impl Game<'_> {
|
||||
) == 0.0;
|
||||
(f64::from(power), infinity)
|
||||
}
|
||||
/// `EnchantmentHelper.onProjectileSpawned` ignite effects of the held bow
|
||||
/// (flame): the seconds the new arrow burns for.
|
||||
pub(super) fn bow_projectile_ignite(&mut self) -> Option<f32> {
|
||||
let bow = self.held().cloned()?;
|
||||
/// `EnchantmentHelper.onProjectileSpawned`, which `AbstractArrow`'s
|
||||
/// constructor runs for every arrow fired from a weapon, bow or crossbow:
|
||||
/// the weapon's `projectile_spawned` ignite effects (Flame) spawn the arrow
|
||||
/// burning.
|
||||
pub(super) fn ignite_spawned_arrow(&mut self, weapon: &ItemStack, arrow: &mut Projectile) {
|
||||
if let Some(seconds) = self.projectile_spawned_ignite(weapon)
|
||||
&& let Some(a) = arrow.arrow.as_mut()
|
||||
{
|
||||
a.ignite_for_seconds(seconds);
|
||||
}
|
||||
}
|
||||
/// The `projectile_spawned` ignite effects of `weapon` (Flame): the seconds
|
||||
/// the new arrow burns for, the longest when several apply.
|
||||
fn projectile_spawned_ignite(&mut self, weapon: &ItemStack) -> Option<f32> {
|
||||
let ctx = EffectContext {
|
||||
this: Some("minecraft:arrow"),
|
||||
items: Some(world_items!(self)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut ignite: Option<f32> = None;
|
||||
for (id, level) in bow.enchantments() {
|
||||
for (id, level) in weapon.enchantments() {
|
||||
let Some(def) = world_stations!(self).enchantments.get(id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -187,28 +187,51 @@ impl Game<'_> {
|
||||
};
|
||||
bite(self);
|
||||
self.each_guest_hit(bite);
|
||||
// `AbstractIllager.considersEntityAsAlly`: with no teams, the illager
|
||||
// tag is allied; `Evoker.considersEntityAsAlly` adds a vex whose
|
||||
// owner is allied (itself or another illager). Teams are not modelled.
|
||||
let illager = |s: Species| {
|
||||
matches!(
|
||||
s,
|
||||
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator
|
||||
)
|
||||
};
|
||||
// `Entity.isAlliedTo`: the owner's or the target's
|
||||
// `considersEntityAsAlly`, teams included.
|
||||
let teams = self.mob_teams();
|
||||
let illagers: Vec<u32> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| illager(m.species()))
|
||||
.filter(|m| is_illager(m.species()))
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
// A missing owner hurts everyone (`currentOwner == null`).
|
||||
let owner_known = illagers.contains(&f.owner);
|
||||
let team_of = |id: u32| teams.of(horizon_client::mobs::Who::Mob(id));
|
||||
let allies: Vec<u32> = if owner_known {
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
let other = Party {
|
||||
illager: is_illager(m.species()),
|
||||
team: team_of(m.id),
|
||||
};
|
||||
let root = (m.species() == Species::Vex)
|
||||
.then_some(m.extra.counter2 as u32)
|
||||
.filter(|r| illagers.contains(r))
|
||||
.map(|r| {
|
||||
(
|
||||
r == f.owner,
|
||||
Party {
|
||||
illager: true,
|
||||
team: team_of(r),
|
||||
},
|
||||
)
|
||||
});
|
||||
let owner = Party {
|
||||
illager: true,
|
||||
team: team_of(f.owner),
|
||||
};
|
||||
fangs_allied(owner, other, root)
|
||||
})
|
||||
.map(|m| m.id)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for m in &mut self.mobs {
|
||||
let ally = owner_known
|
||||
&& (illager(m.species())
|
||||
|| m.species() == Species::Vex
|
||||
&& illagers.contains(&(m.extra.counter2 as u32)));
|
||||
let ally = allies.contains(&m.id);
|
||||
if m.id != f.owner && !ally && m.alive() && m.bounds().intersects(b) {
|
||||
m.damage(
|
||||
6.0,
|
||||
@@ -293,12 +316,41 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `EntityTypeTags.ILLAGER` members, the types of `ILLAGER_FRIENDS`.
|
||||
fn is_illager(s: Species) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator
|
||||
)
|
||||
}
|
||||
/// One side of an ally check: whether it is an illager and its team.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Party<'a> {
|
||||
illager: bool,
|
||||
team: Option<&'a horizon_client::scoreboard::Team>,
|
||||
}
|
||||
/// `AbstractIllager.considersEntityAsAlly(other)`: the base rule (the teams
|
||||
/// are allied), or an illager friend while both have no team.
|
||||
fn illager_considers(me: Party, other: Party) -> bool {
|
||||
horizon_client::scoreboard::teams_allied(me.team, other.team)
|
||||
|| other.illager && me.team.is_none() && other.team.is_none()
|
||||
}
|
||||
/// `owner.isAlliedTo(other)` for an evoker owner: `Evoker.considersEntityAsAlly`
|
||||
/// either way round. `vex_root` is the root owner of a vex target (whether it
|
||||
/// is the owner itself, and its party); the owner treats such a vex as an ally
|
||||
/// when its root owner is.
|
||||
fn fangs_allied(owner: Party, other: Party, vex_root: Option<(bool, Party)>) -> bool {
|
||||
illager_considers(owner, other)
|
||||
|| other.illager && illager_considers(other, owner)
|
||||
|| !other.illager && horizon_client::scoreboard::teams_allied(other.team, owner.team)
|
||||
|| vex_root.is_some_and(|(is_owner, root)| is_owner || illager_considers(owner, root))
|
||||
}
|
||||
fn p_zrot(p: &mut Poses, part: &str, v: f32) {
|
||||
p.zrot(part, v);
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{fang_attack_pitch, fang_crit};
|
||||
use super::{Party, fang_attack_pitch, fang_crit, fangs_allied};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
|
||||
#[test]
|
||||
@@ -334,4 +386,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn party(illager: bool, team: Option<&horizon_client::scoreboard::Team>) -> Party<'_> {
|
||||
Party { illager, team }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fangs_allies_follow_teams_and_the_illager_tag() {
|
||||
let mut board = horizon_client::scoreboard::Scoreboard::default();
|
||||
board.add_team("red");
|
||||
board.add_team("blue");
|
||||
let red = board.team("red").unwrap();
|
||||
let blue = board.team("blue").unwrap();
|
||||
let evoker = |t| party(true, t);
|
||||
// Teamless illagers are friends.
|
||||
assert!(fangs_allied(evoker(None), party(true, None), None));
|
||||
// Different teams: the illager tag no longer applies.
|
||||
assert!(!fangs_allied(
|
||||
evoker(Some(red)),
|
||||
party(true, Some(blue)),
|
||||
None
|
||||
));
|
||||
// One side with a team: still not allied.
|
||||
assert!(!fangs_allied(evoker(Some(red)), party(true, None), None));
|
||||
// The same team allies a non-illager mob.
|
||||
assert!(fangs_allied(
|
||||
evoker(Some(red)),
|
||||
party(false, Some(red)),
|
||||
None
|
||||
));
|
||||
assert!(!fangs_allied(evoker(None), party(false, None), None));
|
||||
// A vex follows its root owner.
|
||||
assert!(fangs_allied(
|
||||
evoker(None),
|
||||
party(false, None),
|
||||
Some((true, evoker(None)))
|
||||
));
|
||||
assert!(!fangs_allied(
|
||||
evoker(Some(red)),
|
||||
party(false, None),
|
||||
Some((false, party(true, Some(blue))))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -349,6 +349,34 @@ impl Game<'_> {
|
||||
"vz": hook.velocity.z.to_bits() as i64, "nibble": hook.nibble, "next": hook.random.next_long(),
|
||||
}));
|
||||
}
|
||||
// The client must take the same block hit before moving as the server;
|
||||
// otherwise it lands farther away and slides back on the next update.
|
||||
let stone = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
self.level_set(block, stone).unwrap();
|
||||
for (case, motion) in [
|
||||
Vec3::new(0.2, -0.4, 0.1),
|
||||
Vec3::new(-0.2, -0.4, 0.1),
|
||||
Vec3::new(0.0, -0.6, -0.2),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut state = (Vec3::new(x, 301.1, z), motion, State::Flying, false);
|
||||
let mut random = LegacyRandom::new(0);
|
||||
for tick in 0..5 {
|
||||
state = self.client_hook_step(state, &mut random);
|
||||
rows.push(serde_json::json!({
|
||||
"case": case, "tick": tick,
|
||||
"position": [state.0.x - f64::from(block[0]), state.0.y, state.0.z - f64::from(block[2])],
|
||||
"motion": [state.1.x, state.1.y, state.1.z],
|
||||
}));
|
||||
}
|
||||
}
|
||||
self.hook = old_hook;
|
||||
self.offhand = old_offhand;
|
||||
self.player.as_mut().unwrap().position = old_position;
|
||||
@@ -874,27 +902,8 @@ impl Game<'_> {
|
||||
// checkCollision: find the first block and entity on the movement
|
||||
// segment so a fast hook cannot tunnel or catch through a wall.
|
||||
let to = h.position.add_vector(h.velocity);
|
||||
let block_hit = {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
traverse_blocks(h.position, to, 1024, |p: BlockPos| {
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
if self.res.facts().is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = self
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?;
|
||||
shape.clip(h.position, to, p)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
};
|
||||
let entity_end = block_hit.map_or(to, |hit| hit.location);
|
||||
let block_hit = self.hook_block_hit(h.position, to);
|
||||
let entity_end = block_hit.unwrap_or(to);
|
||||
let owner = self.lan_acting;
|
||||
let own_vehicle = self.own_vehicle();
|
||||
let hit = first_entity_hit(
|
||||
@@ -968,9 +977,9 @@ impl Game<'_> {
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
Some(horizon_client::mobs::Who::Player),
|
||||
[hit.location.x, hit.location.y, hit.location.z],
|
||||
[hit.x, hit.y, hit.z],
|
||||
);
|
||||
h.velocity = rebound(h.velocity, hit.location, h.position);
|
||||
h.velocity = rebound(h.velocity, hit, h.position);
|
||||
}
|
||||
}
|
||||
State::Hooked(id) => {
|
||||
@@ -1060,7 +1069,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if !in_water && !h.on_ground {
|
||||
h.velocity.y -= 0.03;
|
||||
h.velocity.y -= f64::from(0.03_f32);
|
||||
}
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
@@ -1584,6 +1593,28 @@ impl Game<'_> {
|
||||
self.remote_hooks = hooks;
|
||||
}
|
||||
|
||||
/// `FishingHook.checkCollision` clips blocks on both sides of the connection.
|
||||
fn hook_block_hit(&self, from: Vec3, to: Vec3) -> Option<Vec3> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
traverse_blocks(from, to, 1024, |p: BlockPos| {
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
if self.res.facts().is_air(state) {
|
||||
return None;
|
||||
}
|
||||
self.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?
|
||||
.clip(from, to, p)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|hit| hit.location)
|
||||
}
|
||||
|
||||
/// `FishingHook.tick`: one tick of movement without the fishing (the
|
||||
/// server's): position, motion, state and whether it rests on the ground.
|
||||
fn client_hook_step(
|
||||
@@ -1608,7 +1639,11 @@ impl Game<'_> {
|
||||
State::Flying if velocity == Vec3::ZERO && on_ground => {
|
||||
return (position, velocity, state, on_ground);
|
||||
}
|
||||
State::Flying => {}
|
||||
State::Flying => {
|
||||
if let Some(hit) = self.hook_block_hit(position, position.add_vector(velocity)) {
|
||||
velocity = rebound(velocity, hit, position);
|
||||
}
|
||||
}
|
||||
State::Hooked(id) => {
|
||||
// updateHookedEntity: at 0.8 of the hooked entity's height.
|
||||
let hooked = if self
|
||||
@@ -1661,7 +1696,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if !in_water && !on_ground {
|
||||
velocity.y -= 0.03;
|
||||
velocity.y -= f64::from(0.03_f32);
|
||||
}
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
|
||||
@@ -20,7 +20,11 @@ impl Game<'_> {
|
||||
let mut near = super::item_areas::ItemAreas::default();
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Fox || !m.alive() || m.removed {
|
||||
if m.species() != Species::Fox
|
||||
|| !m.alive()
|
||||
|| m.removed
|
||||
|| !self.creatures.ticked.contains(&m.id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// `Mob.aiStep`: the items in the box grown by (1, 0, 1), in order.
|
||||
@@ -82,7 +86,11 @@ impl Game<'_> {
|
||||
let mut near = super::item_areas::ItemAreas::default();
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Dolphin || !m.alive() || m.removed {
|
||||
if m.species() != Species::Dolphin
|
||||
|| !m.alive()
|
||||
|| m.removed
|
||||
|| !self.creatures.ticked.contains(&m.id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// `Mob.aiStep`: the items in the box grown by (1, 0, 1); `Dolphin.pickUpItem`
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//! test each mob's reach against every item (`Mob.aiStep`'s looting, the
|
||||
//! foxes' and dolphins' item searches). A reach that meets no cell holding an
|
||||
//! item meets no item, so its scan, which would find nothing, is skipped; a
|
||||
//! reach near an item scans as before, in the same order, with the same tests.
|
||||
use horizon_client::{entities::ItemEntity, fast_hash::FxHashSet, join_seq::JoinSeq};
|
||||
//! reach near an item scans as before, in the same order, with the same tests,
|
||||
//! jumping over the items of the cells it doesn't meet ([`ItemScan`]).
|
||||
use horizon_client::{entities::ItemEntity, fast_hash::FxHashMap, join_seq::JoinSeq};
|
||||
use horizon_core::geometry::Aabb;
|
||||
|
||||
/// Cells of 8 blocks on each axis.
|
||||
@@ -11,7 +12,8 @@ const SHIFT: i32 = 3;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ItemAreas {
|
||||
cells: FxHashSet<[i32; 3]>,
|
||||
/// The items in each cell, by index, in the list's order.
|
||||
cells: FxHashMap<[i32; 3], Vec<u32>>,
|
||||
/// What the cells were built from: the list's length, the last entity id
|
||||
/// the game handed out and the next join number. An item spawned or
|
||||
/// removed since changes one of them, and the cells are built again.
|
||||
@@ -21,40 +23,137 @@ pub(super) struct ItemAreas {
|
||||
impl ItemAreas {
|
||||
/// Whether an item of `items` may meet `area`: false only when none can.
|
||||
pub(super) fn may_meet(&mut self, items: &[ItemEntity], next_entity: u32, area: Aabb) -> bool {
|
||||
let key = (items.len(), next_entity, JoinSeq::upcoming().get());
|
||||
if self.built != Some(key) {
|
||||
self.cells.clear();
|
||||
self.cells.extend(items.iter().map(|e| {
|
||||
let p = e.position;
|
||||
[cell(p.x), cell(p.y), cell(p.z)]
|
||||
}));
|
||||
self.built = Some(key);
|
||||
}
|
||||
self.build(items, next_entity);
|
||||
if self.cells.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let (lo, hi) = (area.min_position(), area.max_position());
|
||||
if !lo.is_finite() || !hi.is_finite() {
|
||||
let Some((from, to)) = cell_span(area) else {
|
||||
return true;
|
||||
}
|
||||
// An item's box spans its width sideways and upwards from its
|
||||
// position; a margin of a whole width keeps rounding on the safe side.
|
||||
let m = ItemEntity::WIDTH;
|
||||
let from = [cell(lo.x - m), cell(lo.y - 2.0 * m), cell(lo.z - m)];
|
||||
let to = [cell(hi.x + m), cell(hi.y + m), cell(hi.z + m)];
|
||||
let span = (0..3)
|
||||
.map(|a| i64::from(to[a]) - i64::from(from[a]) + 1)
|
||||
.product::<i64>();
|
||||
if span > self.cells.len() as i64 {
|
||||
};
|
||||
if span(from, to) > self.cells.len() as i64 {
|
||||
return self
|
||||
.cells
|
||||
.iter()
|
||||
.keys()
|
||||
.any(|c| (0..3).all(|a| (from[a]..=to[a]).contains(&c[a])));
|
||||
}
|
||||
(from[2]..=to[2]).any(|z| {
|
||||
(from[1]..=to[1]).any(|y| (from[0]..=to[0]).any(|x| self.cells.contains(&[x, y, z])))
|
||||
(from[1]..=to[1])
|
||||
.any(|y| (from[0]..=to[0]).any(|x| self.cells.contains_key(&[x, y, z])))
|
||||
})
|
||||
}
|
||||
|
||||
/// A scan of `items` for `area`, which jumps over the items that can't
|
||||
/// meet it; empty when none can ([`Self::may_meet`] false).
|
||||
pub(super) fn scan(&mut self, items: &[ItemEntity], next_entity: u32, area: Aabb) -> ItemScan {
|
||||
let all = ItemScan {
|
||||
near: None,
|
||||
passed: 0,
|
||||
len: items.len(),
|
||||
};
|
||||
if !self.may_meet(items, next_entity, area) {
|
||||
return ItemScan {
|
||||
near: Some(Vec::new()),
|
||||
..all
|
||||
};
|
||||
}
|
||||
let Some((from, to)) = cell_span(area) else {
|
||||
return all;
|
||||
};
|
||||
if span(from, to) > self.cells.len() as i64 {
|
||||
return all;
|
||||
}
|
||||
let mut near = Vec::new();
|
||||
for z in from[2]..=to[2] {
|
||||
for y in from[1]..=to[1] {
|
||||
for x in from[0]..=to[0] {
|
||||
if let Some(list) = self.cells.get(&[x, y, z]) {
|
||||
near.extend(list.iter().map(|&i| i as usize));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
near.sort_unstable();
|
||||
ItemScan {
|
||||
near: Some(near),
|
||||
..all
|
||||
}
|
||||
}
|
||||
|
||||
fn build(&mut self, items: &[ItemEntity], next_entity: u32) {
|
||||
let key = (items.len(), next_entity, JoinSeq::upcoming().get());
|
||||
if self.built == Some(key) {
|
||||
return;
|
||||
}
|
||||
self.cells.clear();
|
||||
for (i, e) in items.iter().enumerate() {
|
||||
let p = e.position;
|
||||
let at = [cell(p.x), cell(p.y), cell(p.z)];
|
||||
self.cells.entry(at).or_default().push(i as u32);
|
||||
}
|
||||
self.built = Some(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// One reach's walk over the items, in the list's order: [`Self::next_from`] gives
|
||||
/// the next index to test, past the items that lie in cells the reach doesn't
|
||||
/// meet. Told of each item the walk removes, it keeps up; if the list changes
|
||||
/// any other way (an item joins), the walk tests every item from there on.
|
||||
pub(super) struct ItemScan {
|
||||
/// The items in the cells the reach meets, by index; `None`: every item.
|
||||
near: Option<Vec<usize>>,
|
||||
/// The first of `near` not yet passed.
|
||||
passed: usize,
|
||||
/// The list's length as the walk knows it.
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl ItemScan {
|
||||
/// Whether no item can meet the reach.
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.near.as_ref().is_some_and(Vec::is_empty)
|
||||
}
|
||||
/// The first index from `j` the walk must test (`len` or more: done).
|
||||
pub(super) fn next_from(&mut self, j: usize, len: usize) -> usize {
|
||||
if len != self.len {
|
||||
self.near = None;
|
||||
}
|
||||
let Some(near) = &self.near else {
|
||||
return j;
|
||||
};
|
||||
while near.get(self.passed).is_some_and(|&i| i < j) {
|
||||
self.passed += 1;
|
||||
}
|
||||
near.get(self.passed).copied().unwrap_or(len).max(j)
|
||||
}
|
||||
/// The walk removed item `j`: the items after it moved down one.
|
||||
pub(super) fn removed(&mut self, j: usize) {
|
||||
self.len -= 1;
|
||||
if let Some(near) = &mut self.near {
|
||||
for i in near.iter_mut().filter(|i| **i > j) {
|
||||
*i -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cells an item meeting `area` may lie in: an item's box spans its width
|
||||
/// sideways and upwards from its position, and a margin of a whole width
|
||||
/// keeps rounding on the safe side. `None` for an area that isn't finite.
|
||||
fn cell_span(area: Aabb) -> Option<([i32; 3], [i32; 3])> {
|
||||
let (lo, hi) = (area.min_position(), area.max_position());
|
||||
if !lo.is_finite() || !hi.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let m = ItemEntity::WIDTH;
|
||||
let from = [cell(lo.x - m), cell(lo.y - 2.0 * m), cell(lo.z - m)];
|
||||
let to = [cell(hi.x + m), cell(hi.y + m), cell(hi.z + m)];
|
||||
Some((from, to))
|
||||
}
|
||||
|
||||
fn span(from: [i32; 3], to: [i32; 3]) -> i64 {
|
||||
(0..3)
|
||||
.map(|a| i64::from(to[a]) - i64::from(from[a]) + 1)
|
||||
.product()
|
||||
}
|
||||
|
||||
fn cell(v: f64) -> i32 {
|
||||
@@ -136,6 +235,77 @@ mod tests {
|
||||
assert!(!cells.may_meet(&[], 1, near));
|
||||
}
|
||||
|
||||
/// The items a walk tests that meet `area`, by position: what the scans
|
||||
/// act on. `take` removes the item it names when the walk meets it.
|
||||
fn walk(items: &mut Vec<ItemEntity>, area: Aabb, take: Option<f64>) -> Vec<f64> {
|
||||
let mut cells = ItemAreas::default();
|
||||
let mut scan = cells.scan(items, 1, area);
|
||||
let (mut met, mut j) = (Vec::new(), 0);
|
||||
loop {
|
||||
j = scan.next_from(j, items.len());
|
||||
let Some(e) = items.get(j) else {
|
||||
break;
|
||||
};
|
||||
if !area.intersects(e.bounds()) {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
met.push(e.position.x);
|
||||
if take == Some(e.position.x) {
|
||||
items.remove(j);
|
||||
scan.removed(j);
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
met
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_walk_meets_what_a_full_scan_meets_in_the_same_order() {
|
||||
let mut items = Vec::new();
|
||||
for i in 0..60 {
|
||||
let x = -40.0 + f64::from(i) * 1.37;
|
||||
items.push(item_at(
|
||||
x,
|
||||
64.0 + f64::from(i % 3) * 0.6,
|
||||
(x * 0.7).sin() * 9.0,
|
||||
));
|
||||
}
|
||||
for k in 0..40 {
|
||||
let x = -42.0 + f64::from(k) * 2.11;
|
||||
let area = Aabb::new(Vec3::new(x, 63.5, -10.0), Vec3::new(x + 3.3, 65.5, 10.0));
|
||||
let full: Vec<f64> = items
|
||||
.iter()
|
||||
.filter(|e| area.intersects(e.bounds()))
|
||||
.map(|e| e.position.x)
|
||||
.collect();
|
||||
assert_eq!(walk(&mut items.clone(), area, None), full, "{area:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_walk_keeps_up_with_the_items_it_removes() {
|
||||
let xs = [1.0, 30.0, 1.5, 31.0, 2.0, 2.5];
|
||||
let mut items: Vec<ItemEntity> = xs.iter().map(|&x| item_at(x, 64.0, 1.0)).collect();
|
||||
let area = Aabb::new(Vec3::new(0.0, 63.0, 0.0), Vec3::new(3.0, 65.0, 2.0));
|
||||
assert_eq!(walk(&mut items, area, Some(1.5)), vec![1.0, 1.5, 2.0, 2.5]);
|
||||
assert_eq!(items.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_walk_tests_an_item_that_joins_during_it() {
|
||||
let mut items = vec![item_at(1.0, 64.0, 1.0), item_at(40.0, 64.0, 1.0)];
|
||||
let area = Aabb::new(Vec3::new(0.0, 63.0, 0.0), Vec3::new(3.0, 65.0, 2.0));
|
||||
let mut cells = ItemAreas::default();
|
||||
let mut scan = cells.scan(&items, 1, area);
|
||||
assert_eq!(scan.next_from(0, items.len()), 0);
|
||||
// One joins at the end, inside the reach, after the walk began.
|
||||
items.push(item_at(2.0, 64.0, 1.0));
|
||||
assert_eq!(scan.next_from(1, items.len()), 1);
|
||||
assert_eq!(scan.next_from(2, items.len()), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_spawned_after_the_cells_were_built_is_seen() {
|
||||
let mut items = vec![item_at(100.5, 64.0, 100.5)];
|
||||
|
||||
@@ -268,6 +268,8 @@ pub(super) struct LanHost {
|
||||
/// The entity id counter when this tick's states were taken for guests
|
||||
/// (`lan_note_joined`), None when no guest tracked them.
|
||||
pub(super) states_next_entity: Option<u32>,
|
||||
/// Last tick's states of the idle mobs and items (game/lan_idle.rs).
|
||||
pub(super) idle_states: super::lan_idle::IdleStates,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -1448,6 +1450,7 @@ impl Game<'_> {
|
||||
sleep_ignored: Default::default(),
|
||||
states: Default::default(),
|
||||
states_next_entity: None,
|
||||
idle_states: Default::default(),
|
||||
});
|
||||
self.audio.capture = Some(Vec::new());
|
||||
Ok(())
|
||||
@@ -1896,7 +1899,9 @@ impl Game<'_> {
|
||||
|
||||
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
|
||||
/// `mob_players` is the guest each mob sees as its player (game/lan_mobs.rs).
|
||||
/// Only the entities whose id is above `after` (0: all of them).
|
||||
/// Only the entities whose id is above `after` (0: all of them). With
|
||||
/// `kept`, idle mobs and items no client can pair with are described bare
|
||||
/// (game/lan_idle.rs).
|
||||
fn lan_entities(
|
||||
&mut self,
|
||||
mob_players: &horizon_client::fast_hash::FxHashMap<u32, u32>,
|
||||
@@ -1904,16 +1909,24 @@ impl Game<'_> {
|
||||
out: &mut States,
|
||||
drain_minecart_steps: bool,
|
||||
after: u32,
|
||||
mut kept: Option<&mut super::lan_idle::IdlePass<'_>>,
|
||||
) {
|
||||
out.clear();
|
||||
let mut speeds = super::lan_attributes::SpeedByType::default();
|
||||
// ChunkMap.tick sends the changes of an entity outside the
|
||||
// entity-ticking range only when it changed section or needs sync.
|
||||
let levels = self.tickets.levels();
|
||||
let idle_at = |p: horizon_core::geometry::Vec3| {
|
||||
!levels.in_entity_ticking_range((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4)
|
||||
};
|
||||
for m in self.mobs.iter().filter(|m| !m.removed && m.id > after) {
|
||||
// The lead's holder: the player the mob sees, another mob or a
|
||||
// fence knot (game/lan_entities.rs).
|
||||
let player_entity = mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id);
|
||||
let player_entity = || {
|
||||
(mob_players.get(&m.id))
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
};
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => match m.extra.leash_player {
|
||||
Some(u) => hub
|
||||
@@ -1921,21 +1934,37 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|g| playerdata::uuid_ints(g.uuid) == u)
|
||||
.map(|g| g.entity_id),
|
||||
None => player_entity,
|
||||
None => player_entity(),
|
||||
},
|
||||
Holder::Mob(id) | Holder::Boat(id) => Some(id as i32),
|
||||
Holder::Knot(p) => self.knot_ids.get(&p).map(|&id| id as i32),
|
||||
});
|
||||
let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str());
|
||||
e.position = [m.position.x, m.position.y, m.position.z];
|
||||
(e.yaw, e.pitch, e.head_yaw) = (m.yaw, m.head_pitch, m.head_yaw);
|
||||
e.on_ground = m.on_ground;
|
||||
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
|
||||
let idle = idle_at(m.position);
|
||||
let place = super::lan_idle::Place {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
rotation: (m.yaw, m.head_pitch, m.head_yaw),
|
||||
on_ground: m.on_ground,
|
||||
velocity: [m.velocity.x, m.velocity.y, m.velocity.z],
|
||||
};
|
||||
let kind = m.kind.id.as_str();
|
||||
let bare = idle
|
||||
&& (kept.as_deref_mut()).is_some_and(|k| k.bare(hub, m.id as i32, kind, &place))
|
||||
&& !m.needs_sync;
|
||||
let e = out.add(m.id as i32, entity_uuid(m.id), kind);
|
||||
e.position = place.position;
|
||||
(e.yaw, e.pitch, e.head_yaw) = place.rotation;
|
||||
e.on_ground = place.on_ground;
|
||||
e.velocity = place.velocity;
|
||||
e.needs_sync = m.needs_sync;
|
||||
(e.idle, e.bare) = (idle, bare);
|
||||
e.leash = leash;
|
||||
if bare {
|
||||
continue;
|
||||
}
|
||||
mob_meta(
|
||||
m,
|
||||
hub,
|
||||
player_entity,
|
||||
player_entity(),
|
||||
self.world.game_time,
|
||||
&mut e.meta,
|
||||
&mut e.defaults,
|
||||
@@ -1946,14 +1975,28 @@ impl Game<'_> {
|
||||
mob_freeze_meta(m, &mut e.meta, &mut e.defaults);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut speeds, &mut e.attributes);
|
||||
e.leash = leash;
|
||||
}
|
||||
for d in self.dropped.iter().filter(|d| d.id > after) {
|
||||
let stack = hub.stack(&Some(d.stack.clone()));
|
||||
let idle = idle_at(d.position);
|
||||
let place = super::lan_idle::Place {
|
||||
position: [d.position.x, d.position.y, d.position.z],
|
||||
rotation: (0.0, 0.0, 0.0),
|
||||
on_ground: d.on_ground,
|
||||
velocity: [d.velocity.x, d.velocity.y, d.velocity.z],
|
||||
};
|
||||
let bare = idle
|
||||
&& (kept.as_deref_mut())
|
||||
.is_some_and(|k| k.bare(hub, d.id as i32, "minecraft:item", &place))
|
||||
&& !d.needs_sync;
|
||||
let e = out.add(d.id as i32, entity_uuid(d.id), "minecraft:item");
|
||||
e.position = [d.position.x, d.position.y, d.position.z];
|
||||
e.on_ground = d.on_ground;
|
||||
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
|
||||
e.position = place.position;
|
||||
e.on_ground = place.on_ground;
|
||||
e.velocity = place.velocity;
|
||||
(e.idle, e.bare) = (idle, bare);
|
||||
if bare {
|
||||
continue;
|
||||
}
|
||||
let stack = hub.stack(&Some(d.stack.clone()));
|
||||
// `ServerEntity.getRequiresPrecisePosition` is false for an item:
|
||||
// `needs_sync` asks for its data again, not for an exact position.
|
||||
// The collision case is `full_precision_required` in the tracker.
|
||||
@@ -1970,6 +2013,7 @@ impl Game<'_> {
|
||||
for o in self.orbs.iter().filter(|o| o.id > after) {
|
||||
let e = out.add(o.id as i32, entity_uuid(o.id), "minecraft:experience_orb");
|
||||
e.position = [o.position.x, o.position.y, o.position.z];
|
||||
e.idle = idle_at(o.position);
|
||||
e.on_ground = o.on_ground;
|
||||
e.velocity = [o.velocity.x, o.velocity.y, o.velocity.z];
|
||||
// ExperienceOrb.DATA_VALUE.
|
||||
@@ -2460,7 +2504,14 @@ impl Game<'_> {
|
||||
self.lan_assign_ids();
|
||||
if self.next_entity != next {
|
||||
let mut states = States::default();
|
||||
self.lan_entities(&host.mob_players, &mut host.hub, &mut states, false, next);
|
||||
self.lan_entities(
|
||||
&host.mob_players,
|
||||
&mut host.hub,
|
||||
&mut states,
|
||||
false,
|
||||
next,
|
||||
None,
|
||||
);
|
||||
self.lan_hook_states(&host.play, &host.hub, &mut states);
|
||||
self.lan_entity_uuids(&mut states);
|
||||
host.hub.note_joined(states.as_slice());
|
||||
@@ -2537,8 +2588,31 @@ impl Game<'_> {
|
||||
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
|
||||
.collect();
|
||||
self.lan_riding(&guests, &mut host.hub.riding);
|
||||
// Idle states are kept only when the players' moves are in:
|
||||
// the clients that could pair are known. The range scale is
|
||||
// the one the tracker gets below.
|
||||
let mut kept = if self.level_pass.packets_drained {
|
||||
let percent = if host.hub.config.integrated {
|
||||
(self.settings.entity_distance * 100.0).round() as i32
|
||||
} else {
|
||||
host.hub.entity_range_percent
|
||||
};
|
||||
host.idle_states.pass(&host.hub, percent)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
horizon_client::spans::time("lan_entity_states", || {
|
||||
self.lan_entities(&host.mob_players, &mut host.hub, &mut states, true, 0);
|
||||
self.lan_entities(
|
||||
&host.mob_players,
|
||||
&mut host.hub,
|
||||
&mut states,
|
||||
true,
|
||||
0,
|
||||
kept.as_mut(),
|
||||
);
|
||||
if let Some(kept) = kept {
|
||||
kept.finish();
|
||||
}
|
||||
self.lan_hook_states(&host.play, &host.hub, &mut states);
|
||||
self.lan_entity_uuids(&mut states);
|
||||
});
|
||||
@@ -2803,7 +2877,14 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
// A dedicated server has no host player.
|
||||
self.lan_entities(&host.mob_players, &mut host.hub, &mut theirs, false, 0);
|
||||
self.lan_entities(
|
||||
&host.mob_players,
|
||||
&mut host.hub,
|
||||
&mut theirs,
|
||||
false,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
self.lan_hook_states(&host.play, &host.hub, &mut theirs);
|
||||
self.lan_entity_uuids(&mut theirs);
|
||||
theirs.as_slice()
|
||||
|
||||
@@ -3333,7 +3333,10 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let (old_yaw, old_pitch) = (host.hub.guests[gi].yaw, host.hub.guests[gi].pitch);
|
||||
if self.look_at_entity(host, gi, id, None).is_none() {
|
||||
let boat = self.boats.iter().position(|boat| boat.id as i32 == id);
|
||||
// A packet names its target already. Keep the attacker's actual yaw
|
||||
// for the boat's knockback instead of aiming a ray at its center.
|
||||
if boat.is_none() && self.look_at_entity(host, gi, id, None).is_none() {
|
||||
return;
|
||||
}
|
||||
// A mace's smash sets the attacker's velocity and sends it at once, before the
|
||||
@@ -3344,7 +3347,12 @@ impl Game<'_> {
|
||||
.player
|
||||
.as_mut()
|
||||
.is_some_and(|p| std::mem::take(&mut p.health.sync_velocity));
|
||||
g.attack();
|
||||
if let Some(index) = boat {
|
||||
g.strike_boat(index);
|
||||
g.swing_arm();
|
||||
} else {
|
||||
g.attack();
|
||||
}
|
||||
let p = g.player.as_mut()?;
|
||||
let sent = std::mem::replace(&mut p.health.sync_velocity, marked);
|
||||
sent.then_some(p.velocity)
|
||||
|
||||
@@ -50,6 +50,8 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
needs_sync: false,
|
||||
idle: false,
|
||||
bare: false,
|
||||
leash: None,
|
||||
spectator: false,
|
||||
watching: false,
|
||||
@@ -525,7 +527,8 @@ impl Game<'_> {
|
||||
/// selectors use, so a client can name it back (the crosshair UUID that
|
||||
/// `ClientSuggestionProvider.getSelectedEntities` suggests).
|
||||
pub(super) fn lan_entity_uuids(&mut self, states: &mut States) {
|
||||
for e in states.as_mut_slice() {
|
||||
// A bare state is never paired: its UUID is never read.
|
||||
for e in states.as_mut_slice().iter_mut().filter(|e| !e.bare) {
|
||||
e.uuid = ints_uuid(self.uuid_for(e.id as u32));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Idle mobs and items that need no new description this tick (#10253).
|
||||
//!
|
||||
//! An entity outside the entity-ticking range does not tick, and `ChunkMap.tick` does not send
|
||||
//! its changes (`EntityState::idle`) unless it changed section or needs sync. While it stays
|
||||
//! where it was, its state is read only for where it is, and to pair it with a client that
|
||||
//! starts tracking it. So such an entity is described bare (`EntityState::bare`: where it is,
|
||||
//! none of its data) when no client could pair with it this tick: it was in the tracker's last
|
||||
//! pass, has not moved, carries no passengers, and every client within its tracking range
|
||||
//! already tracks it. Otherwise it is described in full, so a pairing always sends the entity
|
||||
//! as it is.
|
||||
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::lan::{hub::Hub, tracking};
|
||||
|
||||
/// Per level, each idle entity's place and the tracking pass it was last described in.
|
||||
#[derive(Default)]
|
||||
pub(super) struct IdleStates {
|
||||
levels: FxHashMap<String, FxHashMap<i32, (u32, Place)>>,
|
||||
}
|
||||
|
||||
/// Where an entity is: its position, rotation (yaw, pitch, head yaw), whether
|
||||
/// it is on the ground, and its velocity.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(super) struct Place {
|
||||
pub position: [f64; 3],
|
||||
pub rotation: (f32, f32, f32),
|
||||
pub on_ground: bool,
|
||||
pub velocity: [f64; 3],
|
||||
}
|
||||
|
||||
/// The idle entities of the level in place, for one pass.
|
||||
pub(super) struct IdlePass<'a> {
|
||||
kept: &'a mut FxHashMap<i32, (u32, Place)>,
|
||||
/// The level's tracking passes so far (`Hub::tracking_ticks`). An entity
|
||||
/// described one pass earlier was in the tracker's last pass.
|
||||
now: u32,
|
||||
/// The players the level's tracker runs for (`Hub::watches`: their
|
||||
/// index), where they are horizontally, and their view distance in blocks:
|
||||
/// the clients that could pair.
|
||||
viewers: Vec<(usize, [f64; 2], i32)>,
|
||||
/// `entity-broadcast-range-percentage`, as the tracker scales ranges.
|
||||
percent: i32,
|
||||
}
|
||||
|
||||
impl IdleStates {
|
||||
/// The level in place's idle entities, or None before its tracking ran.
|
||||
pub(super) fn pass(&mut self, hub: &Hub, percent: i32) -> Option<IdlePass<'_>> {
|
||||
let now = hub.tracking_ticks()?;
|
||||
let viewers = (hub.guests.iter().enumerate())
|
||||
.filter(|(_, g)| hub.watches(g))
|
||||
.map(|(i, g)| (i, [g.position[0], g.position[2]], g.view_distance * 16))
|
||||
.collect();
|
||||
let kept = self.levels.entry(hub.config.dimension.clone()).or_default();
|
||||
Some(IdlePass {
|
||||
kept,
|
||||
now,
|
||||
viewers,
|
||||
percent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IdlePass<'_> {
|
||||
/// Whether the idle entity `id` of type `kind`, now at `at`, can be
|
||||
/// described bare: it was in the tracker's last pass at the same place,
|
||||
/// carries no passengers (whose ranges widen its own), and every client
|
||||
/// within its tracking range already tracks it. Notes it either way.
|
||||
pub(super) fn bare(&mut self, hub: &Hub, id: i32, kind: &str, at: &Place) -> bool {
|
||||
self.unchanged(id, kind, at, |i| hub.guests[i].tracker.is_tracking(id))
|
||||
&& hub.riding.passengers(id).is_empty()
|
||||
}
|
||||
|
||||
/// [`Self::bare`] but for the passengers, with whether the player `i`
|
||||
/// tracks the entity.
|
||||
fn unchanged(
|
||||
&mut self,
|
||||
id: i32,
|
||||
kind: &str,
|
||||
at: &Place,
|
||||
tracks: impl Fn(usize) -> bool,
|
||||
) -> bool {
|
||||
// A new one reads as noted two passes ago.
|
||||
let now = self.now;
|
||||
let noted = self.kept.entry(id).or_insert((now.wrapping_sub(2), *at));
|
||||
let still = noted.0.wrapping_add(1) == now && noted.1 == *at;
|
||||
*noted = (now, *at);
|
||||
if !still {
|
||||
return false;
|
||||
}
|
||||
// The tracker's `sees`, but for the chunk it also asks the client to have.
|
||||
let range = self.percent * (i32::from(tracking::settings(kind).0) * 16) / 100;
|
||||
!self.viewers.iter().any(|&(i, [x, z], view)| {
|
||||
let range = f64::from(range.min(view));
|
||||
let (dx, dz) = (x - at.position[0], z - at.position[2]);
|
||||
dx * dx + dz * dz <= range * range && !tracks(i)
|
||||
})
|
||||
}
|
||||
|
||||
/// Forget the entities this pass did not note: they left, or tick again.
|
||||
pub(super) fn finish(self) {
|
||||
let now = self.now;
|
||||
self.kept.retain(|_, (stamp, _)| *stamp == now);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn place(x: f64) -> Place {
|
||||
Place {
|
||||
position: [x, 64.0, 0.0],
|
||||
rotation: (0.0, 0.0, 0.0),
|
||||
on_ground: true,
|
||||
velocity: [0.0; 3],
|
||||
}
|
||||
}
|
||||
|
||||
fn pass(kept: &mut FxHashMap<i32, (u32, Place)>, now: u32) -> IdlePass<'_> {
|
||||
// One client at the origin, one 400 blocks away; both see 10 chunks.
|
||||
IdlePass {
|
||||
kept,
|
||||
now,
|
||||
viewers: vec![(0, [0.0, 0.0], 160), (1, [400.0, 0.0], 160)],
|
||||
percent: 100,
|
||||
}
|
||||
}
|
||||
|
||||
/// An idle cow (tracked 10 chunks away) is described bare while it stays
|
||||
/// put and every client in its range tracks it; in full when a client
|
||||
/// there could pair with it, when it moved, and after a pass it was not in.
|
||||
#[test]
|
||||
fn an_idle_entity_is_bare_until_a_client_could_pair_or_it_moves() {
|
||||
let mut kept = FxHashMap::default();
|
||||
let cow = "minecraft:cow";
|
||||
let at = place(100.0);
|
||||
// Its first pass describes it in full.
|
||||
assert!(!pass(&mut kept, 5).unchanged(7, cow, &at, |_| true));
|
||||
// Tracked by the client in range; the far one can't pair.
|
||||
let tracked = |i| i == 0;
|
||||
assert!(pass(&mut kept, 6).unchanged(7, cow, &at, tracked));
|
||||
// The client in range doesn't track it yet: it could pair.
|
||||
assert!(!pass(&mut kept, 7).unchanged(7, cow, &at, |_| false));
|
||||
// 100 blocks is past an item's 6 chunks: no client could pair.
|
||||
assert!(pass(&mut kept, 8).unchanged(7, "minecraft:item", &at, |_| false));
|
||||
// It moved.
|
||||
assert!(!pass(&mut kept, 9).unchanged(7, cow, &place(100.5), tracked));
|
||||
// A pass went by without it.
|
||||
assert!(!pass(&mut kept, 11).unchanged(7, cow, &place(100.5), tracked));
|
||||
// Only what a pass noted stays.
|
||||
pass(&mut kept, 12).finish();
|
||||
assert!(kept.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -68,13 +68,10 @@ pub(super) fn player_for(
|
||||
if m.extra.leash == Some(horizon_client::mobs::leash::Holder::Player)
|
||||
&& let Some(holder) = m.extra.leash_player
|
||||
{
|
||||
if host.is_some_and(|v| v.alive && !v.spectator && v.uuid == holder) {
|
||||
if host.is_some_and(|v| v.uuid == holder) {
|
||||
return None;
|
||||
}
|
||||
if let Some((conn, _)) = guests
|
||||
.iter()
|
||||
.find(|(_, v)| v.alive && !v.spectator && v.uuid == holder)
|
||||
{
|
||||
if let Some((conn, _)) = guests.iter().find(|(_, v)| v.uuid == holder) {
|
||||
return Some(*conn);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,6 +561,7 @@ impl Game<'_> {
|
||||
};
|
||||
if let Some(b) = self.boats.iter_mut().find(|b| b.id == vehicle) {
|
||||
b.paddling = paddles;
|
||||
b.paddle_controller = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,8 +430,11 @@ impl Game<'_> {
|
||||
self.mobs[index].extra.leash = Some(Holder::Player);
|
||||
self.mobs[index].extra.leash_player = Some(holder);
|
||||
self.lead_sound("tied", at);
|
||||
// The lead is always used up.
|
||||
if let Some(stack) = self.held_mut().as_mut() {
|
||||
// `Player.interactOn` restores the count of a player with
|
||||
// infinite materials (creative); others spend the lead.
|
||||
if !self.creative
|
||||
&& let Some(stack) = self.held_mut().as_mut()
|
||||
{
|
||||
stack.count -= 1;
|
||||
if stack.count == 0 {
|
||||
*self.held_mut() = None;
|
||||
|
||||
@@ -1171,9 +1171,7 @@ impl Game<'_> {
|
||||
let Some(id) = map_id(stack) else {
|
||||
return false;
|
||||
};
|
||||
// ItemStack.useOn passes in adventure mode (its `can_place_on`
|
||||
// exception is not ported).
|
||||
if !self.may_build() {
|
||||
if !self.may_build() && !self.can_place_on_in_adventure(hit.pos) {
|
||||
return false;
|
||||
}
|
||||
let [x, y, z] = hit.pos;
|
||||
@@ -2012,4 +2010,107 @@ mod tests {
|
||||
assert_eq!(clamp_map_coordinate(-70.0), -128);
|
||||
assert_eq!(clamp_map_coordinate(10.25), 21);
|
||||
}
|
||||
|
||||
/// `ItemStack.useOn` / `MapItem.useOn`: in adventure mode, a map whose
|
||||
/// `can_place_on` component includes the banner block toggles that banner's
|
||||
/// marker, while one without does not.
|
||||
#[test]
|
||||
fn map_on_banner_in_adventure_mode_honours_can_place_on() {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{chunk::Chunk, command::args::GameMode};
|
||||
use std::sync::Arc;
|
||||
|
||||
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
|
||||
return;
|
||||
};
|
||||
let root = std::path::PathBuf::from(root);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pos = [0, 64, 0];
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
let banner_state = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:red_banner")
|
||||
.unwrap();
|
||||
game.world
|
||||
.set(pos[0], pos[1], pos[2], banner_state)
|
||||
.unwrap();
|
||||
game.world.block_entities.insert(
|
||||
(pos[0], pos[1], pos[2]),
|
||||
horizon_client::containers::BlockEntity::Banner(horizon_client::containers::Banner {
|
||||
layers: Vec::new(),
|
||||
name: None,
|
||||
components: Default::default(),
|
||||
}),
|
||||
);
|
||||
|
||||
let map_id = 42;
|
||||
game.maps
|
||||
.data
|
||||
.insert(map_id, MapData::fresh(0.0, 0.0, 0, "minecraft:overworld"));
|
||||
game.set_mode(GameMode::Adventure);
|
||||
let hit = super::super::Hit {
|
||||
pos,
|
||||
face: [0, 1, 0],
|
||||
point: [
|
||||
pos[0] as f64 + 0.5,
|
||||
pos[1] as f64 + 0.5,
|
||||
pos[2] as f64 + 0.5,
|
||||
],
|
||||
};
|
||||
let mut stack = ItemStack::new("minecraft:filled_map", 1);
|
||||
stack.data_mut().map_id = Some(map_id);
|
||||
|
||||
// Without can_place_on, use_map_on_banner fails in adventure mode.
|
||||
*game.held_mut() = Some(stack.clone());
|
||||
assert!(!game.use_map_on_banner(&hit, &stack));
|
||||
assert!(game.map(map_id).unwrap().banners.is_empty());
|
||||
|
||||
// With can_place_on listing the banner block, the marker toggles.
|
||||
stack.data_mut().other.push((
|
||||
"minecraft:can_place_on".into(),
|
||||
Tag::compound([(
|
||||
"blocks",
|
||||
Tag::List(vec![Tag::String("minecraft:red_banner".into())]),
|
||||
)]),
|
||||
));
|
||||
*game.held_mut() = Some(stack.clone());
|
||||
assert!(game.use_map_on_banner(&hit, &stack));
|
||||
assert_eq!(game.map(map_id).unwrap().banners.len(), 1);
|
||||
assert_eq!(game.map(map_id).unwrap().banners[0].pos, pos);
|
||||
assert_eq!(game.map(map_id).unwrap().banners[0].color, "red");
|
||||
|
||||
// Second use toggles the marker off.
|
||||
assert!(game.use_map_on_banner(&hit, &stack));
|
||||
assert!(game.map(map_id).unwrap().banners.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -585,6 +585,7 @@ impl Game<'_> {
|
||||
// turns nearly all of them away: it is tested first, before a mob's
|
||||
// pushable state, and only the boxes kept are collected.
|
||||
let close = |b: &Aabb| b.intersects(near);
|
||||
let reach = self.vehicle_mob_reach();
|
||||
let mut boxes: Vec<Aabb> = self
|
||||
.carts
|
||||
.iter()
|
||||
@@ -600,10 +601,12 @@ impl Game<'_> {
|
||||
.chain(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| !m.removed && m.extra.mount != Some(c.id))
|
||||
.filter(|m| reach.is_none_or(|r| r.may_meet(m, near)))
|
||||
.map(|m| (m, m.bounds()))
|
||||
.filter(|(m, b)| {
|
||||
close(b)
|
||||
&& !m.removed
|
||||
&& m.extra.mount != Some(c.id)
|
||||
&& (m.pushable()
|
||||
|| m.species() == horizon_client::mobs::Species::Shulker
|
||||
&& m.alive())
|
||||
@@ -1752,17 +1755,17 @@ impl Game<'_> {
|
||||
}
|
||||
/// `positionRider` for the mobs in minecarts: each sits on the cart's
|
||||
/// passenger attachment, less its own vehicle attachment.
|
||||
pub(super) fn seat_cart_mobs(&mut self) {
|
||||
pub(super) fn seat_cart_mobs(&mut self, riding: &super::passengers::Riders) {
|
||||
for i in 0..self.carts.len() {
|
||||
let id = self.carts[i].id;
|
||||
let Some(js) = riding.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
let (pos, prev) = (self.carts[i].position, self.carts[i].previous);
|
||||
let seats: Vec<(usize, f64)> = self
|
||||
.mobs
|
||||
let seats: Vec<(usize, f64)> = js
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| m.extra.mount == Some(id))
|
||||
// Minecart's box is 0.98 wide.
|
||||
.map(|(j, m)| (j, riders::vehicle_attachment(m, 0.98)))
|
||||
.map(|&j| (j, riders::vehicle_attachment(&self.mobs[j], 0.98)))
|
||||
.collect();
|
||||
for (j, offset) in seats {
|
||||
let m = &mut self.mobs[j];
|
||||
@@ -1793,6 +1796,11 @@ impl Game<'_> {
|
||||
}
|
||||
/// Every cart but the ridden one (which ticks with its rider).
|
||||
pub(super) fn tick_carts(&mut self) {
|
||||
self.vehicles_ticking(true);
|
||||
self.tick_carts_now();
|
||||
self.vehicles_ticking(false);
|
||||
}
|
||||
fn tick_carts_now(&mut self) {
|
||||
self.push_carts();
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
|
||||
@@ -28,18 +28,26 @@ impl Game<'_> {
|
||||
let mut near = super::item_areas::ItemAreas::default();
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if !m.can_pick_up_loot() || !m.alive() || m.removed {
|
||||
if !m.can_pick_up_loot()
|
||||
|| !m.alive()
|
||||
|| m.removed
|
||||
|| !self.creatures.ticked.contains(&m.id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// `Mob.ITEM_PICKUP_REACH`.
|
||||
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
|
||||
if !near.may_meet(&self.dropped, self.next_entity, reach) {
|
||||
let mut scan = near.scan(&self.dropped, self.next_entity, reach);
|
||||
if scan.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (species, baby, passenger) = (m.species(), m.is_baby(), m.extra.passenger);
|
||||
let mut j = 0;
|
||||
while j < self.dropped.len() {
|
||||
let e = &self.dropped[j];
|
||||
loop {
|
||||
j = scan.next_from(j, self.dropped.len());
|
||||
let Some(e) = self.dropped.get(j) else {
|
||||
break;
|
||||
};
|
||||
if e.pickup_delay > 0 || !reach.intersects(e.bounds()) {
|
||||
j += 1;
|
||||
continue;
|
||||
@@ -70,6 +78,7 @@ impl Game<'_> {
|
||||
e.stack.count -= taken;
|
||||
if e.stack.count == 0 {
|
||||
self.remove_dropped(j);
|
||||
scan.removed(j);
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
@@ -132,8 +141,22 @@ impl Game<'_> {
|
||||
if species == Species::Drowned && &*current == "minecraft:nautilus_shell" {
|
||||
return false;
|
||||
}
|
||||
let new = self.stack_facts(m, stack, slot);
|
||||
let worn = self.stack_facts(m, &worn_stack, slot);
|
||||
let preferred = equip::preferred_weapon_tag(species).map(|tag| {
|
||||
(
|
||||
self.items().item_in_tag(tag, &stack.item),
|
||||
self.items().item_in_tag(tag, ¤t),
|
||||
)
|
||||
});
|
||||
equip::can_replace_current_item(&new, Some(&worn), slot, preferred)
|
||||
}
|
||||
|
||||
/// 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 new = Facts {
|
||||
Facts {
|
||||
armor,
|
||||
armor_toughness: toughness,
|
||||
attack_damage: attack,
|
||||
@@ -143,24 +166,7 @@ impl Game<'_> {
|
||||
// `EnchantmentEffectComponents.PREVENT_ARMOR_CHANGE` is the Curse of
|
||||
// Binding's, the only enchantment that carries one.
|
||||
prevents_armor_change: stack.enchantment_level("minecraft:binding_curse") > 0,
|
||||
};
|
||||
let (armor, toughness, attack) = self.attributes(m, ¤t, slot);
|
||||
let worn = Facts {
|
||||
armor,
|
||||
armor_toughness: toughness,
|
||||
attack_damage: attack,
|
||||
enchantments: worn_stack.enchantments().len(),
|
||||
damage: worn_stack.damage as i32,
|
||||
custom_name: worn_stack.custom_name().is_some(),
|
||||
prevents_armor_change: worn_stack.enchantment_level("minecraft:binding_curse") > 0,
|
||||
};
|
||||
let preferred = equip::preferred_weapon_tag(species).map(|tag| {
|
||||
(
|
||||
self.items().item_in_tag(tag, &stack.item),
|
||||
self.items().item_in_tag(tag, ¤t),
|
||||
)
|
||||
});
|
||||
equip::can_replace_current_item(&new, Some(&worn), slot, preferred)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mob.getApproximateAttributeWith` for the item `id` in `slot`: each
|
||||
|
||||
@@ -79,6 +79,13 @@ pub(super) fn register_standalone_models() {
|
||||
Game::native_standalone_models(&mut names);
|
||||
horizon_client::models::register_standalone(names);
|
||||
}
|
||||
/// The native mods' entity model layers (`native_layer_definitions`), kept
|
||||
/// once at start for `native_bake_layer`.
|
||||
pub(super) fn register_layer_definitions() {
|
||||
let mut layers = Vec::new();
|
||||
Game::native_layer_definitions(&mut layers);
|
||||
horizon_client::mobs::model_layers::register(layers);
|
||||
}
|
||||
|
||||
/// `root` with the content mods that are on layered over it
|
||||
/// (docs/modpacks.md): their blocks, items, data and assets. Read once at
|
||||
|
||||
@@ -31,7 +31,7 @@ fn stick_on_break(stack: &ItemStack, wear: u32, max: u32) -> Option<ItemStack> {
|
||||
Some(rod)
|
||||
}
|
||||
|
||||
fn steering_item(species: Species, main: Option<&str>, offhand: Option<&str>) -> bool {
|
||||
pub(super) fn steering_item(species: Species, main: Option<&str>, offhand: Option<&str>) -> bool {
|
||||
let item = match species {
|
||||
Species::Pig => "minecraft:carrot_on_a_stick",
|
||||
Species::Strider => "minecraft:warped_fungus_on_a_stick",
|
||||
|
||||
@@ -158,9 +158,7 @@ impl Game<'_> {
|
||||
let local_entity_id = self.remote.as_ref().map(|r| r.entity_id as u32);
|
||||
let ridden = collect_vehicle_ids(
|
||||
&self.mobs,
|
||||
self.text_displays.values().filter_map(|d| d.vehicle),
|
||||
self.armor_stands.iter().filter_map(|a| a.vehicle),
|
||||
self.interactions.values().filter_map(|i| i.vehicle),
|
||||
self.remote_vehicle_ids(),
|
||||
self.riding,
|
||||
local_entity_id,
|
||||
);
|
||||
@@ -662,7 +660,9 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// Collect entity IDs of all vehicles: entities that have passengers
|
||||
/// (mobs, players, text displays, armor stands, or interactions riding them).
|
||||
/// (`passenger_vehicles`: what every entity the server sent rides, text displays,
|
||||
/// armor stands, item displays and interactions included; vanilla's `isVehicle` is
|
||||
/// any passenger at all).
|
||||
///
|
||||
/// On multiplayer servers (e.g. `mcpvp.club`), server plugins hang custom nametags
|
||||
/// (ranks, prefixes, stats) over players using `TextDisplay` or `ArmorStand` passengers.
|
||||
@@ -674,9 +674,7 @@ impl Game<'_> {
|
||||
/// is ridden and suppresses the duplicate tag (#9477).
|
||||
fn collect_vehicle_ids(
|
||||
mobs: &[Mob],
|
||||
text_display_vehicles: impl IntoIterator<Item = u32>,
|
||||
armor_stand_vehicles: impl IntoIterator<Item = u32>,
|
||||
interaction_vehicles: impl IntoIterator<Item = u32>,
|
||||
passenger_vehicles: impl IntoIterator<Item = u32>,
|
||||
local_riding: Option<u32>,
|
||||
local_entity_id: Option<u32>,
|
||||
) -> std::collections::HashSet<u32> {
|
||||
@@ -684,9 +682,7 @@ fn collect_vehicle_ids(
|
||||
.iter()
|
||||
.filter_map(|o| o.extra.mount)
|
||||
.chain(mobs.iter().filter_map(|m| m.extra.rider.map(|_| m.id)))
|
||||
.chain(text_display_vehicles)
|
||||
.chain(armor_stand_vehicles)
|
||||
.chain(interaction_vehicles)
|
||||
.chain(passenger_vehicles)
|
||||
.collect();
|
||||
if let Some(riding) = local_riding {
|
||||
ridden.insert(riding);
|
||||
@@ -903,14 +899,7 @@ mod tests {
|
||||
let mobs = vec![remote_player];
|
||||
|
||||
// 1. TextDisplay riding remote player 42
|
||||
let ridden = collect_vehicle_ids(
|
||||
&mobs,
|
||||
vec![42],
|
||||
std::iter::empty(),
|
||||
std::iter::empty(),
|
||||
None,
|
||||
Some(100),
|
||||
);
|
||||
let ridden = collect_vehicle_ids(&mobs, vec![42], None, Some(100));
|
||||
assert!(
|
||||
ridden.contains(&42),
|
||||
"remote player 42 is ridden by text display"
|
||||
@@ -919,14 +908,7 @@ mod tests {
|
||||
assert!(!ridden.contains(&0), "local model 0 has no passenger");
|
||||
|
||||
// 2. ArmorStand riding local player 100 -> model 0 must be in ridden
|
||||
let ridden_local = collect_vehicle_ids(
|
||||
&mobs,
|
||||
std::iter::empty(),
|
||||
vec![100],
|
||||
std::iter::empty(),
|
||||
None,
|
||||
Some(100),
|
||||
);
|
||||
let ridden_local = collect_vehicle_ids(&mobs, vec![100], None, Some(100));
|
||||
assert!(
|
||||
ridden_local.contains(&100),
|
||||
"local player 100 is ridden by armor stand"
|
||||
|
||||
@@ -18,6 +18,7 @@ use horizon_client::{
|
||||
hud::Rect,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::model_layers::{self, ModelPart},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
@@ -705,6 +706,53 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// Draws model part `part` and the parts under it under `draw`'s pose, as
|
||||
/// vanilla's `ModelPart.render` does: each part moved to its place (in
|
||||
/// texels, 16 to a block), turned about z, then y, then x, and scaled,
|
||||
/// its faces textured from `sprite` (`mymod:entity/golem`, a content
|
||||
/// mod's `assets/mymod/textures/entity/golem.png`, or vanilla's
|
||||
/// `entity/zombie/zombie`) times the 0xRRGGBB `tint`. A part that isn't
|
||||
/// `visible` draws nothing, one that skips drawing (`skip_draw`) only its
|
||||
/// children. A mob's model is built in the model's own frame (+y down,
|
||||
/// its front towards -z, its feet 24 texels down): in `native_mob_submit`,
|
||||
/// `draw.scale([1.0, -1.0, -1.0])` then `draw.translate([0.0, -1.501,
|
||||
/// 0.0])` first, as `LivingEntityRenderer` does. False for an unknown
|
||||
/// sprite. API @API:model_layers@.
|
||||
pub(super) fn native_draw_model_part(
|
||||
&self,
|
||||
draw: &mut BlockEntityDraw,
|
||||
part: &ModelPart,
|
||||
sprite: &str,
|
||||
tint: u32,
|
||||
) -> bool {
|
||||
let Some(rect) = self.gui.atlas.sprites.get(sprite).copied() else {
|
||||
return false;
|
||||
};
|
||||
draw_model_part(draw, part, rect, tint);
|
||||
true
|
||||
}
|
||||
|
||||
/// Entity model layer `layer` baked into its `ModelPart` tree, each part
|
||||
/// at its pose, as `EntityModelSet.bakeLayer` bakes it: one a native mod
|
||||
/// registered (`native_layer_definitions`), or vanilla's, by the name
|
||||
/// `entity-models.json` gives it (`minecraft:zombie#main`,
|
||||
/// `minecraft:player#main`). The tree is the caller's own copy: bake it
|
||||
/// once, as a renderer does, and set its parts' poses each frame. None
|
||||
/// for a layer neither has. A vanilla layer no mob has (a player's, a
|
||||
/// chest's) is read from the resources each call, so slow. API
|
||||
/// @API:model_layers@.
|
||||
pub(super) fn native_bake_layer(&self, layer: &str) -> Option<ModelPart> {
|
||||
if let Some(definition) = model_layers::registered(layer) {
|
||||
return Some(definition.bake_root());
|
||||
}
|
||||
let key = layer.strip_prefix("minecraft:")?;
|
||||
if let Some(part) = self.res.mob_types().values().find_map(|t| t.layer(key)) {
|
||||
return Some(ModelPart::from_part(part));
|
||||
}
|
||||
let models = horizon_client::mobs::EntityModelDocument::load(&self.vanilla_root()).ok()?;
|
||||
models.model(layer).map(|part| ModelPart::from_part(&part))
|
||||
}
|
||||
|
||||
/// Draws `stack` under `draw`'s pose as an item is drawn in the world
|
||||
/// (`ItemModelResolver.updateForTopItem` and `ItemStackRenderState.submit`), centred on the
|
||||
/// pose's origin, in its model's `display` transform: `Fixed` as a
|
||||
@@ -762,6 +810,45 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ModelPart.render`: `part`'s faces under its own pose
|
||||
/// (`translateAndRotate`), then its children under theirs.
|
||||
fn draw_model_part(draw: &mut BlockEntityDraw, part: &ModelPart, sprite: Rect, tint: u32) {
|
||||
if !part.visible || (part.is_empty() && part.children().next().is_none()) {
|
||||
return;
|
||||
}
|
||||
draw.push();
|
||||
draw.apply(part_transform(part));
|
||||
if !part.skip_draw {
|
||||
for polygon in &part.polygons {
|
||||
let corners = polygon.vertices.map(|v| v.pos.map(|c| c / 16.0));
|
||||
let uv = polygon.vertices.map(|v| [v.u, v.v]);
|
||||
draw.quad(corners, uv, sprite, tint);
|
||||
}
|
||||
}
|
||||
for (_, child) in part.children() {
|
||||
draw_model_part(draw, child, sprite, tint);
|
||||
}
|
||||
draw.pop();
|
||||
}
|
||||
|
||||
/// `ModelPart.translateAndRotate`: to the part's place in blocks, turned
|
||||
/// about z, then y, then x (`rotationZYX`), then scaled.
|
||||
fn part_transform(part: &ModelPart) -> Mat4 {
|
||||
let mut m = Mat4::from_translation(Vec3::new(part.x, part.y, part.z) / 16.0);
|
||||
if part.x_rot != 0.0 || part.y_rot != 0.0 || part.z_rot != 0.0 {
|
||||
m *= Mat4::from_quat(Quat::from_euler(
|
||||
glam::EulerRot::ZYX,
|
||||
part.z_rot,
|
||||
part.y_rot,
|
||||
part.x_rot,
|
||||
));
|
||||
}
|
||||
if part.x_scale != 1.0 || part.y_scale != 1.0 || part.z_scale != 1.0 {
|
||||
m *= Mat4::from_scale(Vec3::new(part.x_scale, part.y_scale, part.z_scale));
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -811,6 +898,73 @@ mod tests {
|
||||
assert_eq!(p, Vec3::new(12.0, 4.0, 2.0));
|
||||
}
|
||||
|
||||
/// An arm a block east of the frame's origin, turned a quarter about z,
|
||||
/// with a hand a block below its pivot: `translateAndRotate` turns each
|
||||
/// part about its own pivot, then moves it, and a child goes with its
|
||||
/// parent. Expected corners are worked out by hand from JOML's
|
||||
/// `rotationZYX` (a right-handed quarter turn takes +x to +y).
|
||||
#[test]
|
||||
fn a_model_part_draws_turned_about_its_pivot_and_carries_its_children() {
|
||||
use horizon_client::mobs::model_layers::{
|
||||
CubeListBuilder, LayerDefinition, MeshDefinition, PartPose,
|
||||
};
|
||||
use horizon_core::geometry::Direction as Face;
|
||||
let mut mesh = MeshDefinition::new();
|
||||
let root = mesh.root();
|
||||
let cube = |face| CubeListBuilder::create().add_box_faces([0.0; 3], [16.0; 3], &[face]);
|
||||
let arm = mesh.add_or_replace_child(
|
||||
root,
|
||||
"arm",
|
||||
cube(Face::South),
|
||||
PartPose::offset_and_rotation(16.0, 0.0, 0.0, 0.0, 0.0, std::f32::consts::FRAC_PI_2),
|
||||
);
|
||||
mesh.add_or_replace_child(
|
||||
arm,
|
||||
"hand",
|
||||
cube(Face::North),
|
||||
PartPose::offset(0.0, 16.0, 0.0),
|
||||
);
|
||||
let mut model = LayerDefinition::create(mesh, 64, 64).bake_root();
|
||||
let drawn = |model: &ModelPart| {
|
||||
let mut d = draw();
|
||||
draw_model_part(&mut d, model, sprite(), 0xFF_FFFF);
|
||||
d.into_vertices()
|
||||
};
|
||||
let near = |v: &Vertex, at: [f32; 3]| {
|
||||
assert!(
|
||||
Vec3::from(v.pos).abs_diff_eq(Vec3::from(at), 1e-5),
|
||||
"{:?} not {at:?}",
|
||||
v.pos
|
||||
);
|
||||
};
|
||||
|
||||
let all = drawn(&model);
|
||||
assert_eq!(all.len(), 8);
|
||||
// The arm's south face, (0, 0, 1) (1, 0, 1) (1, 1, 1) (0, 1, 1) in
|
||||
// its frame, turned and moved a block east, then the frame's 10.
|
||||
for (v, at) in all.iter().zip([
|
||||
[11.0, 0.0, 1.0],
|
||||
[11.0, 1.0, 1.0],
|
||||
[10.0, 1.0, 1.0],
|
||||
[10.0, 0.0, 1.0],
|
||||
]) {
|
||||
near(v, at);
|
||||
}
|
||||
// Its net's last strip, 48 to 64 across and 16 to 32 down, in the sprite.
|
||||
assert_eq!(all[0].uv, [0.75, 0.3125]);
|
||||
// The hand's north face starts at its (1, 0, 0), the arm's (1, 1, 0).
|
||||
near(&all[4], [10.0, 1.0, 0.0]);
|
||||
near(&all[5], [10.0, 0.0, 0.0]);
|
||||
|
||||
let arm = model.get_child_mut("arm").unwrap();
|
||||
arm.skip_draw = true;
|
||||
let hand_only = drawn(&model);
|
||||
assert_eq!(hand_only.len(), 4);
|
||||
near(&hand_only[0], [10.0, 1.0, 0.0]);
|
||||
model.get_child_mut("arm").unwrap().visible = false;
|
||||
assert!(drawn(&model).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quad_lands_in_its_sprite_lit_and_shaded() {
|
||||
let mut d = draw();
|
||||
|
||||
@@ -999,7 +999,7 @@ use horizon_client::mod_api::{
|
||||
self, NativeCamera, NativeCommand, NativeDispensed, NativeLifecycleContext,
|
||||
NativeNoteBlockChange, NativeServerPlayer, RecipeBookCategory, ServerTickPhase, WorldPhase,
|
||||
};
|
||||
use horizon_client::mod_api::{AttachmentResult, Attachments, Holder, Suggestion};
|
||||
use horizon_client::mod_api::{AttachmentResult, Attachments, Holder, LayerDefinition, Suggestion};
|
||||
pub(crate) use horizon_client::mod_api::{
|
||||
ClientboundPlayPacket, NativeBlockRim, NativeColoredLight, NativeLootDrops, NativeMinimap,
|
||||
NativeMod, NativePanelEvent, NativeScreen, NativeScreenView, NativeStack, NativeZoom,
|
||||
@@ -3035,6 +3035,17 @@ impl Game<'_> {
|
||||
pub(super) fn native_standalone_models(models: &mut Vec<String>) {
|
||||
mod_api::dispatch_standalone_models(mod_api::mods(), models);
|
||||
}
|
||||
/// Entity model layers built in code (NeoForge's
|
||||
/// `EntityRenderersEvent.RegisterLayerDefinitions`): push each one's id,
|
||||
/// `<mod id>:<model>#<layer>` as a `ModelLayerLocation` names it
|
||||
/// (`mymod:golem#main`), and its `LayerDefinition`, as the mod's
|
||||
/// `createBodyLayer()` builds it. `native_bake_layer` bakes one into the
|
||||
/// `ModelPart` tree `native_draw_model_part` draws. Asked once at start,
|
||||
/// on every side. An id in vanilla's namespace, not of that form, or
|
||||
/// registered already is logged and left out. API @API:model_layers@.
|
||||
pub(super) fn native_layer_definitions(layers: &mut Vec<(String, LayerDefinition)>) {
|
||||
mod_api::dispatch_layer_definitions(mod_api::mods(), layers);
|
||||
}
|
||||
/// Options > Mods lists the native mods: push yours.
|
||||
pub(super) fn native_mods(&mut self, mods: &mut Vec<NativeMod>) {
|
||||
mod_api::dispatch_mods(self, mod_api::mods(), mods);
|
||||
|
||||
@@ -749,15 +749,18 @@ impl<'a> Game<'a> {
|
||||
if self.world_debug {
|
||||
return;
|
||||
}
|
||||
let mut laps = horizon_client::spans::laps();
|
||||
let players = self.level_players();
|
||||
if players.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut scratch = std::mem::take(&mut self.spawn_scratch);
|
||||
let spawnable = self.spawning_chunks(&players, &mut scratch);
|
||||
laps.lap("sn_candidates");
|
||||
for &key in &scratch.candidates {
|
||||
*self.world.inhabited.entry(key).or_insert(0) += 1;
|
||||
}
|
||||
laps.lap("sn_inhabited");
|
||||
let spawn_mobs = self.ui.rules.bool("spawn_mobs");
|
||||
let spawn_enemies = self.ui.rules.bool("spawn_monsters");
|
||||
let mut random = std::mem::replace(&mut self.world.random, LegacyRandom::new(0));
|
||||
@@ -765,6 +768,7 @@ impl<'a> Game<'a> {
|
||||
let last_state;
|
||||
{
|
||||
let level = self.natural_level();
|
||||
laps.lap("sn_level");
|
||||
let counted: Vec<Counted> = self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -788,20 +792,25 @@ impl<'a> Game<'a> {
|
||||
spectator: p.spectator,
|
||||
})
|
||||
.collect();
|
||||
laps.lap("sn_materialize");
|
||||
let mut state = SpawnState::new(&level, spawnable, players, &counted, |cx, cz| {
|
||||
self.world.chunks.contains_key(&(cx, cz))
|
||||
});
|
||||
laps.lap("sn_state");
|
||||
last_state = state.counts();
|
||||
let categories = if spawn_mobs {
|
||||
state.filtered_categories(spawn_enemies, self.world.game_time % 400 == 0)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
laps.lap("sn_categories");
|
||||
// Util.shuffle.
|
||||
for i in (1..scratch.candidates.len()).rev() {
|
||||
let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
|
||||
scratch.candidates.swap(i, j);
|
||||
}
|
||||
laps.lap("sn_shuffle");
|
||||
spawn_selection::attempt_counters(false);
|
||||
if !categories.is_empty() {
|
||||
for chunk in scratch.candidates.drain(..) {
|
||||
let mut spawn = |l: &GameLevel, r: &SpawnRequest| {
|
||||
@@ -823,11 +832,14 @@ impl<'a> Game<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
spawn_selection::attempt_counters(true);
|
||||
laps.lap("sn_attempts");
|
||||
self.world.random = random;
|
||||
// ServerChunkCache.lastSpawnState, for the debug overlay.
|
||||
self.spawn_counts.insert(self.dim, last_state);
|
||||
self.add_spawned(pending, false);
|
||||
self.spawn_scratch = scratch;
|
||||
laps.lap("sn_finalize");
|
||||
}
|
||||
/// `NoiseBasedChunkGenerator.spawnOriginalMobs`: creature packs for a
|
||||
/// freshly generated chunk, from the biome at its corner column's top,
|
||||
|
||||
@@ -28,7 +28,8 @@ impl Game<'_> {
|
||||
let griefing = self.ui.rules.bool("mob_griefing");
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Panda || !m.alive() {
|
||||
if m.species() != Species::Panda || !m.alive() || !self.creatures.ticked.contains(&m.id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let id = m.id;
|
||||
|
||||
@@ -7,6 +7,9 @@ use horizon_client::mobs::{Species, riders};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The mobs riding each vehicle, by the vehicle's id: their indices, in the mobs' order.
|
||||
pub(super) type Riders = HashMap<u32, Vec<usize>>;
|
||||
|
||||
/// `Zombie.finalizeSpawn`: a baby zombie takes a chicken one time in twenty,
|
||||
/// and another one time in twenty of the rest.
|
||||
const CHICKEN_JOCKEY_ODDS: f32 = 0.05;
|
||||
@@ -125,9 +128,18 @@ impl Game<'_> {
|
||||
/// its mob. A server's client places them itself too, as the server sends
|
||||
/// no passenger's position.
|
||||
pub(super) fn position_riders(&mut self) {
|
||||
self.seat_boat_mobs();
|
||||
self.seat_cart_mobs();
|
||||
self.seat_tnt_mobs();
|
||||
// Every vehicle's riders in one pass over the mobs, not one pass per
|
||||
// boat, minecart and primed TNT. Seating moves the riders but changes
|
||||
// no one's vehicle, so it holds for all three.
|
||||
let mut riders = Riders::default();
|
||||
for (j, m) in self.mobs.iter().enumerate() {
|
||||
if let Some(v) = m.extra.mount {
|
||||
riders.entry(v).or_default().push(j);
|
||||
}
|
||||
}
|
||||
self.seat_boat_mobs(&riders);
|
||||
self.seat_cart_mobs(&riders);
|
||||
self.seat_tnt_mobs(&riders);
|
||||
let index: HashMap<u32, usize> = self
|
||||
.mobs
|
||||
.iter()
|
||||
|
||||
@@ -227,6 +227,20 @@ enum RiddenBody {
|
||||
Living(f32),
|
||||
}
|
||||
|
||||
/// Whether a shadow pass that draws the player (`shadow_draws_player`: shadowPlayer, or a
|
||||
/// native pack's shadow) takes the player's own model in first person. A shader pack's shadow
|
||||
/// pass does, except for a spectator: in our probe packs' readings of Iris 1.11.7's shadow map
|
||||
/// in spectator mode, the texels at the first-person camera hold the terrain below it, where
|
||||
/// Horizon drew the spectator's model (vanilla's `PlayerModel` hides a spectator's body:
|
||||
/// `showBody = !state.isSpectator`). A native pack's shadow keeps it.
|
||||
fn shadow_pass_takes_own_model(
|
||||
shadow_draws_player: bool,
|
||||
shader_pack: bool,
|
||||
spectator: bool,
|
||||
) -> bool {
|
||||
shadow_draws_player && !(shader_pack && spectator)
|
||||
}
|
||||
|
||||
/// `LivingEntityRenderer.solveBodyRot` for the rider of a living vehicle: the
|
||||
/// body faces the vehicle's body, the head turning at most 85 degrees from it,
|
||||
/// and past 50 degrees the body turns a fifth of the head's offset along.
|
||||
@@ -654,8 +668,12 @@ impl Game<'_> {
|
||||
/// Whether the player's own model shows in the world view (its body or,
|
||||
/// while invisible, only its armour and held items).
|
||||
pub(super) fn player_model_shown(&self) -> bool {
|
||||
// A shader pack's shadow pass draws the player in first person too (shadowPlayer).
|
||||
(self.own_body_in_view() || self.pack_shadow_casters.1)
|
||||
(self.own_body_in_view()
|
||||
|| shadow_pass_takes_own_model(
|
||||
self.pack_shadow_casters.1,
|
||||
self.pack_ids.is_some(),
|
||||
self.spectator(),
|
||||
))
|
||||
&& self
|
||||
.player
|
||||
.as_ref()
|
||||
@@ -1391,6 +1409,17 @@ fn player_walk_distance(from: Vec3, to: Vec3) -> f32 {
|
||||
mod preview_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_spectators_own_model_stays_out_of_a_shader_packs_shadow_pass() {
|
||||
// First person, a shader pack whose shadow pass draws the player: a spectator's head
|
||||
// isn't in Iris's map (#8464); the other game modes keep the model, as before.
|
||||
assert!(!shadow_pass_takes_own_model(true, true, true));
|
||||
assert!(shadow_pass_takes_own_model(true, true, false));
|
||||
// A native pack's shadow keeps the first-person model, a spectator's too.
|
||||
assert!(shadow_pass_takes_own_model(true, false, true));
|
||||
// No shadow pass that draws the player: nothing to take.
|
||||
assert!(!shadow_pass_takes_own_model(false, true, false));
|
||||
}
|
||||
#[test]
|
||||
fn the_player_model_rises_with_the_camera_at_levitation_255() {
|
||||
// LivingEntity.travel at Levitation 255: 0.05 * 255 blocks a tick.
|
||||
let from = Vec3::new(0.5, 80.0, 0.5);
|
||||
|
||||
@@ -6,6 +6,7 @@ use horizon_client::{
|
||||
items::ItemStack,
|
||||
mobs::{
|
||||
Mob, Species,
|
||||
equip::{self, Slot},
|
||||
finalize::{
|
||||
SLOT_NAMES, item_stack, set_drop_chance, set_item, set_item_stack,
|
||||
set_slot_enchantments, slot_enchantments, stored_drop_chance,
|
||||
@@ -43,15 +44,37 @@ impl Game<'_> {
|
||||
.unwrap_or("mainhand");
|
||||
SLOT_NAMES.iter().position(|n| *n == slot).unwrap_or(0)
|
||||
}
|
||||
/// `Piglin.canReplaceCurrentItem`: an empty slot takes anything, a wanted
|
||||
/// item replaces an unwanted one and never the other way round. Vanilla's
|
||||
/// armour and weapon comparison for two equal-interest items is left out
|
||||
/// (the piglin keeps what it has).
|
||||
fn piglin_can_replace(&self, m: &Mob, item: &str, slot: usize) -> bool {
|
||||
let Some(current) = horizon_client::mobs::finalize::item(m, slot) else {
|
||||
/// `Piglin.canReplaceCurrentItem`: an empty slot takes anything, a
|
||||
/// `PREVENT_ARMOR_CHANGE` piece stays, a wanted item replaces an unwanted
|
||||
/// one and never the other way round; when both or neither are wanted,
|
||||
/// `Mob.canReplaceCurrentItem` compares armour or weapons.
|
||||
fn piglin_can_replace(&self, m: &Mob, stack: &ItemStack, slot: usize) -> bool {
|
||||
let Some(current) = item_stack(m, slot) else {
|
||||
return true;
|
||||
};
|
||||
self.piglin_wanted(m, item) && !self.piglin_wanted(m, ¤t)
|
||||
let new_wanted = self.piglin_wanted(m, &stack.item);
|
||||
let current_wanted = self.piglin_wanted(m, ¤t.item);
|
||||
let slot = [
|
||||
Slot::MainHand,
|
||||
Slot::OffHand,
|
||||
Slot::Feet,
|
||||
Slot::Legs,
|
||||
Slot::Chest,
|
||||
Slot::Head,
|
||||
]
|
||||
.get(slot)
|
||||
.copied()
|
||||
.unwrap_or(Slot::MainHand);
|
||||
let new = self.stack_facts(m, stack, slot);
|
||||
let worn = self.stack_facts(m, ¤t, slot);
|
||||
// `getPreferredWeaponType` is the preferred-weapons tag for adults.
|
||||
let preferred = (!m.is_baby()).then(|| {
|
||||
(
|
||||
self.item_in_tag("minecraft:piglin_preferred_weapons", &stack.item),
|
||||
self.item_in_tag("minecraft:piglin_preferred_weapons", ¤t.item),
|
||||
)
|
||||
});
|
||||
piglin_replace(new_wanted, current_wanted, &new, &worn, slot, preferred)
|
||||
}
|
||||
/// `SimpleContainer.canAddItem` on the piglin's eight slots.
|
||||
fn piglin_can_add(&self, m: &Mob, stack: &ItemStack) -> bool {
|
||||
@@ -86,7 +109,7 @@ impl Game<'_> {
|
||||
} else if self.item_in_tag("minecraft:piglin_food", item) {
|
||||
m.extra.ints[0] <= 0 && space
|
||||
} else if !self.piglin_loved(item) {
|
||||
self.piglin_can_replace(m, item, self.piglin_slot(item))
|
||||
self.piglin_can_replace(m, stack, self.piglin_slot(item))
|
||||
} else {
|
||||
!holds_loved && space
|
||||
}
|
||||
@@ -98,7 +121,7 @@ impl Game<'_> {
|
||||
let item = &*stack.item;
|
||||
let mut slot = self.piglin_slot(item);
|
||||
let m = &self.mobs[index];
|
||||
let mut replace = self.piglin_can_replace(m, item, slot);
|
||||
let mut replace = self.piglin_can_replace(m, stack, slot);
|
||||
if slot > 1 && !replace {
|
||||
slot = 0;
|
||||
replace = m.extra.main_hand.is_none();
|
||||
@@ -194,7 +217,11 @@ impl Game<'_> {
|
||||
}
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Piglin || !m.alive() || m.extra.using_item > 0 {
|
||||
if m.species() != Species::Piglin
|
||||
|| !m.alive()
|
||||
|| m.extra.using_item > 0
|
||||
|| !self.creatures.ticked.contains(&m.id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let pos = m.position;
|
||||
@@ -445,3 +472,126 @@ impl Game<'_> {
|
||||
.is_some_and(|t| t.contains(name))
|
||||
}
|
||||
}
|
||||
|
||||
/// The decision of `Piglin.canReplaceCurrentItem` once both stacks are known.
|
||||
fn piglin_replace(
|
||||
new_wanted: bool,
|
||||
current_wanted: bool,
|
||||
new: &equip::Facts,
|
||||
worn: &equip::Facts,
|
||||
slot: Slot,
|
||||
preferred: Option<(bool, bool)>,
|
||||
) -> bool {
|
||||
if worn.prevents_armor_change {
|
||||
return false;
|
||||
}
|
||||
if new_wanted != current_wanted {
|
||||
return new_wanted;
|
||||
}
|
||||
equip::can_replace_current_item(new, Some(worn), slot, preferred)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn armor(points: f64, binding: bool) -> equip::Facts {
|
||||
equip::Facts {
|
||||
armor: points,
|
||||
armor_toughness: 0.0,
|
||||
attack_damage: 0.0,
|
||||
enchantments: 0,
|
||||
damage: 0,
|
||||
custom_name: false,
|
||||
prevents_armor_change: binding,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn better_helmet_replaces_worse_one() {
|
||||
// Diamond (3) over iron (2), neither loved.
|
||||
let (diamond, iron) = (armor(3.0, false), armor(2.0, false));
|
||||
assert!(piglin_replace(
|
||||
false,
|
||||
false,
|
||||
&diamond,
|
||||
&iron,
|
||||
Slot::Head,
|
||||
None
|
||||
));
|
||||
assert!(!piglin_replace(
|
||||
false,
|
||||
false,
|
||||
&iron,
|
||||
&diamond,
|
||||
Slot::Head,
|
||||
None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_curse_keeps_the_worn_piece() {
|
||||
let (diamond, cursed) = (armor(3.0, false), armor(1.0, true));
|
||||
assert!(!piglin_replace(
|
||||
false,
|
||||
false,
|
||||
&diamond,
|
||||
&cursed,
|
||||
Slot::Head,
|
||||
None
|
||||
));
|
||||
assert!(!piglin_replace(
|
||||
true,
|
||||
false,
|
||||
&diamond,
|
||||
&cursed,
|
||||
Slot::Head,
|
||||
None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wanted_beats_comparison() {
|
||||
let (gold, diamond) = (armor(2.0, false), armor(3.0, false));
|
||||
assert!(piglin_replace(
|
||||
true,
|
||||
false,
|
||||
&gold,
|
||||
&diamond,
|
||||
Slot::Head,
|
||||
None
|
||||
));
|
||||
assert!(!piglin_replace(
|
||||
false,
|
||||
true,
|
||||
&diamond,
|
||||
&gold,
|
||||
Slot::Head,
|
||||
None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_weapon_comparison_by_damage() {
|
||||
let mut strong = armor(0.0, false);
|
||||
strong.attack_damage = 7.0;
|
||||
let mut weak = armor(0.0, false);
|
||||
weak.attack_damage = 4.0;
|
||||
assert!(piglin_replace(
|
||||
false,
|
||||
false,
|
||||
&strong,
|
||||
&weak,
|
||||
Slot::MainHand,
|
||||
None
|
||||
));
|
||||
assert!(!piglin_replace(
|
||||
false,
|
||||
false,
|
||||
&weak,
|
||||
&strong,
|
||||
Slot::MainHand,
|
||||
None
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,24 @@ struct Tracked {
|
||||
kind: String,
|
||||
position: [f64; 3],
|
||||
silent: bool,
|
||||
/// `Entity.vehicle`: the entity it rides (`SetPassengers`), whatever
|
||||
/// either one's type.
|
||||
vehicle: Option<u32>,
|
||||
}
|
||||
|
||||
/// `Entity.startRiding` / `removePassenger` for a `SetPassengers` packet: the
|
||||
/// listed entities ride `vehicle`, the ones that rode it and are no longer
|
||||
/// listed got off. Every tracked entity counts, whatever its type, so a
|
||||
/// server's item display or marker on a player hides that player's name tag
|
||||
/// as vanilla's `isVehicle` does (#5666).
|
||||
fn seat_passengers(entities: &mut HashMap<i32, Tracked>, vehicle: u32, passengers: &[i32]) {
|
||||
for (id, t) in entities.iter_mut() {
|
||||
if passengers.contains(id) {
|
||||
t.vehicle = Some(vehicle);
|
||||
} else if t.vehicle == Some(vehicle) {
|
||||
t.vehicle = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Smooth an entity toward a new position over three ticks (InterpolationHandler).
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -2652,6 +2670,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Entity.isVehicle` on a client: the entities a tracked entity rides.
|
||||
pub(super) fn remote_vehicle_ids(&self) -> impl Iterator<Item = u32> + '_ {
|
||||
self.remote
|
||||
.iter()
|
||||
.flat_map(|r| r.entities.values().filter_map(|t| t.vehicle))
|
||||
}
|
||||
/// The `SetPassengers` bookkeeping of the tracked entities.
|
||||
pub(super) fn remote_seat_passengers(&mut self, vehicle: u32, passengers: &[i32]) {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
seat_passengers(&mut r.entities, vehicle, passengers);
|
||||
}
|
||||
}
|
||||
/// `recreateFromPacket`: yRot and xRot from the packet, the body and head
|
||||
/// both at its head yaw (LivingEntity).
|
||||
fn spawn_entity(&mut self, id: i32, kind: &str, position: [f64; 3], rotation: [f32; 3]) {
|
||||
@@ -2706,6 +2736,7 @@ impl Game<'_> {
|
||||
kind: kind.to_owned(),
|
||||
position,
|
||||
silent: false,
|
||||
vehicle: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -11585,6 +11616,42 @@ mod tests {
|
||||
"minecraft:entity.player.hurt"
|
||||
);
|
||||
}
|
||||
/// `ClientPacketListener.handleSetEntityPassengersPacket`: the vehicle
|
||||
/// ejects its passengers, then each listed entity `startRiding`s it, so
|
||||
/// `isVehicle` holds while the list is not empty, whatever the passenger's
|
||||
/// type: a server's item display riding a player hides that player's name
|
||||
/// tag like a text display does (#5666).
|
||||
#[test]
|
||||
fn any_tracked_passenger_makes_its_vehicle_a_vehicle() {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
let tracked = |kind: &str| super::Tracked {
|
||||
kind: kind.to_owned(),
|
||||
position: [0.0, 64.0, 0.0],
|
||||
silent: false,
|
||||
vehicle: None,
|
||||
};
|
||||
let mut entities = HashMap::from([
|
||||
(42, tracked("minecraft:player")),
|
||||
(43, tracked("minecraft:player")),
|
||||
(9, tracked("minecraft:item_display")),
|
||||
(10, tracked("minecraft:block_display")),
|
||||
]);
|
||||
let vehicles = |e: &HashMap<i32, super::Tracked>| -> HashSet<u32> {
|
||||
e.values().filter_map(|t| t.vehicle).collect()
|
||||
};
|
||||
super::seat_passengers(&mut entities, 42, &[9, 10]);
|
||||
assert_eq!(vehicles(&entities), HashSet::from([42]));
|
||||
// The list replaces the old one: 10 got off, 9 still rides.
|
||||
super::seat_passengers(&mut entities, 42, &[9]);
|
||||
assert_eq!(entities[&10].vehicle, None);
|
||||
assert_eq!(entities[&9].vehicle, Some(42));
|
||||
// startRiding leaves the old vehicle first.
|
||||
super::seat_passengers(&mut entities, 43, &[9]);
|
||||
assert_eq!(vehicles(&entities), HashSet::from([43]));
|
||||
// An empty list: nobody rides 43 any more.
|
||||
super::seat_passengers(&mut entities, 43, &[]);
|
||||
assert!(vehicles(&entities).is_empty());
|
||||
}
|
||||
/// `ClientboundSoundEntityPacket`: the sound is heard where the entity it
|
||||
/// names is — the local player included, which is no tracked entity — and
|
||||
/// an id this level doesn't have plays nothing at all.
|
||||
@@ -11598,6 +11665,7 @@ mod tests {
|
||||
kind: "minecraft:cow".to_owned(),
|
||||
position: [8.5, 64.0, -3.5],
|
||||
silent: false,
|
||||
vehicle: None,
|
||||
},
|
||||
);
|
||||
let player = Some((2, [0.5, 70.0, 0.5], false));
|
||||
@@ -11640,6 +11708,7 @@ mod tests {
|
||||
kind: "minecraft:arrow".to_owned(),
|
||||
position: [8.5, 64.0, -3.5],
|
||||
silent: true,
|
||||
vehicle: None,
|
||||
},
|
||||
);
|
||||
let state = super::entity_sound_state(7, None, None, &tracked, None).unwrap();
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
//! server (`LocalPlayer.tick`: `ServerboundMoveVehiclePacket`,
|
||||
//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). So is a saddled horse,
|
||||
//! donkey, mule or camel: its rider's client moves it and reports the moves,
|
||||
//! and tells the server of a riding jump (`START_RIDING_JUMP`). A minecart
|
||||
//! and tells the server of a riding jump (`START_RIDING_JUMP`). Pigs and striders
|
||||
//! also move on their rider's client when saddled and steered with the right stick.
|
||||
//! A minecart
|
||||
//! moves on the server with the rider's input, and the rider sits in it.
|
||||
use super::{Game, Keys, ray_box};
|
||||
use crate::Result;
|
||||
@@ -154,6 +156,16 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let vehicle_id = vehicle as u32;
|
||||
self.remote_seat_passengers(vehicle_id, passengers);
|
||||
let player_controls = passengers.first().is_some_and(|&id| {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_player(id).is_some())
|
||||
});
|
||||
if let Some(boat) = self.boats.iter_mut().find(|b| b.id == vehicle_id) {
|
||||
boat.paddle_controller = player_controls;
|
||||
}
|
||||
|
||||
for m in &mut self.mobs {
|
||||
// `Entity.isVehicle` (the saddle reins of a ridden horse): the
|
||||
// server sends no rider on the mob itself, only this list.
|
||||
@@ -399,7 +411,7 @@ impl Game<'_> {
|
||||
/// boat on a client: `status`, the two `applyEffectsFromBlocks` (a boat
|
||||
/// above a column may splash; the boat the player drives is lifted or
|
||||
/// dragged by a column it is inside) and `tickBubbleColumn`'s wobble from
|
||||
/// the synched bubble time.
|
||||
/// the synched bubble time, followed by the oars on boats this client does not drive.
|
||||
pub(super) fn tick_remote_boat_bubbles(&mut self) {
|
||||
for i in 0..self.boats.len() {
|
||||
let driven = self.riding == Some(self.boats[i].id);
|
||||
@@ -423,6 +435,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.boats[i].tick_bubble_wobble();
|
||||
if !driven {
|
||||
Self::tick_boat_paddles(
|
||||
&mut self.boats[i],
|
||||
&mut self.audio,
|
||||
&mut self.world.random,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,7 +450,19 @@ impl Game<'_> {
|
||||
/// saddle (`isLocalInstanceAuthoritative`).
|
||||
fn drives(&self, m: &Mob) -> bool {
|
||||
self.riding == Some(m.id)
|
||||
&& client_moves(m, self.remote.as_ref().is_some_and(|r| r.first_passenger))
|
||||
&& client_moves(
|
||||
m,
|
||||
self.remote.as_ref().is_some_and(|r| r.first_passenger),
|
||||
self.steers_mount(m),
|
||||
)
|
||||
}
|
||||
|
||||
fn steers_mount(&self, m: &Mob) -> bool {
|
||||
super::mounts::steering_item(
|
||||
m.species(),
|
||||
self.held().map(|s| s.item.as_ref()),
|
||||
self.offhand.as_ref().map(|s| s.item.as_ref()),
|
||||
)
|
||||
}
|
||||
|
||||
/// The mount this client moves, if the player rides one.
|
||||
@@ -452,6 +483,7 @@ impl Game<'_> {
|
||||
}
|
||||
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
|
||||
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
|
||||
let steer = self.steers_mount(&self.mobs[i]);
|
||||
let m = &mut self.mobs[i];
|
||||
if jump > 0.0 && equine::is_equine(m.species()) {
|
||||
// LocalPlayer.aiStep also runs `onPlayerJump` here: the mount slides
|
||||
@@ -470,7 +502,7 @@ impl Game<'_> {
|
||||
yaw: self.yaw,
|
||||
pitch: self.pitch,
|
||||
jump: if jump > 0.0 { jump } else { pending },
|
||||
steer: false,
|
||||
steer,
|
||||
sprint: sprinting,
|
||||
jumping: keys.jump,
|
||||
client: false,
|
||||
@@ -498,12 +530,15 @@ impl Game<'_> {
|
||||
/// (`HappyGhast.getControllingPassenger`: wearing body armor, player on
|
||||
/// top, not on a still timeout — the rider steers it with `travelRidden`
|
||||
/// on their own client, and the mount goes to the server as `MoveVehicle`).
|
||||
fn client_moves(m: &Mob, first_passenger: bool) -> bool {
|
||||
fn client_moves(m: &Mob, first_passenger: bool, steer: bool) -> bool {
|
||||
if !first_passenger || !m.alive() {
|
||||
return false;
|
||||
}
|
||||
if equine::moved_by_rider(m.species()) && m.extra.saddled {
|
||||
return true;
|
||||
return !matches!(
|
||||
m.species(),
|
||||
horizon_client::mobs::Species::Pig | horizon_client::mobs::Species::Strider
|
||||
) || steer;
|
||||
}
|
||||
m.species() == horizon_client::mobs::Species::HappyGhast
|
||||
&& happy_ghast::harnessed_adult(m)
|
||||
@@ -536,6 +571,129 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_client::mobs::MobTypes;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared resources and the vanilla mount-control reference"]
|
||||
fn steering_mount_control_matches_vanilla() {
|
||||
use super::super::{
|
||||
Audio, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
|
||||
};
|
||||
use horizon_client::{items::ItemStack, motion::PlayerMotion};
|
||||
use horizon_protocol::connection::Pipe;
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let reference = std::env::var_os("HORIZON_MOUNT_CONTROL_REFERENCE").unwrap();
|
||||
let rows: Vec<serde_json::Value> =
|
||||
serde_json::from_slice(&std::fs::read(reference).unwrap()).unwrap();
|
||||
assert_eq!(rows.len(), 32, "missing vanilla control cases");
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!("no menu handoff"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = ExecutionRole::Client;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let (client, _server) = Pipe::pair();
|
||||
game.connect_local(client, "SteeringTest").unwrap();
|
||||
game.remote.as_mut().unwrap().joined = true;
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 300.0, 0.5)).unwrap());
|
||||
let types = MobTypes::load(&root).unwrap();
|
||||
for row in rows {
|
||||
let kind = row["kind"].as_str().unwrap();
|
||||
let t = std::sync::Arc::new(types.types[&format!("minecraft:{kind}")].clone());
|
||||
let mut m = Mob::new(7, t, Vec3::new(0.5, 300.0, 0.5), 0.0);
|
||||
if row["saddled"].as_bool().unwrap() {
|
||||
m.set_saddle(Some(ItemStack::new("minecraft:saddle", 1)));
|
||||
}
|
||||
let stick = if kind == "pig" {
|
||||
"minecraft:carrot_on_a_stick"
|
||||
} else {
|
||||
"minecraft:warped_fungus_on_a_stick"
|
||||
};
|
||||
let wrong = if kind == "pig" {
|
||||
"minecraft:warped_fungus_on_a_stick"
|
||||
} else {
|
||||
"minecraft:carrot_on_a_stick"
|
||||
};
|
||||
let hand = row["hand"].as_str().unwrap();
|
||||
game.inventory[game.selected] = match hand {
|
||||
"main" => Some(ItemStack::new(stick, 1)),
|
||||
"wrong" => Some(ItemStack::new(wrong, 1)),
|
||||
_ => None,
|
||||
};
|
||||
game.offhand = (hand == "off").then(|| ItemStack::new(stick, 1));
|
||||
game.mobs.clear();
|
||||
game.mobs.push(m);
|
||||
game.riding = Some(7);
|
||||
game.remote.as_mut().unwrap().first_passenger = row["first"].as_bool().unwrap();
|
||||
let controlled = row["controlled"].as_bool().unwrap();
|
||||
assert_eq!(game.driven_mount().is_some(), controlled, "{row}");
|
||||
let packets = game.remote_vehicle_packets();
|
||||
assert_eq!(packets.len(), usize::from(controlled), "{row}");
|
||||
if controlled {
|
||||
assert!(matches!(packets[0], S::MoveVehicle(_)), "{row}");
|
||||
let before = game.mobs[0].position;
|
||||
assert!(game.remote_ride(Keys::default(), 0).unwrap());
|
||||
let m = &game.mobs[0];
|
||||
assert_eq!(
|
||||
f64::from(m.ai.zza),
|
||||
row["forward"].as_f64().unwrap(),
|
||||
"{row}"
|
||||
);
|
||||
assert!(m.position.z > before.z, "{row}: mount did not move forward");
|
||||
match &game.remote_vehicle_packets()[0] {
|
||||
S::MoveVehicle(p) => assert_eq!(
|
||||
p.position,
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
"{row}"
|
||||
),
|
||||
packet => panic!("wrong movement packet: {packet:?}"),
|
||||
}
|
||||
}
|
||||
// The server reads the same held-item requirement, then waits for
|
||||
// this controlling client's MoveVehicle rather than traveling twice.
|
||||
if row["first"].as_bool().unwrap() {
|
||||
assert!(game.tick_mount_riding(Keys::default()));
|
||||
game.mobs[0].rider.as_mut().unwrap().client = true;
|
||||
} else {
|
||||
game.mobs[0].rider = None;
|
||||
}
|
||||
assert_eq!(
|
||||
equine::moved_by_client(&game.mobs[0]),
|
||||
!row["server_simulates"].as_bool().unwrap(),
|
||||
"{row}"
|
||||
);
|
||||
if controlled {
|
||||
let mut server_mount = game.mobs[0].clone();
|
||||
let before = server_mount.position;
|
||||
let mut random = game.world.random.clone();
|
||||
let mut events = Vec::new();
|
||||
server_mount.tick(&game.mob_world_base(&[]), &mut random, &mut events);
|
||||
assert_eq!(
|
||||
server_mount.position, before,
|
||||
"server traveled twice: {row}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mob of the prepared resources' type `kind` (None without them).
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -547,7 +705,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// #2920: on a vanilla server the rider's client moves a saddled horse,
|
||||
/// donkey, mule or camel; a pig, a llama, an unsaddled horse or a second
|
||||
/// donkey, mule or camel; a pig without its stick, a llama, an unsaddled horse or a second
|
||||
/// passenger's ride is the server's.
|
||||
#[test]
|
||||
fn the_rider_moves_a_saddled_horse_on_its_client() {
|
||||
@@ -555,14 +713,17 @@ mod tests {
|
||||
let Some(mut m) = mob(&format!("minecraft:{kind}")) else {
|
||||
return;
|
||||
};
|
||||
assert!(!client_moves(&m, true), "{kind} without a saddle");
|
||||
assert!(!client_moves(&m, true, false), "{kind} without a saddle");
|
||||
m.set_saddle(Some(horizon_client::items::ItemStack::new(
|
||||
"minecraft:saddle",
|
||||
1,
|
||||
)));
|
||||
let horse = matches!(kind, "horse" | "donkey" | "mule" | "camel");
|
||||
assert_eq!(client_moves(&m, true), horse, "{kind}");
|
||||
assert!(!client_moves(&m, false), "{kind} behind another rider");
|
||||
assert_eq!(client_moves(&m, true, false), horse, "{kind}");
|
||||
assert!(
|
||||
!client_moves(&m, false, false),
|
||||
"{kind} behind another rider"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,12 +735,12 @@ mod tests {
|
||||
let Some(mut m) = mob("minecraft:happy_ghast") else {
|
||||
return;
|
||||
};
|
||||
assert!(!client_moves(&m, true), "ghast without a harness");
|
||||
assert!(!client_moves(&m, true, false), "ghast without a harness");
|
||||
m.extra.body = Some("minecraft:white_happy_ghast_harness".into());
|
||||
assert!(client_moves(&m, true), "harnessed adult");
|
||||
assert!(!client_moves(&m, false), "behind another rider");
|
||||
assert!(client_moves(&m, true, false), "harnessed adult");
|
||||
assert!(!client_moves(&m, false, false), "behind another rider");
|
||||
m.extra.age = -1;
|
||||
assert!(!client_moves(&m, true), "baby ghast");
|
||||
assert!(!client_moves(&m, true, false), "baby ghast");
|
||||
}
|
||||
|
||||
/// #2920: the client reports where it moved the mount, and a released
|
||||
|
||||
@@ -69,6 +69,14 @@ const HORIZONTAL: [[i32; 3]; 8] = [
|
||||
[-1, 0, 1],
|
||||
[1, 0, 1],
|
||||
];
|
||||
/// Blocks vanilla defines with `.liquid()`: water, lava and the bubble column.
|
||||
fn is_liquid_block_name(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"minecraft:water" | "minecraft:lava" | "minecraft:bubble_column"
|
||||
)
|
||||
}
|
||||
|
||||
fn respawn_offsets() -> impl Iterator<Item = [i32; 3]> {
|
||||
HORIZONTAL
|
||||
.into_iter()
|
||||
@@ -413,7 +421,7 @@ impl Game<'_> {
|
||||
name.ends_with("_banner")
|
||||
|| f.sign_form().is_some()
|
||||
|| name.ends_with("_pressure_plate")
|
||||
|| !(f.legacy_solid || matches!(name, "minecraft:water" | "minecraft:lava"))
|
||||
|| !(f.legacy_solid || is_liquid_block_name(name))
|
||||
}
|
||||
/// `RespawnAnchorBlock.findStandUpPosition`: a safe spot around the
|
||||
/// anchor, else any spot.
|
||||
@@ -539,6 +547,14 @@ fn wrap_degrees(degrees: f64) -> f64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bubble_column_counts_as_liquid_for_respawn() {
|
||||
assert!(is_liquid_block_name("minecraft:bubble_column"));
|
||||
assert!(is_liquid_block_name("minecraft:water"));
|
||||
assert!(is_liquid_block_name("minecraft:lava"));
|
||||
assert!(!is_liquid_block_name("minecraft:kelp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waking_up_next_to_a_bed_looks_toward_the_bed_head() {
|
||||
let head = [10, 64, 5];
|
||||
|
||||
@@ -97,6 +97,8 @@ pub(super) struct SculkState {
|
||||
/// The vehicle each rider had last tick and where it stood.
|
||||
mounts: FxHashMap<Who, (u32, [f64; 3])>,
|
||||
damaged: FxHashMap<u32, i64>,
|
||||
/// Set while the mobs' events are posted (`tick_mob_sculk_events`).
|
||||
mob_slots: Option<MobSlots>,
|
||||
last_health: Option<f32>,
|
||||
/// The darkness effect's blend factor, previous and current tick.
|
||||
pub darkness: (f32, f32),
|
||||
@@ -106,6 +108,27 @@ pub(super) struct SculkState {
|
||||
pub tracker_loaded: bool,
|
||||
}
|
||||
|
||||
/// How far from a warden's or an allay's feet an event may be heard: their
|
||||
/// listeners reach 16 blocks from the eye's block (`warden::listen`,
|
||||
/// `allay::listen`), the eye stands under 3 blocks up, and flooring to blocks
|
||||
/// adds under 2. Past this they do nothing with it.
|
||||
const LISTENING_MOB_REACH: f64 = 24.0;
|
||||
|
||||
fn dist_sqr_f(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
(0..3).map(|i| (a[i] - b[i]) * (a[i] - b[i])).sum()
|
||||
}
|
||||
|
||||
/// Where each mob is in `Game::mobs` while the mobs' events are posted, for
|
||||
/// the thousands of lookups by id they make, and whether any mob listens.
|
||||
struct MobSlots {
|
||||
/// The first slot of each id, as a scan would find.
|
||||
by_id: FxHashMap<u32, usize>,
|
||||
/// The wardens and allays (the mobs that listen): slot, id and position.
|
||||
listeners: Vec<(usize, u32, [f64; 3])>,
|
||||
/// `Game::next_entity` when they were taken: unchanged, no mob joined.
|
||||
next_entity: u32,
|
||||
}
|
||||
|
||||
impl SculkState {
|
||||
pub(super) fn swap_player(&mut self, other: &mut PlayerEvents) {
|
||||
let steps = self.steps.remove(&Who::Player);
|
||||
@@ -551,7 +574,19 @@ impl Game<'_> {
|
||||
}
|
||||
/// `GameEvent.Context.of(mob, state)`.
|
||||
pub(super) fn mob_context(&self, id: u32, state: Option<BlockStateId>) -> Context {
|
||||
self.context_of_mob(self.mobs.iter().find(|m| m.id == id), id, state)
|
||||
self.context_of_mob(self.mob_by_id(id), id, state)
|
||||
}
|
||||
/// The first mob with this id, from the slots taken for the mobs' events
|
||||
/// while the mob is still there (mobs are only appended meanwhile), else
|
||||
/// by a scan.
|
||||
fn mob_by_id(&self, id: u32) -> Option<&Mob> {
|
||||
if let Some(slots) = &self.sculk.mob_slots
|
||||
&& let Some(&i) = slots.by_id.get(&id)
|
||||
&& self.mobs.get(i).is_some_and(|m| m.id == id)
|
||||
{
|
||||
return self.mobs.get(i);
|
||||
}
|
||||
self.mobs.iter().find(|m| m.id == id)
|
||||
}
|
||||
fn context_of_mob(&self, mob: Option<&Mob>, id: u32, state: Option<BlockStateId>) -> Context {
|
||||
Context {
|
||||
@@ -581,6 +616,18 @@ impl Game<'_> {
|
||||
|
||||
/// `Level.gameEvent`: tell the listeners within reach.
|
||||
pub(super) fn post_game_event(&mut self, event: &'static str, pos: [f64; 3], ctx: Context) {
|
||||
self.post_game_event_with(event, pos, |_| ctx);
|
||||
}
|
||||
/// [`Self::post_game_event`] with the context worked out only when some
|
||||
/// listener may hear the event (`GameEventDispatcher.post` visits only the
|
||||
/// listeners registered in the sections around it): most of a server's
|
||||
/// mobs are near none.
|
||||
fn post_game_event_with(
|
||||
&mut self,
|
||||
event: &'static str,
|
||||
pos: [f64; 3],
|
||||
ctx: impl FnOnce(&Self) -> Context,
|
||||
) {
|
||||
self.native_game_event(event, pos);
|
||||
super::dispense_fixture::record(|| {
|
||||
format!(
|
||||
@@ -596,18 +643,50 @@ impl Game<'_> {
|
||||
let at = block_of(pos);
|
||||
let radius = sculk::notification_radius(event).min(16);
|
||||
let near = self.sculk.index.within(at, radius);
|
||||
// The wardens and allays that may hear it, in the mobs' order, while
|
||||
// the slots taken for the mobs' events hold; else every mob is asked.
|
||||
let mobs = self.sculk.mob_slots.as_ref().and_then(|s| {
|
||||
let mut near = Vec::new();
|
||||
for &(i, id, from) in &s.listeners {
|
||||
if self.mobs.get(i).is_none_or(|m| m.id != id) {
|
||||
return None;
|
||||
}
|
||||
let d = dist_sqr_f(from, pos);
|
||||
if d.is_nan() || d <= LISTENING_MOB_REACH * LISTENING_MOB_REACH {
|
||||
near.push(i);
|
||||
}
|
||||
}
|
||||
(s.next_entity == self.next_entity).then_some(near)
|
||||
});
|
||||
if near.is_empty() && mobs.as_ref().is_some_and(Vec::is_empty) {
|
||||
return;
|
||||
}
|
||||
let ctx = ctx(self);
|
||||
for key in near {
|
||||
self.sculk_listen(key, event, pos, &ctx);
|
||||
}
|
||||
for i in 0..self.mobs.len() {
|
||||
if self.mobs[i].species() == Species::Warden && self.mobs[i].alive() {
|
||||
self.warden_listen(i, event, pos, &ctx);
|
||||
match mobs {
|
||||
Some(mobs) => {
|
||||
for i in mobs {
|
||||
self.mob_listen(i, event, pos, &ctx);
|
||||
}
|
||||
}
|
||||
if self.mobs[i].species() == Species::Allay && self.mobs[i].alive() {
|
||||
self.allay_listen(i, event, pos, &ctx);
|
||||
None => {
|
||||
for i in 0..self.mobs.len() {
|
||||
self.mob_listen(i, event, pos, &ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The mob at `index` hears a game event, if it is a warden or an allay.
|
||||
fn mob_listen(&mut self, index: usize, event: &'static str, pos: [f64; 3], ctx: &Context) {
|
||||
if self.mobs[index].species() == Species::Warden && self.mobs[index].alive() {
|
||||
self.warden_listen(index, event, pos, ctx);
|
||||
}
|
||||
if self.mobs[index].species() == Species::Allay && self.mobs[index].alive() {
|
||||
self.allay_listen(index, event, pos, ctx);
|
||||
}
|
||||
}
|
||||
/// A game event at a block (`Level.gameEvent(source, event, pos)`).
|
||||
pub(super) fn post_block_game_event(
|
||||
&mut self,
|
||||
@@ -640,14 +719,11 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]),
|
||||
Who::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.mob_by_id(id)
|
||||
.map(|m| [m.position.x, m.position.y, m.position.z]),
|
||||
};
|
||||
if let Some(at) = at {
|
||||
let ctx = self.who_context(Some(who), None);
|
||||
self.post_game_event(event, at, ctx);
|
||||
self.post_game_event_with(event, at, |g| g.who_context(Some(who), None));
|
||||
}
|
||||
}
|
||||
/// `LevelEvent::GameEvent` from the block behaviour library.
|
||||
@@ -1669,11 +1745,15 @@ impl Game<'_> {
|
||||
let present: HashSet<u32> = self.dropped.iter().map(|item| item.id).collect();
|
||||
self.sculk.falling_items.retain(|id| present.contains(id));
|
||||
}
|
||||
// Dropped items are entities too: Entity.applyEffectsFromBlocks.
|
||||
// Dropped items are entities too: Entity.applyEffectsFromBlocks, from
|
||||
// ItemEntity.tick, so only for the items in entity-ticking chunks.
|
||||
let ticking = self.entity_ticking_area();
|
||||
let items: Vec<[f64; 3]> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.filter(|i| i.on_ground)
|
||||
.filter(|i| {
|
||||
i.on_ground && ticking(i.position.x.floor() as i32, i.position.z.floor() as i32)
|
||||
})
|
||||
.map(|i| [i.position.x, i.position.y, i.position.z])
|
||||
.collect();
|
||||
for at in items {
|
||||
@@ -1893,15 +1973,54 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut by_id = FxHashMap::default();
|
||||
for (i, m) in self.mobs.iter().enumerate() {
|
||||
by_id.entry(m.id).or_insert(i);
|
||||
}
|
||||
let listeners = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| matches!(m.species(), Species::Warden | Species::Allay))
|
||||
.map(|(i, m)| (i, m.id, [m.position.x, m.position.y, m.position.z]))
|
||||
.collect();
|
||||
self.sculk.mob_slots = Some(MobSlots {
|
||||
by_id,
|
||||
listeners,
|
||||
next_entity: self.next_entity,
|
||||
});
|
||||
// Steps, landings, splashes, flaps and stepping on redstone ore come
|
||||
// from the mob's own tick (Entity.move, LivingEntity.aiStep's
|
||||
// applyEffectsFromBlocks), which ServerLevel.tick runs only in
|
||||
// entity-ticking chunks: for the mobs `tick_creature_entities` ticked,
|
||||
// and those standing in that area (the sculk fixture posts before the
|
||||
// mobs tick). Damage and mounting are raised by `hurt` and
|
||||
// `startRiding`, ticking or not.
|
||||
let ticking = self.entity_ticking_area();
|
||||
for (id, at, on_ground, in_water, species, damaged, vehicle, age, resting) in snapshot {
|
||||
if on_ground {
|
||||
let ticked = self.creatures.ticked.contains(&id)
|
||||
|| ticking(at[0].floor() as i32, at[2].floor() as i32);
|
||||
if on_ground && ticked {
|
||||
self.step_on_redstone_ore(at, false, false);
|
||||
}
|
||||
// A mob first seen already riding raised its event when it spawned.
|
||||
let seen = self.sculk.steps.contains_key(&Who::Mob(id));
|
||||
self.mount_events(Who::Mob(id), vehicle, seen);
|
||||
let emits = !matches!(species, Species::Shulker);
|
||||
if emits {
|
||||
if emits && !ticked {
|
||||
// Where it is now, without a move: a mob put elsewhere while
|
||||
// it did not tick (a command) walked nowhere.
|
||||
let entry = self.sculk.steps.entry(Who::Mob(id)).or_insert(Steps {
|
||||
move_dist: 0.0,
|
||||
next_step: 1.0,
|
||||
last: at,
|
||||
on_ground,
|
||||
in_water,
|
||||
});
|
||||
entry.last = at;
|
||||
entry.on_ground = on_ground;
|
||||
entry.in_water = in_water;
|
||||
} else if emits {
|
||||
let warden = species == Species::Warden;
|
||||
self.entity_motion_events(
|
||||
Who::Mob(id),
|
||||
@@ -1926,13 +2045,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(t) = damaged {
|
||||
let seen = self.sculk.damaged.insert(id, t);
|
||||
if seen != Some(t) {
|
||||
self.post_entity_event("minecraft:entity_damage", Who::Mob(id));
|
||||
}
|
||||
if let Some(t) = damaged
|
||||
&& self.sculk.damaged.get(&id) != Some(&t)
|
||||
{
|
||||
self.sculk.damaged.insert(id, t);
|
||||
self.post_entity_event("minecraft:entity_damage", Who::Mob(id));
|
||||
}
|
||||
}
|
||||
self.sculk.mob_slots = None;
|
||||
let alive: FxHashSet<u32> = self.mobs.iter().map(|m| m.id).collect();
|
||||
self.sculk.damaged.retain(|id, _| alive.contains(id));
|
||||
self.sculk.steps.retain(|who, _| match who {
|
||||
@@ -1946,7 +2066,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Where the vehicle `id` stands: a mob, boat, minecart or cushion.
|
||||
fn vehicle_position(&self, id: u32) -> Option<[f64; 3]> {
|
||||
let p = if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
|
||||
let p = if let Some(m) = self.mob_by_id(id) {
|
||||
m.position
|
||||
} else if let Some(b) = self.boats.iter().find(|b| b.id == id) {
|
||||
b.position
|
||||
@@ -1963,6 +2083,10 @@ impl Game<'_> {
|
||||
/// for a rider first seen riding.
|
||||
fn mount_events(&mut self, who: Who, vehicle: Option<u32>, announce: bool) {
|
||||
let last = self.sculk.mounts.get(&who).copied();
|
||||
// Riding nothing, as last tick: nothing to tell or keep.
|
||||
if vehicle.is_none() && last.is_none() {
|
||||
return;
|
||||
}
|
||||
let now = vehicle.and_then(|id| self.vehicle_position(id));
|
||||
if last.map(|l| l.0) != vehicle {
|
||||
if let Some((_, at)) = last {
|
||||
@@ -2022,7 +2146,7 @@ impl Game<'_> {
|
||||
match who {
|
||||
Who::Player => self.player.as_ref()?.ground.supporting_block,
|
||||
Who::Mob(id) => {
|
||||
let mob = self.mobs.iter().find(|m| m.id == id)?;
|
||||
let mob = self.mob_by_id(id)?;
|
||||
self.supporting_block(mob.position, mob.bounds())
|
||||
}
|
||||
}
|
||||
@@ -2039,6 +2163,16 @@ impl Game<'_> {
|
||||
warden: bool,
|
||||
) {
|
||||
self.sculk_prepare();
|
||||
// Standing as it stood with no step due (a mob outside the simulation
|
||||
// distance, or one at rest): nothing below would change or be heard.
|
||||
if let Some(e) = self.sculk.steps.get(&who)
|
||||
&& e.last == at
|
||||
&& e.on_ground == on_ground
|
||||
&& e.in_water == in_water
|
||||
&& e.move_dist <= e.next_step
|
||||
{
|
||||
return;
|
||||
}
|
||||
let entry = self.sculk.steps.entry(who).or_insert(Steps {
|
||||
move_dist: 0.0,
|
||||
next_step: 1.0,
|
||||
@@ -2066,8 +2200,9 @@ impl Game<'_> {
|
||||
at[2].floor() as i32,
|
||||
),
|
||||
};
|
||||
let ctx = self.who_context(Some(who), Some(state));
|
||||
self.post_game_event("minecraft:hit_ground", at, ctx);
|
||||
self.post_game_event_with("minecraft:hit_ground", at, |g| {
|
||||
g.who_context(Some(who), Some(state))
|
||||
});
|
||||
}
|
||||
// Entity.updateInWaterStateAndDoFluidPushing: entering water splashes.
|
||||
if in_water && !previous.in_water {
|
||||
@@ -2118,8 +2253,9 @@ impl Game<'_> {
|
||||
} else {
|
||||
(move_dist as i32).wrapping_add(1) as f32
|
||||
};
|
||||
let ctx = self.who_context(Some(who), Some(state));
|
||||
self.post_game_event("minecraft:step", at, ctx);
|
||||
self.post_game_event_with("minecraft:step", at, |g| {
|
||||
g.who_context(Some(who), Some(state))
|
||||
});
|
||||
} else if in_water {
|
||||
entry.next_step = (move_dist as i32).wrapping_add(1) as f32;
|
||||
self.post_entity_event("minecraft:swim", who);
|
||||
|
||||
@@ -499,11 +499,27 @@ impl Game<'_> {
|
||||
) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
// A shader pack's shadow pass draws a block entity only in the sections it draws
|
||||
// (`Renderer::shadow_pass_block_entities`, #8464): a spawner's mob out of the view and
|
||||
// out of those sections isn't drawn, where it would have gone to the shadow pass alone.
|
||||
let shadow_sections = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.filter(|_| self.pack_ids.is_some() && self.pack_shadow_casters.0);
|
||||
for (key, (_, mob)) in &self.spawners.displays {
|
||||
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
|
||||
if at.distance_squared(camera) >= 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = shadow_sections
|
||||
&& !r.shadow_pass_drew([key.0, key.1, key.2])
|
||||
&& self.view_frustum.is_some_and(|v| {
|
||||
let o = (at - camera).as_vec3();
|
||||
!v.intersects(o - glam::Vec3::ONE, o + glam::Vec3::splat(2.0))
|
||||
})
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (spin, scale) = match self.world.block_entities.get(key) {
|
||||
Some(BlockEntity::Spawner(s)) => {
|
||||
s.display_pose(alpha as f32, mob.width(), mob.height())
|
||||
|
||||
@@ -223,7 +223,7 @@ impl Game<'_> {
|
||||
let griefing = self.ui.rules.bool("mob_griefing");
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::SulfurCube {
|
||||
if m.species() != Species::SulfurCube || !self.creatures.ticked.contains(&m.id) {
|
||||
continue;
|
||||
}
|
||||
if !m.alive() || m.is_baby() || sulfur_cube::has_body(m) || m.extra.ints[3] > 0 {
|
||||
|
||||
@@ -484,17 +484,18 @@ impl Game<'_> {
|
||||
impl Game<'_> {
|
||||
/// `positionRider` for the mobs riding primed TNT: the passenger attachment
|
||||
/// (the top of its 0.98 box), less the rider's own vehicle attachment.
|
||||
pub(super) fn seat_tnt_mobs(&mut self) {
|
||||
pub(super) fn seat_tnt_mobs(&mut self, riders: &super::passengers::Riders) {
|
||||
for i in 0..self.tnts.len() {
|
||||
let (id, pos, prev) = (
|
||||
self.tnts[i].id,
|
||||
self.tnts[i].position,
|
||||
self.tnts[i].previous,
|
||||
);
|
||||
if id == 0 {
|
||||
let Some(js) = riders.get(&id).filter(|_| id != 0) else {
|
||||
continue;
|
||||
}
|
||||
for m in self.mobs.iter_mut().filter(|m| m.extra.mount == Some(id)) {
|
||||
};
|
||||
for &j in js {
|
||||
let m = &mut self.mobs[j];
|
||||
// Primed TNT's box is 0.98 wide.
|
||||
let offset = horizon_client::mobs::riders::vehicle_attachment(m, 0.98);
|
||||
m.position = tnt_seat(pos, offset);
|
||||
|
||||
@@ -247,7 +247,10 @@ impl Game<'_> {
|
||||
pub(super) fn villager_item_sense(&mut self) {
|
||||
if self.dropped.is_empty() {
|
||||
for m in &mut self.mobs {
|
||||
if m.species() == Species::Villager && villager_brain::take_item_scan(m) {
|
||||
if m.species() == Species::Villager
|
||||
&& self.creatures.ticked.contains(&m.id)
|
||||
&& villager_brain::take_item_scan(m)
|
||||
{
|
||||
villager_brain::set_wanted_item(m, None);
|
||||
}
|
||||
}
|
||||
@@ -261,7 +264,10 @@ impl Game<'_> {
|
||||
.cloned();
|
||||
let mut due = Vec::new();
|
||||
for (i, m) in self.mobs.iter_mut().enumerate() {
|
||||
if m.species() == Species::Villager && villager_brain::take_item_scan(m) {
|
||||
if m.species() == Species::Villager
|
||||
&& self.creatures.ticked.contains(&m.id)
|
||||
&& villager_brain::take_item_scan(m)
|
||||
{
|
||||
due.push(i);
|
||||
}
|
||||
}
|
||||
@@ -314,26 +320,49 @@ impl Game<'_> {
|
||||
.item_tags
|
||||
.get("minecraft:villager_picks_up")
|
||||
.cloned();
|
||||
// A villager with no item near skips its scan; one near an item scans
|
||||
// the items of the cells it meets as before, testing the cheap reach
|
||||
// first: a server's villagers and dropped items each run into the
|
||||
// hundreds or thousands.
|
||||
let mut near_items = super::item_areas::ItemAreas::default();
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Villager || !m.alive() {
|
||||
if m.species() != Species::Villager
|
||||
|| !m.alive()
|
||||
|| !self.creatures.ticked.contains(&m.id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (pos, half) = (m.position, f64::from(m.width()) / 2.0 + 1.0);
|
||||
let top = f64::from(m.height());
|
||||
let reach = horizon_core::geometry::Aabb::new(
|
||||
Vec3::new(pos.x - half, pos.y - 0.25, pos.z - half),
|
||||
Vec3::new(pos.x + half, pos.y + top, pos.z + half),
|
||||
);
|
||||
let mut scan = near_items.scan(&self.dropped, self.next_entity, reach);
|
||||
if scan.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let requested = villager::requested_items(m);
|
||||
let mut j = 0;
|
||||
while j < self.dropped.len() {
|
||||
let e = &self.dropped[j];
|
||||
loop {
|
||||
j = scan.next_from(j, self.dropped.len());
|
||||
let Some(e) = self.dropped.get(j) else {
|
||||
break;
|
||||
};
|
||||
let near = (e.position.x - pos.x).abs() <= half
|
||||
&& (e.position.z - pos.z).abs() <= half
|
||||
&& e.position.y >= pos.y - 0.25
|
||||
&& e.position.y <= pos.y + top;
|
||||
if !near || e.pickup_delay > 0 {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
let item = e.stack.item.clone();
|
||||
let wants = villager::food_points(&item) > 0
|
||||
|| picks_up.as_ref().is_some_and(|t| t.contains(&*item))
|
||||
|| requested.contains(&&*item);
|
||||
if !near || e.pickup_delay > 0 || !wants {
|
||||
if !wants {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -355,6 +384,7 @@ impl Game<'_> {
|
||||
match rest {
|
||||
None => {
|
||||
self.remove_dropped(j);
|
||||
scan.removed(j);
|
||||
}
|
||||
Some(rest) => {
|
||||
self.dropped[j].stack.count = rest.count;
|
||||
|
||||
@@ -1036,6 +1036,34 @@ pub fn tag_json(tag: &Tag) -> Value {
|
||||
Tag::LongArray(a) => Value::Array(a.iter().map(|v| Value::from(*v)).collect()),
|
||||
}
|
||||
}
|
||||
/// Custom model flags use Codec.BOOL even though NBT stores them as numbers.
|
||||
/// Keep the other custom-model fields, and generic NBT JSON, numeric.
|
||||
fn custom_model_data_json(tag: &Tag) -> Value {
|
||||
let mut value = tag_json(tag);
|
||||
let flags: Option<Vec<Value>> = match tag.get("flags") {
|
||||
Some(Tag::List(flags)) => flags
|
||||
.iter()
|
||||
.map(|flag| crate::item_data::component_bool(flag).map(Value::Bool))
|
||||
.collect(),
|
||||
Some(Tag::ByteArray(flags)) => flags
|
||||
.iter()
|
||||
.map(|flag| crate::item_data::component_bool(&Tag::Byte(*flag as i8)).map(Value::Bool))
|
||||
.collect(),
|
||||
Some(Tag::IntArray(flags)) => flags
|
||||
.iter()
|
||||
.map(|flag| crate::item_data::component_bool(&Tag::Int(*flag)).map(Value::Bool))
|
||||
.collect(),
|
||||
Some(Tag::LongArray(flags)) => flags
|
||||
.iter()
|
||||
.map(|flag| crate::item_data::component_bool(&Tag::Long(*flag)).map(Value::Bool))
|
||||
.collect(),
|
||||
_ => None,
|
||||
};
|
||||
if let (Some(flags), Some(object)) = (flags, value.as_object_mut()) {
|
||||
object.insert("flags".into(), Value::Array(flags));
|
||||
}
|
||||
value
|
||||
}
|
||||
/// The resolver's view of a client stack: its item, count and component patch.
|
||||
pub fn stack_view(stack: &crate::items::ItemStack) -> StackView {
|
||||
let mut view = StackView::new(&stack.item, stack.count as i32);
|
||||
@@ -1045,7 +1073,20 @@ pub fn stack_view(stack: &crate::items::ItemStack) -> StackView {
|
||||
let key = k.to_string_lossy();
|
||||
match key.strip_prefix('!') {
|
||||
Some(removed) => view.patch.insert(removed.to_owned(), None),
|
||||
None => view.patch.insert(key, Some(tag_json(v))),
|
||||
None => {
|
||||
// The component codec turns NBT numeric booleans into JSON booleans.
|
||||
// Generic NBT conversion must keep unrelated bytes numeric.
|
||||
let value = if key == "minecraft:enchantment_glint_override" {
|
||||
crate::item_data::component_bool(v)
|
||||
.map(Value::Bool)
|
||||
.unwrap_or_else(|| tag_json(v))
|
||||
} else if key == "minecraft:custom_model_data" {
|
||||
custom_model_data_json(v)
|
||||
} else {
|
||||
tag_json(v)
|
||||
};
|
||||
view.patch.insert(key, Some(value))
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1172,6 +1213,65 @@ pub fn cluster_poses(state: &ItemRenderState, pose: Mat4, amount: u32, seed: i64
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn custom_model_flags_select_their_model_after_stack_nbt_decoding() {
|
||||
let mut actual = Vec::new();
|
||||
for flags in [
|
||||
Tag::List(vec![Tag::Byte(1), Tag::Byte(0)]),
|
||||
Tag::ByteArray(vec![1, 0]),
|
||||
] {
|
||||
let mut stack = crate::items::ItemStack::new("minecraft:diamond", 1);
|
||||
let component = Tag::compound([
|
||||
("flags", flags),
|
||||
("floats", Tag::List(vec![Tag::Float(1.25f32.to_bits())])),
|
||||
("colors", Tag::List(vec![Tag::Int(0x12_34_56)])),
|
||||
("strings", Tag::List(vec![Tag::String("keep".into())])),
|
||||
]);
|
||||
(stack.damage, stack.extra) = crate::item_data::decode_components(Some(
|
||||
&Tag::compound([("minecraft:custom_model_data", component)]),
|
||||
));
|
||||
let view = stack_view(&stack);
|
||||
for index in 0..3 {
|
||||
let mut models = ItemModels::default();
|
||||
models.prototypes.insert(
|
||||
"minecraft:diamond".into(),
|
||||
BTreeMap::from([(
|
||||
"minecraft:item_model".into(),
|
||||
serde_json::json!("minecraft:diamond"),
|
||||
)]),
|
||||
);
|
||||
models.definitions.insert("minecraft:diamond".into(), ClientItem::decode(&serde_json::json!({
|
||||
"model": {"type": "minecraft:condition", "property": "minecraft:custom_model_data", "index": index,
|
||||
"on_true": {"type": "minecraft:model", "model": "horizon:item/yes"},
|
||||
"on_false": {"type": "minecraft:model", "model": "horizon:item/no"}}
|
||||
})).unwrap());
|
||||
let resolved = models.resolve(
|
||||
&view,
|
||||
&Context {
|
||||
display: DisplayContext::Gui,
|
||||
owner: None,
|
||||
level: None,
|
||||
seed: 0,
|
||||
},
|
||||
&horizon_assets::item_model::NoEnv,
|
||||
);
|
||||
assert_eq!(resolved.layers.len(), 1);
|
||||
actual.push(
|
||||
resolved.layers[0].content
|
||||
== LayerContent::Model {
|
||||
model: "horizon:item/yes".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
// Non-boolean fields must retain their codec's numeric/string values.
|
||||
let component = view.patch["minecraft:custom_model_data"].as_ref().unwrap();
|
||||
assert_eq!(component["floats"], serde_json::json!([1.25]));
|
||||
assert_eq!(component["colors"], serde_json::json!([0x12_34_56]));
|
||||
assert_eq!(component["strings"], serde_json::json!(["keep"]));
|
||||
}
|
||||
assert_eq!(actual, [true, false, false, true, false, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posed_item_bounds_include_each_cluster_transform() {
|
||||
let state = ItemRenderState {
|
||||
@@ -1332,12 +1432,85 @@ mod tests {
|
||||
("a", Tag::Int(3)),
|
||||
("b", Tag::List(vec![Tag::String("x".into())])),
|
||||
("c", Tag::Float(1.5f32.to_bits())),
|
||||
("d", Tag::Byte(1)),
|
||||
]);
|
||||
assert_eq!(
|
||||
tag_json(&tag),
|
||||
serde_json::json!({"a": 3, "b": ["x"], "c": 1.5})
|
||||
serde_json::json!({"a": 3, "b": ["x"], "c": 1.5, "d": 1})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn stack_glint_override_reaches_the_resolved_item_layers() {
|
||||
use horizon_assets::item_model::NoEnv;
|
||||
let mut models = ItemModels::default();
|
||||
models.definitions.insert(
|
||||
"horizon:fixture".into(),
|
||||
ClientItem::from_json(
|
||||
r#"{"model":{"type":"minecraft:model","model":"horizon:item/fixture"}}"#,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
for default in [false, true] {
|
||||
models.prototypes.insert(
|
||||
"minecraft:diamond".into(),
|
||||
BTreeMap::from([
|
||||
(
|
||||
"minecraft:item_model".into(),
|
||||
serde_json::json!("horizon:fixture"),
|
||||
),
|
||||
(
|
||||
"minecraft:enchantment_glint_override".into(),
|
||||
Value::Bool(default),
|
||||
),
|
||||
]),
|
||||
);
|
||||
for (tag, flag) in [
|
||||
(Tag::Byte(1), true),
|
||||
(Tag::Byte(0), false),
|
||||
(Tag::Byte(-1), true),
|
||||
(Tag::Short(256), true),
|
||||
(Tag::Int(1), true),
|
||||
(Tag::Long(1 << 40), true),
|
||||
(Tag::Float(0.5_f32.to_bits()), true),
|
||||
(Tag::Float((-0.0_f32).to_bits()), false),
|
||||
(Tag::Double(0.5_f64.to_bits()), true),
|
||||
(Tag::Double((-0.0_f64).to_bits()), false),
|
||||
(Tag::Float(f32::NAN.to_bits()), true),
|
||||
(Tag::Double(f64::INFINITY.to_bits()), true),
|
||||
] {
|
||||
let mut stack = crate::items::ItemStack::new("minecraft:diamond", 1);
|
||||
(stack.damage, stack.extra) = crate::item_data::decode_components(Some(
|
||||
&Tag::compound([("minecraft:enchantment_glint_override", tag)]),
|
||||
));
|
||||
assert_eq!(stack.has_foil(default), flag);
|
||||
let view = stack_view(&stack);
|
||||
assert_eq!(
|
||||
view.patch["minecraft:enchantment_glint_override"],
|
||||
Some(Value::Bool(flag))
|
||||
);
|
||||
let resolved = models.resolve(
|
||||
&view,
|
||||
&Context {
|
||||
display: DisplayContext::Gui,
|
||||
owner: None,
|
||||
level: None,
|
||||
seed: 0,
|
||||
},
|
||||
&NoEnv,
|
||||
);
|
||||
assert_eq!(resolved.layers.len(), 1);
|
||||
assert_eq!(
|
||||
resolved.layers[0].foil,
|
||||
if flag {
|
||||
FoilType::Standard
|
||||
} else {
|
||||
FoilType::None
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_trns_partial_alpha_counts_as_translucent() {
|
||||
// Slime/honey (and ice) are indexed PNGs with a tRNS chunk. Decode must
|
||||
|
||||
@@ -1054,16 +1054,15 @@ pub fn container_lines(
|
||||
let mut lines: Vec<String> = slots
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|(_, s)| {
|
||||
count
|
||||
.replacen("%s", &s.count.to_string(), 1)
|
||||
.replacen("%s", &name(s), 1)
|
||||
})
|
||||
.map(|(_, s)| horizon_protocol::text::format_java(&count, &[s.count.to_string(), name(s)]))
|
||||
.collect();
|
||||
if slots.len() > 5 {
|
||||
let more =
|
||||
translate("item.container.more_items").unwrap_or_else(|| "and %s more...".into());
|
||||
lines.push(more.replacen("%s", &(slots.len() - 5).to_string(), 1));
|
||||
lines.push(horizon_protocol::text::format_java(
|
||||
&more,
|
||||
&[(slots.len() - 5).to_string()],
|
||||
));
|
||||
}
|
||||
lines
|
||||
}
|
||||
@@ -1568,6 +1567,12 @@ mod tests {
|
||||
]
|
||||
);
|
||||
assert!(container_lines(&ItemStack::new("minecraft:chest", 1), &name, &lang).is_empty());
|
||||
// German writes the count after the name: "%2$s× %1$s".
|
||||
let german = |k: &str| match k {
|
||||
"item.container.item_count" => Some("%2$s× %1$s".to_owned()),
|
||||
other => lang(other),
|
||||
};
|
||||
assert_eq!(container_lines(&chest, &name, &german)[0], "stone× 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -489,7 +489,7 @@ fn serve_connection(
|
||||
// ServerHandshakePacketListenerImpl: a server that does not reply
|
||||
// to status requests closes the connection.
|
||||
handshake::STATUS if !shared.enable_status() => Ok(()),
|
||||
handshake::STATUS => serve_status(&mut c, &shared),
|
||||
handshake::STATUS => serve_status(&mut c, &shared, intention.protocol),
|
||||
handshake::TRANSFER if !shared.accepts_transfers() => {
|
||||
login_disconnect(
|
||||
&mut c,
|
||||
@@ -498,7 +498,9 @@ fn serve_connection(
|
||||
Ok(())
|
||||
}
|
||||
handshake::LOGIN | handshake::TRANSFER => {
|
||||
if intention.protocol != PROTOCOL_VERSION {
|
||||
if let Some(version) = older_client(&shared, intention.protocol) {
|
||||
c.serve_older(version);
|
||||
} else if intention.protocol != PROTOCOL_VERSION {
|
||||
let key = if intention.protocol < 754 {
|
||||
"multiplayer.disconnect.outdated_client"
|
||||
} else {
|
||||
@@ -520,7 +522,18 @@ fn serve_connection(
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_status(c: &mut Connection, shared: &Shared) -> Result<(), String> {
|
||||
/// The older release a client's handshake states, when this server lets it join
|
||||
/// (`horizon-accept-older-clients`): 26.2 only, for now.
|
||||
fn older_client(
|
||||
shared: &Shared,
|
||||
protocol: i32,
|
||||
) -> Option<horizon_protocol::adapter::ServerVersion> {
|
||||
use horizon_protocol::adapter::ServerVersion;
|
||||
let version = ServerVersion::from_protocol(protocol)?;
|
||||
(shared.config.accept_older_clients && version == ServerVersion::V26_2).then_some(version)
|
||||
}
|
||||
|
||||
fn serve_status(c: &mut Connection, shared: &Shared, protocol: i32) -> Result<(), String> {
|
||||
let e = |e: horizon_protocol::Error| e.to_string();
|
||||
let mut requested = false;
|
||||
loop {
|
||||
@@ -530,7 +543,11 @@ fn serve_status(c: &mut Connection, shared: &Shared) -> Result<(), String> {
|
||||
status::Serverbound::StatusRequest(_) if requested => return Ok(()),
|
||||
status::Serverbound::StatusRequest(_) => {
|
||||
requested = true;
|
||||
let json = shared.status_json();
|
||||
// A client this server lets join is told its own protocol, so its list shows it joinable.
|
||||
let json = match older_client(shared, protocol) {
|
||||
Some(_) => shared.status_json_for(protocol),
|
||||
None => shared.status_json(),
|
||||
};
|
||||
c.send(
|
||||
&status::Clientbound::StatusResponse(status::StatusResponse { json }).encode(),
|
||||
)
|
||||
@@ -962,6 +979,24 @@ fn login_and_configure(
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
// An older client's packet ids are translated, not yet what is inside them (#10404): the registry data,
|
||||
// tags and play packets would reach it in 26.3's ids and layouts, so it is turned away before them.
|
||||
if let Some(version) = c.older_client()
|
||||
&& !shared.config.older_clients_play
|
||||
{
|
||||
let text = super::OLDER_CLIENTS_UNFINISHED;
|
||||
let reason = horizon_world::nbt::Tag::String(horizon_world::nbt::JavaString::from(text));
|
||||
let _ = send(c, cfg::Clientbound::Disconnect(cfg::Disconnect { reason }));
|
||||
if let Some(log) = &shared.config.log {
|
||||
let line = format!(
|
||||
"Disconnecting {name}: its client is on protocol {} ({text})",
|
||||
version.protocol()
|
||||
);
|
||||
(log.write)("Server thread", "INFO", &line);
|
||||
}
|
||||
lost(shared, &format!("{name} ({})", uuid_string(uuid)), text);
|
||||
return Ok(None);
|
||||
}
|
||||
// Custom component IDs need Horizon's registry check. The server may also
|
||||
// require Horizon clients even when it has no custom components.
|
||||
if brand != "horizon"
|
||||
@@ -1599,6 +1634,11 @@ pub(super) fn frame_body(
|
||||
body: &[u8],
|
||||
out: &mut Vec<u8>,
|
||||
) -> horizon_protocol::Result<()> {
|
||||
// Renumbered first for a client of an older release; one with no such packet gets nothing.
|
||||
let Some(body) = writer.for_client(body)? else {
|
||||
return Ok(());
|
||||
};
|
||||
let body = &body[..];
|
||||
match writer.threshold {
|
||||
Some(t) if body.len() >= t.max(SHARED_FRAME_MIN) => {
|
||||
writer.frame_into(&SharedFrames::get().contents(body, t)?, out);
|
||||
|
||||
@@ -75,6 +75,14 @@ pub struct EntityState {
|
||||
pub horizontal_collision: bool,
|
||||
/// `Entity.needsSync`: send its changes this tick, due or not.
|
||||
pub needs_sync: bool,
|
||||
/// Outside `DistanceManager.inEntityTickingRange`: `ChunkMap.tick` sends
|
||||
/// its changes only if it changed section or needs sync (its lead aside,
|
||||
/// which `Leashable` sends at once).
|
||||
pub idle: bool,
|
||||
/// Where it is, and none of its data: an idle entity no client can pair
|
||||
/// with this tick, whose data the tracker then never reads (game/lan_idle.rs).
|
||||
/// It is never paired.
|
||||
pub bare: bool,
|
||||
/// `Leashable.getLeashHolder`: the entity holding its lead.
|
||||
pub leash: Option<i32>,
|
||||
/// `ServerPlayer.isSpectator`: a player in spectator mode, whom only the
|
||||
@@ -111,6 +119,8 @@ impl EntityState {
|
||||
self.vertical_collision = false;
|
||||
self.horizontal_collision = false;
|
||||
self.needs_sync = false;
|
||||
self.idle = false;
|
||||
self.bare = false;
|
||||
self.leash = None;
|
||||
self.spectator = false;
|
||||
self.watching = false;
|
||||
@@ -410,14 +420,7 @@ impl ServerEntity {
|
||||
self.passengers.clear();
|
||||
self.passengers.extend_from_slice(passengers);
|
||||
}
|
||||
// Leashable.setLeashedTo / dropLeash: sendToTrackingPlayers at once.
|
||||
if e.leash != self.leash {
|
||||
out.push(play::Clientbound::SetEntityLink(SetEntityLink {
|
||||
source: e.id,
|
||||
dest: e.leash.unwrap_or(0),
|
||||
}));
|
||||
self.leash = e.leash;
|
||||
}
|
||||
self.send_leash(e, out);
|
||||
// SynchedEntityData.isDirty: once for every client.
|
||||
let dirty = e.meta != self.meta;
|
||||
let due = self.interval != 0 && self.tick.is_multiple_of(self.interval);
|
||||
@@ -555,6 +558,17 @@ impl ServerEntity {
|
||||
self.send_equipment(e, out);
|
||||
}
|
||||
|
||||
/// Leashable.setLeashedTo / dropLeash: sendToTrackingPlayers at once.
|
||||
fn send_leash(&mut self, e: &EntityState, out: &mut Vec<play::Clientbound>) {
|
||||
if e.leash != self.leash {
|
||||
out.push(play::Clientbound::SetEntityLink(SetEntityLink {
|
||||
source: e.id,
|
||||
dest: e.leash.unwrap_or(0),
|
||||
}));
|
||||
self.leash = e.leash;
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerEntity.handleMinecartPosRot: send every recorded substep in order.
|
||||
/// Only an empty queue uses the fallback endpoint (e.g. a stationary cart
|
||||
/// teleported by a command); this never replaces a curved track's steps.
|
||||
@@ -959,6 +973,11 @@ impl Entities {
|
||||
self.states.is_empty()
|
||||
}
|
||||
|
||||
/// How many passes the level's tracking has run.
|
||||
pub fn ticks(&self) -> u32 {
|
||||
self.tick
|
||||
}
|
||||
|
||||
/// Whether the entity `id` is tracked (it was in the last tick's pass).
|
||||
pub fn tracks(&self, id: i32) -> bool {
|
||||
self.states.contains_key(&id)
|
||||
@@ -1241,7 +1260,14 @@ impl Entities {
|
||||
.iter()
|
||||
.any(|&(after, through)| e.id > after && e.id <= through);
|
||||
if !s.added || synced {
|
||||
s.send_changes(e, riding, &mut self.packets);
|
||||
// ChunkMap.tick: sendChanges for an entity that changed
|
||||
// section, needs sync or is in entity-ticking range. An idle
|
||||
// one's update clock stands still and its changes wait.
|
||||
if e.idle && !self.is_moved[index] && !e.needs_sync {
|
||||
s.send_leash(e, &mut self.packets);
|
||||
} else {
|
||||
s.send_changes(e, riding, &mut self.packets);
|
||||
}
|
||||
}
|
||||
s.added = false;
|
||||
// Nothing to send: no changes, and nobody starts tracking it.
|
||||
@@ -1262,6 +1288,7 @@ impl Entities {
|
||||
}
|
||||
v.out.extend(self.bodies.iter().cloned());
|
||||
} else if f & SEES != 0
|
||||
&& !e.bare
|
||||
&& let Some(kind) = s.kind
|
||||
{
|
||||
let bodies = pairing.get_or_insert_with(|| {
|
||||
@@ -1329,6 +1356,8 @@ mod tests {
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
needs_sync: false,
|
||||
idle: false,
|
||||
bare: false,
|
||||
leash: None,
|
||||
spectator: false,
|
||||
watching: false,
|
||||
@@ -1894,6 +1923,95 @@ mod tests {
|
||||
assert_eq!(c.out[0], c.out[1]);
|
||||
}
|
||||
|
||||
/// ChunkMap.tick runs sendChanges only for an entity in entity-ticking
|
||||
/// range, one that changed section or one that needs sync. An idle
|
||||
/// zombie sends nothing, not even the position every 60 ticks (its
|
||||
/// update clock stands still); a data change waits until it is back in
|
||||
/// range, and then goes out as vanilla sends it. A lead goes out at once
|
||||
/// (Leashable), needsSync and a section change send its changes now.
|
||||
#[test]
|
||||
fn an_idle_entity_sends_only_what_chunk_map_sends() {
|
||||
let mut level = Entities::default();
|
||||
let mut c = Clients::new();
|
||||
let mut e = state(15.5);
|
||||
c.tick(&mut level, &[&e]);
|
||||
assert!(c.trackers.iter().all(|t| t.is_tracking(7)));
|
||||
// Its first update: tick 0's position.
|
||||
c.tick(&mut level, &[&e]);
|
||||
assert_eq!(c.out[0].len(), 1);
|
||||
e.idle = true;
|
||||
for _ in 0..100 {
|
||||
c.tick(&mut level, &[&e]);
|
||||
assert!(c.out.iter().all(Vec::is_empty));
|
||||
}
|
||||
let health = |h: f32| {
|
||||
body(play::Clientbound::SetEntityData(SetEntityData {
|
||||
id: 7,
|
||||
values: vec![(9, serializer::FLOAT, Value::Float(h))],
|
||||
}))
|
||||
};
|
||||
e.meta.push((9, serializer::FLOAT, Value::Float(5.0)));
|
||||
c.tick(&mut level, &[&e]);
|
||||
assert!(c.out.iter().all(Vec::is_empty));
|
||||
// Back in range: the change alone (update tick 1 is not a position one).
|
||||
e.idle = false;
|
||||
c.tick(&mut level, &[&e]);
|
||||
assert_eq!(c.out, [vec![health(5.0)], vec![health(5.0)]]);
|
||||
e.idle = true;
|
||||
e.meta[0].2 = Value::Float(4.0);
|
||||
e.needs_sync = true;
|
||||
c.tick(&mut level, &[&e]);
|
||||
assert_eq!(c.out[0], [health(4.0)]);
|
||||
e.needs_sync = false;
|
||||
e.leash = Some(3);
|
||||
c.tick(&mut level, &[&e]);
|
||||
let link = body(play::Clientbound::SetEntityLink(SetEntityLink {
|
||||
source: 7,
|
||||
dest: 3,
|
||||
}));
|
||||
assert_eq!(c.out[0], [link]);
|
||||
// Into the next section: a move.
|
||||
e.position[0] = 16.5;
|
||||
c.tick(&mut level, &[&e]);
|
||||
let moved = MoveEntityPos(MoveEntity {
|
||||
id: 7,
|
||||
delta: Some(VecDelta::Linear([4096, 0, 0])),
|
||||
rotation: None,
|
||||
on_ground: true,
|
||||
});
|
||||
assert_eq!(c.out[0], [body(play::Clientbound::MoveEntityPos(moved))]);
|
||||
c.tick(&mut level, &[&e]);
|
||||
assert!(c.out.iter().all(Vec::is_empty));
|
||||
}
|
||||
|
||||
/// A bare state carries none of the entity's data, so it is never paired:
|
||||
/// a client that could pair is sent the entity once it is described in full.
|
||||
#[test]
|
||||
fn a_bare_state_is_never_paired() {
|
||||
let mut level = Entities::default();
|
||||
let mut c = Clients::new();
|
||||
c.positions[1] = [500.0, 64.0, 0.0];
|
||||
let mut e = state(0.0);
|
||||
e.meta.push((9, serializer::FLOAT, Value::Float(5.0)));
|
||||
c.tick(&mut level, &[&e]);
|
||||
e.idle = true;
|
||||
let bare = EntityState {
|
||||
meta: Vec::new(),
|
||||
bare: true,
|
||||
..e.clone()
|
||||
};
|
||||
c.positions[1] = [0.0, 64.0, 0.0];
|
||||
c.tick(&mut level, &[&bare]);
|
||||
assert!(!c.trackers[1].is_tracking(7) && c.out[1].is_empty());
|
||||
c.tick(&mut level, &[&e]);
|
||||
assert!(c.trackers[1].is_tracking(7));
|
||||
let data = body(play::Clientbound::SetEntityData(SetEntityData {
|
||||
id: 7,
|
||||
values: e.meta.clone(),
|
||||
}));
|
||||
assert!(c.out[1].contains(&data));
|
||||
}
|
||||
|
||||
/// ChunkMap.addEntity pairs an entity as it joined the level: one the
|
||||
/// game added after the last tick's pass (an item a landing falling
|
||||
/// block drops) is paired from where it joined, not from where its first
|
||||
|
||||
@@ -836,6 +836,13 @@ impl Hub {
|
||||
pub fn here(&self, g: &Guest) -> bool {
|
||||
g.placed && g.dimension == self.config.dimension
|
||||
}
|
||||
/// How many passes the level in place's entity tracking has run (None
|
||||
/// before its first).
|
||||
pub fn tracking_ticks(&self) -> Option<u32> {
|
||||
self.entities
|
||||
.get(&self.config.dimension)
|
||||
.map(Entities::ticks)
|
||||
}
|
||||
/// Whether a guest is sent the level in place: the players placed in it, and
|
||||
/// the held ones, which look at it without being part of it.
|
||||
pub fn watches(&self, g: &Guest) -> bool {
|
||||
@@ -3060,6 +3067,8 @@ impl Hub {
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
needs_sync: false,
|
||||
idle: false,
|
||||
bare: false,
|
||||
minecart_steps: None,
|
||||
leash: None,
|
||||
spectator: false,
|
||||
|
||||
@@ -150,6 +150,12 @@ pub const HORIZON_CLIENTS_ONLY: &str = "horizon-clients-only";
|
||||
/// What another client is told.
|
||||
pub const HORIZON_CLIENTS_ONLY_REASON: &str =
|
||||
"This server is for the Horizon client: playhorizon.dev";
|
||||
/// The server.properties key (server.toml's `server.accept_older_clients`) that lets 26.2 clients join
|
||||
/// (off unless "true").
|
||||
pub const ACCEPT_OLDER_CLIENTS: &str = "horizon-accept-older-clients";
|
||||
/// What a 26.2 client is told while only its packet ids are translated (#10404).
|
||||
pub const OLDER_CLIENTS_UNFINISHED: &str =
|
||||
"This server lets 26.2 join, but Horizon cannot translate 26.2 yet: join with 26.3";
|
||||
|
||||
/// How the server side of the network behaves: an integrated server's LAN
|
||||
/// uses the defaults, a dedicated server takes them from server.properties.
|
||||
@@ -210,6 +216,15 @@ pub struct NetConfig {
|
||||
/// client whose `minecraft:brand` isn't Horizon's is turned away during
|
||||
/// configuration. A brand can be spoofed: a gate, not security.
|
||||
pub horizon_clients_only: bool,
|
||||
/// `horizon-accept-older-clients` (off): a client of 26.2 joins too, its packets renumbered
|
||||
/// (horizon_protocol::adapter::ClientSession). Off, it is turned away as vanilla turns it away.
|
||||
pub accept_older_clients: bool,
|
||||
/// Lets an older client past configuration although only its packet ids are translated yet
|
||||
/// (#10404's later parts translate the registry ids and the packets whose layout changed): for tests
|
||||
/// of the id renumbering, never in server.toml. False, such a client is told
|
||||
/// [`OLDER_CLIENTS_UNFINISHED`] before any registry data, tag or play packet would reach it in 26.3's
|
||||
/// ids and layouts.
|
||||
pub older_clients_play: bool,
|
||||
/// `horizon-lod-distance` (Horizon's own server.properties key, 64): how
|
||||
/// far, in chunks, the server samples far terrain for Horizon clients
|
||||
/// (`lod_net`); 0 serves none.
|
||||
@@ -258,6 +273,8 @@ impl NetConfig {
|
||||
code_of_conducts: Vec::new(),
|
||||
server_links: Vec::new(),
|
||||
horizon_clients_only: false,
|
||||
accept_older_clients: false,
|
||||
older_clients_play: false,
|
||||
lod_distance: crate::lod_net::DEFAULT_DISTANCE,
|
||||
levels: crate::chunk::LevelShapes::VANILLA,
|
||||
owner_profile: None,
|
||||
@@ -481,6 +498,10 @@ impl Shared {
|
||||
/// ServerStatus.CODEC): description, players with up to 12 shuffled
|
||||
/// names, version, favicon and enforcesSecureChat when true.
|
||||
pub fn status_json(&self) -> String {
|
||||
self.status_json_for(PROTOCOL_VERSION)
|
||||
}
|
||||
/// [`Self::status_json`] stating `protocol`: an older client's own, when it may join.
|
||||
pub fn status_json_for(&self, protocol: i32) -> String {
|
||||
use crate::server::json::Json;
|
||||
let motd = self.motd.lock().unwrap().clone();
|
||||
let online: Vec<(u128, String)> = self.online.lock().unwrap().clone();
|
||||
@@ -522,10 +543,7 @@ impl Shared {
|
||||
"version".to_owned(),
|
||||
Json::Object(vec![
|
||||
("name".to_owned(), Json::String(GAME_VERSION.to_owned())),
|
||||
(
|
||||
"protocol".to_owned(),
|
||||
Json::Number(PROTOCOL_VERSION.to_string()),
|
||||
),
|
||||
("protocol".to_owned(), Json::Number(protocol.to_string())),
|
||||
]),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -56,6 +56,7 @@ pub mod landing;
|
||||
pub mod leash;
|
||||
pub mod living;
|
||||
pub mod llama;
|
||||
pub mod model_layers;
|
||||
pub mod monster_brain;
|
||||
pub mod nautilus;
|
||||
pub mod nav;
|
||||
@@ -1316,6 +1317,39 @@ impl Extra {
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the boxes of a set of mobs reach from their positions: the widest half width and the
|
||||
/// tallest height among them. A box query over many mobs (a vehicle's, against every mob of the
|
||||
/// level) tests each mob's position against this first, and works out the box ([`Mob::bounds`],
|
||||
/// which reads the mob's whole size) only for the mobs whose position comes close.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct BoxReach {
|
||||
half_width: f64,
|
||||
height: f64,
|
||||
}
|
||||
impl BoxReach {
|
||||
pub fn of(mobs: &[Mob]) -> Self {
|
||||
mobs.iter().fold(Self::default(), |r, m| {
|
||||
let (width, height, _) = m.dims();
|
||||
Self {
|
||||
half_width: r.half_width.max(width / 2.0),
|
||||
height: r.height.max(height),
|
||||
}
|
||||
})
|
||||
}
|
||||
/// Whether the box of `m`, one of the mobs this was worked out for, can meet `area`: a
|
||||
/// superset, the caller still tests the box.
|
||||
#[inline]
|
||||
pub fn may_meet(self, m: &Mob, area: Aabb) -> bool {
|
||||
let (lo, hi, p) = (area.min_position(), area.max_position(), m.position);
|
||||
p.x - self.half_width <= hi.x
|
||||
&& p.x + self.half_width >= lo.x
|
||||
&& p.z - self.half_width <= hi.z
|
||||
&& p.z + self.half_width >= lo.z
|
||||
&& p.y <= hi.y
|
||||
&& p.y + self.height >= lo.y
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Mob {
|
||||
/// When it joined its list ([`crate::join_seq`]); set by the list, not by the spawn site.
|
||||
@@ -1403,6 +1437,9 @@ pub struct Mob {
|
||||
/// way it was travelling. None for any other direct entity.
|
||||
pub projectile_motion: Option<[f64; 2]>,
|
||||
pub climbing: bool,
|
||||
/// `LivingEntity.onClimbable` as of the last mob tick, for `isPushable`
|
||||
/// checks made outside the mob world (minecarts, boats).
|
||||
pub on_climbable: bool,
|
||||
pub fire_ticks: i32,
|
||||
pub ticks_frozen: i32,
|
||||
pub glowing: bool,
|
||||
@@ -2490,7 +2527,9 @@ impl Mob {
|
||||
}
|
||||
|
||||
/// What other mobs see of this one this tick (`player`: the player's
|
||||
/// UUID, which villagers' reputation is keyed by).
|
||||
/// UUID, which villagers' reputation is keyed by). Inline: the game's
|
||||
/// snapshot of every mob writes each one in its slot, not through a copy.
|
||||
#[inline]
|
||||
pub fn seen(&self, player: Option<[i32; 4]>) -> Seen {
|
||||
let m = self;
|
||||
let villager = m.species() == Species::Villager;
|
||||
@@ -3062,6 +3101,7 @@ impl Mob {
|
||||
attacker_aim: None,
|
||||
projectile_motion: None,
|
||||
climbing: false,
|
||||
on_climbable: false,
|
||||
fire_ticks: 0,
|
||||
ticks_frozen: 0,
|
||||
glowing: false,
|
||||
|
||||
@@ -681,6 +681,7 @@ pub fn tick(
|
||||
super::villager_brain::woken(m, w.game_time);
|
||||
}
|
||||
if m.no_ai {
|
||||
reset_effect_fall_distance(m);
|
||||
// Mob.aiStep still runs without AI (only serverAiStep is skipped),
|
||||
// so undead still burn in daylight and the endermite's life keeps
|
||||
// counting.
|
||||
@@ -1541,6 +1542,14 @@ fn ageable_step(
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.aiStep`: both flight effects discard accumulated fall before
|
||||
/// travel, even when NoAI skips the server AI step.
|
||||
fn reset_effect_fall_distance(m: &mut Mob) {
|
||||
if m.has_effect("minecraft:slow_falling") || m.has_effect("minecraft:levitation") {
|
||||
m.fall_distance = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// LivingEntity.aiStep (and Mob/AgeableMob/Animal additions).
|
||||
fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &mut Vec<MobEvent>) {
|
||||
if m.species() == Species::Parrot {
|
||||
@@ -1602,6 +1611,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
|
||||
f64::from(m.ai.yya),
|
||||
f64::from(m.ai.zza),
|
||||
);
|
||||
reset_effect_fall_distance(m);
|
||||
if super::equine::moved_by_client(m) {
|
||||
// LivingEntity.travelRidden: the rider's client moves it
|
||||
// (handleMoveVehicle), so the server only stops it.
|
||||
@@ -1624,11 +1634,12 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
|
||||
}
|
||||
impl Mob {
|
||||
/// LivingEntity.isPushable (and the push it takes from `Entity.push(Entity)`):
|
||||
/// bats and shulkers are never moved by others.
|
||||
/// bats and shulkers are never moved by others, nor is a mob on a climbable
|
||||
/// block (`onClimbable`, cached by the last tick).
|
||||
pub fn pushable(&self) -> bool {
|
||||
self.alive()
|
||||
&& !matches!(self.species(), Species::Bat | Species::Shulker)
|
||||
&& (!self.kind.flags.climber || !self.climbing)
|
||||
&& !self.on_climbable
|
||||
&& (self.species() != Species::Creaking || super::creaking::can_move(self))
|
||||
&& (self.species() != Species::Warden || !super::warden::digging_or_emerging(self))
|
||||
}
|
||||
@@ -1653,6 +1664,7 @@ fn root_vehicle(w: &MobWorld, id: u32, vehicle: Option<u32>) -> u32 {
|
||||
/// pushed apart from this one (`doPush(e)` = `e.push(this)`); pushes of other
|
||||
/// mobs are `Push` events their tick picks up.
|
||||
fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
m.on_climbable = on_climbable(m, w);
|
||||
if m.species() == Species::Bat {
|
||||
return;
|
||||
}
|
||||
@@ -1660,7 +1672,7 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
// The pusher's own isPushable, checked by `e.push(this)` before moving it.
|
||||
let self_pushable = m.alive()
|
||||
&& m.species() != Species::Shulker
|
||||
&& !on_climbable(m, w)
|
||||
&& !m.on_climbable
|
||||
&& (m.species() != Species::Creaking || super::creaking::can_move(m))
|
||||
&& (m.species() != Species::Warden || !super::warden::digging_or_emerging(m))
|
||||
&& !super::equine::is_vehicle(m);
|
||||
@@ -2127,6 +2139,8 @@ pub fn voice_sounds(m: &Mob) -> [&'static str; 3] {
|
||||
Species::Nautilus | Species::ZombieNautilus => super::nautilus::sounds(m),
|
||||
Species::SulfurCube => super::sulfur_cube::sounds(m),
|
||||
Species::Armadillo => super::armadillo::sounds(m),
|
||||
// HappyGhast.getHurtSound and getDeathSound: the ghastling's by age.
|
||||
Species::HappyGhast => super::happy_ghast::sounds(m),
|
||||
// Panda.getAmbientSound.
|
||||
Species::Panda => {
|
||||
let ambient = if super::panda::is_aggressive(m) {
|
||||
@@ -4969,3 +4983,75 @@ mod air_tests {
|
||||
assert_eq!(air, 300);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod flight_effect_fall_tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn flight_effects_reset_accumulated_fall_distance_even_without_ai() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let ran = crate::mobs::world::with_resource_test_world(
|
||||
&root,
|
||||
&[],
|
||||
true,
|
||||
true,
|
||||
|world, _| {
|
||||
world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(crate::chunk::Chunk::empty(0, 0)));
|
||||
},
|
||||
|world, types| {
|
||||
let mut failures = Vec::new();
|
||||
let mut rows = Vec::new();
|
||||
for effect in [
|
||||
None,
|
||||
Some("minecraft:levitation"),
|
||||
Some("minecraft:slow_falling"),
|
||||
] {
|
||||
for no_ai in [false, true] {
|
||||
for velocity_y in [0.0, 0.2, -0.2] {
|
||||
let kind = Arc::new(types.types["minecraft:cow"].clone());
|
||||
let mut mob = Mob::new(1, kind, Vec3::new(2.5, 64.0, 2.5), 0.0);
|
||||
mob.no_ai = no_ai;
|
||||
mob.no_gravity = true;
|
||||
mob.persistent = true;
|
||||
mob.fall_distance = 12.0;
|
||||
mob.velocity.y = velocity_y;
|
||||
if let Some(effect) = effect {
|
||||
mob.add_effect(effect, 100, 0);
|
||||
}
|
||||
let mut random = LegacyRandom::new(0);
|
||||
assert!(tick(&mut mob, world, &mut random, &mut Vec::new()));
|
||||
// The pinned Java aiStep resets before travel. A
|
||||
// downward move then adds 0.2F of NEW fall distance;
|
||||
// NoAI skips movement but must still erase the old fall.
|
||||
let expected = match (effect.is_some(), no_ai, velocity_y < 0.0) {
|
||||
(false, false, true) => 12.200000002980232,
|
||||
(true, false, true) => 0.20000000298023224,
|
||||
(false, _, _) => 12.0,
|
||||
(true, _, _) => 0.0,
|
||||
};
|
||||
let name = effect.unwrap_or("none").trim_start_matches("minecraft:");
|
||||
rows.push(format!(
|
||||
"{name} {no_ai} {:016x} {:016x}",
|
||||
velocity_y.to_bits(),
|
||||
mob.fall_distance.to_bits()
|
||||
));
|
||||
if mob.fall_distance != expected {
|
||||
failures.push((effect, no_ai, velocity_y, mob.fall_distance));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(path) = std::env::var_os("HORIZON_MOB_FALL_RESET_OUTPUT") {
|
||||
std::fs::write(path, rows.join("\n") + "\n").unwrap();
|
||||
}
|
||||
assert!(failures.is_empty(), "fall distance differs: {failures:?}");
|
||||
},
|
||||
);
|
||||
assert!(ran, "prepared resources must run the integration fixture");
|
||||
}
|
||||
}
|
||||
@@ -285,12 +285,12 @@ pub fn controlled(m: &Mob) -> bool {
|
||||
_ => m.extra.saddled,
|
||||
}
|
||||
}
|
||||
/// `AbstractHorse`s (camels too, not llamas, which no player steers): the
|
||||
/// player controlling one moves it on its own client
|
||||
/// Saddled equines (not llamas), pigs and striders: the controlling player
|
||||
/// moves the mount on their own client
|
||||
/// (`Entity.isLocalInstanceAuthoritative`, `LivingEntity.travelRidden`) and
|
||||
/// sends the moves (`ServerboundMoveVehiclePacket`).
|
||||
pub fn moved_by_rider(s: Species) -> bool {
|
||||
is_equine(s) && !super::llama::is_llama(s)
|
||||
matches!(s, Species::Pig | Species::Strider) || (is_equine(s) && !super::llama::is_llama(s))
|
||||
}
|
||||
/// A server's mount that its rider's client moves: `travelRidden` without
|
||||
/// `canSimulateMovement` turns it (`tickRidden`) and stops it there.
|
||||
@@ -892,10 +892,20 @@ pub fn client_tick(m: &mut Mob) {
|
||||
m.anim.vo = m.anim.v;
|
||||
animate(m);
|
||||
}
|
||||
/// How long a client counts a rear the server's flags say is on. A client
|
||||
/// never runs `standCounter` (`standIfPossible` only sets it on the server);
|
||||
/// `isStanding` is just the synched flag. The mount a client drives does run
|
||||
/// `stand_tick`, so a counter of 1 would end the rear a tick after the flag
|
||||
/// arrived, long before the jump lands. The next flags without the bit end it.
|
||||
const CLIENT_STANDING: i32 = 1 << 20;
|
||||
/// `AbstractHorse.DATA_ID_FLAGS` on a client: eating 16 and standing 32.
|
||||
pub fn set_flags(m: &mut Mob, flags: u8) {
|
||||
m.extra.flag = flags & 16 != 0;
|
||||
m.extra.counter = i32::from(flags & 32 != 0);
|
||||
if flags & 32 == 0 {
|
||||
m.extra.counter = 0;
|
||||
} else if m.extra.counter == 0 {
|
||||
m.extra.counter = CLIENT_STANDING;
|
||||
}
|
||||
}
|
||||
/// Whether `ai_step` above runs this species' grazing and rearing, so that
|
||||
/// the server's flags 16 and 32 mean eating and standing.
|
||||
@@ -1117,6 +1127,25 @@ mod tests {
|
||||
assert!((60..=140).contains(&n), "{n} of 300 hits reared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_synched_rear_lasts_until_the_flag_goes() {
|
||||
// The mount a client drives ticks `stand_tick` itself; the synched
|
||||
// standing flag, not that counter, decides when the rear is over.
|
||||
let mut m = equine(1, Species::Donkey, "minecraft:donkey");
|
||||
set_flags(&mut m, 32);
|
||||
for _ in 0..25 {
|
||||
stand_tick(&mut m);
|
||||
animate(&mut m);
|
||||
}
|
||||
assert!(m.extra.counter > 0, "still standing");
|
||||
assert_eq!(m.anim.v[STAND], 1.0);
|
||||
set_flags(&mut m, 4);
|
||||
assert_eq!(m.extra.counter, 0);
|
||||
stand_tick(&mut m);
|
||||
animate(&mut m);
|
||||
assert!(m.anim.v[STAND] < 1.0, "falls once the flag is gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rear_ends_after_twenty_ticks() {
|
||||
// AbstractHorse.tick: standCounter runs down, then clearStanding.
|
||||
|
||||
@@ -20,8 +20,43 @@ use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
pub(super) fn golem_prey(s: &super::Seen) -> bool {
|
||||
s.hostile && s.species != Species::Creeper
|
||||
}
|
||||
/// `IronGolem.registerGoals`, without `ResetUniversalAngerTargetGoal` (that
|
||||
/// goal is not ported).
|
||||
/// `ResetUniversalAngerTargetGoal` with `alertOthersOfSameType` false (the
|
||||
/// iron golem's): with the `universal_anger` rule on, a golem whose latest
|
||||
/// attacker is a player, hit after the one it last handled, forgets its
|
||||
/// target and starts its anger timer over.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ResetUniversalAnger {
|
||||
/// `lastHurtByPlayerTimestamp`.
|
||||
timestamp: i32,
|
||||
}
|
||||
impl Goal for ResetUniversalAnger {
|
||||
goal_clone!();
|
||||
fn name(&self) -> &'static str {
|
||||
"ResetUniversalAngerTargetGoal"
|
||||
}
|
||||
fn flags(&self) -> u8 {
|
||||
0
|
||||
}
|
||||
/// The rule and `wasHurtByPlayer`.
|
||||
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
cx.w.universal_anger
|
||||
&& m.ai.last_hurt_by == Some(Who::Player)
|
||||
&& m.ai.last_hurt_by_time > self.timestamp
|
||||
}
|
||||
/// `NeutralMob.forgetCurrentTargetAndRefreshUniversalAnger`:
|
||||
/// `stopBeingAngry`, then `startPersistentAngerTimer`.
|
||||
fn start(&mut self, m: &mut Mob, _: &mut Cx) {
|
||||
self.timestamp = m.ai.last_hurt_by_time;
|
||||
// setLastHurtByMob(null) also stamps the time.
|
||||
m.ai.last_hurt_by = None;
|
||||
m.ai.last_hurt_by_time = m.age as i32;
|
||||
m.ai.target = None;
|
||||
m.extra.angry_at = None;
|
||||
// `TimeUtil.rangeOfSeconds(20, 39)`.
|
||||
m.extra.anger = 400 + m.random.next_int_bound(381).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
/// `IronGolem.registerGoals`.
|
||||
pub fn register_iron(m: &mut Mob) {
|
||||
let g = &mut m.ai.goals;
|
||||
g.add(1, MeleeAttack::new(1.0, true));
|
||||
@@ -67,6 +102,7 @@ pub fn register_iron(m: &mut Mob) {
|
||||
3,
|
||||
NearestTarget::new(Prey::Filter(golem_prey), false).interval(5),
|
||||
);
|
||||
m.ai.targets.add(4, ResetUniversalAnger::default());
|
||||
}
|
||||
/// `SectionPos.of`: the section a position is in.
|
||||
fn section_of(p: Vec3) -> [i32; 3] {
|
||||
@@ -634,6 +670,46 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// `ResetUniversalAngerTargetGoal`: with the rule on, a golem hurt by a
|
||||
/// player forgets its target once and not again until hurt again; with
|
||||
/// the rule off it does nothing.
|
||||
#[test]
|
||||
fn a_golem_hurt_by_a_player_forgets_its_target_once_under_universal_anger() {
|
||||
for rule in [true, false] {
|
||||
with_test_world(&[], |w, _| {
|
||||
let mut w = w.clone();
|
||||
w.universal_anger = rule;
|
||||
let mut golem = mob(1, Species::IronGolem, Vec3::new(0.5, 64.0, 0.5));
|
||||
golem.age = 50;
|
||||
golem.ai.target = Some(Who::Mob(2));
|
||||
golem.extra.anger = 5;
|
||||
golem.ai.last_hurt_by = Some(Who::Player);
|
||||
golem.ai.last_hurt_by_time = 10;
|
||||
let mut goal = ResetUniversalAnger::default();
|
||||
let mut events = Vec::new();
|
||||
let mut lr = horizon_core::random::LegacyRandom::new(0);
|
||||
let mut cx = Cx {
|
||||
w: &w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
};
|
||||
assert_eq!(goal.can_use(&mut golem, &mut cx), rule);
|
||||
if !rule {
|
||||
return;
|
||||
}
|
||||
goal.start(&mut golem, &mut cx);
|
||||
assert_eq!(golem.ai.target, None);
|
||||
assert_eq!(golem.ai.last_hurt_by, None);
|
||||
assert!((400..=780).contains(&golem.extra.anger));
|
||||
assert!(!goal.can_use(&mut golem, &mut cx), "not again");
|
||||
// Hurt again: a newer hit by the player.
|
||||
golem.ai.last_hurt_by = Some(Who::Player);
|
||||
golem.ai.last_hurt_by_time = 60;
|
||||
assert!(goal.can_use(&mut golem, &mut cx));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `IronGolem.canAttack` refuses creepers, and `HurtByTargetGoal.canUse`
|
||||
/// asks it: the creeper that hurt the golem is never fought back, while
|
||||
/// a zombie that hurts it is.
|
||||
|
||||
@@ -327,6 +327,20 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Leashable.tickLeash: a holder that cannot interact with the level (dead
|
||||
// or spectating, `Entity.canInteractWithLevel`) drops the lead.
|
||||
if holder == Holder::Player
|
||||
&& let Some(p) = w.player.as_ref()
|
||||
&& m.extra.leash_player.is_none_or(|u| p.uuid == u)
|
||||
&& (p.spectator || !p.alive)
|
||||
{
|
||||
events.push(MobEvent::LeashBreak {
|
||||
mob: m.id,
|
||||
drop: true,
|
||||
sound: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
let state = holder_state(w, holder).filter(|_| {
|
||||
holder != Holder::Player
|
||||
|| m.extra
|
||||
|
||||
@@ -1382,11 +1382,6 @@ impl Mob {
|
||||
{
|
||||
y += (0.05 * f64::from(amp + 1) - movement.y) * 0.2;
|
||||
} else {
|
||||
// LivingEntity.travelInAir: a Slow Falling mob on its way down lands without
|
||||
// having fallen.
|
||||
if self.velocity.y <= 0.0 && self.has_effect("minecraft:slow_falling") {
|
||||
self.fall_distance = 0.0;
|
||||
}
|
||||
y -= self.effective_gravity();
|
||||
}
|
||||
// LivingEntity.shouldDiscardFriction (a breeze's, a frog's or a goat's
|
||||
|
||||
@@ -0,0 +1,954 @@
|
||||
//! Entity model layers built in code, as vanilla's
|
||||
//! `net.minecraft.client.model.geom.builders` build them: a mod's
|
||||
//! `createBodyLayer()` (a [`MeshDefinition`] of named parts, each a
|
||||
//! [`CubeListBuilder`]'s boxes at a [`PartPose`], made a [`LayerDefinition`]
|
||||
//! with its texture's size) bakes into a [`ModelPart`] tree
|
||||
//! (`LayerDefinition.bakeRoot`). A mod turns its parts each frame, as
|
||||
//! `setupAnim` does, and draws it with `native_draw_model_part`
|
||||
//! (`ModelPart.render`). Mods register their layers by id
|
||||
//! (`native_layer_definitions`, NeoForge's
|
||||
//! `EntityRenderersEvent.RegisterLayerDefinitions`), and `native_bake_layer`
|
||||
//! bakes one of them or a vanilla layer (`EntityModelSet.bakeLayer`).
|
||||
|
||||
use super::Part;
|
||||
use horizon_core::geometry::Direction;
|
||||
use std::{collections::HashMap, sync::OnceLock};
|
||||
|
||||
/// `CubeDeformation`: how far a box grows past its size on each side, in
|
||||
/// texels, with its texture unchanged: a hat or an armour layer over a head.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct CubeDeformation {
|
||||
pub grow_x: f32,
|
||||
pub grow_y: f32,
|
||||
pub grow_z: f32,
|
||||
}
|
||||
|
||||
impl CubeDeformation {
|
||||
pub const NONE: Self = Self::xyz(0.0, 0.0, 0.0);
|
||||
|
||||
/// The same growth on every axis (`new CubeDeformation(grow)`).
|
||||
pub const fn new(grow: f32) -> Self {
|
||||
Self::xyz(grow, grow, grow)
|
||||
}
|
||||
|
||||
pub const fn xyz(grow_x: f32, grow_y: f32, grow_z: f32) -> Self {
|
||||
Self {
|
||||
grow_x,
|
||||
grow_y,
|
||||
grow_z,
|
||||
}
|
||||
}
|
||||
|
||||
/// This growth and `by` more on every axis (`extend`).
|
||||
pub fn extend(self, by: f32) -> Self {
|
||||
self.extend_xyz(by, by, by)
|
||||
}
|
||||
|
||||
pub fn extend_xyz(self, x: f32, y: f32, z: f32) -> Self {
|
||||
Self::xyz(self.grow_x + x, self.grow_y + y, self.grow_z + z)
|
||||
}
|
||||
}
|
||||
|
||||
/// `PartPose`: where a part sits in its parent, in texels from the parent's
|
||||
/// pivot (+y down, as a model's frame is), its rotation in radians (about z,
|
||||
/// then y, then x, as `ModelPart.translateAndRotate` turns it) and its scale.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PartPose {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub z: f32,
|
||||
pub x_rot: f32,
|
||||
pub y_rot: f32,
|
||||
pub z_rot: f32,
|
||||
pub x_scale: f32,
|
||||
pub y_scale: f32,
|
||||
pub z_scale: f32,
|
||||
}
|
||||
|
||||
impl Default for PartPose {
|
||||
fn default() -> Self {
|
||||
Self::ZERO
|
||||
}
|
||||
}
|
||||
|
||||
impl PartPose {
|
||||
pub const ZERO: Self = Self::offset_and_rotation(0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
|
||||
pub const fn offset(x: f32, y: f32, z: f32) -> Self {
|
||||
Self::offset_and_rotation(x, y, z, 0.0, 0.0, 0.0)
|
||||
}
|
||||
|
||||
pub const fn rotation(x_rot: f32, y_rot: f32, z_rot: f32) -> Self {
|
||||
Self::offset_and_rotation(0.0, 0.0, 0.0, x_rot, y_rot, z_rot)
|
||||
}
|
||||
|
||||
pub const fn offset_and_rotation(
|
||||
x: f32,
|
||||
y: f32,
|
||||
z: f32,
|
||||
x_rot: f32,
|
||||
y_rot: f32,
|
||||
z_rot: f32,
|
||||
) -> Self {
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
x_rot,
|
||||
y_rot,
|
||||
z_rot,
|
||||
x_scale: 1.0,
|
||||
y_scale: 1.0,
|
||||
z_scale: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// This pose moved by `x`, `y` and `z` texels (`translated`).
|
||||
pub fn translated(self, x: f32, y: f32, z: f32) -> Self {
|
||||
Self {
|
||||
x: self.x + x,
|
||||
y: self.y + y,
|
||||
z: self.z + z,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// This pose at `scale` on every axis (`withScale`).
|
||||
pub fn with_scale(self, scale: f32) -> Self {
|
||||
Self {
|
||||
x_scale: scale,
|
||||
y_scale: scale,
|
||||
z_scale: scale,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One box of a part (`CubeDefinition`), as a [`CubeListBuilder`] adds it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CubeDefinition {
|
||||
/// Its corner at the least x, y and z, in texels from the part's pivot.
|
||||
pub origin: [f32; 3],
|
||||
/// Its size in texels, which is also the size of its faces on the texture.
|
||||
pub dimensions: [f32; 3],
|
||||
pub grow: CubeDeformation,
|
||||
/// Drawn with its texture flipped left to right, as a left limb draws a
|
||||
/// right one's.
|
||||
pub mirror: bool,
|
||||
/// The top left corner of its faces on the texture, in texels (`texOffs`).
|
||||
pub tex_coord: [i32; 2],
|
||||
/// How many of the texture's texels one of its own texels takes, 1 for most.
|
||||
pub tex_scale: [f32; 2],
|
||||
/// The faces it has, in [`Direction::ALL`]'s order; all six for most.
|
||||
pub visible_faces: [bool; 6],
|
||||
}
|
||||
|
||||
/// `CubeListBuilder`: the boxes of one part, each placed on the texture from
|
||||
/// the last `tex_offs` and mirrored after `mirror`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct CubeListBuilder {
|
||||
cubes: Vec<CubeDefinition>,
|
||||
tex: [i32; 2],
|
||||
mirror: bool,
|
||||
}
|
||||
|
||||
impl CubeListBuilder {
|
||||
pub fn create() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Where the next boxes' faces start on the texture, in texels (`texOffs`).
|
||||
pub fn tex_offs(mut self, u: i32, v: i32) -> Self {
|
||||
self.tex = [u, v];
|
||||
self
|
||||
}
|
||||
|
||||
/// The next boxes are mirrored (`mirror()`).
|
||||
pub fn mirror(self) -> Self {
|
||||
self.set_mirror(true)
|
||||
}
|
||||
|
||||
/// Whether the next boxes are mirrored (`mirror(boolean)`).
|
||||
pub fn set_mirror(mut self, mirror: bool) -> Self {
|
||||
self.mirror = mirror;
|
||||
self
|
||||
}
|
||||
|
||||
/// A box at `origin` of `size` texels (`addBox(x, y, z, w, h, d)`).
|
||||
pub fn add_box(self, origin: [f32; 3], size: [f32; 3]) -> Self {
|
||||
self.add_box_with(origin, size, CubeDeformation::NONE)
|
||||
}
|
||||
|
||||
/// A box grown by `grow` (`addBox(x, y, z, w, h, d, deformation)`).
|
||||
pub fn add_box_with(self, origin: [f32; 3], size: [f32; 3], grow: CubeDeformation) -> Self {
|
||||
self.add_box_tex_scaled(origin, size, grow, [1.0, 1.0])
|
||||
}
|
||||
|
||||
/// A box mirrored or not by `mirror`, whatever `mirror()` said
|
||||
/// (`addBox(x, y, z, w, h, d, mirror)`).
|
||||
pub fn add_box_mirrored(mut self, origin: [f32; 3], size: [f32; 3], mirror: bool) -> Self {
|
||||
let tex = self.tex;
|
||||
self.cubes
|
||||
.push(cube(origin, size, CubeDeformation::NONE, mirror, tex));
|
||||
self
|
||||
}
|
||||
|
||||
/// A box with only `faces` (`addBox(x, y, z, w, h, d, visibleFaces)`).
|
||||
pub fn add_box_faces(mut self, origin: [f32; 3], size: [f32; 3], faces: &[Direction]) -> Self {
|
||||
let mut cube = cube(origin, size, CubeDeformation::NONE, self.mirror, self.tex);
|
||||
cube.visible_faces = Direction::ALL.map(|d| faces.contains(&d));
|
||||
self.cubes.push(cube);
|
||||
self
|
||||
}
|
||||
|
||||
/// A box whose texels take `tex_scale` of the texture's
|
||||
/// (`addBox(x, y, z, w, h, d, deformation, texScaleU, texScaleV)`).
|
||||
pub fn add_box_tex_scaled(
|
||||
mut self,
|
||||
origin: [f32; 3],
|
||||
size: [f32; 3],
|
||||
grow: CubeDeformation,
|
||||
tex_scale: [f32; 2],
|
||||
) -> Self {
|
||||
let mut cube = cube(origin, size, grow, self.mirror, self.tex);
|
||||
cube.tex_scale = tex_scale;
|
||||
self.cubes.push(cube);
|
||||
self
|
||||
}
|
||||
|
||||
/// A box with its own place on the texture, leaving `tex_offs` as it is
|
||||
/// (`addBox(comment, x, y, z, w, h, d, deformation, texU, texV)`).
|
||||
pub fn add_box_at(
|
||||
mut self,
|
||||
origin: [f32; 3],
|
||||
size: [f32; 3],
|
||||
grow: CubeDeformation,
|
||||
tex: [i32; 2],
|
||||
) -> Self {
|
||||
self.cubes.push(cube(origin, size, grow, self.mirror, tex));
|
||||
self
|
||||
}
|
||||
|
||||
/// The boxes added so far (`getCubes`).
|
||||
pub fn cubes(&self) -> &[CubeDefinition] {
|
||||
&self.cubes
|
||||
}
|
||||
}
|
||||
|
||||
fn cube(
|
||||
origin: [f32; 3],
|
||||
dimensions: [f32; 3],
|
||||
grow: CubeDeformation,
|
||||
mirror: bool,
|
||||
tex_coord: [i32; 2],
|
||||
) -> CubeDefinition {
|
||||
CubeDefinition {
|
||||
origin,
|
||||
dimensions,
|
||||
grow,
|
||||
mirror,
|
||||
tex_coord,
|
||||
tex_scale: [1.0, 1.0],
|
||||
visible_faces: [true; 6],
|
||||
}
|
||||
}
|
||||
|
||||
impl CubeDefinition {
|
||||
/// `ModelPart.Cube`: the box's faces on a `texture` of that many texels,
|
||||
/// in vanilla's order (down, up, west, north, east, south), each face's
|
||||
/// corners and texture net as `Cube`'s constructor makes them.
|
||||
fn bake_into(&self, texture: [f32; 2], out: &mut Vec<ModelPolygon>) {
|
||||
let [w, h, d] = self.dimensions;
|
||||
let [mut x0, mut y0, mut z0] = self.origin;
|
||||
let (mut x1, mut y1, mut z1) = (x0 + w, y0 + h, z0 + d);
|
||||
let g = self.grow;
|
||||
x0 -= g.grow_x;
|
||||
y0 -= g.grow_y;
|
||||
z0 -= g.grow_z;
|
||||
x1 += g.grow_x;
|
||||
y1 += g.grow_y;
|
||||
z1 += g.grow_z;
|
||||
if self.mirror {
|
||||
std::mem::swap(&mut x0, &mut x1);
|
||||
}
|
||||
// The constructor's eight vertices, v7 v v1 v2 v3 v4 v5 v6.
|
||||
let corner = [
|
||||
[x0, y0, z0],
|
||||
[x1, y0, z0],
|
||||
[x1, y1, z0],
|
||||
[x0, y1, z0],
|
||||
[x0, y0, z1],
|
||||
[x1, y0, z1],
|
||||
[x1, y1, z1],
|
||||
[x0, y1, z1],
|
||||
];
|
||||
let tex_w = texture[0] * self.tex_scale[0];
|
||||
let tex_h = texture[1] * self.tex_scale[1];
|
||||
let [u, v] = self.tex_coord.map(|t| t as f32);
|
||||
let (u1, u2, u3) = (u + d, u + d + w, u + d + w + w);
|
||||
let (u4, u5) = (u + d + w + d, u + d + w + d + w);
|
||||
let (v1, v2) = (v + d, v + d + h);
|
||||
let faces = [
|
||||
(Direction::Down, [5, 4, 0, 1], [u1, v, u2, v1]),
|
||||
(Direction::Up, [2, 3, 7, 6], [u2, v1, u3, v]),
|
||||
(Direction::West, [0, 4, 7, 3], [u, v1, u1, v2]),
|
||||
(Direction::North, [1, 0, 3, 2], [u1, v1, u2, v2]),
|
||||
(Direction::East, [5, 1, 2, 6], [u2, v1, u4, v2]),
|
||||
(Direction::South, [4, 5, 6, 7], [u4, v1, u5, v2]),
|
||||
];
|
||||
for (direction, corners, [ua, va, ub, vb]) in faces {
|
||||
let shown = Direction::ALL
|
||||
.iter()
|
||||
.position(|d| *d == direction)
|
||||
.is_some_and(|i| self.visible_faces[i]);
|
||||
if !shown {
|
||||
continue;
|
||||
}
|
||||
// `Polygon`'s remap: right top, left top, left bottom, right bottom.
|
||||
let uv = [(ub, va), (ua, va), (ua, vb), (ub, vb)];
|
||||
let mut vertices = std::array::from_fn(|k| ModelVertex {
|
||||
pos: corner[corners[k]],
|
||||
u: uv[k].0 / tex_w,
|
||||
v: uv[k].1 / tex_h,
|
||||
});
|
||||
let [nx, ny, nz] = direction.normal();
|
||||
let mut normal = [nx as f32, ny as f32, nz as f32];
|
||||
if self.mirror {
|
||||
vertices.reverse();
|
||||
normal[0] = -normal[0];
|
||||
}
|
||||
out.push(ModelPolygon { vertices, normal });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A part of a [`MeshDefinition`], as `add_or_replace_child` gives it
|
||||
/// (`PartDefinition`): a handle into that mesh only.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct PartId(usize);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct PartNode {
|
||||
cubes: Vec<CubeDefinition>,
|
||||
pose: PartPose,
|
||||
children: Vec<(String, usize)>,
|
||||
}
|
||||
|
||||
/// `MeshDefinition`: a model's parts, from a root part with no boxes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MeshDefinition {
|
||||
parts: Vec<PartNode>,
|
||||
}
|
||||
|
||||
impl Default for MeshDefinition {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MeshDefinition {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
parts: vec![PartNode {
|
||||
cubes: Vec::new(),
|
||||
pose: PartPose::ZERO,
|
||||
children: Vec::new(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// The root part (`getRoot`).
|
||||
pub fn root(&self) -> PartId {
|
||||
PartId(0)
|
||||
}
|
||||
|
||||
/// Adds part `name` to `parent`, with `cubes` at `pose`
|
||||
/// (`PartDefinition.addOrReplaceChild`). A part of that name already
|
||||
/// there is replaced and its children kept.
|
||||
pub fn add_or_replace_child(
|
||||
&mut self,
|
||||
parent: PartId,
|
||||
name: &str,
|
||||
cubes: CubeListBuilder,
|
||||
pose: PartPose,
|
||||
) -> PartId {
|
||||
let node = PartNode {
|
||||
cubes: cubes.cubes,
|
||||
pose,
|
||||
children: Vec::new(),
|
||||
};
|
||||
if let Some(at) = self.child(parent, name) {
|
||||
let old = std::mem::replace(&mut self.parts[at.0], node);
|
||||
self.parts[at.0].children = old.children;
|
||||
return at;
|
||||
}
|
||||
self.parts.push(node);
|
||||
let at = self.parts.len() - 1;
|
||||
self.parts[parent.0].children.push((name.to_owned(), at));
|
||||
PartId(at)
|
||||
}
|
||||
|
||||
/// `parent`'s part `name` (`getChild`).
|
||||
pub fn child(&self, parent: PartId, name: &str) -> Option<PartId> {
|
||||
self.parts[parent.0]
|
||||
.children
|
||||
.iter()
|
||||
.find(|(n, _)| n == name)
|
||||
.map(|&(_, at)| PartId(at))
|
||||
}
|
||||
|
||||
fn bake(&self, at: usize, texture: [f32; 2]) -> ModelPart {
|
||||
let node = &self.parts[at];
|
||||
let mut polygons = Vec::new();
|
||||
for cube in &node.cubes {
|
||||
cube.bake_into(texture, &mut polygons);
|
||||
}
|
||||
let children = node
|
||||
.children
|
||||
.iter()
|
||||
.map(|(name, child)| (name.clone(), self.bake(*child, texture)))
|
||||
.collect();
|
||||
ModelPart::new(polygons, children, node.pose)
|
||||
}
|
||||
}
|
||||
|
||||
/// `LayerDefinition`: a mesh and the size of the texture its boxes are on.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LayerDefinition {
|
||||
pub mesh: MeshDefinition,
|
||||
pub texture_width: u32,
|
||||
pub texture_height: u32,
|
||||
}
|
||||
|
||||
impl LayerDefinition {
|
||||
/// `LayerDefinition.create(mesh, 64, 64)`.
|
||||
pub fn create(mesh: MeshDefinition, texture_width: u32, texture_height: u32) -> Self {
|
||||
Self {
|
||||
mesh,
|
||||
texture_width,
|
||||
texture_height,
|
||||
}
|
||||
}
|
||||
|
||||
/// The part tree, each part at its pose (`bakeRoot`).
|
||||
pub fn bake_root(&self) -> ModelPart {
|
||||
let texture = [self.texture_width as f32, self.texture_height as f32];
|
||||
self.mesh.bake(0, texture)
|
||||
}
|
||||
}
|
||||
|
||||
/// A corner of a baked face (`ModelPart.Vertex`): in texels in its part's
|
||||
/// frame, and where it falls on the texture, 0 to 1 across it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ModelVertex {
|
||||
pub pos: [f32; 3],
|
||||
pub u: f32,
|
||||
pub v: f32,
|
||||
}
|
||||
|
||||
/// A baked face (`ModelPart.Polygon`), with the normal it faces in its
|
||||
/// part's frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ModelPolygon {
|
||||
pub vertices: [ModelVertex; 4],
|
||||
pub normal: [f32; 3],
|
||||
}
|
||||
|
||||
/// `ModelPart`: a baked part, its children by name, and the pose a mod sets
|
||||
/// each frame before drawing it (`setupAnim`), from the pose it was baked
|
||||
/// with (`reset_pose`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ModelPart {
|
||||
/// Where it sits in its parent, in texels.
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub z: f32,
|
||||
/// Its rotation in radians, applied about z, then y, then x.
|
||||
pub x_rot: f32,
|
||||
pub y_rot: f32,
|
||||
pub z_rot: f32,
|
||||
pub x_scale: f32,
|
||||
pub y_scale: f32,
|
||||
pub z_scale: f32,
|
||||
/// False draws neither it nor its children.
|
||||
pub visible: bool,
|
||||
/// True draws its children but not its own faces (`skipDraw`).
|
||||
pub skip_draw: bool,
|
||||
/// Its boxes' faces.
|
||||
pub polygons: Vec<ModelPolygon>,
|
||||
children: Vec<(String, ModelPart)>,
|
||||
initial_pose: PartPose,
|
||||
}
|
||||
|
||||
impl ModelPart {
|
||||
/// A part with `polygons` and `children` at `pose`, which it is reset to.
|
||||
pub fn new(
|
||||
polygons: Vec<ModelPolygon>,
|
||||
children: Vec<(String, ModelPart)>,
|
||||
pose: PartPose,
|
||||
) -> Self {
|
||||
let mut part = Self {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0.0,
|
||||
x_rot: 0.0,
|
||||
y_rot: 0.0,
|
||||
z_rot: 0.0,
|
||||
x_scale: 1.0,
|
||||
y_scale: 1.0,
|
||||
z_scale: 1.0,
|
||||
visible: true,
|
||||
skip_draw: false,
|
||||
polygons,
|
||||
children,
|
||||
initial_pose: pose,
|
||||
};
|
||||
part.load_pose(pose);
|
||||
part
|
||||
}
|
||||
|
||||
/// A vanilla layer's part tree from `entity-models.json`.
|
||||
pub fn from_part(part: &Part) -> Self {
|
||||
let p = part.pose;
|
||||
let pose = PartPose {
|
||||
x: p[0],
|
||||
y: p[1],
|
||||
z: p[2],
|
||||
x_rot: p[3],
|
||||
y_rot: p[4],
|
||||
z_rot: p[5],
|
||||
x_scale: p[6],
|
||||
y_scale: p[7],
|
||||
z_scale: p[8],
|
||||
};
|
||||
let polygons = part
|
||||
.polygons
|
||||
.iter()
|
||||
.map(|poly| ModelPolygon {
|
||||
vertices: poly.vertices.map(|v| ModelVertex {
|
||||
pos: [v[0], v[1], v[2]],
|
||||
u: v[3],
|
||||
v: v[4],
|
||||
}),
|
||||
normal: poly.normal,
|
||||
})
|
||||
.collect();
|
||||
let children = part
|
||||
.children
|
||||
.iter()
|
||||
.map(|c| (c.name.clone(), Self::from_part(c)))
|
||||
.collect();
|
||||
Self::new(polygons, children, pose)
|
||||
}
|
||||
|
||||
/// Its part `name` (`getChild`).
|
||||
pub fn get_child(&self, name: &str) -> Option<&ModelPart> {
|
||||
self.children
|
||||
.iter()
|
||||
.find(|(n, _)| n == name)
|
||||
.map(|(_, c)| c)
|
||||
}
|
||||
|
||||
pub fn get_child_mut(&mut self, name: &str) -> Option<&mut ModelPart> {
|
||||
self.children
|
||||
.iter_mut()
|
||||
.find(|(n, _)| n == name)
|
||||
.map(|(_, c)| c)
|
||||
}
|
||||
|
||||
/// Its parts by name, in the order they draw.
|
||||
pub fn children(&self) -> impl Iterator<Item = (&str, &ModelPart)> {
|
||||
self.children.iter().map(|(n, c)| (n.as_str(), c))
|
||||
}
|
||||
|
||||
pub fn children_mut(&mut self) -> impl Iterator<Item = (&str, &mut ModelPart)> {
|
||||
self.children.iter_mut().map(|(n, c)| (n.as_str(), c))
|
||||
}
|
||||
|
||||
/// Whether it has no faces of its own (`isEmpty`).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.polygons.is_empty()
|
||||
}
|
||||
|
||||
/// Its pose now (`storePose`).
|
||||
pub fn store_pose(&self) -> PartPose {
|
||||
PartPose {
|
||||
x: self.x,
|
||||
y: self.y,
|
||||
z: self.z,
|
||||
x_rot: self.x_rot,
|
||||
y_rot: self.y_rot,
|
||||
z_rot: self.z_rot,
|
||||
x_scale: self.x_scale,
|
||||
y_scale: self.y_scale,
|
||||
z_scale: self.z_scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts it at `pose` (`loadPose`).
|
||||
pub fn load_pose(&mut self, pose: PartPose) {
|
||||
self.x = pose.x;
|
||||
self.y = pose.y;
|
||||
self.z = pose.z;
|
||||
self.x_rot = pose.x_rot;
|
||||
self.y_rot = pose.y_rot;
|
||||
self.z_rot = pose.z_rot;
|
||||
self.x_scale = pose.x_scale;
|
||||
self.y_scale = pose.y_scale;
|
||||
self.z_scale = pose.z_scale;
|
||||
}
|
||||
|
||||
/// The pose it was baked with, unless set since (`getInitialPose`).
|
||||
pub fn initial_pose(&self) -> PartPose {
|
||||
self.initial_pose
|
||||
}
|
||||
|
||||
pub fn set_initial_pose(&mut self, pose: PartPose) {
|
||||
self.initial_pose = pose;
|
||||
}
|
||||
|
||||
/// Puts it back at its initial pose (`resetPose`), its children as they are.
|
||||
pub fn reset_pose(&mut self) {
|
||||
self.load_pose(self.initial_pose);
|
||||
}
|
||||
|
||||
/// Puts it and every part under it back at their initial poses, as
|
||||
/// `Model.resetPose` does before each frame's `setupAnim`.
|
||||
pub fn reset_all_poses(&mut self) {
|
||||
self.reset_pose();
|
||||
for (_, child) in &mut self.children {
|
||||
child.reset_all_poses();
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes `other`'s position, rotation and scale (`copyFrom`).
|
||||
pub fn copy_from(&mut self, other: &ModelPart) {
|
||||
self.load_pose(other.store_pose());
|
||||
}
|
||||
|
||||
pub fn set_pos(&mut self, x: f32, y: f32, z: f32) {
|
||||
(self.x, self.y, self.z) = (x, y, z);
|
||||
}
|
||||
|
||||
pub fn set_rotation(&mut self, x_rot: f32, y_rot: f32, z_rot: f32) {
|
||||
(self.x_rot, self.y_rot, self.z_rot) = (x_rot, y_rot, z_rot);
|
||||
}
|
||||
|
||||
/// Moves it by `by` texels (`offsetPos`), as keyframe animations do.
|
||||
pub fn offset_pos(&mut self, by: [f32; 3]) {
|
||||
self.x += by[0];
|
||||
self.y += by[1];
|
||||
self.z += by[2];
|
||||
}
|
||||
|
||||
/// Turns it by `by` radians (`offsetRotation`).
|
||||
pub fn offset_rotation(&mut self, by: [f32; 3]) {
|
||||
self.x_rot += by[0];
|
||||
self.y_rot += by[1];
|
||||
self.z_rot += by[2];
|
||||
}
|
||||
|
||||
/// Scales it by `by` more (`offsetScale`: added, as vanilla adds it).
|
||||
pub fn offset_scale(&mut self, by: [f32; 3]) {
|
||||
self.x_scale += by[0];
|
||||
self.y_scale += by[1];
|
||||
self.z_scale += by[2];
|
||||
}
|
||||
}
|
||||
|
||||
static REGISTERED: OnceLock<HashMap<String, LayerDefinition>> = OnceLock::new();
|
||||
|
||||
/// Keeps the layers native mods registered (`native_layer_definitions`),
|
||||
/// once at start. An id is `<namespace>:<path>#<layer>` (NeoForge's
|
||||
/// `ModelLayerLocation`) in a namespace other than vanilla's; one that isn't,
|
||||
/// or that another layer has already, is logged and left out.
|
||||
pub fn register(layers: Vec<(String, LayerDefinition)>) {
|
||||
let mut kept = HashMap::new();
|
||||
for (id, layer) in layers {
|
||||
if !is_mod_layer_id(&id) {
|
||||
log::warn!("model layer {id:?} is not <namespace>:<path>#<layer> in a mod's namespace");
|
||||
continue;
|
||||
}
|
||||
if kept.contains_key(&id) {
|
||||
log::warn!("model layer {id} is registered twice; the first is kept");
|
||||
continue;
|
||||
}
|
||||
kept.insert(id, layer);
|
||||
}
|
||||
if REGISTERED.set(kept).is_err() {
|
||||
log::warn!("model layers were registered already; these are left out");
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer a native mod registered as `id`.
|
||||
pub fn registered(id: &str) -> Option<&'static LayerDefinition> {
|
||||
REGISTERED.get()?.get(id)
|
||||
}
|
||||
|
||||
fn is_mod_layer_id(id: &str) -> bool {
|
||||
let Some((location, layer)) = id.split_once('#') else {
|
||||
return false;
|
||||
};
|
||||
let Some((namespace, path)) = location.split_once(':') else {
|
||||
return false;
|
||||
};
|
||||
let ok = |s: &str, extra: &[char]| {
|
||||
!s.is_empty()
|
||||
&& s.chars().all(|c| {
|
||||
c.is_ascii_lowercase()
|
||||
|| c.is_ascii_digit()
|
||||
|| "_.-".contains(c)
|
||||
|| extra.contains(&c)
|
||||
})
|
||||
};
|
||||
namespace != "minecraft"
|
||||
&& ok(namespace, &[])
|
||||
&& ok(path, &['/'])
|
||||
&& !layer.is_empty()
|
||||
&& !layer.contains(['#', ':'])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn polygon(part: &ModelPart, i: usize) -> ([[f32; 3]; 4], [[f32; 2]; 4], [f32; 3]) {
|
||||
let p = &part.polygons[i];
|
||||
(
|
||||
p.vertices.map(|v| v.pos),
|
||||
p.vertices.map(|v| [v.u, v.v]),
|
||||
p.normal,
|
||||
)
|
||||
}
|
||||
|
||||
/// One 8-texel head box on a 64 by 64 texture (`HumanoidModel`'s head),
|
||||
/// worked out by hand from `ModelPart.Cube`'s constructor: its top face
|
||||
/// is the net's first strip, 8 to 16 across and 0 to 8 down, at the
|
||||
/// box's least y (a model's +y is down).
|
||||
#[test]
|
||||
fn a_box_bakes_its_faces_on_the_texture_as_vanilla_cubes_do() {
|
||||
let mut mesh = MeshDefinition::new();
|
||||
let root = mesh.root();
|
||||
let cubes = CubeListBuilder::create()
|
||||
.tex_offs(0, 0)
|
||||
.add_box([-4.0, -8.0, -4.0], [8.0, 8.0, 8.0]);
|
||||
mesh.add_or_replace_child(root, "head", cubes, PartPose::offset(0.0, 2.0, 0.0));
|
||||
let baked = LayerDefinition::create(mesh, 64, 64).bake_root();
|
||||
let head = baked.get_child("head").unwrap();
|
||||
assert_eq!(head.polygons.len(), 6);
|
||||
assert_eq!((head.x, head.y, head.z), (0.0, 2.0, 0.0));
|
||||
|
||||
let (pos, uv, normal) = polygon(head, 0);
|
||||
assert_eq!(normal, [0.0, -1.0, 0.0]);
|
||||
assert_eq!(
|
||||
pos,
|
||||
[
|
||||
[4.0, -8.0, 4.0],
|
||||
[-4.0, -8.0, 4.0],
|
||||
[-4.0, -8.0, -4.0],
|
||||
[4.0, -8.0, -4.0]
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
uv,
|
||||
[[0.25, 0.0], [0.125, 0.0], [0.125, 0.125], [0.25, 0.125]]
|
||||
);
|
||||
|
||||
// The face, north (-z): 8 to 16 across, 8 to 16 down.
|
||||
let (pos, uv, normal) = polygon(head, 3);
|
||||
assert_eq!(normal, [0.0, 0.0, -1.0]);
|
||||
assert_eq!(
|
||||
pos,
|
||||
[
|
||||
[4.0, -8.0, -4.0],
|
||||
[-4.0, -8.0, -4.0],
|
||||
[-4.0, 0.0, -4.0],
|
||||
[4.0, 0.0, -4.0]
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
uv,
|
||||
[[0.25, 0.125], [0.125, 0.125], [0.125, 0.25], [0.25, 0.25]]
|
||||
);
|
||||
|
||||
// The back, south: the net's last strip, 24 to 32 across.
|
||||
let (_, uv, normal) = polygon(head, 5);
|
||||
assert_eq!(normal, [0.0, 0.0, 1.0]);
|
||||
assert_eq!(
|
||||
uv,
|
||||
[[0.5, 0.125], [0.375, 0.125], [0.375, 0.25], [0.5, 0.25]]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirroring_swaps_the_sides_and_growing_keeps_the_texture() {
|
||||
let plain = CubeListBuilder::create()
|
||||
.tex_offs(40, 16)
|
||||
.add_box([-1.0, -2.0, -2.0], [4.0, 12.0, 4.0]);
|
||||
let mirrored = CubeListBuilder::create()
|
||||
.tex_offs(40, 16)
|
||||
.mirror()
|
||||
.add_box([-1.0, -2.0, -2.0], [4.0, 12.0, 4.0]);
|
||||
let grown = CubeListBuilder::create().tex_offs(40, 16).add_box_with(
|
||||
[-1.0, -2.0, -2.0],
|
||||
[4.0, 12.0, 4.0],
|
||||
CubeDeformation::new(0.25),
|
||||
);
|
||||
let bake = |cubes| {
|
||||
let mut mesh = MeshDefinition::new();
|
||||
let root = mesh.root();
|
||||
mesh.add_or_replace_child(root, "arm", cubes, PartPose::ZERO);
|
||||
LayerDefinition::create(mesh, 64, 64)
|
||||
.bake_root()
|
||||
.get_child("arm")
|
||||
.unwrap()
|
||||
.clone()
|
||||
};
|
||||
let (plain, mirrored, grown) = (bake(plain), bake(mirrored), bake(grown));
|
||||
|
||||
// West (index 2): mirrored, its corners sit at the other x and
|
||||
// wind the other way, and it faces east; its net stays the same.
|
||||
let (pos, uv, normal) = polygon(&plain, 2);
|
||||
let (mpos, muv, mnormal) = polygon(&mirrored, 2);
|
||||
assert_eq!(normal, [-1.0, 0.0, 0.0]);
|
||||
assert_eq!(mnormal, [1.0, 0.0, 0.0]);
|
||||
assert_eq!(pos[0], [-1.0, -2.0, -2.0]);
|
||||
assert_eq!(mpos[3], [3.0, -2.0, -2.0]);
|
||||
assert_eq!(muv, [uv[3], uv[2], uv[1], uv[0]]);
|
||||
|
||||
// Grown a quarter texel on each side, with the same texture.
|
||||
let (gpos, guv, _) = polygon(&grown, 2);
|
||||
assert_eq!(gpos[0], [-1.25, -2.25, -2.25]);
|
||||
assert_eq!(guv, uv);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_faces_and_texture_scale_change_what_bakes() {
|
||||
let cubes = CubeListBuilder::create()
|
||||
.tex_offs(0, 0)
|
||||
.add_box_faces([0.0; 3], [2.0, 2.0, 2.0], &[Direction::North])
|
||||
.add_box_tex_scaled([0.0; 3], [2.0, 2.0, 2.0], CubeDeformation::NONE, [2.0, 2.0]);
|
||||
let mut mesh = MeshDefinition::new();
|
||||
let root = mesh.root();
|
||||
mesh.add_or_replace_child(root, "p", cubes, PartPose::ZERO);
|
||||
let part = LayerDefinition::create(mesh, 16, 16).bake_root();
|
||||
let part = part.get_child("p").unwrap();
|
||||
// One face of the first box, six of the second.
|
||||
assert_eq!(part.polygons.len(), 7);
|
||||
assert_eq!(part.polygons[0].normal, [0.0, 0.0, -1.0]);
|
||||
// North of the second: 2 to 4 across, on a texture taken as 32 wide.
|
||||
let (_, uv, _) = polygon(part, 4);
|
||||
assert_eq!(uv[0], [4.0 / 32.0, 2.0 / 32.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_replaced_part_keeps_its_children_and_poses_reset() {
|
||||
let mut mesh = MeshDefinition::new();
|
||||
let root = mesh.root();
|
||||
let body =
|
||||
mesh.add_or_replace_child(root, "body", CubeListBuilder::create(), PartPose::ZERO);
|
||||
mesh.add_or_replace_child(
|
||||
body,
|
||||
"arm",
|
||||
CubeListBuilder::create(),
|
||||
PartPose::offset(5.0, 2.0, 0.0),
|
||||
);
|
||||
let again = mesh.add_or_replace_child(
|
||||
root,
|
||||
"body",
|
||||
CubeListBuilder::create().add_box([0.0; 3], [1.0; 3]),
|
||||
PartPose::rotation(0.5, 0.0, 0.0),
|
||||
);
|
||||
assert_eq!(again, body);
|
||||
assert!(mesh.child(body, "arm").is_some());
|
||||
|
||||
let mut model = LayerDefinition::create(mesh, 64, 32).bake_root();
|
||||
let body = model.get_child_mut("body").unwrap();
|
||||
assert_eq!(body.polygons.len(), 6);
|
||||
assert_eq!(body.x_rot, 0.5);
|
||||
body.x_rot = 1.5;
|
||||
let arm = body.get_child_mut("arm").unwrap();
|
||||
arm.offset_pos([1.0, 0.0, 0.0]);
|
||||
assert_eq!(arm.x, 6.0);
|
||||
model.reset_all_poses();
|
||||
let body = model.get_child("body").unwrap();
|
||||
assert_eq!(body.x_rot, 0.5);
|
||||
assert_eq!(body.get_child("arm").unwrap().x, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_ids_are_a_mods_own_model_and_layer() {
|
||||
assert!(is_mod_layer_id("mymod:golem#main"));
|
||||
assert!(is_mod_layer_id("my_mod:mobs/golem.v2#outer_armor"));
|
||||
for bad in [
|
||||
"minecraft:zombie#main",
|
||||
"mymod:golem",
|
||||
"golem#main",
|
||||
"mymod:#main",
|
||||
"mymod:golem#",
|
||||
"MyMod:golem#main",
|
||||
"mymod:golem#a#b",
|
||||
] {
|
||||
assert!(!is_mod_layer_id(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `HumanoidModel.createMesh(CubeDeformation.NONE, 0)` built with these
|
||||
/// builders bakes what vanilla baked for the zombie
|
||||
/// (`minecraft:zombie#main` in the prepared `entity-models.json`):
|
||||
/// `cargo test -p horizon-client-lib --lib model_layers -- --ignored`
|
||||
/// with HORIZON_RESOURCES set to the prepared resources.
|
||||
#[test]
|
||||
#[ignore = "needs the prepared resources"]
|
||||
fn the_humanoid_mesh_bakes_as_vanillas_zombie() {
|
||||
let Some(root) = std::env::var_os("HORIZON_RESOURCES").map(std::path::PathBuf::from) else {
|
||||
return;
|
||||
};
|
||||
let vanilla =
|
||||
super::super::load_model(&root, "minecraft:zombie#main").expect("zombie#main");
|
||||
let vanilla = ModelPart::from_part(&vanilla);
|
||||
|
||||
// `HumanoidModel.createMesh`: (part, parent, texOffs, mirror, origin, size, grow, pose).
|
||||
let half = CubeDeformation::new(0.5);
|
||||
let none = CubeDeformation::NONE;
|
||||
let (z4, limb) = ([-4.0, -8.0, -4.0], [4.0, 12.0, 4.0]);
|
||||
#[rustfmt::skip]
|
||||
let parts = [
|
||||
("head", "", (0, 0), false, z4, [8.0; 3], none, [0.0; 3]),
|
||||
("hat", "head", (32, 0), false, z4, [8.0; 3], half, [0.0; 3]),
|
||||
("body", "", (16, 16), false, [-4.0, 0.0, -2.0], [8.0, 12.0, 4.0], none, [0.0; 3]),
|
||||
("right_arm", "", (40, 16), false, [-3.0, -2.0, -2.0], limb, none, [-5.0, 2.0, 0.0]),
|
||||
("left_arm", "", (40, 16), true, [-1.0, -2.0, -2.0], limb, none, [5.0, 2.0, 0.0]),
|
||||
("right_leg", "", (0, 16), false, [-2.0, 0.0, -2.0], limb, none, [-1.9, 12.0, 0.0]),
|
||||
("left_leg", "", (0, 16), true, [-2.0, 0.0, -2.0], limb, none, [1.9, 12.0, 0.0]),
|
||||
];
|
||||
let mut mesh = MeshDefinition::new();
|
||||
for (name, parent, (u, v), mirror, origin, size, grow, [x, y, z]) in parts {
|
||||
let parent = mesh.child(mesh.root(), parent).unwrap_or(mesh.root());
|
||||
let cubes = CubeListBuilder::create()
|
||||
.tex_offs(u, v)
|
||||
.set_mirror(mirror)
|
||||
.add_box_with(origin, size, grow);
|
||||
mesh.add_or_replace_child(parent, name, cubes, PartPose::offset(x, y, z));
|
||||
}
|
||||
let ours = LayerDefinition::create(mesh, 64, 64).bake_root();
|
||||
|
||||
fn same(path: &str, ours: &ModelPart, vanilla: &ModelPart) {
|
||||
assert_eq!(ours.store_pose(), vanilla.store_pose(), "{path} pose");
|
||||
assert_eq!(ours.polygons, vanilla.polygons, "{path} faces");
|
||||
let mut names: Vec<_> = ours.children().map(|(n, _)| n).collect();
|
||||
let mut theirs: Vec<_> = vanilla.children().map(|(n, _)| n).collect();
|
||||
names.sort_unstable();
|
||||
theirs.sort_unstable();
|
||||
assert_eq!(names, theirs, "{path} children");
|
||||
for (name, child) in ours.children() {
|
||||
same(
|
||||
&format!("{path}/{name}"),
|
||||
child,
|
||||
vanilla.get_child(name).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
same("root", &ours, &vanilla);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ pub fn happy_ghast_attachment(index: usize) -> [f64; 3] {
|
||||
let [x, y, z] = SEATS[index.min(SEATS.len() - 1)];
|
||||
pt(x, y, z)
|
||||
}
|
||||
/// `Pig.getDefaultDimensions` and other species' age-specific passenger attachments.
|
||||
fn base_attachment(v: &Mob) -> [f64; 3] {
|
||||
let baby = v.is_baby();
|
||||
// Players and mannequins have no `passengerAttachments`: their box top.
|
||||
@@ -74,6 +75,7 @@ fn base_attachment(v: &Mob) -> [f64; 3] {
|
||||
Species::Donkey => up(1.1125),
|
||||
Species::Mule => up(1.2125),
|
||||
Species::SkeletonHorse | Species::ZombieHorse => up(1.31875),
|
||||
Species::Pig if baby => up(0.5),
|
||||
Species::Pig => up(0.86875),
|
||||
// `Hoglin.BABY_DIMENSIONS` and `EntityType.HOGLIN`.
|
||||
Species::Hoglin if baby => up(0.875),
|
||||
@@ -572,6 +574,35 @@ mod tests {
|
||||
1.312_500_059_604_645
|
||||
));
|
||||
}
|
||||
/// `Pig.getDefaultDimensions`: BABY_DIMENSIONS overrides PASSENGER.
|
||||
#[test]
|
||||
fn a_baby_pig_uses_its_own_passenger_attachment() {
|
||||
let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
|
||||
kind.id = "minecraft:pig".into();
|
||||
kind.species = Species::Pig;
|
||||
let kind = std::sync::Arc::new(kind);
|
||||
let mut vehicle = Mob::new(1, kind.clone(), Vec3::new(4.5, 200.0, 4.5), 0.0);
|
||||
let rider = Mob::new(2, kind, Vec3::ZERO, 0.0);
|
||||
for (scale, baby_y, adult_y) in [
|
||||
(0.0, 0.5, 0.868_749_976_158_142_1),
|
||||
(1.0, 0.5, 0.868_749_976_158_142_1),
|
||||
(2.0, 1.0, 1.737_499_952_316_284_2),
|
||||
] {
|
||||
vehicle.extra.model_scale = scale;
|
||||
for (age, expected) in [(-24000, baby_y), (-1, baby_y), (0, adult_y), (42, adult_y)] {
|
||||
vehicle.extra.age = age;
|
||||
let attachment = passenger_attachment(&vehicle);
|
||||
assert_eq!(attachment, [0.0, expected, 0.0], "age={age}, scale={scale}");
|
||||
let offset = vehicle_attachment(&rider, vehicle.width());
|
||||
assert_eq!(offset, 0.0);
|
||||
assert_eq!(
|
||||
seat(attachment, offset, vehicle.position, vehicle.yaw),
|
||||
Vec3::new(4.5, 200.0 + expected, 4.5)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archers_keep_their_distance_and_brawlers_close_in() {
|
||||
assert!(ranged(Species::Skeleton) && ranged(Species::Pillager));
|
||||
|
||||
@@ -119,9 +119,18 @@ fn can_see_sky_from_below_water<L: SpawnLevel>(l: &L, x: i32, y: i32, z: i32) ->
|
||||
}
|
||||
((y + 1)..sea).all(|yy| {
|
||||
let f = l.facts().get(l.state(x, yy, z));
|
||||
f.dampening == 0 || f.fluid != Fluid::None
|
||||
// `BlockState.liquid()` is water, lava and the bubble column. Kelp,
|
||||
// seagrass and waterlogged blocks hold water and still stop the scan.
|
||||
f.dampening == 0 || liquid_block(&f.name)
|
||||
})
|
||||
}
|
||||
/// `BlockState.liquid()`: the three blocks whose property is set.
|
||||
fn liquid_block(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"minecraft:water" | "minecraft:lava" | "minecraft:bubble_column"
|
||||
)
|
||||
}
|
||||
|
||||
/// `EntityType.fireImmune`.
|
||||
pub fn fire_immune(s: Species) -> bool {
|
||||
@@ -618,3 +627,85 @@ impl SpawnLevel for super::MobWorld<'_> {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod underwater_sky_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::with_built_test_world;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::{block_state_properties::BlockProperties, chunk::BlockState};
|
||||
|
||||
fn with_props(
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
name: &str,
|
||||
props: &[(&str, &str)],
|
||||
) -> horizon_world::registry::BlockStateId {
|
||||
let mut properties = BlockProperties::new();
|
||||
for (key, value) in props {
|
||||
properties.insert((*key).to_owned(), (*value).to_owned());
|
||||
}
|
||||
registry
|
||||
.resolve(&BlockState {
|
||||
name: format!("minecraft:{name}"),
|
||||
properties,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `Guardian.checkGuardianSpawnRules`: below sea level the 1-in-20 roll is
|
||||
/// skipped when `canSeeSkyFromBelowWater` is false. Kelp (dampening 1, not
|
||||
/// `liquid()`) makes it false. A bubble column is `liquid()`, so the scan
|
||||
/// continues and the roll is still required.
|
||||
#[test]
|
||||
fn kelp_hides_the_sky_from_a_guardian_and_a_bubble_column_does_not() {
|
||||
let ran = with_built_test_world(
|
||||
&[],
|
||||
|world, registry| {
|
||||
world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(crate::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
let water = registry.default_state("minecraft:water").unwrap();
|
||||
let kelp = with_props(registry, "kelp", &[("age", "0")]);
|
||||
let column = with_props(registry, "bubble_column", &[("drag", "true")]);
|
||||
for x in [0, 1] {
|
||||
world.set(x, 49, 0, water).unwrap();
|
||||
world.set(x, 50, 0, water).unwrap();
|
||||
}
|
||||
world.set(0, 55, 0, kelp).unwrap();
|
||||
world.set(1, 55, 0, column).unwrap();
|
||||
},
|
||||
|w, _| {
|
||||
// Sea level in this world is 63, so y=55 sits in the scan.
|
||||
assert_eq!(w.sea_level(), 63);
|
||||
let mut probe = LegacyRandom::new(1);
|
||||
assert_ne!(
|
||||
probe.next_int_bound(20).unwrap(),
|
||||
0,
|
||||
"seed 1 must miss the 1-in-20"
|
||||
);
|
||||
let mut random = LegacyRandom::new(1);
|
||||
assert!(
|
||||
check_spawn_rules(Species::Guardian, w, Reason::Natural, &mut random, 0, 50, 0),
|
||||
"kelp between the guardian and the sea hides the sky"
|
||||
);
|
||||
let mut random = LegacyRandom::new(1);
|
||||
assert!(
|
||||
!check_spawn_rules(
|
||||
Species::Guardian,
|
||||
w,
|
||||
Reason::Natural,
|
||||
&mut random,
|
||||
1,
|
||||
50,
|
||||
0
|
||||
),
|
||||
"a bubble column is liquid(), so the sky still shows"
|
||||
);
|
||||
},
|
||||
);
|
||||
// Without the vanilla data under .local/horizon-client the world
|
||||
// cannot be built: the test has nothing to check.
|
||||
let _ = ran;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,29 @@ use std::{borrow::Cow, collections::HashMap, fs, path::Path};
|
||||
|
||||
pub use crate::biome::{SpawnCost, parse_costs, parse_entries};
|
||||
|
||||
thread_local! {
|
||||
static ATTEMPT_COUNTS: std::cell::Cell<[u64; 2]> = const { std::cell::Cell::new([0; 2]) };
|
||||
}
|
||||
/// Reset or publish once per natural-spawn tick, not once per chunk.
|
||||
pub fn attempt_counters(publish: bool) {
|
||||
if !crate::spans::enabled() {
|
||||
return;
|
||||
}
|
||||
let counts = ATTEMPT_COUNTS.with(|c| c.replace([0; 2]));
|
||||
if publish {
|
||||
crate::spans::counters(&[("sa_attempts", counts[0]), ("sa_spawns", counts[1])]);
|
||||
}
|
||||
}
|
||||
fn attempt_count(index: usize) {
|
||||
if crate::spans::enabled() {
|
||||
ATTEMPT_COUNTS.with(|c| {
|
||||
let mut counts = c.get();
|
||||
counts[index] += 1;
|
||||
c.set(counts);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `MobCategory`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Category {
|
||||
@@ -1070,14 +1093,19 @@ pub fn spawn_category_for_chunk<L: NaturalLevel, R: RandomSource>(
|
||||
) {
|
||||
let x = chunk.0 * 16 + random.next_int_bound(16).unwrap_or(0);
|
||||
let z = chunk.1 * 16 + random.next_int_bound(16).unwrap_or(0);
|
||||
let top = l.heightmap(Heightmap::WorldSurface, x, z) + 1;
|
||||
let min = l.min_y();
|
||||
let y = min + random.next_int_bound(top - min + 1).unwrap_or(0);
|
||||
let y = random_start_y(random, min, l.heightmap(Heightmap::WorldSurface, x, z));
|
||||
trace(Event::Start(category, [x, y, z]));
|
||||
if y > min {
|
||||
spawn_category_for_position(l, random, state, category, chunk, [x, y, z], spawn, trace);
|
||||
}
|
||||
}
|
||||
// NaturalSpawner.getRandomPosWithin adds one to ChunkAccess.getHeight's
|
||||
// highest occupied Y. NaturalLevel.heightmap already returns the first empty Y.
|
||||
fn random_start_y(random: &mut impl RandomSource, min: i32, first_empty: i32) -> i32 {
|
||||
min + random.next_int_bound(first_empty - min + 1).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `NaturalSpawner.spawnCategoryForPosition` with the spawn state's
|
||||
/// `canSpawn` and `afterSpawn`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1111,6 +1139,7 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
|
||||
z += random.next_int_bound(6).unwrap_or(0) - random.next_int_bound(6).unwrap_or(0);
|
||||
let (xx, zz) = (f64::from(x) + 0.5, f64::from(z) + 0.5);
|
||||
trace(Event::Try(x, z));
|
||||
attempt_count(0);
|
||||
let Some(distance) = nearest.distance_sqr(l, xx, f64::from(y), zz) else {
|
||||
continue;
|
||||
};
|
||||
@@ -1177,6 +1206,7 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
|
||||
let Some(result) = spawn(l, &request) else {
|
||||
continue;
|
||||
};
|
||||
attempt_count(1);
|
||||
group_index += 1;
|
||||
spawned.push(result.bounds);
|
||||
cluster += 1;
|
||||
@@ -1458,6 +1488,28 @@ fn top_non_colliding<L: NaturalLevel>(l: &L, entity: &str, x: i32, z: i32) -> [i
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn random_start_uses_first_empty_y_as_inclusive_ceiling() {
|
||||
use horizon_core::random::LegacyRandom;
|
||||
|
||||
// Vanilla getRandomPosWithin: a solid column through Y=63 has
|
||||
// topEmptyY=64, so [-64, 64] contains 129 possible starts, not 130.
|
||||
for seed in 0..1024 {
|
||||
let mut actual = LegacyRandom::new(seed);
|
||||
let mut vanilla = LegacyRandom::new(seed);
|
||||
let expected = -64 + vanilla.next_int_bound(129).unwrap();
|
||||
assert_eq!(random_start_y(&mut actual, -64, 64), expected);
|
||||
assert_eq!(actual.next_long(), vanilla.next_long());
|
||||
}
|
||||
// An empty column must still consume nextInt(1), then fail the
|
||||
// spawnCategoryForChunk minimum-height gate without any group draws.
|
||||
let mut actual = LegacyRandom::new(42);
|
||||
let mut vanilla = LegacyRandom::new(42);
|
||||
vanilla.next_int_bound(1).unwrap();
|
||||
assert_eq!(random_start_y(&mut actual, -64, -64), -64);
|
||||
assert_eq!(actual.next_long(), vanilla.next_long());
|
||||
}
|
||||
|
||||
/// The grid finds the players whose chunk is within 8 of a chunk on both
|
||||
/// axes, in their order, as a look at every player does.
|
||||
#[test]
|
||||
|
||||
@@ -612,8 +612,8 @@ mod tests {
|
||||
#[test]
|
||||
fn water_speed_is_cut_away_from_home_and_for_babies() {
|
||||
// updateSpeed in water near home: no cut, the lerp climbs towards
|
||||
// 0.25, and it lifts the turtle 0.005 a tick. (Only the control's
|
||||
// speed: Turtle.travelInWater is not ported.)
|
||||
// 0.25, and it lifts the turtle 0.005 a tick. (Tests only the move
|
||||
// control's speed; Turtle.travelInWater is ported in travel().)
|
||||
let mut m = strolling_turtle();
|
||||
m.on_ground = false;
|
||||
m.fluids.in_water = true;
|
||||
|
||||
@@ -41,6 +41,10 @@ pub use crate::items::ItemStack;
|
||||
pub use crate::merchant::MerchantOffer;
|
||||
pub use crate::mesher::TintSource;
|
||||
pub use crate::mobs::goals::{Cx, Goal, GoalSelector};
|
||||
pub use crate::mobs::model_layers::{
|
||||
CubeDefinition, CubeDeformation, CubeListBuilder, LayerDefinition, MeshDefinition, ModelPart,
|
||||
ModelPolygon, ModelVertex, PartId, PartPose,
|
||||
};
|
||||
pub use crate::mobs::{Mob, MobWorld};
|
||||
pub use crate::music::{Music, MusicLayer};
|
||||
pub use crate::particles::{ParticleOptions, ParticleType};
|
||||
|
||||
@@ -38,7 +38,7 @@ use horizon_world::{
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs, io,
|
||||
io,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
@@ -94,13 +94,13 @@ impl MotionResources {
|
||||
assets: &Assets,
|
||||
facts: &crate::blocks::BlockFacts,
|
||||
) -> Result<Self> {
|
||||
let attributes = Arc::new(Catalog::from_json(&serde_json::from_slice(&fs::read(
|
||||
root.join("attributes.json"),
|
||||
)?)?)?);
|
||||
let attributes = Arc::new(Catalog::from_json(&horizon_world::json_file::read(
|
||||
&root.join("attributes.json"),
|
||||
)?)?);
|
||||
let file = BlockTableFile::open(&root.join("movement-properties.nbt"), assets.registry)?;
|
||||
let properties = MovementProperties::from_table(file.table("movement")?)?;
|
||||
let bundle: BTreeMap<String, String> =
|
||||
serde_json::from_slice(&fs::read(root.join("block-tags.json"))?)?;
|
||||
horizon_world::json_file::read(&root.join("block-tags.json"))?;
|
||||
let mut definitions = TagDefinitions::default();
|
||||
for (name, text) in bundle {
|
||||
definitions.apply_json(&name, "vanilla", &text)?;
|
||||
@@ -374,6 +374,8 @@ pub struct PlayerMotion {
|
||||
/// move.
|
||||
pub nudge: [Option<f64>; 2],
|
||||
pub last_climbable: Option<BlockPos>,
|
||||
/// Result of the latest `LivingEntity.onClimbable` query.
|
||||
pub on_climbable: bool,
|
||||
/// `Entity.moveDist`, `flyDist` and `nextStep`: footsteps come once per
|
||||
/// metre walked, not once per move.
|
||||
pub steps: MovementDistances,
|
||||
@@ -602,6 +604,7 @@ impl PlayerMotion {
|
||||
item_use_slows_sprint: false,
|
||||
nudge: [None; 2],
|
||||
last_climbable: None,
|
||||
on_climbable: false,
|
||||
steps: MovementDistances {
|
||||
next_step: 1.0,
|
||||
..MovementDistances::default()
|
||||
@@ -1096,10 +1099,15 @@ impl MotionWorld<'_, '_, '_> {
|
||||
/// matching ladder. It remembers the block.
|
||||
pub fn climbable(&mut self) -> Result<bool> {
|
||||
if self.player.flying {
|
||||
self.player.on_climbable = false;
|
||||
return Ok(false);
|
||||
}
|
||||
let resources = self.resources;
|
||||
climbing::on_climbable(self, &resources.climbing).map_err(|e| e.to_string().into())
|
||||
let found: Result<bool> =
|
||||
climbing::on_climbable(self, &resources.climbing).map_err(|e| e.to_string().into());
|
||||
let climbable = found?;
|
||||
self.player.on_climbable = climbable;
|
||||
Ok(climbable)
|
||||
}
|
||||
pub fn travel_in_air(&mut self, input: Vec3) -> Result<()> {
|
||||
living_air::travel_in_air(self, input)
|
||||
|
||||
@@ -104,7 +104,7 @@ impl RandomTickSections {
|
||||
key: (i32, i32),
|
||||
chunk: &Arc<Chunk>,
|
||||
facts: &BlockFacts,
|
||||
candidate: impl FnMut(u32) -> bool,
|
||||
mut candidate: impl FnMut(u32) -> bool,
|
||||
from_y: i32,
|
||||
) -> Option<(i32, Rc<RandomTickMask>)> {
|
||||
let cached = Self::refresh(&mut self.chunks, key, chunk, &mut |id| {
|
||||
@@ -113,12 +113,32 @@ impl RandomTickSections {
|
||||
let y = cached.sections.from(from_y)?;
|
||||
let index = ((y - cached.sections.base) >> 4) as usize;
|
||||
let mask = cached.masks[index].get_or_insert_with(|| {
|
||||
let mut blocks = [0u32; 4096];
|
||||
chunk.sections[index].blocks.unpack(&mut blocks);
|
||||
Rc::new(RandomTickMask::of(&blocks, candidate))
|
||||
Rc::new(Self::candidate_mask(&chunk.sections[index], &mut candidate))
|
||||
});
|
||||
Some((y, mask.clone()))
|
||||
}
|
||||
fn candidate_mask(section: &Section, mut candidate: impl FnMut(u32) -> bool) -> RandomTickMask {
|
||||
let blocks = §ion.blocks;
|
||||
let mut ids = [0u16; 4096];
|
||||
if let Some(palette) = blocks.unpack_ids(&mut ids) {
|
||||
// ServerLevel.tickChunk's per-state eligibility is constant
|
||||
// across a section: resolve it once per palette entry, not
|
||||
// once per block when an edited section rebuilds its mask.
|
||||
let mut candidates = [false; 256];
|
||||
for (i, &state) in palette.iter().enumerate() {
|
||||
candidates[i] = candidate(state);
|
||||
}
|
||||
let mut bits = [0u64; 64];
|
||||
for (i, &id) in ids.iter().enumerate() {
|
||||
bits[i >> 6] |= u64::from(candidates[id as usize]) << (i & 63);
|
||||
}
|
||||
RandomTickMask(bits)
|
||||
} else {
|
||||
let mut states = [0u32; 4096];
|
||||
blocks.unpack(&mut states);
|
||||
RandomTickMask::of(&states, candidate)
|
||||
}
|
||||
}
|
||||
fn refresh<'a>(
|
||||
chunks: &'a mut FxHashMap<(i32, i32), Cached>,
|
||||
key: (i32, i32),
|
||||
@@ -166,6 +186,78 @@ mod tests {
|
||||
use crate::chunk::SECTIONS;
|
||||
use crate::chunk::Section;
|
||||
|
||||
#[test]
|
||||
fn palette_masks_preserve_positions_and_rng() {
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::level::Ticks;
|
||||
|
||||
// Alternating states defeat the old mask builder's consecutive-state
|
||||
// shortcut. Include single, local and global palettes and unused IDs.
|
||||
for distinct in [1, 2, 256, 300] {
|
||||
let states = std::array::from_fn::<_, 4096, _>(|i| (i % distinct) as u32);
|
||||
let mut section = Section {
|
||||
blocks: Section::from_blocks(&states),
|
||||
..Default::default()
|
||||
};
|
||||
if distinct == 2 {
|
||||
section.set(0, 999);
|
||||
section.set(0, states[0]);
|
||||
}
|
||||
let candidate = |id| id % 3 == 0;
|
||||
let reference = RandomTickMask::of(&states, candidate);
|
||||
let mut calls = 0;
|
||||
let optimized = RandomTickSections::candidate_mask(§ion, |id| {
|
||||
calls += 1;
|
||||
candidate(id)
|
||||
});
|
||||
assert_eq!(reference, optimized);
|
||||
if distinct == 2 {
|
||||
assert_eq!(calls, 3); // two live states and one stale palette entry
|
||||
}
|
||||
let (mut before, mut after) = (Ticks::new(12345), Ticks::new(12345));
|
||||
let mut expected = Vec::new();
|
||||
let mut actual = Vec::new();
|
||||
// Non-ticking sections consume no draws; each ticking section
|
||||
// gets three, even if none hits a candidate.
|
||||
for (y, ticking) in [(-64, false), (-48, true), (-32, false), (16, true)] {
|
||||
if !ticking {
|
||||
continue;
|
||||
}
|
||||
for _ in 0..3 {
|
||||
let p = before.block_random_pos(-32, y, 48, 15);
|
||||
if reference.may_tick(p) {
|
||||
expected.push(p);
|
||||
}
|
||||
let p = after.block_random_pos(-32, y, 48, 15);
|
||||
if optimized.may_tick(p) {
|
||||
actual.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(expected, actual);
|
||||
assert_eq!(before.random_tick_value, after.random_tick_value);
|
||||
// Independent wrapping i32 LCG oracle, including coordinate bits.
|
||||
let mut value = 12345u32;
|
||||
let mut oracle = Vec::new();
|
||||
for y in [-48, 16] {
|
||||
for _ in 0..3 {
|
||||
value = value.wrapping_mul(3).wrapping_add(1_013_904_223);
|
||||
let p = BlockPos::new(
|
||||
-32 + ((value >> 2) & 15) as i32,
|
||||
y + ((value >> 18) & 15) as i32,
|
||||
48 + ((value >> 10) & 15) as i32,
|
||||
);
|
||||
let i = ((p.y & 15) << 8 | (p.z & 15) << 4 | (p.x & 15)) as usize;
|
||||
if candidate(states[i]) {
|
||||
oracle.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(actual, oracle);
|
||||
assert_eq!(after.random_tick_value as u32, value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tall_chunk_ticks_its_sections_near_the_top() {
|
||||
let layout = crate::chunk::ColumnLayout::new(-64, 2096).unwrap();
|
||||
|
||||
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user