mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
Compare commits
211
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c002989c82 | ||
|
|
523280e781 | ||
|
|
65b0cb2521 | ||
|
|
add777206d | ||
|
|
91c84ca76d | ||
|
|
de23ad4338 | ||
|
|
56108d1b37 | ||
|
|
e6bd4cf76c | ||
|
|
cf7e635cb0 | ||
|
|
d6f9367ff1 | ||
|
|
fbbf0ac64c | ||
|
|
c07049a77f | ||
|
|
83fcd89847 | ||
|
|
6f67726a5b | ||
|
|
1318853025 | ||
|
|
3fef8426d4 | ||
|
|
bd374a9f30 | ||
|
|
f1cb0920ce | ||
|
|
b2604a3963 | ||
|
|
18d98c064a | ||
|
|
686697840d | ||
|
|
dcfab95680 | ||
|
|
d560f1534f | ||
|
|
8194ef9740 | ||
|
|
2cb20cda62 | ||
|
|
fa09f3486f | ||
|
|
b3e41ac6b5 | ||
|
|
6cead02e34 | ||
|
|
02f0970cd4 | ||
|
|
cfbbc077cc | ||
|
|
db87ac7956 | ||
|
|
5fc4723bab | ||
|
|
c3198c9c17 | ||
|
|
3c83854938 | ||
|
|
3e10fb465b | ||
|
|
06f666db57 | ||
|
|
789326bb92 | ||
|
|
c8d55feca3 | ||
|
|
3f8f346851 | ||
|
|
755a6acc76 | ||
|
|
21117506d2 | ||
|
|
ed6125756c | ||
|
|
8ae648d67d | ||
|
|
7c92e49971 | ||
|
|
6f29f197a0 | ||
|
|
e6605b2364 | ||
|
|
11d72958f4 | ||
|
|
e304294056 | ||
|
|
0904969572 | ||
|
|
a2950bce2f | ||
|
|
4f6b549e5d | ||
|
|
1397756847 | ||
|
|
71827f3b4d | ||
|
|
2cb278ab3c | ||
|
|
56f2552241 | ||
|
|
aae895a096 | ||
|
|
e414d77f68 | ||
|
|
c33e47a33b | ||
|
|
260b564fe1 | ||
|
|
9d008e809f | ||
|
|
98d98f4dde | ||
|
|
6def29e16f | ||
|
|
fab334a930 | ||
|
|
fe0d9733ad | ||
|
|
b9381720d4 | ||
|
|
e70f4650ab | ||
|
|
1c71afafab | ||
|
|
8ff13ea185 | ||
|
|
9f89c65d34 | ||
|
|
f2460134fe | ||
|
|
f0e1d992ef | ||
|
|
e60ce10870 | ||
|
|
292cecbda3 | ||
|
|
5211bdd224 | ||
|
|
e42a82e9b9 | ||
|
|
d77569c672 | ||
|
|
937dcba5b8 | ||
|
|
d8b49e8697 | ||
|
|
71b8f39eb5 | ||
|
|
36f6ab9839 | ||
|
|
d3b28edfb6 | ||
|
|
9c49424b4d | ||
|
|
6a65ee1124 | ||
|
|
ad70f755d7 | ||
|
|
d2ca443d6f | ||
|
|
716cc7f102 | ||
|
|
64279fa1ce | ||
|
|
c35436a7da | ||
|
|
393b8ef7e6 | ||
|
|
15be176380 | ||
|
|
8905aaf0dd | ||
|
|
b463f1c80a | ||
|
|
a8ccf43d2b | ||
|
|
6964ee8ece | ||
|
|
bbc1ffb408 | ||
|
|
4c78d32e36 | ||
|
|
4516f54e2b | ||
|
|
9809bf38b6 | ||
|
|
38cd3095e5 | ||
|
|
c752c21246 | ||
|
|
82055fb57d | ||
|
|
739d4fe187 | ||
|
|
24744aae44 | ||
|
|
8f78fdeb7b | ||
|
|
ed9fdb5208 | ||
|
|
6120c8c0ad | ||
|
|
53ea3e7224 | ||
|
|
462787aee1 | ||
|
|
da736d56d6 | ||
|
|
e872d3ae54 | ||
|
|
484cc945aa | ||
|
|
a14da6bb70 | ||
|
|
3114fc66e9 | ||
|
|
452fca8bdd | ||
|
|
ab40cd63e6 | ||
|
|
00f2663e7b | ||
|
|
91d6bdba85 | ||
|
|
4248048ab6 | ||
|
|
0300ad8922 | ||
|
|
e8ae26c270 | ||
|
|
b62095dfcb | ||
|
|
ba3b567f54 | ||
|
|
5d3e9d218d | ||
|
|
36ef05b0b9 | ||
|
|
49ad61035b | ||
|
|
3842d851d8 | ||
|
|
c987778e97 | ||
|
|
b28108128d | ||
|
|
c50557ea0b | ||
|
|
f364c4f9c6 | ||
|
|
9841ce7824 | ||
|
|
83a6f5c7b9 | ||
|
|
18f4dd92ae | ||
|
|
47654b62a3 | ||
|
|
2f3dade42f | ||
|
|
af49fd7dc3 | ||
|
|
af6faa88e5 | ||
|
|
e71d4281ac | ||
|
|
26626e8c27 | ||
|
|
7102727197 | ||
|
|
70868f3208 | ||
|
|
aff673a08d | ||
|
|
2e642857fc | ||
|
|
147e5269c7 | ||
|
|
b3a499d85d | ||
|
|
3c013dac75 | ||
|
|
b913dab5e5 | ||
|
|
e87e839c97 | ||
|
|
8c8f857313 | ||
|
|
9c427dfae0 | ||
|
|
13144a82a1 | ||
|
|
ef711f8b1f | ||
|
|
601e7918a8 | ||
|
|
dd3697bb4d | ||
|
|
273729aac8 | ||
|
|
94592a35ac | ||
|
|
728bf14aa7 | ||
|
|
53501ab94e | ||
|
|
581dc08cf5 | ||
|
|
85a6ce0921 | ||
|
|
1b4fe0f067 | ||
|
|
fd0f09d6f6 | ||
|
|
06fb03567e | ||
|
|
c418034f10 | ||
|
|
c273a7271e | ||
|
|
5b9a66089e | ||
|
|
4c72a3a44f | ||
|
|
8691d9a8e8 | ||
|
|
d2105fe66c | ||
|
|
79bfb8fcb8 | ||
|
|
20630b1230 | ||
|
|
8d77913ea5 | ||
|
|
66637220ef | ||
|
|
5fa423169f | ||
|
|
183382cd2b | ||
|
|
84aba7b3d4 | ||
|
|
329ce1b724 | ||
|
|
e2a399aff0 | ||
|
|
2a0824cf5f | ||
|
|
a4d0f76edd | ||
|
|
00e2cfec29 | ||
|
|
a5fa40d2ee | ||
|
|
cc39d57e07 | ||
|
|
0a5e80de7b | ||
|
|
b7c47ccbf9 | ||
|
|
9c3450622b | ||
|
|
3615c65d18 | ||
|
|
7cc8def23f | ||
|
|
e553db9bb5 | ||
|
|
790b191ed1 | ||
|
|
d136dda092 | ||
|
|
a8205dd80d | ||
|
|
f252941ac1 | ||
|
|
30fa41ddbd | ||
|
|
e2781f7939 | ||
|
|
d338bcd070 | ||
|
|
8697f3fd3f | ||
|
|
6bc8a8b889 | ||
|
|
4825e537cc | ||
|
|
2e91bd84e9 | ||
|
|
5c0f7142f3 | ||
|
|
5d72fdd8c2 | ||
|
|
3287a74d64 | ||
|
|
946f47b3ec | ||
|
|
09078949d7 | ||
|
|
265965445d | ||
|
|
f5f7448909 | ||
|
|
9e3956282a | ||
|
|
408a75aeb3 | ||
|
|
19a5098b14 | ||
|
|
a7ff22eea0 |
No files matched your search
@@ -72,9 +72,6 @@ jobs:
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
|
||||
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib --features horizon-client/mods,horizon-mods/pack mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
|
||||
@@ -66,7 +66,6 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
|
||||
Generated
+2
-122
@@ -477,13 +477,6 @@ dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coords-hud-lua"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-mod-lua",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -937,13 +930,6 @@ version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
|
||||
|
||||
[[package]]
|
||||
name = "far-view"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"horizon-mod-sdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -1107,30 +1093,6 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gc-arena"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3cd70cf88a32937834aae9614ff2569b5d9467fa0c42c5d7762fd94a8de88266"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"gc-arena-derive",
|
||||
"hashbrown 0.14.5",
|
||||
"sptr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gc-arena-derive"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c612a69f5557a11046b77a7408d2836fe77077f842171cd211c5ef504bd3cddd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure 0.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -1295,16 +1257,6 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1509,16 +1461,6 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-lua"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-mod-sdk",
|
||||
"piccolo",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-sdk"
|
||||
version = "0.1.0"
|
||||
@@ -2795,21 +2737,6 @@ dependencies = [
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "piccolo"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "003bf52de285e1ff1adcbc6572588db3849988ea660a2d55af3a2ffbc81f597f"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
"anyhow",
|
||||
"gc-arena",
|
||||
"hashbrown 0.14.5",
|
||||
"rand",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
@@ -2937,15 +2864,6 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "predator-missile"
|
||||
version = "0.1.0"
|
||||
@@ -3046,27 +2964,6 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
@@ -3546,12 +3443,6 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sptr"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -3669,17 +3560,6 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.14.0"
|
||||
@@ -5341,7 +5221,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure 0.14.0",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5382,7 +5262,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure 0.14.0",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -30,18 +30,15 @@ members = [
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-sdk/examples/coords-hud",
|
||||
"crates/horizon-mod-sdk/examples/coords-hud-lua",
|
||||
"crates/horizon-mod-sdk/examples/predator-missile",
|
||||
"crates/horizon-mod-sdk/examples/welcome",
|
||||
"crates/horizon-mod-sdk/examples/beacons",
|
||||
"mods/official/fps-overlay",
|
||||
"mods/official/keystrokes",
|
||||
"mods/official/far-view",
|
||||
"mods/official/minimap",
|
||||
"mods/official/zoom",
|
||||
"xtask",
|
||||
"mods/official/recipes",
|
||||
"crates/horizon-mod-lua",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
|
||||
//!
|
||||
//! - `new <path> [--template code|content|lua] [--name "Name"] [--sdk-path <dir>]`:
|
||||
//! a mod from a template: a Rust crate, a content mod (no code), or a Lua mod.
|
||||
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
|
||||
//! a mod from a template: a Rust crate, or a content mod (no code).
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
|
||||
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
@@ -26,8 +26,6 @@ use std::{
|
||||
|
||||
const TARGET: &str = "wasm32-unknown-unknown";
|
||||
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
|
||||
/// The Lua runtime from the same repository, with the template's capability.
|
||||
const LUA_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\", features = [\"chat\"] }";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
|
||||
@@ -44,7 +42,7 @@ fn main() -> ExitCode {
|
||||
Some("test") => test(&args[1..]),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--template code|content|lua] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -85,26 +83,18 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
})
|
||||
.collect();
|
||||
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
|
||||
let (sdk, lua) = match flag(args, "--sdk-path") {
|
||||
let sdk = match flag(args, "--sdk-path") {
|
||||
Some(p) => {
|
||||
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
|
||||
let lua = p.parent().unwrap_or(&p).join("horizon-mod-lua");
|
||||
(
|
||||
format!("{{ path = {:?} }}", p.display().to_string()),
|
||||
format!(
|
||||
"{{ path = {:?}, features = [\"chat\"] }}",
|
||||
lua.display().to_string()
|
||||
),
|
||||
)
|
||||
format!("{{ path = {:?} }}", p.display().to_string())
|
||||
}
|
||||
None => (SDK_GIT.to_string(), LUA_GIT.to_string()),
|
||||
None => SDK_GIT.to_string(),
|
||||
};
|
||||
let fill = |t: &str| {
|
||||
t.replace("{crate}", &id.replace('_', "-"))
|
||||
.replace("{id}", &id)
|
||||
.replace("{name}", &name)
|
||||
.replace("{sdk}", &sdk)
|
||||
.replace("{lua}", &lua)
|
||||
};
|
||||
let code = [
|
||||
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
|
||||
@@ -156,19 +146,10 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
// The code template's mod, written in Lua.
|
||||
let lua_files = [
|
||||
("Cargo.toml", include_str!("../template/lua/Cargo.toml.in")),
|
||||
(MANIFEST, include_str!("../template/lua/horizon-mod.toml")),
|
||||
("src/lib.rs", include_str!("../template/lua/src/lib.rs")),
|
||||
("main.lua", include_str!("../template/lua/main.lua")),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
|
||||
"code" => &code,
|
||||
"content" => &content,
|
||||
"lua" => &lua_files,
|
||||
other => return Err(format!("no template {other}: code, content or lua")),
|
||||
other => return Err(format!("no template {other}: code or content")),
|
||||
};
|
||||
for &(file, text) in files {
|
||||
let file = fill(file);
|
||||
@@ -234,13 +215,6 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let mut cmd = Command::new(cargo);
|
||||
// The Lua runtime's seeds (crates/horizon-mod-lua): getrandom 0.3
|
||||
// takes a custom source only with this cfg.
|
||||
let flags = std::env::var("CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS").unwrap_or_default();
|
||||
cmd.env(
|
||||
"CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS",
|
||||
format!("{flags} --cfg getrandom_backend=\"custom\"").trim(),
|
||||
);
|
||||
cmd.current_dir(root).args([
|
||||
"build",
|
||||
"--lib",
|
||||
@@ -412,26 +386,6 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lua_template_is_a_valid_mod() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-lua-{}", std::process::id()));
|
||||
let path = dir.join("hello-lua");
|
||||
new(&[
|
||||
path.display().to_string(),
|
||||
"--template".into(),
|
||||
"lua".into(),
|
||||
])
|
||||
.unwrap();
|
||||
let manifest =
|
||||
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
|
||||
assert_eq!(manifest.capabilities, [horizon_mods::Capability::Chat]);
|
||||
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
|
||||
assert!(cargo.contains("horizon-mod-lua = ") && cargo.contains(LUA_GIT));
|
||||
let script = std::fs::read_to_string(path.join("main.lua")).unwrap();
|
||||
assert!(script.contains("horizon.command(\"hello\")"));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_content_template_builds_and_checks_with_no_code() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
|
||||
@@ -470,7 +424,7 @@ mod tests {
|
||||
new(&[
|
||||
dir.join("x").display().to_string(),
|
||||
"--template".into(),
|
||||
"python".into()
|
||||
"lua".into()
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "{crate}"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
# The Lua interpreter, built into the mod with main.lua. Its features are the
|
||||
# capabilities horizon-mod.toml lists (game-read, chat, hud, storage): keep
|
||||
# the two lists the same.
|
||||
[dependencies]
|
||||
horizon-mod-lua = {lua}
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
@@ -1,12 +0,0 @@
|
||||
id = "{id}"
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
# The Horizon mod API the Lua runtime targets.
|
||||
api = "0.1"
|
||||
# client: runs in the player's game.
|
||||
side = "client"
|
||||
# What the mod may do, shown to players before they turn it on:
|
||||
# game-read (position, health, time), chat (show messages), hud (draw text).
|
||||
capabilities = ["chat"]
|
||||
@@ -1,12 +0,0 @@
|
||||
-- {name}: a Horizon mod in Lua (https://playhorizon.dev/docs/mod-code).
|
||||
-- Commands come from horizon.command; hooks are global functions:
|
||||
-- on_tick(tick), on_frame(seconds), on_command(name, args), on_chat(text).
|
||||
|
||||
local greeted = 0
|
||||
|
||||
horizon.command("hello")
|
||||
|
||||
function on_command(name, args)
|
||||
greeted = greeted + 1
|
||||
horizon.chat.show(string.format("Hello %s! (%d so far)", args, greeted))
|
||||
end
|
||||
@@ -1,2 +0,0 @@
|
||||
// The mod is main.lua: this builds it into the mod with its interpreter.
|
||||
horizon_mod_lua::lua_mod!("main.lua");
|
||||
@@ -54,7 +54,7 @@ pub fn main() -> Result<()> {
|
||||
match c["axis"].as_i64() {
|
||||
Some(axis @ 0..=2) => {
|
||||
let line = record("electric_spark");
|
||||
particles::along_axis(&mut random, axis as usize, p, |p, v| {
|
||||
particles::along_axis(&mut random, axis as usize, p, (10, 19), |p, v| {
|
||||
lines.push(line(p, v))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,11 +154,11 @@ const MEMORIES: [&str; 7] = [
|
||||
fn variant(m: &Mob, regs: &SpawnRegistries, v: &mut Vec<String>) {
|
||||
use Species as S;
|
||||
let s = m.species();
|
||||
let named = |reg: &finalize::VariantRegistry, i: Option<u8>| {
|
||||
let name = i.map_or("classic", |i| {
|
||||
let named = |reg: &finalize::VariantRegistry, i: u8| {
|
||||
format!(
|
||||
"minecraft:{}",
|
||||
reg.names.get(i as usize).map_or("?", |n| n.as_str())
|
||||
});
|
||||
format!("minecraft:{name}")
|
||||
)
|
||||
};
|
||||
let var = format!("minecraft:{}", finalize::variant_name(s, m.extra.variant));
|
||||
match s {
|
||||
|
||||
@@ -75,6 +75,10 @@ pub fn main() -> Result<()> {
|
||||
registry: assets.registry,
|
||||
raining_at: &|_| false,
|
||||
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
|
||||
model_offset: &|_, _, _| None,
|
||||
gateway_particles: &|_, _| 0,
|
||||
thundering: false,
|
||||
ultrawarm: false,
|
||||
};
|
||||
engine.seed_randoms(1057, 0, 0);
|
||||
engine.requested = Some(Vec::new());
|
||||
|
||||
@@ -580,7 +580,7 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
|
||||
match e {
|
||||
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
|
||||
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
|
||||
MobEvent::Explode(at, radius) => {
|
||||
MobEvent::Explode(at, radius, _) => {
|
||||
let d = Vec3::new(at[0], at[1], at[2])
|
||||
.distance_squared(p.position)
|
||||
.sqrt();
|
||||
@@ -1050,6 +1050,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
off_hand: None,
|
||||
head: None,
|
||||
armor_cover: 0.0,
|
||||
invisible: false,
|
||||
spectator: false,
|
||||
gold_armor: false,
|
||||
hurt_by: None,
|
||||
@@ -1061,6 +1062,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
uuid: PLAYER_UUID,
|
||||
allies: Default::default(),
|
||||
remote: false,
|
||||
passenger: false,
|
||||
}
|
||||
});
|
||||
let mut scratch = LegacyRandom::new(0);
|
||||
@@ -1098,6 +1100,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
block_tags: &items.block_tags,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
mob_drops: true,
|
||||
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
|
||||
end: false,
|
||||
world_seed: 0,
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
//!
|
||||
//! usage: recipe_book_fixture RESOURCES CASES OUTPUT
|
||||
use horizon_client::recipe_book::{BookType, RecipeIndex, Settings};
|
||||
use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
packets::recipes::RecipeBookChangeSettings,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::BTreeSet, fs, path::Path};
|
||||
@@ -97,23 +93,8 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
_ => BookType::Smoker,
|
||||
}
|
||||
.index();
|
||||
let packet = RecipeBookChangeSettings {
|
||||
book_type: i as i32,
|
||||
open: op["open"].as_bool().unwrap_or(false),
|
||||
filtering: op["filtering"].as_bool().unwrap_or(false),
|
||||
};
|
||||
let mut wire = Writer::new();
|
||||
RecipeBookChangeSettings::write(&packet, &mut wire);
|
||||
extra = format!(
|
||||
" packet={}",
|
||||
wire.data
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
);
|
||||
let decoded = RecipeBookChangeSettings::read(&mut Reader::new(&wire.data))?;
|
||||
book.open[decoded.book_type as usize] = decoded.open;
|
||||
book.filtering[decoded.book_type as usize] = decoded.filtering;
|
||||
book.open[i] = op["open"].as_bool().unwrap_or(false);
|
||||
book.filtering[i] = op["filtering"].as_bool().unwrap_or(false);
|
||||
}
|
||||
"save" => {
|
||||
let tag = book.write();
|
||||
|
||||
@@ -208,7 +208,7 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
|
||||
)),
|
||||
MotionEvent::Emission {
|
||||
state,
|
||||
in_water: true,
|
||||
muffled: true,
|
||||
..
|
||||
} => Some(format!("muffled-step {}", state.0)),
|
||||
MotionEvent::BubbleColumnParticle { bubble, position } => Some(format!(
|
||||
@@ -353,6 +353,7 @@ pub fn main() -> Result<()> {
|
||||
horizon_world::food::Difficulty::Hard,
|
||||
][op["difficulty"].as_u64().unwrap() as usize],
|
||||
natural_regeneration: op["regenerate"].as_bool().unwrap(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
"food" => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! conditions advancements use).
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
@@ -437,6 +437,8 @@ pub struct EntityView {
|
||||
/// mainhand, offhand, head, chest, legs, feet, body.
|
||||
pub equipment: [Option<ItemView>; 7],
|
||||
pub vehicle: Option<Box<EntityView>>,
|
||||
/// Player: what it looks at within 100 blocks (`PlayerPredicate.lookingAt`).
|
||||
pub looking_at: Option<Box<EntityView>>,
|
||||
pub effects: Vec<(String, i32, i32)>,
|
||||
/// Player: game mode name.
|
||||
pub gamemode: Option<String>,
|
||||
@@ -458,7 +460,7 @@ fn range_f(v: &Value, x: f64) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn range_i(v: &Value, x: i64) -> bool {
|
||||
pub fn range_i(v: &Value, x: i64) -> bool {
|
||||
range_f(v, x as f64)
|
||||
}
|
||||
/// A registry id, `#tag`, or list matched against one id.
|
||||
@@ -473,6 +475,51 @@ pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// `PotionsPredicate`: the potion in a `HolderSet`, plus a `CollectionPredicate`
|
||||
/// over the potion's effects (`contains`, `count`, `size`).
|
||||
pub fn potions(v: &Value, potion: Option<&str>, w: &dyn World) -> bool {
|
||||
if v.is_null() {
|
||||
return true;
|
||||
}
|
||||
if !v["potions"].is_null() && !potion.is_some_and(|p| id_matches(&v["potions"], "potion", p, w))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let c = &v["effects"];
|
||||
if c.is_null() {
|
||||
return true;
|
||||
}
|
||||
let effects: Vec<(String, i32, i32)> = potion
|
||||
.and_then(crate::brewing::potion)
|
||||
.map(|d| {
|
||||
d.effects
|
||||
.iter()
|
||||
.map(|(id, dur, amp)| (format!("minecraft:{id}"), *amp, *dur))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// `MobEffectsPredicate.test`: the one listed effect, amplifier and duration.
|
||||
let test = |pred: &Value, e: &(String, i32, i32)| {
|
||||
pred.as_object().is_some_and(|m| {
|
||||
m.get(&e.0).is_some_and(|want| {
|
||||
range_i(&want["amplifier"], i64::from(e.1))
|
||||
&& range_i(&want["duration"], i64::from(e.2))
|
||||
&& want["ambient"].as_bool().is_none_or(|b| !b)
|
||||
&& want["visible"].as_bool().is_none_or(|b| b)
|
||||
})
|
||||
})
|
||||
};
|
||||
c["contains"]
|
||||
.as_array()
|
||||
.is_none_or(|l| l.iter().all(|p| effects.iter().any(|e| test(p, e))))
|
||||
&& c["count"].as_array().is_none_or(|l| {
|
||||
l.iter().all(|entry| {
|
||||
let n = effects.iter().filter(|e| test(&entry["test"], e)).count();
|
||||
range_i(&entry["count"], n as i64)
|
||||
})
|
||||
})
|
||||
&& range_i(&c["size"], effects.len() as i64)
|
||||
}
|
||||
/// `ItemPredicate`.
|
||||
pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
|
||||
if v.is_null() {
|
||||
@@ -614,6 +661,18 @@ fn components(expected: &Value, have: &[(String, String)]) -> bool {
|
||||
})
|
||||
})
|
||||
}
|
||||
/// `DistancePredicate.matches` between two points.
|
||||
pub fn distance(v: &Value, a: [f64; 3], b: [f64; 3]) -> bool {
|
||||
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
range_f(&v["x"], d[0].abs())
|
||||
&& range_f(&v["y"], d[1].abs())
|
||||
&& range_f(&v["z"], d[2].abs())
|
||||
&& range_f(&v["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt())
|
||||
&& range_f(
|
||||
&v["absolute"],
|
||||
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
|
||||
)
|
||||
}
|
||||
/// `EntityPredicate` (26.x keyed sub-predicates).
|
||||
pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool {
|
||||
if v.is_null() {
|
||||
@@ -629,21 +688,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
"minecraft:stepping_on" => {
|
||||
location(p, [e.position[0], e.position[1] - 0.5, e.position[2]], w)
|
||||
}
|
||||
"minecraft:distance" | "distance" => {
|
||||
let d = [
|
||||
e.position[0] - origin[0],
|
||||
e.position[1] - origin[1],
|
||||
e.position[2] - origin[2],
|
||||
];
|
||||
range_f(&p["x"], d[0].abs())
|
||||
&& range_f(&p["y"], d[1].abs())
|
||||
&& range_f(&p["z"], d[2].abs())
|
||||
&& range_f(&p["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt())
|
||||
&& range_f(
|
||||
&p["absolute"],
|
||||
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
|
||||
)
|
||||
}
|
||||
"minecraft:distance" | "distance" => distance(p, e.position, origin),
|
||||
"minecraft:flags" | "flags" => {
|
||||
let f = |k: &str, have: bool| p[k].as_bool().is_none_or(|want| want == have);
|
||||
f("is_on_fire", e.on_fire)
|
||||
@@ -678,11 +723,16 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
})
|
||||
})
|
||||
}),
|
||||
"minecraft:type_specific/player" => p["gamemode"].as_array().is_none_or(|modes| {
|
||||
e.gamemode
|
||||
.as_deref()
|
||||
.is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g)))
|
||||
}),
|
||||
"minecraft:type_specific/player" => {
|
||||
p["gamemode"].as_array().is_none_or(|modes| {
|
||||
e.gamemode
|
||||
.as_deref()
|
||||
.is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g)))
|
||||
}) && (p["looking_at"].is_null()
|
||||
|| e.looking_at
|
||||
.as_ref()
|
||||
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
|
||||
}
|
||||
"minecraft:type_specific/lightning" => {
|
||||
range_i(&p["blocks_set_on_fire"], 0)
|
||||
&& (p["entity_struck"].is_null()
|
||||
@@ -703,6 +753,87 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `KilledByArrowTrigger.TriggerInstance.matches`: the weapon, each listed
|
||||
/// victim condition matched by a distinct victim, and the count of entity types.
|
||||
pub fn killed_by_arrow(
|
||||
c: &Value,
|
||||
me: &EntityView,
|
||||
victims: &[EntityView],
|
||||
weapon: Option<&ItemView>,
|
||||
w: &dyn World,
|
||||
) -> bool {
|
||||
fn ctx<'a>(e: &'a EntityView, me: &EntityView) -> Entities<'a> {
|
||||
Entities {
|
||||
this: Some(e),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
}
|
||||
}
|
||||
if !conditions(&c["player"], &ctx(me, me), w) {
|
||||
return false;
|
||||
}
|
||||
if !c["fired_from_weapon"].is_null()
|
||||
&& !weapon.is_some_and(|it| item(&c["fired_from_weapon"], it, w))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut left: Vec<&EntityView> = victims.iter().collect();
|
||||
for want in c["victims"].as_array().into_iter().flatten() {
|
||||
let Some(i) = left.iter().position(|e| conditions(want, &ctx(e, me), w)) else {
|
||||
return false;
|
||||
};
|
||||
left.remove(i);
|
||||
}
|
||||
let kinds: BTreeSet<&str> = victims.iter().map(|e| e.kind.as_str()).collect();
|
||||
range_i(&c["unique_entity_types"], kinds.len() as i64)
|
||||
}
|
||||
/// A damage event as `DamagePredicate` sees it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DamageView {
|
||||
/// The damage type id (`minecraft:arrow`).
|
||||
pub kind: String,
|
||||
/// `DamageSource.getDirectEntity` and `getEntity`.
|
||||
pub direct: Option<EntityView>,
|
||||
pub source: Option<EntityView>,
|
||||
/// `DamageSource.isDirect`: the direct entity is the causing one.
|
||||
pub is_direct: bool,
|
||||
/// The damage before and after the shield took its share.
|
||||
pub original: f32,
|
||||
pub actual: f32,
|
||||
pub blocked: bool,
|
||||
}
|
||||
/// `DamagePredicate`, with its `DamageSourcePredicate` as `type`.
|
||||
pub fn damage(v: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> bool {
|
||||
if v.is_null() {
|
||||
return true;
|
||||
}
|
||||
let by = |key: &str, e: &Option<EntityView>| {
|
||||
v[key].is_null() || e.as_ref().is_some_and(|e| entity(&v[key], e, w, origin))
|
||||
};
|
||||
if !range_f(&v["dealt"], f64::from(d.original))
|
||||
|| !range_f(&v["taken"], f64::from(d.actual))
|
||||
|| !by("source_entity", &d.source)
|
||||
|| v["blocked"].as_bool().is_some_and(|b| b != d.blocked)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let ty = &v["type"];
|
||||
let tags_ok = ty["tags"].as_array().into_iter().flatten().all(|t| {
|
||||
let tag = t["id"].as_str().unwrap_or("");
|
||||
let tag = tag.strip_prefix('#').unwrap_or(tag);
|
||||
w.in_tag("damage_type", tag, &d.kind) == t["expected"].as_bool().unwrap_or(true)
|
||||
});
|
||||
let direct_ok = ty["direct_entity"].is_null()
|
||||
|| d.direct
|
||||
.as_ref()
|
||||
.is_some_and(|e| entity(&ty["direct_entity"], e, w, origin));
|
||||
let source_ok = ty["source_entity"].is_null()
|
||||
|| d.source
|
||||
.as_ref()
|
||||
.is_some_and(|e| entity(&ty["source_entity"], e, w, origin));
|
||||
tags_ok && direct_ok && source_ok && ty["is_direct"].as_bool().is_none_or(|b| b == d.is_direct)
|
||||
}
|
||||
/// The entities a loot-style condition list may refer to.
|
||||
pub struct Entities<'a> {
|
||||
pub this: Option<&'a EntityView>,
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::{
|
||||
blocks::{BlockFacts, Fluid},
|
||||
command::args::GameMode,
|
||||
items::ItemStack,
|
||||
particles::{ParticleEngine, ParticleOptions, ParticleType as T},
|
||||
particles::{ParticleEngine, ParticleOptions, ParticleType as T, along_axis},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -73,6 +73,22 @@ enum Animate {
|
||||
waterlogged: bool,
|
||||
},
|
||||
EndPortal,
|
||||
/// A powered lever: its facing and the direction it is attached toward.
|
||||
Lever {
|
||||
facing: [i32; 3],
|
||||
connected: [i32; 3],
|
||||
},
|
||||
/// A powered repeater.
|
||||
Repeater {
|
||||
facing: [i32; 3],
|
||||
delay: i32,
|
||||
},
|
||||
/// A free-hanging stalactite tip that is not waterlogged.
|
||||
PointedDripstone,
|
||||
WitherRose,
|
||||
EndGateway,
|
||||
/// A lightning rod, by the axis it points along (0, 1, 2 for X, Y, Z).
|
||||
LightningRod(usize),
|
||||
}
|
||||
fn step(facing: &str) -> [i32; 3] {
|
||||
match facing {
|
||||
@@ -87,6 +103,7 @@ fn step(facing: &str) -> [i32; 3] {
|
||||
fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
let lit = prop("lit").as_deref() == Some("true");
|
||||
let powered = prop("powered").as_deref() == Some("true");
|
||||
let facing = || step(&prop("facing").unwrap_or_default());
|
||||
match name {
|
||||
"torch" => Animate::Torch(T::Flame),
|
||||
@@ -167,6 +184,38 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
waterlogged: prop("waterlogged").as_deref() == Some("true"),
|
||||
},
|
||||
"end_portal" => Animate::EndPortal,
|
||||
"lever" if powered => Animate::Lever {
|
||||
facing: facing(),
|
||||
// FaceAttachedHorizontalDirectionalBlock.getConnectedDirection.
|
||||
connected: match prop("face").as_deref() {
|
||||
Some("ceiling") => [0, -1, 0],
|
||||
Some("floor") => [0, 1, 0],
|
||||
_ => facing(),
|
||||
},
|
||||
},
|
||||
"repeater" if powered => Animate::Repeater {
|
||||
facing: facing(),
|
||||
delay: prop("delay").and_then(|d| d.parse().ok()).unwrap_or(1),
|
||||
},
|
||||
"pointed_dripstone"
|
||||
if prop("vertical_direction").as_deref() == Some("down")
|
||||
&& prop("thickness").as_deref() == Some("tip")
|
||||
&& prop("waterlogged").as_deref() != Some("true") =>
|
||||
{
|
||||
Animate::PointedDripstone
|
||||
}
|
||||
"wither_rose" => Animate::WitherRose,
|
||||
"end_gateway" => Animate::EndGateway,
|
||||
_ if name == "lightning_rod" || name.ends_with("_lightning_rod") => {
|
||||
let [dx, dy, _] = facing();
|
||||
Animate::LightningRod(if dx != 0 {
|
||||
0
|
||||
} else if dy != 0 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
})
|
||||
}
|
||||
"sculk_sensor" | "calibrated_sculk_sensor"
|
||||
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
|
||||
{
|
||||
@@ -237,6 +286,8 @@ pub struct AnimateTables {
|
||||
burnable: Vec<bool>,
|
||||
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
|
||||
ambient: Vec<Vec<(T, f32)>>,
|
||||
/// Per state: 1 for a pointed dripstone, 2 for one whose tip points down.
|
||||
dripstone: Vec<u8>,
|
||||
}
|
||||
impl AnimateTables {
|
||||
pub fn load(root: &Path, registry: &BlockRegistry, biomes: &Biomes) -> crate::Result<Self> {
|
||||
@@ -302,8 +353,25 @@ impl AnimateTables {
|
||||
}
|
||||
}
|
||||
}
|
||||
let dripstone = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
registry.state(BlockStateId(id)).map_or(0, |s| {
|
||||
let name = s.name.strip_prefix("minecraft:").unwrap_or(&s.name);
|
||||
if name != "pointed_dripstone" {
|
||||
0
|
||||
} else if s.properties.get("vertical_direction").map(String::as_str)
|
||||
== Some("down")
|
||||
{
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Self {
|
||||
kinds,
|
||||
dripstone,
|
||||
impermeable: by_state("minecraft:impermeable"),
|
||||
power_provider: by_state("minecraft:enchantment_power_provider"),
|
||||
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
|
||||
@@ -335,6 +403,14 @@ pub struct AnimateContext<'a> {
|
||||
pub raining_at: &'a dyn Fn([i32; 3]) -> bool,
|
||||
/// Creative main-hand marker block and a resolver for each placed state's sprite.
|
||||
pub marker: Option<(&'a str, &'a dyn Fn(BlockStateId) -> Option<ParticleOptions>)>,
|
||||
/// `BlockState.getOffset` of a state at (x, z), for blocks with a random model offset.
|
||||
pub model_offset: &'a dyn Fn(BlockStateId, i32, i32) -> Option<[f64; 3]>,
|
||||
/// `TheEndGatewayBlockEntity.getParticleAmount` of the gateway state at a position.
|
||||
pub gateway_particles: &'a dyn Fn(BlockStateId, [i32; 3]) -> usize,
|
||||
/// `Level.isThundering`.
|
||||
pub thundering: bool,
|
||||
/// The dimension has `water_evaporates` and drips lava from dripstone (the Nether).
|
||||
pub ultrawarm: bool,
|
||||
}
|
||||
|
||||
/// `ClientLevel.getMarkerParticleTarget`: only the selected main-hand item counts.
|
||||
@@ -383,6 +459,9 @@ impl AnimateContext<'_> {
|
||||
],
|
||||
))
|
||||
}
|
||||
fn offset_of(&self, state: BlockStateId, p: [i32; 3]) -> [f64; 3] {
|
||||
(self.model_offset)(state, p[0], p[2]).unwrap_or([0.0; 3])
|
||||
}
|
||||
fn foliage(&self, p: [i32; 3]) -> u32 {
|
||||
let biome = self
|
||||
.world
|
||||
@@ -1027,6 +1106,99 @@ fn block_animate_tick(
|
||||
let p = [fx + r.next_double(), fy + 0.8, fz + r.next_double()];
|
||||
add(engine, T::Smoke, p, [0.0; 3]);
|
||||
}
|
||||
Animate::Lever { facing, connected } => {
|
||||
// LeverBlock.animateTick: a powered lever puffs red dust a quarter of the time.
|
||||
if engine.random().next_float() < 0.25 {
|
||||
// LeverBlock.makeParticle with scale 0.5.
|
||||
let at = |base: f64, i: usize| {
|
||||
base + 0.5 + 0.1 * f64::from(-facing[i]) + 0.2 * f64::from(-connected[i])
|
||||
};
|
||||
let p = [at(fx, 0), at(fy, 1), at(fz, 2)];
|
||||
engine.add(&ParticleOptions::dust([1.0, 0.0, 0.0], 0.5), p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
Animate::Repeater { facing, delay } => {
|
||||
// RepeaterBlock.animateTick: dust at one of the torches.
|
||||
let r = engine.random();
|
||||
let x = fx + 0.5 + (r.next_double() - 0.5) * 0.2;
|
||||
let y = fy + 0.4 + (r.next_double() - 0.5) * 0.2;
|
||||
let z = fz + 0.5 + (r.next_double() - 0.5) * 0.2;
|
||||
let mut offset = -5.0f32;
|
||||
if r.next_boolean() {
|
||||
offset = (delay * 2 - 1) as f32;
|
||||
}
|
||||
offset /= 16.0;
|
||||
let xo = f64::from(offset * facing[0] as f32);
|
||||
let zo = f64::from(offset * facing[2] as f32);
|
||||
redstone(engine, [x + xo, y, z + zo]);
|
||||
}
|
||||
Animate::PointedDripstone => dripstone_drip(tables, engine, ctx, state, pos),
|
||||
Animate::WitherRose => {
|
||||
// WitherRoseBlock.animateTick: smoke puffs at the centre of the shape.
|
||||
let center = ctx.shape_y(pos).map_or([0.5; 2], |s| {
|
||||
[(s.2[0] + s.2[1]) / 2.0, (s.2[2] + s.2[3]) / 2.0]
|
||||
});
|
||||
let offset = ctx.offset_of(state, pos);
|
||||
let x = fx + center[0] + offset[0];
|
||||
let z = fz + center[1] + offset[2];
|
||||
for _ in 0..3 {
|
||||
let r = engine.random();
|
||||
if r.next_boolean() {
|
||||
let p = [
|
||||
x + r.next_double() / 5.0,
|
||||
fy + (0.5 - r.next_double()),
|
||||
z + r.next_double() / 5.0,
|
||||
];
|
||||
add(engine, T::Smoke, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::EndGateway => {
|
||||
// EndGatewayBlock.animateTick: portal particles, one for each visible face.
|
||||
for _ in 0..(ctx.gateway_particles)(state, pos) {
|
||||
let r = engine.random();
|
||||
let mut p = [
|
||||
fx + r.next_double(),
|
||||
fy + r.next_double(),
|
||||
fz + r.next_double(),
|
||||
];
|
||||
let mut v = [
|
||||
(r.next_double() - 0.5) * 0.5,
|
||||
(r.next_double() - 0.5) * 0.5,
|
||||
(r.next_double() - 0.5) * 0.5,
|
||||
];
|
||||
let flip = next_int(r, 2) * 2 - 1;
|
||||
if r.next_boolean() {
|
||||
p[2] = fz + 0.5 + 0.25 * f64::from(flip);
|
||||
v[2] = f64::from(r.next_float() * 2.0 * flip as f32);
|
||||
} else {
|
||||
p[0] = fx + 0.5 + 0.25 * f64::from(flip);
|
||||
v[0] = f64::from(r.next_float() * 2.0 * flip as f32);
|
||||
}
|
||||
add(engine, T::Portal, p, v);
|
||||
}
|
||||
}
|
||||
Animate::LightningRod(axis) => {
|
||||
// LightningRodBlock.animateTick: in a thunderstorm, sparks along the
|
||||
// topmost rod, more often as the game time within 200 ticks grows.
|
||||
if ctx.thundering
|
||||
&& i64::from(next_int(engine.level_random(), 200)) <= ctx.world.game_time % 200
|
||||
&& pos[1]
|
||||
== ctx
|
||||
.world
|
||||
.world_surface(pos[0], pos[2], |id| ctx.facts.is_air(id))
|
||||
- 1
|
||||
{
|
||||
let mut sparks = Vec::new();
|
||||
along_axis(engine.level_random(), axis, pos, (1, 2), |p, v| {
|
||||
sparks.push((p, v));
|
||||
});
|
||||
let options = ParticleOptions::simple(T::ElectricSpark);
|
||||
for (p, v) in sparks {
|
||||
engine.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::EnderChest => {
|
||||
for _ in 0..3 {
|
||||
let r = engine.random();
|
||||
@@ -1282,6 +1454,68 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PointedDripstoneBlock.animateTick`: a free-hanging stalactite tip drips the
|
||||
/// fluid above the root of its stalactite.
|
||||
fn dripstone_drip(
|
||||
tables: &AnimateTables,
|
||||
engine: &mut ParticleEngine,
|
||||
ctx: &AnimateContext,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
) {
|
||||
let roll = engine.random().next_float();
|
||||
if roll > 0.12 {
|
||||
return;
|
||||
}
|
||||
// getFluidAboveStalactite: findRootBlock climbs at most ten blocks of
|
||||
// stalactite and stops at the first block that is not pointed dripstone.
|
||||
let mut root = None;
|
||||
for step in 1..11 {
|
||||
let p = [pos[0], pos[1] + step, pos[2]];
|
||||
match tables
|
||||
.dripstone
|
||||
.get(ctx.state(p).0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
{
|
||||
0 => {
|
||||
root = Some(p);
|
||||
break;
|
||||
}
|
||||
2 => {}
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
let Some(root) = root else {
|
||||
return;
|
||||
};
|
||||
let above = ctx.facts.get(ctx.state([root[0], root[1] + 1, root[2]]));
|
||||
// Mud above the root counts as a water source unless water evaporates here.
|
||||
let (fluid, source) = if &*above.name == "minecraft:mud" && !ctx.ultrawarm {
|
||||
(Fluid::Water, true)
|
||||
} else {
|
||||
(above.fluid, above.fluid_level >= 8 && !above.fluid_falling)
|
||||
};
|
||||
// canFillCauldron: only a water or lava source passes below a 2% roll.
|
||||
if roll >= 0.02 && !(fluid != Fluid::None && source) {
|
||||
return;
|
||||
}
|
||||
// getDripParticle: no fluid drips what the dimension's default says.
|
||||
let lava = fluid == Fluid::Lava || (fluid == Fluid::None && ctx.ultrawarm);
|
||||
let ty = if lava {
|
||||
T::DrippingDripstoneLava
|
||||
} else {
|
||||
T::DrippingDripstoneWater
|
||||
};
|
||||
let offset = ctx.offset_of(state, pos);
|
||||
// STALACTITE_DRIP_START_PIXEL is the tip's lowest point, 5/16.
|
||||
let p = [
|
||||
f64::from(pos[0]) + 0.5 + offset[0],
|
||||
f64::from(pos[1]) + 0.3125 - 0.0625,
|
||||
f64::from(pos[2]) + 0.5 + offset[2],
|
||||
];
|
||||
add(engine, ty, p, [0.0; 3]);
|
||||
}
|
||||
/// `AbstractCandleBlock.addParticlesAndSound`.
|
||||
fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
|
||||
let r = engine.random();
|
||||
|
||||
@@ -159,6 +159,8 @@ pub struct ArrowState {
|
||||
pub pickup: Pickup,
|
||||
/// `pickupItemStack`: what picking the arrow up gives.
|
||||
pub pickup_item: crate::items::ItemStack,
|
||||
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
|
||||
pub fired_from_weapon: Option<crate::items::ItemStack>,
|
||||
pub no_physics: bool,
|
||||
/// `AbstractArrow.getPierceLevel`.
|
||||
pub pierce: u8,
|
||||
@@ -208,6 +210,7 @@ impl ArrowState {
|
||||
last_state: None,
|
||||
pickup: Pickup::Disallowed,
|
||||
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
|
||||
fired_from_weapon: None,
|
||||
no_physics: false,
|
||||
pierce: 0,
|
||||
pierced: Vec::new(),
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
//! (`entity-models.json`); transforms, animation and textures follow each
|
||||
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
|
||||
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
|
||||
use crate::{hud::Rect, mesher::Vertex, world::World};
|
||||
use crate::{
|
||||
hud::Rect,
|
||||
mesher::{CULL_BACK, Vertex},
|
||||
world::World,
|
||||
};
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
use horizon_core::geometry::{
|
||||
Direction,
|
||||
@@ -932,6 +936,30 @@ impl BlockEntityRenderer {
|
||||
let lights = diffuse_lights(scene.nether);
|
||||
self.render_one(scene, pos, kind, base, packed, lights, false, false, out);
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity.shouldRenderFace` for each face of the gateway
|
||||
/// at `pos`: the faces its portal is drawn on.
|
||||
pub fn gateway_faces(
|
||||
&self,
|
||||
world: &World,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
) -> Vec<Direction> {
|
||||
Direction::ALL
|
||||
.into_iter()
|
||||
.filter(|&d| {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = world.block(pos[0] + dx, pos[1] + dy, pos[2] + dz);
|
||||
self.visibility.as_ref().is_none_or(|v| {
|
||||
v.lock()
|
||||
.map(|mut v| {
|
||||
v.should_render_face(state, neighbor, d, true)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Whether this block state has a block-entity renderer here.
|
||||
pub fn renders(&self, state: u32) -> bool {
|
||||
!matches!(self.kind(state), Kind::None)
|
||||
@@ -1385,6 +1413,7 @@ impl BlockEntityRenderer {
|
||||
m *= around(Mat4::from_rotation_y(turn * decay), pivot);
|
||||
}
|
||||
}
|
||||
let first = out.len();
|
||||
if let Some(paint) =
|
||||
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
|
||||
{
|
||||
@@ -1402,6 +1431,11 @@ impl BlockEntityRenderer {
|
||||
draw_part(part, side, m, &none, paint, out);
|
||||
}
|
||||
}
|
||||
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
|
||||
// back faces: the flat `top` and `bottom` planes show one side each.
|
||||
for vertex in &mut out[first..] {
|
||||
vertex.normal = CULL_BACK;
|
||||
}
|
||||
}
|
||||
Kind::EndPortal => {
|
||||
// TheEndPortalRenderer.TRANSFORMATION: y from 6/16 to 12/16; only the
|
||||
@@ -1432,22 +1466,8 @@ impl BlockEntityRenderer {
|
||||
out,
|
||||
);
|
||||
}
|
||||
// AbstractEndPortalRenderer.extractRenderState: TheEndGatewayBlockEntity.shouldRenderFace.
|
||||
let faces: Vec<Direction> = Direction::ALL
|
||||
.into_iter()
|
||||
.filter(|&d| {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = scene.world.block(pos[0] + dx, pos[1] + dy, pos[2] + dz);
|
||||
self.visibility.as_ref().is_none_or(|v| {
|
||||
v.lock()
|
||||
.map(|mut v| {
|
||||
v.should_render_face(BlockStateId(*state), neighbor, d, true)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// AbstractEndPortalRenderer.extractRenderState.
|
||||
let faces = self.gateway_faces(scene.world, BlockStateId(*state), pos);
|
||||
portal_cube(base, &faces, END_GATEWAY_LAYER, out);
|
||||
}
|
||||
Kind::Conduit => {
|
||||
|
||||
@@ -40,6 +40,8 @@ pub struct Interaction {
|
||||
/// The cauldron's new state.
|
||||
pub block: Option<BlockState>,
|
||||
pub sound: Option<&'static str>,
|
||||
/// The custom statistic the player earns (`use_cauldron`, ...).
|
||||
pub stat: Option<&'static str>,
|
||||
}
|
||||
|
||||
fn state(name: &str, level: Option<u8>) -> BlockState {
|
||||
@@ -57,6 +59,7 @@ fn pass() -> Interaction {
|
||||
outcome: Outcome::Pass,
|
||||
block: None,
|
||||
sound: None,
|
||||
stat: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,11 +120,12 @@ fn lowered(cauldron: &BlockState) -> BlockState {
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(block: BlockState, sound: Option<&'static str>) -> Interaction {
|
||||
fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -> Interaction {
|
||||
Interaction {
|
||||
outcome: Outcome::Success,
|
||||
block: Some(block),
|
||||
sound,
|
||||
stat: Some(stat),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,18 +183,22 @@ pub fn interact(
|
||||
stack.data_mut().dyed_color = None;
|
||||
stack.tidy();
|
||||
}
|
||||
return changed(lowered(cauldron), None);
|
||||
return changed(lowered(cauldron), None, "clean_armor");
|
||||
}
|
||||
let fill_bucket = |player: &mut dyn Player, new: &str, can_fill: bool, sound| {
|
||||
if !can_fill {
|
||||
return pass();
|
||||
}
|
||||
filled(player, ItemStack::new(new, 1), true);
|
||||
changed(state("minecraft:cauldron", None), Some(sound))
|
||||
changed(
|
||||
state("minecraft:cauldron", None),
|
||||
Some(sound),
|
||||
"use_cauldron",
|
||||
)
|
||||
};
|
||||
let empty_bucket = |player: &mut dyn Player, block: BlockState, sound| {
|
||||
filled(player, ItemStack::new("minecraft:bucket", 1), true);
|
||||
changed(block, Some(sound))
|
||||
changed(block, Some(sound), "fill_cauldron")
|
||||
};
|
||||
match item {
|
||||
"minecraft:lava_bucket" => {
|
||||
@@ -199,6 +207,7 @@ pub fn interact(
|
||||
outcome: Outcome::Consume,
|
||||
block: None,
|
||||
sound: None,
|
||||
stat: None,
|
||||
}
|
||||
} else {
|
||||
empty_bucket(
|
||||
@@ -221,6 +230,7 @@ pub fn interact(
|
||||
outcome: Outcome::Consume,
|
||||
block: None,
|
||||
sound: None,
|
||||
stat: None,
|
||||
}
|
||||
} else {
|
||||
empty_bucket(
|
||||
@@ -241,6 +251,7 @@ pub fn interact(
|
||||
changed(
|
||||
state("minecraft:water_cauldron", Some(1)),
|
||||
Some("minecraft:item.bottle.empty"),
|
||||
"use_cauldron",
|
||||
)
|
||||
}
|
||||
("water", "minecraft:bucket") => fill_bucket(
|
||||
@@ -255,7 +266,11 @@ pub fn interact(
|
||||
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"),
|
||||
true,
|
||||
);
|
||||
changed(lowered(cauldron), Some("minecraft:item.bottle.fill"))
|
||||
changed(
|
||||
lowered(cauldron),
|
||||
Some("minecraft:item.bottle.fill"),
|
||||
"use_cauldron",
|
||||
)
|
||||
}
|
||||
("water", "minecraft:potion") => {
|
||||
if level == 3 || !water_potion {
|
||||
@@ -266,7 +281,7 @@ pub fn interact(
|
||||
// BlockState.cycle on a 1..=3 property
|
||||
next.properties
|
||||
.insert("level".into(), (level % 3 + 1).to_string());
|
||||
changed(next, Some("minecraft:item.bottle.empty"))
|
||||
changed(next, Some("minecraft:item.bottle.empty"), "use_cauldron")
|
||||
}
|
||||
("water", name)
|
||||
if name
|
||||
@@ -283,7 +298,7 @@ pub fn interact(
|
||||
cleaned.data_mut().banner_patterns.pop();
|
||||
cleaned.tidy();
|
||||
filled(player, cleaned, false);
|
||||
changed(lowered(cauldron), None)
|
||||
changed(lowered(cauldron), None, "clean_banner")
|
||||
}
|
||||
("water", name)
|
||||
if name
|
||||
@@ -292,7 +307,7 @@ pub fn interact(
|
||||
.is_some_and(|c| COLORS.contains(&c)) =>
|
||||
{
|
||||
filled(player, held.transmute("minecraft:shulker_box", 1), false);
|
||||
changed(lowered(cauldron), None)
|
||||
changed(lowered(cauldron), None, "clean_shulker_box")
|
||||
}
|
||||
("lava", "minecraft:bucket") => fill_bucket(
|
||||
player,
|
||||
|
||||
@@ -76,11 +76,14 @@ impl<'a> IntoIterator for &'a ChunkMap {
|
||||
}
|
||||
}
|
||||
impl crate::light::LightWorld for ChunkMap {
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<crate::chunk::Section>> {
|
||||
self.columns.section(cx, s, cz)
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
self.columns.state(x, y, z)
|
||||
}
|
||||
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: crate::chunk::Light) {
|
||||
self.columns.set_section_light(cx, s, cz, light);
|
||||
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8> {
|
||||
self.columns.light(x, y, z)
|
||||
}
|
||||
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8) {
|
||||
self.columns.set_light(x, y, z, packed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,13 +106,7 @@ mod tests {
|
||||
for column in map.values_mut() {
|
||||
Arc::make_mut(column).light_loaded = true;
|
||||
}
|
||||
crate::light::LightWorld::set_section_light(
|
||||
&mut map,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
crate::chunk::Light::Uniform(0),
|
||||
);
|
||||
crate::light::LightWorld::set_light(&mut map, 0, 0, 0, 0);
|
||||
assert_eq!(inserted, map.membership_revision());
|
||||
map.remove(&(0, 0));
|
||||
map.insert((1, 0), chunk);
|
||||
|
||||
@@ -728,6 +728,18 @@ impl Furnace {
|
||||
.recipes_used
|
||||
.entry(recipe_used_key(&recipe.id))
|
||||
.or_insert(0) += 1;
|
||||
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
|
||||
// the bucket in the fuel slot.
|
||||
if self.items[0]
|
||||
.as_ref()
|
||||
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
|
||||
&& self.items[1]
|
||||
.as_ref()
|
||||
.is_some_and(|f| &*f.item == "minecraft:bucket")
|
||||
{
|
||||
self.items[1] =
|
||||
Some(ItemStack::new("minecraft:water_bucket", 1));
|
||||
}
|
||||
if let Some(i) = self.items[0].as_mut() {
|
||||
i.count -= 1;
|
||||
if i.count == 0 {
|
||||
|
||||
@@ -129,15 +129,16 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `InstantenousMobEffect.applyInstantenousEffect` (potions apply these directly).
|
||||
/// `HealOrHarmMobEffect.applyInstantaneousEffect` (potions apply these directly;
|
||||
/// the amount rounds to the nearest whole point).
|
||||
pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
|
||||
match id {
|
||||
"instant_health" => Some(EffectAction::Heal(
|
||||
(scale * f64::from(4 << amp.min(30))) as i32 as f32,
|
||||
(scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32,
|
||||
)),
|
||||
"instant_damage" => Some(EffectAction::Hurt(
|
||||
"minecraft:magic",
|
||||
(scale * f64::from(6 << amp.min(30))) as i32 as f32,
|
||||
(scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32,
|
||||
)),
|
||||
"saturation" => Some(EffectAction::Eat(amp + 1, 1.0)),
|
||||
_ => None,
|
||||
@@ -213,8 +214,9 @@ impl PlayerEffects {
|
||||
}
|
||||
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
|
||||
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
|
||||
/// (on its last tick). Returns the actions to apply, in order, and
|
||||
/// whether Raid Omen was newly added.
|
||||
/// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s
|
||||
/// answer: the effect ends once the absorption is used up. Returns the
|
||||
/// actions to apply, in order, and whether Raid Omen was newly added.
|
||||
///
|
||||
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
|
||||
/// iterated: vanilla's iterator then throws a
|
||||
@@ -224,6 +226,7 @@ impl PlayerEffects {
|
||||
pub fn tick_with(
|
||||
&mut self,
|
||||
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
|
||||
absorption_empty: bool,
|
||||
) -> (Vec<EffectAction>, bool) {
|
||||
self.tick_count = self.tick_count.wrapping_add(1);
|
||||
let mut actions = Vec::new();
|
||||
@@ -246,6 +249,7 @@ impl PlayerEffects {
|
||||
let answer = match e.id {
|
||||
"bad_omen" => omen(e),
|
||||
"raid_omen" if t == 1 => omen(e),
|
||||
"absorption" if absorption_empty => OmenTick::Remove,
|
||||
_ => OmenTick::Continue,
|
||||
};
|
||||
match answer {
|
||||
@@ -465,13 +469,13 @@ mod tests {
|
||||
};
|
||||
// The first tick adds Raid Omen and stops there (the iterator's
|
||||
// ConcurrentModificationException): Bad Omen and Speed stay as they were.
|
||||
let (_, added) = fx.tick_with(&mut convert);
|
||||
let (_, added) = fx.tick_with(&mut convert, false);
|
||||
assert!(added);
|
||||
assert_eq!(fx.get("bad_omen").unwrap().duration, 100);
|
||||
assert_eq!(fx.get("speed").unwrap().duration, 50);
|
||||
assert_eq!(fx.get("raid_omen").unwrap().duration, 600);
|
||||
// The second refreshes Raid Omen and drops Bad Omen.
|
||||
let (_, added) = fx.tick_with(&mut convert);
|
||||
let (_, added) = fx.tick_with(&mut convert, false);
|
||||
assert!(!added);
|
||||
assert!(fx.get("bad_omen").is_none());
|
||||
assert_eq!(fx.get("speed").unwrap().duration, 49);
|
||||
@@ -486,9 +490,9 @@ mod tests {
|
||||
seen.push(e.duration);
|
||||
OmenTick::Remove
|
||||
};
|
||||
fx.tick_with(&mut watch);
|
||||
fx.tick_with(&mut watch, false);
|
||||
assert!(fx.get("raid_omen").is_some());
|
||||
fx.tick_with(&mut watch);
|
||||
fx.tick_with(&mut watch, false);
|
||||
assert!(fx.get("raid_omen").is_none());
|
||||
assert_eq!(seen, vec![1]);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ struct Out {
|
||||
// entity.vsh's lightMapColor, multiplied in after the overlay.
|
||||
@location(9) light_color: vec4<f32>,
|
||||
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
|
||||
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
|
||||
@location(11) @interpolate(flat) cull: u32,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
@@ -92,7 +94,10 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
// minecraft_mix_light: the vertex colour times the cardinal light only.
|
||||
var c = color;
|
||||
var back = c;
|
||||
if normal.w != 0.0 {
|
||||
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
|
||||
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
|
||||
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
|
||||
if abs(normal.w) >= 0.5 {
|
||||
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
|
||||
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
|
||||
}
|
||||
@@ -198,7 +203,7 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
// clouds.fsh: alpha fades out towards the cloud fog end.
|
||||
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
|
||||
}
|
||||
if color.a < 0.1 { discard; }
|
||||
if color.a < 0.1 || (in.cull != 0u && !front) { discard; }
|
||||
// entity.fsh calculateFinalColor: the overlay (hurt red, TNT white) over the
|
||||
// lit texture, then the lightmap, then the glint and fog.
|
||||
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
|
||||
@@ -219,22 +224,9 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
}
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
return celestial_color(in);
|
||||
}
|
||||
|
||||
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
|
||||
// premultiplied for an additive (one, one) blend, and as alpha how much of it
|
||||
// the texel adds, which the blend takes off the frame's alpha. HDR output
|
||||
// (hdr.wgsl) lifts only where that left it low: the sun's and moon's discs, not
|
||||
// the dark rest of their quads or the dim End sky.
|
||||
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
let added = color.rgb * color.a;
|
||||
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
return color;
|
||||
}
|
||||
|
||||
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
|
||||
|
||||
@@ -244,51 +244,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
if is_animal(id) {
|
||||
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
|
||||
}
|
||||
let species = m.species();
|
||||
if let Some((_, variant)) =
|
||||
crate::mobs::finalize::species_variant_component(species, m.extra.variant)
|
||||
{
|
||||
map.insert("variant".into(), string(&variant));
|
||||
}
|
||||
if species == crate::mobs::Species::Chicken {
|
||||
map.insert("EggLayTime".into(), Tag::Int(m.extra.egg_time));
|
||||
}
|
||||
let sounds = crate::mobs::finalize::sound_variants(species);
|
||||
if !sounds.is_empty() {
|
||||
let sound = m.extra.sound_variant.and_then(|i| sounds.get(i as usize));
|
||||
let sound = sound.copied().unwrap_or("classic");
|
||||
map.insert(
|
||||
"sound_variant".into(),
|
||||
string(&format!("minecraft:{sound}")),
|
||||
);
|
||||
}
|
||||
match id {
|
||||
// Fox.addAdditionalSaveData; Horizon models only the type.
|
||||
"minecraft:fox" => {
|
||||
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
|
||||
map.insert("Type".into(), string(kind));
|
||||
for (key, value) in [
|
||||
("Trusted", Tag::List(Vec::new())),
|
||||
("Sleeping", Tag::Byte(0)),
|
||||
("Sitting", Tag::Byte(0)),
|
||||
("Crouching", Tag::Byte(0)),
|
||||
] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
"minecraft:rabbit" => {
|
||||
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
|
||||
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
|
||||
}
|
||||
"minecraft:goat" => {
|
||||
map.insert(
|
||||
"IsScreamingGoat".into(),
|
||||
Tag::Byte(i8::from(m.extra.screaming)),
|
||||
);
|
||||
let [left, right] = m.extra.lost_horns;
|
||||
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
|
||||
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
|
||||
}
|
||||
"minecraft:creeper" => {
|
||||
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
@@ -304,6 +260,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
|
||||
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
|
||||
let variant = crate::mobs::tame::CAT_VARIANTS[(m.extra.variant as usize).min(10)];
|
||||
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
|
||||
map.entry("sound_variant".into())
|
||||
.or_insert_with(|| string("minecraft:classic"));
|
||||
}
|
||||
"minecraft:wolf" => {
|
||||
if let Some(owner) = m.extra.owner {
|
||||
@@ -313,6 +273,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
|
||||
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
|
||||
let variant = crate::mobs::tame::WOLF_VARIANTS[(m.extra.variant as usize).min(8)].0;
|
||||
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
|
||||
map.entry("sound_variant".into())
|
||||
.or_insert_with(|| string("minecraft:classic"));
|
||||
}
|
||||
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
|
||||
// Only Piglin.addAdditionalSaveData writes CannotHunt.
|
||||
@@ -347,10 +311,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
| "minecraft:trader_llama" => {
|
||||
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
|
||||
map.insert("Temper".into(), Tag::Int(i32::from(m.extra.variant3)));
|
||||
// AbstractHorse.addAdditionalSaveData; Horizon models neither.
|
||||
for key in ["EatingHaystack", "Bred"] {
|
||||
map.entry(key.into()).or_insert(Tag::Byte(0));
|
||||
}
|
||||
if let Some(owner) = m.extra.owner {
|
||||
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
|
||||
}
|
||||
@@ -527,6 +487,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.insert("anger".into(), Tag::compound(Vec::<(&str, Tag)>::new()));
|
||||
}
|
||||
}
|
||||
// Armadillo.addAdditionalSaveData: the time left to the next scute.
|
||||
"minecraft:armadillo" => {
|
||||
map.insert("scute_time".into(), Tag::Int(m.extra.egg_time));
|
||||
}
|
||||
"minecraft:ravager" => {
|
||||
map.insert("AttackTick".into(), Tag::Int(m.extra.counter));
|
||||
map.insert("StunTick".into(), Tag::Int(m.extra.counter2));
|
||||
@@ -840,12 +804,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
|
||||
}
|
||||
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
|
||||
// LivingEntity.setHealth clamps to the max health: a horse or llama is made
|
||||
// with the type's and saved before it drops to the one it rolled. (Only
|
||||
// the base is read here, so the clamp would cut a leader zombie's bonus.)
|
||||
if crate::mobs::equine::is_equine(m.species()) {
|
||||
m.health = m.health.min(m.attrs.max_health);
|
||||
}
|
||||
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
|
||||
// What it holds (equipment.mainhand / offhand), when saved.
|
||||
if let Some(eq) = tag.get("equipment") {
|
||||
@@ -981,45 +939,20 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.vector[0] = 0.7;
|
||||
}
|
||||
}
|
||||
if crate::mobs::finalize::species_variant_component(m.species(), 0).is_some()
|
||||
&& let Some(v) = text(tag.get("variant"))
|
||||
{
|
||||
m.extra.variant = crate::mobs::finalize::variant_index(m.species(), &v);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Chicken
|
||||
&& let Some(t) = number(tag.get("EggLayTime"))
|
||||
{
|
||||
m.extra.egg_time = t as i32;
|
||||
}
|
||||
if let Some(v) = text(tag.get("sound_variant")) {
|
||||
let v = v.trim_start_matches("minecraft:");
|
||||
let sounds = crate::mobs::finalize::sound_variants(m.species());
|
||||
m.extra.sound_variant = sounds.iter().position(|n| *n == v).map(|i| i as u8);
|
||||
}
|
||||
match m.species() {
|
||||
crate::mobs::Species::Fox => {
|
||||
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
|
||||
}
|
||||
crate::mobs::Species::Rabbit => {
|
||||
// Rabbit.Variant.byId: an unknown id is brown.
|
||||
m.extra.variant = match number(tag.get("RabbitType")).unwrap_or(0) {
|
||||
v @ (0..=5 | 99) => v as u8,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
crate::mobs::Species::Goat => {
|
||||
m.extra.screaming = flag(tag, "IsScreamingGoat");
|
||||
let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0);
|
||||
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Cat {
|
||||
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
|
||||
m.extra.tame = m.extra.owner.is_some();
|
||||
m.extra.sitting = flag(tag, "Sitting");
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
if let Some(Tag::String(v)) = tag.get("variant") {
|
||||
let v = v.to_string().unwrap_or_default();
|
||||
let v = v.trim_start_matches("minecraft:");
|
||||
m.extra.variant = crate::mobs::tame::CAT_VARIANTS
|
||||
.iter()
|
||||
.position(|n| *n == v)
|
||||
.unwrap_or(0) as u8;
|
||||
}
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Wolf {
|
||||
let health = m.health;
|
||||
@@ -1030,6 +963,14 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.sitting = flag(tag, "Sitting");
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
if let Some(Tag::String(v)) = tag.get("variant") {
|
||||
let v = v.to_string().unwrap_or_default();
|
||||
let v = v.trim_start_matches("minecraft:");
|
||||
m.extra.variant = crate::mobs::tame::WOLF_VARIANTS
|
||||
.iter()
|
||||
.position(|(n, _)| *n == v)
|
||||
.unwrap_or(0) as u8;
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
m.species(),
|
||||
@@ -1128,6 +1069,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
st.vibration = crate::sculk::Data::from_nbt(listener);
|
||||
}
|
||||
}
|
||||
// Armadillo.readAdditionalSaveData: keeps the constructor's time when absent.
|
||||
if m.species() == crate::mobs::Species::Armadillo
|
||||
&& let Some(t) = number(tag.get("scute_time"))
|
||||
{
|
||||
m.extra.egg_time = t as i32;
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Ravager {
|
||||
m.extra.counter = number(tag.get("AttackTick")).unwrap_or(0) as i32;
|
||||
m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32;
|
||||
@@ -2630,123 +2577,6 @@ mod sheep_nbt_tests {
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn farm_animals_keep_their_variant_and_sound_variant() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
types["minecraft:pig"].clone(),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
assert_eq!(
|
||||
text(tag.get("variant")).as_deref(),
|
||||
Some("minecraft:temperate")
|
||||
);
|
||||
assert_eq!(
|
||||
text(tag.get("sound_variant")).as_deref(),
|
||||
Some("minecraft:classic")
|
||||
);
|
||||
// Spawned in a warm biome with the mini sounds.
|
||||
(m.extra.variant, m.extra.sound_variant) = (1, Some(2));
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:warm"));
|
||||
assert_eq!(
|
||||
text(tag.get("sound_variant")).as_deref(),
|
||||
Some("minecraft:mini")
|
||||
);
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
assert_eq!((back.extra.variant, back.extra.sound_variant), (1, Some(2)));
|
||||
let mut hen = Mob::new(
|
||||
3,
|
||||
types["minecraft:chicken"].clone(),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
(hen.extra.variant, hen.extra.egg_time) = (2, 7795);
|
||||
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None);
|
||||
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:cold"));
|
||||
let back = mob_from_nbt(&tag, &types, 4).unwrap();
|
||||
assert_eq!((back.extra.variant, back.extra.egg_time), (2, 7795));
|
||||
// The sound variants are indices into the registries finalizeSpawn draws from.
|
||||
use crate::mobs::{Species, finalize};
|
||||
let registries = finalize::SpawnRegistries::load(&root);
|
||||
for (species, registry) in [
|
||||
(Species::Pig, ®istries.pig_sound),
|
||||
(Species::Cow, ®istries.cow_sound),
|
||||
(Species::Chicken, ®istries.chicken_sound),
|
||||
(Species::Cat, ®istries.cat_sound),
|
||||
(Species::Wolf, ®istries.wolf_sound),
|
||||
] {
|
||||
assert_eq!(
|
||||
finalize::sound_variants(species),
|
||||
registry.names,
|
||||
"{species:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foxes_rabbits_and_goats_keep_their_kind() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let new = |kind: &str| Mob::new(1, types[kind].clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
|
||||
let round_trip = |m: &Mob| {
|
||||
let tag = mob_to_nbt(m, [1, 2, 3, 4], None);
|
||||
(mob_from_nbt(&tag, &types, 2).unwrap(), tag)
|
||||
};
|
||||
let mut fox = new("minecraft:fox");
|
||||
fox.extra.variant = 1;
|
||||
let (back, tag) = round_trip(&fox);
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("snow"));
|
||||
assert_eq!(number(tag.get("Sleeping")), Some(0));
|
||||
assert_eq!(back.extra.variant, 1);
|
||||
let mut rabbit = new("minecraft:rabbit");
|
||||
rabbit.extra.variant = 4;
|
||||
let (back, tag) = round_trip(&rabbit);
|
||||
assert_eq!(number(tag.get("RabbitType")), Some(4));
|
||||
assert_eq!(back.extra.variant, 4);
|
||||
let mut goat = new("minecraft:goat");
|
||||
(goat.extra.screaming, goat.extra.lost_horns) = (true, [false, true]);
|
||||
let (back, tag) = round_trip(&goat);
|
||||
assert_eq!(number(tag.get("HasRightHorn")), Some(0));
|
||||
assert_eq!(
|
||||
(back.extra.screaming, back.extra.lost_horns),
|
||||
(true, [false, true])
|
||||
);
|
||||
// A goat saved without horn flags has both, as vanilla reads it.
|
||||
let bare = Tag::compound([
|
||||
("id", string("minecraft:goat")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
]);
|
||||
assert_eq!(
|
||||
mob_from_nbt(&bare, &types, 3).unwrap().extra.lost_horns,
|
||||
[false; 2]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+284
-188
@@ -157,7 +157,6 @@ mod scoreboard;
|
||||
mod screens;
|
||||
mod sculk;
|
||||
mod sculk_fixture;
|
||||
mod server_links;
|
||||
mod server_mods;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
@@ -509,12 +508,6 @@ enum Screen {
|
||||
Friends,
|
||||
/// CommandBlockEditScreen (game/command_blocks.rs).
|
||||
CommandBlock,
|
||||
/// ServerLinksDialogScreen: a server's links (game/server_links.rs).
|
||||
ServerLinks,
|
||||
/// ConfirmLinkScreen for a server link (game/server_links.rs).
|
||||
ConfirmLink,
|
||||
/// DialogScreen.WarningScreen: the note about custom screens.
|
||||
DialogWarning,
|
||||
/// WinScreen: the end poem and the credits (game/win.rs).
|
||||
Win,
|
||||
/// A `ui` mod's own screen, or its settings page from Options > Mods
|
||||
@@ -571,6 +564,8 @@ struct GameLevel<'w> {
|
||||
player_collision: Option<PlayerCollision>,
|
||||
/// Minecart boxes (detector rails).
|
||||
cart_boxes: Vec<([f64; 3], [f64; 3])>,
|
||||
/// Each minecart's kind and comparator signal, in `cart_boxes` order.
|
||||
cart_signals: Vec<(minecarts::CartKind, i32)>,
|
||||
/// Arrow and thrown trident boxes (wooden buttons).
|
||||
arrow_boxes: Vec<([f64; 3], [f64; 3])>,
|
||||
/// The other entities that keep blocks from being placed into them.
|
||||
@@ -649,6 +644,37 @@ impl EntityObstructionWorld for BuildingObstruction {
|
||||
}
|
||||
}
|
||||
impl GameLevel<'_> {
|
||||
/// `DetectorRailBlock.getAnalogOutputSignal`: a powered rail reads the
|
||||
/// minecart on it, a command block cart first (its success count, which
|
||||
/// is 0 here), else the first container cart's fullness.
|
||||
fn detector_rail_signal(&self, pos: BlockPos, powered: bool) -> i32 {
|
||||
if !powered {
|
||||
return 0;
|
||||
}
|
||||
let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z));
|
||||
let area = Aabb::new(
|
||||
Vec3::new(x + 0.2, y, z + 0.2),
|
||||
Vec3::new(x + 0.8, y + 0.8, z + 0.8),
|
||||
);
|
||||
let mut container = None;
|
||||
for ((lo, hi), &(kind, signal)) in self.cart_boxes.iter().zip(&self.cart_signals) {
|
||||
let cart = Aabb::new(
|
||||
Vec3::new(lo[0], lo[1], lo[2]),
|
||||
Vec3::new(hi[0], hi[1], hi[2]),
|
||||
);
|
||||
if !area.intersects(cart) {
|
||||
continue;
|
||||
}
|
||||
match kind {
|
||||
minecarts::CartKind::CommandBlock => return 0,
|
||||
minecarts::CartKind::Chest | minecarts::CartKind::Hopper => {
|
||||
container = container.or(Some(signal));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
container.unwrap_or(0)
|
||||
}
|
||||
/// Every entity box that blocks building: the players who aren't
|
||||
/// spectators, the minecarts and the `Blockers` (a removed mob or a marker
|
||||
/// armor stand, whose box is empty, doesn't).
|
||||
@@ -709,6 +735,14 @@ impl LevelAccess for GameLevel<'_> {
|
||||
state: BlockStateId,
|
||||
_direction: Direction,
|
||||
) -> i32 {
|
||||
if let Some(s) = self.registry.state(state)
|
||||
&& s.name == "minecraft:detector_rail"
|
||||
{
|
||||
return self.detector_rail_signal(
|
||||
pos,
|
||||
s.properties.get("powered").is_some_and(|v| v == "true"),
|
||||
);
|
||||
}
|
||||
if let Some(signal) = command_blocks::analog_signal(self.world, (pos.x, pos.y, pos.z)) {
|
||||
return signal;
|
||||
}
|
||||
@@ -1483,6 +1517,8 @@ struct Game<'a> {
|
||||
/// `ClientAvatarState`: walked distance and view bob.
|
||||
avatar: horizon_client::camera::AvatarBob,
|
||||
flash: u32,
|
||||
/// `ClientLevel.rainSoundTime`: ticks of rain since the last rain sound.
|
||||
rain_sound_time: i32,
|
||||
/// Bolts that struck here and sky flashes they made, in total (`status`).
|
||||
lightning_seen: [u32; 2],
|
||||
/// The head of the bed while sleeping; the visual timer also survives waking.
|
||||
@@ -1490,6 +1526,8 @@ struct Game<'a> {
|
||||
sleep_timer: u32,
|
||||
dim: Dim,
|
||||
world_root: PathBuf,
|
||||
/// The world is superflat (`ServerLevel.isFlat`, for every dimension).
|
||||
world_flat: bool,
|
||||
/// A player carried through a portal, re-placed once the destination loads.
|
||||
travelling: Option<(PlayerMotion, Vec3)>,
|
||||
/// A server's players on their way to another level, each waiting for
|
||||
@@ -1565,6 +1603,12 @@ struct Game<'a> {
|
||||
/// Map textures waiting for the next HUD batch to upload them.
|
||||
map_patches: Vec<horizon_client::font::Patch>,
|
||||
text_displays: std::collections::HashMap<i32, text_displays::RemoteTextDisplay>,
|
||||
/// The fence knots a server tracks to this client, by entity id, at their
|
||||
/// block (`LeashFenceKnotEntity`).
|
||||
remote_knots: HashMap<u32, [i32; 3]>,
|
||||
/// The entity ids a hosted server gives the knots its mobs hang on, by
|
||||
/// block, while a lead is tied there.
|
||||
knot_ids: HashMap<[i32; 3], u32>,
|
||||
text_display_vertices: usize,
|
||||
/// Player skin downloads, and which skin each atlas skin slot holds
|
||||
/// (least recently shown first).
|
||||
@@ -1682,7 +1726,6 @@ struct Game<'a> {
|
||||
guest_use: Option<GuestUse>,
|
||||
/// Level events of this tick for the guests, and the guest acting now.
|
||||
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
|
||||
lan_cushion_breaks: Vec<(horizon_world::registry::BlockStateId, [f64; 3])>,
|
||||
/// Block events (position, b0, b1, state) and entity events (id, event,
|
||||
/// position) for the guests.
|
||||
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
|
||||
@@ -1706,9 +1749,6 @@ struct Game<'a> {
|
||||
/// The acting guest's cooldowns, as (item, ticks): their packet needs the
|
||||
/// hub's item registry, which the guest's turn does not hold.
|
||||
guest_cooldowns: Vec<(Arc<str>, i32)>,
|
||||
/// The acting guest's own entity events (`ServerPlayer.connection.send`),
|
||||
/// sent with their entity id, which the guest's turn does not hold.
|
||||
guest_entity_events: Vec<i8>,
|
||||
/// `broadcastToAdmins` lines a guest's command queued, sent once it
|
||||
/// returns: the line, whether operators get it and whether the console
|
||||
/// logs it.
|
||||
@@ -1743,8 +1783,6 @@ struct Game<'a> {
|
||||
restore_player_state: bool,
|
||||
stats_ui: stats_screen::StatsUi,
|
||||
adv_ui: advancements_screen::AdvUi,
|
||||
/// The Server Links screens' state (game/server_links.rs).
|
||||
links_ui: server_links::LinksUi,
|
||||
camera_type: perspective::CameraType,
|
||||
fangs: fangs::FangState,
|
||||
/// The local player as a model (third person, the inventory).
|
||||
@@ -2575,8 +2613,14 @@ impl Game<'_> {
|
||||
pickups::Taken::Item(Box::new(item.clone())),
|
||||
pickups::Collector::Player,
|
||||
);
|
||||
// handleTakeItemEntity: the random pitch of a full pickup.
|
||||
let pitch = ((self.world.random.next_float()
|
||||
- self.world.random.next_float())
|
||||
* 0.7
|
||||
+ 1.0)
|
||||
* 2.0;
|
||||
self.audio
|
||||
.play("minecraft:entity.item.pickup", Some(feet), 0.2, 1.0);
|
||||
.play("minecraft:entity.item.pickup", Some(feet), 0.2, pitch);
|
||||
}
|
||||
item.stack = rest;
|
||||
}
|
||||
@@ -2633,6 +2677,7 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let cart_signals = self.cart_signals();
|
||||
let arrow_boxes = self
|
||||
.creatures
|
||||
.projectiles
|
||||
@@ -2675,6 +2720,7 @@ impl Game<'_> {
|
||||
};
|
||||
let mut level = GameLevel {
|
||||
cart_boxes,
|
||||
cart_signals,
|
||||
arrow_boxes,
|
||||
blockers,
|
||||
world: &mut self.world,
|
||||
@@ -2729,9 +2775,13 @@ impl Game<'_> {
|
||||
self.process_level_events()
|
||||
}
|
||||
fn attack_block(&mut self, pos: [i32; 3]) -> Result<()> {
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.attack_block(level, ticks, BlockPos::new(pos[0], pos[1], pos[2]));
|
||||
let note = self.with_level(|b, level, ticks| {
|
||||
b.attack_block(level, ticks, BlockPos::new(pos[0], pos[1], pos[2]))
|
||||
});
|
||||
// NoteBlock.attack awards the statistic.
|
||||
if note {
|
||||
self.stats.custom("play_noteblock", 1);
|
||||
}
|
||||
self.process_level_events()
|
||||
}
|
||||
/// Track which chunks are ticking (Ticks needs chunk load/unload).
|
||||
@@ -3032,11 +3082,6 @@ impl Game<'_> {
|
||||
pos,
|
||||
data,
|
||||
} => self.dragon_egg_particles([pos.x, pos.y, pos.z], data),
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2016,
|
||||
pos,
|
||||
data,
|
||||
} => self.shulker_teleport_particles([pos.x, pos.y, pos.z], data),
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2017,
|
||||
pos,
|
||||
@@ -3071,6 +3116,34 @@ impl Game<'_> {
|
||||
pos,
|
||||
data,
|
||||
} => self.composter_fill_event(pos, data == 1),
|
||||
// LevelEventHandler 1504: the stalactite's drip particle.
|
||||
LevelEvent::LevelEvent { id: 1504, pos, .. } => self.dripstone_drip_particle(pos),
|
||||
// LevelEventHandler 2006: the dragon fireball's burst.
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2006,
|
||||
pos,
|
||||
data,
|
||||
} => self.dragon_fireball_burst(pos, data),
|
||||
// LevelEventHandler 2009: a wet sponge drying.
|
||||
LevelEvent::LevelEvent { id: 2009, pos, .. } => {
|
||||
self.puffs_above_block(pos, horizon_client::particles::ParticleType::Cloud)
|
||||
}
|
||||
// LevelEventHandler 2012: a turtle egg laid.
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2012,
|
||||
pos,
|
||||
data,
|
||||
} => self.turtle_egg_particles(pos, data),
|
||||
// LevelEventHandler 2014: a straw bed destroyed, with no sound.
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2014,
|
||||
pos,
|
||||
data,
|
||||
} => self.destroy_particles([pos.x, pos.y, pos.z], BlockStateId(data as u32)),
|
||||
// LevelEventHandler 3009: a sniffer egg cracking.
|
||||
LevelEvent::LevelEvent { id: 3009, pos, .. } => {
|
||||
self.sniffer_egg_crack_particles(pos)
|
||||
}
|
||||
LevelEvent::LevelEvent {
|
||||
id: id @ 3011..=3021,
|
||||
pos,
|
||||
@@ -3086,6 +3159,16 @@ impl Game<'_> {
|
||||
1501 => Some(("minecraft:block.lava.extinguish", 0.5)),
|
||||
1009 => Some(("minecraft:block.fire.extinguish", 0.5)),
|
||||
1502 => Some(("minecraft:block.redstone_torch.burnout", 0.5)),
|
||||
1033 => Some(("minecraft:block.chorus_flower.grow", 1.0)),
|
||||
1034 => Some(("minecraft:block.chorus_flower.death", 1.0)),
|
||||
1046 => Some((
|
||||
"minecraft:block.pointed_dripstone.drip_lava_into_cauldron",
|
||||
2.0,
|
||||
)),
|
||||
1047 => Some((
|
||||
"minecraft:block.pointed_dripstone.drip_water_into_cauldron",
|
||||
2.0,
|
||||
)),
|
||||
// LevelEvent.SOUND_ZOMBIE_WOODEN_DOOR (attack, break).
|
||||
1019 => Some(("minecraft:entity.zombie.attack_wooden_door", 2.0)),
|
||||
1021 => Some(("minecraft:entity.zombie.break_wooden_door", 2.0)),
|
||||
@@ -3107,6 +3190,13 @@ impl Game<'_> {
|
||||
- self.world.random.next_float())
|
||||
* 0.8
|
||||
}
|
||||
// LevelEventHandler draws these from the client
|
||||
// level's random, before 1501's and 1502's smoke.
|
||||
1501 | 1502 => {
|
||||
let r = self.particles.level_random();
|
||||
2.6 + (r.next_float() - r.next_float()) * 0.8
|
||||
}
|
||||
1046 | 1047 => self.particles.level_random().next_float() * 0.1 + 0.9,
|
||||
1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => {
|
||||
self.world.random.next_float() * 0.1 + 0.9
|
||||
}
|
||||
@@ -3125,6 +3215,15 @@ impl Game<'_> {
|
||||
self.audio.play(name, c, volume, pitch);
|
||||
}
|
||||
}
|
||||
// LevelEventHandler 1501 and 1502 draw their smoke after the sound.
|
||||
match id {
|
||||
1501 => self.puffs_above_block(
|
||||
pos,
|
||||
horizon_client::particles::ParticleType::LargeSmoke,
|
||||
),
|
||||
1502 => self.torch_burnout_smoke(pos),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
LevelEvent::Sound {
|
||||
pos,
|
||||
@@ -3191,6 +3290,8 @@ impl Game<'_> {
|
||||
// Popped as the behaviour emits it (GameLevel::emit).
|
||||
PlayerEffect::DropItem(_) => {}
|
||||
PlayerEffect::EatCake => {
|
||||
// CakeBlock.eat
|
||||
self.stats.custom("eat_cake_slice", 1);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
let food = &mut p.health.food;
|
||||
food.food = (food.food + 2).min(20);
|
||||
@@ -3324,20 +3425,21 @@ impl Game<'_> {
|
||||
self.open_command_block(key);
|
||||
return;
|
||||
}
|
||||
// SignBlock.useWithoutItem: the text editor, not a container.
|
||||
if horizon_client::signs::sign_kind(&name).is_some() {
|
||||
self.use_sign_empty_hand(pos);
|
||||
return;
|
||||
}
|
||||
if &*name == "minecraft:crafting_table" {
|
||||
self.craft_size = 3;
|
||||
self.craft_table = Some(pos);
|
||||
} else if &*name == "minecraft:ender_chest" {
|
||||
self.open_ender_chest(key);
|
||||
self.container_opener_event(key, true);
|
||||
let at = [
|
||||
f64::from(key.0) + 0.5,
|
||||
f64::from(key.1) + 0.5,
|
||||
f64::from(key.2) + 0.5,
|
||||
];
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.audio
|
||||
.play("minecraft:block.ender_chest.open", Some(at), 0.5, pitch);
|
||||
// EnderChestBlock.useWithoutItem
|
||||
self.stats.custom("open_enderchest", 1);
|
||||
if self.container_opener_event(key, true) {
|
||||
self.container_edge_sound(key, true);
|
||||
}
|
||||
} else if &*name == "minecraft:beacon" && self.open_station(pos, &name) {
|
||||
self.world.block_entities.get_or_insert_with(key, || {
|
||||
BlockEntity::for_block(&name).unwrap_or(BlockEntity::Chest(Vec::new()))
|
||||
@@ -3353,7 +3455,7 @@ impl Game<'_> {
|
||||
];
|
||||
self.resolve_container_maps(key, centre);
|
||||
self.container = Some(key);
|
||||
self.container_opener_event(key, true);
|
||||
let first = self.container_opener_event(key, true);
|
||||
self.post_block_game_event(
|
||||
"minecraft:container_open",
|
||||
[key.0, key.1, key.2],
|
||||
@@ -3377,26 +3479,8 @@ impl Game<'_> {
|
||||
if self.guarded_by_piglins(&name) {
|
||||
self.anger_piglins(true);
|
||||
}
|
||||
let sound = if storage::is_shulker_box(&name) {
|
||||
Some("minecraft:block.shulker_box.open")
|
||||
} else if name.contains("chest") {
|
||||
Some("minecraft:block.chest.open")
|
||||
} else if &*name == "minecraft:barrel" {
|
||||
Some("minecraft:block.barrel.open")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(sound) = sound {
|
||||
self.audio.play(
|
||||
sound,
|
||||
Some([
|
||||
f64::from(key.0) + 0.5,
|
||||
f64::from(key.1) + 0.5,
|
||||
f64::from(key.2) + 0.5,
|
||||
]),
|
||||
0.5,
|
||||
1.0,
|
||||
);
|
||||
if first {
|
||||
self.container_edge_sound(key, true);
|
||||
}
|
||||
} else if self.open_station(pos, &name) {
|
||||
// Workstation menus: game/stations.rs.
|
||||
@@ -3720,6 +3804,21 @@ impl Game<'_> {
|
||||
{
|
||||
self.hive_broken_by_player(pos, hive, honey, facing);
|
||||
}
|
||||
// InfestedBlock.spawnAfterBreak: its silverfish comes out unless the
|
||||
// tool has silk touch (#prevents_infested_spawns) or blocks don't drop.
|
||||
if facts.name.starts_with("minecraft:infested_")
|
||||
&& !silk
|
||||
&& self.ui.rules.bool("block_drops")
|
||||
{
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
if let Some(id) = self.spawn_mob("minecraft:silverfish", at, false) {
|
||||
self.mob_poof(id);
|
||||
}
|
||||
}
|
||||
// SpawnerBlock.spawnAfterBreak pops its experience whatever the tool.
|
||||
let xp = if silk && &*facts.name != "minecraft:spawner" {
|
||||
0
|
||||
@@ -3841,6 +3940,14 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.use_sign_applicator(hit.pos, held.as_ref()) {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.use_golem_statue(hit.pos, held.as_ref()) {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.use_jukebox(hit.pos, held.as_ref()) {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
@@ -3861,6 +3968,10 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.use_beehive(hit.pos, held.as_ref())? {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.use_campfire_douse(hit.pos, held.as_ref())? {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
@@ -3900,6 +4011,20 @@ impl Game<'_> {
|
||||
)
|
||||
});
|
||||
self.process_level_events()?;
|
||||
// NoteBlock.useWithoutItem (tuned) and BellBlock.onHit (rung).
|
||||
if result == horizon_world::level::InteractionResult::Success {
|
||||
let name = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
|
||||
.name
|
||||
.clone();
|
||||
match &*name {
|
||||
"minecraft:note_block" => self.stats.custom("tune_noteblock", 1),
|
||||
"minecraft:bell" => self.stats.custom("bell_ring", 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if result.consumes_action() {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
@@ -4203,6 +4328,8 @@ impl Game<'_> {
|
||||
self.release_used_item(&stack, ticks, hand_changed);
|
||||
return;
|
||||
}
|
||||
// ServerPlayer.updateUsingItem: USING_ITEM every tick of the use.
|
||||
self.trigger_using_item(&stack);
|
||||
let Some(info) = self.res.items().get(&stack.item).cloned() else {
|
||||
self.using = None;
|
||||
return;
|
||||
@@ -4230,7 +4357,6 @@ impl Game<'_> {
|
||||
if &*stack.item == "minecraft:spyglass" {
|
||||
// SpyglassItem.USE_DURATION, then finishUsingItem.
|
||||
if ticks + 1 >= 1200 {
|
||||
self.send_use_item_complete();
|
||||
self.stop_spyglass();
|
||||
self.using = None;
|
||||
} else {
|
||||
@@ -4252,7 +4378,6 @@ impl Game<'_> {
|
||||
}
|
||||
if ticks >= duration {
|
||||
self.using = None;
|
||||
self.send_use_item_complete();
|
||||
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
|
||||
self.apply_use_cooldown(&stack);
|
||||
self.finish_consuming(&info, consumable, &stack);
|
||||
@@ -4722,13 +4847,11 @@ impl Game<'_> {
|
||||
for event in events {
|
||||
match event {
|
||||
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, feet),
|
||||
MotionEvent::Emission {
|
||||
state, in_water, ..
|
||||
} if !crouching => {
|
||||
MotionEvent::Emission { state, muffled, .. } if !crouching => {
|
||||
let sounds = self.res.facts().get(state).sounds.clone();
|
||||
// Entity.playStepSound: the block's step sound at 0.15 volume,
|
||||
// muffled under the swim sound in water.
|
||||
let (volume, pitch) = if in_water {
|
||||
let (volume, pitch) = if muffled {
|
||||
(sounds.volume * 0.05, sounds.pitch * 0.8)
|
||||
} else {
|
||||
(sounds.volume * 0.15, sounds.pitch)
|
||||
@@ -4794,6 +4917,10 @@ impl Game<'_> {
|
||||
};
|
||||
self.audio.play(name, Some(feet), volume, pitch);
|
||||
}
|
||||
// Player.causeFallDamage: a fall of 2 blocks or more counts.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) => {
|
||||
self.stats.custom("fall_one_cm", cm)
|
||||
}
|
||||
// LivingEntity.checkFallDamage: the block's crumbs where the fall landed.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
|
||||
state,
|
||||
@@ -5133,9 +5260,6 @@ impl Game<'_> {
|
||||
| Screen::Advancements
|
||||
| Screen::WorldOptions
|
||||
| Screen::Friends
|
||||
| Screen::ServerLinks
|
||||
| Screen::ConfirmLink
|
||||
| Screen::DialogWarning
|
||||
| Screen::CommandBlock
|
||||
) || self.book.is_some()
|
||||
|| self.win_screen_pauses())
|
||||
@@ -5619,9 +5743,6 @@ impl Game<'_> {
|
||||
Screen::Advancements => self.draw_advancements(&mut batch),
|
||||
Screen::WorldOptions => self.draw_world_options(&mut batch),
|
||||
Screen::Friends => self.draw_friends_screen(&mut batch),
|
||||
Screen::ServerLinks => self.draw_server_links(&mut batch),
|
||||
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
|
||||
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
|
||||
Screen::CommandBlock => self.draw_command_block(&mut batch),
|
||||
Screen::Inventory => {
|
||||
self.draw_inventory(&mut batch);
|
||||
@@ -6042,41 +6163,15 @@ impl Game<'_> {
|
||||
if let Some(key) = self.container.take() {
|
||||
// A cart's or chest boat's items go back to it.
|
||||
self.close_cart_container(key);
|
||||
self.container_opener_event(key, false);
|
||||
let last = self.container_opener_event(key, false);
|
||||
self.post_block_game_event(
|
||||
"minecraft:container_close",
|
||||
[key.0, key.1, key.2],
|
||||
Some(horizon_client::mobs::Who::Player),
|
||||
None,
|
||||
);
|
||||
let name = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(key.0, key.1, key.2))
|
||||
.name
|
||||
.clone();
|
||||
let sound = if storage::is_shulker_box(&name) {
|
||||
Some("minecraft:block.shulker_box.close")
|
||||
} else if &*name == "minecraft:ender_chest" {
|
||||
Some("minecraft:block.ender_chest.close")
|
||||
} else if name.contains("chest") {
|
||||
Some("minecraft:block.chest.close")
|
||||
} else if &*name == "minecraft:barrel" {
|
||||
Some("minecraft:block.barrel.close")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(sound) = sound {
|
||||
self.audio.play(
|
||||
sound,
|
||||
Some([
|
||||
f64::from(key.0) + 0.5,
|
||||
f64::from(key.1) + 0.5,
|
||||
f64::from(key.2) + 0.5,
|
||||
]),
|
||||
0.5,
|
||||
1.0,
|
||||
);
|
||||
if last {
|
||||
self.container_edge_sound(key, false);
|
||||
}
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
}
|
||||
@@ -6596,6 +6691,7 @@ impl Game<'_> {
|
||||
self.player_menu_sound(sound);
|
||||
}
|
||||
let crafted = std::mem::take(&mut menu.crafted);
|
||||
let brewed = std::mem::take(&mut menu.brewed);
|
||||
let equipped = std::mem::take(&mut menu.equipped);
|
||||
for (i, stack) in after.into_iter().enumerate() {
|
||||
if let Some(g) = game_of[i] {
|
||||
@@ -6611,6 +6707,9 @@ impl Game<'_> {
|
||||
self.player_drop(stack);
|
||||
}
|
||||
self.award_crafted(&crafted);
|
||||
for stack in &brewed {
|
||||
self.trigger_brewed_potion(stack);
|
||||
}
|
||||
if furnace_before > furnace_after
|
||||
&& self.remote.is_none()
|
||||
&& let (Some(pos), Some(p)) = (self.container, self.player.as_ref())
|
||||
@@ -7930,13 +8029,15 @@ impl Game<'_> {
|
||||
}
|
||||
/// The visual and sound part of `Player.doSweepAttack` after a successful hit.
|
||||
pub(super) fn sweep_attack(&mut self, at: [f64; 3], from: Option<[f64; 3]>) {
|
||||
self.particles.add(
|
||||
&horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::SweepAttack,
|
||||
),
|
||||
at,
|
||||
[0.0; 3],
|
||||
let sweep = horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::SweepAttack,
|
||||
);
|
||||
self.particles.add(&sweep, at, [0.0; 3]);
|
||||
// Player.sweepAttack's `sendParticles(SWEEP_ATTACK, ..., 0, dx, 0, dz, 0)`:
|
||||
// the offset is the one from the player to the particle.
|
||||
let origin = from.unwrap_or(at);
|
||||
let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]];
|
||||
self.lan_send_particles(&sweep, at, spread, 0.0, 0);
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0);
|
||||
}
|
||||
@@ -8091,11 +8192,6 @@ impl Game<'_> {
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
let (knockback, knockback_attack) = self.extra_knockback(&target_kind, scale);
|
||||
self.attack_ticks = 0;
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !self.creative
|
||||
{
|
||||
p.cause_food_exhaustion(0.1);
|
||||
}
|
||||
let player = self.player.as_ref();
|
||||
let from = player.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
if knockback_attack {
|
||||
@@ -8110,6 +8206,15 @@ impl Game<'_> {
|
||||
None => horizon_client::mobs::Damage::of(horizon_client::mobs::DamageKind::Generic),
|
||||
};
|
||||
let landed = self.hurt_by_player(index, amount, source);
|
||||
if landed {
|
||||
let victim = self.mob_entity(&self.mobs[index]);
|
||||
self.player_hit(
|
||||
&victim,
|
||||
creatures::damage_type(source.kind, true),
|
||||
None,
|
||||
amount,
|
||||
);
|
||||
}
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
if p.attack_log.len() >= 64 {
|
||||
p.attack_log.remove(0);
|
||||
@@ -8175,6 +8280,14 @@ impl Game<'_> {
|
||||
0.2,
|
||||
(actual * 0.5) as u32,
|
||||
);
|
||||
// The players a server serves are sent them too.
|
||||
self.lan_send_particles(
|
||||
&indicator,
|
||||
[feet[0], feet[1] + height * 0.5, feet[2]],
|
||||
[0.1, 0.0, 0.1],
|
||||
0.2,
|
||||
(actual * 0.5) as u32,
|
||||
);
|
||||
}
|
||||
self.last_hurt_by_player
|
||||
.insert(self.mobs[index].id, self.lan_acting);
|
||||
@@ -8195,16 +8308,32 @@ impl Game<'_> {
|
||||
// The hurt or death sound the mob raised (getHurtSound / getDeathSound,
|
||||
// its voice pitch from its own random).
|
||||
let queued = std::mem::take(&mut self.mobs[index].outbox);
|
||||
let mut infested = false;
|
||||
for event in queued {
|
||||
if let horizon_client::mobs::MobEvent::Sound(s, at, volume, pitch) = event {
|
||||
self.audio
|
||||
.play(&format!("minecraft:{s}"), Some(at), volume, pitch);
|
||||
} else {
|
||||
// Left for the end of the tick.
|
||||
self.mobs[index].outbox.push(event);
|
||||
match event {
|
||||
horizon_client::mobs::MobEvent::Sound(s, at, volume, pitch) => {
|
||||
self.audio
|
||||
.play(&format!("minecraft:{s}"), Some(at), volume, pitch);
|
||||
}
|
||||
horizon_client::mobs::MobEvent::Infested { .. } => infested = true,
|
||||
other => {
|
||||
// Left for the end of the tick.
|
||||
self.mobs[index].outbox.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
if infested {
|
||||
let id = self.mobs[index].id;
|
||||
self.mob_infested(id);
|
||||
}
|
||||
self.weapon_hit(index);
|
||||
// Player.attack: causeFoodExhaustion(0.1F) only when the hit hurt
|
||||
// (inside `if (wasHurt)`), after damageStatsAndHearts.
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !self.creative
|
||||
{
|
||||
p.cause_food_exhaustion(0.1);
|
||||
}
|
||||
} else {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
|
||||
@@ -8465,6 +8594,7 @@ impl Game<'_> {
|
||||
book.to_be_displayed.retain(|r| index.contains(r));
|
||||
self.dim = dim;
|
||||
self.world_root = save_root.clone();
|
||||
self.world_flat = horizon_client::save::WorldGen::read(&save_root).is_some_and(|w| w.flat);
|
||||
self.block_ticks = Ticks::new(self.level.seed as i32);
|
||||
self.tick_chunks.clear();
|
||||
self.clear_entities();
|
||||
@@ -8768,6 +8898,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.lan_close(false);
|
||||
self.save_game()?;
|
||||
if let Some(mut remote) = self.remote.take() {
|
||||
// Minecraft.disconnect closes the connection, so the host sees a clean leave.
|
||||
remote.session.disconnect();
|
||||
}
|
||||
self.saved_for_exit = true;
|
||||
Ok(())
|
||||
}
|
||||
@@ -9049,6 +9183,9 @@ impl Game<'_> {
|
||||
if result.outcome == Outcome::Pass {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(stat) = result.stat {
|
||||
self.stats.custom(stat, 1);
|
||||
}
|
||||
if let Some(block) = result.block
|
||||
&& let Ok(new) = registry.resolve(&block)
|
||||
{
|
||||
@@ -9158,6 +9295,8 @@ impl Game<'_> {
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
// BlockTransformer.transformBlock: ITEM_USED_ON_BLOCK before the swap.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, stack);
|
||||
self.level_set(pos, new)?;
|
||||
// BlockTransformer.TransformParticle.send.
|
||||
self.transform_particle(&particle, pos);
|
||||
@@ -9430,6 +9569,8 @@ impl Game<'_> {
|
||||
if let Some(waxed) = old.and_then(|old| self.res.transformers().waxed(registry, &old))
|
||||
&& let Ok(state) = registry.resolve(&waxed)
|
||||
{
|
||||
// HoneycombItem.useOn: ITEM_USED_ON_BLOCK before the swap.
|
||||
self.trigger_block("minecraft:item_used_on_block", hit.pos, stack);
|
||||
self.level_set(hit.pos, state)?;
|
||||
self.level_event_at(3003, hit.pos, 0);
|
||||
self.audio.play(
|
||||
@@ -10143,37 +10284,7 @@ impl Game<'_> {
|
||||
if self.flash > 0 {
|
||||
self.flash -= 1;
|
||||
}
|
||||
let (rain, _) = self.world.weather.levels(1.0);
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return;
|
||||
};
|
||||
// WeatherEffectRenderer.tickRainParticles: occasional rain sounds.
|
||||
if rain > 0.0 && self.world.random.next_int_bound(3).unwrap_or(1) < (rain * 3.0) as i32 {
|
||||
let r = &mut self.world.random;
|
||||
let (dx, dz) = (
|
||||
r.next_int_bound(21).unwrap_or(10) - 10,
|
||||
r.next_int_bound(21).unwrap_or(10) - 10,
|
||||
);
|
||||
let (x, z) = (eye[0].floor() as i32 + dx, eye[2].floor() as i32 + dz);
|
||||
let top = self.rain_height(x, z);
|
||||
if self.precipitation(x, top, z) == Some(false)
|
||||
&& (top - eye[1] as i32).abs() < 10
|
||||
&& self.world.random.next_int_bound(5).unwrap_or(1) == 0
|
||||
{
|
||||
let above = f64::from(top) > eye[1] + 1.0;
|
||||
let (name, volume, pitch) = if above {
|
||||
("minecraft:weather.rain.above", 0.1, 0.5)
|
||||
} else {
|
||||
("minecraft:weather.rain", 0.2, 1.0)
|
||||
};
|
||||
self.audio.play(
|
||||
name,
|
||||
Some([f64::from(x) + 0.5, f64::from(top), f64::from(z) + 0.5]),
|
||||
volume,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
}
|
||||
// The rain sounds come with the rain splashes (`tick_particles`).
|
||||
}
|
||||
/// WeatherEffectRenderer: rain and snow columns within the weather radius.
|
||||
fn weather_geometry(
|
||||
@@ -11416,10 +11527,6 @@ impl Game<'_> {
|
||||
Screen::GameModeSwitcher => self.close_gamemode_switcher(false),
|
||||
Screen::Dead => {}
|
||||
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
|
||||
Screen::ServerLinks => self.screen = Screen::Paused,
|
||||
// ConfirmScreen.keyPressed: Escape answers no; both return to
|
||||
// the Server Links screen they came from.
|
||||
Screen::ConfirmLink | Screen::DialogWarning => self.screen = Screen::ServerLinks,
|
||||
Screen::Advancements => self.close_advancements(),
|
||||
Screen::WorldOptions => self.world_options_escape(),
|
||||
Screen::CommandBlock => self.close_command_block(),
|
||||
@@ -11488,10 +11595,8 @@ impl Game<'_> {
|
||||
// key.swapOffhand: on a server, ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND
|
||||
// (the server swaps the hands and sends both slots back; spectators can't).
|
||||
KeyCode::KeyF if self.screen == Screen::Playing && self.remote.is_some() => {
|
||||
if !self.spectator()
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
r.pending_keys.push(key);
|
||||
if !self.spectator() {
|
||||
self.remote_swap_hands();
|
||||
}
|
||||
}
|
||||
KeyCode::KeyF if self.screen == Screen::Playing => {
|
||||
@@ -11588,28 +11693,11 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// LOD Distance and Far Detail Reduced as chosen: the player's LOD
|
||||
/// Distance when it is on, else a mod's far terrain
|
||||
/// (`render.set-far-terrain`).
|
||||
fn far_terrain_choice(&self) -> (u32, bool) {
|
||||
let g = &self.settings.horizon;
|
||||
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
|
||||
if g.lod_distance > 0 {
|
||||
return (g.lod_distance, reduced);
|
||||
}
|
||||
match self.mod_far_terrain() {
|
||||
Some(f) => (f.distance, reduced || f.reduced),
|
||||
None => (0, reduced),
|
||||
}
|
||||
}
|
||||
/// LOD Distance as far terrain uses it (`graphics::lod_distance_capped`).
|
||||
/// LOD Distance as far terrain uses it (`Graphics::lod_distance_on`).
|
||||
fn lod_distance(&self) -> u32 {
|
||||
let (distance, _) = self.far_terrain_choice();
|
||||
match &self.renderer {
|
||||
Some(renderer) => {
|
||||
horizon_client::graphics::lod_distance_capped(distance, &renderer.info)
|
||||
}
|
||||
None => distance.min(1024),
|
||||
Some(renderer) => self.settings.horizon.lod_distance_on(&renderer.info),
|
||||
None => self.settings.horizon.lod_distance.min(1024),
|
||||
}
|
||||
}
|
||||
/// Only enabled LOD worlds extend the renderer's range, never simulation.
|
||||
@@ -11630,7 +11718,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn prepare_lod(&mut self) {
|
||||
let enabled = self.far_terrain_choice().0 > 0
|
||||
let enabled = self.settings.horizon.lod_distance > 0
|
||||
&& self.renderer.is_some()
|
||||
&& self.player.is_some()
|
||||
&& self.title.is_none()
|
||||
@@ -11727,7 +11815,6 @@ impl Game<'_> {
|
||||
{
|
||||
log::warn!("Horizon: failed to save unloaded chunks: {e}");
|
||||
}
|
||||
let (far_distance, far_reduced) = self.far_terrain_choice();
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
let g = &self.settings.horizon;
|
||||
let budgeted = g.chunk_updates > 0 || g.upload_budget_kib > 0;
|
||||
@@ -11781,10 +11868,12 @@ impl Game<'_> {
|
||||
}
|
||||
renderer.remove_columns(unloaded.iter().map(|c| (c.x, c.z)));
|
||||
if let (Some(lod), Some(player)) = (&mut self.lod, &self.player) {
|
||||
let lod_distance =
|
||||
horizon_client::graphics::lod_distance_capped(far_distance, &renderer.info);
|
||||
let lod_distance = self.settings.horizon.lod_distance_on(&renderer.info);
|
||||
lod.set_near_loading(self.streaming.busy());
|
||||
lod.set_reduced_detail(far_reduced);
|
||||
lod.set_reduced_detail(
|
||||
self.settings.horizon.far_detail
|
||||
== horizon_client::graphics::FarDetail::Reduced,
|
||||
);
|
||||
// The covered columns change only with the complete ones and
|
||||
// the render area: rebuilt then, not every frame.
|
||||
let at = (
|
||||
@@ -12118,9 +12207,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.screen == Screen::WorldOptions && d != 0.0 {
|
||||
self.world_options_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::ServerLinks && d != 0.0 {
|
||||
self.server_links_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Advancements {
|
||||
self.advancements_scroll(scaled(raw_x) as f32, d);
|
||||
}
|
||||
@@ -12312,15 +12398,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
Screen::WorldOptions if pressed && button == MouseButton::Left => {
|
||||
self.world_options_click()
|
||||
}
|
||||
Screen::ServerLinks if pressed && button == MouseButton::Left => {
|
||||
self.server_links_click()?
|
||||
}
|
||||
Screen::ConfirmLink if pressed && button == MouseButton::Left => {
|
||||
self.confirm_link_click()
|
||||
}
|
||||
Screen::DialogWarning if pressed && button == MouseButton::Left => {
|
||||
self.dialog_warning_click()?
|
||||
}
|
||||
Screen::WorldOptions if button == MouseButton::Left => {
|
||||
self.world_options_release()
|
||||
}
|
||||
@@ -12752,6 +12829,16 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
}
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if horizon_threads::stop_requests() > 0
|
||||
&& self.pending_save.as_ref().is_none_or(|save| !save.quit)
|
||||
{
|
||||
// SIGTERM, SIGINT or SIGHUP: the shutdown hook's server halt, as a window close.
|
||||
if let Err(error) = self.begin_closing_save(true) {
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.pending_save.is_some() {
|
||||
match self.poll_closing_save() {
|
||||
Ok(true) => event_loop.exit(),
|
||||
@@ -13042,11 +13129,13 @@ impl<'a> Game<'a> {
|
||||
dragging: false,
|
||||
avatar: Default::default(),
|
||||
flash: 0,
|
||||
rain_sound_time: 0,
|
||||
lightning_seen: [0; 2],
|
||||
sleeping: None,
|
||||
sleep_timer: 0,
|
||||
dim: Dim::Overworld,
|
||||
world_root: PathBuf::new(),
|
||||
world_flat: false,
|
||||
travelling: None,
|
||||
server_trips: Vec::new(),
|
||||
level_teleports: Vec::new(),
|
||||
@@ -13096,6 +13185,8 @@ impl<'a> Game<'a> {
|
||||
dragon_model: prepared.dragon_model,
|
||||
map_patches: Vec::new(),
|
||||
text_displays: Default::default(),
|
||||
remote_knots: Default::default(),
|
||||
knot_ids: Default::default(),
|
||||
text_display_vertices: 0,
|
||||
skin_downloads: RefCell::new(horizon_client::skins::Downloader::new(
|
||||
std::env::current_dir()
|
||||
@@ -13168,7 +13259,6 @@ impl<'a> Game<'a> {
|
||||
forced_hit: None,
|
||||
guest_use: None,
|
||||
lan_events: Vec::new(),
|
||||
lan_cushion_breaks: Vec::new(),
|
||||
lan_block_events: Vec::new(),
|
||||
lan_entity_events: Vec::new(),
|
||||
lan_critical_hits: Vec::new(),
|
||||
@@ -13177,7 +13267,6 @@ impl<'a> Game<'a> {
|
||||
lan_acting: None,
|
||||
guest_packets: Vec::new(),
|
||||
guest_cooldowns: Vec::new(),
|
||||
guest_entity_events: Vec::new(),
|
||||
lan_acting_name: None,
|
||||
lan_acting_uuid: None,
|
||||
chat_capture: None,
|
||||
@@ -13204,7 +13293,6 @@ impl<'a> Game<'a> {
|
||||
restore_player_state: true,
|
||||
stats_ui: Default::default(),
|
||||
adv_ui: Default::default(),
|
||||
links_ui: Default::default(),
|
||||
camera_type: Default::default(),
|
||||
fangs: Default::default(),
|
||||
player_model: None,
|
||||
@@ -14241,6 +14329,11 @@ impl Loading {
|
||||
}
|
||||
}
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if horizon_threads::stop_requests() > 0 {
|
||||
// No world is open yet: nothing to save.
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
if let Err(error) = self.poll_menu_resources() {
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
@@ -14518,6 +14611,9 @@ pub fn run() -> Result<()> {
|
||||
{
|
||||
horizon_client::heartbeat::start();
|
||||
}
|
||||
if options.execution_role.is_client() {
|
||||
horizon_threads::catch_stop_signals();
|
||||
}
|
||||
let started = Instant::now();
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
let vanilla_root = options.resources.clone();
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_client::{
|
||||
text::{self, Style, Text},
|
||||
toasts::Slide,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, VecDeque},
|
||||
@@ -82,6 +83,8 @@ pub(super) struct AdvState {
|
||||
pub(super) quiet: bool,
|
||||
/// The inventory last seen by `inventory_changed`.
|
||||
pub(super) last_inventory: u64,
|
||||
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
|
||||
pub(super) levitation: Option<([f64; 3], u64)>,
|
||||
/// While the game acts for a network player (`as_guest`): the
|
||||
/// advancements whose progress changed (`progressChanged`), the recipes
|
||||
/// unlocked and the announcements, for their client and everyone's.
|
||||
@@ -180,6 +183,14 @@ impl adv::World for View<'_, '_> {
|
||||
"block" => items.block_tags.get(tag).is_some_and(|t| t.contains(id)),
|
||||
"entity_type" => self.0.res.mob_data().entity_has(tag, id),
|
||||
"worldgen/biome" => self.0.res.mob_data().biome_has(tag, id),
|
||||
"damage_type" => self
|
||||
.0
|
||||
.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.damage_tags
|
||||
.get(tag)
|
||||
.is_some_and(|t| t.contains(id)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -647,6 +658,28 @@ impl Game<'_> {
|
||||
};
|
||||
adv::conditions(&c["player"], &ctx, w)
|
||||
});
|
||||
// ServerPlayer.onEffectAdded and onEffectsRemoved keep the start of a
|
||||
// levitation; tick fires LEVITATION while there is one.
|
||||
let levitating = self
|
||||
.potions
|
||||
.effects
|
||||
.list
|
||||
.iter()
|
||||
.any(|e| e.id == "levitation");
|
||||
match (levitating, self.adv.levitation) {
|
||||
(true, None) => {
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.adv.levitation = at.map(|at| (at, self.ticks));
|
||||
}
|
||||
(false, Some(_)) => self.adv.levitation = None,
|
||||
_ => {}
|
||||
}
|
||||
if let Some((start, since)) = self.adv.levitation {
|
||||
self.trigger_levitation(start, (self.ticks - since) as i64);
|
||||
}
|
||||
if self.ticks.is_multiple_of(20) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:location", |c, w| {
|
||||
@@ -681,6 +714,136 @@ impl Game<'_> {
|
||||
adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w)
|
||||
});
|
||||
}
|
||||
/// `LevitationTrigger`: how far the player rose from where it started
|
||||
/// levitating, and for how long.
|
||||
fn trigger_levitation(&mut self, start: [f64; 3], duration: i64) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:levitation", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& (c["distance"].is_null() || adv::distance(&c["distance"], start, me.position))
|
||||
&& adv::range_i(&c["duration"], duration)
|
||||
});
|
||||
}
|
||||
/// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use, with
|
||||
/// what the player looks at (`PlayerPredicate.lookingAt`).
|
||||
pub(super) fn trigger_using_item(&mut self, stack: &ItemStack) {
|
||||
if self.remote.is_some() || self.no_player_here() {
|
||||
return;
|
||||
}
|
||||
let mut me = self.player_entity();
|
||||
me.looking_at = self.looking_at_entity().map(Box::new);
|
||||
let it = item_view(stack);
|
||||
self.trigger("minecraft:using_item", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w) && adv::item(&c["item"], &it, w)
|
||||
});
|
||||
}
|
||||
/// The mob a 100 block ray from the eyes meets first, if no block is in
|
||||
/// the way (`ProjectileUtil.getEntityHitResult` and `hasLineOfSight`).
|
||||
fn looking_at_entity(&self) -> Option<EntityView> {
|
||||
let eye = self.eye_position()?;
|
||||
let dir = super::view_vector(self.yaw, self.pitch);
|
||||
let (m, _) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.filter_map(|m| Some((m, super::ray_box(eye, dir, 100.0, m.bounds())?)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
// `LivingEntity.hasLineOfSight`: eye to the target's eye height,
|
||||
// `ClipContext.Block.COLLIDER`, no fluids, within 128 blocks.
|
||||
let from = Vec3::new(eye[0], eye[1], eye[2]);
|
||||
let to = Vec3::new(m.position.x, m.eye_y(), m.position.z);
|
||||
let world = self.mob_world_base(&[]);
|
||||
let clear = to.distance(from) <= 128.0 && world.clear_between(from, to, false);
|
||||
clear.then(|| self.mob_entity(m))
|
||||
}
|
||||
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
|
||||
pub(super) fn trigger_killed_by_arrow(
|
||||
&mut self,
|
||||
victims: &[EntityView],
|
||||
weapon: Option<&ItemStack>,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let victims = victims.to_vec();
|
||||
let weapon = weapon.map(item_view);
|
||||
self.trigger("minecraft:killed_by_arrow", move |c, w| {
|
||||
adv::killed_by_arrow(c, &me, &victims, weapon.as_ref(), w)
|
||||
});
|
||||
}
|
||||
/// `PlayerHurtEntityTrigger`: the player, the damage and the victim.
|
||||
pub(super) fn trigger_player_hurt_entity(
|
||||
&mut self,
|
||||
victim: &EntityView,
|
||||
damage: &adv::DamageView,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let (victim, damage) = (victim.clone(), damage.clone());
|
||||
self.trigger("minecraft:player_hurt_entity", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let vctx = adv::Entities {
|
||||
this: Some(&victim),
|
||||
..pctx
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::damage(&c["damage"], &damage, w, me.position)
|
||||
&& adv::conditions(&c["entity"], &vctx, w)
|
||||
});
|
||||
}
|
||||
/// The player's hit on `victim` as `LivingEntity.hurtServer` reports it:
|
||||
/// `kind` damage of `amount` from `direct` (the player's own hand when
|
||||
/// `None`), nothing blocked.
|
||||
pub(super) fn player_hit(
|
||||
&mut self,
|
||||
victim: &EntityView,
|
||||
kind: &str,
|
||||
direct: Option<EntityView>,
|
||||
amount: f32,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
self.trigger_player_hurt_entity(
|
||||
victim,
|
||||
&adv::DamageView {
|
||||
kind: kind.to_owned(),
|
||||
is_direct: direct.is_none(),
|
||||
direct: direct.or_else(|| Some(me.clone())),
|
||||
source: Some(me),
|
||||
original: amount,
|
||||
actual: amount,
|
||||
blocked: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
/// `EntityHurtPlayerTrigger`: the player and the damage it took.
|
||||
pub(super) fn trigger_entity_hurt_player(&mut self, damage: &adv::DamageView) {
|
||||
let me = self.player_entity();
|
||||
let damage = damage.clone();
|
||||
self.trigger("minecraft:entity_hurt_player", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::damage(&c["damage"], &damage, w, me.position)
|
||||
});
|
||||
}
|
||||
/// A player condition plus one item (`item` key).
|
||||
pub(super) fn trigger_item(&mut self, name: &str, stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
@@ -708,6 +871,101 @@ impl Game<'_> {
|
||||
let it = item_view(stack);
|
||||
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
|
||||
}
|
||||
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
|
||||
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:construct_beacon", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w) && adv::range_i(&c["level"], i64::from(levels))
|
||||
});
|
||||
}
|
||||
/// `BrewedPotionTrigger`: the potion contents of a stack taken from a
|
||||
/// brewing stand.
|
||||
pub(super) fn trigger_brewed_potion(&mut self, stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
let potion = stack.potion().map(str::to_owned);
|
||||
self.trigger("minecraft:brewed_potion", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::potions(&c["potion"], potion.as_deref(), w)
|
||||
});
|
||||
}
|
||||
/// `EnchantedItemTrigger`: the enchanted item and the levels it cost.
|
||||
pub(super) fn trigger_enchanted_item(&mut self, stack: &ItemStack, levels: i32) {
|
||||
let me = self.player_entity();
|
||||
let it = item_view(stack);
|
||||
self.trigger("minecraft:enchanted_item", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::item(&c["item"], &it, w)
|
||||
&& adv::range_i(&c["levels"], i64::from(levels))
|
||||
});
|
||||
}
|
||||
/// `TradeTrigger`: the villager and the item the trade gave.
|
||||
pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
let villager = villager.clone();
|
||||
let it = item_view(stack);
|
||||
self.trigger("minecraft:villager_trade", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let vctx = adv::Entities {
|
||||
this: Some(&villager),
|
||||
..pctx
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::conditions(&c["villager"], &vctx, w)
|
||||
&& adv::item(&c["item"], &it, w)
|
||||
});
|
||||
}
|
||||
/// `FishingRodHookedTrigger`: the rod, what the hook is in (or the hook)
|
||||
/// and the items it caught.
|
||||
pub(super) fn trigger_fishing_rod_hooked(
|
||||
&mut self,
|
||||
rod: &ItemStack,
|
||||
hooked: &EntityView,
|
||||
items: &[ItemStack],
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let rod = item_view(rod);
|
||||
let hooked = hooked.clone();
|
||||
let items: Vec<ItemView> = items.iter().map(item_view).collect();
|
||||
self.trigger("minecraft:fishing_rod_hooked", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let ectx = adv::Entities {
|
||||
this: Some(&hooked),
|
||||
..pctx
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::item(&c["rod"], &rod, w)
|
||||
&& adv::conditions(&c["entity"], &ectx, w)
|
||||
&& (c["item"].is_null() || items.iter().any(|i| adv::item(&c["item"], i, w)))
|
||||
});
|
||||
}
|
||||
/// `EnterBlockTrigger`: the block, its state properties and the player.
|
||||
pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) {
|
||||
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
|
||||
|
||||
@@ -419,9 +419,6 @@ impl Game<'_> {
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::ServerLinks => "server-links",
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
@@ -710,12 +707,9 @@ impl Game<'_> {
|
||||
// the recipe buttons of the page, page arrows, filter and tabs.
|
||||
let book = self.recipe_book_targets();
|
||||
if !book.is_empty() {
|
||||
let (open, filtering) = self.recipe_book_flags().unwrap_or_default();
|
||||
v["recipe_book"] = json!({
|
||||
"targets": book.iter().map(|t| t.rect).collect::<Vec<_>>(),
|
||||
"known": self.recipe_book_known(),
|
||||
"open": open,
|
||||
"filtering": filtering,
|
||||
});
|
||||
}
|
||||
v["slot_ids"] = json!(
|
||||
|
||||
@@ -131,7 +131,8 @@ impl Game<'_> {
|
||||
let m = &mut self.mobs[i];
|
||||
m.extra.ints[1] -= 1;
|
||||
m.extra.ints[3] = 60;
|
||||
self.spawn_item(ItemStack::new(&item, 1), from, v, 40);
|
||||
// ItemEntity.setDefaultPickUpDelay.
|
||||
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
|
||||
if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 {
|
||||
const PITCHES: [f32; 16] = [
|
||||
0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5,
|
||||
@@ -160,7 +161,7 @@ impl Game<'_> {
|
||||
}
|
||||
// Allay.duplicateAllay: entity event 18, three spawnHeartParticle.
|
||||
let id = self.mobs[index].id;
|
||||
self.particles_around_mob(id, "heart", horizon_client::particles::Burst::ALLAY);
|
||||
self.mob_event_particles(id, 18);
|
||||
self.audio
|
||||
.play("minecraft:block.amethyst_block.chime", Some(at), 2.0, 1.0);
|
||||
self.consume_held();
|
||||
|
||||
@@ -164,11 +164,35 @@ impl Game<'_> {
|
||||
let (was, is) = (previous > 0, b.levels > 0);
|
||||
if !was && is {
|
||||
self.beacon_sound([x, y, z], "minecraft:block.beacon.activate");
|
||||
let levels = b.levels;
|
||||
self.for_each_player(|g| g.beacon_constructed([x, y, z], levels));
|
||||
} else if was && !is {
|
||||
self.beacon_sound([x, y, z], "minecraft:block.beacon.deactivate");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `BeaconBlockEntity.tick` after activating: `CONSTRUCT_BEACON` for a
|
||||
/// player in the box `AABB(x, y, z, x, y - 4, z).inflate(10, 5, 10)`.
|
||||
fn beacon_constructed(&mut self, p: [i32; 3], levels: i32) {
|
||||
let Some(bb) = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|pl| pl.dimensions.bounds(pl.position))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let [x, y, z] = p.map(f64::from);
|
||||
if lo.x < x + 10.0
|
||||
&& hi.x > x - 10.0
|
||||
&& lo.y < y + 5.0
|
||||
&& hi.y > y - 9.0
|
||||
&& lo.z < z + 10.0
|
||||
&& hi.z > z - 10.0
|
||||
{
|
||||
self.trigger_construct_beacon(levels);
|
||||
}
|
||||
}
|
||||
pub(super) fn beacon_sound(&mut self, p: [i32; 3], sound: &str) {
|
||||
let at = [
|
||||
f64::from(p[0]) + 0.5,
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
//! player breaks the hive or fire reaches it.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
beehive::{self, Beehive, HiveHost, Release},
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
mobs::{Mob, Who, bee},
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -194,19 +196,139 @@ impl Game<'_> {
|
||||
}
|
||||
self.anger_nearby_bees(at);
|
||||
}
|
||||
/// `BeehiveBlock.useItemOn` with `held` in the acting hand: shears take
|
||||
/// the honeycombs and a glass bottle a honey bottle from a full hive
|
||||
/// (honey level 5), then the honey level resets and, unless a campfire
|
||||
/// smokes the hive, the bees come out angry. True when it took the click.
|
||||
pub(super) fn use_beehive(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> Result<bool> {
|
||||
let Some(held) = held else { return Ok(false) };
|
||||
let shears = &*held.item == "minecraft:shears";
|
||||
if !shears && &*held.item != "minecraft:glass_bottle" {
|
||||
return Ok(false);
|
||||
}
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !matches!(
|
||||
&*self.res.facts().get(state).name,
|
||||
"minecraft:beehive" | "minecraft:bee_nest"
|
||||
) {
|
||||
return Ok(false);
|
||||
}
|
||||
let prop = |key: &str| {
|
||||
self.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get(key).cloned())
|
||||
};
|
||||
if prop("honey_level").and_then(|v| v.parse::<i32>().ok()) != Some(5) {
|
||||
return Ok(false);
|
||||
}
|
||||
let facing = beehive::facing_step(prop("facing").as_deref());
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
if shears {
|
||||
// dropHoneycomb (harvest/beehive), the shear sound, hurtAndBreak.
|
||||
for stack in self.sequence_loot(
|
||||
"minecraft:harvest/beehive",
|
||||
"minecraft:harvest/beehive",
|
||||
Some(held),
|
||||
) {
|
||||
self.pop_resource(pos, stack);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:block.beehive.shear", at, 1.0, 1.0);
|
||||
self.damage_held(1);
|
||||
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
|
||||
} else {
|
||||
// shrink(1), then the honey bottle in the hand, the inventory or
|
||||
// on the ground.
|
||||
self.audio.play("minecraft:item.bottle.fill", at, 1.0, 1.0);
|
||||
let cell = self.held_mut();
|
||||
if let Some(s) = cell.as_mut() {
|
||||
s.count -= 1;
|
||||
if s.count == 0 {
|
||||
*cell = None;
|
||||
}
|
||||
}
|
||||
let bottle = ItemStack::new("minecraft:honey_bottle", 1);
|
||||
if self.held().is_none() {
|
||||
*self.held_mut() = Some(bottle);
|
||||
} else if let Some(rest) = self.give(bottle)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40);
|
||||
}
|
||||
self.post_block_game_event("minecraft:fluid_pickup", pos, self.sculk_actor(), None);
|
||||
}
|
||||
self.stats.add("used", &held.item, 1);
|
||||
let smokey = beehive::smokey(&self.mob_world_base(&[]), pos);
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if !smokey
|
||||
&& matches!(self.world.block_entities.get(&key), Some(BlockEntity::Beehive(h)) if !h.bees.is_empty())
|
||||
{
|
||||
self.anger_nearby_bees(pos);
|
||||
}
|
||||
// resetHoneyLevel.
|
||||
if let Some(reset) = self.with_props(state, &[("honey_level", "0")]) {
|
||||
self.level_set(pos, reset)?;
|
||||
}
|
||||
if !smokey
|
||||
&& let Some(BlockEntity::Beehive(mut hive)) = self.world.block_entities.take(&key)
|
||||
{
|
||||
// emptyAllLivingFromHive: the bees within 4 blocks of the player
|
||||
// go for them.
|
||||
let released = hive.release_all(
|
||||
pos,
|
||||
facing,
|
||||
None,
|
||||
Release::Emergency,
|
||||
&mut GameHive { game: self },
|
||||
);
|
||||
self.world
|
||||
.block_entities
|
||||
.put(key, BlockEntity::Beehive(hive));
|
||||
if let Some(p) = self.player_view().map(|p| p.position) {
|
||||
for m in self.mobs.iter_mut().filter(|m| released.contains(&m.id)) {
|
||||
if m.distance_sqr(p) <= 16.0 {
|
||||
m.ai.target = Some(Who::Player);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ServerPlayerGameMode.useItemOn: the action was consumed.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, held);
|
||||
Ok(true)
|
||||
}
|
||||
/// `BeehiveBlock.angerNearbyBees`: bees within 8 blocks (6 up and down)
|
||||
/// without a target go for a player in the same box.
|
||||
fn anger_nearby_bees(&mut self, at: [i32; 3]) {
|
||||
/// without a target go for a random player in the same box, the window's
|
||||
/// own or a guest (`None` is the window's player).
|
||||
pub(super) fn anger_nearby_bees(&mut self, at: [i32; 3]) {
|
||||
let min = Vec3::new(
|
||||
f64::from(at[0]) - 8.0,
|
||||
f64::from(at[1]) - 6.0,
|
||||
f64::from(at[2]) - 8.0,
|
||||
);
|
||||
let area = Aabb::new(min, Vec3::new(min.x + 17.0, min.y + 13.0, min.z + 17.0));
|
||||
if !self
|
||||
let mut players: Vec<Option<u32>> = Vec::new();
|
||||
if self
|
||||
.player_view()
|
||||
.is_some_and(|p| p.bounds.intersects(area))
|
||||
{
|
||||
players.push(None);
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
players.extend(
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.filter(|g| super::lan_mobs::guest_bounds(g).intersects(area))
|
||||
.map(|g| Some(g.conn)),
|
||||
);
|
||||
}
|
||||
if players.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut bees: Vec<usize> = (0..self.mobs.len())
|
||||
@@ -219,9 +341,20 @@ impl Game<'_> {
|
||||
.collect();
|
||||
bees.retain(|&i| self.mobs[i].ai.target.is_none());
|
||||
for i in bees {
|
||||
// Util.getRandom over the one player.
|
||||
let _ = self.world.random.next_int_bound(1);
|
||||
// Util.getRandom over the players.
|
||||
let pick = self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound(players.len() as i32)
|
||||
.unwrap_or(0) as usize;
|
||||
self.mobs[i].ai.target = Some(Who::Player);
|
||||
// The player a mob sees is its guest binding, else the window's.
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
match players[pick] {
|
||||
Some(conn) => host.mob_players.insert(self.mobs[i].id, conn),
|
||||
None => host.mob_players.remove(&self.mobs[i].id),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,23 +76,24 @@ impl Game<'_> {
|
||||
/// A player opened (`open`) or closed the container at `key`: the opener
|
||||
/// count block event (`ContainerOpenersCounter` / `ShulkerBoxBlockEntity`
|
||||
/// `startOpen`/`stopOpen`) for chests, ender chests and shulker boxes,
|
||||
/// on both halves of a double chest (`CompoundContainer`).
|
||||
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
/// on both halves of a double chest (`CompoundContainer`). True when the
|
||||
/// count went from 0 to 1 or back to 0: the only moments the container
|
||||
/// plays its open or close sound.
|
||||
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) -> bool {
|
||||
// Spectators never count as openers.
|
||||
if !self.spectator() {
|
||||
self.container_openers_changed(key, open);
|
||||
}
|
||||
!self.spectator() && self.container_openers_changed(key, open)
|
||||
}
|
||||
/// `container_opener_event` for a player who is not a spectator (a guest).
|
||||
pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
|
||||
let pos = [key.0, key.1, key.2];
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]).0;
|
||||
match self.be_anims.ticker(state) {
|
||||
Some(Ticker::Lid) => {
|
||||
self.lid_openers_changed(key, open);
|
||||
let edge = self.lid_openers_changed(key, open);
|
||||
if let Some(other) = self.chest_other_half(key) {
|
||||
self.lid_openers_changed(other, open);
|
||||
}
|
||||
edge
|
||||
}
|
||||
_ if self
|
||||
.res
|
||||
@@ -102,26 +103,27 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
== "minecraft:barrel" =>
|
||||
{
|
||||
self.barrel_openers_changed(key, open);
|
||||
self.barrel_openers_changed(key, open)
|
||||
}
|
||||
Some(Ticker::Shulker { .. }) => {
|
||||
let shulker = self.be_anims.shulkers.entry(pos).or_default();
|
||||
let (count, _) = if open {
|
||||
let (count, edge) = if open {
|
||||
shulker.start_open()
|
||||
} else {
|
||||
shulker.stop_open()
|
||||
};
|
||||
self.block_entity_event(pos, 1, count);
|
||||
self.lan_block_event(pos, 1, count);
|
||||
edge
|
||||
}
|
||||
_ => {}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// `ContainerOpenersCounter.incrementOpeners` / `decrementOpeners` of the
|
||||
/// chest at `key` (the host and the guests are counted), then
|
||||
/// `ChestBlockEntity.signalOpenCount`: block event 1 with the count, here
|
||||
/// and for the guests.
|
||||
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
/// and for the guests. True when the count crossed zero.
|
||||
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
|
||||
let (before, count) = self.count_opener(key, open);
|
||||
let pos = [key.0, key.1, key.2];
|
||||
self.block_entity_event(pos, 1, count);
|
||||
@@ -139,12 +141,15 @@ impl Game<'_> {
|
||||
b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block);
|
||||
});
|
||||
}
|
||||
(before == 0) != (count == 0)
|
||||
}
|
||||
/// `BarrelBlockEntity.openersCounter`: the first opener opens the barrel
|
||||
/// (`open=true`), the last one to leave closes it (`updateBlockState`).
|
||||
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
/// True when the count crossed zero.
|
||||
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
|
||||
let (before, count) = self.count_opener(key, open);
|
||||
if (before == 0) != (count == 0) {
|
||||
let edge = (before == 0) != (count == 0);
|
||||
if edge {
|
||||
self.set_bool_prop(
|
||||
key,
|
||||
"open",
|
||||
@@ -152,6 +157,7 @@ impl Game<'_> {
|
||||
horizon_world::level::flags::UPDATE_ALL,
|
||||
);
|
||||
}
|
||||
edge
|
||||
}
|
||||
/// One opener more (`open`) or less at `key`: the count before and after
|
||||
/// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`).
|
||||
|
||||
@@ -862,7 +862,7 @@ impl Game<'_> {
|
||||
let a = alpha as f32;
|
||||
let range = super::entity_culling::default_range(
|
||||
glam::DVec3::new(WIDTH, HEIGHT, WIDTH),
|
||||
self.view_scale(),
|
||||
self.settings.entity_distance,
|
||||
);
|
||||
for b in &self.boats {
|
||||
let entity_start = out.len();
|
||||
|
||||
@@ -1009,12 +1009,9 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let indicator = |batch: &mut HudBatch, page: usize, count: usize| {
|
||||
// BookViewScreen.getPageNumberMessage: 26.3's en_us template is
|
||||
// positional ("Page %1$s of %2$s").
|
||||
let text = horizon_protocol::text::format_java(
|
||||
&tr("book.pageIndicator", "Page %1$s of %2$s"),
|
||||
&[(page + 1).to_string(), count.max(1).to_string()],
|
||||
);
|
||||
let text = tr("book.pageIndicator", "Page %s of %s")
|
||||
.replacen("%s", &(page + 1).to_string(), 1)
|
||||
.replacen("%s", &count.max(1).to_string(), 1);
|
||||
let tw = HudBatch::text_width(atlas, &text);
|
||||
batch.text(atlas, &text, left + 148.0 - tw, top + 16.0, BLACK, false);
|
||||
};
|
||||
|
||||
@@ -789,6 +789,11 @@ impl Game<'_> {
|
||||
pub(super) fn sync_rules(&mut self) {
|
||||
self.world.health_rules.natural_regeneration =
|
||||
self.ui.rules.bool("natural_health_regeneration");
|
||||
// Player.isInvulnerableTo: the damage-type rules.
|
||||
self.world.health_rules.drowning_damage = self.ui.rules.bool("drowning_damage");
|
||||
self.world.health_rules.fall_damage = self.ui.rules.bool("fall_damage");
|
||||
self.world.health_rules.fire_damage = self.ui.rules.bool("fire_damage");
|
||||
self.world.health_rules.freeze_damage = self.ui.rules.bool("freeze_damage");
|
||||
use horizon_world::food::Difficulty;
|
||||
self.world.health_rules.difficulty = match self.ui.difficulty {
|
||||
0 => Difficulty::Peaceful,
|
||||
|
||||
@@ -36,6 +36,13 @@ pub(super) struct CombatTracker {
|
||||
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
|
||||
/// fire decides whom the blast may hurt.
|
||||
pub(super) explosion_source: Option<u32>,
|
||||
/// The blast now going off comes from a primed TNT, creeper, wither
|
||||
/// skull, wither or TNT minecart, the sources that empty a hive they
|
||||
/// destroy (`BeehiveBlock.getDrops`).
|
||||
pub(super) hive_emptying_blast: bool,
|
||||
/// The mob that owns the projectile exploding now (`getIndirectSourceEntity`):
|
||||
/// the mobs the blast hurts record it as their attacker.
|
||||
pub(super) explosion_owner: Option<u32>,
|
||||
/// Charged creepers whose blast just went off and that have not dropped
|
||||
/// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list,
|
||||
/// but the mobs they killed still ask for their loot.
|
||||
@@ -90,6 +97,19 @@ impl Game<'_> {
|
||||
self.recheck_combat();
|
||||
for e in events {
|
||||
let (name, amount) = match e {
|
||||
// LivingEntity.getDamageAfterMagicAbsorb (Resistance) and
|
||||
// Player.actuallyHurt (absorption hearts) and
|
||||
// LivingEntity.hurtServer (the shield, only for an accepted
|
||||
// hit), in tenths.
|
||||
MotionEvent::Health(HealthEvent::Statistic { name, amount }) => {
|
||||
match *name {
|
||||
"resisted" => self.stats.custom("damage_resisted", *amount),
|
||||
"absorbed" => self.stats.custom("damage_absorbed", *amount),
|
||||
"blocked" => self.stats.custom("damage_blocked_by_shield", *amount),
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
MotionEvent::Health(HealthEvent::RecordedDamage { source, amount }) => {
|
||||
let Some(d) = self.motion.health.damage.definition(*source) else {
|
||||
continue;
|
||||
@@ -136,6 +156,10 @@ impl Game<'_> {
|
||||
direct,
|
||||
});
|
||||
self.combat.last_damage = now;
|
||||
// InfestedMobEffect.onMobHurt.
|
||||
if self.potions.effects.get("infested").is_some() {
|
||||
self.player_infested();
|
||||
}
|
||||
}
|
||||
self.combat.pending.clear();
|
||||
self.combat.pending_direct.clear();
|
||||
|
||||
@@ -907,12 +907,10 @@ impl Game<'_> {
|
||||
.coords(c, "pos")
|
||||
.map(|co| co.block(self.source_position(), self.source_rotation()));
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
// ExecuteCommand.isChunkLoaded: ENTITY_TICKING, which is past lit.
|
||||
let loaded = |p: [i32; 3]| {
|
||||
let key = (p[0] >> 4, p[2] >> 4);
|
||||
self.world.chunks.contains_key(&key) && self.streaming.is_lit(key.0, key.1)
|
||||
};
|
||||
return Ok(flag(dimension == self.dim && p.is_some_and(loaded)));
|
||||
return Ok(flag(
|
||||
dimension == self.dim
|
||||
&& p.is_some_and(|p| self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4))),
|
||||
));
|
||||
}
|
||||
if c.has("dimension") {
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
|
||||
@@ -19,17 +19,7 @@ use std::convert::Infallible;
|
||||
|
||||
type ItemConsumable = Consumable<String, ConsumeEffect>;
|
||||
|
||||
/// `EntityEvent.USE_ITEM_COMPLETE`: the server finished the player's use.
|
||||
pub(super) const USE_ITEM_COMPLETE_EVENT: i8 = 9;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ServerPlayer.completeUsingItem`: the player's own client is told
|
||||
/// first, and completes the use itself (eating the food there too).
|
||||
pub(super) fn send_use_item_complete(&mut self) {
|
||||
if self.lan_acting.is_some() {
|
||||
self.guest_entity_events.push(USE_ITEM_COMPLETE_EVENT);
|
||||
}
|
||||
}
|
||||
/// `Consumable.emitParticlesAndSounds(user.getRandom(), user, stack, count)`.
|
||||
pub(super) fn emit_consume(
|
||||
&mut self,
|
||||
@@ -67,6 +57,17 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:entity.player.burp", self.feet(), 0.5, pitch);
|
||||
}
|
||||
// SuspiciousStewEffects.onConsume, a ConsumableListener: each entry is
|
||||
// added as a `MobEffectInstance(effect, duration)` (amplifier 0).
|
||||
if let Some(data) = stack.extra.as_deref() {
|
||||
for (id, duration) in &data.stew_effects {
|
||||
if let Some(def) = horizon_client::brewing::effect(id) {
|
||||
self.add_player_effect(horizon_client::effects::EffectInstance::new(
|
||||
def.id, *duration, 0,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for effect in &consumable.effects {
|
||||
self.apply_consume_effect(effect);
|
||||
}
|
||||
|
||||
@@ -878,9 +878,6 @@ impl Game<'_> {
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::ServerLinks => "server-links",
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
|
||||
@@ -44,13 +44,13 @@ impl Game<'_> {
|
||||
slot: usize,
|
||||
stack: Option<ItemStack>,
|
||||
) {
|
||||
if let Some(e) = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_mut(&(chest[0], chest[1], chest[2]))
|
||||
let key = (chest[0], chest[1], chest[2]);
|
||||
if let Some(e) = self.world.block_entities.get_mut(&key)
|
||||
&& let Some(s) = e.slots_mut().get_mut(slot)
|
||||
{
|
||||
*s = stack;
|
||||
// TransportItemsBetweenContainers calls Container.setChanged.
|
||||
self.container_changed(key);
|
||||
}
|
||||
}
|
||||
/// `ContainerOpenersCounter.recheckOpeners` every five ticks: the golems
|
||||
@@ -204,6 +204,64 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// ---- interactions -----------------------------------------------------
|
||||
/// `WeatheringCopperGolemStatueBlock.useItemOn`: an axe on an unoxidized
|
||||
/// statue turns it back into a golem (`CopperGolemStatueBlockEntity.removeStatue`),
|
||||
/// wearing the axe. Weathered statues are scraped by the axe's own
|
||||
/// transform instead.
|
||||
pub(super) fn use_golem_statue(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
if !held.is_some_and(|s| s.item.ends_with("_axe")) {
|
||||
return false;
|
||||
}
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(state) = registry.state(self.world.block(pos[0], pos[1], pos[2])) else {
|
||||
return false;
|
||||
};
|
||||
if state.name != "minecraft:copper_golem_statue" {
|
||||
return false;
|
||||
}
|
||||
// `Direction.toYRot` of the statue's facing.
|
||||
let yaw = match state.properties.get("facing").map(String::as_str) {
|
||||
Some("west") => 90.0,
|
||||
Some("north") => 180.0,
|
||||
Some("east") => 270.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
let waterlogged = state
|
||||
.properties
|
||||
.get("waterlogged")
|
||||
.is_some_and(|w| w == "true");
|
||||
// initCopperGolem: at the block's feet, facing as the statue does.
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let Some(id) = self.spawn_mob("minecraft:copper_golem", at, true) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.yaw = yaw;
|
||||
m.previous_yaw = yaw;
|
||||
m.body_yaw = yaw;
|
||||
m.previous_body_yaw = yaw;
|
||||
m.head_yaw = yaw;
|
||||
m.previous_head_yaw = yaw;
|
||||
}
|
||||
self.damage_held(1);
|
||||
self.audio
|
||||
.play("minecraft:entity.copper_golem.spawn", Some(at), 1.0, 1.0);
|
||||
// Level.removeBlock(pos, false): air, or the water the statue stood in.
|
||||
let replacement = registry.default_state(if waterlogged {
|
||||
"minecraft:water"
|
||||
} else {
|
||||
"minecraft:air"
|
||||
});
|
||||
if let Some(replacement) = replacement {
|
||||
let _ = self.level_set(pos, replacement);
|
||||
}
|
||||
self.world.block_entities.remove(&(pos[0], pos[1], pos[2]));
|
||||
true
|
||||
}
|
||||
/// `CopperGolem.mobInteract`; true when handled.
|
||||
pub(super) fn interact_copper_golem(&mut self, index: usize, item: &str) -> bool {
|
||||
if self.mobs[index].species() != Species::CopperGolem || !self.mobs[index].alive() {
|
||||
|
||||
@@ -46,10 +46,22 @@ pub(super) struct Creatures {
|
||||
pub projectiles: Vec<Projectile>,
|
||||
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
|
||||
pub wind_bursts: Vec<super::wind::Burst>,
|
||||
/// Explosion packets from a guest's turn, waiting for it to end
|
||||
/// (centre, small, radius, blocks hit; game/lan_particles.rs).
|
||||
pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>,
|
||||
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
|
||||
pub elder_apparition: i32,
|
||||
/// The wandering trader spawner's clock.
|
||||
pub trader: super::wandering_trader::Spawner,
|
||||
/// Particles from a guest's turn, waiting for it to end
|
||||
/// (`lan_send_particles`: options, centre, spread, speed, count).
|
||||
pub lan_particles: Vec<(
|
||||
horizon_client::particles::ParticleOptions,
|
||||
[f64; 3],
|
||||
[f64; 3],
|
||||
f64,
|
||||
u32,
|
||||
)>,
|
||||
}
|
||||
pub(super) struct ProjWorld<'a, 'w> {
|
||||
pub w: &'a MobWorld<'w>,
|
||||
@@ -187,6 +199,7 @@ impl Game<'_> {
|
||||
off_hand: None,
|
||||
head: self.armor[0].as_ref().map(|s| s.item.clone()),
|
||||
armor_cover: self.armor.iter().filter(|a| a.is_some()).count() as f32 / 4.0,
|
||||
invisible: self.potions.effects.get("invisibility").is_some(),
|
||||
spectator: self.spectator(),
|
||||
gold_armor: self.wearing_gold(),
|
||||
hurt_by: self.player_hurt_by(),
|
||||
@@ -198,6 +211,7 @@ impl Game<'_> {
|
||||
uuid: self.level.player_uuid.unwrap_or_default(),
|
||||
allies: std::sync::Arc::new(self.player_allies()),
|
||||
remote: false,
|
||||
passenger: self.riding.is_some(),
|
||||
})
|
||||
}
|
||||
/// Biome facts at a position for finalizeSpawn and variant rules.
|
||||
@@ -419,6 +433,7 @@ impl Game<'_> {
|
||||
block_tags: &self.res.items().block_tags,
|
||||
biomes: self.res.biomes(),
|
||||
mob_griefing: self.ui.rules.bool("mob_griefing"),
|
||||
mob_drops: self.ui.rules.bool("mob_drops"),
|
||||
end: self.dim == Dim::End,
|
||||
world_seed: self.level.seed,
|
||||
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
|
||||
@@ -512,6 +527,7 @@ impl Game<'_> {
|
||||
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 ticking = self.entity_ticking_area();
|
||||
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
|
||||
@@ -606,6 +622,11 @@ impl Game<'_> {
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
let from = events.len();
|
||||
let kept = m.tick(&w, &mut scratch, &mut events);
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
// death run (`LivingEntity.remove` with KILLED).
|
||||
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
|
||||
corpses.push(m.clone());
|
||||
}
|
||||
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
|
||||
// LivingEntity.tickDeath: after twenty ticks the body is removed.
|
||||
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
|
||||
@@ -685,6 +706,16 @@ impl Game<'_> {
|
||||
for m in dead {
|
||||
self.mob_died(&m);
|
||||
}
|
||||
for mut m in corpses {
|
||||
let names: Vec<&'static str> = m
|
||||
.extra
|
||||
.effects
|
||||
.iter()
|
||||
.map(|e| e.0.trim_start_matches("minecraft:"))
|
||||
.collect();
|
||||
let bounds = m.bounds();
|
||||
self.effects_on_death(&names, bounds, false, Some(m.id), Some(&mut m.random));
|
||||
}
|
||||
self.handle_mob_events(events)?;
|
||||
self.lan_guest_mob_events(guest_events);
|
||||
self.tick_projectiles()?;
|
||||
@@ -757,6 +788,7 @@ impl Game<'_> {
|
||||
self.villager_murdered(m, by_player);
|
||||
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
|
||||
self.drop_death_loot(&mut dying, by_player, consumed, pos);
|
||||
self.create_wither_rose(m);
|
||||
if m.species() == Species::CopperGolem {
|
||||
// CopperGolem.dropEquipment: the preserved equipment too.
|
||||
self.golem_drop_equipment(&mut dying);
|
||||
@@ -887,6 +919,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `InfestedMobEffect.onMobHurt` for a mob: silverfish from half its height up.
|
||||
pub(super) fn mob_infested(&mut self, mob: u32) {
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
|
||||
let at = [
|
||||
m.position.x,
|
||||
m.position.y + f64::from(m.height()) / 2.0,
|
||||
m.position.z,
|
||||
];
|
||||
let look = m.view_vector();
|
||||
self.infested_hurt(at, look, Some(mob));
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.dropFromGiftLootTable`: the gift table from its random
|
||||
/// sequence with the mob as `this` (a chicken's egg depends on its
|
||||
/// variant); the loot data of the game's resources decides, and without it
|
||||
@@ -980,21 +1024,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::Shift { target, delta } => {
|
||||
let d = Vec3::new(delta[0], delta[1], delta[2]);
|
||||
match target {
|
||||
Who::Player => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = p.position.add_vector(d);
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.position = m.position.add_vector(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::Wololo { mob } => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
// DyeColor.RED.
|
||||
@@ -1014,7 +1043,7 @@ impl Game<'_> {
|
||||
self.combat.charged_exploders.push(mob);
|
||||
}
|
||||
}
|
||||
MobEvent::Explode(at, radius) => {
|
||||
MobEvent::Explode(at, radius, species) => {
|
||||
// The exploding mob causes the damage (Creeper.explodeCreeper).
|
||||
let source_mob = self.combat.explosion_source.or_else(|| {
|
||||
self.mobs
|
||||
@@ -1030,7 +1059,14 @@ impl Game<'_> {
|
||||
self.note_attacker("minecraft:explosion", id);
|
||||
}
|
||||
self.combat.explosion_source = source_mob;
|
||||
let result = self.explode(at, radius);
|
||||
// Only a creeper or a wither empties the hives it blows up.
|
||||
// The creeper is already gone from the list, so the event
|
||||
// carries its kind.
|
||||
let result = if matches!(species, Species::Creeper | Species::Wither) {
|
||||
self.with_hive_emptying_blast(|g| g.explode(at, radius))
|
||||
} else {
|
||||
self.explode(at, radius)
|
||||
};
|
||||
self.combat.explosion_source = None;
|
||||
result?
|
||||
}
|
||||
@@ -1041,6 +1077,7 @@ impl Game<'_> {
|
||||
} => {
|
||||
let mut killed = false;
|
||||
let mut hurt = false;
|
||||
let mut infested = false;
|
||||
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
|
||||
let hit = t.damage(amount, source);
|
||||
hurt = hit;
|
||||
@@ -1049,20 +1086,27 @@ impl Game<'_> {
|
||||
// The hurt or death sound the mob raised.
|
||||
let center = t.center();
|
||||
for event in std::mem::take(&mut t.outbox) {
|
||||
if let MobEvent::Sound(s, _, volume, pitch) = event {
|
||||
self.audio.play(
|
||||
&format!("minecraft:{s}"),
|
||||
Some(center),
|
||||
volume,
|
||||
pitch,
|
||||
);
|
||||
} else {
|
||||
// Left for the end of the tick.
|
||||
t.outbox.push(event);
|
||||
match event {
|
||||
MobEvent::Sound(s, _, volume, pitch) => {
|
||||
self.audio.play(
|
||||
&format!("minecraft:{s}"),
|
||||
Some(center),
|
||||
volume,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
MobEvent::Infested { .. } => infested = true,
|
||||
other => {
|
||||
// Left for the end of the tick.
|
||||
t.outbox.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if infested {
|
||||
self.mob_infested(target);
|
||||
}
|
||||
if hurt
|
||||
&& let Some(Who::Mob(attacker)) = source.attacker
|
||||
&& let Some(m) = self
|
||||
@@ -1108,6 +1152,7 @@ impl Game<'_> {
|
||||
}
|
||||
MobEvent::BecomeStatue { mob } => self.golem_become_statue(mob),
|
||||
MobEvent::EnterHive { mob, hive } => self.bee_enter_hive(mob, hive),
|
||||
MobEvent::Infested { mob } => self.mob_infested(mob),
|
||||
MobEvent::Gift { target, item } => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == target)
|
||||
&& m.saddle_item().is_none()
|
||||
@@ -1271,12 +1316,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
MobEvent::AroundSelf {
|
||||
mob,
|
||||
event,
|
||||
kind,
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
burst,
|
||||
} => self.particles_around(kind, feet, [width, height], burst),
|
||||
} => {
|
||||
self.lan_entity_event(mob as i32, event, feet);
|
||||
self.particles_around(kind, feet, [width, height], burst)
|
||||
}
|
||||
MobEvent::ParticleAt { kind, at, velocity } => {
|
||||
if let Some(ty) = horizon_client::particles::ParticleType::from_name(kind) {
|
||||
let options = horizon_client::particles::ParticleOptions::simple(ty);
|
||||
@@ -1387,6 +1437,22 @@ impl Game<'_> {
|
||||
m.ai.aggressive |= aggressive;
|
||||
}
|
||||
}
|
||||
MobEvent::ShedScute { mob, at, pitch } => {
|
||||
// The mob already checked `shouldDropLoot` (a baby, `mob_drops`).
|
||||
let table = "minecraft:gameplay/armadillo_shed";
|
||||
let drops = self.sequence_loot(table, table, None);
|
||||
for d in drops {
|
||||
self.spawn_at_location(Vec3::new(at[0], at[1], at[2]), 0.0, d);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:entity.armadillo.scute_drop",
|
||||
Some(at),
|
||||
1.0,
|
||||
pitch,
|
||||
);
|
||||
let ctx = self.who_context(Some(Who::Mob(mob)), None);
|
||||
self.post_game_event("minecraft:entity_place", at, ctx);
|
||||
}
|
||||
MobEvent::GiftLoot { mob, table, at } => {
|
||||
// Turtle.ageBoundaryReached: only with mob loot on.
|
||||
if table == "minecraft:gameplay/turtle_grow" && !self.ui.rules.bool("mob_drops")
|
||||
@@ -1515,6 +1581,8 @@ impl Game<'_> {
|
||||
q.extra.in_love = 0;
|
||||
}
|
||||
let q = self.mobs.iter().find(|m| m.id == partner).cloned();
|
||||
// Animal.finalizeSpawnChildFromBreeding: entity event 18 for the parent.
|
||||
self.mob_event_particles(parent, 18);
|
||||
// Sniffer.spawnChildFromBreeding: an egg instead of a baby.
|
||||
if p.species() == Species::Sniffer {
|
||||
self.lay_sniffer_egg(&p);
|
||||
@@ -1787,6 +1855,10 @@ impl Game<'_> {
|
||||
let (xd, zd) = from.map_or((0.0, 0.0), |from| {
|
||||
(from[0] - player.position.x, from[2] - player.position.z)
|
||||
});
|
||||
// dealDefaultKnockback's `indicateDamage`, unless blocked.
|
||||
if o.blocked <= 0.0 {
|
||||
player.health.indicate_damage(xd, zd, player.yaw);
|
||||
}
|
||||
self.knock_back_player(f64::from(0.4_f32), xd, zd);
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(r) = p.hurt_log.last_mut()
|
||||
@@ -1799,6 +1871,23 @@ impl Game<'_> {
|
||||
self.thorns(a);
|
||||
}
|
||||
}
|
||||
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned
|
||||
// early (invulnerable, or inside the hurt cooldown).
|
||||
if outcome.hurt || outcome.sound {
|
||||
let source = attacker
|
||||
.or(owner)
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|m| self.mob_entity(m));
|
||||
self.trigger_entity_hurt_player(&horizon_client::advancements::DamageView {
|
||||
kind: damage_type.to_owned(),
|
||||
direct: source.clone().filter(|_| !projectile),
|
||||
is_direct: !projectile,
|
||||
source,
|
||||
original: amount,
|
||||
actual: amount - outcome.blocked,
|
||||
blocked: outcome.blocked > 0.0,
|
||||
});
|
||||
}
|
||||
if raised {
|
||||
self.after_block(outcome, amount, attacker, projectile);
|
||||
}
|
||||
@@ -2100,6 +2189,7 @@ impl Game<'_> {
|
||||
_ => self.projectile_hit(&p, hit)?,
|
||||
}
|
||||
}
|
||||
self.flush_lan_explosions();
|
||||
self.flush_wind_bursts()
|
||||
}
|
||||
/// `projectile_hit` for a projectile the guest `conn` shot, run as them;
|
||||
@@ -2451,6 +2541,35 @@ impl Game<'_> {
|
||||
.is_some_and(|m| m.damage(amount, source));
|
||||
if p.owner == Some(Who::Player) {
|
||||
self.last_hurt_by_player.insert(id, self.lan_acting);
|
||||
if hurt && let Some(m) = self.mobs.iter().find(|m| m.id == id) {
|
||||
// LivingEntity.hurtServer: PLAYER_HURT_ENTITY.
|
||||
let victim = self.mob_entity(m);
|
||||
let spectral = p.arrow.as_ref().is_some_and(|a| {
|
||||
&*a.pickup_item.item == "minecraft:spectral_arrow"
|
||||
});
|
||||
let direct = horizon_client::advancements::EntityView {
|
||||
kind: if spectral {
|
||||
"minecraft:spectral_arrow"
|
||||
} else {
|
||||
"minecraft:arrow"
|
||||
}
|
||||
.into(),
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
..Default::default()
|
||||
};
|
||||
self.player_hit(&victim, "minecraft:arrow", Some(direct), amount);
|
||||
}
|
||||
// AbstractArrow.onHitEntity: KILLED_BY_ARROW for a kill
|
||||
// (piercing arrows are not modelled).
|
||||
if hurt
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
|
||||
&& !m.alive()
|
||||
{
|
||||
let victim = self.mob_entity(m);
|
||||
let weapon =
|
||||
p.arrow.as_ref().and_then(|a| a.fired_from_weapon.as_ref());
|
||||
self.trigger_killed_by_arrow(&[victim], weapon);
|
||||
}
|
||||
}
|
||||
hurt
|
||||
}
|
||||
@@ -2494,6 +2613,7 @@ impl Game<'_> {
|
||||
};
|
||||
m.damage(amount, source);
|
||||
}
|
||||
self.thrown_break_event(p.id, atp);
|
||||
self.item_burst(atp, "minecraft:snowball");
|
||||
}
|
||||
(ProjectileKind::Egg, hit) => {
|
||||
@@ -2519,6 +2639,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.thrown_break_event(p.id, atp);
|
||||
self.item_burst(atp, "minecraft:egg");
|
||||
}
|
||||
(ProjectileKind::EnderPearl, hit) => {
|
||||
@@ -2622,7 +2743,10 @@ impl Game<'_> {
|
||||
if let Some(owner) = self.combat.projectile_owner {
|
||||
self.note_attacker("minecraft:explosion", owner);
|
||||
}
|
||||
self.explode(atp, *power as f32)?;
|
||||
self.combat.explosion_owner = self.combat.projectile_owner;
|
||||
let result = self.explode(atp, *power as f32);
|
||||
self.combat.explosion_owner = None;
|
||||
result?;
|
||||
}
|
||||
(
|
||||
ProjectileKind::Potion {
|
||||
@@ -2768,10 +2892,17 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `Snowball`/`ThrownEgg.onHit`: entity event 3 for the guests, before the
|
||||
/// projectile is discarded (the clients lose it when the tracker next runs).
|
||||
fn thrown_break_event(&mut self, id: u32, at: [f64; 3]) {
|
||||
if id != 0 {
|
||||
self.lan_entity_event(id as i32, 3, at);
|
||||
}
|
||||
}
|
||||
/// `Snowball`/`ThrownEgg.handleEntityEvent(3)`: eight crumbs of the
|
||||
/// thrown item where it broke, at rest for a snowball, scattered by the
|
||||
/// client entity's random (the particle engine's here) for an egg.
|
||||
fn item_burst(&mut self, at: [f64; 3], item: &str) {
|
||||
pub(super) fn item_burst(&mut self, at: [f64; 3], item: &str) {
|
||||
let options = self.item_particle(&horizon_client::items::ItemStack::new(item, 1));
|
||||
let egg = item != "minecraft:snowball";
|
||||
for _ in 0..8 {
|
||||
@@ -2807,6 +2938,10 @@ impl Game<'_> {
|
||||
if self.name_mob(index) {
|
||||
return Ok(true);
|
||||
}
|
||||
if self.spawn_egg_offspring(index) {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
{
|
||||
let item = self.held().map(|h| h.item.to_string()).unwrap_or_default();
|
||||
if self.interact_leash(index, &item) {
|
||||
@@ -2868,6 +3003,14 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.interact_armadillo(index, &item) {
|
||||
if let Some(stack) = &held {
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
|
||||
}
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.interact_copper_golem(index, &item) {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
@@ -2923,6 +3066,16 @@ impl Game<'_> {
|
||||
consume = true;
|
||||
sound = Some("entity.cow.milk");
|
||||
}
|
||||
// Goat.mobInteract: a bucket on an adult gives milk.
|
||||
Species::Goat if item == "minecraft:bucket" && !m.is_baby() => {
|
||||
give = Some("minecraft:milk_bucket");
|
||||
consume = true;
|
||||
sound = Some(if m.extra.screaming {
|
||||
"entity.goat.screaming.milk"
|
||||
} else {
|
||||
"entity.goat.milk"
|
||||
});
|
||||
}
|
||||
Species::Mooshroom if item == "minecraft:bowl" && !m.is_baby() => {
|
||||
give = Some("minecraft:mushroom_stew");
|
||||
consume = true;
|
||||
@@ -3043,14 +3196,8 @@ impl Game<'_> {
|
||||
m.ai.target = None;
|
||||
m.extra.sitting = true;
|
||||
}
|
||||
// TamableAnimal.spawnTamingParticles.
|
||||
let kind = if tamed { "heart" } else { "smoke" };
|
||||
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
|
||||
self.particles_around_mob(
|
||||
self.mobs[index].id,
|
||||
kind,
|
||||
horizon_client::particles::Burst::TAMING,
|
||||
);
|
||||
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
|
||||
if tamed {
|
||||
let view = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_entity("minecraft:tame_animal", "entity", &view);
|
||||
@@ -3117,13 +3264,8 @@ impl Game<'_> {
|
||||
m.extra.sitting = true;
|
||||
}
|
||||
eat = eating_sound(m, &mut self.world.random);
|
||||
let kind = if tamed { "heart" } else { "smoke" };
|
||||
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
|
||||
self.particles_around_mob(
|
||||
self.mobs[index].id,
|
||||
kind,
|
||||
horizon_client::particles::Burst::TAMING,
|
||||
);
|
||||
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
|
||||
if tamed {
|
||||
let view = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_entity("minecraft:tame_animal", "entity", &view);
|
||||
@@ -3171,18 +3313,42 @@ impl Game<'_> {
|
||||
m.extra.tame = true;
|
||||
m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1]));
|
||||
}
|
||||
let kind = if tamed { "heart" } else { "smoke" };
|
||||
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
|
||||
self.particles_around_mob(
|
||||
self.mobs[index].id,
|
||||
kind,
|
||||
horizon_client::particles::Burst::TAMING,
|
||||
);
|
||||
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
|
||||
if tamed {
|
||||
let view = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_entity("minecraft:tame_animal", "entity", &view);
|
||||
}
|
||||
}
|
||||
// Tadpole.feed: frog food ages it by a tenth of the time left to
|
||||
// grow into a frog (it grows on its next tick once past 24000).
|
||||
Species::Tadpole if is_food && !m.extra.age_locked => {
|
||||
consume = true;
|
||||
let left = 24_000 - m.extra.age;
|
||||
m.extra.age += ((left / 20) as f32 * 0.1) as i32 * 20;
|
||||
// One happy villager particle at rest about the body.
|
||||
self.particles_around_mob(
|
||||
self.mobs[index].id,
|
||||
"happy_villager",
|
||||
horizon_client::particles::Burst::HORSE_EATING,
|
||||
);
|
||||
}
|
||||
// Bee.mobInteract: a wither rose or an open eyeblossom is eaten for
|
||||
// its `getBeeInteractionEffect` instead of breeding the bee.
|
||||
Species::Bee
|
||||
if is_food
|
||||
&& matches!(
|
||||
&*item,
|
||||
"minecraft:wither_rose" | "minecraft:open_eyeblossom"
|
||||
) =>
|
||||
{
|
||||
if &*item == "minecraft:wither_rose" {
|
||||
m.add_effect("minecraft:wither", 40, 0);
|
||||
} else {
|
||||
m.add_effect("minecraft:poison", 25, 0);
|
||||
}
|
||||
consume = true;
|
||||
}
|
||||
// HappyGhast.canFallInLove: never, so only a ghastling takes food.
|
||||
_ if is_food
|
||||
&& m.kind.flags.animal
|
||||
@@ -3195,6 +3361,11 @@ impl Game<'_> {
|
||||
Some(sound) => {
|
||||
consume = true;
|
||||
eat = sound;
|
||||
// Animal.setInLove: entity event 18 (a baby that
|
||||
// only grew is not in love).
|
||||
if self.mobs[index].extra.in_love > 0 {
|
||||
self.mob_event_particles(self.mobs[index].id, 18);
|
||||
}
|
||||
}
|
||||
// Animal.mobInteract still consumes the client click when
|
||||
// an adult cannot breed, without swinging or taking food.
|
||||
@@ -3270,6 +3441,50 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `Armadillo.mobInteract`: a brush brushes a scute off an adult
|
||||
/// (`brushOffScute`) and loses 16 durability.
|
||||
fn interact_armadillo(&mut self, index: usize, item: &str) -> bool {
|
||||
let m = &self.mobs[index];
|
||||
if item != "minecraft:brush"
|
||||
|| m.species() != Species::Armadillo
|
||||
|| !m.alive()
|
||||
|| m.is_baby()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (id, at) = (m.id, m.position);
|
||||
let feet = [at.x, at.y, at.z];
|
||||
let tool = self.held().cloned();
|
||||
let table = "minecraft:brush/armadillo";
|
||||
for d in self.sequence_loot(table, table, tool.as_ref()) {
|
||||
self.spawn_at_location(at, 0.0, d);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:entity.armadillo.brush", Some(feet), 1.0, 1.0);
|
||||
let ctx = self.who_context(Some(Who::Mob(id)), None);
|
||||
self.post_game_event("minecraft:entity_interact", feet, ctx);
|
||||
// ItemStack.hurtAndBreak(16).
|
||||
if !self.creative
|
||||
&& let Some(stack) = tool
|
||||
{
|
||||
let max = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(0, |i| i.max_damage);
|
||||
if max > 0 {
|
||||
let loss = self.durability_loss(&stack, 16);
|
||||
let slot = self.held_slot();
|
||||
if let Some(held) = self.stack_in_mut(slot).as_mut() {
|
||||
held.damage += loss;
|
||||
if held.damage >= max {
|
||||
*self.stack_in_mut(slot) = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
pub(super) fn damage_held_tool(&mut self) {
|
||||
if self.creative {
|
||||
return;
|
||||
@@ -3347,6 +3562,45 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
|
||||
/// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby
|
||||
/// at the mob's feet, taking the parent's variant as `getBreedOffspring`
|
||||
/// does, and one egg used. False (PASS) when the mob has no baby form.
|
||||
fn spawn_egg_offspring(&mut self, index: usize) -> bool {
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return false;
|
||||
};
|
||||
let parent = &self.mobs[index];
|
||||
if !parent.alive()
|
||||
|| stack.item.strip_suffix("_spawn_egg") != Some(parent.kind.id.as_str())
|
||||
|| !horizon_client::mobs::drops::can_be_baby(parent.species())
|
||||
// WanderingTrader.getBreedOffspring returns null: no offspring, the
|
||||
// click carries on to trading.
|
||||
|| parent.species() == Species::WanderingTrader
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (kind, at) = (
|
||||
parent.kind.id.clone(),
|
||||
[parent.position.x, parent.position.y, parent.position.z],
|
||||
);
|
||||
let (species, variant, color) =
|
||||
(parent.species(), parent.extra.variant, parent.extra.color);
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
let Some(id) = self.spawn_mob(&kind, at, true) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::finalize::set_baby(child, true);
|
||||
child.extra.variant = variant;
|
||||
if species == Species::Sheep {
|
||||
child.extra.color = color;
|
||||
}
|
||||
}
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
|
||||
self.consume_held();
|
||||
true
|
||||
}
|
||||
fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
|
||||
let Some(hit) = self.pick() else {
|
||||
return Ok(false);
|
||||
@@ -3421,7 +3675,7 @@ impl Game<'_> {
|
||||
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let scale = self.view_scale();
|
||||
let scale = f64::from(self.settings.entity_distance);
|
||||
for mob in &self.mobs {
|
||||
let entity_start = out.len();
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
|
||||
@@ -3430,16 +3684,9 @@ impl Game<'_> {
|
||||
}
|
||||
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
|
||||
// mean edge, scaled by the view scale. RemotePlayer's box counts
|
||||
// ten times.
|
||||
// mean edge, scaled by the entity distance option.
|
||||
let (w, h, _) = mob.dims();
|
||||
let edge = (w + h + w) / 3.0
|
||||
* if &*mob.kind.id == "minecraft:player" {
|
||||
10.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let range = edge * 64.0 * scale;
|
||||
let range = (w + h + w) / 3.0 * 64.0 * scale;
|
||||
if d.length_squared() >= range * range {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ impl Game<'_> {
|
||||
if let Some(a) = arrow.arrow.as_mut() {
|
||||
a.set_item(ammo);
|
||||
a.set_owner(Some(Who::Player));
|
||||
a.fired_from_weapon = Some(weapon.clone());
|
||||
a.pierce = pierce;
|
||||
}
|
||||
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
|
||||
@@ -235,6 +236,11 @@ impl Game<'_> {
|
||||
.play("minecraft:item.crossbow.shoot", Some(eye), 1.0, pitch);
|
||||
self.damage_held(if rocket { 3 } else { 1 });
|
||||
}
|
||||
// CrossbowItem.performShooting: SHOT_CROSSBOW with the crossbow, which
|
||||
// is an empty stack (no match) when the shots broke it.
|
||||
if let Some(stack) = self.held().cloned() {
|
||||
self.trigger_item("minecraft:shot_crossbow", &stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -348,15 +348,11 @@ impl Game<'_> {
|
||||
let Some(state) = self.pack.assets.registry.default_state(&name) else {
|
||||
return;
|
||||
};
|
||||
let p = c.position;
|
||||
// Entity.getY(2/3) is relative to the cushion's quarter-block height.
|
||||
let particle_at = [p.x, p.y + 0.25 * (2.0 / 3.0), p.z];
|
||||
// Guest actions temporarily take self.lan; flush once the hub is back.
|
||||
// Queue before resolving a sprite: a dedicated server has no renderer.
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_cushion_breaks.push((state, particle_at));
|
||||
}
|
||||
let at = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
|
||||
let at = [
|
||||
c.position.x.floor() as i32,
|
||||
c.position.y.floor() as i32,
|
||||
c.position.z.floor() as i32,
|
||||
];
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
|
||||
return;
|
||||
};
|
||||
@@ -365,8 +361,14 @@ impl Game<'_> {
|
||||
sprite,
|
||||
tint,
|
||||
);
|
||||
self.particles
|
||||
.send_particles(&options, particle_at, [0.25, 0.0625, 0.25], 0.05, 10);
|
||||
let p = c.position;
|
||||
self.particles.send_particles(
|
||||
&options,
|
||||
[p.x, p.y + 0.666_666_7, p.z],
|
||||
[0.25, 0.0625, 0.25],
|
||||
0.05,
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
/// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100.
|
||||
@@ -453,7 +455,8 @@ impl Game<'_> {
|
||||
glam::DVec3::new(lo.x, lo.y, lo.z),
|
||||
glam::DVec3::new(hi.x, hi.y, hi.z),
|
||||
);
|
||||
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
|
||||
let range =
|
||||
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
|
||||
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ use horizon_client::{
|
||||
use horizon_protocol::text::translatable;
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
use std::{
|
||||
io::Write,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex, OnceLock, mpsc},
|
||||
time::{Duration, Instant},
|
||||
@@ -90,9 +89,7 @@ fn log_from(thread: &str, level: &str, message: &str) {
|
||||
}
|
||||
});
|
||||
// Log4j's %d{HH:mm:ss}: the local time, as in logs/latest.log.
|
||||
// The launcher can close its stdout pipe while the server keeps running.
|
||||
let _ = writeln!(
|
||||
std::io::stdout().lock(),
|
||||
println!(
|
||||
"[{}] [{thread}/{level}]: {message}",
|
||||
chrono::Local::now().format("%H:%M:%S")
|
||||
);
|
||||
@@ -621,22 +618,6 @@ impl Game<'_> {
|
||||
log_ips: props.log_ips,
|
||||
}),
|
||||
resource_pack: props.resource_pack.clone(),
|
||||
// DedicatedServer.serverLinks: `bug-report-link` as the only link
|
||||
// (ServerLinks.KnownLinkType.BUG_REPORT is id 0).
|
||||
server_links: if props.bug_report_link.is_empty() {
|
||||
Vec::new()
|
||||
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
|
||||
vec![horizon_protocol::packets::configuration::ServerLink {
|
||||
kind: horizon_protocol::packets::configuration::ServerLinkKind::Known(0),
|
||||
link: props.bug_report_link.clone(),
|
||||
}]
|
||||
} else {
|
||||
log(
|
||||
"WARN",
|
||||
&format!("Failed to parse bug link {}", props.bug_report_link),
|
||||
);
|
||||
Vec::new()
|
||||
},
|
||||
};
|
||||
let config = HubConfig {
|
||||
default_game_mode: game_mode,
|
||||
@@ -1330,110 +1311,3 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let h = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{capture_log, log, log_from};
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
process::{Command, Stdio},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
const LOGIN: &str = "Player[/127.0.0.1:0] logged in with entity id 2 at (0.5, 115.0, 0.5)";
|
||||
const WARNING: &str = "Authentication warning";
|
||||
const READY: &str = "horizon-console-log-ready\n";
|
||||
const CHILD_HOME: &str = "HORIZON_TEST_CONSOLE_LOG_HOME";
|
||||
|
||||
fn assert_log_lines(text: &str) {
|
||||
let expected = [
|
||||
format!("] [Server thread/INFO]: {LOGIN}"),
|
||||
format!("] [User Authenticator #1/WARN]: {WARNING}"),
|
||||
];
|
||||
assert_eq!(text.lines().count(), expected.len());
|
||||
for (line, suffix) in text.lines().zip(expected) {
|
||||
assert!(line.starts_with('['));
|
||||
assert!(chrono::NaiveTime::parse_from_str(&line[1..9], "%H:%M:%S").is_ok());
|
||||
assert_eq!(&line[9..], suffix);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn console_logging_survives_closed_stdout() {
|
||||
if let Some(home) = std::env::var_os(CHILD_HOME) {
|
||||
horizon_client::logging::init(std::path::Path::new(&home), "Server thread");
|
||||
// Let the parent close the real stdout pipe only after libtest's startup output.
|
||||
io::stdout().write_all(READY.as_bytes()).unwrap();
|
||||
io::stdout().flush().unwrap();
|
||||
io::stdin().read_exact(&mut [0]).unwrap();
|
||||
std::thread::Builder::new()
|
||||
.name("Server thread".into())
|
||||
.spawn(|| {
|
||||
let captured = capture_log(|| {
|
||||
log("INFO", LOGIN);
|
||||
log_from("User Authenticator #1", "WARN", WARNING);
|
||||
});
|
||||
assert_eq!(captured, [LOGIN, WARNING]);
|
||||
horizon_client::logging::flush();
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
// Libtest's final status also writes to stdout; only the game logger is under test.
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
for closed_stdout in [false, true] {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let home = std::env::temp_dir().join(format!(
|
||||
"horizon-console-log-test-{}-{unique}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir(&home).unwrap();
|
||||
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"game::dedicated::tests::console_logging_survives_closed_stdout",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_HOME, &home)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
assert!(
|
||||
stdout.read_line(&mut line).unwrap() > 0,
|
||||
"child was not ready"
|
||||
);
|
||||
if line.ends_with(READY) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if closed_stdout {
|
||||
drop(stdout); // No reader remains: the next console write gets EPIPE.
|
||||
} else {
|
||||
child.stdout = Some(stdout.into_inner());
|
||||
}
|
||||
child.stdin.take().unwrap().write_all(&[1]).unwrap();
|
||||
let output = child.wait_with_output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"closed_stdout={closed_stdout}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
if !closed_stdout {
|
||||
assert_log_lines(std::str::from_utf8(&output.stdout).unwrap());
|
||||
}
|
||||
assert_log_lines(&fs::read_to_string(home.join("logs/latest.log")).unwrap());
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,11 @@ impl Game<'_> {
|
||||
p.guest = guest;
|
||||
// Not into the one who turned it on its way out.
|
||||
p.left_owner = false;
|
||||
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
|
||||
// `needsSync` that `AIM_DEFLECT` sets: a server's players see it turn
|
||||
// at once, not at the fireball's next update ten ticks later.
|
||||
p.acceleration = 0.1;
|
||||
p.needs_sync = true;
|
||||
self.reset_attack_strength();
|
||||
self.swing_arm();
|
||||
let from = self
|
||||
|
||||
@@ -27,6 +27,8 @@ pub(super) const SPECIAL: u32 = u32::MAX - 4096;
|
||||
const CRYSTAL_IDS: u32 = SPECIAL + 16;
|
||||
const FIGHT_FILE: &str = "data/minecraft/ender_dragon_fight.dat";
|
||||
const MAX_HEALTH: f32 = 200.0;
|
||||
/// `Mob.getAmbientSoundInterval`.
|
||||
const AMBIENT_INTERVAL: i32 = 80;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Fight {
|
||||
@@ -270,6 +272,8 @@ pub(super) struct Dragon {
|
||||
flame_count: i32,
|
||||
sitting_damage: f32,
|
||||
growl: i32,
|
||||
/// `Mob.ambientSoundTime`.
|
||||
ambient_time: i32,
|
||||
ticks: i32,
|
||||
/// DragonFlightHistory: (y, yRot) samples, newest at `head`.
|
||||
history: [(f64, f32); 64],
|
||||
@@ -291,11 +295,11 @@ pub(super) enum FightWho {
|
||||
}
|
||||
/// A player in the End as the dragon sees it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct FightPlayer {
|
||||
who: FightWho,
|
||||
position: Vec3,
|
||||
bounds: Aabb,
|
||||
alive: bool,
|
||||
pub(super) struct FightPlayer {
|
||||
pub(super) who: FightWho,
|
||||
pub(super) position: Vec3,
|
||||
pub(super) bounds: Aabb,
|
||||
pub(super) alive: bool,
|
||||
creative: bool,
|
||||
spectator: bool,
|
||||
}
|
||||
@@ -397,6 +401,7 @@ impl Dragon {
|
||||
flame_count: 0,
|
||||
sitting_damage: 0.0,
|
||||
growl: 100,
|
||||
ambient_time: 0,
|
||||
ticks: 0,
|
||||
history: [(position.y, yaw); 64],
|
||||
head: 0,
|
||||
@@ -693,7 +698,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The players in the level the dragon sees (`level.players()`): the
|
||||
/// window's own, then every guest in the level.
|
||||
fn fight_players(&self) -> Vec<FightPlayer> {
|
||||
pub(super) fn fight_players(&self) -> Vec<FightPlayer> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
out.push(FightPlayer {
|
||||
@@ -722,7 +727,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Run `f` as the fight's player `who` (a guest's turn, its knockback
|
||||
/// sent to its client).
|
||||
fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
|
||||
pub(super) fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
|
||||
match who {
|
||||
FightWho::Host => f(self),
|
||||
FightWho::Guest(conn) => {
|
||||
@@ -987,6 +992,8 @@ impl Game<'_> {
|
||||
};
|
||||
let hurt = d.hurt(part, damage, counts, arrow);
|
||||
if hurt == (Hurt::Landed { full: true }) {
|
||||
// Mob.playHurtSound: the ambient sound clock starts again.
|
||||
d.ambient_time = -AMBIENT_INTERVAL;
|
||||
let at = [d.position.x, d.position.y, d.position.z];
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.3;
|
||||
self.audio
|
||||
@@ -1566,6 +1573,7 @@ impl Game<'_> {
|
||||
age: 0,
|
||||
duration: 200,
|
||||
from_dragon: true,
|
||||
potion: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1658,6 +1666,24 @@ impl Game<'_> {
|
||||
self.tick_dragon_death();
|
||||
return;
|
||||
}
|
||||
// Mob.baseTick: the ambient sound, rarer right after one.
|
||||
let roll = d.random.next_int_bound(1000).unwrap_or(999);
|
||||
let waited = d.ambient_time;
|
||||
d.ambient_time += 1;
|
||||
if roll < waited {
|
||||
d.ambient_time = -AMBIENT_INTERVAL;
|
||||
let at = [d.position.x, d.position.y, d.position.z];
|
||||
// LivingEntity.getVoicePitch, from the dragon's own random.
|
||||
let r = &mut d.random;
|
||||
let pitch = (r.next_float() - r.next_float()) * 0.2 + 1.0;
|
||||
self.audio.play(
|
||||
"minecraft:entity.ender_dragon.ambient",
|
||||
Some(at),
|
||||
5.0,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
// Flapping, growls and the flap sound.
|
||||
let flap_was = (d.old_flap * std::f32::consts::TAU).cos();
|
||||
let v = d.velocity;
|
||||
@@ -2078,12 +2104,18 @@ impl Game<'_> {
|
||||
age: 0,
|
||||
duration: 600,
|
||||
from_dragon: false,
|
||||
potion: None,
|
||||
});
|
||||
self.audio.play(
|
||||
"minecraft:entity.dragon_fireball.explode",
|
||||
Some([next.x, next.y, next.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
// DragonFireball.onHit: level event 2006 (the burst of dragon
|
||||
// breath and the explosion sound) at its block position.
|
||||
self.level_event_at(
|
||||
2006,
|
||||
[
|
||||
next.x.floor() as i32,
|
||||
next.y.floor() as i32,
|
||||
next.z.floor() as i32,
|
||||
],
|
||||
1,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -2110,6 +2142,10 @@ impl Game<'_> {
|
||||
let mut harmed: Vec<(FightWho, f32)> = Vec::new();
|
||||
let mut clouds = std::mem::take(&mut self.breath_clouds);
|
||||
clouds.retain_mut(|c| {
|
||||
// Lingering potions' clouds tick with the potions.
|
||||
if c.potion.is_some() {
|
||||
return true;
|
||||
}
|
||||
c.age += 1;
|
||||
if c.age >= c.duration {
|
||||
return false;
|
||||
@@ -2257,8 +2293,22 @@ pub(super) struct Breath {
|
||||
pub(super) at: [f64; 3],
|
||||
pub(super) radius: f32,
|
||||
pub(super) age: i32,
|
||||
duration: i32,
|
||||
from_dragon: bool,
|
||||
pub(super) duration: i32,
|
||||
pub(super) from_dragon: bool,
|
||||
/// A lingering potion's cloud (`ThrownLingeringPotion`): what it applies.
|
||||
pub(super) potion: Option<super::potions::PotionCloud>,
|
||||
}
|
||||
impl Breath {
|
||||
/// `AreaEffectCloud.isWaiting`: before its wait time (20 ticks, 10 for a
|
||||
/// lingering potion's).
|
||||
pub(super) fn waiting(&self) -> bool {
|
||||
self.age
|
||||
< if self.potion.is_some() {
|
||||
super::potions::LINGERING_WAIT
|
||||
} else {
|
||||
20
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `EnderDragonRenderer` and `EnderDragonModel.setupAnim`.
|
||||
|
||||
@@ -19,7 +19,7 @@ use horizon_core::{
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// ServerLevel.END_SPAWN_POINT.
|
||||
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
|
||||
const END_SPAWN: [i32; 3] = [100, 50, 0];
|
||||
/// EnderDragonFight gateway ring radius and height.
|
||||
const GATEWAY_RADIUS: f64 = 96.0;
|
||||
const GATEWAY_Y: i32 = 75;
|
||||
|
||||
@@ -60,27 +60,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Entity.setViewScale (LevelExtractor.extractVisibleEntities): the entity
|
||||
/// distance option times the render distance's boost — 1 at 8 chunks, 2.5
|
||||
/// at 20 or more. The render distance is the client setting, capped by
|
||||
/// the server's view distance when connected
|
||||
/// (Options.getEffectiveRenderDistance).
|
||||
pub(super) fn view_scale(&self) -> f64 {
|
||||
let distance = match &self.remote {
|
||||
Some(r) => self.settings.render_distance.min(r.view_distance),
|
||||
None => self.settings.render_distance,
|
||||
};
|
||||
(f64::from(distance) / 8.0).clamp(1.0, 2.5) * f64::from(self.settings.entity_distance)
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity.shouldRenderAtSqrDistance's default range: 64 times the bounding
|
||||
/// box's mean edge, scaled by the view scale.
|
||||
pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 {
|
||||
/// box's mean edge, scaled by the entity distance option.
|
||||
pub(super) fn default_range(size: glam::DVec3, view_scale: f32) -> f64 {
|
||||
let edge = (size.x + size.y + size.z) / 3.0;
|
||||
let edge = if edge.is_nan() { 1.0 } else { edge };
|
||||
edge * 64.0 * view_scale
|
||||
edge * 64.0 * f64::from(view_scale)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -307,6 +307,12 @@ impl Game<'_> {
|
||||
)
|
||||
{
|
||||
outcome = o;
|
||||
// dealDefaultKnockback's `indicateDamage` points at the blast
|
||||
// (the source position), not the no-direction fallback.
|
||||
if o.knockback && o.blocked <= 0.0 {
|
||||
p.health
|
||||
.indicate_damage(center[0] - feet[0], center[2] - feet[2], p.yaw);
|
||||
}
|
||||
}
|
||||
if !spectator && !(creative && flying) {
|
||||
p.velocity = Vec3::new(
|
||||
@@ -394,7 +400,14 @@ impl Game<'_> {
|
||||
// it kills drop its charged-creeper loot (`Creeper.killedEntity`).
|
||||
let source = horizon_client::mobs::Damage {
|
||||
kind: horizon_client::mobs::DamageKind::Explosion,
|
||||
attacker: exploding.map(horizon_client::mobs::Who::Mob),
|
||||
// A projectile's owner (`getIndirectSourceEntity`), else
|
||||
// the exploding mob itself.
|
||||
attacker: self
|
||||
.combat
|
||||
.explosion_owner
|
||||
.or(exploding)
|
||||
.filter(|&o| o != m.id)
|
||||
.map(horizon_client::mobs::Who::Mob),
|
||||
from: Some(center),
|
||||
};
|
||||
if hurtable && !m.damage(damage(dist, exposure), source) {
|
||||
@@ -490,6 +503,15 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Runs an explosion whose source empties the hives it destroys
|
||||
/// (`BeehiveBlock.getDrops`: primed TNT, creeper, wither skull, wither,
|
||||
/// TNT minecart).
|
||||
pub(super) fn with_hive_emptying_blast<R>(&mut self, blast: impl FnOnce(&mut Self) -> R) -> R {
|
||||
self.combat.hive_emptying_blast = true;
|
||||
let result = blast(self);
|
||||
self.combat.hive_emptying_blast = false;
|
||||
result
|
||||
}
|
||||
/// `Level.explode` on the server: positions, entities, blocks, fire; then
|
||||
/// the client's explosion sound.
|
||||
pub(super) fn server_explode(
|
||||
@@ -606,6 +628,22 @@ impl Game<'_> {
|
||||
// Block.onExplosionHit: the drops copy from the block entity, which
|
||||
// then spills its contents as the block turns to air.
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let is_hive = matches!(&*name, "minecraft:beehive" | "minecraft:bee_nest");
|
||||
// BeehiveBlock.getDrops: the bees of a hive blown up by TNT, a
|
||||
// creeper, a wither skull, a wither or a TNT minecart come out.
|
||||
if is_hive && self.combat.hive_emptying_blast {
|
||||
let facing = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("facing").cloned());
|
||||
self.empty_hive(
|
||||
pos,
|
||||
horizon_client::beehive::facing_step(facing.as_deref()),
|
||||
horizon_client::beehive::Release::Emergency,
|
||||
);
|
||||
}
|
||||
if let Some(entity) = self.world.block_entities.get(&key).cloned() {
|
||||
let custom = self.world.block_names.get(&key).cloned();
|
||||
self.pending.record(pos, &entity, &name, custom);
|
||||
@@ -638,6 +676,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.pending.clear(pos);
|
||||
self.level_set(pos, self.world.air)?;
|
||||
// BeehiveBlock.onExplosionHit: the bees about turn on the player.
|
||||
if is_hive {
|
||||
self.anger_nearby_bees(pos);
|
||||
}
|
||||
// TntBlock.wasExploded: new PrimedTnt (its push drawn first), then
|
||||
// getRandomShortFuse(80); no game event.
|
||||
if is_tnt && self.ui.rules.bool("tnt_explodes") {
|
||||
|
||||
@@ -23,7 +23,7 @@ pub(super) struct Rocket {
|
||||
/// The entity id a server tracks it by (0 until it is tracked).
|
||||
pub id: u32,
|
||||
pub(super) position: Vec3,
|
||||
previous: Vec3,
|
||||
pub(super) previous: Vec3,
|
||||
pub(super) velocity: Vec3,
|
||||
life: i32,
|
||||
lifetime: i32,
|
||||
@@ -62,11 +62,56 @@ impl Rocket {
|
||||
item,
|
||||
}
|
||||
}
|
||||
/// One tick of a server's client: the previous position, then `step`.
|
||||
pub(super) fn remote_step(&mut self, step: impl FnOnce(Vec3) -> Vec3) {
|
||||
/// One tick of a server's client: the rocket flies on by itself between
|
||||
/// the server's updates (a rocket that follows an entity is placed by the
|
||||
/// caller).
|
||||
pub(super) fn remote_step(&mut self, scene: &mut SceneCollisions) {
|
||||
self.previous = self.position;
|
||||
if self.attached_to.is_none() {
|
||||
self.free_flight(scene);
|
||||
}
|
||||
self.life += 1;
|
||||
self.position = step(self.position);
|
||||
}
|
||||
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
|
||||
/// up (an angled one keeps its motion) and rise, then move. True when a
|
||||
/// block stopped it.
|
||||
fn free_flight(&mut self, scene: &mut SceneCollisions) -> bool {
|
||||
let h = 0.125;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
|
||||
Vec3::new(
|
||||
self.position.x + h,
|
||||
self.position.y + 2.0 * h,
|
||||
self.position.z + h,
|
||||
),
|
||||
);
|
||||
let wanted = if self.angled {
|
||||
self.velocity
|
||||
} else {
|
||||
Vec3::new(
|
||||
self.velocity.x * 1.15,
|
||||
self.velocity.y + 0.04,
|
||||
self.velocity.z * 1.15,
|
||||
)
|
||||
};
|
||||
let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else {
|
||||
return false;
|
||||
};
|
||||
let blocked = (moved.x - wanted.x).abs() > 1e-7
|
||||
|| (moved.y - wanted.y).abs() > 1e-7
|
||||
|| (moved.z - wanted.z).abs() > 1e-7;
|
||||
let horizontal = (moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
self.velocity = if horizontal {
|
||||
Vec3::new(self.velocity.x, wanted.y, self.velocity.z)
|
||||
} else {
|
||||
wanted
|
||||
};
|
||||
blocked
|
||||
}
|
||||
/// Position and motion (the dispenser parity fixture).
|
||||
pub(super) fn motion(&self) -> (Vec3, Vec3) {
|
||||
@@ -191,46 +236,14 @@ impl Game<'_> {
|
||||
r.velocity = p.velocity;
|
||||
}
|
||||
} else {
|
||||
// Not shot at an angle: accelerate (unless blocked sideways) and rise.
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let h = 0.125;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(r.position.x - h, r.position.y, r.position.z - h),
|
||||
Vec3::new(r.position.x + h, r.position.y + 2.0 * h, r.position.z + h),
|
||||
);
|
||||
let wanted = if r.angled {
|
||||
r.velocity
|
||||
} else {
|
||||
Vec3::new(
|
||||
r.velocity.x * 1.15,
|
||||
r.velocity.y + 0.04,
|
||||
r.velocity.z * 1.15,
|
||||
)
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) {
|
||||
let blocked = (moved.x - wanted.x).abs() > 1e-7
|
||||
|| (moved.y - wanted.y).abs() > 1e-7
|
||||
|| (moved.z - wanted.z).abs() > 1e-7;
|
||||
if r.angled && blocked {
|
||||
// hitTargetOrDeflectSelf: explode on the block.
|
||||
r.life = r.lifetime;
|
||||
}
|
||||
let horizontal =
|
||||
(moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
|
||||
r.position = Vec3::new(
|
||||
r.position.x + moved.x,
|
||||
r.position.y + moved.y,
|
||||
r.position.z + moved.z,
|
||||
);
|
||||
r.velocity = if horizontal {
|
||||
Vec3::new(r.velocity.x, wanted.y, r.velocity.z)
|
||||
} else {
|
||||
wanted
|
||||
};
|
||||
if r.free_flight(&mut scene) && r.angled {
|
||||
// hitTargetOrDeflectSelf: explode on the block.
|
||||
r.life = r.lifetime;
|
||||
}
|
||||
}
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
@@ -383,7 +396,12 @@ impl Game<'_> {
|
||||
/// lit at the rocket's light probe.
|
||||
pub(super) fn rocket_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let orientation = glam::Mat4::from_quat(self.camera_orientation());
|
||||
for r in self.rockets.iter().filter(|r| !r.attached) {
|
||||
// shouldRender: not while attached to an entity (`isAttachedToEntity`).
|
||||
for r in self
|
||||
.rockets
|
||||
.iter()
|
||||
.filter(|r| !r.attached && r.attached_to.is_none())
|
||||
{
|
||||
// FireworkRocketEntity.shouldRenderAtSqrDistance: within 64 blocks;
|
||||
// EntityRenderer.shouldRender: its 0.25 box in the view.
|
||||
if !self.standing_entity_in_view(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
|
||||
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
|
||||
use super::{Game, view_vector};
|
||||
use horizon_client::advancements::EntityView;
|
||||
use horizon_client::lan::{entity::States, hub::Hub};
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
@@ -77,7 +78,7 @@ impl Game<'_> {
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
|
||||
if self.hook.is_some() {
|
||||
let damage = self.retrieve_hook();
|
||||
let damage = self.retrieve_hook(stack);
|
||||
self.damage_held(damage);
|
||||
self.audio.play(
|
||||
"minecraft:entity.fishing_bobber.retrieve",
|
||||
@@ -143,7 +144,7 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
/// `FishingHook.retrieve`: the rod damage it costs.
|
||||
fn retrieve_hook(&mut self) -> u32 {
|
||||
fn retrieve_hook(&mut self, rod: &ItemStack) -> u32 {
|
||||
let Some(hook) = self.hook.take() else {
|
||||
return 0;
|
||||
};
|
||||
@@ -167,6 +168,15 @@ impl Game<'_> {
|
||||
item.velocity = pulled(item.velocity);
|
||||
hooked_item = true;
|
||||
}
|
||||
// CriteriaTriggers.FISHING_ROD_HOOKED with no items.
|
||||
let hooked = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| self.mob_entity(m));
|
||||
if let Some(hooked) = hooked {
|
||||
self.trigger_fishing_rod_hooked(rod, &hooked, &[]);
|
||||
}
|
||||
// `hookedIn instanceof ItemEntity ? 3 : 5`.
|
||||
damage = if hooked_item { 3 } else { 5 };
|
||||
} else if hook.nibble > 0 {
|
||||
@@ -191,6 +201,7 @@ impl Game<'_> {
|
||||
Some(hook.open_water),
|
||||
);
|
||||
self.world.random = random;
|
||||
let mut caught = Vec::new();
|
||||
for stack in items {
|
||||
// FishingHook.retrieve: fish_caught for each catch in #minecraft:fishes.
|
||||
if self
|
||||
@@ -222,6 +233,7 @@ impl Game<'_> {
|
||||
if let Some(potion) = &stack.potion {
|
||||
item = item.with_potion(potion);
|
||||
}
|
||||
caught.push(item.clone());
|
||||
let (dx, dy, dz) = (
|
||||
owner.x - hook.position.x,
|
||||
owner.y - hook.position.y,
|
||||
@@ -241,6 +253,15 @@ impl Game<'_> {
|
||||
let xp = self.world.random.next_int_bound(6).unwrap_or(0) as u32 + 1;
|
||||
self.spawn_orbs([owner.x, owner.y + 0.5, owner.z + 0.5], xp);
|
||||
}
|
||||
// CriteriaTriggers.FISHING_ROD_HOOKED: the hook itself is the entity.
|
||||
let bobber = EntityView {
|
||||
kind: "minecraft:fishing_bobber".into(),
|
||||
position: [hook.position.x, hook.position.y, hook.position.z],
|
||||
on_ground: hook.on_ground,
|
||||
fishing_open_water: Some(hook.open_water),
|
||||
..Default::default()
|
||||
};
|
||||
self.trigger_fishing_rod_hooked(rod, &bobber, &caught);
|
||||
damage = 1;
|
||||
}
|
||||
if hook.on_ground {
|
||||
@@ -433,6 +454,8 @@ impl Game<'_> {
|
||||
let options = ParticleOptions::simple(kind);
|
||||
self.particles
|
||||
.send_particles(&options, at, spread, speed, count);
|
||||
// The players a server serves are sent them too.
|
||||
self.lan_send_particles(&options, at, spread, speed, count);
|
||||
}
|
||||
/// `FishingHook.tick`.
|
||||
pub(super) fn tick_fishing(&mut self) {
|
||||
|
||||
@@ -114,7 +114,7 @@ impl Game<'_> {
|
||||
living: Some(HurtBob {
|
||||
hurt_time: p.health.hurt_animation_time as f32 - partial,
|
||||
hurt_duration: hurt.hurt_duration,
|
||||
hurt_dir: 0.0,
|
||||
hurt_dir: p.health.hurt_dir,
|
||||
death_time: self.death_time().map(|t| t as f32 + partial),
|
||||
}),
|
||||
player: Some((
|
||||
|
||||
@@ -311,9 +311,8 @@ impl Game<'_> {
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
let mut maps: Vec<(i32, glam::Mat4, u32)> = Vec::new();
|
||||
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8)> = Vec::new();
|
||||
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at a view scale
|
||||
// of 1.
|
||||
let range = 16.0 * 64.0 * self.view_scale();
|
||||
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at 100%.
|
||||
let range = 16.0 * 64.0 * f64::from(self.settings.entity_distance);
|
||||
for f in &self.item_frames {
|
||||
let entity_start = out.len();
|
||||
let map = f.item.as_ref().and_then(super::maps::map_id);
|
||||
|
||||
@@ -79,6 +79,7 @@ impl Game<'_> {
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
self.stats.custom("play_record", 1);
|
||||
self.world.block_entities.insert(
|
||||
(pos[0], pos[1], pos[2]),
|
||||
BlockEntity::Jukebox(Jukebox {
|
||||
@@ -90,6 +91,8 @@ impl Game<'_> {
|
||||
self.set_has_record(pos, true);
|
||||
self.start_song(pos, &song);
|
||||
self.jukebox_changed(pos);
|
||||
// ServerPlayerGameMode.useItemOn: the action was consumed.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, stack);
|
||||
true
|
||||
}
|
||||
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
|
||||
|
||||
@@ -21,7 +21,6 @@ use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::{
|
||||
crypto::offline_uuid,
|
||||
entity_data::{Value, serializer},
|
||||
packets::play::{self, Clientbound as C},
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
@@ -339,6 +338,14 @@ fn mob_meta(
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// HappyGhast.STAYS_STILL (after AgeableMob's two and IS_LEASH_HOLDER),
|
||||
// set while `serverStillTimeout > 0` (`syncStayStillFlag`).
|
||||
"minecraft:happy_ghast" => {
|
||||
if horizon_client::mobs::happy_ghast::on_still_timeout(m) {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after Zombie's three).
|
||||
"minecraft:villager" | "minecraft:zombie_villager" => {
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
@@ -653,7 +660,8 @@ impl Game<'_> {
|
||||
let host_uuid = host.unwrap_or(0);
|
||||
self.next_entity += 1;
|
||||
let host_entity = self.next_entity as i32;
|
||||
let hub = Hub::new(lan, sync, encoder, config);
|
||||
let mut hub = Hub::new(lan, sync, encoder, config);
|
||||
hub.flat_world = self.world_flat;
|
||||
let port = hub.lan.port;
|
||||
self.world.block_log = Some(Vec::new());
|
||||
self.world.light_log = Some(Default::default());
|
||||
@@ -716,7 +724,6 @@ impl Game<'_> {
|
||||
self.audio.capture = None;
|
||||
self.lan_events.clear();
|
||||
self.lan_critical_hits.clear();
|
||||
self.lan_cushion_breaks.clear();
|
||||
self.streaming.extra.clear();
|
||||
if announce {
|
||||
self.system_message(Text::translate(
|
||||
@@ -912,7 +919,7 @@ impl Game<'_> {
|
||||
out.clear();
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
// The lead's holder: the player the mob sees (a guest, else the
|
||||
// host) or another mob. A fence knot isn't an entity here.
|
||||
// host), another mob or a fence knot (game/lan_entities.rs).
|
||||
let player_entity = mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
@@ -921,7 +928,7 @@ impl Game<'_> {
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => player_entity,
|
||||
Holder::Mob(id) => Some(id as i32),
|
||||
Holder::Knot(_) => None,
|
||||
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];
|
||||
@@ -930,6 +937,9 @@ impl Game<'_> {
|
||||
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
|
||||
e.needs_sync = m.needs_sync;
|
||||
mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults);
|
||||
// HappyGhast.aiStep: requiresPrecisePosition while on a still timeout.
|
||||
e.precise = m.species() == horizon_client::mobs::Species::HappyGhast
|
||||
&& horizon_client::mobs::happy_ghast::on_still_timeout(m);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut e.attributes);
|
||||
e.leash = leash;
|
||||
@@ -1006,6 +1016,9 @@ impl Game<'_> {
|
||||
for m in &mut self.mobs {
|
||||
m.needs_sync = false;
|
||||
}
|
||||
for p in &mut self.creatures.projectiles {
|
||||
p.needs_sync = false;
|
||||
}
|
||||
// `runBlockEvents`' broadcast precedes the tick's block changes
|
||||
// (`ChunkHolder.broadcastChanges` in `tick_level` below): the
|
||||
// guest re-runs a piston's `triggerEvent` while its level still
|
||||
@@ -1166,8 +1179,11 @@ impl Game<'_> {
|
||||
drop(holding);
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
self.lan = Some(host);
|
||||
// A mace's wind burst from a guest's attack.
|
||||
// A blast from a guest's attack, and a mace's wind burst.
|
||||
self.flush_lan_explosions();
|
||||
let _ = self.flush_wind_bursts();
|
||||
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
|
||||
self.flush_lan_particles();
|
||||
}
|
||||
|
||||
fn lan_guest_joined(
|
||||
@@ -1333,33 +1349,6 @@ impl Game<'_> {
|
||||
/// Send the tick's level events, sounds and mob animations.
|
||||
fn lan_flush_effects(&mut self, host: &mut LanHost) {
|
||||
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
|
||||
for (state, at) in std::mem::take(&mut self.lan_cushion_breaks) {
|
||||
if let Some(id) = host
|
||||
.hub
|
||||
.registry_id("minecraft:particle_type", "minecraft:block")
|
||||
{
|
||||
host.hub.broadcast_near(
|
||||
at,
|
||||
32.0,
|
||||
&C::LevelParticles(play::LevelParticles {
|
||||
particle: horizon_protocol::entity_data::Particle {
|
||||
id,
|
||||
data: Some(horizon_protocol::entity_data::ParticleData::State(
|
||||
state.0 as i32,
|
||||
)),
|
||||
},
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: [0.25, 0.0625, 0.25],
|
||||
max_speed: [0.05; 3],
|
||||
count: 10,
|
||||
randomization: 0,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) {
|
||||
if kind == 2001 {
|
||||
host.broken
|
||||
|
||||
@@ -75,6 +75,8 @@ pub(super) struct GuestPlay {
|
||||
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
|
||||
pub(super) stats: super::stats::Stats,
|
||||
last_inventory: u64,
|
||||
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
|
||||
levitation: Option<([f64; 3], u64)>,
|
||||
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
|
||||
pub(super) portal: horizon_client::nether_portal::PortalState,
|
||||
/// `ServerPlayer.wonGame`: the End poem is showing, the player waits for
|
||||
@@ -158,6 +160,7 @@ pub(super) struct GuestSwap {
|
||||
host_ender: Option<(i32, i32, i32)>,
|
||||
adv_loaded: bool,
|
||||
last_inventory: u64,
|
||||
levitation: Option<([f64; 3], u64)>,
|
||||
death_cause: Option<String>,
|
||||
/// The guest's own damage record, in place while they act.
|
||||
combat: super::combat::CombatTracker,
|
||||
@@ -165,8 +168,6 @@ pub(super) struct GuestSwap {
|
||||
acting: (Option<u32>, Option<String>, Option<u128>),
|
||||
/// The packets queued for that guest.
|
||||
packets: Vec<play::Clientbound>,
|
||||
/// The entity events queued for that guest.
|
||||
entity_events: Vec<i8>,
|
||||
/// The host's count of swings started, kept while the guest acts.
|
||||
swings: u32,
|
||||
/// How many projectiles there were: those added since are the guest's.
|
||||
@@ -267,8 +268,10 @@ impl Game<'_> {
|
||||
}
|
||||
let mut play = host.play.remove(&conn).unwrap_or_default();
|
||||
if fresh {
|
||||
// A new body (joined, respawned, changed level): nothing on it yet.
|
||||
// A new body (joined, respawned, changed level): nothing on it yet,
|
||||
// its effects' modifiers and armor protection included.
|
||||
play.equipped = None;
|
||||
play.potions = Default::default();
|
||||
play.sculk = Default::default();
|
||||
}
|
||||
let g = &mut host.hub.guests[i];
|
||||
@@ -384,6 +387,7 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.hook, &mut play.hook);
|
||||
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
|
||||
let last_inventory = std::mem::replace(&mut self.adv.last_inventory, play.last_inventory);
|
||||
let levitation = std::mem::replace(&mut self.adv.levitation, play.levitation);
|
||||
let death_cause = std::mem::replace(&mut self.ui.death_cause, play.death_cause.take());
|
||||
let acting = (
|
||||
self.lan_acting.replace(conn),
|
||||
@@ -391,7 +395,6 @@ impl Game<'_> {
|
||||
self.lan_acting_uuid.replace(g.uuid),
|
||||
);
|
||||
let packets = std::mem::take(&mut self.guest_packets);
|
||||
let entity_events = std::mem::take(&mut self.guest_entity_events);
|
||||
let swings = self.swings_started;
|
||||
let projectiles = self.creatures.projectiles.len();
|
||||
let modifiers = std::mem::replace(
|
||||
@@ -405,6 +408,12 @@ impl Game<'_> {
|
||||
self.apply_equipment();
|
||||
play.equipped = Some(equipped);
|
||||
}
|
||||
// The player's effects are on the new body before anything it does
|
||||
// (the movement that brought it, a landing): vanilla keeps the same
|
||||
// entity through a level change, with its attributes and effects.
|
||||
if fresh {
|
||||
self.apply_effect_status();
|
||||
}
|
||||
self.update_totem_ready();
|
||||
Some(GuestSwap {
|
||||
conn,
|
||||
@@ -449,10 +458,10 @@ impl Game<'_> {
|
||||
host_ender,
|
||||
adv_loaded,
|
||||
last_inventory,
|
||||
levitation,
|
||||
death_cause,
|
||||
acting,
|
||||
packets,
|
||||
entity_events,
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
@@ -508,11 +517,11 @@ impl Game<'_> {
|
||||
host_ender,
|
||||
adv_loaded,
|
||||
last_inventory,
|
||||
levitation,
|
||||
death_cause,
|
||||
combat,
|
||||
acting,
|
||||
packets,
|
||||
entity_events,
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
@@ -534,17 +543,6 @@ impl Game<'_> {
|
||||
for packet in std::mem::replace(&mut self.guest_packets, packets) {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
// ServerPlayer.connection.send: the guest's own entity events.
|
||||
let entity_id = host.hub.guests[i].entity_id;
|
||||
for event in std::mem::replace(&mut self.guest_entity_events, entity_events) {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::EntityEvent(play::EntityEvent {
|
||||
id: entity_id,
|
||||
event,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// ServerItemCooldowns.onCooldownStarted: the guest's own cooldown.
|
||||
for (group, duration) in std::mem::take(&mut self.guest_cooldowns) {
|
||||
host.hub.send_to(
|
||||
@@ -630,6 +628,7 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.hook, &mut play.hook);
|
||||
self.adv.loaded = adv_loaded;
|
||||
play.last_inventory = std::mem::replace(&mut self.adv.last_inventory, last_inventory);
|
||||
play.levitation = std::mem::replace(&mut self.adv.levitation, levitation);
|
||||
let stat_events = std::mem::take(&mut play.stats.events);
|
||||
let name = g.name.clone();
|
||||
self.potions.swap_player(&mut g.effects, &mut play.potions);
|
||||
@@ -673,6 +672,13 @@ impl Game<'_> {
|
||||
g.sprinting = false;
|
||||
}
|
||||
m.sprinting = false;
|
||||
// ServerPlayer.indicateDamage: the hit's direction to the player.
|
||||
if let Some(yaw) = m.health.indicated_dir.take() {
|
||||
g.send(&C::HurtAnimation(play::HurtAnimation {
|
||||
id: g.entity_id,
|
||||
yaw,
|
||||
}));
|
||||
}
|
||||
let teleported = std::mem::take(&mut m.teleported);
|
||||
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
|
||||
g.motion = Some(Box::new(m));
|
||||
@@ -874,13 +880,17 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// The guest's open container is closing (`AbstractContainerMenu.removed`):
|
||||
/// it no longer counts among the openers of the lid.
|
||||
/// it no longer counts among the openers of the lid, and the lid's close
|
||||
/// sound is played for everyone around (`ChestBlockEntity.onClose`, a
|
||||
/// `playSound` with no player), the guest included, once the last opener
|
||||
/// has left.
|
||||
pub(super) fn guest_container_closed(&mut self, host: &mut LanHost, conn: u32) {
|
||||
if let Some(g) = host.hub.guest(conn)
|
||||
&& let Some(pos) = g.menu.as_ref().and_then(|m| m.pos)
|
||||
&& !g.spectator()
|
||||
&& self.container_openers_changed(pos, false)
|
||||
{
|
||||
self.container_openers_changed(pos, false);
|
||||
self.container_edge_sound(pos, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -974,6 +984,20 @@ impl Game<'_> {
|
||||
// The pause between two blocks is the client's (MultiPlayerGameMode's
|
||||
// destroyDelay); the server digs from the START it receives.
|
||||
play.destroy_delay = 0;
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: a survival START
|
||||
// attacks the block at once, so a tap released in the same tick
|
||||
// still plays a note. The dig is marked begun for tick_mining.
|
||||
let pos = a.pos;
|
||||
self.as_guest(host, conn, |g| {
|
||||
if g.may_build() && !g.creative {
|
||||
let _ = g.attack_block(pos);
|
||||
g.mining = Some(Mining {
|
||||
pos,
|
||||
progress: 0.0,
|
||||
ticks: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
1 => {}
|
||||
3 => {
|
||||
@@ -1540,9 +1564,13 @@ impl Game<'_> {
|
||||
if let Some(victim) = Self::pvp_victim(host, conn, id) {
|
||||
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
|
||||
&& self.pvp_land(host, victim, &strike)
|
||||
&& strike.stops_sprint()
|
||||
{
|
||||
self.as_guest(host, conn, |g| g.stop_sprint_after_extra_knockback());
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.pvp_exhaustion();
|
||||
if strike.stops_sprint() {
|
||||
g.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1847,10 +1875,6 @@ impl Game<'_> {
|
||||
};
|
||||
xp.respawn(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
|
||||
(g.xp_level, g.xp_progress, g.xp_total) = (xp.level, xp.progress, xp.total);
|
||||
// A new body: nothing given to the old one carries over.
|
||||
if let Some(play) = host.play.get_mut(&conn) {
|
||||
play.potions = Default::default();
|
||||
}
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
@@ -1932,8 +1956,21 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
// ServerGamePacketListenerImpl.tick's dropSpamThrottler.
|
||||
// Player.tick's ItemCooldowns.tick, once a tick for each player
|
||||
// (the shield an axe disabled, a thrown pearl).
|
||||
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
|
||||
play.creative_drops = play.creative_drops.saturating_sub(1);
|
||||
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
|
||||
// the guest (a duration of 0 removes it).
|
||||
for group in play.cooldowns.tick() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::Cooldown(play::Cooldown {
|
||||
group: group.to_string(),
|
||||
duration: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let digging = play.digging;
|
||||
let using = play.using.is_some();
|
||||
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
|
||||
@@ -1970,24 +2007,6 @@ impl Game<'_> {
|
||||
play.digging = None;
|
||||
}
|
||||
}
|
||||
// Player.tick's ItemCooldowns.tick, once a tick for each player
|
||||
// (the shield an axe disabled, a thrown pearl), after
|
||||
// LivingEntity.tick's use: an entry a finished use starts has 20
|
||||
// ticks left when the next tick's packets are handled, as the
|
||||
// guest's own client counts.
|
||||
let play = host.play.entry(conn).or_default();
|
||||
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
|
||||
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
|
||||
// the guest (a duration of 0 removes it).
|
||||
for group in play.cooldowns.tick() {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::Cooldown(play::Cooldown {
|
||||
group: group.to_string(),
|
||||
duration: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Player.tick: a different main-hand item resets the attack
|
||||
// strength, and detectEquipmentUpdates gives the guest its modifiers.
|
||||
let g = &host.hub.guests[gi];
|
||||
@@ -2332,6 +2351,13 @@ impl Game<'_> {
|
||||
// ServerEntity.sendChanges sends it as it is after this travel.
|
||||
let marked = std::mem::take(&mut player.health.sync_velocity);
|
||||
let velocity = marked.then_some(player.velocity);
|
||||
// Player.causeFallDamage: a fall of 2 blocks or more counts.
|
||||
for event in &events {
|
||||
if let MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) = event
|
||||
{
|
||||
game.stats.custom("fall_one_cm", *cm);
|
||||
}
|
||||
}
|
||||
game.record_combat(&events);
|
||||
game.wear_armor(&events);
|
||||
(events, velocity)
|
||||
|
||||
@@ -127,9 +127,18 @@ impl Game<'_> {
|
||||
}
|
||||
let dropped = std::mem::take(&mut menu.dropped);
|
||||
let equipped = std::mem::take(&mut menu.equipped);
|
||||
let brewed = std::mem::take(&mut menu.brewed);
|
||||
menu.drops.clear();
|
||||
menu.crafted.clear();
|
||||
store_player(menu, &mut host.hub.guests[gi]);
|
||||
if !brewed.is_empty() {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
self.as_guest(host, conn, |g| {
|
||||
for stack in &brewed {
|
||||
g.trigger_brewed_potion(stack);
|
||||
}
|
||||
});
|
||||
}
|
||||
if !equipped.is_empty() {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
self.as_guest(host, conn, |g| {
|
||||
|
||||
@@ -225,6 +225,16 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
for (p, &id) in &self.knot_ids {
|
||||
// LeashFenceKnotEntity.getAddEntityPacket: the fence's block.
|
||||
out.push(state(
|
||||
id,
|
||||
"minecraft:leash_knot",
|
||||
p.map(f64::from),
|
||||
0.0,
|
||||
false,
|
||||
));
|
||||
}
|
||||
for p in &self.paintings {
|
||||
let mut e = state(p.id, "minecraft:painting", p.pos.map(f64::from), 0.0, false);
|
||||
// Direction.get2DDataValue (south 0, west 1, north 2, east 3) to its
|
||||
@@ -270,6 +280,24 @@ impl Game<'_> {
|
||||
/// Gives the entities Horizon keeps without an id one (`Entity.id`, from
|
||||
/// the same counter as the rest) before they are first tracked.
|
||||
pub(super) fn lan_assign_ids(&mut self) {
|
||||
// LeashFenceKnotEntity: one per fence post with a lead on it
|
||||
// (`notifyLeasheeRemoved` discards it once the last lead is gone).
|
||||
let knots: Vec<[i32; 3]> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| !m.removed)
|
||||
.filter_map(|m| match m.extra.leash {
|
||||
Some(horizon_client::mobs::leash::Holder::Knot(p)) => Some(p),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
self.knot_ids.retain(|p, _| knots.contains(p));
|
||||
for p in knots {
|
||||
self.knot_ids.entry(p).or_insert_with(|| {
|
||||
self.next_entity += 1;
|
||||
self.next_entity
|
||||
});
|
||||
}
|
||||
let next = &mut self.next_entity;
|
||||
let mut give = |id: &mut u32| {
|
||||
if *id == 0 {
|
||||
@@ -342,6 +370,7 @@ impl Game<'_> {
|
||||
let mut e = state(p.id, kind, at(p.position), p.yaw, false);
|
||||
e.pitch = p.pitch;
|
||||
e.velocity = at(p.velocity);
|
||||
e.needs_sync = p.needs_sync;
|
||||
// Projectile.getAddEntityPacket: the owner's id.
|
||||
e.data = match p.guest.and_then(|conn| hub.guest(conn)) {
|
||||
Some(g) => g.entity_id,
|
||||
@@ -466,7 +495,7 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
// The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS,
|
||||
// The dragon's breath and lingering potion clouds (AreaEffectCloud's DATA_RADIUS,
|
||||
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
|
||||
// client draws them from these (`tick_remote_clouds`).
|
||||
let breath = self.network_particle(
|
||||
@@ -484,10 +513,14 @@ impl Game<'_> {
|
||||
for b in self.breath_clouds.iter().filter(|b| b.id != 0) {
|
||||
let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false);
|
||||
e.meta.push((8, serializer::FLOAT, Value::Float(b.radius)));
|
||||
if b.age < 20 {
|
||||
if b.waiting() {
|
||||
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
if let Some(p) = &breath {
|
||||
let particle = match &b.potion {
|
||||
Some(p) => self.network_particle(&p.particle(), None),
|
||||
None => breath.clone(),
|
||||
};
|
||||
if let Some(p) = &particle {
|
||||
e.meta
|
||||
.push((10, serializer::PARTICLE, Value::Particle(p.clone())));
|
||||
}
|
||||
|
||||
@@ -363,6 +363,7 @@ mod tests {
|
||||
off_hand: None,
|
||||
head: None,
|
||||
armor_cover: 0.0,
|
||||
invisible: false,
|
||||
spectator: false,
|
||||
gold_armor: false,
|
||||
hurt_by: None,
|
||||
@@ -374,6 +375,7 @@ mod tests {
|
||||
uuid: [0; 4],
|
||||
allies: Default::default(),
|
||||
remote: false,
|
||||
passenger: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -318,16 +318,86 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.sendParticles` for the players a server serves: one
|
||||
/// `ClientboundLevelParticlesPacket` to each player in the level whose
|
||||
/// block is within 32 blocks of `at`. The window's own player is not
|
||||
/// sent it (the caller draws it in this window). While the host holds
|
||||
/// the players (a guest's turn through `as_guest`, a host's hit on a
|
||||
/// guest) the packet waits for the turn to end so that it reaches every
|
||||
/// player, the guest too.
|
||||
pub(super) fn lan_send_particles(
|
||||
&mut self,
|
||||
options: &ParticleOptions,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: u32,
|
||||
) {
|
||||
if self.lan.is_none() {
|
||||
// The server is running (its sounds are captured for the guests).
|
||||
if self.audio.capture.is_some() {
|
||||
self.creatures
|
||||
.lan_particles
|
||||
.push((options.clone(), at, spread, speed, count));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(particle) = self.network_particle(options, None) else {
|
||||
return;
|
||||
};
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let body = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: spread.map(|d| d as f32),
|
||||
max_speed: [speed as f32; 3],
|
||||
count: count as i32,
|
||||
randomization: 0,
|
||||
})
|
||||
.encode();
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
// BlockPos.closerToCenterThan(at, 32): from the centre of the
|
||||
// player's block.
|
||||
let d: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if d < 32.0 * 32.0 {
|
||||
g.sender.send(body.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The particles guests' turns put off.
|
||||
pub(super) fn flush_lan_particles(&mut self) {
|
||||
for (options, at, spread, speed, count) in std::mem::take(&mut self.creatures.lan_particles)
|
||||
{
|
||||
self.lan_send_particles(&options, at, spread, speed, count);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
|
||||
/// sound and the push reach them on their own (the level's sounds, the
|
||||
/// motion a hurt player is sent), so the packet carries neither.
|
||||
pub(super) fn lan_explode(
|
||||
&self,
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
small: bool,
|
||||
radius: f32,
|
||||
block_count: usize,
|
||||
) {
|
||||
// In a guest's turn (their hit on an end crystal or a minecart) the
|
||||
// host holds the players (`as_guest`): the packet waits for the turn
|
||||
// to end so that it reaches every player, the guest too.
|
||||
if self.lan_acting.is_some() && self.lan.is_none() {
|
||||
self.creatures
|
||||
.lan_explosions
|
||||
.push((center, small, radius, block_count));
|
||||
return;
|
||||
}
|
||||
let emitter = if small {
|
||||
"minecraft:explosion"
|
||||
} else {
|
||||
@@ -341,6 +411,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The explosions guests' turns put off.
|
||||
pub(super) fn flush_lan_explosions(&mut self) {
|
||||
for (center, small, radius, block_count) in
|
||||
std::mem::take(&mut self.creatures.lan_explosions)
|
||||
{
|
||||
self.lan_explode(center, small, radius, block_count);
|
||||
}
|
||||
}
|
||||
|
||||
/// A wind charge's burst (`AbstractWindCharge.explode`): gust emitters,
|
||||
/// no block particles, and for each player it pushed the push
|
||||
/// (`ServerExplosion.hitPlayers`), which only that client applies, as
|
||||
|
||||
@@ -86,9 +86,11 @@ impl Game<'_> {
|
||||
};
|
||||
if let Some(strike) = self.pvp_strike()
|
||||
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
|
||||
&& strike.stops_sprint()
|
||||
{
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
self.pvp_exhaustion();
|
||||
if strike.stops_sprint() {
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
}
|
||||
self.lan = Some(host);
|
||||
true
|
||||
@@ -106,8 +108,18 @@ impl Game<'_> {
|
||||
.map(|g| Victim::Guest(g.conn))
|
||||
}
|
||||
|
||||
/// `Player.attack`'s `causeFoodExhaustion(0.1F)`, charged to the attacker
|
||||
/// (the host, or a guest swapped in) only once the hit hurt the victim.
|
||||
pub(super) fn pvp_exhaustion(&mut self) {
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !self.creative
|
||||
{
|
||||
p.cause_food_exhaustion(0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The attacker's side of `Player.attack` (the host, or a guest swapped
|
||||
/// in): the damage, the charge reset, the exhaustion and the swing.
|
||||
/// in): the damage, the charge reset and the swing.
|
||||
pub(super) fn pvp_strike(&mut self) -> Option<Strike> {
|
||||
let p = self.player.as_ref()?;
|
||||
let from = [p.position.x, p.position.y, p.position.z];
|
||||
@@ -116,11 +128,6 @@ impl Game<'_> {
|
||||
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
|
||||
let (xd, zd) = self.extra_knockback_direction();
|
||||
self.attack_ticks = 0;
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !self.creative
|
||||
{
|
||||
p.cause_food_exhaustion(0.1);
|
||||
}
|
||||
self.swing_arm();
|
||||
Some(Strike {
|
||||
attacker: self.lan_acting,
|
||||
@@ -260,6 +267,30 @@ impl Game<'_> {
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
// ServerPlayer.awardKillScore: the blow that kills a player.
|
||||
if hurt {
|
||||
let died = match victim {
|
||||
// A totem that fired is applied on the next tick_totem, so the
|
||||
// victim is still at zero health but is not dying.
|
||||
Victim::Host => self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.and_then(|g| g.motion.as_deref())
|
||||
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
|
||||
};
|
||||
if died {
|
||||
match s.attacker {
|
||||
Some(conn) => {
|
||||
self.as_guest(host, conn, |g| g.stats.custom("player_kills", 1));
|
||||
}
|
||||
None => self.stats.custom("player_kills", 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
if hurt && (s.crit || s.magic) {
|
||||
let target = match victim {
|
||||
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
|
||||
|
||||
@@ -317,6 +317,8 @@ impl Game<'_> {
|
||||
self.as_guest(host, conn, |game| {
|
||||
game.give_levels(-need);
|
||||
game.stats.custom("enchant_item", 1);
|
||||
// CriteriaTriggers.ENCHANTED_ITEM, with the levels it cost.
|
||||
game.trigger_enchanted_item(&enchanted, need);
|
||||
});
|
||||
host.hub.guests[gi].xp_seed = self.world.random.next_int();
|
||||
let env = self.menu_env();
|
||||
|
||||
@@ -277,7 +277,12 @@ impl Game<'_> {
|
||||
g.carried = menu.carried.take();
|
||||
}
|
||||
self.register_exploration_maps(made);
|
||||
gm.merchant.session = self.merchant_apply(session, gm.merchant.mob);
|
||||
let (session, traded) = self.merchant_apply(session, gm.merchant.mob);
|
||||
gm.merchant.session = session;
|
||||
if !traded.is_empty() {
|
||||
// The guest's own statistic and advancements.
|
||||
self.as_guest(host, conn, |g| g.merchant_traded(gm.merchant.mob, &traded));
|
||||
}
|
||||
self.drop_guest_items(host, gi, dropped);
|
||||
self.lan_send_offers(host, conn, &mut gm, false);
|
||||
self.lan_send_merchant_content(host, conn, &mut gm);
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
//! Level events whose client handler (`LevelEventHandler`) spawns particles
|
||||
//! on a block: bone meal's growth (1505), dragon egg teleportation (2015), a
|
||||
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
|
||||
//! off (3004) and scraping (3005).
|
||||
//! off (3004) and scraping (3005), plus the smoke, drips, egg and dragon
|
||||
//! fireball particles of 1501, 1502, 1504, 2006, 2009, 2012 and 3009.
|
||||
use super::Game;
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::particles::{self, ParticleOptions, ParticleType};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_core::{geometry::trig, position::BlockPos, random::RandomSource};
|
||||
use horizon_world::level::LevelEvent;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.block_teleportation(pos, data, [16, 8, 16], self.settings.particles);
|
||||
}
|
||||
/// Event 2016: `Shulker.teleportSomewhere`'s particles between the
|
||||
/// shell's old and new cells.
|
||||
pub(super) fn shulker_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.block_teleportation(pos, data, [8, 8, 8], self.settings.particles);
|
||||
}
|
||||
/// Event 2017: `TeleportRandomlyConsumeEffect`'s directional particles.
|
||||
pub(super) fn consume_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
@@ -40,7 +36,7 @@ impl Game<'_> {
|
||||
// 3002 with an axis: a lightning rod; otherwise copper cleaned by lightning.
|
||||
match usize::try_from(data) {
|
||||
Ok(axis) if id == 3002 && axis < 3 => {
|
||||
particles::along_axis(random, axis, p, |p, v| spawned.push((p, v)));
|
||||
particles::along_axis(random, axis, p, (10, 19), |p, v| spawned.push((p, v)));
|
||||
}
|
||||
_ => particles::on_block_faces(random, p, |p, v| spawned.push((p, v))),
|
||||
}
|
||||
@@ -72,24 +68,42 @@ impl Game<'_> {
|
||||
} else {
|
||||
// ParticleUtils.spawnParticleInBlock: up to the
|
||||
// block's shape (a full block when air).
|
||||
let inside = self.world.block(at[0], at[1], at[2]);
|
||||
let height = if facts.is_air(inside) {
|
||||
1.0
|
||||
} else {
|
||||
facts
|
||||
.get(inside)
|
||||
.outline
|
||||
.iter()
|
||||
.map(|b| b[4])
|
||||
.fold(0.0, f64::max)
|
||||
};
|
||||
(at, count, 0.5, height, true)
|
||||
(at, count, 0.5, self.block_shape_top(at), true)
|
||||
}
|
||||
}
|
||||
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
|
||||
None => return,
|
||||
};
|
||||
let options = ParticleOptions::simple(ParticleType::HappyVillager);
|
||||
self.spawn_particles(at, count, xz, height, floating, ParticleType::HappyVillager);
|
||||
}
|
||||
/// `BlockState.getShape(..).max(Y)` of the block at `at`, 1.0 for air.
|
||||
fn block_shape_top(&self, at: [i32; 3]) -> f64 {
|
||||
let facts = self.res.facts();
|
||||
let inside = self.world.block(at[0], at[1], at[2]);
|
||||
if facts.is_air(inside) {
|
||||
1.0
|
||||
} else {
|
||||
facts
|
||||
.get(inside)
|
||||
.outline
|
||||
.iter()
|
||||
.map(|b| b[4])
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
}
|
||||
/// `ParticleUtils.spawnParticles`: `count` slow particles spread over
|
||||
/// `xz` blocks around `at` and `height` up, only above solid ground
|
||||
/// unless floating ones are allowed.
|
||||
fn spawn_particles(
|
||||
&mut self,
|
||||
at: [i32; 3],
|
||||
count: i32,
|
||||
xz: f64,
|
||||
height: f64,
|
||||
floating: bool,
|
||||
ty: ParticleType,
|
||||
) {
|
||||
let options = ParticleOptions::simple(ty);
|
||||
for _ in 0..count.max(0) {
|
||||
let r = self.particles.level_random();
|
||||
let v = [0.0; 3].map(|_: f64| r.next_gaussian() * 0.02);
|
||||
@@ -108,6 +122,161 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LevelEventHandler` 2012 (a turtle egg laid on sand):
|
||||
/// `ParticleUtils.spawnParticleInBlock` with happy villager sparks.
|
||||
pub(super) fn turtle_egg_particles(&mut self, pos: BlockPos, count: i32) {
|
||||
let at = [pos.x, pos.y, pos.z];
|
||||
let height = self.block_shape_top(at);
|
||||
self.spawn_particles(at, count, 0.5, height, true, ParticleType::HappyVillager);
|
||||
}
|
||||
/// `LevelEventHandler` 3009 (a sniffer egg cracking on moss):
|
||||
/// `spawnParticlesOnBlockFaces(.., EGG_CRACK, UniformInt.of(3, 6))`.
|
||||
pub(super) fn sniffer_egg_crack_particles(&mut self, pos: BlockPos) {
|
||||
let mut spawned = Vec::new();
|
||||
particles::on_block_faces_between(
|
||||
self.particles.level_random(),
|
||||
[pos.x, pos.y, pos.z],
|
||||
3,
|
||||
6,
|
||||
|p, v| spawned.push((p, v)),
|
||||
);
|
||||
let options = ParticleOptions::simple(ParticleType::EggCrack);
|
||||
for (p, v) in spawned {
|
||||
self.particles.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
/// `LevelEventHandler` 1501 (lava put out) and 2009 (a wet sponge dried):
|
||||
/// eight puffs just above the block, drawn after the sound.
|
||||
pub(super) fn puffs_above_block(&mut self, pos: BlockPos, ty: ParticleType) {
|
||||
let options = ParticleOptions::simple(ty);
|
||||
for _ in 0..8 {
|
||||
let r = self.particles.level_random();
|
||||
let x = f64::from(pos.x) + r.next_double();
|
||||
let z = f64::from(pos.z) + r.next_double();
|
||||
self.particles
|
||||
.add(&options, [x, f64::from(pos.y) + 1.2, z], [0.0; 3]);
|
||||
}
|
||||
}
|
||||
/// `LevelEventHandler` 1502 (a redstone torch burning out): five smoke
|
||||
/// puffs inside the block, drawn after the sound.
|
||||
pub(super) fn torch_burnout_smoke(&mut self, pos: BlockPos) {
|
||||
let options = ParticleOptions::simple(ParticleType::Smoke);
|
||||
for _ in 0..5 {
|
||||
let r = self.particles.level_random();
|
||||
let x = f64::from(pos.x) + r.next_double() * 0.6 + 0.2;
|
||||
let y = f64::from(pos.y) + r.next_double() * 0.6 + 0.2;
|
||||
let z = f64::from(pos.z) + r.next_double() * 0.6 + 0.2;
|
||||
self.particles.add(&options, [x, y, z], [0.0; 3]);
|
||||
}
|
||||
}
|
||||
/// `LevelEventHandler` 2006 (`DragonFireball.onHit`): 200 dragon breath
|
||||
/// particles thrown outwards, then the explosion sound when `data` is 1.
|
||||
pub(super) fn dragon_fireball_burst(&mut self, pos: BlockPos, data: i32) {
|
||||
for _ in 0..200 {
|
||||
let r = self.particles.level_random();
|
||||
let dist = r.next_float() * 4.0;
|
||||
let angle = r.next_float() * std::f32::consts::TAU;
|
||||
let vx = f64::from(trig::cos(f64::from(angle)) * dist);
|
||||
let vy = 0.01 + r.next_double() * 0.5;
|
||||
let vz = f64::from(trig::sin(f64::from(angle)) * dist);
|
||||
let options = ParticleOptions {
|
||||
scale: dist,
|
||||
..ParticleOptions::simple(ParticleType::DragonBreath)
|
||||
};
|
||||
self.particles.add(
|
||||
&options,
|
||||
[
|
||||
f64::from(pos.x) + vx * 0.1,
|
||||
f64::from(pos.y) + 0.3,
|
||||
f64::from(pos.z) + vz * 0.1,
|
||||
],
|
||||
[vx, vy, vz],
|
||||
);
|
||||
}
|
||||
if data == 1 {
|
||||
let at = [pos.x, pos.y, pos.z].map(|v| f64::from(v) + 0.5);
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.audio.play(
|
||||
"minecraft:entity.dragon_fireball.explode",
|
||||
Some(at),
|
||||
1.0,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `SpeleothemBlock.isStalactite`: a speleothem whose tip points down.
|
||||
fn is_stalactite(&self, state: BlockStateId) -> bool {
|
||||
self.res.facts().get(state).has_class("SpeleothemBlock")
|
||||
&& self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("vertical_direction"))
|
||||
.is_some_and(|d| d == "down")
|
||||
}
|
||||
/// `LevelEventHandler` 1504: `PointedDripstoneBlock.spawnDripParticle` at
|
||||
/// the tip `pos`. The fluid above the stalactite's root picks the drip;
|
||||
/// with none, the dimension's default (lava in the Nether).
|
||||
pub(super) fn dripstone_drip_particle(&mut self, pos: BlockPos) {
|
||||
let tip_state = self.world.block(pos.x, pos.y, pos.z);
|
||||
if !self.is_stalactite(tip_state) {
|
||||
return;
|
||||
}
|
||||
// findRootBlock: up through the same stalactite, at most 11 steps.
|
||||
let tip_name = self.res.facts().get(tip_state).name.clone();
|
||||
let mut root = None;
|
||||
for step in 1..11 {
|
||||
let y = pos.y + step;
|
||||
if !(horizon_client::chunk::MIN_Y
|
||||
..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT)
|
||||
.contains(&y)
|
||||
{
|
||||
break;
|
||||
}
|
||||
let state = self.world.block(pos.x, y, pos.z);
|
||||
if self.res.facts().get(state).name != tip_name {
|
||||
root = Some(y);
|
||||
break;
|
||||
}
|
||||
if !self.is_stalactite(state) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(root_y) = root else {
|
||||
return;
|
||||
};
|
||||
let above = self.world.block(pos.x, root_y + 1, pos.z);
|
||||
let above_facts = self.res.facts().get(above);
|
||||
let nether = self.dim == super::Dim::Nether;
|
||||
let fluid = if &*above_facts.name == "minecraft:mud" && !nether {
|
||||
horizon_client::blocks::Fluid::Water
|
||||
} else {
|
||||
above_facts.fluid
|
||||
};
|
||||
let ty = match fluid {
|
||||
horizon_client::blocks::Fluid::Water => ParticleType::DrippingDripstoneWater,
|
||||
horizon_client::blocks::Fluid::Lava => ParticleType::DrippingDripstoneLava,
|
||||
horizon_client::blocks::Fluid::None if nether => ParticleType::DrippingDripstoneLava,
|
||||
horizon_client::blocks::Fluid::None => ParticleType::DrippingDripstoneWater,
|
||||
};
|
||||
let offset = self
|
||||
.pack
|
||||
.context
|
||||
.tables
|
||||
.offset(tip_state, pos.x, pos.z)
|
||||
.unwrap_or([0.0; 3]);
|
||||
// The tip shape starts 5/16 up; the particle sits a pixel below that.
|
||||
self.particles.add(
|
||||
&ParticleOptions::simple(ty),
|
||||
[
|
||||
f64::from(pos.x) + 0.5 + offset[0],
|
||||
f64::from(pos.y) + 5.0 / 16.0 - 0.0625,
|
||||
f64::from(pos.z) + 0.5 + offset[2],
|
||||
],
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
/// `ServerLevel.globalLevelEvent`: heard by every player on the server
|
||||
/// (an end portal opening, the dragon's death roar): here, and sent to
|
||||
/// every guest whatever their dimension.
|
||||
|
||||
@@ -66,6 +66,8 @@ pub(super) struct Parked {
|
||||
tnts: Vec<tnt::PrimedTnt>,
|
||||
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
|
||||
mobs: Vec<Mob>,
|
||||
/// The entity ids of the level's fence knots (`knot_ids`).
|
||||
knot_ids: HashMap<[i32; 3], u32>,
|
||||
projectiles: Vec<horizon_client::projectiles::Projectile>,
|
||||
dropped: Vec<ItemEntity>,
|
||||
orbs: Vec<ExperienceOrb>,
|
||||
@@ -166,6 +168,7 @@ impl Game<'_> {
|
||||
tnts: Vec::new(),
|
||||
hoppers: HashMap::new(),
|
||||
mobs: Vec::new(),
|
||||
knot_ids: HashMap::new(),
|
||||
projectiles: Vec::new(),
|
||||
dropped: Vec::new(),
|
||||
orbs: Vec::new(),
|
||||
@@ -219,6 +222,7 @@ impl Game<'_> {
|
||||
swap(&mut self.tnts, &mut p.tnts);
|
||||
swap(&mut self.hoppers, &mut p.hoppers);
|
||||
swap(&mut self.mobs, &mut p.mobs);
|
||||
swap(&mut self.knot_ids, &mut p.knot_ids);
|
||||
swap(&mut self.creatures.projectiles, &mut p.projectiles);
|
||||
swap(&mut self.dropped, &mut p.dropped);
|
||||
swap(&mut self.orbs, &mut p.orbs);
|
||||
|
||||
@@ -16,7 +16,7 @@ pub(super) struct Bolt {
|
||||
flashes: i32,
|
||||
seed: i64,
|
||||
visual_only: bool,
|
||||
/// Mooshrooms switch colour once per bolt.
|
||||
/// Mooshrooms and copper golems change once per bolt.
|
||||
converted: Vec<u32>,
|
||||
}
|
||||
impl Game<'_> {
|
||||
@@ -278,6 +278,13 @@ impl Game<'_> {
|
||||
}
|
||||
self.strike_rod(b.at);
|
||||
}
|
||||
// LightningBolt.tick runs clearCopperOnLightningStrike for
|
||||
// every server bolt, a skeleton trap's visual one included;
|
||||
// a client's copy of a server bolt (spawn_remote_bolt) only
|
||||
// flashes.
|
||||
if self.remote.is_none() {
|
||||
self.bolt_clear_copper(b.at);
|
||||
}
|
||||
}
|
||||
b.life -= 1;
|
||||
if b.life < 0 {
|
||||
@@ -334,6 +341,16 @@ impl Game<'_> {
|
||||
self.level_event_at(3002, p, axis);
|
||||
}
|
||||
}
|
||||
/// `LightningBolt.clearCopperOnLightningStrike` at the block the bolt struck.
|
||||
fn bolt_clear_copper(&mut self, at: [f64; 3]) {
|
||||
let p = BlockPos::new(
|
||||
at[0].floor() as i32,
|
||||
(at[1] - 1.0e-6).floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
);
|
||||
self.with_level(|b, level, ticks| b.lightning_clear_copper(level, ticks, p));
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
/// `thunderHit` for entities within 3 blocks (up to 9 above).
|
||||
fn thunder_hit(&mut self, b: &mut Bolt) {
|
||||
let at = b.at;
|
||||
@@ -393,6 +410,17 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
Species::Creeper => m.extra.charged = true,
|
||||
// CopperGolem.thunderHit (after Entity.thunderHit's fire and
|
||||
// damage): once per bolt, an oxidized golem goes back one step
|
||||
// and its weathering timer (or wax, -2) is reset to -1.
|
||||
Species::CopperGolem if !b.converted.contains(&m.id) => {
|
||||
b.converted.push(m.id);
|
||||
if m.extra.variant > 0 {
|
||||
m.extra.vector[0] = -1.0;
|
||||
m.extra.variant =
|
||||
horizon_client::mobs::copper_golem::weather_previous(m.extra.variant);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Entity.thunderHit: burning (8 seconds) and 5 lightning damage.
|
||||
|
||||
@@ -25,11 +25,13 @@ impl Game<'_> {
|
||||
};
|
||||
let m = &mut self.mobs[index];
|
||||
let mut used = false;
|
||||
let mut loved = false;
|
||||
if item == "minecraft:hay_block" && m.extra.tame && m.extra.age == 0 && m.extra.in_love <= 0
|
||||
{
|
||||
m.extra.in_love = 600;
|
||||
m.extra.love_player = true;
|
||||
used = true;
|
||||
loved = true;
|
||||
}
|
||||
if m.health < m.attrs.max_health {
|
||||
m.health = (m.health + heal).min(m.attrs.max_health);
|
||||
@@ -57,6 +59,10 @@ impl Game<'_> {
|
||||
if !used {
|
||||
return false;
|
||||
}
|
||||
// Animal.setInLove: entity event 18.
|
||||
if loved {
|
||||
self.mob_event_particles(self.mobs[index].id, 18);
|
||||
}
|
||||
self.llama_sound(index, "entity.llama.eat", 1.0);
|
||||
self.consume_held();
|
||||
true
|
||||
|
||||
@@ -626,46 +626,8 @@ impl Game<'_> {
|
||||
/// `HopperBlockEntity.suckInItems`: from the container above, or item entities.
|
||||
fn hopper_suck(&mut self, pos: Pos, hopper: &View) -> bool {
|
||||
let above = (pos.0, pos.1 + 1, pos.2);
|
||||
let c = self.containers();
|
||||
if let Some(source) = c.view(above, false) {
|
||||
if let Kind::Composter(level) = source.kind {
|
||||
if level != 8 {
|
||||
return false;
|
||||
}
|
||||
// tryTakeInItemFromSlot on the OutputContainer: removeItem calls
|
||||
// its setChanged, which empties the composter, and so does the
|
||||
// second setChanged after the move or, when the hopper has no
|
||||
// room, the setItem putting the bone meal back into the detached
|
||||
// container (whose bone meal is lost with it).
|
||||
let bone_meal = c.bone_meal.clone();
|
||||
let at = BlockPos::new(above.0, above.1, above.2);
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
self.world.modified.insert((above.0 >> 4, above.2 >> 4));
|
||||
return moved;
|
||||
}
|
||||
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
|
||||
for slot in source.slots_for_face(Some(Direction::Down)) {
|
||||
let c = self.containers();
|
||||
let Some(stack) = c.get(&source, slot) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(&source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
let Some(one) = self.take_one(&source, slot) else {
|
||||
continue;
|
||||
};
|
||||
match self.add_item(source_hopper, hopper, one, None) {
|
||||
None => {
|
||||
self.view_changed(&source);
|
||||
return true;
|
||||
}
|
||||
Some(rest) => self.put_back(&source, slot, rest),
|
||||
}
|
||||
}
|
||||
return false;
|
||||
if let Some(took) = self.suck_from_container(above, hopper) {
|
||||
return took;
|
||||
}
|
||||
let above_state = self.world.block(above.0, above.1, above.2);
|
||||
let blocked = self.res.facts().get(above_state).full_collision
|
||||
@@ -695,6 +657,56 @@ impl Game<'_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.suck_item_entities(hopper, min, max)
|
||||
}
|
||||
/// The container half of `HopperBlockEntity.suckInItems`: one item from the
|
||||
/// container at `above`, through its bottom face. `None` when there is no
|
||||
/// container there.
|
||||
fn suck_from_container(&mut self, above: Pos, hopper: &View) -> Option<bool> {
|
||||
let c = self.containers();
|
||||
let source = c.view(above, false)?;
|
||||
if let Kind::Composter(level) = source.kind {
|
||||
if level != 8 {
|
||||
return Some(false);
|
||||
}
|
||||
// tryTakeInItemFromSlot on the OutputContainer: removeItem calls
|
||||
// its setChanged, which empties the composter, and so does the
|
||||
// second setChanged after the move or, when the hopper has no
|
||||
// room, the setItem putting the bone meal back into the detached
|
||||
// container (whose bone meal is lost with it).
|
||||
let bone_meal = c.bone_meal.clone();
|
||||
let at = BlockPos::new(above.0, above.1, above.2);
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
self.world.modified.insert((above.0 >> 4, above.2 >> 4));
|
||||
return Some(moved);
|
||||
}
|
||||
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
|
||||
for slot in source.slots_for_face(Some(Direction::Down)) {
|
||||
let c = self.containers();
|
||||
let Some(stack) = c.get(&source, slot) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(&source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
let Some(one) = self.take_one(&source, slot) else {
|
||||
continue;
|
||||
};
|
||||
match self.add_item(source_hopper, hopper, one, None) {
|
||||
None => {
|
||||
self.view_changed(&source);
|
||||
return Some(true);
|
||||
}
|
||||
Some(rest) => self.put_back(&source, slot, rest),
|
||||
}
|
||||
}
|
||||
Some(false)
|
||||
}
|
||||
/// The item half of `HopperBlockEntity.suckInItems` (`getItemsAtAndAbove`):
|
||||
/// adds the item entities that meet the box, until one is taken whole.
|
||||
fn suck_item_entities(&mut self, hopper: &View, min: [f64; 3], max: [f64; 3]) -> bool {
|
||||
let mut i = 0;
|
||||
while i < self.dropped.len() {
|
||||
let b = self.dropped[i].bounds();
|
||||
@@ -719,6 +731,66 @@ impl Game<'_> {
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `MinecartHopper.tick`'s `tryConsumeItems` (`suckInItems`) for an enabled
|
||||
/// hopper minecart: one item from the container above, else the item
|
||||
/// entities over it or touching it, each tick without a cooldown.
|
||||
pub(super) fn hopper_cart_suck(&mut self, cart: &mut super::minecarts::Minecart) {
|
||||
// The cart's slots are edited as a container under the placeholder key
|
||||
// its menu uses; when that menu is open the placeholder is the live copy.
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, cart.id as i32);
|
||||
let open = self.world.block_entities.contains_key(&key);
|
||||
if !open {
|
||||
let items = BlockEntity::Chest(std::mem::take(&mut cart.items));
|
||||
self.world.block_entities.insert(key, items);
|
||||
}
|
||||
let size = self
|
||||
.world
|
||||
.block_entities
|
||||
.get(&key)
|
||||
.map_or(0, |e| e.slots().len());
|
||||
let view = View {
|
||||
parts: vec![(key, size)],
|
||||
kind: Kind::Generic,
|
||||
};
|
||||
let p = cart.position;
|
||||
// getLevelY is the cart's y + 0.5, so the container is the block at
|
||||
// y + 1.5, and Hopper.SUCK_AABB (moved by getLevel - 0.5) starts 11/16
|
||||
// above the cart.
|
||||
let above = (
|
||||
p.x.floor() as i32,
|
||||
(p.y + 1.5).floor() as i32,
|
||||
p.z.floor() as i32,
|
||||
);
|
||||
let took = match self.suck_from_container(above, &view) {
|
||||
Some(took) => took,
|
||||
None => self.suck_item_entities(
|
||||
&view,
|
||||
[p.x - 0.5, p.y + 11.0 / 16.0, p.z - 0.5],
|
||||
[p.x + 0.5, p.y + 2.0, p.z + 0.5],
|
||||
),
|
||||
};
|
||||
if !took {
|
||||
// The cart's own box, inflated by 0.25 sideways.
|
||||
let b = cart.bounds();
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
self.suck_item_entities(
|
||||
&view,
|
||||
[lo.x - 0.25, lo.y, lo.z - 0.25],
|
||||
[hi.x + 0.25, hi.y, hi.z + 0.25],
|
||||
);
|
||||
}
|
||||
// The cart's own items stay the one source of truth: a menu copy that
|
||||
// is still around (open, or left by a guest's close) gets the pulled
|
||||
// items back into `cart.items`, so reopening never restores a stale copy.
|
||||
let slots = if open {
|
||||
self.world.block_entities.get(&key).cloned()
|
||||
} else {
|
||||
self.world.block_entities.remove(&key)
|
||||
};
|
||||
if let Some(BlockEntity::Chest(items)) = slots {
|
||||
cart.items = items;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- dispensers and droppers ----
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ use horizon_client::{
|
||||
blocks::Fluid,
|
||||
collisions::SceneCollisions,
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
items::{ItemStack, Items},
|
||||
mesher::Vertex,
|
||||
mobs::{Part, anim::Poses},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
@@ -90,8 +90,6 @@ pub(super) struct Minecart {
|
||||
/// Chest minecarts: the loot table rolled into `items` on load, saved
|
||||
/// again while the items are still that roll.
|
||||
pub loot: Option<horizon_client::save::RolledLoot>,
|
||||
/// `Entity.firstTick`: not ticked yet (saved as `HasTicked`).
|
||||
pub first_tick: bool,
|
||||
}
|
||||
pub(super) const WIDTH: f64 = 0.98;
|
||||
pub(super) const HEIGHT: f64 = 0.7;
|
||||
@@ -188,7 +186,6 @@ impl Minecart {
|
||||
fuse: -1,
|
||||
enabled: true,
|
||||
loot: None,
|
||||
first_tick: true,
|
||||
}
|
||||
}
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
@@ -201,6 +198,17 @@ impl Minecart {
|
||||
fn horizontal(&self) -> f64 {
|
||||
self.velocity.x.hypot(self.velocity.z)
|
||||
}
|
||||
/// `AbstractContainerMenu.getRedstoneSignalFromContainer` of `slots`.
|
||||
fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
|
||||
let mut total = 0.0f32;
|
||||
for s in slots.iter().flatten() {
|
||||
let max = items.get(&s.item).map_or(64, |i| i.max_stack).min(64);
|
||||
total += s.count as f32 / max as f32;
|
||||
}
|
||||
total /= slots.len().max(1) as f32;
|
||||
// Mth.lerpDiscrete(total, 0, 15)
|
||||
(total * 14.0).floor() as i32 + i32::from(total > 0.0)
|
||||
}
|
||||
}
|
||||
pub(super) struct CartModels {
|
||||
pub models: HashMap<CartKind, Part>,
|
||||
@@ -539,7 +547,6 @@ impl Game<'_> {
|
||||
}
|
||||
/// `AbstractMinecart.tick` for one cart; returns false once it is gone.
|
||||
fn tick_cart(&mut self, c: &mut Minecart, intent: [f64; 2]) -> bool {
|
||||
c.first_tick = false;
|
||||
c.previous = c.position;
|
||||
c.previous_yaw = c.yaw;
|
||||
c.hurt_time = (c.hurt_time - 1).max(0);
|
||||
@@ -547,7 +554,6 @@ impl Game<'_> {
|
||||
// applyGravity
|
||||
c.velocity.y -= if self.cart_in_water(c) { 0.005 } else { 0.04 };
|
||||
let pos = self.rail_block(c.position);
|
||||
let fall_speed = c.velocity;
|
||||
if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
|
||||
self.move_along_track(c, pos, &rail, intent);
|
||||
if &*rail.name == "minecraft:activator_rail" {
|
||||
@@ -589,22 +595,26 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
CartKind::Tnt => {
|
||||
let speed = fall_speed.x * fall_speed.x + fall_speed.z * fall_speed.z;
|
||||
// MinecartTNT.tick: the fuse and the collision check are separate.
|
||||
if c.fuse > 0 {
|
||||
c.fuse -= 1;
|
||||
let at = [c.position.x, c.position.y + 0.5, c.position.z];
|
||||
self.particles
|
||||
.add(&ParticleOptions::simple(ParticleType::Smoke), at, [0.0; 3]);
|
||||
} else if c.fuse == 0 || (c.horizontal_collision && speed >= 0.01) {
|
||||
} else if c.fuse == 0 {
|
||||
// explode(horizontalDistanceSqr()): a rising cart's
|
||||
// vertical speed adds nothing.
|
||||
let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z;
|
||||
let speed = if c.fuse == 0 && c.velocity.y > 0.0 {
|
||||
c.velocity.y * c.velocity.y
|
||||
} else {
|
||||
speed
|
||||
};
|
||||
self.explode_cart(c, speed);
|
||||
return false;
|
||||
}
|
||||
if c.horizontal_collision {
|
||||
let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z;
|
||||
if speed >= f64::from(0.01_f32) {
|
||||
self.explode_cart(c, speed);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -648,51 +658,9 @@ impl Game<'_> {
|
||||
// The cart is the explosion's source: it is gone before the blast
|
||||
// looks for entities, so it can't hit itself.
|
||||
self.carts.retain(|x| x.id != c.id);
|
||||
let _ = self.server_explode(at, power, BlockInteraction::Destroy, false);
|
||||
}
|
||||
/// `MinecartHopper.suckInItems`: items above, into the five slots.
|
||||
fn hopper_cart_pickup(&mut self, c: &mut Minecart) {
|
||||
if !c.enabled {
|
||||
return;
|
||||
}
|
||||
let p = c.position;
|
||||
let zone = Aabb::new(
|
||||
Vec3::new(p.x - 0.5, p.y - 0.5 + 0.0, p.z - 0.5),
|
||||
Vec3::new(p.x + 0.5, p.y + 1.5, p.z + 0.5),
|
||||
);
|
||||
let mut i = 0;
|
||||
while i < self.dropped.len() {
|
||||
let e = &self.dropped[i];
|
||||
if e.pickup_delay > 0 || !zone.contains(e.position) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut stack = e.stack.clone();
|
||||
for slot in c.items.iter_mut() {
|
||||
match slot {
|
||||
None => {
|
||||
*slot = Some(stack.clone());
|
||||
stack.count = 0;
|
||||
}
|
||||
Some(s) if s.same_item_same_components(&stack) => {
|
||||
let max = self.res.items().get(&s.item).map_or(64, |i| i.max_stack);
|
||||
let moved = stack.count.min(max.saturating_sub(s.count));
|
||||
s.count += moved;
|
||||
stack.count -= moved;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if stack.count == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if stack.count == 0 {
|
||||
self.dropped.remove(i);
|
||||
} else {
|
||||
self.dropped[i].stack = stack;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let _ = self.with_hive_emptying_blast(|g| {
|
||||
g.server_explode(at, power, BlockInteraction::Destroy, false)
|
||||
});
|
||||
}
|
||||
/// `AbstractMinecart.push` from each player walking into a cart
|
||||
/// (`LivingEntity.pushEntities`): the window's player and every server
|
||||
@@ -831,8 +799,8 @@ impl Game<'_> {
|
||||
}
|
||||
let mut c = self.carts[i].clone();
|
||||
let alive = self.tick_cart(&mut c, [0.0; 2]);
|
||||
if c.kind == CartKind::Hopper {
|
||||
self.hopper_cart_pickup(&mut c);
|
||||
if c.kind == CartKind::Hopper && c.enabled {
|
||||
self.hopper_cart_suck(&mut c);
|
||||
}
|
||||
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
|
||||
continue;
|
||||
@@ -950,7 +918,7 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `getDismountLocationForPassenger`: beside the cart (across its motion), else on top.
|
||||
/// `getDismountLocationForPassenger`: beside the cart (across its facing), else on top.
|
||||
pub(super) fn dismount_cart(&mut self, i: usize) {
|
||||
self.riding = None;
|
||||
let c = &self.carts[i];
|
||||
@@ -960,17 +928,25 @@ impl Game<'_> {
|
||||
c.position.z.floor() as i32,
|
||||
);
|
||||
let mut target = None;
|
||||
// DismountHelper.offsetsForDirection of the cart's facing
|
||||
// (Direction.fromYRot of its yaw): right, left, back right, back left,
|
||||
// forward right, forward left, back, forward.
|
||||
let step = |q: i32| [(0, 1), (-1, 0), (0, -1), (1, 0)][(q & 3) as usize];
|
||||
let facing = (f64::from(c.yaw) / 90.0 + 0.5).floor() as i32;
|
||||
let (fx, fz) = step(facing);
|
||||
let (rx, rz) = (-fz, fx);
|
||||
let offsets = [
|
||||
(rx, rz),
|
||||
(-rx, -rz),
|
||||
(rx - fx, rz - fz),
|
||||
(-rx - fx, -rz - fz),
|
||||
(fx + rx, fz + rz),
|
||||
(fx - rx, fz - rz),
|
||||
(-fx, -fz),
|
||||
(fx, fz),
|
||||
];
|
||||
'search: for dy in [0, 1, -1] {
|
||||
for (dx, dz) in [
|
||||
(1, 0),
|
||||
(-1, 0),
|
||||
(0, 1),
|
||||
(0, -1),
|
||||
(1, 1),
|
||||
(-1, -1),
|
||||
(1, -1),
|
||||
(-1, 1),
|
||||
] {
|
||||
for (dx, dz) in offsets {
|
||||
let (x, y, z) = (bx + dx, by + dy, bz + dz);
|
||||
let below = self.res.facts().get(self.world.block(x, y - 1, z));
|
||||
let here = self.res.facts().get(self.world.block(x, y, z));
|
||||
@@ -1054,6 +1030,22 @@ impl Game<'_> {
|
||||
}
|
||||
best
|
||||
}
|
||||
/// Each cart's kind and comparator signal, for the detector rail
|
||||
/// (`DetectorRailBlock.getAnalogOutputSignal`). A cart whose menu is open
|
||||
/// counts the items in that menu.
|
||||
pub(super) fn cart_signals(&self) -> Vec<(CartKind, i32)> {
|
||||
self.carts
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
|
||||
let slots = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Chest(open)) => open,
|
||||
_ => &c.items,
|
||||
};
|
||||
(c.kind, Minecart::container_signal(slots, self.res.items()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `interact`: ride, open, fuel or ignite the cart under the crosshair.
|
||||
pub(super) fn interact_cart(&mut self) -> bool {
|
||||
let Some((i, d)) = self.cart_hit() else {
|
||||
@@ -1071,10 +1063,8 @@ impl Game<'_> {
|
||||
}
|
||||
self.riding = Some(self.carts[i].id);
|
||||
}
|
||||
// AbstractMinecartContainer.interact: no sneak check.
|
||||
CartKind::Chest | CartKind::Hopper => {
|
||||
if sneaking {
|
||||
return false;
|
||||
}
|
||||
let c = self.carts[i].clone();
|
||||
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
|
||||
self.world
|
||||
@@ -1344,8 +1334,9 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's mean
|
||||
// edge, scaled by the view scale.
|
||||
let range = (WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * self.view_scale();
|
||||
// edge, scaled by the entity distance option.
|
||||
let range =
|
||||
(WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * f64::from(self.settings.entity_distance);
|
||||
for c in &self.carts {
|
||||
let entity_start = out.len();
|
||||
let Some(model) = self.cart_models.models.get(&c.kind) else {
|
||||
@@ -1512,10 +1503,6 @@ impl Minecart {
|
||||
map.insert(JavaString::from(k), v);
|
||||
};
|
||||
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
|
||||
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
|
||||
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
|
||||
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
|
||||
put("Fire", Tag::Short(0));
|
||||
match self.kind {
|
||||
CartKind::Chest | CartKind::Hopper => {
|
||||
put("Items", horizon_client::save::encode_items(&self.items));
|
||||
@@ -1565,7 +1552,6 @@ impl Minecart {
|
||||
_ => 0.0,
|
||||
};
|
||||
c.flipped = int("FlippedRotation").unwrap_or(0) != 0;
|
||||
c.first_tick = int("HasTicked").unwrap_or(0) != 0;
|
||||
if kind.slots() > 0 {
|
||||
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
|
||||
}
|
||||
@@ -1590,30 +1576,4 @@ mod tests {
|
||||
assert_eq!(display_offset(CartKind::CommandBlock), 6.0);
|
||||
assert_eq!(display_offset(CartKind::Rideable), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cart_saves_as_never_ticked_until_it_ticks() {
|
||||
use horizon_world::nbt::Tag;
|
||||
let generated = Tag::compound([
|
||||
("id", Tag::String("minecraft:chest_minecart".into())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(
|
||||
[0.5f64, 40.5, 0.5]
|
||||
.map(|v| Tag::Double(v.to_bits()))
|
||||
.to_vec(),
|
||||
),
|
||||
),
|
||||
("HasTicked", Tag::Byte(1)),
|
||||
("Fire", Tag::Short(0)),
|
||||
]);
|
||||
let mut cart = Minecart::from_nbt(&generated, 1, CartKind::Chest).unwrap();
|
||||
let saved = cart.to_nbt([0; 4], Some(&generated));
|
||||
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
|
||||
cart.first_tick = false;
|
||||
let saved = cart.to_nbt([0; 4], Some(&generated));
|
||||
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,6 @@ impl Game<'_> {
|
||||
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
|
||||
None
|
||||
}
|
||||
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A mod's zoom (`render.set-zoom`), clamped by the runtime.
|
||||
@@ -54,13 +51,6 @@ pub(super) struct ModMinimap {
|
||||
pub(super) round: bool,
|
||||
}
|
||||
|
||||
/// A mod's far terrain (`render.set-far-terrain`), clamped by the runtime.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ModFarTerrain {
|
||||
pub(super) distance: u32,
|
||||
pub(super) reduced: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
impl Game<'_> {
|
||||
/// The main hand's stack as `input` shows it; none when it is empty.
|
||||
@@ -200,12 +190,4 @@ impl Game<'_> {
|
||||
round: m.round,
|
||||
})
|
||||
}
|
||||
/// The far terrain the first mod drawing one asks for.
|
||||
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
|
||||
let f = self.mods.mods.far_terrain()?;
|
||||
Some(ModFarTerrain {
|
||||
distance: f.distance,
|
||||
reduced: f.reduced,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -126,19 +126,6 @@ impl Game<'_> {
|
||||
if !equine::is_equine(species) || !m.alive() || Some(m.id) == self.riding {
|
||||
return false;
|
||||
}
|
||||
// AbstractHorse.mobInteract: a horse a mob rides (`isVehicle`) has no
|
||||
// seat, inventory or feeding to give. Camels seat two and check their
|
||||
// passengers themselves.
|
||||
let id = m.id;
|
||||
if !matches!(species, Species::Camel | Species::CamelHusk)
|
||||
&& self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|p| p.extra.mount == Some(id) && !p.removed && p.alive())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let m = &self.mobs[index];
|
||||
// AbstractHorse.mobInteract: shift + use opens a tamed adult's inventory.
|
||||
if !m.is_baby()
|
||||
&& m.extra.tame
|
||||
@@ -228,10 +215,12 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let mut used = false;
|
||||
let mut loved = false;
|
||||
if love && m.extra.tame && m.extra.age == 0 && m.extra.in_love <= 0 {
|
||||
m.extra.in_love = 600;
|
||||
m.extra.love_player = true;
|
||||
used = true;
|
||||
loved = true;
|
||||
}
|
||||
if m.health < m.attrs.max_health && heal > 0.0 {
|
||||
m.health = (m.health + heal).min(m.attrs.max_health);
|
||||
@@ -251,6 +240,7 @@ impl Game<'_> {
|
||||
// eating(): the mouth opens and the horse eats.
|
||||
m.anim.v[2] = 1.0;
|
||||
let at = m.center();
|
||||
let id = m.id;
|
||||
let sound = match m.species() {
|
||||
Species::Camel => "minecraft:entity.camel.eat",
|
||||
Species::CamelHusk => "minecraft:entity.camel_husk.eat",
|
||||
@@ -260,6 +250,10 @@ impl Game<'_> {
|
||||
};
|
||||
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
|
||||
self.audio.play(sound, Some(at), 1.0, pitch);
|
||||
// Animal.setInLove: entity event 18.
|
||||
if loved {
|
||||
self.mob_event_particles(id, 18);
|
||||
}
|
||||
self.consume_held();
|
||||
true
|
||||
}
|
||||
@@ -465,14 +459,16 @@ impl Game<'_> {
|
||||
m.extra.owner = owner;
|
||||
m.persistent = true;
|
||||
// AbstractHorse.tameWithName: entity event 7, spawnTamingParticles(true).
|
||||
self.particles_around_mob(
|
||||
mob,
|
||||
"heart",
|
||||
horizon_client::particles::Burst::TAMING,
|
||||
);
|
||||
self.mob_event_particles(mob, 7);
|
||||
}
|
||||
}
|
||||
MobEvent::Buck { mob } if self.riding == Some(mob) => self.dismount_mount(),
|
||||
MobEvent::Buck { mob } => {
|
||||
if self.riding == Some(mob) {
|
||||
self.dismount_mount();
|
||||
}
|
||||
// RunAroundLikeCrazyGoal.tick: entity event 6, smoke.
|
||||
self.mob_event_particles(mob, 6);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,13 +85,8 @@ impl Game<'_> {
|
||||
m.ai.target = None;
|
||||
}
|
||||
let sound = nautilus::eat_sound(m);
|
||||
let kind = if tamed { "heart" } else { "smoke" };
|
||||
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
|
||||
self.particles_around_mob(
|
||||
self.mobs[index].id,
|
||||
kind,
|
||||
horizon_client::particles::Burst::TAMING,
|
||||
);
|
||||
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
|
||||
self.nautilus_sound(index, sound);
|
||||
if tamed {
|
||||
let view = self.mob_entity(&self.mobs[index]);
|
||||
@@ -145,6 +140,8 @@ impl Game<'_> {
|
||||
let sound = nautilus::eat_sound(m);
|
||||
self.use_nautilus_food(item);
|
||||
self.nautilus_sound(index, sound);
|
||||
// Animal.setInLove: entity event 18.
|
||||
self.mob_event_particles(self.mobs[index].id, 18);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
//! `Entity.setAsInsidePortal` / `handlePortal` with `PortalProcessor`,
|
||||
//! `NetherPortalBlock.getPortalTransitionTime` and `getPortalDestination`
|
||||
//! (`PortalForcer` through `horizon_world::level::portal_travel`), and
|
||||
//! `LocalPlayer.handlePortalTransitionEffect`. Items and mobs also go through
|
||||
//! End portals here (`EndPortalBlock.entityInside` / `getPortalDestination`);
|
||||
//! the player's End portals are game/end.rs's.
|
||||
//! `LocalPlayer.handlePortalTransitionEffect`.
|
||||
//!
|
||||
//! Only one dimension is loaded at a time. The player's destination is found
|
||||
//! once the other dimension has loaded around it. An item or mob that goes
|
||||
@@ -14,7 +12,7 @@ use super::{Dim, Game, Screen};
|
||||
use horizon_client::Result;
|
||||
use horizon_client::nether_portal::PortalState;
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Axis, Vec3},
|
||||
geometry::{Axis, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
@@ -42,32 +40,6 @@ pub(super) struct Plan {
|
||||
offset: Vec3,
|
||||
width: f32,
|
||||
height: f32,
|
||||
/// Through an End portal: `approximate` is the End's spawn point, or the
|
||||
/// Overworld's shared spawn.
|
||||
end: bool,
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
/// `EndPortalBlock.getPortalDestination` for an entity other than a
|
||||
/// player in `from`: the End's spawn point, or out of the End the
|
||||
/// Overworld's shared spawn `world_spawn`.
|
||||
fn end_portal(from: Dim, world_spawn: BlockPos, width: f32, height: f32) -> Self {
|
||||
let (dim, approximate) = if from == Dim::End {
|
||||
(Dim::Overworld, world_spawn)
|
||||
} else {
|
||||
let [x, y, z] = super::end::END_SPAWN;
|
||||
(Dim::End, BlockPos::new(x, y, z))
|
||||
};
|
||||
Self {
|
||||
dim,
|
||||
approximate,
|
||||
entry_x_axis: true,
|
||||
offset: Vec3::ZERO,
|
||||
width,
|
||||
height,
|
||||
end: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An entity on its way to a dimension that isn't loaded.
|
||||
@@ -109,89 +81,6 @@ fn rotate_y(v: Vec3, degrees: f32) -> Vec3 {
|
||||
fn doubles(values: [f64; 3]) -> Tag {
|
||||
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
|
||||
}
|
||||
/// Whether a `width` by `height` box moving from `from` to `to` meets the End
|
||||
/// portal at `pos`: `Entity.collidedWithShapeMovingFrom` against its
|
||||
/// entity-inside shape, y 6/16..12/16 of the block.
|
||||
fn touches_end_portal(from: Vec3, to: Vec3, width: f32, height: f32, pos: BlockPos) -> bool {
|
||||
let half = f64::from(width) / 2.0;
|
||||
let start = Aabb::new(
|
||||
Vec3::new(from.x - half, from.y, from.z - half),
|
||||
Vec3::new(from.x + half, from.y + f64::from(height), from.z + half),
|
||||
);
|
||||
let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z));
|
||||
let shape = Aabb::new(
|
||||
Vec3::new(x, y + 0.375, z),
|
||||
Vec3::new(x + 1.0, y + 0.75, z + 1.0),
|
||||
);
|
||||
start.collided_along_vector(to.subtract(from), &[shape])
|
||||
}
|
||||
/// `PositionMoveRotation.calculateAbsolute` for an End portal's transition
|
||||
/// of an entity facing `[yaw, pitch]`: into the End it faces west and keeps
|
||||
/// its pitch (`Relative.X_ROT`); out of it both turn by the world spawn's
|
||||
/// angles (`Relative.ROTATION`). The pitch stays within ±90.
|
||||
fn end_portal_rotation(into_end: bool, [yaw, pitch]: [f32; 2], spawn: [f32; 2]) -> [f32; 2] {
|
||||
let (yaw, pitch) = if into_end {
|
||||
// Direction.WEST.toYRot().
|
||||
(90.0, pitch)
|
||||
} else {
|
||||
(yaw + spawn[0], pitch + spawn[1])
|
||||
};
|
||||
[yaw, pitch.clamp(-90.0, 90.0)]
|
||||
}
|
||||
/// A waiting entity as `horizon_portal_arrivals.dat` keeps it.
|
||||
fn arrival_tag(a: &Arrival) -> Tag {
|
||||
let p = &a.plan;
|
||||
Tag::compound([
|
||||
("dimension", Tag::String(p.dim.id().into())),
|
||||
("x", Tag::Int(p.approximate.x)),
|
||||
("y", Tag::Int(p.approximate.y)),
|
||||
("z", Tag::Int(p.approximate.z)),
|
||||
("x_axis", Tag::Int(i32::from(p.entry_x_axis))),
|
||||
("right", Tag::Double(p.offset.x.to_bits())),
|
||||
("up", Tag::Double(p.offset.y.to_bits())),
|
||||
("forward", Tag::Double(p.offset.z.to_bits())),
|
||||
("width", Tag::Float(p.width.to_bits())),
|
||||
("height", Tag::Float(p.height.to_bits())),
|
||||
("end", Tag::Int(i32::from(p.end))),
|
||||
("entity", a.tag.clone()),
|
||||
])
|
||||
}
|
||||
/// The waiting entity `arrival_tag` wrote.
|
||||
fn arrival_from_tag(a: &Tag) -> Option<Arrival> {
|
||||
let int = |k: &str| match a.get(k) {
|
||||
Some(Tag::Int(v)) => Some(*v),
|
||||
_ => None,
|
||||
};
|
||||
let float = |k: &str| match a.get(k) {
|
||||
Some(Tag::Float(v)) => Some(f32::from_bits(*v)),
|
||||
_ => None,
|
||||
};
|
||||
let double = |k: &str| match a.get(k) {
|
||||
Some(Tag::Double(v)) => Some(f64::from_bits(*v)),
|
||||
_ => None,
|
||||
};
|
||||
let dim = Dim::from_id(&horizon_client::save::text(a.get("dimension"))?);
|
||||
let (Some(x), Some(y), Some(z), Some(tag)) = (int("x"), int("y"), int("z"), a.get("entity"))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
Some(Arrival {
|
||||
plan: Plan {
|
||||
dim,
|
||||
approximate: BlockPos::new(x, y, z),
|
||||
entry_x_axis: int("x_axis").unwrap_or(1) != 0,
|
||||
offset: Vec3::new(
|
||||
double("right").unwrap_or(0.5),
|
||||
double("up").unwrap_or(0.0),
|
||||
double("forward").unwrap_or(0.0),
|
||||
),
|
||||
width: float("width").unwrap_or(0.25),
|
||||
height: float("height").unwrap_or(0.25),
|
||||
end: int("end").unwrap_or(0) != 0,
|
||||
},
|
||||
tag: tag.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The world border's extent.
|
||||
@@ -257,54 +146,8 @@ impl Game<'_> {
|
||||
offset,
|
||||
width,
|
||||
height,
|
||||
end: false,
|
||||
}
|
||||
}
|
||||
/// The plan of an item or mob going through the portal block at `entry`:
|
||||
/// an End portal's fixed destination, else the Nether portal's.
|
||||
fn entity_plan(
|
||||
&self,
|
||||
entry: BlockPos,
|
||||
at: Vec3,
|
||||
width: f32,
|
||||
height: f32,
|
||||
reset_forward: bool,
|
||||
) -> Plan {
|
||||
if !self.is_end_portal(self.world.block(entry.x, entry.y, entry.z)) {
|
||||
return self.portal_plan(entry, at, width, height, reset_forward);
|
||||
}
|
||||
let spawn = self.level.world_spawn.unwrap_or_else(|| {
|
||||
self.spawn.map_or([0, 64, 0], |s| {
|
||||
[s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32]
|
||||
})
|
||||
});
|
||||
Plan::end_portal(
|
||||
self.dim,
|
||||
BlockPos::new(spawn[0], spawn[1], spawn[2]),
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
fn is_end_portal(&self, state: horizon_world::registry::BlockStateId) -> bool {
|
||||
&*self.res.facts().get(state).name == "minecraft:end_portal"
|
||||
}
|
||||
/// Where an item or mob through an End portal lands once `plan`'s level
|
||||
/// has loaded around it: the bottom centre of the End's spawn point,
|
||||
/// above the platform built below it (`EndPlatformFeature.createEndPlatform`);
|
||||
/// out of the End, the top of the shared spawn's column
|
||||
/// (`Entity.adjustSpawnLocation`).
|
||||
fn end_portal_exit(&mut self, plan: &Plan) -> Vec3 {
|
||||
let p = plan.approximate;
|
||||
let centre = Vec3::new(f64::from(p.x) + 0.5, f64::from(p.y), f64::from(p.z) + 0.5);
|
||||
if plan.dim == Dim::End {
|
||||
if let Err(e) = self.end_arrival(centre) {
|
||||
log::error!("End platform: {e}");
|
||||
}
|
||||
return centre;
|
||||
}
|
||||
let y = self.motion_top(p.x, p.z);
|
||||
Vec3::new(centre.x, f64::from(y), centre.z)
|
||||
}
|
||||
/// The nether portal blocks of the chunks within `radius` of `center`
|
||||
/// (the points of interest `PortalForcer` searches): loaded chunks as
|
||||
/// they are, the others as saved; chunks never generated have none.
|
||||
@@ -677,10 +520,9 @@ impl Game<'_> {
|
||||
|| self.ridden_by_guest(id)
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
}
|
||||
/// `NetherPortalBlock.entityInside` and `EndPortalBlock.entityInside`
|
||||
/// for the items, mobs and empty boats and minecarts that moved this
|
||||
/// tick: the portal blocks their box swept through (an End portal's
|
||||
/// transition time is 0 too).
|
||||
/// `NetherPortalBlock.entityInside` for the items, mobs and empty boats
|
||||
/// and minecarts that moved this tick: the portal blocks their box swept
|
||||
/// through.
|
||||
pub(super) fn find_entity_portals(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
@@ -728,7 +570,7 @@ impl Game<'_> {
|
||||
.set_inside(pos, ENTITY_DELAY);
|
||||
}
|
||||
}
|
||||
/// The first nether or End portal block an entity box swept through
|
||||
/// The first nether portal block an entity box swept through
|
||||
/// (`Entity.checkInsideBlocks`, whose first portal sets the entry).
|
||||
fn swept_portal(
|
||||
&self,
|
||||
@@ -762,9 +604,11 @@ impl Game<'_> {
|
||||
bounds,
|
||||
1_000_000,
|
||||
|p, _| {
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
if self.res.behaviours().portal_axis(state).is_some()
|
||||
|| (self.is_end_portal(state) && touches_end_portal(from, to, width, height, p))
|
||||
if self
|
||||
.res
|
||||
.behaviours()
|
||||
.portal_axis(self.world.block(p.x, p.y, p.z))
|
||||
.is_some()
|
||||
{
|
||||
found = Some(p);
|
||||
return false;
|
||||
@@ -774,8 +618,8 @@ impl Game<'_> {
|
||||
);
|
||||
found
|
||||
}
|
||||
/// Whether a section `reach` touches has a nether or End portal state in
|
||||
/// its palette: without one, no block there is a portal. Palette answers are
|
||||
/// Whether a section `reach` touches has a nether portal state in its
|
||||
/// palette: without one, no block there is a portal. Palette answers are
|
||||
/// kept per chunk across ticks (`portal_sections`); a box over many
|
||||
/// sections is not checked.
|
||||
fn may_hold_portal(
|
||||
@@ -812,8 +656,9 @@ impl Game<'_> {
|
||||
(z0..=z1).any(|cz| {
|
||||
chunks.get(&(cx, cz)).is_some_and(|chunk| {
|
||||
sections.get((cx, cz), chunk, |v| {
|
||||
let state = horizon_world::registry::BlockStateId(v);
|
||||
behaviours.portal_axis(state).is_some() || self.is_end_portal(state)
|
||||
behaviours
|
||||
.portal_axis(horizon_world::registry::BlockStateId(v))
|
||||
.is_some()
|
||||
}) & y_mask
|
||||
!= 0
|
||||
})
|
||||
@@ -825,7 +670,7 @@ impl Game<'_> {
|
||||
fn send_through_portal(&mut self, id: u32, entry: BlockPos) {
|
||||
if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
|
||||
let e = &self.dropped[i];
|
||||
let plan = self.entity_plan(entry, e.position, 0.25, 0.25, false);
|
||||
let plan = self.portal_plan(entry, e.position, 0.25, 0.25, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
@@ -841,7 +686,7 @@ impl Game<'_> {
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
|
||||
let m = &self.mobs[i];
|
||||
let plan = self.entity_plan(entry, m.position, m.width(), m.height(), true);
|
||||
let plan = self.portal_plan(entry, m.position, m.width(), m.height(), true);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
@@ -871,7 +716,7 @@ impl Game<'_> {
|
||||
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
let b = &self.boats[i];
|
||||
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
|
||||
let plan = self.entity_plan(entry, b.position, width, height, false);
|
||||
let plan = self.portal_plan(entry, b.position, width, height, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
@@ -896,7 +741,7 @@ impl Game<'_> {
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
);
|
||||
let plan = self.entity_plan(entry, c.position, width, height, false);
|
||||
let plan = self.portal_plan(entry, c.position, width, height, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
@@ -909,8 +754,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// Entities waiting for the loaded dimension whose exit area is loaded:
|
||||
/// their exit portal (found or built) or End destination, position,
|
||||
/// turned yaw and motion, and portal cooldown; they join the world with the chunk entities.
|
||||
/// their exit portal (found or built), position, turned yaw and motion,
|
||||
/// and portal cooldown; they join the world with the chunk entities.
|
||||
pub(super) fn resolve_arrivals(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
@@ -927,28 +772,21 @@ impl Game<'_> {
|
||||
}
|
||||
let Arrival { plan, mut tag } = self.portals.arrivals.remove(i);
|
||||
self.portals.dirty = true;
|
||||
let motion = horizon_client::entity_nbt::motion(&tag);
|
||||
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
|
||||
let (at, motion, [yaw, pitch]) = if plan.end {
|
||||
// Relative.DELTA: the motion is kept.
|
||||
let at = self.end_portal_exit(&plan);
|
||||
let into_end = plan.dim == Dim::End;
|
||||
let turn =
|
||||
end_portal_rotation(into_end, [yaw, pitch], self.level.world_spawn_angle);
|
||||
(at, motion, turn)
|
||||
} else {
|
||||
let Some(exit) = self.nether_exit_portal(&plan) else {
|
||||
continue;
|
||||
};
|
||||
let (at, rotation) = self.arrival(&plan, exit);
|
||||
(at, rotate_y(motion, -rotation), [yaw + rotation, pitch])
|
||||
let Some(exit) = self.nether_exit_portal(&plan) else {
|
||||
continue;
|
||||
};
|
||||
let (at, rotation) = self.arrival(&plan, exit);
|
||||
let motion = rotate_y(horizon_client::entity_nbt::motion(&tag), -rotation);
|
||||
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
|
||||
c.insert("Motion".into(), doubles([motion.x, motion.y, motion.z]));
|
||||
c.insert(
|
||||
"Rotation".into(),
|
||||
Tag::List(vec![Tag::Float(yaw.to_bits()), Tag::Float(pitch.to_bits())]),
|
||||
Tag::List(vec![
|
||||
Tag::Float((yaw + rotation).to_bits()),
|
||||
Tag::Float(pitch.to_bits()),
|
||||
]),
|
||||
);
|
||||
// setPortalCooldown before the teleport, copied by restoreFrom.
|
||||
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
@@ -985,117 +823,75 @@ impl Game<'_> {
|
||||
let Some(Tag::List(list)) = root.get("arrivals") else {
|
||||
return;
|
||||
};
|
||||
self.portals
|
||||
.arrivals
|
||||
.extend(list.iter().filter_map(arrival_from_tag));
|
||||
for a in list {
|
||||
let int = |k: &str| match a.get(k) {
|
||||
Some(Tag::Int(v)) => Some(*v),
|
||||
_ => None,
|
||||
};
|
||||
let float = |k: &str| match a.get(k) {
|
||||
Some(Tag::Float(v)) => Some(f32::from_bits(*v)),
|
||||
_ => None,
|
||||
};
|
||||
let double = |k: &str| match a.get(k) {
|
||||
Some(Tag::Double(v)) => Some(f64::from_bits(*v)),
|
||||
_ => None,
|
||||
};
|
||||
let dim = match horizon_client::save::text(a.get("dimension")).as_deref() {
|
||||
Some("minecraft:the_nether") => Dim::Nether,
|
||||
Some(_) => Dim::Overworld,
|
||||
None => continue,
|
||||
};
|
||||
let (Some(x), Some(y), Some(z), Some(tag)) =
|
||||
(int("x"), int("y"), int("z"), a.get("entity"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
self.portals.arrivals.push(Arrival {
|
||||
plan: Plan {
|
||||
dim,
|
||||
approximate: BlockPos::new(x, y, z),
|
||||
entry_x_axis: int("x_axis").unwrap_or(1) != 0,
|
||||
offset: Vec3::new(
|
||||
double("right").unwrap_or(0.5),
|
||||
double("up").unwrap_or(0.0),
|
||||
double("forward").unwrap_or(0.0),
|
||||
),
|
||||
width: float("width").unwrap_or(0.25),
|
||||
height: float("height").unwrap_or(0.25),
|
||||
},
|
||||
tag: tag.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Write the waiting entities when they changed.
|
||||
pub(super) fn save_portal_arrivals(&mut self) {
|
||||
if !self.portals.dirty || self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
let list = self.portals.arrivals.iter().map(arrival_tag).collect();
|
||||
let list = self
|
||||
.portals
|
||||
.arrivals
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let p = &a.plan;
|
||||
Tag::compound([
|
||||
("dimension", Tag::String(p.dim.id().into())),
|
||||
("x", Tag::Int(p.approximate.x)),
|
||||
("y", Tag::Int(p.approximate.y)),
|
||||
("z", Tag::Int(p.approximate.z)),
|
||||
("x_axis", Tag::Int(i32::from(p.entry_x_axis))),
|
||||
("right", Tag::Double(p.offset.x.to_bits())),
|
||||
("up", Tag::Double(p.offset.y.to_bits())),
|
||||
("forward", Tag::Double(p.offset.z.to_bits())),
|
||||
("width", Tag::Float(p.width.to_bits())),
|
||||
("height", Tag::Float(p.height.to_bits())),
|
||||
("entity", a.tag.clone()),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
let root = Tag::compound([("arrivals", Tag::List(list))]);
|
||||
if horizon_client::save::write_dat(&self.arrivals_path(), root).is_ok() {
|
||||
self.portals.dirty = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn end_portals_lead_to_the_end_spawn_and_back_to_the_world_spawn() {
|
||||
let spawn = BlockPos::new(-7, 70, 12);
|
||||
for from in [Dim::Overworld, Dim::Nether] {
|
||||
let plan = Plan::end_portal(from, spawn, 0.9, 1.4);
|
||||
assert_eq!(plan.dim, Dim::End);
|
||||
assert_eq!(plan.approximate, BlockPos::new(100, 50, 0));
|
||||
assert!(plan.end);
|
||||
}
|
||||
let plan = Plan::end_portal(Dim::End, spawn, 0.25, 0.25);
|
||||
assert_eq!(plan.dim, Dim::Overworld);
|
||||
assert_eq!(plan.approximate, spawn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_end_portal_shape_takes_falling_items_and_standing_mobs() {
|
||||
let portal = BlockPos::new(0, 100, 3);
|
||||
let at = |y| Vec3::new(0.5, y, 3.5);
|
||||
// A dropped item falling through the slab, or in it.
|
||||
assert!(touches_end_portal(
|
||||
at(100.6),
|
||||
at(100.56),
|
||||
0.25,
|
||||
0.25,
|
||||
portal
|
||||
));
|
||||
// One tick's fall across the whole slab, in it at neither end.
|
||||
assert!(touches_end_portal(at(101.0), at(99.9), 0.25, 0.25, portal));
|
||||
// Resting below the slab (on the floor) or above it.
|
||||
assert!(!touches_end_portal(
|
||||
at(100.0),
|
||||
at(100.0),
|
||||
0.25,
|
||||
0.25,
|
||||
portal
|
||||
));
|
||||
assert!(!touches_end_portal(
|
||||
at(100.8),
|
||||
at(100.8),
|
||||
0.25,
|
||||
0.25,
|
||||
portal
|
||||
));
|
||||
// A cow standing in the portal block.
|
||||
assert!(touches_end_portal(at(100.0), at(100.0), 0.9, 1.4, portal));
|
||||
// Beside it.
|
||||
let beside = Vec3::new(2.0, 100.0, 3.5);
|
||||
assert!(!touches_end_portal(beside, beside, 0.9, 1.4, portal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_portals_turn_entities_like_their_teleport_transition() {
|
||||
// Into the End: facing west, pitch kept.
|
||||
assert_eq!(
|
||||
end_portal_rotation(true, [-30.0, 20.0], [45.0, 10.0]),
|
||||
[90.0, 20.0]
|
||||
);
|
||||
// Out of it: turned by the world spawn's yaw and pitch, pitch clamped.
|
||||
assert_eq!(
|
||||
end_portal_rotation(false, [-30.0, 20.0], [45.0, 10.0]),
|
||||
[15.0, 30.0]
|
||||
);
|
||||
assert_eq!(
|
||||
end_portal_rotation(false, [0.0, 80.0], [0.0, 30.0]),
|
||||
[0.0, 90.0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_arrivals_keep_their_end_destination_when_saved() {
|
||||
let tag = Tag::compound([("id", Tag::String("minecraft:cow".into()))]);
|
||||
for (from, dim) in [(Dim::Overworld, Dim::End), (Dim::End, Dim::Overworld)] {
|
||||
let arrival = Arrival {
|
||||
plan: Plan::end_portal(from, BlockPos::new(3, 64, -5), 0.9, 1.4),
|
||||
tag: tag.clone(),
|
||||
};
|
||||
let back = arrival_from_tag(&arrival_tag(&arrival)).unwrap();
|
||||
assert_eq!(back.plan.dim, dim);
|
||||
assert_eq!(back.plan.approximate, arrival.plan.approximate);
|
||||
assert!(back.plan.end);
|
||||
assert_eq!(back.tag, tag);
|
||||
}
|
||||
// Arrivals saved before End portals were Nether portal plans.
|
||||
let mut old = arrival_tag(&Arrival {
|
||||
plan: Plan::end_portal(Dim::End, BlockPos::new(0, 64, 0), 0.25, 0.25),
|
||||
tag,
|
||||
});
|
||||
if let Tag::Compound(c) = &mut old {
|
||||
c.remove(&"end".into());
|
||||
}
|
||||
assert!(!arrival_from_tag(&old).unwrap().plan.end);
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,6 @@ pub fn open_folder(dir: &Path) {
|
||||
log::warn!("Horizon: could not open {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
/// `Util.getPlatform().openUri`: the system browser on the link.
|
||||
pub fn open_uri(uri: &str) {
|
||||
open_folder(Path::new(uri));
|
||||
}
|
||||
/// The resource root to load from, given the vanilla root, the selection and
|
||||
/// a server's packs, which go on top (`Pack.Position.TOP`, fixed).
|
||||
pub fn startup_root(
|
||||
|
||||
@@ -267,7 +267,8 @@ impl Game<'_> {
|
||||
// measured from the box's centre (HangingEntity's position).
|
||||
let (lo, hi) = p.bounds();
|
||||
let (lo, hi) = (glam::DVec3::from(lo), glam::DVec3::from(hi));
|
||||
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
|
||||
let range =
|
||||
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
|
||||
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -218,11 +218,20 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
// A living mount at the default scale and camera distance.
|
||||
// Camera.setup: the living mount's scale and camera distance
|
||||
// attributes (the happy ghast's is 8, the default 4).
|
||||
let mount = self
|
||||
.riding
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|_| (1.0, 4.0));
|
||||
.map(|m| {
|
||||
let attribute = |name: &str, default: f64| {
|
||||
m.kind.attributes.get(name).copied().unwrap_or(default)
|
||||
};
|
||||
(
|
||||
attribute("minecraft:scale", 1.0) as f32,
|
||||
attribute("minecraft:camera_distance", 4.0),
|
||||
)
|
||||
});
|
||||
// AbstractBoat.positionRider turns a passenger by the boat's
|
||||
// deltaRotation after its yRotO was saved, and LocalPlayer.getViewYRot
|
||||
// interpolates a passenger's yaw: the boat's turn is spread over the
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
//! `Consumable`), throwing splash/lingering potions (`ThrownSplashPotion`),
|
||||
//! filling glass bottles (`BottleItem`), the per-tick effect application,
|
||||
//! effect attribute modifiers and the HUD effect icons (`Hud.extractEffects`).
|
||||
use super::{Game, Screen, item_draw::owner_at};
|
||||
use super::{
|
||||
Game, Screen,
|
||||
dragon::{Breath, FightPlayer, FightWho},
|
||||
item_draw::owner_at,
|
||||
remote_projectiles::cloud_client_tick,
|
||||
};
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
block_entity_render::vertex_light,
|
||||
@@ -12,9 +17,13 @@ use horizon_client::{
|
||||
hud_layout, hud_overlays, inventory_effects,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Direction, Vec3},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
|
||||
/// A thrown splash or lingering potion in flight.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -31,6 +40,35 @@ pub(super) struct ThrownPotion {
|
||||
/// for a dispenser's).
|
||||
owner: Option<Text>,
|
||||
}
|
||||
/// `AreaEffectCloud` wait time set by `ThrownLingeringPotion.onHitAsPotion`.
|
||||
pub(super) const LINGERING_WAIT: i32 = 10;
|
||||
/// `AreaEffectCloud.DEFAULT_REAPPLICATION_DELAY`.
|
||||
const REAPPLICATION_DELAY: i32 = 20;
|
||||
/// An entity a cloud has affected (`AreaEffectCloud.victims`).
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Victim {
|
||||
Mob(u32),
|
||||
Player(FightWho),
|
||||
}
|
||||
/// What a lingering potion's `AreaEffectCloud` applies: the potion's effects
|
||||
/// (at the lingering item's 0.25 duration scale) and who it has affected.
|
||||
pub(super) struct PotionCloud {
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
/// The potion's colour (`PotionContents.getColor`).
|
||||
color: u32,
|
||||
/// Each victim and the tick it can be affected again.
|
||||
victims: Vec<(Victim, i32)>,
|
||||
/// Who its instant damage names (`indirectMagic(cloud, owner)`): the
|
||||
/// thrower, else the cloud.
|
||||
source: Text,
|
||||
}
|
||||
impl PotionCloud {
|
||||
/// `AreaEffectCloud.updateParticle`: an entity effect in the potion's colour.
|
||||
pub(super) fn particle(&self) -> ParticleOptions {
|
||||
let c = |shift: u32| (self.color >> shift & 255) as f32 / 255.0;
|
||||
ParticleOptions::colored(ParticleType::EntityEffect, [c(16), c(8), c(0), 1.0])
|
||||
}
|
||||
}
|
||||
/// A mob's effect ids carry the `minecraft:` namespace.
|
||||
fn mob_effect_id(id: &str) -> Option<&'static str> {
|
||||
use std::{collections::HashMap, sync::OnceLock};
|
||||
@@ -48,7 +86,203 @@ fn mob_effect_id(id: &str) -> Option<&'static str> {
|
||||
.get(id)
|
||||
.copied()
|
||||
}
|
||||
/// `LivingEntity.canBeAffected` with the mob overrides (`WitherBoss` and
|
||||
/// `WitherSkeleton` shrug off wither, `Spider` and `AbstractNautilus` poison,
|
||||
/// `Parched` weakness): the immune-to-infested and immune-to-oozing tags
|
||||
/// decide first, then the tag of mobs that ignore poison and regeneration.
|
||||
fn mob_can_be_affected(
|
||||
tags: &std::collections::HashMap<String, std::collections::HashSet<String>>,
|
||||
kind: &str,
|
||||
effect: &str,
|
||||
) -> bool {
|
||||
let effect = effect.trim_start_matches("minecraft:");
|
||||
let kind = kind.trim_start_matches("minecraft:");
|
||||
let overridden = match kind {
|
||||
"wither" | "wither_skeleton" => effect == "wither",
|
||||
"spider" | "cave_spider" | "nautilus" | "zombie_nautilus" => effect == "poison",
|
||||
"parched" => effect == "weakness",
|
||||
_ => false,
|
||||
};
|
||||
if overridden {
|
||||
return false;
|
||||
}
|
||||
let in_tag = |tag: &str| {
|
||||
tags.get(tag)
|
||||
.is_some_and(|t| t.contains(&format!("minecraft:{kind}")))
|
||||
};
|
||||
if in_tag("minecraft:immune_to_infested") {
|
||||
effect != "infested"
|
||||
} else if in_tag("minecraft:immune_to_oozing") {
|
||||
effect != "oozing"
|
||||
} else {
|
||||
!in_tag("minecraft:ignores_poison_and_regen")
|
||||
|| (effect != "regeneration" && effect != "poison")
|
||||
}
|
||||
}
|
||||
/// `ThrownSplashPotion.onHitAsPotion`: the squared distance from the potion's
|
||||
/// box at the hit point to an entity's box grown by the hit margin
|
||||
/// (`ProjectileUtil.computeMargin`), for entities that meet the potion's box
|
||||
/// grown by (4, 2, 4); none when it is 16 or more.
|
||||
fn splash_distance_sqr(at: Vec3, age: u32, bounds: Aabb) -> Option<f64> {
|
||||
let potion = Aabb::new(
|
||||
Vec3::new(at.x - 0.125, at.y, at.z - 0.125),
|
||||
Vec3::new(at.x + 0.125, at.y + 0.25, at.z + 0.125),
|
||||
);
|
||||
if !potion.inflate(Vec3::new(4.0, 2.0, 4.0)).intersects(bounds) {
|
||||
return None;
|
||||
}
|
||||
let margin = ((f64::from(age) - 2.0) / 20.0).clamp(0.0, 0.3);
|
||||
let d2 = potion.distance_squared_to_box(bounds.inflate(Vec3::new(margin, margin, margin)));
|
||||
(d2 < 16.0).then_some(d2)
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `LivingEntity.triggerOnDeathMobEffects` (`onMobRemoved` with the reason
|
||||
/// KILLED) for what Oozing, Weaving and Wind Charged do when their bearer
|
||||
/// dies. `bearer` is the dying mob's id, `player` whether it is a player,
|
||||
/// `random` the dying mob's own (`mob.getRandom()`; the world's for the
|
||||
/// player).
|
||||
pub(super) fn effects_on_death(
|
||||
&mut self,
|
||||
effects: &[&str],
|
||||
bounds: Aabb,
|
||||
player: bool,
|
||||
bearer: Option<u32>,
|
||||
mut random: Option<&mut LegacyRandom>,
|
||||
) {
|
||||
let feet = bounds.bottom_center();
|
||||
if effects.contains(&"oozing") {
|
||||
// OozingMobEffect.numberOfSlimesToSpawn: two, fewer when the
|
||||
// slimes close by already fill the cramming limit.
|
||||
let limit = self.ui.rules.int("max_entity_cramming");
|
||||
let mut count = 2;
|
||||
if limit >= 1 {
|
||||
let near = bounds.inflate(Vec3::new(2.0, 2.0, 2.0));
|
||||
let nearby = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.species() == horizon_client::mobs::Species::Slime
|
||||
&& Some(m.id) != bearer
|
||||
&& m.bounds().intersects(near)
|
||||
})
|
||||
.count()
|
||||
.min(limit as usize) as i32;
|
||||
count = (limit - nearby).clamp(0, count);
|
||||
}
|
||||
for _ in 0..count {
|
||||
// spawnSlimeOffspring: a size 2 slime half a block up.
|
||||
if let Some(id) = self.spawn_mob_for(
|
||||
"minecraft:slime",
|
||||
[feet.x, feet.y + 0.5, feet.z],
|
||||
false,
|
||||
true,
|
||||
) && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
horizon_client::mobs::cube::set_size(m, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
if effects.contains(&"weaving") && (player || self.ui.rules.bool("mob_griefing")) {
|
||||
self.spawn_cobwebs_around(
|
||||
[
|
||||
feet.x.floor() as i32,
|
||||
feet.y.floor() as i32,
|
||||
feet.z.floor() as i32,
|
||||
],
|
||||
random.as_deref_mut(),
|
||||
);
|
||||
}
|
||||
if effects.contains(&"wind_charged") {
|
||||
// A burst centred at half the height, radius 3 to 5.
|
||||
let center = [feet.x, feet.y + bounds.size().y / 2.0, feet.z];
|
||||
let r = random.unwrap_or(&mut self.world.random);
|
||||
let radius = 3.0 + r.next_float() * 2.0;
|
||||
let _ = self.wind_burst(center, radius, 1.0, false, false);
|
||||
}
|
||||
}
|
||||
/// `WeavingMobEffect.spawnCobwebsRandomlyAround`: two or three cobwebs in
|
||||
/// free spaces with a solid floor, picked among 15 random blocks within one
|
||||
/// block of `pos`.
|
||||
fn spawn_cobwebs_around(&mut self, pos: [i32; 3], mut random: Option<&mut LegacyRandom>) {
|
||||
let r = random.as_deref_mut().unwrap_or(&mut self.world.random);
|
||||
let wanted = 2 + r.next_int_bound(2).unwrap_or(0) as usize;
|
||||
let mut chosen: Vec<[i32; 3]> = Vec::new();
|
||||
for _ in 0..15 {
|
||||
let r = random.as_deref_mut().unwrap_or(&mut self.world.random);
|
||||
let at = [
|
||||
pos[0] - 1 + r.next_int_bound(3).unwrap_or(0),
|
||||
pos[1] - 1 + r.next_int_bound(3).unwrap_or(0),
|
||||
pos[2] - 1 + r.next_int_bound(3).unwrap_or(0),
|
||||
];
|
||||
let facts = self.res.facts();
|
||||
let here = facts.get(self.world.block(at[0], at[1], at[2]));
|
||||
let below = facts.get(self.world.block(at[0], at[1] - 1, at[2]));
|
||||
if !chosen.contains(&at)
|
||||
&& here.replaceable
|
||||
&& below.sturdy_full & (1 << Direction::Up as u8) != 0
|
||||
{
|
||||
chosen.push(at);
|
||||
if chosen.len() >= wanted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for at in chosen {
|
||||
if self.set_state_named(at, "minecraft:cobweb", &[]).is_ok() {
|
||||
self.level_event_at(3018, at, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `InfestedMobEffect.onMobHurt` for the player: from half its height up,
|
||||
/// along where it looks.
|
||||
pub(super) fn player_infested(&mut self) {
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let at = [
|
||||
p.position.x,
|
||||
p.position.y + f64::from(p.dimensions.height) / 2.0,
|
||||
p.position.z,
|
||||
];
|
||||
let look = super::view_vector(self.yaw, self.pitch);
|
||||
self.infested_hurt(at, look, None);
|
||||
}
|
||||
}
|
||||
/// A living mob's own random (`Entity.random`), else the world's.
|
||||
fn bearer_random(&mut self, bearer: Option<u32>) -> &mut LegacyRandom {
|
||||
match bearer.and_then(|id| self.mobs.iter().position(|m| m.id == id)) {
|
||||
Some(i) => &mut self.mobs[i].random,
|
||||
None => &mut self.world.random,
|
||||
}
|
||||
}
|
||||
/// `InfestedMobEffect.onMobHurt`: a 10% chance of one or two silverfish at
|
||||
/// `at` (half the bearer's height up), thrown along its look direction.
|
||||
/// The draws are the bearer's (`mob.getRandom()`): `bearer` is the mob,
|
||||
/// none for the player (the world's random).
|
||||
pub(super) fn infested_hurt(&mut self, at: [f64; 3], look: [f64; 3], bearer: Option<u32>) {
|
||||
if self.bearer_random(bearer).next_float() > 0.1 {
|
||||
return;
|
||||
}
|
||||
let count = 1 + self.bearer_random(bearer).next_int_bound(2).unwrap_or(0);
|
||||
for _ in 0..count {
|
||||
// Mth.randomBetween(-PI / 2, PI / 2), then the look direction
|
||||
// scaled by (0.3, 0.45, 0.3) and turned about the y axis.
|
||||
let half = std::f32::consts::FRAC_PI_2;
|
||||
let angle = self.bearer_random(bearer).next_float() * (2.0 * half) - half;
|
||||
let (sin, cos) = angle.sin_cos();
|
||||
let (x, z) = (look[0] * 0.3, look[2] * 0.3);
|
||||
let velocity = Vec3::new(
|
||||
f64::from(x as f32 * cos + z as f32 * sin),
|
||||
look[1] * 0.3 * 1.5,
|
||||
f64::from(-x as f32 * sin + z as f32 * cos),
|
||||
);
|
||||
if let Some(id) = self.spawn_mob_for("minecraft:silverfish", at, false, true)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
m.velocity = velocity;
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:entity.silverfish.hurt", Some(at), 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
/// A splash potion, lingering potion or experience bottle in flight (the
|
||||
/// player's throws and dispensers both end up here).
|
||||
pub(super) fn launch_thrown(
|
||||
@@ -313,7 +547,6 @@ impl Game<'_> {
|
||||
return true;
|
||||
}
|
||||
self.using = None;
|
||||
self.send_use_item_complete();
|
||||
self.post_entity_event("minecraft:drink", horizon_client::mobs::Who::Player);
|
||||
if ominous {
|
||||
self.drink_ominous_bottle(slot, stack);
|
||||
@@ -391,6 +624,7 @@ impl Game<'_> {
|
||||
self.tick_mob_effects();
|
||||
if running {
|
||||
self.tick_thrown_potions();
|
||||
self.tick_potion_clouds();
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.tickEffects` (server side) for `self.player`, the local
|
||||
@@ -401,13 +635,37 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
if player.health.dead_or_dying() {
|
||||
// LivingEntity.die → removeAllEffects.
|
||||
// LivingEntity.die → removeAllEffects. Vanilla runs the effects
|
||||
// that act on death (`onMobRemoved`) when the dead player is
|
||||
// removed, up to 20 ticks later; here it is as the effects go.
|
||||
let w = f64::from(player.dimensions.width) / 2.0;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(
|
||||
player.position.x - w,
|
||||
player.position.y,
|
||||
player.position.z - w,
|
||||
),
|
||||
Vec3::new(
|
||||
player.position.x + w,
|
||||
player.position.y + f64::from(player.dimensions.height),
|
||||
player.position.z + w,
|
||||
),
|
||||
);
|
||||
let ids: Vec<&'static str> = self.potions.effects.list.iter().map(|e| e.id).collect();
|
||||
self.potions.effects.clear();
|
||||
if !ids.is_empty() {
|
||||
self.effects_on_death(&ids, bounds, true, None, None);
|
||||
}
|
||||
}
|
||||
// The omens act from inside the effect tick (BadOmenMobEffect,
|
||||
// RaidOmenMobEffect).
|
||||
let absorption_empty = self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.health.absorption() <= 0.0);
|
||||
let mut effects = std::mem::take(&mut self.potions.effects);
|
||||
let (actions, raid_omen_added) = effects.tick_with(&mut |e| self.omen_tick(e));
|
||||
let (actions, raid_omen_added) =
|
||||
effects.tick_with(&mut |e| self.omen_tick(e), absorption_empty);
|
||||
self.potions.effects = effects;
|
||||
if raid_omen_added {
|
||||
self.raid_omen_added();
|
||||
@@ -548,29 +806,176 @@ impl Game<'_> {
|
||||
let state =
|
||||
self.world
|
||||
.block(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
|
||||
if self.res.facts().get(state).full_collision
|
||||
|| self.mobs.iter().any(|m| {
|
||||
m.alive()
|
||||
&& (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125
|
||||
&& (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125
|
||||
&& p.y >= m.position.y
|
||||
&& p.y <= m.position.y + m.kind.height
|
||||
})
|
||||
{
|
||||
hit = Some(p);
|
||||
let mob = self.mobs.iter().find(|m| {
|
||||
m.alive()
|
||||
&& (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125
|
||||
&& (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125
|
||||
&& p.y >= m.position.y
|
||||
&& p.y <= m.position.y + m.kind.height
|
||||
});
|
||||
if self.res.facts().get(state).full_collision || mob.is_some() {
|
||||
hit = Some((p, mob.map(|m| m.position)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(p) = hit {
|
||||
landed.push((t.stack.clone(), p, t.owner.clone()));
|
||||
if let Some((p, mob)) = hit {
|
||||
landed.push((t.stack.clone(), p, mob, t.age, t.owner.clone()));
|
||||
return false;
|
||||
}
|
||||
t.position = next;
|
||||
t.age < 1200 && t.position.y > -128.0
|
||||
});
|
||||
self.potions.thrown = thrown;
|
||||
for (stack, at, owner) in landed {
|
||||
self.splash(&stack, at, owner);
|
||||
for (stack, at, mob, age, owner) in landed {
|
||||
self.splash(&stack, at, mob, age, owner);
|
||||
}
|
||||
}
|
||||
/// `AreaEffectCloud.tick` for the lingering potions' clouds: the client
|
||||
/// tick's particles, then the server tick.
|
||||
fn tick_potion_clouds(&mut self) {
|
||||
if self.breath_clouds.iter().all(|c| c.potion.is_none()) {
|
||||
return;
|
||||
}
|
||||
let players: Vec<FightPlayer> = self
|
||||
.fight_players()
|
||||
.into_iter()
|
||||
.filter(|p| p.alive)
|
||||
.collect();
|
||||
let mut clouds = std::mem::take(&mut self.breath_clouds);
|
||||
clouds.retain_mut(|c| c.potion.is_none() || self.tick_potion_cloud(c, &players));
|
||||
self.breath_clouds.append(&mut clouds);
|
||||
}
|
||||
/// `AreaEffectCloud.serverTick` for a lingering potion's cloud: after its
|
||||
/// wait it shrinks, and every 5 ticks it affects the living entities in
|
||||
/// its box and radius that were not affected in the last 20 ticks, each
|
||||
/// taking some of its radius. False once it is gone.
|
||||
fn tick_potion_cloud(&mut self, c: &mut Breath, players: &[FightPlayer]) -> bool {
|
||||
c.age += 1;
|
||||
let Some(potion) = c.potion.as_mut() else {
|
||||
return true;
|
||||
};
|
||||
if c.age - LINGERING_WAIT >= c.duration {
|
||||
return false;
|
||||
}
|
||||
let waiting = c.age < LINGERING_WAIT;
|
||||
cloud_client_tick(
|
||||
&mut self.particles,
|
||||
c.at,
|
||||
c.radius,
|
||||
waiting,
|
||||
&potion.particle(),
|
||||
);
|
||||
if waiting {
|
||||
return true;
|
||||
}
|
||||
// setRadiusPerTick(-radius / duration).
|
||||
c.radius += -3.0 / 600.0;
|
||||
if c.radius < 0.5 {
|
||||
return false;
|
||||
}
|
||||
if c.age % 5 != 0 {
|
||||
return true;
|
||||
}
|
||||
potion.victims.retain(|(_, until)| c.age < *until);
|
||||
let r = f64::from(c.radius);
|
||||
let area = Aabb::new(
|
||||
Vec3::new(c.at[0] - r, c.at[1], c.at[2] - r),
|
||||
Vec3::new(c.at[0] + r, c.at[1] + 0.5, c.at[2] + r),
|
||||
);
|
||||
let mut inside = Vec::new();
|
||||
for p in players.iter().filter(|p| p.bounds.intersects(area)) {
|
||||
inside.push((Victim::Player(p.who), p.position.x, p.position.z));
|
||||
}
|
||||
let effects = potion.effects;
|
||||
let tags = &self.res.stations().enchantments.entity_tags;
|
||||
// Only mobs that at least one of the cloud's effects can affect are
|
||||
// taken (`allEffects.stream().anyMatch(entity::canBeAffected)`), so
|
||||
// the others neither become victims nor use up its radius.
|
||||
for m in self.mobs.iter().filter(|m| {
|
||||
m.alive()
|
||||
&& m.bounds().intersects(area)
|
||||
&& effects
|
||||
.iter()
|
||||
.any(|(id, ..)| mob_can_be_affected(tags, &m.kind.id, id))
|
||||
}) {
|
||||
inside.push((Victim::Mob(m.id), m.position.x, m.position.z));
|
||||
}
|
||||
for (victim, x, z) in inside {
|
||||
let (dx, dz) = (x - c.at[0], z - c.at[2]);
|
||||
if potion.victims.iter().any(|(v, _)| *v == victim)
|
||||
|| dx * dx + dz * dz > f64::from(c.radius * c.radius)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
potion.victims.push((victim, c.age + REAPPLICATION_DELAY));
|
||||
self.cloud_affect(victim, effects, c.at, &potion.source);
|
||||
// setRadiusOnUse(-0.5).
|
||||
c.radius -= 0.5;
|
||||
if c.radius < 0.5 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// What a cloud puts on one entity: instant effects at half strength
|
||||
/// (`applyInstantaneousEffect` with 0.5), the others for their duration
|
||||
/// at the lingering item's 0.25 scale (`PotionContents.forEachEffect`).
|
||||
fn cloud_affect(
|
||||
&mut self,
|
||||
victim: Victim,
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
at: [f64; 3],
|
||||
source: &Text,
|
||||
) {
|
||||
let scaled = |duration: i32| ((f64::from(duration) * 0.25) as i32).max(1);
|
||||
match victim {
|
||||
Victim::Player(who) => self.as_fight_player(who, |g| {
|
||||
for (id, duration, amp) in effects {
|
||||
let Some(e) = brewing::effect(id) else {
|
||||
continue;
|
||||
};
|
||||
if e.instant {
|
||||
if let Some(a) = horizon_client::effects::instant(id, *amp, 0.5) {
|
||||
g.apply_indirect_effect_action(a, source);
|
||||
}
|
||||
} else {
|
||||
g.add_player_effect(EffectInstance::new(e.id, scaled(*duration), *amp));
|
||||
}
|
||||
}
|
||||
}),
|
||||
Victim::Mob(mob) => {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == mob) else {
|
||||
return;
|
||||
};
|
||||
// Undead invert healing and harming.
|
||||
let tags = &self.res.stations().enchantments.entity_tags;
|
||||
let inverted = tags
|
||||
.get("minecraft:inverted_healing_and_harm")
|
||||
.is_some_and(|t| t.contains(&self.mobs[i].kind.id));
|
||||
for (id, duration, amp) in effects {
|
||||
let m = &mut self.mobs[i];
|
||||
match *id {
|
||||
"instant_damage" | "instant_health" => {
|
||||
let amount =
|
||||
|base: i32| (0.5 * f64::from(base << amp) + 0.5) as i32 as f32;
|
||||
if (*id == "instant_damage") != inverted {
|
||||
m.hurt(amount(6), Some(at), &mut self.world.random);
|
||||
} else {
|
||||
m.health = (m.health + amount(4)).min(m.kind.max_health);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// `LivingEntity.addEffect` refuses what the mob
|
||||
// cannot be affected by.
|
||||
if mob_can_be_affected(tags, &m.kind.id, id)
|
||||
&& let Some(name) = mob_effect_id(id)
|
||||
{
|
||||
m.add_effect(name, scaled(*duration), *amp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which
|
||||
@@ -672,44 +1077,46 @@ impl Game<'_> {
|
||||
self.submit_item_world(&state, pose, vertex_light(light), out);
|
||||
}
|
||||
}
|
||||
/// `HealOrHarmMobEffect.applyInstantaneousEffect` with a source (a
|
||||
/// potion or a cloud): indirectMagic, not plain magic.
|
||||
fn apply_indirect_effect_action(&mut self, a: EffectAction, source: &Text) {
|
||||
let a = match a {
|
||||
EffectAction::Hurt("minecraft:magic", amount) => {
|
||||
self.note_direct_entity("minecraft:indirect_magic", source.clone());
|
||||
EffectAction::Hurt("minecraft:indirect_magic", amount)
|
||||
}
|
||||
a => a,
|
||||
};
|
||||
self.apply_effect_action(a);
|
||||
}
|
||||
/// A splash potion's effects on the player in place, by their distance.
|
||||
fn splash_on_player(
|
||||
&mut self,
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
at: Vec3,
|
||||
scale: f64,
|
||||
age: u32,
|
||||
source: &Text,
|
||||
) {
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let center = Vec3::new(
|
||||
p.position.x,
|
||||
p.position.y + f64::from(p.dimensions.height) / 2.0,
|
||||
p.position.z,
|
||||
let w = f64::from(p.dimensions.width) / 2.0;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(p.position.x - w, p.position.y, p.position.z - w),
|
||||
Vec3::new(
|
||||
p.position.x + w,
|
||||
p.position.y + f64::from(p.dimensions.height),
|
||||
p.position.z + w,
|
||||
),
|
||||
);
|
||||
let d2 =
|
||||
(center.x - at.x).powi(2) + (center.y - at.y).powi(2) + (center.z - at.z).powi(2);
|
||||
if d2 < 16.0 && (center.y - at.y).abs() <= 2.0 + f64::from(p.dimensions.height) {
|
||||
if let Some(d2) = splash_distance_sqr(at, age, bounds) {
|
||||
let intensity = 1.0 - d2.sqrt() / 4.0;
|
||||
for (id, duration, amp) in effects {
|
||||
let instant = brewing::effect(id).is_some_and(|e| e.instant);
|
||||
if instant {
|
||||
if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) {
|
||||
// HealOrHarmMobEffect.applyInstantaneousEffect with
|
||||
// a source: indirectMagic, not plain magic.
|
||||
let a = match a {
|
||||
EffectAction::Hurt("minecraft:magic", amount) => {
|
||||
self.note_direct_entity(
|
||||
"minecraft:indirect_magic",
|
||||
source.clone(),
|
||||
);
|
||||
EffectAction::Hurt("minecraft:indirect_magic", amount)
|
||||
}
|
||||
a => a,
|
||||
};
|
||||
self.apply_effect_action(a);
|
||||
self.apply_indirect_effect_action(a, source);
|
||||
}
|
||||
} else {
|
||||
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
|
||||
let d = (intensity * f64::from(*duration) + 0.5) as i32;
|
||||
if d > 20
|
||||
&& let Some(e) = brewing::effect(id)
|
||||
{
|
||||
@@ -720,8 +1127,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash).
|
||||
fn splash(&mut self, stack: &ItemStack, at: Vec3, owner: Option<Text>) {
|
||||
/// `ThrownSplashPotion.onHitAsPotion`, or `ThrownLingeringPotion.onHitAsPotion`
|
||||
/// for a lingering potion; `mob` is where it hit a mob, `age` its age.
|
||||
fn splash(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
at: Vec3,
|
||||
mob: Option<Vec3>,
|
||||
age: u32,
|
||||
owner: Option<Text>,
|
||||
) {
|
||||
let block = [at.x, at.y, at.z].map(|v| v.floor() as i32);
|
||||
if &*stack.item == "minecraft:experience_bottle" {
|
||||
// ThrownExperienceBottle.onHit: the water-coloured splash (level
|
||||
@@ -745,31 +1160,48 @@ impl Game<'_> {
|
||||
let color = brewing::potion_color(stack.potion());
|
||||
self.level_event_at(if instant { 2007 } else { 2002 }, block, color as i32);
|
||||
self.level_event_at(if instant { 1054 } else { 1053 }, block, 0);
|
||||
let scale = if lingering { 0.25 } else { 1.0 };
|
||||
let Some(def) = potion else {
|
||||
return;
|
||||
};
|
||||
if lingering {
|
||||
// The cloud sits at the mob it hit (its feet), else the impact;
|
||||
// radius 3, wait 10, 600 ticks, shrinking to nothing, and a
|
||||
// potion with no effects leaves none.
|
||||
if !def.effects.is_empty() {
|
||||
let at = mob.unwrap_or(at);
|
||||
self.breath_clouds.push(Breath {
|
||||
id: 0,
|
||||
at: [at.x, at.y, at.z],
|
||||
radius: 3.0,
|
||||
age: 0,
|
||||
duration: 600,
|
||||
from_dragon: false,
|
||||
potion: Some(PotionCloud {
|
||||
effects: def.effects,
|
||||
color,
|
||||
victims: Vec::new(),
|
||||
source: owner.unwrap_or_else(|| {
|
||||
Text::translate("entity.minecraft.area_effect_cloud", &[])
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Every player in range (`ThrownSplashPotion.applySplash` over
|
||||
// `LivingEntity`s, the players among them).
|
||||
// Instant damage is indirectMagic(potion or cloud, owner), which the
|
||||
// death message names by the owner, else by the potion or cloud.
|
||||
let source = owner.unwrap_or_else(|| {
|
||||
let entity = if lingering {
|
||||
"entity.minecraft.area_effect_cloud"
|
||||
} else {
|
||||
"entity.minecraft.splash_potion"
|
||||
};
|
||||
Text::translate(entity, &[])
|
||||
});
|
||||
self.for_each_player(|g| g.splash_on_player(def.effects, at, scale, &source));
|
||||
// Instant damage is indirectMagic(potion, owner), which the death
|
||||
// message names by the owner, else by the potion.
|
||||
let source =
|
||||
owner.unwrap_or_else(|| Text::translate("entity.minecraft.splash_potion", &[]));
|
||||
self.for_each_player(|g| g.splash_on_player(def.effects, at, age, &source));
|
||||
// Mobs take the instant effects (undead invert healing and harming).
|
||||
let undead = &self.res.stations().enchantments.entity_tags;
|
||||
let mut hits = Vec::new();
|
||||
for (i, m) in self.mobs.iter().enumerate() {
|
||||
let d2 = (m.position.x - at.x).powi(2)
|
||||
+ (m.position.y + m.kind.height / 2.0 - at.y).powi(2)
|
||||
+ (m.position.z - at.z).powi(2);
|
||||
if m.alive() && d2 < 16.0 {
|
||||
if m.alive()
|
||||
&& let Some(d2) = splash_distance_sqr(at, age, m.bounds())
|
||||
{
|
||||
let inverted = undead
|
||||
.get("minecraft:inverted_healing_and_harm")
|
||||
.is_some_and(|t| t.contains(&m.kind.id));
|
||||
@@ -784,7 +1216,7 @@ impl Game<'_> {
|
||||
_ => {
|
||||
// `Potion.applySplash`: timed effects last in
|
||||
// proportion to how close the mob was.
|
||||
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
|
||||
let d = (intensity * f64::from(*duration) + 0.5) as i32;
|
||||
if d > 20
|
||||
&& let Some(name) = mob_effect_id(id)
|
||||
{
|
||||
@@ -796,12 +1228,12 @@ impl Game<'_> {
|
||||
let m = &mut self.mobs[i];
|
||||
if harm {
|
||||
m.hurt(
|
||||
(intensity * f64::from(6 << amp)) as i32 as f32,
|
||||
(intensity * f64::from(6 << amp) + 0.5) as i32 as f32,
|
||||
Some([at.x, at.y, at.z]),
|
||||
&mut self.world.random,
|
||||
);
|
||||
} else {
|
||||
m.health = (m.health + (intensity * f64::from(4 << amp)) as i32 as f32)
|
||||
m.health = (m.health + (intensity * f64::from(4 << amp) + 0.5) as i32 as f32)
|
||||
.min(m.kind.max_health);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -609,7 +609,6 @@ impl Game<'_> {
|
||||
if inside(cursor, bx, by, 20.0, 18.0) {
|
||||
let open = !self.book_open(book_type);
|
||||
self.level.recipe_book.open[book_type.index()] = open;
|
||||
self.send_book_settings(book_type);
|
||||
if !open {
|
||||
self.recipe_ui.overlay = None;
|
||||
}
|
||||
@@ -695,7 +694,6 @@ impl Game<'_> {
|
||||
if inside(cursor, xo + 110.0, yo + 12.0, 26.0, 16.0) {
|
||||
let filtering = !self.book_filtering(book_type);
|
||||
self.level.recipe_book.filtering[book_type.index()] = filtering;
|
||||
self.send_book_settings(book_type);
|
||||
self.recipe_ui.page = 0;
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
@@ -716,22 +714,6 @@ impl Game<'_> {
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `RecipeBookComponent.sendUpdateSettings`: the server owns the saved book.
|
||||
fn send_book_settings(&self, book_type: BookType) {
|
||||
self.send(
|
||||
horizon_protocol::packets::play::Serverbound::RecipeBookChangeSettings(
|
||||
horizon_protocol::packets::recipes::RecipeBookChangeSettings {
|
||||
book_type: book_type.index() as i32,
|
||||
open: self.book_open(book_type),
|
||||
filtering: self.book_filtering(book_type),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
pub(super) fn recipe_book_flags(&self) -> Option<(bool, bool)> {
|
||||
let book = self.active_book()?;
|
||||
Some((self.book_open(book), self.book_filtering(book)))
|
||||
}
|
||||
/// How many displays the client's book holds.
|
||||
pub(super) fn recipe_book_known(&self) -> usize {
|
||||
self.recipe_ui.client.displays().len()
|
||||
|
||||
@@ -117,17 +117,13 @@ pub(super) struct Remote {
|
||||
joined: bool,
|
||||
status: String,
|
||||
pub(super) entity_id: i32,
|
||||
/// The item the player's use began with (`LivingEntity.useItem`).
|
||||
pub(super) use_item: Option<Arc<str>>,
|
||||
dimension: DimensionType,
|
||||
pub(super) dimension: DimensionType,
|
||||
/// What chunk packets decode with (the dimension's height and sky, the
|
||||
/// biome registry); replaced whenever one of them changes.
|
||||
pub(super) chunk_format: Arc<ChunkFormat>,
|
||||
/// Decodes chunk packets as they arrive, off the frame.
|
||||
decoder: ChunkDecoder,
|
||||
/// Options.serverRenderDistance: the server's view distance from the
|
||||
/// login or the last chunk-cache-radius packet.
|
||||
pub(super) view_distance: i32,
|
||||
view_distance: i32,
|
||||
sequence: i32,
|
||||
position_sender: horizon_client::player_sync::PositionSender,
|
||||
last_input: u8,
|
||||
@@ -160,6 +156,9 @@ pub(super) struct Remote {
|
||||
stepped: HashMap<i32, SteppedInterpolation>,
|
||||
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
|
||||
head_lerps: HashMap<i32, (f32, u32)>,
|
||||
/// The happy ghast the player just got off, until the server's teleport
|
||||
/// puts the player on top of it (`place_ghast_under`).
|
||||
pub(super) ghast_dismount: Option<u32>,
|
||||
/// Item entities waiting for their item data: where they are and their
|
||||
/// motion.
|
||||
pending_items: HashMap<i32, ([f64; 3], Vec3)>,
|
||||
@@ -193,12 +192,10 @@ pub(super) struct Remote {
|
||||
destroying: Option<([i32; 3], u8, f32)>,
|
||||
destroy_delay: u32,
|
||||
input: horizon_client::interaction_input::InteractionInput,
|
||||
/// Keys whose presses send a packet (swap hands, drop, a mount's
|
||||
/// inventory): like the click queue, they wait for the tick's
|
||||
/// `handleKeybinds` phase rather than going out between window events.
|
||||
pub(super) pending_keys: Vec<KeyCode>,
|
||||
stop_mining: bool,
|
||||
placing: Option<([i32; 3], u64)>,
|
||||
/// `CommonPlayerSpawnInfo.isFlat`: the level is a superflat one.
|
||||
pub(super) flat: bool,
|
||||
death_message: Option<String>,
|
||||
player_type: Option<Arc<MobType>>,
|
||||
/// Inventory as last synchronised, for creative-mode slot updates.
|
||||
@@ -220,9 +217,6 @@ pub(super) struct Remote {
|
||||
/// `ClientboundTransferPacket`'s target: when the connection ends the
|
||||
/// game connects here, the stored cookies carried over (`TransferState`).
|
||||
transfer: Option<String>,
|
||||
/// `ClientCommonPacketListenerImpl.serverLinks`: the links "Server
|
||||
/// Links..." in the pause menu lists (only `http`/`https` URIs kept).
|
||||
pub(super) server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
|
||||
}
|
||||
|
||||
/// The time a frame spends on chunk packets before it leaves the rest of
|
||||
@@ -405,9 +399,6 @@ impl Game<'_> {
|
||||
self.carried = None;
|
||||
self.using = None;
|
||||
self.clicks.miss_time = 0;
|
||||
// A new LocalPlayer has no effects: the server sends what it has
|
||||
// (ClientboundUpdateMobEffectPacket on placement).
|
||||
self.potions = Default::default();
|
||||
self.set_mode_id(0);
|
||||
self.experience = Experience {
|
||||
level: 0,
|
||||
@@ -442,7 +433,6 @@ impl Game<'_> {
|
||||
joined: false,
|
||||
status: "Connecting to the server...".into(),
|
||||
entity_id: 0,
|
||||
use_item: None,
|
||||
dimension: DimensionType::vanilla("minecraft:overworld"),
|
||||
chunk_format: Arc::new(ChunkFormat::new(
|
||||
&DimensionType::vanilla("minecraft:overworld"),
|
||||
@@ -469,6 +459,7 @@ impl Game<'_> {
|
||||
lerps: HashMap::new(),
|
||||
stepped: HashMap::new(),
|
||||
head_lerps: HashMap::new(),
|
||||
ghast_dismount: None,
|
||||
pending_items: HashMap::new(),
|
||||
health: (20.0, 20, 5.0),
|
||||
flash_on_set_health: false,
|
||||
@@ -488,9 +479,9 @@ impl Game<'_> {
|
||||
destroying: None,
|
||||
destroy_delay: 0,
|
||||
input: Default::default(),
|
||||
pending_keys: Vec::new(),
|
||||
stop_mining: false,
|
||||
placing: None,
|
||||
flat: false,
|
||||
death_message: None,
|
||||
player_type,
|
||||
synced: vec![None; 36],
|
||||
@@ -502,7 +493,6 @@ impl Game<'_> {
|
||||
chunk_center: (0, 0),
|
||||
backlog: VecDeque::new(),
|
||||
backlog_since: None,
|
||||
server_links: Vec::new(),
|
||||
transfer: None,
|
||||
});
|
||||
// The level's TickRateManager follows the server's ticking packets.
|
||||
@@ -879,11 +869,6 @@ impl Game<'_> {
|
||||
}
|
||||
Event::PackPush(p) => self.handle_pack_push(p),
|
||||
Event::PackPop(id) => self.handle_pack_pop(id),
|
||||
Event::ServerLinks(links) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.server_links = super::server_links::trusted(links);
|
||||
}
|
||||
}
|
||||
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
|
||||
Event::ClearDialog => self.clear_dialog(),
|
||||
Event::DecodeError(e) => {
|
||||
@@ -1011,6 +996,7 @@ impl Game<'_> {
|
||||
r.chunk_format = Arc::new(r.chunk_format.with_dimension(&dimension));
|
||||
r.dimension = dimension;
|
||||
r.game_mode = spawn.game_type;
|
||||
r.flat = spawn.flat;
|
||||
r.loaded_sent = false;
|
||||
// A replacement LocalPlayer has a fresh sendPosition baseline.
|
||||
r.position_sender = Default::default();
|
||||
@@ -1430,6 +1416,13 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
// So does a firework rocket: its own tick flies it on.
|
||||
if path.is_some()
|
||||
&& let Some(f) = self.rockets.iter_mut().find(|f| f.id == id as u32)
|
||||
{
|
||||
f.position = Vec3::new(position[0], position[1], position[2]);
|
||||
return;
|
||||
}
|
||||
// A dragon fireball keeps Entity's NO_OP InterpolationHandler too:
|
||||
// the server's position replaces its own at once.
|
||||
if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
|
||||
@@ -1643,11 +1636,7 @@ impl Game<'_> {
|
||||
}
|
||||
C::Login(p) => {
|
||||
self.level.hardcore = p.hardcore;
|
||||
// handleLogin makes a new MultiPlayerGameMode (carried index 0) and a
|
||||
// new LocalPlayer (slot 0).
|
||||
self.selected = 0;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.carried = Some(0);
|
||||
r.online_mode = p.online_mode;
|
||||
r.entity_id = p.player_id;
|
||||
r.view_distance = p.chunk_radius;
|
||||
@@ -1665,10 +1654,8 @@ impl Game<'_> {
|
||||
self.send(S::ClientInformation(info));
|
||||
}
|
||||
C::Respawn(p) => {
|
||||
// A new player, without effects (the server sends them
|
||||
// again) and with no item in use.
|
||||
// A new player, without effects: the server sends them again.
|
||||
self.potions.effects.clear();
|
||||
self.using = None;
|
||||
self.enter_remote_dimension(&p.spawn, true);
|
||||
self.screen = Screen::Playing;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
@@ -1896,7 +1883,8 @@ impl Game<'_> {
|
||||
}
|
||||
C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers),
|
||||
// ClientPacketListener.handleEntityLinkPacket: who holds a mob's
|
||||
// lead (this player, another player or a mob), for the rope.
|
||||
// lead (this player, another player, a mob or a fence knot), for
|
||||
// the rope.
|
||||
C::SetEntityLink(p) => {
|
||||
let me = self.remote.as_ref().map(|r| r.entity_id);
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) {
|
||||
@@ -1904,7 +1892,12 @@ impl Game<'_> {
|
||||
m.extra.leash = match p.dest {
|
||||
0 => None,
|
||||
d if Some(d) == me => Some(Holder::Player),
|
||||
d => Some(Holder::Mob(d as u32)),
|
||||
d => match self.remote_knots.get(&(d as u32)) {
|
||||
Some(&block) => Some(Holder::Knot(block)),
|
||||
// Not a known knot yet: kept by id until the knot
|
||||
// arrives (game/remote_objects.rs).
|
||||
None => Some(Holder::Mob(d as u32)),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2137,8 +2130,9 @@ impl Game<'_> {
|
||||
}
|
||||
C::HurtAnimation(p) => {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
// Player.animateHurt: the tilt turns around the yaw.
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.health.animate_hurt();
|
||||
pl.health.animate_hurt_dir(p.yaw);
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.hurt_time = 10;
|
||||
@@ -2185,6 +2179,37 @@ impl Game<'_> {
|
||||
if p.event == super::totem::TOTEM_EVENT {
|
||||
self.remote_totem(p.id);
|
||||
}
|
||||
// Mob.handleEntityEvent 20: the spawn poof.
|
||||
if p.event == 20 && self.mobs.iter().any(|m| m.id == p.id as u32) {
|
||||
self.poof_particles(p.id as u32);
|
||||
}
|
||||
// Villager 12, 13 and 14, TamableAnimal and AbstractHorse 6 and
|
||||
// 7, Animal and Allay 18: the particles about the mob.
|
||||
if matches!(p.event, 6 | 7 | 12 | 13 | 14 | 18) {
|
||||
self.entity_event_particles(p.id as u32, p.event);
|
||||
}
|
||||
// Snowball and ThrownEgg.handleEntityEvent 3: the crumbs where
|
||||
// the projectile is (the server removes it right after).
|
||||
if p.event == 3 {
|
||||
let crumbs = self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.find(|pr| pr.id == p.id as u32)
|
||||
.and_then(|pr| {
|
||||
let item = match pr.kind {
|
||||
horizon_client::projectiles::ProjectileKind::Snowball => {
|
||||
"minecraft:snowball"
|
||||
}
|
||||
horizon_client::projectiles::ProjectileKind::Egg => "minecraft:egg",
|
||||
_ => return None,
|
||||
};
|
||||
Some(([pr.position.x, pr.position.y, pr.position.z], item))
|
||||
});
|
||||
if let Some((at, item)) = crumbs {
|
||||
self.item_burst(at, item);
|
||||
}
|
||||
}
|
||||
// Entity event 53: the honey slide particles of a player
|
||||
// sliding down a honey block (HoneyBlock.showSlideParticles).
|
||||
if p.event == 53 {
|
||||
@@ -2202,11 +2227,6 @@ impl Game<'_> {
|
||||
self.honey_slide_particles(at);
|
||||
}
|
||||
}
|
||||
if p.event == super::consume::USE_ITEM_COMPLETE_EVENT
|
||||
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
{
|
||||
self.remote_complete_using();
|
||||
}
|
||||
}
|
||||
C::Animate(p) => self.remote_animate(p.id, p.action),
|
||||
// ClientPacketListener.handleSwingAnimation: the entity swings
|
||||
@@ -2369,11 +2389,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.apply_remote_abilities();
|
||||
}
|
||||
// ClientPacketListener.handleSetHeldSlot: a hotbar slot only, and the
|
||||
// carried index stays, so the next tick answers with SetCarriedItem.
|
||||
C::SetHeldSlot(p) => {
|
||||
if (0..9).contains(&p.slot) {
|
||||
self.selected = p.slot as usize;
|
||||
self.selected = p.slot.clamp(0, 8) as usize;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.carried = Some(self.selected);
|
||||
}
|
||||
}
|
||||
C::ContainerSetContent(p) => {
|
||||
@@ -3091,6 +3110,8 @@ impl Game<'_> {
|
||||
p.velocity = v;
|
||||
} else if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
|
||||
b.velocity = v;
|
||||
} else if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id as u32) {
|
||||
r.velocity = v;
|
||||
} else if let Some(p) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
@@ -3113,6 +3134,37 @@ impl Game<'_> {
|
||||
p.health.food.saturation = saturation;
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.dismountTo` lands a rider at the ghast's own (x, maxY, z)
|
||||
/// (`HappyGhast.getDismountLocationForPassenger`). A vanilla client
|
||||
/// simulates the ghast it rides, so its copy is where the server has it;
|
||||
/// this window's copy trails the server's by the stepped interpolation and
|
||||
/// would rise into the rider's feet, which lets the rider fall through.
|
||||
/// Put the copy under the landing spot.
|
||||
fn place_ghast_under(&mut self, id: u32, spot: Vec3) {
|
||||
let Some(m) = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id && m.species() == horizon_client::mobs::Species::HappyGhast)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let at = Vec3::new(spot.x, spot.y - f64::from(m.height()), spot.z);
|
||||
// Only a teleport that is that dismount: right above the ghast.
|
||||
if (at.x - m.position.x).powi(2) + (at.z - m.position.z).powi(2) > 25.0
|
||||
|| (at.y - m.position.y).abs() > 5.0
|
||||
{
|
||||
return;
|
||||
}
|
||||
m.position = at;
|
||||
m.previous = at;
|
||||
if let Some(handler) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.and_then(|r| r.stepped.get_mut(&(id as i32)))
|
||||
{
|
||||
handler.cancel();
|
||||
}
|
||||
}
|
||||
/// ClientPacketListener.handleMovePlayer.
|
||||
fn remote_teleport(&mut self, p: &play::PlayerPosition) -> Result<()> {
|
||||
use play::relative as rel;
|
||||
@@ -3172,6 +3224,9 @@ impl Game<'_> {
|
||||
self.previous_position = position;
|
||||
self.yaw = yaw;
|
||||
self.pitch = pitch;
|
||||
if let Some(id) = self.remote.as_mut().and_then(|r| r.ghast_dismount.take()) {
|
||||
self.place_ghast_under(id, position);
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
// handleMovePlayer does not change LocalPlayer.sendPosition's
|
||||
// baseline: the next tick must still report the corrected state.
|
||||
@@ -3205,6 +3260,12 @@ impl Game<'_> {
|
||||
fn tick_remote_level(&mut self) -> Result<()> {
|
||||
self.ticks += 1;
|
||||
self.track_experience_display();
|
||||
// Player.tick's ItemCooldowns.tick: this path replaces `tick_world`'s
|
||||
// for a server's client (multiplayer and the default single player),
|
||||
// so the entries advance here or the slot stays shaded until the
|
||||
// server says the entry ended. `onCooldownEnded` is the plain
|
||||
// ItemCooldowns' no-op on a client.
|
||||
self.cooldowns.tick();
|
||||
// Leaving a slot clears its bundle's selection here and on the server.
|
||||
self.tick_bundle_hover();
|
||||
// Options.broadcastOptions: a changed render distance or Chat option
|
||||
@@ -3240,14 +3301,6 @@ impl Game<'_> {
|
||||
// Window callbacks only queue clicks, so an interaction cannot leak
|
||||
// between CLIENT_TICK_END and a later pong.
|
||||
self.remote_handle_input();
|
||||
// Player.tick's ItemCooldowns.tick, in ClientLevel.tickEntities after
|
||||
// handleKeybinds: this path replaces `tick_world`'s for a server's
|
||||
// client (multiplayer and the default single player), so the entries
|
||||
// advance here or the slot stays shaded until the server says the
|
||||
// entry ended. `onCooldownEnded` is the plain ItemCooldowns' no-op on
|
||||
// a client. Ticking it after the keybinds keeps a held use from
|
||||
// trying again a tick before the server's entry ends.
|
||||
self.cooldowns.tick();
|
||||
// ClientLevel.tick: the border's size change.
|
||||
self.border.tick();
|
||||
self.player_border();
|
||||
@@ -3388,12 +3441,12 @@ impl Game<'_> {
|
||||
let feet = [player.position.x, player.position.y, player.position.z];
|
||||
for event in events {
|
||||
match event {
|
||||
horizon_client::motion::MotionEvent::Emission {
|
||||
state, in_water, ..
|
||||
} if !crouching => {
|
||||
horizon_client::motion::MotionEvent::Emission { state, muffled, .. }
|
||||
if !crouching =>
|
||||
{
|
||||
let sounds = self.res.facts().get(state).sounds.clone();
|
||||
// Player.playStepSound: muffled under the swim sound in water.
|
||||
let (volume, pitch) = if in_water {
|
||||
let (volume, pitch) = if muffled {
|
||||
(sounds.volume * 0.05, sounds.pitch * 0.8)
|
||||
} else {
|
||||
(sounds.volume * 0.15, sounds.pitch)
|
||||
@@ -4275,6 +4328,16 @@ impl Game<'_> {
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
|
||||
return (false, DryUse::default());
|
||||
}
|
||||
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
|
||||
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
|
||||
// no UseItem follows (the server does the harvest, see `use_beehive`).
|
||||
if held.is_some_and(|s| matches!(&*s.item, "minecraft:shears" | "minecraft:glass_bottle"))
|
||||
&& self
|
||||
.hive_state(hit.pos)
|
||||
.is_some_and(|(honey, _)| honey == Some(5))
|
||||
{
|
||||
return (true, DryUse::default());
|
||||
}
|
||||
use horizon_core::geometry::Direction;
|
||||
let face = match hit.face {
|
||||
[0, 1, 0] => Direction::Up,
|
||||
@@ -4384,27 +4447,13 @@ impl Game<'_> {
|
||||
let Some(info) = self.res.items().get(&held.item).cloned() else {
|
||||
return false;
|
||||
};
|
||||
// MultiPlayerGameMode.useItem's prediction: an item cooling down
|
||||
// passes (its packet still goes).
|
||||
let group = horizon_client::cooldowns::group(&held, info.use_cooldown.as_ref());
|
||||
if self.cooldowns.is_on_cooldown(&group) {
|
||||
return false;
|
||||
}
|
||||
// Food starts being eaten (Consumable.startConsuming). A food that
|
||||
// can't be eaten (full and not always-edible) fails the use
|
||||
// (InteractionResult.FAIL): startUseItem tries the other hand, and
|
||||
// no cooldown or use animation starts.
|
||||
let eating = self.with_hand(hand, |g| g.start_eating(&info));
|
||||
if info.food.is_some() && !eating {
|
||||
return false;
|
||||
}
|
||||
// It runs the use here too: ItemStack.use starts the `use_cooldown`
|
||||
// of an item with no use time, and the server's
|
||||
// ClientboundCooldownPacket confirms it; one held in use starts it at
|
||||
// finishUsingItem (remote_complete_using).
|
||||
if !info.consumable.as_ref().is_some_and(|c| c.ticks() > 0) {
|
||||
self.apply_use_cooldown(&held);
|
||||
}
|
||||
// MultiPlayerGameMode.useItem runs the use here too (inside
|
||||
// startPrediction): the `use_cooldown` component starts its entry, and
|
||||
// the server's ClientboundCooldownPacket confirms it.
|
||||
self.apply_use_cooldown(&held);
|
||||
// Food starts being eaten (Consumable.startConsuming), a book and
|
||||
// quill opens.
|
||||
self.with_hand(hand, |g| g.start_eating(&info));
|
||||
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
|
||||
return true;
|
||||
}
|
||||
@@ -4476,25 +4525,14 @@ impl Game<'_> {
|
||||
self.remote_use_item(Hand::Off);
|
||||
}
|
||||
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
|
||||
/// use (`held_until_released`), and a consumable's eating animation and
|
||||
/// its crumbs until the server's USE_ITEM_COMPLETE. The server sends the
|
||||
/// stack, its hunger and the sounds.
|
||||
/// use (`held_until_released`), and a
|
||||
/// consumable's eating animation and its crumbs. The server eats it and
|
||||
/// sends the stack, the hunger and the sounds.
|
||||
fn remote_tick_using(&mut self) {
|
||||
let Some((slot, ticks)) = self.using else {
|
||||
return;
|
||||
};
|
||||
let stack = self.stack_in(slot).cloned();
|
||||
// The use stops when the hand no longer holds the item it began with
|
||||
// (ItemStack.isSameItem).
|
||||
let item = stack.as_ref().map(|s| Arc::clone(&s.item));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
if ticks == 0 {
|
||||
r.use_item = item;
|
||||
} else if r.use_item != item {
|
||||
self.using = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
|
||||
// different stack in the hand. The off hand keeps its item.
|
||||
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
|
||||
@@ -4519,51 +4557,20 @@ impl Game<'_> {
|
||||
self.using = None;
|
||||
return;
|
||||
}
|
||||
// Consumable.startConsuming eats one with no use time at once, without
|
||||
// a use (nor a USE_ITEM_COMPLETE).
|
||||
if consumable.ticks() <= 0 {
|
||||
self.using = None;
|
||||
return;
|
||||
}
|
||||
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item. The
|
||||
// countdown goes on past zero (LivingEntity.updateUsingItem completes
|
||||
// only on the server) until the server's USE_ITEM_COMPLETE.
|
||||
let remaining = consumable.ticks() - ticks as i32;
|
||||
if consumable.particles && consumable.should_emit(remaining) {
|
||||
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item.
|
||||
let duration = consumable.ticks().max(0) as u32;
|
||||
let remaining = duration - ticks.min(duration);
|
||||
if consumable.particles && consumable.should_emit(remaining as i32) {
|
||||
self.eat_particles(&stack, 5);
|
||||
}
|
||||
self.using = Some((slot, ticks + 1));
|
||||
}
|
||||
/// `Player.handleEntityEvent(9)`: `completeUsingItem` on the client. A consumable's last crumbs, and FoodProperties.onConsume,
|
||||
/// which runs on the client too (Consumable.onConsume calls its
|
||||
/// ConsumableListeners on both sides): the client's FoodData eats at once,
|
||||
/// so a held right click finds `Player.canEat` false instead of starting
|
||||
/// another bite before the server's ClientboundSetHealth arrives.
|
||||
fn remote_complete_using(&mut self) {
|
||||
let Some((slot, _)) = self.using.take() else {
|
||||
return;
|
||||
};
|
||||
let Some(stack) = self.stack_in(slot).cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(info) = self.res.items().get(&stack.item).cloned() else {
|
||||
return;
|
||||
};
|
||||
if info.consumable.as_ref().is_some_and(|c| c.particles) {
|
||||
self.eat_particles(&stack, 16);
|
||||
if ticks + 1 >= duration {
|
||||
self.using = None;
|
||||
if consumable.particles {
|
||||
self.eat_particles(&stack, 16);
|
||||
}
|
||||
} else {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
}
|
||||
if let Some((nutrition, saturation)) = info.food
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
// FoodData.eat(nutrition, saturation), as finish_consuming does;
|
||||
// the next ClientboundSetHealth replaces it.
|
||||
let (_, food, sat) = &mut r.health;
|
||||
*food = (*food + nutrition as i32).clamp(0, 20);
|
||||
*sat = (*sat + saturation).clamp(0.0, *food as f32);
|
||||
self.apply_remote_health();
|
||||
}
|
||||
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
|
||||
self.apply_use_cooldown(&stack);
|
||||
}
|
||||
/// MouseHandler updates KeyMapping; handleKeybinds sends packets on a tick.
|
||||
pub(super) fn remote_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
|
||||
@@ -4628,31 +4635,6 @@ impl Game<'_> {
|
||||
}
|
||||
let (attack, use_item) = (self.left, self.right);
|
||||
input.tick(&mut RemoteInputWorld(self), attack, use_item);
|
||||
// handleKeybinds' keybind polls: the queued presses act now, in tick
|
||||
// order, so no packet goes out between CLIENT_TICK_END and a pong.
|
||||
let pending = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.map(|r| std::mem::take(&mut r.pending_keys))
|
||||
.unwrap_or_default();
|
||||
for key in pending {
|
||||
match key {
|
||||
KeyCode::KeyF if !self.spectator() => self.remote_swap_hands(),
|
||||
KeyCode::KeyQ if !self.spectator() => {
|
||||
let all = self.keys.contains(&KeyCode::ControlLeft);
|
||||
self.remote_drop(all);
|
||||
}
|
||||
KeyCode::KeyE if self.riding_mount_with_menu() => {
|
||||
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
|
||||
self.send(S::PlayerCommand(play::PlayerCommand {
|
||||
id,
|
||||
action: 5,
|
||||
data: 0,
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if self.clicks.miss_time > 0 {
|
||||
self.clicks.miss_time -= 1;
|
||||
}
|
||||
@@ -4764,8 +4746,22 @@ impl Game<'_> {
|
||||
if self.spectator() {
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.pending_keys.push(key);
|
||||
let all = self.keys.contains(&KeyCode::ControlLeft);
|
||||
// MultiPlayerGameMode.dropItem: predict removal before sending
|
||||
// the action, then swing only if a nonempty stack was removed.
|
||||
let dropped = horizon_client::items::take_held_drop(
|
||||
&mut self.inventory[self.selected],
|
||||
all,
|
||||
false,
|
||||
);
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: if all { 4 } else { 5 },
|
||||
pos: [0; 3],
|
||||
direction: 0,
|
||||
sequence: 0,
|
||||
}));
|
||||
if dropped.is_some() {
|
||||
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -4773,9 +4769,12 @@ impl Game<'_> {
|
||||
// horse or llama, the server opens its inventory
|
||||
// (ServerboundPlayerCommandPacket OPEN_INVENTORY).
|
||||
KeyCode::KeyE if self.riding_mount_with_menu() => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.pending_keys.push(key);
|
||||
}
|
||||
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
|
||||
self.send(S::PlayerCommand(play::PlayerCommand {
|
||||
id,
|
||||
action: 5,
|
||||
data: 0,
|
||||
}));
|
||||
Ok(true)
|
||||
}
|
||||
KeyCode::KeyE => {
|
||||
@@ -4822,21 +4821,6 @@ impl Game<'_> {
|
||||
}));
|
||||
}
|
||||
}
|
||||
/// MultiPlayerGameMode.dropItem (key.drop): predict the removal, then send
|
||||
/// the action; swing only if a nonempty stack was removed.
|
||||
fn remote_drop(&mut self, all: bool) {
|
||||
let dropped =
|
||||
horizon_client::items::take_held_drop(&mut self.inventory[self.selected], all, false);
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: if all { 4 } else { 5 },
|
||||
pos: [0; 3],
|
||||
direction: 0,
|
||||
sequence: 0,
|
||||
}));
|
||||
if dropped.is_some() {
|
||||
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
|
||||
}
|
||||
}
|
||||
/// Minecraft.handleKeybinds, key.swapOffhand: the server swaps the hands
|
||||
/// (ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND at BlockPos.ZERO, facing down) and
|
||||
/// sends both slots back; nothing is predicted.
|
||||
|
||||
@@ -97,6 +97,10 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
|
||||
// IS_CELEBRATING takes 16).
|
||||
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
|
||||
// HappyGhast.STAYS_STILL (after AgeableMob's two and IS_LEASH_HOLDER):
|
||||
// `isOnStillTimeout` on a client, which keeps the ghast solid to
|
||||
// the player (`canBeCollidedWith`).
|
||||
(Species::HappyGhast, 19, Value::Bool(still)) => m.extra.counter = i32::from(*still),
|
||||
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
|
||||
(Species::Evoker | Species::Illusioner, 17, Value::Byte(spell)) => {
|
||||
m.extra.variant2 = (*spell).max(0) as u8;
|
||||
|
||||
@@ -66,8 +66,11 @@ impl Game<'_> {
|
||||
| Species::Salmon
|
||||
| Species::TropicalFish
|
||||
| Species::Pufferfish
|
||||
| Species::Axolotl
|
||||
| Species::Tadpole => item == "minecraft:water_bucket",
|
||||
| Species::Axolotl => item == "minecraft:water_bucket",
|
||||
// Tadpole.mobInteract: frog food, else the bucket.
|
||||
Species::Tadpole => {
|
||||
item == "minecraft:water_bucket" || m.kind.food.iter().any(|f| f.as_str() == item)
|
||||
}
|
||||
Species::Armadillo => item == "minecraft:brush",
|
||||
// ZombieVillager.mobInteract: CONSUME for a golden apple.
|
||||
Species::ZombieVillager => item == "minecraft:golden_apple",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Boats, minecarts, armor stands, item frames and paintings on a server's
|
||||
//! Boats, minecarts, armor stands, item frames, paintings and fence knots on a server's
|
||||
//! client (`ClientPacketListener.handleAddEntity`, `handleSetEntityData`,
|
||||
//! `handleSetEquipment`): the server simulates them, the client places them
|
||||
//! where it says and interpolates their moves like a mob's.
|
||||
use super::Game;
|
||||
use horizon_client::entities::{ArmorStand, Cushion, ItemFrame, Painting};
|
||||
use horizon_client::mobs::leash::Holder;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use std::sync::Arc;
|
||||
@@ -24,7 +25,17 @@ impl Game<'_> {
|
||||
let at = Vec3::new(position[0], position[1], position[2]);
|
||||
let block = position.map(|v| v.floor() as i32);
|
||||
self.remove_remote_object(id);
|
||||
if kind == "minecraft:text_display" {
|
||||
if kind == "minecraft:leash_knot" {
|
||||
// A lead that came before its knot waits as a mob holder with the
|
||||
// knot's id (handleEntityLinkPacket's delayed holder); now it can
|
||||
// be tied to the fence.
|
||||
self.remote_knots.insert(id, block);
|
||||
for m in &mut self.mobs {
|
||||
if m.extra.leash == Some(Holder::Mob(id)) {
|
||||
m.extra.leash = Some(Holder::Knot(block));
|
||||
}
|
||||
}
|
||||
} else if kind == "minecraft:text_display" {
|
||||
self.text_displays.insert(
|
||||
id as i32,
|
||||
super::text_displays::RemoteTextDisplay::new(position, yaw),
|
||||
@@ -304,6 +315,7 @@ impl Game<'_> {
|
||||
/// Every object a server sent (a new connection, level or dimension).
|
||||
pub(super) fn clear_remote_objects(&mut self) {
|
||||
self.text_displays.clear();
|
||||
self.remote_knots.clear();
|
||||
self.carts.clear();
|
||||
self.boats.clear();
|
||||
self.armor_stands.clear();
|
||||
@@ -314,6 +326,7 @@ impl Game<'_> {
|
||||
|
||||
pub(super) fn remove_remote_object(&mut self, id: u32) {
|
||||
self.text_displays.remove(&(id as i32));
|
||||
self.remote_knots.remove(&id);
|
||||
self.carts.retain(|c| c.id != id);
|
||||
self.boats.retain(|b| b.id != id);
|
||||
self.armor_stands.retain(|a| a.id != id);
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
//! and `handleSetEntityData`): the server simulates them, the client draws
|
||||
//! them where it says and interpolates their moves.
|
||||
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld};
|
||||
use horizon_client::{
|
||||
collisions::SceneCollisions,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
@@ -23,6 +26,58 @@ fn default_cloud_particle() -> ParticleOptions {
|
||||
ParticleOptions::colored(ParticleType::EntityEffect, [1.0; 4])
|
||||
}
|
||||
|
||||
/// `AreaEffectCloud.clientTick`: while waiting, now and then two particles
|
||||
/// near the middle; then about one per square block of the disc, drifting
|
||||
/// (an entity effect stays still).
|
||||
pub(super) fn cloud_client_tick(
|
||||
particles: &mut horizon_client::particles::ParticleEngine,
|
||||
at: [f64; 3],
|
||||
radius: f32,
|
||||
waiting: bool,
|
||||
particle: &ParticleOptions,
|
||||
) {
|
||||
if waiting && particles.random().next_boolean() {
|
||||
return;
|
||||
}
|
||||
let (count, spread) = if waiting {
|
||||
(2, 0.2)
|
||||
} else {
|
||||
(
|
||||
(std::f32::consts::PI * radius * radius).ceil() as i32,
|
||||
radius,
|
||||
)
|
||||
};
|
||||
for _ in 0..count {
|
||||
let r = particles.random();
|
||||
let angle = r.next_float() * std::f32::consts::TAU;
|
||||
let distance = r.next_float().sqrt() * spread;
|
||||
let at = [
|
||||
at[0] + f64::from(angle.cos() * distance),
|
||||
at[1],
|
||||
at[2] + f64::from(angle.sin() * distance),
|
||||
];
|
||||
let (options, velocity) = if particle.ty == ParticleType::EntityEffect {
|
||||
let default = waiting && r.next_boolean();
|
||||
let options = if default {
|
||||
default_cloud_particle()
|
||||
} else {
|
||||
particle.clone()
|
||||
};
|
||||
(options, [0.0; 3])
|
||||
} else if waiting {
|
||||
(particle.clone(), [0.0; 3])
|
||||
} else {
|
||||
let v = [
|
||||
(0.5 - r.next_double()) * 0.15,
|
||||
f64::from(0.01f32),
|
||||
(0.5 - r.next_double()) * 0.15,
|
||||
];
|
||||
(particle.clone(), v)
|
||||
};
|
||||
particles.add(&options, at, velocity);
|
||||
}
|
||||
}
|
||||
|
||||
/// The projectile a type id names, as Horizon models it.
|
||||
fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
|
||||
Some(match kind {
|
||||
@@ -360,31 +415,58 @@ impl Game<'_> {
|
||||
let id = t.id;
|
||||
t.remote_step(|at| step(id, at));
|
||||
}
|
||||
// FireworkRocketEntity.tick: a rocket attached to the gliding local
|
||||
// player boosts it client-side too (attachedToEntity.isFallFlying).
|
||||
// FireworkRocketEntity.tick: a rocket attached to an entity follows it
|
||||
// and boosts it client-side too (attachedToEntity.isFallFlying), and
|
||||
// every rocket leaves its spark trail on a client.
|
||||
let me = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| u32::try_from(r.entity_id).ok());
|
||||
for r in &mut self.rockets {
|
||||
let id = r.id;
|
||||
r.remote_step(|at| step(id, at));
|
||||
if me.is_some()
|
||||
&& r.attached_to == me
|
||||
&& let Some(p) = self.player.as_mut()
|
||||
&& p.fall_flying
|
||||
{
|
||||
let look = view_vector(self.yaw, self.pitch);
|
||||
let m = p.velocity;
|
||||
p.velocity = Vec3::new(
|
||||
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
|
||||
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
|
||||
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
|
||||
);
|
||||
r.position = p.position;
|
||||
r.velocity = p.velocity;
|
||||
let mut rockets = std::mem::take(&mut self.rockets);
|
||||
for r in &mut rockets {
|
||||
r.remote_step(&mut SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
});
|
||||
if let Some(target) = r.attached_to {
|
||||
if me == Some(target) {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
if p.fall_flying {
|
||||
let look = view_vector(self.yaw, self.pitch);
|
||||
let m = p.velocity;
|
||||
p.velocity = Vec3::new(
|
||||
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
|
||||
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
|
||||
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
|
||||
);
|
||||
}
|
||||
r.position = p.position;
|
||||
r.velocity = p.velocity;
|
||||
}
|
||||
} else if let Some(m) = self.mobs.iter().find(|m| m.id == target) {
|
||||
// Another player's motion is not known here: its step.
|
||||
r.velocity = Vec3::new(
|
||||
m.position.x - r.previous.x,
|
||||
m.position.y - r.previous.y,
|
||||
m.position.z - r.previous.z,
|
||||
);
|
||||
r.position = m.position;
|
||||
}
|
||||
}
|
||||
let g = |g: &mut horizon_core::random::LegacyRandom| g.next_gaussian() * 0.05;
|
||||
let v = [
|
||||
g(&mut self.world.random),
|
||||
-r.velocity.y * 0.5,
|
||||
g(&mut self.world.random),
|
||||
];
|
||||
self.particles.add(
|
||||
&ParticleOptions::simple(ParticleType::Firework),
|
||||
[r.position.x, r.position.y, r.position.z],
|
||||
v,
|
||||
);
|
||||
}
|
||||
self.rockets = rockets;
|
||||
for c in &mut self.crystals {
|
||||
c.time += 1;
|
||||
c.position = step(c.id, c.position);
|
||||
@@ -414,52 +496,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `AreaEffectCloud.clientTick`: while waiting, now and then two
|
||||
/// particles near the middle; then about one per square block of the
|
||||
/// disc, drifting (an entity effect stays still).
|
||||
/// `AreaEffectCloud.clientTick` for each cloud a server tracks.
|
||||
fn tick_remote_clouds(&mut self) {
|
||||
for c in &self.potions.clouds {
|
||||
let r = self.particles.random();
|
||||
if c.waiting && r.next_boolean() {
|
||||
continue;
|
||||
}
|
||||
let (count, spread) = if c.waiting {
|
||||
(2, 0.2)
|
||||
} else {
|
||||
(
|
||||
(std::f32::consts::PI * c.radius * c.radius).ceil() as i32,
|
||||
c.radius,
|
||||
)
|
||||
};
|
||||
for _ in 0..count {
|
||||
let r = self.particles.random();
|
||||
let angle = r.next_float() * std::f32::consts::TAU;
|
||||
let distance = r.next_float().sqrt() * spread;
|
||||
let at = [
|
||||
c.at[0] + f64::from(angle.cos() * distance),
|
||||
c.at[1],
|
||||
c.at[2] + f64::from(angle.sin() * distance),
|
||||
];
|
||||
let (options, velocity) = if c.particle.ty == ParticleType::EntityEffect {
|
||||
let default = c.waiting && r.next_boolean();
|
||||
let options = if default {
|
||||
default_cloud_particle()
|
||||
} else {
|
||||
c.particle.clone()
|
||||
};
|
||||
(options, [0.0; 3])
|
||||
} else if c.waiting {
|
||||
(c.particle.clone(), [0.0; 3])
|
||||
} else {
|
||||
let v = [
|
||||
(0.5 - r.next_double()) * 0.15,
|
||||
f64::from(0.01f32),
|
||||
(0.5 - r.next_double()) * 0.15,
|
||||
];
|
||||
(c.particle.clone(), v)
|
||||
};
|
||||
self.particles.add(&options, at, velocity);
|
||||
}
|
||||
cloud_client_tick(&mut self.particles, c.at, c.radius, c.waiting, &c.particle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,17 @@ impl Game<'_> {
|
||||
} else if self.riding == Some(vehicle_id) {
|
||||
// The server takes the player out, then teleports it.
|
||||
self.riding = None;
|
||||
// HappyGhast.removePassenger: a still timeout of 10 (the server's
|
||||
// STAYS_STILL confirms it), and the rider lands on top of the ghast
|
||||
// (`place_ghast_under`).
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| {
|
||||
m.id == vehicle_id && m.species() == horizon_client::mobs::Species::HappyGhast
|
||||
}) {
|
||||
m.extra.counter = 10;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.ghast_dismount = Some(vehicle_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,9 +92,8 @@ impl Game<'_> {
|
||||
distant_terrain: self.lod.is_some(),
|
||||
cloud_range_chunks: self.settings.cloud_range,
|
||||
camera_y: camera.y,
|
||||
// level.getMinY(): the Nether and the End start at y 0.
|
||||
min_y: if self.dim == Dim::Overworld { MIN_Y } else { 0 },
|
||||
flat: false,
|
||||
min_y: self.level_min_y(),
|
||||
flat: self.level_flat(),
|
||||
forward: forward.map(|c| c as f32),
|
||||
rain,
|
||||
thunder,
|
||||
@@ -108,9 +107,30 @@ impl Game<'_> {
|
||||
},
|
||||
)
|
||||
}
|
||||
/// `SkyRenderer.shouldRenderDarkDisc`: eye below the horizon (y 63) and not under water.
|
||||
/// `ClientLevelData.isFlat`: on the server thread, the world-wide flag.
|
||||
fn level_flat(&self) -> bool {
|
||||
match self.remote.as_ref() {
|
||||
Some(r) => r.flat,
|
||||
None => self.world_flat,
|
||||
}
|
||||
}
|
||||
/// `Level.getMinY`: the current dimension type's minimum y.
|
||||
fn level_min_y(&self) -> i32 {
|
||||
match self.remote.as_ref() {
|
||||
Some(r) => r.dimension.min_y,
|
||||
None => self.dim.min_y(),
|
||||
}
|
||||
}
|
||||
/// `SkyRenderer.shouldRenderDarkDisc`: eye below the horizon and not under
|
||||
/// water. `ClientLevelData.getHorizonHeight`: the bottom of a flat world,
|
||||
/// otherwise y 63.
|
||||
pub(super) fn dark_disc(&self, camera: glam::DVec3) -> bool {
|
||||
camera.y < 63.0
|
||||
let horizon = if self.level_flat() {
|
||||
f64::from(self.level_min_y())
|
||||
} else {
|
||||
63.0
|
||||
};
|
||||
camera.y < horizon
|
||||
&& fog_type(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
@@ -272,6 +292,16 @@ impl Game<'_> {
|
||||
&& motion_blocking(world, facts, p[0], p[2], p[1] + 64) <= p[1]
|
||||
&& rains_at(world, biomes, p[0], p[1], p[2])
|
||||
};
|
||||
let tables = &self.pack.context.tables;
|
||||
let model_offset = |state, x, z| tables.offset(state, x, z);
|
||||
let gateway_particles = |state, p| {
|
||||
self.res
|
||||
.block_entity_render()
|
||||
.gateway_faces(world, state, p)
|
||||
.len()
|
||||
};
|
||||
// Level.isThundering: a thunder level above 0.9 where the sky is open.
|
||||
let thundering = self.dim == Dim::Overworld && world.weather.levels(1.0).1 > 0.9;
|
||||
let ctx = AnimateContext {
|
||||
world,
|
||||
facts,
|
||||
@@ -280,6 +310,10 @@ impl Game<'_> {
|
||||
registry: self.pack.assets.registry,
|
||||
raining_at: &raining_at,
|
||||
marker: marker.map(|name| (name, &marker_options as &dyn Fn(_) -> _)),
|
||||
model_offset: &model_offset,
|
||||
gateway_particles: &gateway_particles,
|
||||
thundering,
|
||||
ultrawarm: self.dim == Dim::Nether,
|
||||
};
|
||||
if let Some(e) = eye {
|
||||
let block = [
|
||||
@@ -289,11 +323,13 @@ impl Game<'_> {
|
||||
];
|
||||
animate_tick(self.res.animate(), &mut self.particles, &ctx, block);
|
||||
if rain > 0.0 {
|
||||
// ClientLevel.tickWeatherEffects: rain splashes (weather radius 10).
|
||||
// ClientLevel.tickWeatherEffects: rain splashes (weather radius 10)
|
||||
// and the rain sound.
|
||||
let mut random = LegacyRandom::new(self.world.game_time.wrapping_mul(312_987_231));
|
||||
let radius = 10;
|
||||
let diameter = 2 * radius + 1;
|
||||
let count = (0.225 * (diameter * diameter) as f32 * rain * rain) as i32;
|
||||
let mut rain_position = None;
|
||||
for _ in 0..count {
|
||||
let x = block[0] + random.next_int_bound(diameter).unwrap_or(0) - radius;
|
||||
let z = block[2] + random.next_int_bound(diameter).unwrap_or(0) - radius;
|
||||
@@ -306,6 +342,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let below = top - 1;
|
||||
rain_position = Some([x, below, z]);
|
||||
let (bx, bz) = (random.next_double(), random.next_double());
|
||||
let state = world.block(x, below, z);
|
||||
let f = facts.get(state);
|
||||
@@ -337,6 +374,26 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.add(&ParticleOptions::simple(ty), p, [0.0; 3]);
|
||||
}
|
||||
// The last rain column of the tick makes the sound, more often the
|
||||
// longer it has been quiet; indoors it is the quieter one.
|
||||
if let Some(at) = rain_position {
|
||||
let waited = self.rain_sound_time;
|
||||
self.rain_sound_time += 1;
|
||||
if random.next_int_bound(3).unwrap_or(0) < waited {
|
||||
self.rain_sound_time = 0;
|
||||
let roof =
|
||||
motion_blocking(world, facts, block[0], block[2], MIN_Y + HEIGHT);
|
||||
let (name, volume, pitch) = if at[1] > block[1] + 1 && roof > block[1] {
|
||||
("minecraft:weather.rain.above", 0.1, 0.5)
|
||||
} else {
|
||||
("minecraft:weather.rain", 0.2, 1.0)
|
||||
};
|
||||
let p = at.map(|c| f64::from(c) + 0.5);
|
||||
// playLocalSound: guests make their own rain, so a host
|
||||
// must not capture it for them.
|
||||
self.audio.play_unshared(name, Some(p), volume, pitch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(r) = &self.renderer {
|
||||
|
||||
@@ -19,9 +19,6 @@ impl Screen {
|
||||
| Self::Advancements
|
||||
| Self::WorldOptions
|
||||
| Self::Friends
|
||||
| Self::ServerLinks
|
||||
| Self::ConfirmLink
|
||||
| Self::DialogWarning
|
||||
| Self::Mod => true,
|
||||
Self::Playing
|
||||
| Self::GameModeSwitcher
|
||||
@@ -38,35 +35,20 @@ impl Screen {
|
||||
}
|
||||
|
||||
/// PauseScreen buttons: (x, y, width, label key, action id, active).
|
||||
/// `links`: the `minecraft:server_links` dialog's button, which the
|
||||
/// `pause_screen_additions` tag (empty in vanilla) falls back to when the
|
||||
/// server sent links — a full-width row that pushes the last two rows down.
|
||||
/// https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/PauseScreen
|
||||
fn pause_buttons(
|
||||
w: f32,
|
||||
h: f32,
|
||||
remote: bool,
|
||||
links: bool,
|
||||
) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
|
||||
fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
|
||||
// GridLayout of two 98px columns (padding 4), aligned at (0.5, 0.25).
|
||||
let (grid_w, grid_h) = (212.0, 166.0 + if links { 24.0 } else { 0.0 });
|
||||
let (grid_w, grid_h) = (212.0, 166.0);
|
||||
let x0 = ((w - grid_w) * 0.5).floor();
|
||||
let y0 = ((h - grid_h) * 0.25).floor();
|
||||
let down = if links { 24.0 } else { 0.0 };
|
||||
let mut v = vec![
|
||||
vec![
|
||||
(x0 + 4.0, y0 + 50.0, 204.0, "menu.returnToGame", 0, true),
|
||||
(x0 + 4.0, y0 + 74.0, 98.0, "gui.advancements", 4, true),
|
||||
(x0 + 110.0, y0 + 74.0, 98.0, "gui.stats", 3, true),
|
||||
];
|
||||
if links {
|
||||
// CommonDialogData.computeExternalTitle of Dialogs.SERVER_LINKS.
|
||||
v.push((x0 + 4.0, y0 + 122.0, 204.0, "menu.server_links", 11, true));
|
||||
}
|
||||
v.extend([
|
||||
(x0 + 4.0, y0 + 122.0 + down, 98.0, "menu.options", 1, true),
|
||||
(x0 + 4.0, y0 + 122.0, 98.0, "menu.options", 1, true),
|
||||
(
|
||||
x0 + 110.0,
|
||||
y0 + 122.0 + down,
|
||||
y0 + 122.0,
|
||||
98.0,
|
||||
"options.worldOptions.button",
|
||||
9,
|
||||
@@ -74,7 +56,7 @@ fn pause_buttons(
|
||||
),
|
||||
(
|
||||
x0 + 4.0,
|
||||
y0 + 146.0 + down,
|
||||
y0 + 146.0,
|
||||
204.0,
|
||||
if remote {
|
||||
"menu.disconnect"
|
||||
@@ -84,17 +66,16 @@ fn pause_buttons(
|
||||
2,
|
||||
true,
|
||||
),
|
||||
]);
|
||||
v
|
||||
]
|
||||
}
|
||||
|
||||
/// The icon row: report bugs, feedback, friends, player reporting.
|
||||
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's
|
||||
/// Play with Friends; the others stay inactive.
|
||||
const FRIENDS_ICON: usize = 2;
|
||||
fn pause_icons(w: f32, h: f32, links: bool) -> [(f32, f32, &'static str); 4] {
|
||||
fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
|
||||
let x0 = ((w - 212.0) * 0.5).floor() + 60.0;
|
||||
let y = ((h - 166.0 - if links { 24.0 } else { 0.0 }) * 0.25).floor() + 98.0;
|
||||
let y = ((h - 166.0) * 0.25).floor() + 98.0;
|
||||
[
|
||||
(x0, y, "gui/sprites/pause_menu/bug"),
|
||||
(x0 + 24.0, y, "gui/sprites/pause_menu/social_interactions"),
|
||||
@@ -139,9 +120,7 @@ impl Game<'_> {
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
let links = !self.server_links().is_empty();
|
||||
let mut tooltip = None;
|
||||
for (x, y, bw, key, id, active) in pause_buttons(w, h, self.external_server(), links) {
|
||||
for (x, y, bw, key, _, active) in pause_buttons(w, h, self.external_server()) {
|
||||
let hover = active && ui::hovered(self.cursor, x, y, bw, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
@@ -154,12 +133,9 @@ impl Game<'_> {
|
||||
active,
|
||||
hover,
|
||||
);
|
||||
// PauseScreen's customAdditions button: CUSTOM_OPTIONS_TOOLTIP.
|
||||
if id == 11 && hover {
|
||||
tooltip = Some(Text::translate("menu.custom_options.tooltip", &[]));
|
||||
}
|
||||
}
|
||||
for (i, (x, y, sprite)) in pause_icons(w, h, links).into_iter().enumerate() {
|
||||
let mut tooltip = None;
|
||||
for (i, (x, y, sprite)) in pause_icons(w, h).into_iter().enumerate() {
|
||||
let active = i == FRIENDS_ICON && friends;
|
||||
let hover = ui::hovered(self.cursor, x, y, 20.0, 20.0);
|
||||
// The icon has no label: name it for controller focus and the
|
||||
@@ -192,13 +168,7 @@ impl Game<'_> {
|
||||
}
|
||||
// SpriteIconButton.withTootip: the label as a tooltip.
|
||||
if let Some(tip) = tooltip {
|
||||
let lines: Vec<Text> = atlas
|
||||
.font
|
||||
.split_lines(&tip, batch.width)
|
||||
.into_iter()
|
||||
.map(|(line, _)| line)
|
||||
.collect();
|
||||
ui::tooltip(batch, atlas, &lines, self.cursor);
|
||||
ui::tooltip(batch, atlas, &[tip], self.cursor);
|
||||
}
|
||||
}
|
||||
pub(super) fn pause_click(&mut self) -> Result<()> {
|
||||
@@ -210,9 +180,8 @@ impl Game<'_> {
|
||||
let b = HudBatch::new(size);
|
||||
(b.width, b.height)
|
||||
};
|
||||
let links = !self.server_links().is_empty();
|
||||
let friends = pause_icons(w, h, links)[FRIENDS_ICON];
|
||||
let hit = pause_buttons(w, h, self.external_server(), links)
|
||||
let friends = pause_icons(w, h)[FRIENDS_ICON];
|
||||
let hit = pause_buttons(w, h, self.external_server())
|
||||
.into_iter()
|
||||
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
|
||||
.map(|b| b.4)
|
||||
@@ -231,11 +200,6 @@ impl Game<'_> {
|
||||
Some(4) => self.open_advancements(true),
|
||||
Some(9) => self.open_world_options(),
|
||||
Some(10) => self.screen = Screen::Friends,
|
||||
// DialogConnectionAccess.openDialog(Dialogs.SERVER_LINKS).
|
||||
Some(11) => {
|
||||
self.links_ui.scroll = 0.0;
|
||||
self.screen = Screen::ServerLinks;
|
||||
}
|
||||
Some(2) => self.close_world()?,
|
||||
_ => {}
|
||||
}
|
||||
@@ -393,7 +357,7 @@ impl Game<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Screen, pause_buttons, pause_icons};
|
||||
use super::{Screen, pause_buttons};
|
||||
|
||||
#[test]
|
||||
fn menu_blur_matches_vanilla_screen_backgrounds() {
|
||||
@@ -426,32 +390,11 @@ mod tests {
|
||||
#[test]
|
||||
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
|
||||
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
|
||||
let buttons = pause_buttons(427.0, 240.0, remote, false);
|
||||
let buttons = pause_buttons(427.0, 240.0, remote);
|
||||
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
|
||||
assert_eq!(quit.3, label);
|
||||
assert!(quit.5);
|
||||
assert!(buttons.iter().any(|b| b.4 == 9 && b.5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_links_adds_a_full_width_row_above_options() {
|
||||
let with = pause_buttons(427.0, 240.0, true, true);
|
||||
let without = pause_buttons(427.0, 240.0, true, false);
|
||||
let links = with.iter().find(|b| b.4 == 11).unwrap();
|
||||
assert_eq!(links.2, 204.0);
|
||||
assert_eq!(links.3, "menu.server_links");
|
||||
assert!(links.5);
|
||||
// The icon row sits above it; Options and Disconnect move down 24.
|
||||
let icons = pause_icons(427.0, 240.0, true);
|
||||
let y0_icons = ((240.0f32 - 190.0) * 0.25).floor() + 98.0;
|
||||
assert_eq!(icons[0].1, y0_icons);
|
||||
assert!(links.1 > icons[0].1);
|
||||
for b in &with {
|
||||
if b.4 == 1 || b.4 == 9 || b.4 == 2 {
|
||||
let same = without.iter().find(|o| o.4 == b.4).unwrap();
|
||||
assert_eq!(b.1, same.1 + 24.0 - 6.0, "{}", b.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
//! `ServerLinksDialogScreen`: the pause menu's "Server Links..." button opens
|
||||
//! the `minecraft:server_links` dialog (the `pause_screen_additions` tag's
|
||||
//! fallback when a server sent ClientboundServerLinksPacket): a scrollable
|
||||
//! list of the server's links, each opened through `ConfirmLinkScreen`
|
||||
//! (Screen.clickUrlAction), and the `dialog/warning_button`'s
|
||||
//! DialogScreen.WarningScreen ("Note about custom screens").
|
||||
//! https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/dialog/ServerLinksDialogScreen
|
||||
use super::{Game, Screen, packs::open_uri};
|
||||
use horizon_client::{Result, clipboard::Clipboard, hud::HudBatch, text::Text, ui};
|
||||
use horizon_protocol::packets::configuration::{ServerLink, ServerLinkKind};
|
||||
|
||||
/// HeaderAndFooterLayout's header and footer heights; the content wants to
|
||||
/// start 30 pixels under the header (its `CONTENTS_MARGIN_TOP`).
|
||||
const HEADER: f32 = 33.0;
|
||||
const FOOTER: f32 = 33.0;
|
||||
const CONTENT_TOP: f32 = HEADER + 30.0;
|
||||
/// `server_links.json`: `columns` 1, `button_width` 310, `exit_action`'s 200.
|
||||
const LINK_WIDTH: f32 = 310.0;
|
||||
/// packControlsIntoColumns: rowSpacing 2 under the 20-pixel buttons.
|
||||
const ROW: f32 = 22.0;
|
||||
/// ScrollableLayout's BOTH reserve (scrollbarSpacing 4 + SCROLLBAR_WIDTH 6).
|
||||
const SCROLL_RESERVE: f32 = 10.0;
|
||||
/// DialogScreen's `dialog/warning_button` ImageButton.
|
||||
const WARNING_BUTTON: f32 = 20.0;
|
||||
|
||||
/// The ServerLinks screen's state: ScrollableLayout's scroll and the link
|
||||
/// ConfirmLinkScreen is asking about (`Screen::ConfirmLink`).
|
||||
#[derive(Default)]
|
||||
pub(super) struct LinksUi {
|
||||
pub scroll: f32,
|
||||
pub confirm: Option<String>,
|
||||
}
|
||||
|
||||
/// `Util.parseAndValidateUntrustedUri`: an absolute `http`/`https` URI
|
||||
/// (java.net.URI's scheme then the allowlist; whitespace-free).
|
||||
pub(super) fn valid_untrusted_uri(uri: &str) -> bool {
|
||||
let Some(colon) = uri.find(':') else {
|
||||
return false;
|
||||
};
|
||||
let scheme = &uri[..colon];
|
||||
let mut chars = scheme.chars();
|
||||
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||
|| !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https") {
|
||||
return false;
|
||||
}
|
||||
!uri.chars().any(|c| c.is_control() || c == ' ')
|
||||
}
|
||||
/// `ClientCommonPacketListenerImpl.handleServerLinks`: keep only the links
|
||||
/// that validate, warning about the dropped ones.
|
||||
pub(super) fn trusted(links: Vec<ServerLink>) -> Vec<ServerLink> {
|
||||
links
|
||||
.into_iter()
|
||||
.filter(|l| {
|
||||
let ok = valid_untrusted_uri(&l.link);
|
||||
if !ok {
|
||||
log::warn!("Horizon: server sent an invalid link: {}", l.link);
|
||||
}
|
||||
ok
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `ServerLinks.Entry.displayName`: `known_server_link.<name>` for a known
|
||||
/// type (ByIdMap.ZERO reads unknown ids as `report_bug`), the component else.
|
||||
fn link_label(link: &ServerLink) -> Text {
|
||||
const KNOWN: [&str; 10] = [
|
||||
"report_bug",
|
||||
"community_guidelines",
|
||||
"support",
|
||||
"status",
|
||||
"feedback",
|
||||
"community",
|
||||
"website",
|
||||
"forums",
|
||||
"news",
|
||||
"announcements",
|
||||
];
|
||||
match &link.kind {
|
||||
ServerLinkKind::Known(id) => Text::translate(
|
||||
&format!(
|
||||
"known_server_link.{}",
|
||||
KNOWN.get(*id as usize).copied().unwrap_or(KNOWN[0])
|
||||
),
|
||||
&[],
|
||||
),
|
||||
ServerLinkKind::Custom(tag) => {
|
||||
horizon_client::scoreboard::comp_text(&horizon_client::scoreboard::tag_to_json(tag))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The scrolled column of link buttons: content height and, for a list that
|
||||
/// fits, the `HeaderAndFooterLayout` contents frame's top.
|
||||
fn list_height(n: usize) -> f32 {
|
||||
n as f32 * ROW - 2.0
|
||||
}
|
||||
fn list_top(h: f32, n: usize) -> f32 {
|
||||
CONTENT_TOP.min(h - FOOTER - list_height(n).min(h - HEADER - FOOTER))
|
||||
}
|
||||
fn scroll_limit(h: f32, n: usize) -> f32 {
|
||||
(list_height(n) - (h - HEADER - FOOTER)).max(0.0)
|
||||
}
|
||||
|
||||
/// `ServerLinksScreen`'s buttons as (x, y) over the scrolled content, and the
|
||||
/// footer `Back` button's corner. `top` includes the scroll.
|
||||
fn link_buttons(w: f32, top: f32, n: usize) -> Vec<(f32, f32)> {
|
||||
let x = ((w - LINK_WIDTH - 2.0 * SCROLL_RESERVE) / 2.0 + SCROLL_RESERVE).floor();
|
||||
(0..n).map(|i| (x, top + i as f32 * ROW)).collect()
|
||||
}
|
||||
/// The warning button beside the title (DialogScreen's title row: 10 pixels
|
||||
/// apart), clamped onto the screen (makeSureWarningButtonIsInBounds).
|
||||
fn warning_button(w: f32, h: f32, title_width: f32) -> (f32, f32) {
|
||||
let x = ((w - title_width - 30.0) / 2.0).floor() + title_width + 10.0;
|
||||
let y = ((HEADER - WARNING_BUTTON) / 2.0).floor();
|
||||
if x < 0.0 || y < 0.0 || x > w - WARNING_BUTTON || y > h - WARNING_BUTTON {
|
||||
(0.0f32.max(w - 40.0), 5.0f32.min(h))
|
||||
} else {
|
||||
(x, y)
|
||||
}
|
||||
}
|
||||
fn back_button(w: f32, h: f32) -> (f32, f32) {
|
||||
(
|
||||
((w - 200.0) / 2.0).floor(),
|
||||
h - FOOTER + ((FOOTER - 20.0) / 2.0).floor(),
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `connection.serverLinks()`: the links the open server sent (empty for
|
||||
/// a world without a connection or one that sent none).
|
||||
pub(super) fn server_links(&self) -> &[ServerLink] {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.map_or(&[], |r| r.server_links.as_slice())
|
||||
}
|
||||
/// DialogScreen over the pause menu: `menu.server_links.title`, the
|
||||
/// scrollable link buttons and `gui.back` in the footer.
|
||||
pub(super) fn draw_server_links(&mut self, batch: &mut HudBatch) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let links = self.server_links().to_vec();
|
||||
self.links_ui.scroll = self
|
||||
.links_ui
|
||||
.scroll
|
||||
.clamp(0.0, scroll_limit(h, links.len()));
|
||||
let top = list_top(h, links.len()) - self.links_ui.scroll.trunc();
|
||||
// The title row: the title, then 10 pixels on the warning button.
|
||||
let title = Text::translate("menu.server_links.title", &[]);
|
||||
let tw = atlas.font.width_text(&title);
|
||||
let tx = ((w - tw - 30.0) / 2.0).floor();
|
||||
batch.text_styled(atlas, &title, tx, 12.0, u32::MAX, true);
|
||||
let (wx, wy) = warning_button(w, h, tw);
|
||||
let wh = ui::hovered(self.cursor, wx, wy, WARNING_BUTTON, WARNING_BUTTON);
|
||||
horizon_client::widgets::record(
|
||||
"menu.custom_screen_info.tooltip",
|
||||
[wx, wy, WARNING_BUTTON, WARNING_BUTTON],
|
||||
true,
|
||||
);
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
if wh {
|
||||
"gui/sprites/dialog/warning_button_highlighted"
|
||||
} else {
|
||||
"gui/sprites/dialog/warning_button"
|
||||
},
|
||||
wx,
|
||||
wy,
|
||||
WARNING_BUTTON,
|
||||
WARNING_BUTTON,
|
||||
u32::MAX,
|
||||
);
|
||||
let first = batch.vertices.len();
|
||||
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
|
||||
for (link, (x, y)) in links.iter().zip(link_buttons(w, top, links.len())) {
|
||||
let hover = inside && ui::hovered(self.cursor, x, y, LINK_WIDTH, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
LINK_WIDTH,
|
||||
20.0,
|
||||
&link_label(link),
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
(w / 2.0).floor() + 155.0 + 4.0,
|
||||
HEADER,
|
||||
h - FOOTER,
|
||||
list_height(links.len()),
|
||||
self.links_ui.scroll.trunc(),
|
||||
);
|
||||
let (bx, by) = back_button(w, h);
|
||||
let hover = ui::hovered(self.cursor, bx, by, 200.0, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
bx,
|
||||
by,
|
||||
200.0,
|
||||
20.0,
|
||||
&Text::translate("gui.back", &[]),
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
if wh {
|
||||
ui::tooltip(
|
||||
batch,
|
||||
atlas,
|
||||
&[Text::translate("menu.custom_screen_info.tooltip", &[])],
|
||||
self.cursor,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// A click on `Screen::ServerLinks`: a link, the warning button or Back.
|
||||
pub(super) fn server_links_click(&mut self) -> Result<()> {
|
||||
let (w, h) = self.gui_size();
|
||||
let links = self.server_links().to_vec();
|
||||
let top = list_top(h, links.len()) - self.links_ui.scroll.trunc();
|
||||
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
|
||||
if inside
|
||||
&& let Some(i) = link_buttons(w, top, links.len())
|
||||
.iter()
|
||||
.position(|(x, y)| ui::hovered(self.cursor, *x, *y, LINK_WIDTH, 20.0))
|
||||
{
|
||||
self.audio.play_button_click();
|
||||
self.click_link(&links[i].link);
|
||||
return Ok(());
|
||||
}
|
||||
let title = Text::translate("menu.server_links.title", &[]);
|
||||
let tw = self.gui.atlas.font.width_text(&title);
|
||||
let (wx, wy) = warning_button(w, h, tw);
|
||||
if ui::hovered(self.cursor, wx, wy, WARNING_BUTTON, WARNING_BUTTON) {
|
||||
self.audio.play_button_click();
|
||||
self.screen = Screen::DialogWarning;
|
||||
return Ok(());
|
||||
}
|
||||
let (bx, by) = back_button(w, h);
|
||||
if ui::hovered(self.cursor, bx, by, 200.0, 20.0) {
|
||||
self.audio.play_button_click();
|
||||
self.screen = Screen::Paused;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `Screen.clickUrlAction`: "Web Links" off ignores the click; "Prompt on
|
||||
/// Links" asks with ConfirmLinkScreen, else the browser opens directly.
|
||||
fn click_link(&mut self, url: &str) {
|
||||
if !self.settings.chat_links {
|
||||
return;
|
||||
}
|
||||
if self.settings.chat_links_prompt {
|
||||
self.links_ui.confirm = Some(url.to_owned());
|
||||
self.screen = Screen::ConfirmLink;
|
||||
} else {
|
||||
open_uri(url);
|
||||
}
|
||||
}
|
||||
/// `ScrollableLayout`'s mouse wheel over the links.
|
||||
pub(super) fn server_links_scroll(&mut self, delta: f32) {
|
||||
let (_, h) = self.gui_size();
|
||||
self.links_ui.scroll = (self.links_ui.scroll - delta * 10.0)
|
||||
.clamp(0.0, scroll_limit(h, self.server_links().len()));
|
||||
}
|
||||
/// `ConfirmLinkScreen` (untrusted): "Are you sure you want to open the
|
||||
/// following website?", the link, the red warning, and Yes / Copy Link to
|
||||
/// Clipboard / No.
|
||||
pub(super) fn draw_confirm_link(&mut self, batch: &mut HudBatch) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let url = self.links_ui.confirm.clone().unwrap_or_default();
|
||||
let layout = confirm_link_layout(atlas, (w, h), &url);
|
||||
for (line, x, y) in &layout.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
let labels = ["gui.yes", "gui.copy_link_to_clipboard", "gui.no"];
|
||||
for ((x, y), key) in layout.buttons.iter().zip(labels) {
|
||||
let hover = ui::hovered(self.cursor, *x, *y, 100.0, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
*x,
|
||||
*y,
|
||||
100.0,
|
||||
20.0,
|
||||
&Text::translate(key, &[]),
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// ConfirmLinkScreen's answer: yes opens the browser, the middle button
|
||||
/// copies the link, no closes; all return to Server Links.
|
||||
pub(super) fn confirm_link_click(&mut self) {
|
||||
let (w, h) = self.gui_size();
|
||||
let url = self.links_ui.confirm.clone().unwrap_or_default();
|
||||
let layout = confirm_link_layout(&self.gui.atlas, (w, h), &url);
|
||||
for (i, (x, y)) in layout.buttons.iter().enumerate() {
|
||||
if !ui::hovered(self.cursor, *x, *y, 100.0, 20.0) {
|
||||
continue;
|
||||
}
|
||||
self.audio.play_button_click();
|
||||
match i {
|
||||
0 => open_uri(&url),
|
||||
1 => {
|
||||
self.clipboard.set(&url);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.screen = Screen::ServerLinks;
|
||||
return;
|
||||
}
|
||||
}
|
||||
/// `DialogScreen.WarningScreen`: the custom-screens note, a disconnect
|
||||
/// button ("Custom screen rejected" is the shown reason) and Back.
|
||||
pub(super) fn draw_dialog_warning(&mut self, batch: &mut HudBatch) {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let layout = dialog_warning_layout(atlas, (w, h));
|
||||
for (line, x, y) in &layout.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
let quit = if self.external_server() {
|
||||
"menu.disconnect"
|
||||
} else {
|
||||
"menu.returnToMenu"
|
||||
};
|
||||
for ((x, y), key) in layout.buttons.into_iter().zip([quit, "gui.back"]) {
|
||||
let hover = ui::hovered(self.cursor, x, y, 150.0, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
150.0,
|
||||
20.0,
|
||||
&Text::translate(key, &[]),
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// WarningScreen's answer: disconnect, or back to Server Links.
|
||||
pub(super) fn dialog_warning_click(&mut self) -> Result<()> {
|
||||
let (w, h) = self.gui_size();
|
||||
let layout = dialog_warning_layout(&self.gui.atlas, (w, h));
|
||||
for (i, (x, y)) in layout.buttons.iter().enumerate() {
|
||||
if !ui::hovered(self.cursor, *x, *y, 150.0, 20.0) {
|
||||
continue;
|
||||
}
|
||||
self.audio.play_button_click();
|
||||
if i == 0 {
|
||||
// DialogConnectionAccess.disconnect: leave with the custom
|
||||
// screens' reason.
|
||||
let reason = self.translate(
|
||||
"menu.custom_screen_info.disconnect",
|
||||
"Custom screen rejected",
|
||||
);
|
||||
self.leave_server(Some(reason))?;
|
||||
} else {
|
||||
self.screen = Screen::ServerLinks;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A confirm screen's laid-out text (title, message, extra warning lines) and
|
||||
/// button corners; the whole column centred (ConfirmScreen.init).
|
||||
struct ConfirmLayout {
|
||||
lines: Vec<(Text, f32, f32)>,
|
||||
buttons: Vec<(f32, f32)>,
|
||||
}
|
||||
/// `ConfirmScreen.init`: title, message wrapped to `width - 50` (15 rows at
|
||||
/// most), the extra lines, then a button row `spacing` 4 with `paddingTop` 16.
|
||||
fn confirm_layout(
|
||||
font: &horizon_client::font::Font,
|
||||
(w, h): (f32, f32),
|
||||
title: &Text,
|
||||
message: &Text,
|
||||
extra: &[Text],
|
||||
button_widths: &[f32],
|
||||
) -> ConfirmLayout {
|
||||
let message: Vec<Text> = font
|
||||
.split_lines(message, w - 50.0)
|
||||
.into_iter()
|
||||
.take(15)
|
||||
.map(|(line, _)| line)
|
||||
.collect();
|
||||
// LinearLayout vertical spacing 8; the button row's children pad 16 on top.
|
||||
let extra_h = 17.0 * extra.len() as f32;
|
||||
let mh = 9.0 * message.len() as f32;
|
||||
let total = 9.0 + 8.0 + mh + extra_h + 8.0 + 16.0 + 20.0;
|
||||
let mut y = ((h - total) / 2.0).trunc();
|
||||
let mut lines = Vec::new();
|
||||
let mut push = |line: &Text, at: f32| {
|
||||
let lw = font.width_text(line);
|
||||
lines.push((line.clone(), ((w - lw) / 2.0).trunc(), at));
|
||||
};
|
||||
push(title, y);
|
||||
y += 17.0;
|
||||
for line in &message {
|
||||
push(line, y);
|
||||
y += 9.0;
|
||||
}
|
||||
for line in extra {
|
||||
y += 8.0;
|
||||
push(line, y);
|
||||
y += 9.0;
|
||||
}
|
||||
let row_w: f32 = button_widths.iter().sum::<f32>() + 4.0 * (button_widths.len() - 1) as f32;
|
||||
let by = y + 8.0 + 16.0;
|
||||
let mut bx = ((w - row_w) / 2.0).trunc();
|
||||
let buttons = button_widths
|
||||
.iter()
|
||||
.map(|bw| {
|
||||
let p = (bx, by);
|
||||
bx += bw + 4.0;
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
ConfirmLayout { lines, buttons }
|
||||
}
|
||||
fn confirm_link_layout(
|
||||
atlas: &horizon_client::hud::HudAtlas,
|
||||
size: (f32, f32),
|
||||
url: &str,
|
||||
) -> ConfirmLayout {
|
||||
// ConfirmLinkScreen (untrusted): `chat.link.confirm`, the link as the
|
||||
// message, `chat.link.warning` in its red (0xFFCCCC).
|
||||
confirm_layout(
|
||||
&atlas.font,
|
||||
size,
|
||||
&Text::translate("chat.link.confirm", &[]),
|
||||
&Text::literal(url.to_owned()),
|
||||
&[Text::translate("chat.link.warning", &[]).with_color(0xFFCCCC)],
|
||||
&[100.0; 3],
|
||||
)
|
||||
}
|
||||
fn dialog_warning_layout(atlas: &horizon_client::hud::HudAtlas, size: (f32, f32)) -> ConfirmLayout {
|
||||
confirm_layout(
|
||||
&atlas.font,
|
||||
size,
|
||||
&Text::translate("menu.custom_screen_info.title", &[]),
|
||||
&Text::translate("menu.custom_screen_info.contents", &[]),
|
||||
&[],
|
||||
&[150.0; 2],
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn untrusted_uris_are_http_or_https_with_a_scheme() {
|
||||
for ok in [
|
||||
"https://example.net/x",
|
||||
"http://example.net",
|
||||
"HTTPS://EXAMPLE.NET",
|
||||
] {
|
||||
assert!(valid_untrusted_uri(ok), "{ok}");
|
||||
}
|
||||
for bad in [
|
||||
"example.net/no-scheme",
|
||||
"ftp://example.net",
|
||||
"file:///etc/passwd",
|
||||
"javascript:alert(1)",
|
||||
"https://exa mple.net",
|
||||
"://example.net",
|
||||
"",
|
||||
] {
|
||||
assert!(!valid_untrusted_uri(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
hud::{HudBatch, rgba},
|
||||
signs::wood_type,
|
||||
items::ItemStack,
|
||||
signs::{DYE_TEXT, wood_type},
|
||||
ui::EditBox,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
@@ -31,6 +32,157 @@ fn sign_box() -> EditBox {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `SignBlockEntity.getSlotPlayerIsFacing`: whether the player stands in
|
||||
/// front of the sign's text (within 90 degrees of its facing).
|
||||
fn facing_sign_front(&self, pos: (i32, i32, i32)) -> bool {
|
||||
let Some(player) = self.player.as_ref() else {
|
||||
return true;
|
||||
};
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let Some(state) = self.pack.assets.registry.state(state) else {
|
||||
return true;
|
||||
};
|
||||
let prop = |key: &str| state.properties.get(key).map(String::as_str);
|
||||
// getYRotationDegrees: a wall sign's `Direction.toYRot`, a standing or
|
||||
// ceiling sign's rotation segment.
|
||||
let y_rot = if state.name.contains("_wall_") {
|
||||
match prop("facing") {
|
||||
Some("west") => 90.0,
|
||||
Some("north") => 180.0,
|
||||
Some("east") => 270.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
} else {
|
||||
prop("rotation")
|
||||
.and_then(|r| r.parse::<f32>().ok())
|
||||
.unwrap_or(0.0)
|
||||
* 22.5
|
||||
};
|
||||
// getSignHitboxCenterPosition: only a wall sign's board is off-centre,
|
||||
// against the wall it hangs on.
|
||||
let centre = if state.name.ends_with("_wall_sign") {
|
||||
match y_rot as i32 {
|
||||
90 => (0.9375, 0.5),
|
||||
180 => (0.5, 0.9375),
|
||||
270 => (0.0625, 0.5),
|
||||
_ => (0.5, 0.0625),
|
||||
}
|
||||
} else {
|
||||
(0.5, 0.5)
|
||||
};
|
||||
let xd = player.position.x - (f64::from(pos.0) + centre.0);
|
||||
let zd = player.position.z - (f64::from(pos.2) + centre.1);
|
||||
let player_y_rot = (zd.atan2(xd).to_degrees() - 90.0) as f32;
|
||||
// Mth.degreesDifferenceAbs.
|
||||
let diff = (player_y_rot - y_rot).rem_euclid(360.0);
|
||||
let diff = if diff >= 180.0 { 360.0 - diff } else { diff };
|
||||
diff <= 90.0
|
||||
}
|
||||
/// `SignBlock.useWithoutItem` (the block's `OpenMenu` effect): a waxed
|
||||
/// sign plays the failed-interaction sound; otherwise a player who may
|
||||
/// build edits the side they face.
|
||||
pub(super) fn use_sign_empty_hand(&mut self, pos: [i32; 3]) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
|
||||
return;
|
||||
};
|
||||
if sign.waxed {
|
||||
// `getSignInteractionFailedSoundEvent`: a hanging sign has its own.
|
||||
let sound = if sign.hanging {
|
||||
"minecraft:block.hanging_sign.waxed_interact_fail"
|
||||
} else {
|
||||
"minecraft:block.sign.waxed_interact_fail"
|
||||
};
|
||||
self.audio
|
||||
.play(sound, Some(pos.map(|v| f64::from(v) + 0.5)), 1.0, 1.0);
|
||||
} else if self.may_build() {
|
||||
let front = self.facing_sign_front(key);
|
||||
self.open_sign_editor(key, front);
|
||||
}
|
||||
}
|
||||
/// `SignBlock.useItemOn` with a `SignApplicator` in hand (dyes, ink sacs,
|
||||
/// honeycomb): the item changes the side the player faces and is used up.
|
||||
/// Whether it did; a sign it does not apply to goes on to the empty hand.
|
||||
pub(super) fn use_sign_applicator(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
let Some(stack) = held else {
|
||||
return false;
|
||||
};
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
|
||||
return false;
|
||||
};
|
||||
if sign.waxed || !self.may_build() {
|
||||
return false;
|
||||
}
|
||||
let item = &*stack.item;
|
||||
let dye = if item.ends_with("_dye") {
|
||||
self.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.dye.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let colour = dye.and_then(|d| DYE_TEXT.iter().position(|(n, _)| *n == d));
|
||||
if !matches!(
|
||||
item,
|
||||
"minecraft:honeycomb" | "minecraft:glow_ink_sac" | "minecraft:ink_sac"
|
||||
) && colour.is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let front = self.facing_sign_front(key);
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else {
|
||||
return false;
|
||||
};
|
||||
let centre = pos.map(|v| f64::from(v) + 0.5);
|
||||
let sound = if item == "minecraft:honeycomb" {
|
||||
// HoneycombItem.tryApplyToSign: waxes any sign, empty or not.
|
||||
sign.waxed = true;
|
||||
"minecraft:item.honeycomb.wax_on"
|
||||
} else {
|
||||
let side = if front {
|
||||
&mut sign.front
|
||||
} else {
|
||||
&mut sign.back
|
||||
};
|
||||
// SignApplicator.canApplyToSign: the side must have text.
|
||||
if side.is_empty() {
|
||||
return false;
|
||||
}
|
||||
match colour {
|
||||
Some(c) if side.color != c as u8 => {
|
||||
side.color = c as u8;
|
||||
"minecraft:item.dye.use"
|
||||
}
|
||||
None if item == "minecraft:glow_ink_sac" && !side.glowing => {
|
||||
side.glowing = true;
|
||||
"minecraft:item.glow_ink_sac.use"
|
||||
}
|
||||
None if item == "minecraft:ink_sac" && side.glowing => {
|
||||
side.glowing = false;
|
||||
"minecraft:item.ink_sac.use"
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
};
|
||||
if item == "minecraft:honeycomb" {
|
||||
self.level_event_at(3003, pos, 0);
|
||||
}
|
||||
self.audio.play(sound, Some(centre), 1.0, 1.0);
|
||||
self.mark_block_entity_updated(key);
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_change",
|
||||
pos,
|
||||
self.sculk_actor(),
|
||||
Some(state),
|
||||
);
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.consume_held();
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, stack);
|
||||
true
|
||||
}
|
||||
/// `SignBlock.openTextEdit` for the front side after placement.
|
||||
pub(super) fn open_sign_editor(&mut self, pos: (i32, i32, i32), front: bool) {
|
||||
self.sign_edit = Some(SignEditor {
|
||||
|
||||
@@ -253,12 +253,21 @@ impl Game<'_> {
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
}
|
||||
/// `LivingEntity.makePoofParticles` (entity event 20) for a mob.
|
||||
fn mob_poof(&mut self, id: u32) {
|
||||
pub(super) fn mob_poof(&mut self, id: u32) {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let at = m.position;
|
||||
self.lan_entity_event(id as i32, 20, [at.x, at.y, at.z]);
|
||||
self.poof_particles(id);
|
||||
}
|
||||
/// `Mob.handleEntityEvent` 20 (`spawnAnim` on a client): the poof about
|
||||
/// mob `id`.
|
||||
pub(super) fn poof_particles(&mut self, id: u32) {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
|
||||
self.lan_entity_event(id as i32, 20, [at.x, at.y, at.z]);
|
||||
let options = ParticleOptions::simple(ParticleType::Poof);
|
||||
for _ in 0..20 {
|
||||
let r = self.particles.level_random();
|
||||
|
||||
@@ -779,6 +779,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let pos = s.pos;
|
||||
let enchanted_item = enchanted.clone();
|
||||
// Player.onEnchantmentPerformed
|
||||
self.give_levels(-(lapis_needed as i32));
|
||||
self.level.xp_seed = self.world.random.next_int();
|
||||
@@ -795,6 +796,7 @@ impl Game<'_> {
|
||||
}
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.stats.custom("enchant_item", 1);
|
||||
self.trigger_enchanted_item(&enchanted_item, lapis_needed as i32);
|
||||
self.play_at("minecraft:block.enchantment_table.use", pos, 1.0, pitch);
|
||||
self.station_changed();
|
||||
}
|
||||
|
||||
@@ -256,9 +256,8 @@ impl Game<'_> {
|
||||
.fluid
|
||||
== horizon_client::blocks::Fluid::Water;
|
||||
let on_ground = p.ground.on_ground;
|
||||
let (sprinting, flying, gliding, swimming) = (
|
||||
let (sprinting, gliding, swimming) = (
|
||||
p.sprinting,
|
||||
p.flying,
|
||||
p.fall_flying,
|
||||
p.pose == horizon_client::pose::Pose::Swimming,
|
||||
);
|
||||
@@ -272,15 +271,27 @@ impl Game<'_> {
|
||||
});
|
||||
let s = &mut self.stats;
|
||||
match riding {
|
||||
Some(Ok(horizon_client::mobs::Species::Pig)) => s.custom("pig_one_cm", total),
|
||||
Some(Ok(horizon_client::mobs::Species::Strider)) => s.custom("strider_one_cm", total),
|
||||
Some(Ok(sp)) if horizon_client::mobs::equine::is_equine(sp) => {
|
||||
s.custom("horse_one_cm", horizontal)
|
||||
Some(Ok(sp)) => {
|
||||
use horizon_client::mobs::Species;
|
||||
// `checkRidingStatistics`: only these mounts count, the horse
|
||||
// family by the full 3D distance; any other mount nothing.
|
||||
match sp {
|
||||
Species::Pig => s.custom("pig_one_cm", total),
|
||||
Species::Strider => s.custom("strider_one_cm", total),
|
||||
Species::HappyGhast => s.custom("happy_ghast_one_cm", total),
|
||||
Species::Nautilus | Species::ZombieNautilus => {
|
||||
s.custom("nautilus_one_cm", total)
|
||||
}
|
||||
sp if horizon_client::mobs::equine::is_equine(sp) => {
|
||||
s.custom("horse_one_cm", total)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
Some(Err(())) => {
|
||||
if self.boats.iter().any(|b| Some(b.id) == self.riding) {
|
||||
s.custom("boat_one_cm", total)
|
||||
} else {
|
||||
} else if self.carts.iter().any(|c| Some(c.id) == self.riding) {
|
||||
s.custom("minecart_one_cm", total)
|
||||
}
|
||||
}
|
||||
@@ -300,8 +311,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
None if gliding => s.custom("aviate_one_cm", total),
|
||||
None if flying => s.custom("fly_one_cm", horizontal),
|
||||
None if d.y < 0.0 => s.custom("fall_one_cm", (-d.y * 100.0).round() as i32),
|
||||
// Airborne and not gliding: more than 25 cm sideways per move
|
||||
// (creative flight, or a long jump), not only flying.
|
||||
None if horizontal > 25 => s.custom("fly_one_cm", horizontal),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! player's `PlayerEnderChestContainer`, saved as `EnderItems`).
|
||||
use super::Game;
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack, text::Text};
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
pub(super) fn is_shulker_box(name: &str) -> bool {
|
||||
name.ends_with("shulker_box")
|
||||
@@ -156,6 +157,79 @@ impl Game<'_> {
|
||||
);
|
||||
paired.then_some((other, kind == "right"))
|
||||
}
|
||||
/// The open or close sound of a container block (`ChestBlockEntity.playSound`,
|
||||
/// `BarrelBlockEntity.playSound`, `ShulkerBoxBlockEntity.startOpen`): volume
|
||||
/// 0.5 and pitch 0.9 to 1.0. A double chest sounds once from the middle of
|
||||
/// both halves, a barrel from the face it opens on.
|
||||
pub(super) fn container_sound(&mut self, sound: &str, key: (i32, i32, i32), name: &str) {
|
||||
let mut at = [
|
||||
f64::from(key.0) + 0.5,
|
||||
f64::from(key.1) + 0.5,
|
||||
f64::from(key.2) + 0.5,
|
||||
];
|
||||
if let Some((other, _)) = self.chest_partner(key) {
|
||||
at[0] = f64::from(key.0 + other.0) / 2.0 + 0.5;
|
||||
at[2] = f64::from(key.2 + other.2) / 2.0 + 0.5;
|
||||
} else if name == "minecraft:barrel"
|
||||
&& let Some(state) = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(self.world.block(key.0, key.1, key.2))
|
||||
&& let Some(facing) = state.properties.get("facing")
|
||||
{
|
||||
let (dx, dy, dz) = match facing.as_str() {
|
||||
"up" => (0.0, 0.5, 0.0),
|
||||
"down" => (0.0, -0.5, 0.0),
|
||||
f => {
|
||||
let (x, z) = facing_step(f);
|
||||
(f64::from(x) / 2.0, 0.0, f64::from(z) / 2.0)
|
||||
}
|
||||
};
|
||||
at = [at[0] + dx, at[1] + dy, at[2] + dz];
|
||||
}
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.audio.play(sound, Some(at), 0.5, pitch);
|
||||
}
|
||||
/// The open or close sound of the container block at `key`, if it has one.
|
||||
/// Callers play it only when the opener count crosses zero
|
||||
/// (`ContainerOpenersCounter.onOpen` / `onClose`).
|
||||
pub(super) fn container_edge_sound(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
let name = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(key.0, key.1, key.2))
|
||||
.name
|
||||
.clone();
|
||||
let sound = if is_shulker_box(&name) {
|
||||
if open {
|
||||
"minecraft:block.shulker_box.open"
|
||||
} else {
|
||||
"minecraft:block.shulker_box.close"
|
||||
}
|
||||
} else if &*name == "minecraft:ender_chest" {
|
||||
if open {
|
||||
"minecraft:block.ender_chest.open"
|
||||
} else {
|
||||
"minecraft:block.ender_chest.close"
|
||||
}
|
||||
} else if name.contains("chest") {
|
||||
if open {
|
||||
"minecraft:block.chest.open"
|
||||
} else {
|
||||
"minecraft:block.chest.close"
|
||||
}
|
||||
} else if &*name == "minecraft:barrel" {
|
||||
if open {
|
||||
"minecraft:block.barrel.open"
|
||||
} else {
|
||||
"minecraft:block.barrel.close"
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
self.container_sound(sound, key, &name);
|
||||
}
|
||||
/// The other half of a double chest at `key`, for neighbour updates.
|
||||
pub(super) fn chest_other_half(&self, key: (i32, i32, i32)) -> Option<(i32, i32, i32)> {
|
||||
self.chest_partner(key).map(|p| p.0)
|
||||
|
||||
@@ -164,12 +164,11 @@ impl Game<'_> {
|
||||
} else {
|
||||
[self.yaw, self.pitch]
|
||||
};
|
||||
let scale = self.view_scale();
|
||||
for display in self.text_displays.values_mut() {
|
||||
display.last_progress = display.progress(partial as f32);
|
||||
let d = display.shown.blend(&display.from, display.last_progress);
|
||||
let position = display.previous.lerp(display.position, partial);
|
||||
let range = f64::from(d.view_range) * 64.0 * scale;
|
||||
let range = f64::from(d.view_range) * 64.0 * f64::from(self.settings.entity_distance);
|
||||
if position.distance_squared(camera) >= range * range || d.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -178,7 +178,9 @@ impl Game<'_> {
|
||||
];
|
||||
self.tnts.remove(i);
|
||||
count -= 1;
|
||||
self.server_explode(center, 4.0, BlockInteraction::Destroy, false)?;
|
||||
self.with_hive_emptying_blast(|g| {
|
||||
g.server_explode(center, 4.0, BlockInteraction::Destroy, false)
|
||||
})?;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
@@ -252,7 +254,7 @@ impl Game<'_> {
|
||||
}
|
||||
let range = super::entity_culling::default_range(
|
||||
glam::DVec3::splat(PrimedTnt::SIZE),
|
||||
self.view_scale(),
|
||||
self.settings.entity_distance,
|
||||
);
|
||||
for t in &self.tnts {
|
||||
// EntityRenderer.shouldRender.
|
||||
|
||||
@@ -245,7 +245,7 @@ impl Game<'_> {
|
||||
self.handle_mob_events(events)?;
|
||||
return Ok(true);
|
||||
}
|
||||
self.stats.custom("talk_to_villager", 1);
|
||||
self.stats.custom("talked_to_villager", 1);
|
||||
self.ensure_offers(index);
|
||||
let m = &self.mobs[index];
|
||||
let trade = m.extra.trade.as_deref().cloned().unwrap_or_default();
|
||||
@@ -398,18 +398,21 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.and_then(|s| s.merchant.as_ref())
|
||||
.and_then(|m| m.mob);
|
||||
let session = self.merchant_apply(session, mob);
|
||||
let (session, traded) = self.merchant_apply(session, mob);
|
||||
self.merchant_traded(mob, &traded);
|
||||
if let Some(m) = self.station.as_mut().and_then(|s| s.merchant.as_mut()) {
|
||||
m.session = session;
|
||||
}
|
||||
}
|
||||
/// What a menu click's session did, applied to the world and its mob.
|
||||
/// What a menu click's session did, applied to the world and its mob;
|
||||
/// with the result of each trade made, for `merchant_traded`.
|
||||
pub(super) fn merchant_apply(
|
||||
&mut self,
|
||||
session: Box<merchant::Session>,
|
||||
mob: Option<u32>,
|
||||
) -> Box<merchant::Session> {
|
||||
) -> (Box<merchant::Session>, Vec<ItemStack>) {
|
||||
let mut session = session;
|
||||
let mut traded = Vec::new();
|
||||
let events = std::mem::take(&mut session.events);
|
||||
let (villager, origin) = (session.villager, session.origin);
|
||||
if let Some(m) = mob.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id)) {
|
||||
@@ -438,11 +441,26 @@ impl Game<'_> {
|
||||
};
|
||||
self.audio.play(sound, Some(origin), 1.0, pitch);
|
||||
}
|
||||
merchant::Event::Traded(_) => self.stats.custom("traded_with_villager", 1),
|
||||
merchant::Event::Traded(index) => {
|
||||
traded.extend(session.offers.get(index).map(|o| o.result.clone()));
|
||||
}
|
||||
merchant::Event::TradeSound => {}
|
||||
}
|
||||
}
|
||||
session
|
||||
(session, traded)
|
||||
}
|
||||
/// `MerchantResultSlot.checkTakeAchievements` and `AbstractVillager.notifyTrade`
|
||||
/// for each trade made: the statistic and the `villager_trade` trigger.
|
||||
pub(super) fn merchant_traded(&mut self, mob: Option<u32>, results: &[ItemStack]) {
|
||||
for result in results {
|
||||
self.stats.custom("traded_with_villager", 1);
|
||||
let villager = mob
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|m| self.mob_entity(m));
|
||||
if let Some(villager) = villager {
|
||||
self.trigger_trade(&villager, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Villager.customServerAiStep`: the TRADE reputation event of the last
|
||||
/// trade, a tick later; the prices follow the new reputation.
|
||||
|
||||
@@ -32,6 +32,8 @@ pub(super) struct ThrownTrident {
|
||||
pub(super) no_physics: bool,
|
||||
returning: u32,
|
||||
creative_only: bool,
|
||||
/// The guest that threw it (`Projectile.getOwner`); `None` for the host.
|
||||
guest: Option<u32>,
|
||||
}
|
||||
impl ThrownTrident {
|
||||
/// A trident a server sent (`handleAddEntity`): placed and moved by it.
|
||||
@@ -51,6 +53,7 @@ impl ThrownTrident {
|
||||
no_physics: false,
|
||||
returning: 0,
|
||||
creative_only: false,
|
||||
guest: None,
|
||||
}
|
||||
}
|
||||
/// One tick of a server's client: the previous position, then `step`.
|
||||
@@ -302,11 +305,33 @@ impl Game<'_> {
|
||||
no_physics: false,
|
||||
returning: 0,
|
||||
creative_only: self.creative,
|
||||
guest: self.lan_acting,
|
||||
});
|
||||
self.player_projectile_shot();
|
||||
self.audio
|
||||
.play("minecraft:item.trident.throw", at, 1.0, 1.0);
|
||||
}
|
||||
/// The thrower's `PLAYER_HURT_ENTITY` for a trident hit: run as the
|
||||
/// guest that threw it, and not at all once they have left.
|
||||
fn thrower_hit(
|
||||
&mut self,
|
||||
guest: Option<u32>,
|
||||
victim: &horizon_client::advancements::EntityView,
|
||||
direct: horizon_client::advancements::EntityView,
|
||||
damage: f32,
|
||||
) {
|
||||
let Some(conn) = guest.filter(|c| self.lan_acting != Some(*c)) else {
|
||||
self.player_hit(victim, "minecraft:trident", Some(direct), damage);
|
||||
return;
|
||||
};
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
self.as_guest(&mut host, conn, |g| {
|
||||
g.player_hit(victim, "minecraft:trident", Some(direct), damage)
|
||||
});
|
||||
self.lan = Some(host);
|
||||
}
|
||||
pub(super) fn tick_tridents(&mut self) {
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return;
|
||||
@@ -394,6 +419,18 @@ impl Game<'_> {
|
||||
{
|
||||
self.channel_lightning(at);
|
||||
}
|
||||
if hurt_at.is_some()
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
|
||||
{
|
||||
// LivingEntity.hurtServer: PLAYER_HURT_ENTITY.
|
||||
let victim = self.mob_entity(m);
|
||||
let direct = horizon_client::advancements::EntityView {
|
||||
kind: "minecraft:trident".into(),
|
||||
position: [t.position.x, t.position.y, t.position.z],
|
||||
..Default::default()
|
||||
};
|
||||
self.thrower_hit(t.guest, &victim, direct, damage);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:item.trident.hit",
|
||||
Some([t.position.x, t.position.y, t.position.z]),
|
||||
@@ -495,7 +532,7 @@ impl Game<'_> {
|
||||
let range = 10.0
|
||||
* super::entity_culling::default_range(
|
||||
glam::DVec3::splat(2.0 * HALF),
|
||||
self.view_scale(),
|
||||
self.settings.entity_distance,
|
||||
);
|
||||
for t in &self.tridents {
|
||||
// EntityRenderer.shouldRender; ThrownTridentRenderer grows the box by 1.5.
|
||||
|
||||
@@ -169,6 +169,8 @@ impl Game<'_> {
|
||||
self.consume_held();
|
||||
}
|
||||
}
|
||||
// ServerPlayerGameMode.useItemOn: the action was consumed.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, &stack);
|
||||
Ok(true)
|
||||
}
|
||||
fn vault_events(&mut self, key: Pos, events: Vec<VaultEvent>) -> Result<()> {
|
||||
|
||||
@@ -148,7 +148,7 @@ impl Game<'_> {
|
||||
// VillagerMakeLove: entity event 13 for the partner, then the parent.
|
||||
for i in [ib, ia] {
|
||||
let id = self.mobs[i].id;
|
||||
self.villager_particles(id, "angry_villager");
|
||||
self.mob_event_particles(id, 13);
|
||||
}
|
||||
return;
|
||||
};
|
||||
@@ -173,12 +173,31 @@ impl Game<'_> {
|
||||
child.extra.bed = Some(bed);
|
||||
}
|
||||
// VillagerMakeLove.giveBirth: entity event 12 for the child.
|
||||
self.villager_particles(id, "heart");
|
||||
self.mob_event_particles(id, 12);
|
||||
}
|
||||
/// `Villager.handleEntityEvent` 12, 13, 14 and 42:
|
||||
/// `AbstractVillager.addParticlesAroundSelf`.
|
||||
pub(super) fn villager_particles(&mut self, id: u32, kind: &'static str) {
|
||||
self.particles_around_mob(id, kind, horizon_client::particles::Burst::VILLAGER);
|
||||
/// `Level.broadcastEntityEvent` of an event whose `handleEntityEvent`
|
||||
/// draws particles about the mob (villager 12, 13 and 14, taming 6 and 7,
|
||||
/// love 18): the guests get the byte, and this game draws it as its own
|
||||
/// client would.
|
||||
pub(super) fn mob_event_particles(&mut self, id: u32, event: i8) {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
self.lan_entity_event(id as i32, event, at);
|
||||
self.entity_event_particles(id, event);
|
||||
}
|
||||
/// `handleEntityEvent(event)`'s particles about mob `id`
|
||||
/// (`Villager`, `TamableAnimal`, `AbstractHorse`, `Animal`, `Allay`).
|
||||
pub(super) fn entity_event_particles(&mut self, id: u32, event: i8) {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
if let Some((kind, burst)) =
|
||||
horizon_client::particles::entity_event_burst(&m.kind.id, event as u8)
|
||||
{
|
||||
self.particles_around_mob(id, kind, burst);
|
||||
}
|
||||
}
|
||||
/// `particles::around_self` about mob `id`.
|
||||
pub(super) fn particles_around_mob(
|
||||
@@ -315,7 +334,7 @@ impl Game<'_> {
|
||||
}
|
||||
// Villager.setLastHurtByMob: entity event 13.
|
||||
for id in angry {
|
||||
self.villager_particles(id, "angry_villager");
|
||||
self.mob_event_particles(id, 13);
|
||||
}
|
||||
}
|
||||
/// `Villager.tellWitnessesThatIWasMurdered`: the villagers that saw a
|
||||
@@ -370,7 +389,7 @@ impl Game<'_> {
|
||||
gossip::REPUTATION_CHANGE_PER_TRADE,
|
||||
);
|
||||
// Villager.customServerAiStep after a trade: entity event 14.
|
||||
self.villager_particles(id, "happy_villager");
|
||||
self.mob_event_particles(id, 14);
|
||||
}
|
||||
/// `Villager.getPlayerReputation` of the local player.
|
||||
pub(super) fn villager_reputation(&self, id: u32) -> i32 {
|
||||
|
||||
@@ -2,11 +2,53 @@
|
||||
//! its boss bar, and its skulls hitting and exploding.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
mobs::{Damage, DamageKind, Species, Who},
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `LivingEntity.createWitherRose`: a mob a wither killed leaves a wither
|
||||
/// rose where it stood (with mobGriefing, on air that can hold it), else
|
||||
/// drops one.
|
||||
pub(super) fn create_wither_rose(&mut self, m: &horizon_client::mobs::Mob) {
|
||||
// getKillCredit: the player who last hurt it, else the last mob (kept
|
||||
// for 100 ticks).
|
||||
let by_wither = m.ai.last_hurt_by_player <= 0
|
||||
&& m.age as i32 - m.ai.last_hurt_by_time <= 100
|
||||
&& matches!(m.ai.last_hurt_by, Some(Who::Mob(id))
|
||||
if self.mobs.iter().any(|o| o.id == id && o.alive() && o.species() == Species::Wither));
|
||||
if !by_wither {
|
||||
return;
|
||||
}
|
||||
let pos = [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
];
|
||||
let registry = self.pack.assets.registry;
|
||||
let rose = registry.default_state("minecraft:wither_rose");
|
||||
if self.ui.rules.bool("mob_griefing")
|
||||
&& let Some(rose) = rose
|
||||
&& self
|
||||
.res
|
||||
.facts()
|
||||
.is_air(self.world.block(pos[0], pos[1], pos[2]))
|
||||
&& self.with_level(|b, level, ticks| {
|
||||
b.can_survive(level, ticks, BlockPos::new(pos[0], pos[1], pos[2]), rose)
|
||||
})
|
||||
{
|
||||
let _ = self.level_set(pos, rose);
|
||||
return;
|
||||
}
|
||||
self.spawn_item(
|
||||
ItemStack::new("minecraft:wither_rose", 1),
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
super::dispenser::new_item_motion(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
fn is_wither_skull(&self, p: [i32; 3]) -> bool {
|
||||
let name = &*self
|
||||
.res
|
||||
@@ -130,7 +172,10 @@ impl Game<'_> {
|
||||
if let Some(id) = owner {
|
||||
self.note_attacker("minecraft:explosion", id);
|
||||
}
|
||||
self.explode(at, 1.0)
|
||||
self.combat.explosion_owner = owner;
|
||||
let result = self.with_hive_emptying_blast(|g| g.explode(at, 1.0));
|
||||
self.combat.explosion_owner = None;
|
||||
result
|
||||
}
|
||||
}
|
||||
/// Wither skulls drawn as the skull model, blue when dangerous.
|
||||
|
||||
@@ -511,10 +511,10 @@ pub struct Renderer {
|
||||
occlusion: occlusion::Occlusion,
|
||||
/// Resolution scale and FXAA (Horizon settings).
|
||||
world_post: world_post::Post,
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is scRGB.
|
||||
hdr: Option<hdr::Hdr>,
|
||||
/// The HDR output the window's surface can present.
|
||||
hdr_output: Option<hdr::Output>,
|
||||
/// Whether the window's surface can present scRGB.
|
||||
hdr_capable: bool,
|
||||
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
|
||||
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
|
||||
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
|
||||
@@ -1071,7 +1071,7 @@ impl Renderer {
|
||||
let info = adapter.get_info();
|
||||
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
|
||||
let size = window.inner_size();
|
||||
let (config, present_modes, hdr_output) = if let Some(surface) = &surface {
|
||||
let (config, present_modes, hdr_capable) = if let Some(surface) = &surface {
|
||||
let mut config = surface
|
||||
.get_default_config(&adapter, size.width.max(1), size.height.max(1))
|
||||
.ok_or("surface has no supported configuration")?;
|
||||
@@ -1099,7 +1099,7 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
surface.configure(&device, &config);
|
||||
(config, caps.present_modes.clone(), hdr::output(&caps))
|
||||
(config, caps.present_modes.clone(), hdr::supported(&caps))
|
||||
} else {
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
@@ -1113,7 +1113,7 @@ impl Renderer {
|
||||
view_formats: vec![format],
|
||||
color_space: wgpu::SurfaceColorSpace::default(),
|
||||
};
|
||||
(config, Vec::new(), None)
|
||||
(config, Vec::new(), false)
|
||||
};
|
||||
let offscreen = surface
|
||||
.is_none()
|
||||
@@ -1953,23 +1953,22 @@ impl Renderer {
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &entity_shader,
|
||||
entry_point: Some("fs_sky_celestial"),
|
||||
entry_point: Some("fs_celestial"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
// RenderPipelines.CELESTIAL: additive (src alpha, one), with
|
||||
// the colour premultiplied in fs_sky_celestial. Its alpha is
|
||||
// how much a texel adds: HDR output (hdr.wgsl) lets the sun's
|
||||
// and moon's discs shine; nothing else reads the frame's alpha.
|
||||
// RenderPipelines.CELESTIAL: additive (src alpha, one). The
|
||||
// alpha goes to 0: HDR output (hdr.wgsl) lets the sun and
|
||||
// moon shine; nothing else reads the frame's alpha.
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||
dst_factor: wgpu::BlendFactor::Zero,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
}),
|
||||
@@ -2457,7 +2456,7 @@ impl Renderer {
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
world_post,
|
||||
hdr: None,
|
||||
hdr_output,
|
||||
hdr_capable,
|
||||
pack_post: None,
|
||||
native: None,
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
@@ -2991,24 +2990,24 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
/// The swapchain's configuration: `config` (what every pipeline draws in), or
|
||||
/// HDR10 or scRGB while HDR output is on.
|
||||
/// scRGB while HDR output is on.
|
||||
fn surface_config(&self) -> wgpu::SurfaceConfiguration {
|
||||
let mut config = self.config.clone();
|
||||
if let Some(hdr) = &self.hdr {
|
||||
config.format = hdr.output.format();
|
||||
config.view_formats = vec![hdr.output.format()];
|
||||
config.color_space = hdr.output.color_space();
|
||||
if self.hdr.is_some() {
|
||||
config.format = hdr::FORMAT;
|
||||
config.view_formats = vec![hdr::FORMAT];
|
||||
config.color_space = hdr::COLOR_SPACE;
|
||||
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
|
||||
}
|
||||
config
|
||||
}
|
||||
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
|
||||
/// HDR output (Horizon setting): scRGB at `paper_white` nits, the world's
|
||||
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
|
||||
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32) {
|
||||
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
|
||||
if output.is_some() != self.hdr.is_some() {
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
|
||||
let on = on && self.hdr_capable && self.surface.is_some();
|
||||
if on != self.hdr.is_some() {
|
||||
self.hdr =
|
||||
on.then(|| hdr::Hdr::new(&self.device, self.config.format.remove_srgb_suffix()));
|
||||
self.configure_surface();
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr
|
||||
|
||||
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user