mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 22:43:47 -10:00
Compare commits
237
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bb7be3765 | ||
|
|
4af2c671c7 | ||
|
|
e92c9ea743 | ||
|
|
9635fbb0a9 | ||
|
|
b409d5f624 | ||
|
|
9502716a07 | ||
|
|
723ab2aa50 | ||
|
|
4d5ee970d7 | ||
|
|
b4db58ab8e | ||
|
|
3833886df3 | ||
|
|
104ef68ee6 | ||
|
|
31ebe6db6c | ||
|
|
9e12caaabc | ||
|
|
23aa18322c | ||
|
|
617665a176 | ||
|
|
2036f0f517 | ||
|
|
f770f15616 | ||
|
|
0b95d39d86 | ||
|
|
3de1a56f1f | ||
|
|
9dc425d357 | ||
|
|
b16ab9e8c4 | ||
|
|
b2edfb2c5b | ||
|
|
644cca2a10 | ||
|
|
07dd22212e | ||
|
|
2d3b53a785 | ||
|
|
1e3c9c9ef7 | ||
|
|
d930105423 | ||
|
|
a6b61e9a3c | ||
|
|
80751276f3 | ||
|
|
23743968e1 | ||
|
|
6af60956ec | ||
|
|
8c1fa65f35 | ||
|
|
bc1549a438 | ||
|
|
b6f18dc8f3 | ||
|
|
504c9daf20 | ||
|
|
5a6bf9705c | ||
|
|
8f0797f258 | ||
|
|
8ecf5d6e47 | ||
|
|
c1ebf5d1b0 | ||
|
|
9ad735b9d7 | ||
|
|
6f19f06634 | ||
|
|
83ee5c303b | ||
|
|
c79b5be2e4 | ||
|
|
89225ec081 | ||
|
|
7b11005688 | ||
|
|
40f8b9600d | ||
|
|
6044c6ff88 | ||
|
|
ab099b2701 | ||
|
|
6cb5e4954e | ||
|
|
d74d39cb05 | ||
|
|
2ba33bd146 | ||
|
|
4f6e4e72b1 | ||
|
|
73aef0ce8e | ||
|
|
91e332a5eb | ||
|
|
a8fe843daa | ||
|
|
37d31da543 | ||
|
|
c5165a98cf | ||
|
|
e1f7ad93ee | ||
|
|
7731b4274a | ||
|
|
7df621fdb5 | ||
|
|
39c8a2aa0e | ||
|
|
21fd3a6b06 | ||
|
|
3eb3d1fc5f | ||
|
|
3052cc41db | ||
|
|
8beaa57331 | ||
|
|
43f5388038 | ||
|
|
b3709b9766 | ||
|
|
08492a3007 | ||
|
|
3d0ef4da75 | ||
|
|
b2199f5a0e | ||
|
|
dfb28e42d0 | ||
|
|
a2f62b1545 | ||
|
|
f1f34edb80 | ||
|
|
cb20bb508b | ||
|
|
4168590221 | ||
|
|
9bc8e12ad1 | ||
|
|
2a7b79dfb6 | ||
|
|
f1f587e7c2 | ||
|
|
51447edda4 | ||
|
|
4018fa9559 | ||
|
|
7115c708e1 | ||
|
|
95e7cd69dd | ||
|
|
5a6ae76ec3 | ||
|
|
4a523b5ce8 | ||
|
|
eae5b13fc6 | ||
|
|
1ad1e957a3 | ||
|
|
b5f1e65162 | ||
|
|
39910efabf | ||
|
|
b0103bd62f | ||
|
|
44eb7008f1 | ||
|
|
179e410923 | ||
|
|
b0a6e63ce7 | ||
|
|
4517d75a65 | ||
|
|
4b042eae28 | ||
|
|
30c6a4ac7b | ||
|
|
ce73764585 | ||
|
|
59d0b45349 | ||
|
|
7373ee8da5 | ||
|
|
a6af0d647f | ||
|
|
8b87d128e6 | ||
|
|
8a2c7eee4a | ||
|
|
8a572c9239 | ||
|
|
6ea9f9580c | ||
|
|
e9bcd10869 | ||
|
|
3bd504c6c4 | ||
|
|
a0343bceab | ||
|
|
25ad1db6d3 | ||
|
|
65cc4c6f6b | ||
|
|
8892f0fabf | ||
|
|
d6fc0f6e43 | ||
|
|
82c8398c6b | ||
|
|
517cbe76bd | ||
|
|
236371d06d | ||
|
|
06c2371360 | ||
|
|
b3a1f66c35 | ||
|
|
cca3b735cc | ||
|
|
e338690bf9 | ||
|
|
0ab24d0805 | ||
|
|
dfa907d28e | ||
|
|
d9dc1c866b | ||
|
|
ec8d5f1e06 | ||
|
|
cde33a8e42 | ||
|
|
0ccf3e110b | ||
|
|
b8b353c92e | ||
|
|
e28bcba3d3 | ||
|
|
4482ff63f3 | ||
|
|
a9e5214ae4 | ||
|
|
2a7420651e | ||
|
|
d601715126 | ||
|
|
9cb2779608 | ||
|
|
e70ead5bfe | ||
|
|
aa6fdac501 | ||
|
|
cac63dedca | ||
|
|
9ae37fcb50 | ||
|
|
b39c4b8dd4 | ||
|
|
bad95e4f6a | ||
|
|
3c257bb3e6 | ||
|
|
2ef155348f | ||
|
|
8866e618b5 | ||
|
|
7732ce986f | ||
|
|
e5c3d7cff7 | ||
|
|
ddaa514f3a | ||
|
|
b03d1411d1 | ||
|
|
42ef865c34 | ||
|
|
e8891849a4 | ||
|
|
ee0af20e16 | ||
|
|
b1450f1ef2 | ||
|
|
64cd55cf74 | ||
|
|
5b5ebfcce3 | ||
|
|
a24a6fa26c | ||
|
|
faeff96cf9 | ||
|
|
a55c825467 | ||
|
|
6c716f457b | ||
|
|
3f0e70882c | ||
|
|
54ae097faa | ||
|
|
1b0937abff | ||
|
|
34f57dbcac | ||
|
|
83b544f04f | ||
|
|
1258c2473b | ||
|
|
3bb6e40304 | ||
|
|
eab8d0f911 | ||
|
|
6d9bb375c5 | ||
|
|
d7fece5825 | ||
|
|
bab4251715 | ||
|
|
9eb913affe | ||
|
|
c808fae288 | ||
|
|
65c60ab996 | ||
|
|
abb4e7b5cd | ||
|
|
f4a235e027 | ||
|
|
3755bb7d77 | ||
|
|
94748162bc | ||
|
|
06b046d8fc | ||
|
|
c85f6f2e39 | ||
|
|
36c06d6133 | ||
|
|
ffc343aadd | ||
|
|
4cf9ffd847 | ||
|
|
e23fcb1040 | ||
|
|
27b81d9a8f | ||
|
|
7b3595b8f2 | ||
|
|
ae3737b1c2 | ||
|
|
c417556834 | ||
|
|
bf62ccdabc | ||
|
|
41d8b9e48a | ||
|
|
0e658e3e62 | ||
|
|
041ecb7b7a | ||
|
|
41ca489d4e | ||
|
|
82d2b61a4a | ||
|
|
f9b8b40a7d | ||
|
|
08cd05ed6e | ||
|
|
f312aae7db | ||
|
|
591b7b74da | ||
|
|
d78238f765 | ||
|
|
303281ed09 | ||
|
|
18f8a6868b | ||
|
|
43d1f4b801 | ||
|
|
a08f3eb2fa | ||
|
|
96b538d48f | ||
|
|
29eae3a1af | ||
|
|
4efa49ae82 | ||
|
|
85db9a23a9 | ||
|
|
00cb813dc8 | ||
|
|
9e848670db | ||
|
|
dbd24e2e16 | ||
|
|
5a70691d63 | ||
|
|
e7e6e5bc07 | ||
|
|
4ebfaef0d3 | ||
|
|
7112d0cd64 | ||
|
|
818b377c02 | ||
|
|
766bfbcdfa | ||
|
|
0c127ec1d9 | ||
|
|
10c9d8440c | ||
|
|
7f5e3384b3 | ||
|
|
76d748caf8 | ||
|
|
2c8b933be4 | ||
|
|
f661443fa9 | ||
|
|
442d52a038 | ||
|
|
004ad993d9 | ||
|
|
42a57b8286 | ||
|
|
de141bb7cb | ||
|
|
8526305faa | ||
|
|
b37528f5d6 | ||
|
|
2a98f584df | ||
|
|
7f6d556e68 | ||
|
|
d33f547bf7 | ||
|
|
cfd2dc8392 | ||
|
|
6f4c2cfbbc | ||
|
|
2e7283d01c | ||
|
|
9e71837df9 | ||
|
|
9a7f955a77 | ||
|
|
6b7617c277 | ||
|
|
7c5efff307 | ||
|
|
1f6e498238 | ||
|
|
32f6079134 | ||
|
|
ce1db1b0c3 | ||
|
|
ad3ddeb43f | ||
|
|
34f4d3b451 | ||
|
|
27de895fdb |
No files matched your search
@@ -72,6 +72,9 @@ 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
|
||||
|
||||
@@ -10,7 +10,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
|
||||
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
|
||||
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
|
||||
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
|
||||
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
|
||||
@@ -65,6 +66,7 @@ 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
+122
-2
@@ -477,6 +477,13 @@ 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"
|
||||
@@ -930,6 +937,13 @@ 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"
|
||||
@@ -1093,6 +1107,30 @@ 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"
|
||||
@@ -1257,6 +1295,16 @@ 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"
|
||||
@@ -1461,6 +1509,16 @@ 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"
|
||||
@@ -2737,6 +2795,21 @@ 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"
|
||||
@@ -2864,6 +2937,15 @@ 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"
|
||||
@@ -2964,6 +3046,27 @@ 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"
|
||||
@@ -3443,6 +3546,12 @@ 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"
|
||||
@@ -3560,6 +3669,17 @@ 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"
|
||||
@@ -5221,7 +5341,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5262,7 +5382,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -30,15 +30,18 @@ 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] [--name "Name"] [--sdk-path <dir>]`:
|
||||
//! a mod from a template: a Rust crate, or a content mod (no code).
|
||||
//! - `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.
|
||||
//! - `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,6 +26,8 @@ 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>`.
|
||||
@@ -42,7 +44,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] | 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|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()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -83,18 +85,26 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
})
|
||||
.collect();
|
||||
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
|
||||
let sdk = match flag(args, "--sdk-path") {
|
||||
let (sdk, lua) = match flag(args, "--sdk-path") {
|
||||
Some(p) => {
|
||||
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
|
||||
format!("{{ path = {:?} }}", p.display().to_string())
|
||||
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()
|
||||
),
|
||||
)
|
||||
}
|
||||
None => SDK_GIT.to_string(),
|
||||
None => (SDK_GIT.to_string(), LUA_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")),
|
||||
@@ -146,10 +156,19 @@ 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,
|
||||
other => return Err(format!("no template {other}: code or content")),
|
||||
"lua" => &lua_files,
|
||||
other => return Err(format!("no template {other}: code, content or lua")),
|
||||
};
|
||||
for &(file, text) in files {
|
||||
let file = fill(file);
|
||||
@@ -215,6 +234,13 @@ 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",
|
||||
@@ -386,6 +412,26 @@ 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()));
|
||||
@@ -424,7 +470,7 @@ mod tests {
|
||||
new(&[
|
||||
dir.join("x").display().to_string(),
|
||||
"--template".into(),
|
||||
"lua".into()
|
||||
"python".into()
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[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
|
||||
@@ -0,0 +1,12 @@
|
||||
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"]
|
||||
@@ -0,0 +1,12 @@
|
||||
-- {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
|
||||
@@ -0,0 +1,2 @@
|
||||
// The mod is main.lua: this builds it into the mod with its interpreter.
|
||||
horizon_mod_lua::lua_mod!("main.lua");
|
||||
@@ -163,6 +163,8 @@ pub fn main() -> Result<()> {
|
||||
ArrowSound::HitGround => "hit",
|
||||
ArrowSound::Splash => "splash",
|
||||
ArrowSound::Extinguish => "extinguish",
|
||||
ArrowSound::TridentHitGround => "trident_hit_ground",
|
||||
ArrowSound::TridentHit => "trident_hit",
|
||||
},
|
||||
volume.to_bits(),
|
||||
pitch.to_bits()
|
||||
|
||||
@@ -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: u8| {
|
||||
format!(
|
||||
"minecraft:{}",
|
||||
let named = |reg: &finalize::VariantRegistry, i: Option<u8>| {
|
||||
let name = i.map_or("classic", |i| {
|
||||
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 {
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
//!
|
||||
//! 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};
|
||||
@@ -93,8 +97,23 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
_ => BookType::Smoker,
|
||||
}
|
||||
.index();
|
||||
book.open[i] = op["open"].as_bool().unwrap_or(false);
|
||||
book.filtering[i] = op["filtering"].as_bool().unwrap_or(false);
|
||||
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;
|
||||
}
|
||||
"save" => {
|
||||
let tag = book.write();
|
||||
|
||||
@@ -35,8 +35,9 @@ enum Animate {
|
||||
Campfire {
|
||||
spawn_particles: bool,
|
||||
},
|
||||
/// Leaves: optional falling-leaf particle and chance, tinted by foliage.
|
||||
Leaves(Option<(T, f32)>),
|
||||
/// Leaves: optional falling-leaf particle and chance; a tinted leaf takes
|
||||
/// the biome foliage colour unless a fixed colour is given (azalea).
|
||||
Leaves(Option<(T, f32)>, Option<u32>),
|
||||
NetherPortal,
|
||||
EnchantingTable,
|
||||
EndRod([i32; 3]),
|
||||
@@ -49,6 +50,8 @@ enum Animate {
|
||||
EnderChest,
|
||||
/// FallingBlock with its dust colour.
|
||||
Falling(u32),
|
||||
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
|
||||
Beehive,
|
||||
RedstoneOre,
|
||||
RedstoneWire {
|
||||
power: u8,
|
||||
@@ -104,10 +107,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
spawn_particles: false,
|
||||
},
|
||||
"oak_leaves" | "birch_leaves" | "jungle_leaves" | "acacia_leaves" | "dark_oak_leaves"
|
||||
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01))),
|
||||
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1))),
|
||||
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02))),
|
||||
"spruce_leaves" | "azalea_leaves" | "flowering_azalea_leaves" => Animate::Leaves(None),
|
||||
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None),
|
||||
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None),
|
||||
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None),
|
||||
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None),
|
||||
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None),
|
||||
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None),
|
||||
// The azalea blocks drop tinted leaves of the fixed colour -9399763.
|
||||
"azalea_leaves" | "flowering_azalea_leaves" => {
|
||||
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D))
|
||||
}
|
||||
"spruce_leaves" => Animate::Leaves(None, None),
|
||||
"nether_portal" => Animate::NetherPortal,
|
||||
"enchanting_table" => Animate::EnchantingTable,
|
||||
"end_rod" => Animate::EndRod(facing()),
|
||||
@@ -119,6 +129,14 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
"sand" => Animate::Falling(0xDBD3A0),
|
||||
"red_sand" => Animate::Falling(0xA95821),
|
||||
"gravel" => Animate::Falling(0x807C7B),
|
||||
// AnvilBlock/ConcretePowderBlock.getDustColor is the map colour;
|
||||
// DragonEggBlock's is black.
|
||||
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7),
|
||||
"dragon_egg" => Animate::Falling(0x000000),
|
||||
_ if name.ends_with("_concrete_powder") => Animate::Falling(concrete_powder_color(
|
||||
name.trim_end_matches("_concrete_powder"),
|
||||
)),
|
||||
"beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive,
|
||||
"redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre,
|
||||
"redstone_wire" => {
|
||||
let power = prop("power").and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||
@@ -161,6 +179,27 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
_ => Animate::None,
|
||||
}
|
||||
}
|
||||
/// `DyeColor.getMapColor` of a concrete powder colour.
|
||||
fn concrete_powder_color(dye: &str) -> u32 {
|
||||
match dye {
|
||||
"white" => 0xFFFFFF,
|
||||
"orange" => 0xD87F33,
|
||||
"magenta" => 0xB24CD8,
|
||||
"light_blue" => 0x6699D8,
|
||||
"yellow" => 0xE5E533,
|
||||
"lime" => 0x7FCC19,
|
||||
"pink" => 0xF27FA5,
|
||||
"gray" => 0x4C4C4C,
|
||||
"light_gray" => 0x999999,
|
||||
"cyan" => 0x4C7F99,
|
||||
"purple" => 0x7F3FB2,
|
||||
"blue" => 0x334CB2,
|
||||
"brown" => 0x664C33,
|
||||
"green" => 0x667F33,
|
||||
"red" => 0x993333,
|
||||
_ => 0x191919,
|
||||
}
|
||||
}
|
||||
/// `RedstoneWireBlock.COLORS`.
|
||||
fn redstone_color(power: u8) -> [f32; 3] {
|
||||
let f = f32::from(power) / 15.0;
|
||||
@@ -504,6 +543,53 @@ fn try_spawn_drip(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `BeehiveBlock.trySpawnDripParticles`.
|
||||
fn beehive_drip(
|
||||
tables: &AnimateTables,
|
||||
engine: &mut ParticleEngine,
|
||||
ctx: &AnimateContext,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
) {
|
||||
if ctx.facts.get(state).fluid != Fluid::None || engine.random().next_float() < 0.3 {
|
||||
return;
|
||||
}
|
||||
let Some((bottom, top, bounds)) = ctx.shape_y(pos) else {
|
||||
return;
|
||||
};
|
||||
if top < 1.0
|
||||
|| tables
|
||||
.impermeable
|
||||
.get(state.0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let y = if bottom > 0.0 {
|
||||
f64::from(pos[1]) + bottom - 0.05
|
||||
} else {
|
||||
let below = [pos[0], pos[1] - 1, pos[2]];
|
||||
let below_top = ctx.shape_y(below).map_or(1.0, |s| s.1);
|
||||
// `belowTop < 1 || !isCollisionShapeFullBlock` and no fluid below.
|
||||
if (below_top < 1.0 || !ctx.sturdy(below))
|
||||
&& ctx.facts.get(ctx.state(below)).fluid == Fluid::None
|
||||
{
|
||||
f64::from(pos[1]) - 0.05
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let r = engine.random();
|
||||
let (a, b) = (r.next_double(), r.next_double());
|
||||
let (fx, fz) = (f64::from(pos[0]), f64::from(pos[2]));
|
||||
let p = [
|
||||
fx + bounds[0] + (bounds[1] - bounds[0]) * a,
|
||||
y,
|
||||
fz + bounds[2] + (bounds[3] - bounds[2]) * b,
|
||||
];
|
||||
add(engine, T::DrippingHoney, p, [0.0; 3]);
|
||||
}
|
||||
/// `WaterFluid.animateTick` / `LavaFluid.animateTick`.
|
||||
fn fluid_animate_tick(
|
||||
engine: &mut ParticleEngine,
|
||||
@@ -685,7 +771,7 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::Leaves(falling) => {
|
||||
Animate::Leaves(falling, fixed_tint) => {
|
||||
let below = [pos[0], pos[1] - 1, pos[2]];
|
||||
// LeavesBlock.makeDrippingWaterParticles.
|
||||
if (ctx.raining_at)([pos[0], pos[1] + 1, pos[2]]) && next_int(engine.random(), 15) == 1
|
||||
@@ -705,7 +791,7 @@ fn block_animate_tick(
|
||||
let r = engine.random();
|
||||
let p = [fx + r.next_double(), fy - 0.05, fz + r.next_double()];
|
||||
let options = if ty == T::TintedLeaves {
|
||||
let c = ctx.foliage(pos);
|
||||
let c = fixed_tint.unwrap_or_else(|| ctx.foliage(pos));
|
||||
let f = |s: u32| ((c >> s) & 255) as f32 / 255.0;
|
||||
ParticleOptions::colored(ty, [f(16), f(8), f(0), 1.0])
|
||||
} else {
|
||||
@@ -977,6 +1063,13 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::Beehive => {
|
||||
// BeehiveBlock.animateTick: nextInt(1) + 1 attempts.
|
||||
let attempts = next_int(engine.random(), 1) + 1;
|
||||
for _ in 0..attempts {
|
||||
beehive_drip(tables, engine, ctx, state, pos);
|
||||
}
|
||||
}
|
||||
Animate::RedstoneOre => {
|
||||
for d in [
|
||||
[0, -1, 0],
|
||||
@@ -1209,21 +1302,32 @@ fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
|
||||
}
|
||||
add(engine, T::SmallFlame, p, [0.0; 3]);
|
||||
}
|
||||
/// `CampfireBlockEntity.particleTick` for every lit campfire (no cooking items).
|
||||
/// `CampfireBlockEntity.particleTick` for every lit campfire; `cooking` says
|
||||
/// which of a campfire's four slots hold an item.
|
||||
pub fn campfire_particles<'a>(
|
||||
tables: &AnimateTables,
|
||||
engine: &mut ParticleEngine,
|
||||
ctx: &AnimateContext,
|
||||
blocks: impl Iterator<Item = &'a ([i32; 3], u32)>,
|
||||
cooking: &dyn Fn([i32; 3]) -> [bool; 4],
|
||||
) {
|
||||
for &(pos, state) in blocks {
|
||||
if !tables.is_lit_campfire(state) {
|
||||
continue;
|
||||
}
|
||||
let signal = ctx
|
||||
let properties = ctx
|
||||
.registry
|
||||
.state(BlockStateId(state))
|
||||
.is_some_and(|s| s.properties.get("signal_fire").map(String::as_str) == Some("true"));
|
||||
.map(|s| &s.properties);
|
||||
let prop = |k: &str| properties.and_then(|p| p.get(k)).map(String::as_str);
|
||||
let signal = prop("signal_fire") == Some("true");
|
||||
// Direction.get2DDataValue: south, west, north, east.
|
||||
let rotation = match prop("facing") {
|
||||
Some("west") => 1,
|
||||
Some("north") => 2,
|
||||
Some("east") => 3,
|
||||
_ => 0,
|
||||
};
|
||||
let r = engine.random();
|
||||
if r.next_float() < 0.11 {
|
||||
let count = next_int(r, 2) + 2;
|
||||
@@ -1231,6 +1335,28 @@ pub fn campfire_particles<'a>(
|
||||
make_campfire_smoke(engine, pos, signal, false);
|
||||
}
|
||||
}
|
||||
// The smoke over each cooking item, at the slot's corner of the fire.
|
||||
let occupied = cooking(pos);
|
||||
for (slot, _) in occupied.iter().enumerate().filter(|(_, held)| **held) {
|
||||
if engine.random().next_float() >= 0.2 {
|
||||
continue;
|
||||
}
|
||||
// Direction.from2DDataValue((slot + rotation) % 4) and its clockwise side.
|
||||
let (step, clockwise) = match (slot + rotation) % 4 {
|
||||
0 => ([0.0, 1.0], [-1.0, 0.0]),
|
||||
1 => ([-1.0, 0.0], [0.0, -1.0]),
|
||||
2 => ([0.0, -1.0], [1.0, 0.0]),
|
||||
_ => ([1.0, 0.0], [0.0, 1.0]),
|
||||
};
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5 - step[0] * 0.3125 + clockwise[0] * 0.3125,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5 - step[1] * 0.3125 + clockwise[1] * 0.3125,
|
||||
];
|
||||
for _ in 0..4 {
|
||||
add(engine, T::Smoke, at, [0.0, 5.0e-4, 0.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
collisions::SceneCollisions,
|
||||
mobs::Who,
|
||||
motion::{FluidResources, LAVA, WATER},
|
||||
projectiles::{Hit, Projectile},
|
||||
projectiles::{Hit, Projectile, ProjectileKind},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -132,6 +132,10 @@ pub enum ArrowSound {
|
||||
HitGround,
|
||||
/// `entity.generic.extinguish_fire`.
|
||||
Extinguish,
|
||||
/// A thrown trident's `getDefaultHitGroundSoundEvent` (`item.trident.hit_ground`).
|
||||
TridentHitGround,
|
||||
/// `ThrownTrident.onHitEntity`'s `item.trident.hit`.
|
||||
TridentHit,
|
||||
}
|
||||
/// What a burning arrow does to the block it hits (`onProjectileHit`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -169,6 +173,9 @@ pub struct ArrowState {
|
||||
pub was_eye_in_water: bool,
|
||||
pub first_tick: bool,
|
||||
pub has_been_shot: bool,
|
||||
/// `ThrownTrident.dealtDamage`: it has hit an entity (or sat in a block
|
||||
/// for over 4 ticks) and hits no more.
|
||||
pub dealt_damage: bool,
|
||||
pub removed: bool,
|
||||
/// The arrow's own random (`Entity.random`).
|
||||
pub random: LegacyRandom,
|
||||
@@ -211,6 +218,7 @@ impl ArrowState {
|
||||
was_eye_in_water: false,
|
||||
first_tick: true,
|
||||
has_been_shot: false,
|
||||
dealt_damage: false,
|
||||
removed: false,
|
||||
random,
|
||||
events: Vec::new(),
|
||||
@@ -451,12 +459,14 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity
|
||||
let Some(mut st) = p.arrow.take() else {
|
||||
return false;
|
||||
};
|
||||
let trident = matches!(p.kind, ProjectileKind::Trident);
|
||||
let mut ticker = Ticker {
|
||||
p,
|
||||
st: &mut st,
|
||||
scene,
|
||||
};
|
||||
match outcome {
|
||||
EntityHit::Hurt | EntityHit::Refused if trident => ticker.trident_bounce(),
|
||||
EntityHit::Hurt => {
|
||||
ticker.hit_sound();
|
||||
// A piercing arrow flies on (`getPierceLevel() <= 0` discards).
|
||||
@@ -501,7 +511,16 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
p.z,
|
||||
))
|
||||
}
|
||||
/// Whether this is a `ThrownTrident` (`AbstractArrow` with its own water
|
||||
/// inertia, sounds and hit).
|
||||
fn trident(&self) -> bool {
|
||||
matches!(self.p.kind, ProjectileKind::Trident)
|
||||
}
|
||||
fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit {
|
||||
// ThrownTrident.tick.
|
||||
if self.trident() && self.st.in_ground_time > 4 {
|
||||
self.st.dealt_damage = true;
|
||||
}
|
||||
let physics = !self.st.no_physics;
|
||||
let movement = self.p.velocity;
|
||||
let block_pos = self.block_position();
|
||||
@@ -549,8 +568,9 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.st.in_ground_time = 0;
|
||||
let original = self.position();
|
||||
if self.st.was_touching_water {
|
||||
// getWaterInertia, then the bubbles behind it.
|
||||
self.p.velocity = self.p.velocity.scale(f64::from(0.6f32));
|
||||
// getWaterInertia (0.99 for a trident), then the bubbles behind it.
|
||||
let inertia = if self.trident() { 0.99f32 } else { 0.6f32 };
|
||||
self.p.velocity = self.p.velocity.scale(f64::from(inertia));
|
||||
let v = self.p.velocity;
|
||||
for _ in 0..4 {
|
||||
self.st.events.push(ArrowEvent::Particle {
|
||||
@@ -687,6 +707,8 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
.expand_towards(self.p.velocity)
|
||||
.inflate(Vec3::new(1.0, 1.0, 1.0));
|
||||
let mut best: Option<(Who, Vec3, f64)> = None;
|
||||
// ThrownTrident.findHitEntity: none once it has dealt damage.
|
||||
let targets = if self.st.dealt_damage { &[] } else { targets };
|
||||
for (who, b) in targets {
|
||||
if Some(*who) == self.p.owner && !self.p.left_owner || !b.intersects(area) {
|
||||
continue;
|
||||
@@ -757,12 +779,33 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
|
||||
fn hit_sound(&mut self) {
|
||||
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
|
||||
let sound = if self.trident() {
|
||||
ArrowSound::TridentHitGround
|
||||
} else {
|
||||
ArrowSound::HitGround
|
||||
};
|
||||
self.st.events.push(ArrowEvent::Sound {
|
||||
sound: ArrowSound::HitGround,
|
||||
sound,
|
||||
volume: 1.0,
|
||||
pitch,
|
||||
});
|
||||
}
|
||||
/// `ThrownTrident.onHitEntity` after the hurt, whether it landed or not:
|
||||
/// the hit sound, then `deflect(REVERSE, ..., (0.02, 0.2, 0.02))`. The
|
||||
/// trident keeps flying (it has dealt its damage) and falls.
|
||||
fn trident_bounce(&mut self) {
|
||||
self.st.dealt_damage = true;
|
||||
self.st.events.push(ArrowEvent::Sound {
|
||||
sound: ArrowSound::TridentHit,
|
||||
volume: 1.0,
|
||||
pitch: 1.0,
|
||||
});
|
||||
// ProjectileDeflection.REVERSE.
|
||||
let rotation = 170.0 + self.st.random.next_float() * 20.0;
|
||||
let v = self.p.velocity;
|
||||
self.p.velocity = Vec3::new(v.x * (-0.02 * 0.5), v.y * (-0.2 * 0.5), v.z * (-0.02 * 0.5));
|
||||
self.p.yaw += rotation;
|
||||
}
|
||||
/// `onHitEntity` when the entity took no damage: `deflect(REVERSE, 0.2)`,
|
||||
/// a half turn give or take ten degrees at a tenth of the speed; one
|
||||
/// left almost still drops its pickup (ALLOWED) and is discarded.
|
||||
|
||||
@@ -90,7 +90,7 @@ pub const MAP_COLORS: [u32; 62] = [
|
||||
14200723, 8365974,
|
||||
];
|
||||
/// The 12 edges of the unit cube (outline data predating the export).
|
||||
fn cube_edges() -> Vec<[f64; 6]> {
|
||||
pub fn cube_edges() -> Vec<[f64; 6]> {
|
||||
let mut edges = Vec::new();
|
||||
for a in 0..3 {
|
||||
for (u, v) in [(0., 0.), (1., 0.), (0., 1.), (1., 1.)] {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! `BundleContents.Mutable`: what a bundle takes in and gives back, by
|
||||
//! weight. The menus (`menu`, `lan::menu`) run `BundleItem`'s click
|
||||
//! overrides on it.
|
||||
use crate::item_data::BundleSelected;
|
||||
use crate::items::{ItemStack, Items};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
@@ -97,6 +98,8 @@ pub fn can_be_in_bundle(stack: &ItemStack) -> bool {
|
||||
pub struct Contents {
|
||||
items: Vec<ItemStack>,
|
||||
weight: Weight,
|
||||
/// `selectedItem`: an index into `items`, -1 for none.
|
||||
selected: i32,
|
||||
}
|
||||
|
||||
impl Contents {
|
||||
@@ -104,9 +107,11 @@ impl Contents {
|
||||
pub fn of(items: &Items, stack: &ItemStack) -> Option<Self> {
|
||||
let inside = contents_of(items, stack)?.to_vec();
|
||||
let weight = contents_weight(items, &inside);
|
||||
let selected = stack.data().map_or(-1, |d| d.bundle_selected.0);
|
||||
Some(Self {
|
||||
items: inside,
|
||||
weight,
|
||||
selected,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,18 +159,26 @@ impl Contents {
|
||||
amount
|
||||
}
|
||||
|
||||
/// `removeOne`: the first stack (Horizon keeps no selected item).
|
||||
/// `removeOne`: the selected stack, else the first; nothing is selected after.
|
||||
pub fn remove_one(&mut self, items: &Items) -> Option<ItemStack> {
|
||||
if self.items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let stack = self.items.remove(0);
|
||||
let at =
|
||||
usize::try_from(self.selected).map_or(0, |i| if i < self.items.len() { i } else { 0 });
|
||||
let stack = self.items.remove(at);
|
||||
self.weight = self
|
||||
.weight
|
||||
.sub(item_weight(items, &stack).times(stack.count));
|
||||
self.selected = -1;
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// `getSelectedItemIndex`.
|
||||
pub fn selected(&self) -> i32 {
|
||||
self.selected
|
||||
}
|
||||
|
||||
/// The stacks inside, in display order (the first is the next out).
|
||||
pub fn items(&self) -> &[ItemStack] {
|
||||
&self.items
|
||||
@@ -200,11 +213,28 @@ impl Contents {
|
||||
|
||||
/// `toImmutable`, written back into the bundle's component.
|
||||
pub fn store(self, bundle: &mut ItemStack) {
|
||||
bundle.data_mut().bundle_contents = self.items;
|
||||
let data = bundle.data_mut();
|
||||
data.bundle_contents = self.items;
|
||||
data.bundle_selected = BundleSelected(self.selected);
|
||||
bundle.tidy();
|
||||
}
|
||||
}
|
||||
|
||||
/// `BundleItem.toggleSelectedItem`: select `index`, or nothing when it is the
|
||||
/// selected one already or outside the contents.
|
||||
pub fn toggle_selected(bundle: &mut ItemStack, index: i32) {
|
||||
let Some(data) = bundle.extra.as_deref_mut() else {
|
||||
return;
|
||||
};
|
||||
let inside = (0..data.bundle_contents.len() as i32).contains(&index);
|
||||
let selected = if data.bundle_selected.0 != index && inside {
|
||||
index
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
data.bundle_selected = BundleSelected(selected);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Weight;
|
||||
@@ -232,8 +262,10 @@ pub struct TooltipLayout {
|
||||
/// The component's own width (96) and height, the text's excluded.
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
/// Each slot's top-left and the stack in it, in reading order.
|
||||
/// Each slot's top-left and the stack in it, in drawing order.
|
||||
pub slots: Vec<([f32; 2], ItemStack)>,
|
||||
/// The position in `slots` of the selected item, which is drawn highlighted.
|
||||
pub selected: Option<usize>,
|
||||
/// The `+N` of the items past the grid: its top-left and the count.
|
||||
pub overflow: Option<([f32; 2], u32)>,
|
||||
/// The progress bar's top-left, its fill's width out of 94, and whether
|
||||
@@ -266,6 +298,7 @@ pub fn tooltip_layout(
|
||||
width: 96.0,
|
||||
height: description_height + 13.0 + 8.0,
|
||||
slots: Vec::new(),
|
||||
selected: None,
|
||||
overflow: None,
|
||||
bar: [x + offset, y + description_height + 4.0],
|
||||
fill: 0.0,
|
||||
@@ -282,6 +315,7 @@ pub fn tooltip_layout(
|
||||
let x_start = x + offset + 96.0;
|
||||
let y_start = y + grid_height;
|
||||
let mut slots = Vec::with_capacity(shown.len());
|
||||
let mut selected = None;
|
||||
let mut overflow = None;
|
||||
// Rows fill bottom-up and columns right-to-left, as vanilla draws them.
|
||||
let mut slot = 0;
|
||||
@@ -290,8 +324,13 @@ pub fn tooltip_layout(
|
||||
let at = [x_start - column as f32 * 24.0, y_start - row as f32 * 24.0];
|
||||
if hidden > 0 && row == 1 && column == 1 {
|
||||
overflow = Some((at, hidden));
|
||||
} else if let Some(stack) = shown.get(slot) {
|
||||
slots.push((at, stack.clone()));
|
||||
} else if slot < shown.len() {
|
||||
// `extractSlot`: the cell of slot number n holds item `size - n`.
|
||||
let index = shown.len() - 1 - slot;
|
||||
if usize::try_from(contents.selected()) == Ok(index) {
|
||||
selected = Some(slots.len());
|
||||
}
|
||||
slots.push((at, shown[index].clone()));
|
||||
slot += 1;
|
||||
}
|
||||
}
|
||||
@@ -301,6 +340,7 @@ pub fn tooltip_layout(
|
||||
width: 96.0,
|
||||
height: grid_height + 13.0 + 8.0,
|
||||
slots,
|
||||
selected,
|
||||
overflow,
|
||||
bar: [x + offset, y + grid_height + 4.0],
|
||||
// `Mth.mulAndTruncate(weight, 94)`, kept within the bar.
|
||||
@@ -349,8 +389,8 @@ mod tooltip_tests {
|
||||
assert_eq!(layout.slots.len(), 12);
|
||||
assert_eq!(layout.overflow, None);
|
||||
assert_eq!(layout.height, 3.0 * 24.0 + 13.0 + 8.0);
|
||||
// Rows fill bottom-up and columns right-to-left: the first item is in
|
||||
// the bottom-right cell, the twelfth in the top-left.
|
||||
// Rows fill bottom-up and columns right-to-left: the twelfth item is in
|
||||
// the bottom-right cell, the first in the top-left.
|
||||
assert_eq!(layout.slots[0].0, [96.0 - 24.0, 3.0 * 24.0 - 24.0]);
|
||||
assert_eq!(&*layout.slots[0].1.item, "minecraft:stone");
|
||||
assert_eq!(layout.slots[11].0, [0.0, 0.0]);
|
||||
|
||||
@@ -76,14 +76,11 @@ impl<'a> IntoIterator for &'a ChunkMap {
|
||||
}
|
||||
}
|
||||
impl crate::light::LightWorld for ChunkMap {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
self.columns.state(x, y, z)
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<crate::chunk::Section>> {
|
||||
self.columns.section(cx, s, cz)
|
||||
}
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +103,13 @@ mod tests {
|
||||
for column in map.values_mut() {
|
||||
Arc::make_mut(column).light_loaded = true;
|
||||
}
|
||||
crate::light::LightWorld::set_light(&mut map, 0, 0, 0, 0);
|
||||
crate::light::LightWorld::set_section_light(
|
||||
&mut map,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
crate::chunk::Light::Uniform(0),
|
||||
);
|
||||
assert_eq!(inserted, map.membership_revision());
|
||||
map.remove(&(0, 0));
|
||||
map.insert((1, 0), chunk);
|
||||
|
||||
@@ -545,6 +545,32 @@ impl Enchantments {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `EnchantmentHelper.forEachModifier` as an item's tooltip lists them: the
|
||||
/// `minecraft:attributes` effects of `stack`'s enchantments as rows of
|
||||
/// (attribute, id, amount, operation, slot group), one per slot group the
|
||||
/// enchantment lists.
|
||||
pub fn modifier_rows(&self, stack: &ItemStack) -> Vec<(String, String, f64, String, String)> {
|
||||
let mut rows = Vec::new();
|
||||
for (id, level) in stack.enchantments() {
|
||||
let Some(e) = self.get(id) else { continue };
|
||||
for a in e.effects["minecraft:attributes"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
for slot in &e.slots {
|
||||
rows.push((
|
||||
a["attribute"].as_str().unwrap_or("").to_owned(),
|
||||
a["id"].as_str().unwrap_or("").to_owned(),
|
||||
f64::from(level_value(&a["amount"], *level)),
|
||||
a["operation"].as_str().unwrap_or("add_value").to_owned(),
|
||||
slot.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
/// Sum of `add_value` amounts of one attribute from enchantments on `stack`.
|
||||
pub fn attribute_add(&self, stack: Option<&ItemStack>, attribute: &str) -> f64 {
|
||||
stack.map_or(0.0, |s| {
|
||||
|
||||
@@ -219,9 +219,22 @@ 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> {
|
||||
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
return color;
|
||||
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))));
|
||||
}
|
||||
|
||||
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
|
||||
|
||||
@@ -244,7 +244,51 @@ 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);
|
||||
@@ -260,10 +304,6 @@ 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 {
|
||||
@@ -273,10 +313,6 @@ 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.
|
||||
@@ -311,6 +347,10 @@ 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()));
|
||||
}
|
||||
@@ -800,6 +840,12 @@ 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") {
|
||||
@@ -935,20 +981,45 @@ 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;
|
||||
@@ -959,14 +1030,6 @@ 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(),
|
||||
@@ -2567,6 +2630,123 @@ 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)]
|
||||
|
||||
@@ -18,6 +18,7 @@ mod boats;
|
||||
mod books;
|
||||
mod bossbars;
|
||||
mod breezes;
|
||||
mod bundles;
|
||||
mod campfire;
|
||||
mod campfire_douse;
|
||||
mod cartography;
|
||||
@@ -156,6 +157,7 @@ mod scoreboard;
|
||||
mod screens;
|
||||
mod sculk;
|
||||
mod sculk_fixture;
|
||||
mod server_links;
|
||||
mod server_mods;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
@@ -507,6 +509,12 @@ 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
|
||||
@@ -1275,6 +1283,8 @@ struct Game<'a> {
|
||||
inv_clicks: horizon_client::container_screen::Clicks,
|
||||
/// The Mouse Tweaks extra's drag and wheel (`tweaks`).
|
||||
tweaks: tweaks::Tweaks,
|
||||
/// The wheel and hovered slot of `BundleMouseActions`.
|
||||
bundle_mouse: bundles::BundleMouse,
|
||||
/// The last inventory click, for the double click that gathers a stack.
|
||||
last_inv_click: Option<(std::time::Instant, usize, bool)>,
|
||||
/// `lastQuickMoved`: what the last Shift click moved, which a Shift
|
||||
@@ -1442,6 +1452,10 @@ struct Game<'a> {
|
||||
titles: titles::Titles,
|
||||
/// `Hud.toolHighlightTimer`: the selected item name.
|
||||
tool_highlight: horizon_client::hud_overlays::ToolHighlight,
|
||||
/// `Hud.autosaveIndicatorValue`.
|
||||
saving_indicator: horizon_client::hud_overlays::SavingIndicator,
|
||||
/// This game's world is autosaving this tick (`MinecraftServer.isSaving`).
|
||||
autosaving: bool,
|
||||
applied_modifiers: Vec<(String, String)>,
|
||||
experience: Experience,
|
||||
orbs: Vec<ExperienceOrb>,
|
||||
@@ -1668,12 +1682,13 @@ 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)>,
|
||||
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
|
||||
/// Successful crits (attacking guest, hit entity), queued while the hub is taken.
|
||||
lan_critical_hits: Vec<(Option<u32>, i32)>,
|
||||
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
|
||||
/// Global level events for every guest (`ServerLevel.globalLevelEvent`).
|
||||
lan_global_events: Vec<(i32, [i32; 3], i32)>,
|
||||
/// `ContainerOpenersCounter`: how many players (the host and the guests)
|
||||
@@ -1691,6 +1706,9 @@ 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.
|
||||
@@ -1725,6 +1743,8 @@ 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).
|
||||
@@ -1821,9 +1841,46 @@ impl Game<'_> {
|
||||
fn reach(&self) -> f64 {
|
||||
if self.creative { 5.0 } else { 4.5 }
|
||||
}
|
||||
/// Outline shapes that depend on more than the block state, as one box
|
||||
/// in block space: `LightBlock` and `ScaffoldingBlock.getShape` are a full
|
||||
/// block while the main hand holds the block's own item
|
||||
/// (`CollisionContext.isHoldingItem`), and `ShulkerBoxBlock.getShape`
|
||||
/// grows with the lid (`ShulkerBoxBlockEntity.getBoundingBox`).
|
||||
fn context_outline(&self, pos: [i32; 3], state: BlockStateId) -> Option<[f64; 6]> {
|
||||
let name = &self.res.facts().get(state).name;
|
||||
if matches!(&**name, "minecraft:light" | "minecraft:scaffolding") {
|
||||
return self
|
||||
.held()
|
||||
.is_some_and(|s| *s.item == **name)
|
||||
.then_some([0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
if !name.ends_with("shulker_box") {
|
||||
return None;
|
||||
}
|
||||
let progress = self.be_anims.shulkers.get(&pos).map_or(0.0, |s| s.progress);
|
||||
let facing = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("facing").cloned());
|
||||
let d = facing_offset(facing.as_deref().unwrap_or("up"));
|
||||
let mut b = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
|
||||
for a in 0..3 {
|
||||
match d[a] {
|
||||
1 => b[a + 3] += 0.5 * f64::from(progress),
|
||||
-1 => b[a] -= 0.5 * f64::from(progress),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(b)
|
||||
}
|
||||
/// A block's outline boxes in world space (`getShape` plus the random offset).
|
||||
fn outline_boxes(&self, pos: [i32; 3]) -> Vec<[f64; 6]> {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if let Some(b) = self.context_outline(pos, state) {
|
||||
return vec![std::array::from_fn(|i| f64::from(pos[i % 3]) + b[i])];
|
||||
}
|
||||
let f = self.res.facts().get(state);
|
||||
let o = if f.outline_offset {
|
||||
self.pack
|
||||
@@ -2118,6 +2175,33 @@ impl Game<'_> {
|
||||
}
|
||||
/// `TerrainParticle` at `pos`: the model's particle sprite and tint source
|
||||
/// 0's `colorAsTerrainParticle` (white when the block has none).
|
||||
/// `HoneyBlock.showSlideParticles` (entity event 53): five honey block
|
||||
/// particles at the entity.
|
||||
fn honey_slide_particles(&mut self, at: [f64; 3]) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let Some(honey) = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:honey_block")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let block = [
|
||||
at[0].floor() as i32,
|
||||
at[1].floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
];
|
||||
if let Some((sprite, tint)) = self.particle_sprite(honey, block) {
|
||||
for _ in 0..5 {
|
||||
self.particles.add(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite.clone(), tint),
|
||||
at,
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn particle_sprite(&self, state: BlockStateId, pos: [i32; 3]) -> Option<(String, u32)> {
|
||||
let sprite = Self::block_particle_sprite(&self.pack, state)?;
|
||||
let chunks = &self.world.chunks;
|
||||
@@ -2335,11 +2419,18 @@ impl Game<'_> {
|
||||
yaw.cos() * pitch.cos() * 0.3 + spread.sin() * strength,
|
||||
];
|
||||
self.spawn_item(thrown, [eye[0], eye[1] - 0.3, eye[2]], v, 40);
|
||||
if thrown_from_hand && self.lan_acting.is_none() {
|
||||
let uuid = self.level.ensure_player_uuid();
|
||||
if thrown_from_hand {
|
||||
let uuid = self.acting_player_uuid();
|
||||
self.dropped.last_mut().unwrap().thrower = Some(uuid);
|
||||
}
|
||||
}
|
||||
/// The UUID of the player acting: the guest whose turn it is, else the host.
|
||||
pub(super) fn acting_player_uuid(&mut self) -> [i32; 4] {
|
||||
match self.lan_acting_uuid {
|
||||
Some(guest) => horizon_client::lan::playerdata::uuid_ints(guest),
|
||||
None => self.level.ensure_player_uuid(),
|
||||
}
|
||||
}
|
||||
fn tick_items(&mut self) {
|
||||
let mut items = std::mem::take(&mut self.dropped);
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
@@ -2912,6 +3003,16 @@ impl Game<'_> {
|
||||
data,
|
||||
} => self.sculk_charge_event(pos, data),
|
||||
LevelEvent::LevelEvent { id: 3007, pos, .. } => self.shriek_event(pos),
|
||||
// LevelEventHandler 2019 and 2020: the crack on a face of the
|
||||
// block being mined, with its hit sound for 2020.
|
||||
LevelEvent::LevelEvent {
|
||||
id: id @ (2019 | 2020),
|
||||
pos,
|
||||
data,
|
||||
} => {
|
||||
let face = horizon_core::geometry::Direction::ALL[data.clamp(0, 5) as usize];
|
||||
self.breaking_block_effects([pos.x, pos.y, pos.z], face.normal(), id == 2020);
|
||||
}
|
||||
LevelEvent::LevelEvent { id: 2004, pos, .. } => {
|
||||
self.spawner_burst([pos.x, pos.y, pos.z])
|
||||
}
|
||||
@@ -2931,6 +3032,11 @@ 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,
|
||||
@@ -2944,6 +3050,17 @@ impl Game<'_> {
|
||||
pos,
|
||||
data,
|
||||
} => self.smash_dust(pos, data),
|
||||
// LevelEventHandler 2002 and 2007: a potion's splash.
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2002,
|
||||
pos,
|
||||
data,
|
||||
} => self.potion_splash_particles(pos, data, false),
|
||||
LevelEvent::LevelEvent {
|
||||
id: 2007,
|
||||
pos,
|
||||
data,
|
||||
} => self.potion_splash_particles(pos, data, true),
|
||||
LevelEvent::LevelEvent {
|
||||
id: id @ 3002..=3005,
|
||||
pos,
|
||||
@@ -2979,6 +3096,8 @@ impl Game<'_> {
|
||||
1044 => Some(("minecraft:block.smithing_table.use", 1.0)),
|
||||
// LevelEvent.SOUND_PAGE_TURN (a lectern's page).
|
||||
1043 => Some(("minecraft:item.book.page_turn", 1.0)),
|
||||
// LevelEvent.SOUND_SPLASH_POTION and its instant twin.
|
||||
1053 | 1054 => Some(("minecraft:entity.splash_potion.break", 1.0)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((name, volume)) = sound {
|
||||
@@ -2988,7 +3107,7 @@ impl Game<'_> {
|
||||
- self.world.random.next_float())
|
||||
* 0.8
|
||||
}
|
||||
1029 | 1030 | 1042 | 1043 | 1044 => {
|
||||
1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => {
|
||||
self.world.random.next_float() * 0.1 + 0.9
|
||||
}
|
||||
1019 | 1021 => {
|
||||
@@ -2998,7 +3117,7 @@ impl Game<'_> {
|
||||
}
|
||||
_ => 1.0,
|
||||
};
|
||||
if matches!(id, 1019 | 1021 | 1029 | 1030) {
|
||||
if matches!(id, 1019 | 1021 | 1029 | 1030 | 1053 | 1054) {
|
||||
// Clients play the anvil sound from its level event;
|
||||
// capturing it also sends a duplicate sound packet.
|
||||
self.audio.play_unshared(name, c, volume, pitch);
|
||||
@@ -3152,6 +3271,31 @@ impl Game<'_> {
|
||||
LevelEvent::BlockEntityEvent { pos, a, b, .. } => {
|
||||
self.block_entity_event([pos.x, pos.y, pos.z], a, b)
|
||||
}
|
||||
// NoteBlock.triggerEvent: a NOTE particle above, coloured by
|
||||
// the note (`note / 24.0` in the x speed).
|
||||
LevelEvent::BlockEvent {
|
||||
pos,
|
||||
state,
|
||||
kind: "note_particle",
|
||||
} => {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let note = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("note")?.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
self.particles.add(
|
||||
&ParticleOptions::simple(ParticleType::Note),
|
||||
[
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y) + 1.2,
|
||||
f64::from(pos.z) + 0.5,
|
||||
],
|
||||
[note / 24.0, 0.0, 0.0],
|
||||
);
|
||||
}
|
||||
LevelEvent::GrowTree { .. }
|
||||
| LevelEvent::PlaceFeature { .. }
|
||||
| LevelEvent::BlockEvent { .. } => {}
|
||||
@@ -3527,7 +3671,7 @@ impl Game<'_> {
|
||||
} else if let Some(tag) = spill::saved_entity(&self.world, pos) {
|
||||
self.pending.record_saved(pos, &tag);
|
||||
}
|
||||
self.creaking_heart_broken(pos);
|
||||
self.creaking_heart_broken(pos, true);
|
||||
let hive = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::Beehive(h)) => self.hive_state(pos).map(|s| ((**h).clone(), s)),
|
||||
_ => None,
|
||||
@@ -4086,6 +4230,7 @@ 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 {
|
||||
@@ -4107,6 +4252,7 @@ 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);
|
||||
@@ -4125,6 +4271,17 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `ServerPlayerGameMode.tick` while a player digs: level event 2020 every
|
||||
/// fourth tick since the START (the hit sound), else 2019 (the crack only),
|
||||
/// for everyone around, the digger included (`levelEvent(null, ...)`).
|
||||
fn destroy_progress_event(&mut self, pos: [i32; 3], face: [i32; 3], ticks: u32) {
|
||||
let id = if ticks.is_multiple_of(4) { 2020 } else { 2019 };
|
||||
let direction = horizon_core::geometry::Direction::ALL
|
||||
.iter()
|
||||
.position(|d| d.normal() == face)
|
||||
.unwrap_or(1);
|
||||
self.level_event_at(id, pos, direction as i32);
|
||||
}
|
||||
fn tick_mining(&mut self) -> Result<()> {
|
||||
if self.destroy_delay > 0 {
|
||||
self.destroy_delay -= 1;
|
||||
@@ -4168,6 +4325,11 @@ impl Game<'_> {
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let facts = self.res.facts().get(state);
|
||||
if facts.hardness < 0.0 {
|
||||
// The server still counts its destroy ticks on an unbreakable block.
|
||||
let m = self.mining.as_mut().ok_or("mining")?;
|
||||
m.ticks += 1;
|
||||
let (pos, ticks) = (m.pos, m.ticks);
|
||||
self.destroy_progress_event(pos, hit.face, ticks);
|
||||
return Ok(());
|
||||
}
|
||||
// Player.getDestroySpeed / BlockBehaviour.getDestroyProgress.
|
||||
@@ -4198,26 +4360,15 @@ impl Game<'_> {
|
||||
} else {
|
||||
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
|
||||
};
|
||||
let hit_sound = facts.sounds.clone();
|
||||
self.swing_arm();
|
||||
let m = self.mining.as_mut().ok_or("mining")?;
|
||||
m.progress += progress;
|
||||
m.ticks += 1;
|
||||
let crack = (m.pos, hit.face);
|
||||
if m.ticks % 4 == 1 {
|
||||
let c = [
|
||||
f64::from(m.pos[0]) + 0.5,
|
||||
f64::from(m.pos[1]) + 0.5,
|
||||
f64::from(m.pos[2]) + 0.5,
|
||||
];
|
||||
self.audio.play(
|
||||
&hit_sound.hit,
|
||||
Some(c),
|
||||
(hit_sound.volume + 1.0) / 8.0,
|
||||
hit_sound.pitch * 0.5,
|
||||
);
|
||||
// An instant break at START sends no destroy events.
|
||||
if m.progress < 1.0 || m.ticks > 1 {
|
||||
let (pos, ticks) = (m.pos, m.ticks);
|
||||
self.destroy_progress_event(pos, hit.face, ticks);
|
||||
}
|
||||
self.crack_particle(crack.0, crack.1, state);
|
||||
let m = self.mining.as_mut().ok_or("mining")?;
|
||||
// ServerPlayerGameMode breaks a player's block at once only when it is instant (START's
|
||||
// insta-mine); otherwise it waits for the client's STOP_DESTROY_BLOCK
|
||||
@@ -4319,6 +4470,12 @@ impl Game<'_> {
|
||||
// The world's spawn from the start, not only once saved: a bed set
|
||||
// before the first save must not become it.
|
||||
self.level.world_spawn.get_or_insert([x, y, z]);
|
||||
// The "Bonus Chest" create-world option, placed once with the spawn.
|
||||
if horizon_client::save::WorldGen::read(&self.world_root)
|
||||
.is_some_and(|g| g.bonus_chest)
|
||||
{
|
||||
self.place_bonus_chest([x, y, z])?;
|
||||
}
|
||||
}
|
||||
let s = self
|
||||
.level
|
||||
@@ -4422,7 +4579,11 @@ impl Game<'_> {
|
||||
self.world.weather.tick_levels();
|
||||
self.tick_weather_effects();
|
||||
}
|
||||
if self.ticks.is_multiple_of(6000) {
|
||||
self.autosaving = self.ticks.is_multiple_of(6000);
|
||||
if let Some(serving) = &self.serving {
|
||||
serving.set_saving(self.autosaving);
|
||||
}
|
||||
if self.autosaving {
|
||||
self.save_game_with(false)?;
|
||||
}
|
||||
if running {
|
||||
@@ -4633,9 +4794,32 @@ impl Game<'_> {
|
||||
};
|
||||
self.audio.play(name, Some(feet), volume, pitch);
|
||||
}
|
||||
// LivingEntity.checkFallDamage: the block's crumbs where the fall landed.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
|
||||
state,
|
||||
position,
|
||||
count,
|
||||
speed,
|
||||
}) => self.landing_dust(None, state, position, count, speed),
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
|
||||
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
|
||||
MotionEvent::HoneySlide {
|
||||
sound, particles, ..
|
||||
} => {
|
||||
if sound {
|
||||
self.audio
|
||||
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
|
||||
}
|
||||
if particles {
|
||||
self.honey_slide_particles(feet);
|
||||
// The server's event 53 reaches the guests too.
|
||||
let id = self.lan.as_ref().map(|h| h.host_entity);
|
||||
if let Some(id) = id {
|
||||
self.lan_entity_event(id, 53, feet);
|
||||
}
|
||||
}
|
||||
}
|
||||
MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos),
|
||||
MotionEvent::CauldronExtinguish { pos } => self.cauldron_extinguish(pos),
|
||||
MotionEvent::InsideWitherRose => self.wither_rose_contact(),
|
||||
@@ -4746,6 +4930,7 @@ impl Game<'_> {
|
||||
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
|
||||
self.cooldowns.tick();
|
||||
horizon_client::spans::time("tick_using", || self.tick_using());
|
||||
self.tick_bundle_hover();
|
||||
horizon_client::spans::time("tick_hud", || self.tick_hud());
|
||||
// The player's digging comes in with its packets, before the level
|
||||
// ticks; ServerLevel.tick then runs the scheduled and random block
|
||||
@@ -4948,6 +5133,9 @@ impl Game<'_> {
|
||||
| Screen::Advancements
|
||||
| Screen::WorldOptions
|
||||
| Screen::Friends
|
||||
| Screen::ServerLinks
|
||||
| Screen::ConfirmLink
|
||||
| Screen::DialogWarning
|
||||
| Screen::CommandBlock
|
||||
) || self.book.is_some()
|
||||
|| self.win_screen_pauses())
|
||||
@@ -5431,6 +5619,9 @@ 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);
|
||||
@@ -5443,6 +5634,7 @@ impl Game<'_> {
|
||||
Screen::Mod => self.draw_mod_screen(&mut batch),
|
||||
Screen::Playing | Screen::Options | Screen::Win => {}
|
||||
}
|
||||
self.draw_saving_indicator(&mut batch);
|
||||
self.draw_toasts(&mut batch);
|
||||
self.draw_pack_toasts(&mut batch);
|
||||
self.remote_hud(&mut batch);
|
||||
@@ -5943,6 +6135,12 @@ impl Game<'_> {
|
||||
/// Several clicks on one menu instance (a quick craft drag is three).
|
||||
fn menu_ops(&mut self, ops: &[(Option<usize>, i32, horizon_client::menu::Input)]) -> bool {
|
||||
use horizon_client::menu::Input;
|
||||
// BundleMouseActions.onSlotClicked: a Shift click or a swap clears the bundle's selection.
|
||||
for &(slot, _, input) in ops {
|
||||
if let (Some(slot), Input::QuickMove | Input::Swap) = (slot, input) {
|
||||
self.unselect_bundle(slot);
|
||||
}
|
||||
}
|
||||
if self.remote.is_some() && self.screen == Screen::Inventory {
|
||||
// On a server the clicks are the server's: predicted and sent.
|
||||
return self.remote_menu_ops(ops);
|
||||
@@ -7268,7 +7466,12 @@ impl Game<'_> {
|
||||
self.see_through_vertices = see_through;
|
||||
// Entity shadows (EntityRenderer.extractShadow).
|
||||
self.mark_translucent_entity(out.len(), "minecraft:entity_shadow");
|
||||
if let Some(sprite) = self.gui.atlas.sprites.get("misc/shadow").copied() {
|
||||
let shadow_sprite = self
|
||||
.settings
|
||||
.entity_shadows
|
||||
.then(|| self.gui.atlas.sprites.get("misc/shadow").copied())
|
||||
.flatten();
|
||||
if let Some(sprite) = shadow_sprite {
|
||||
let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 };
|
||||
let darken = self.sky_darken();
|
||||
let a = alpha;
|
||||
@@ -7667,7 +7870,7 @@ impl Game<'_> {
|
||||
/// strength and whether it is a critical hit): the weapon and effects
|
||||
/// scaled by the attack strength, a mace's smash, the critical hit, then
|
||||
/// enchantments.
|
||||
pub(super) fn melee_damage(&mut self, target_kind: &str) -> (f32, f32, bool) {
|
||||
pub(super) fn melee_damage(&mut self, target_kind: &str) -> (f32, f32, bool, bool) {
|
||||
let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage);
|
||||
// Riptide's spin replaces the attack damage attribute.
|
||||
let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg);
|
||||
@@ -7695,8 +7898,9 @@ impl Game<'_> {
|
||||
amount *= 1.5;
|
||||
}
|
||||
// Enchantment damage (sharpness, smite, bane) scaled by the attack strength.
|
||||
amount += self.enchant_damage_bonus(target_kind) * scale;
|
||||
(amount, scale, crit)
|
||||
let magic_boost = self.enchant_damage_bonus(target_kind) * scale;
|
||||
amount += magic_boost;
|
||||
(amount, scale, crit, magic_boost > 0.0)
|
||||
}
|
||||
/// `Player.getAttackStrengthScale(0.5)`.
|
||||
fn attack_strength_scale(&self) -> f32 {
|
||||
@@ -7883,7 +8087,7 @@ impl Game<'_> {
|
||||
pub(super) fn attack_mob(&mut self, index: usize) {
|
||||
let target_kind = self.mobs[index].kind.id.clone();
|
||||
let target_kind = format!("minecraft:{}", target_kind.trim_start_matches("minecraft:"));
|
||||
let (amount, scale, crit) = self.melee_damage(&target_kind);
|
||||
let (amount, scale, crit, magic) = self.melee_damage(&target_kind);
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
let (knockback, knockback_attack) = self.extra_knockback(&target_kind, scale);
|
||||
self.attack_ticks = 0;
|
||||
@@ -7946,6 +8150,18 @@ impl Game<'_> {
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
false,
|
||||
);
|
||||
}
|
||||
// Player.attackVisualEffects: magicCrit when the enchantments added damage.
|
||||
if magic {
|
||||
self.critical_hit(
|
||||
self.lan_acting,
|
||||
self.mobs[index].id as i32,
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if actual > 2.0 {
|
||||
@@ -10798,6 +11014,17 @@ impl Game<'_> {
|
||||
[0.0; 3]
|
||||
};
|
||||
let p = h.pos.map(f64::from);
|
||||
if let Some(b) = self.context_outline(h.pos, state) {
|
||||
return horizon_client::blocks::cube_edges()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
std::array::from_fn(|i| {
|
||||
let k = i % 3;
|
||||
p[k] + b[k] + e[i] * (b[k + 3] - b[k])
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
f.outline_edges
|
||||
.iter()
|
||||
.map(|e| std::array::from_fn(|i| p[i % 3] + o[i % 3] + e[i]))
|
||||
@@ -11189,6 +11416,10 @@ 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(),
|
||||
@@ -11257,8 +11488,10 @@ 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() {
|
||||
self.remote_swap_hands();
|
||||
if !self.spectator()
|
||||
&& let Some(r) = self.remote.as_mut()
|
||||
{
|
||||
r.pending_keys.push(key);
|
||||
}
|
||||
}
|
||||
KeyCode::KeyF if self.screen == Screen::Playing => {
|
||||
@@ -11355,11 +11588,28 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// LOD Distance as far terrain uses it (`Graphics::lod_distance_on`).
|
||||
/// 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`).
|
||||
fn lod_distance(&self) -> u32 {
|
||||
let (distance, _) = self.far_terrain_choice();
|
||||
match &self.renderer {
|
||||
Some(renderer) => self.settings.horizon.lod_distance_on(&renderer.info),
|
||||
None => self.settings.horizon.lod_distance.min(1024),
|
||||
Some(renderer) => {
|
||||
horizon_client::graphics::lod_distance_capped(distance, &renderer.info)
|
||||
}
|
||||
None => distance.min(1024),
|
||||
}
|
||||
}
|
||||
/// Only enabled LOD worlds extend the renderer's range, never simulation.
|
||||
@@ -11380,7 +11630,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn prepare_lod(&mut self) {
|
||||
let enabled = self.settings.horizon.lod_distance > 0
|
||||
let enabled = self.far_terrain_choice().0 > 0
|
||||
&& self.renderer.is_some()
|
||||
&& self.player.is_some()
|
||||
&& self.title.is_none()
|
||||
@@ -11477,6 +11727,7 @@ 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;
|
||||
@@ -11530,12 +11781,10 @@ 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 = self.settings.horizon.lod_distance_on(&renderer.info);
|
||||
let lod_distance =
|
||||
horizon_client::graphics::lod_distance_capped(far_distance, &renderer.info);
|
||||
lod.set_near_loading(self.streaming.busy());
|
||||
lod.set_reduced_detail(
|
||||
self.settings.horizon.far_detail
|
||||
== horizon_client::graphics::FarDetail::Reduced,
|
||||
);
|
||||
lod.set_reduced_detail(far_reduced);
|
||||
// The covered columns change only with the complete ones and
|
||||
// the render area: rebuilt then, not every frame.
|
||||
let at = (
|
||||
@@ -11858,6 +12107,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
if self.screen == Screen::Inventory
|
||||
&& d != 0.0
|
||||
&& !self.bundle_scroll(scaled(raw_x), scaled(raw_y))
|
||||
&& !self.tweak_scroll(d, self.inventory_target().0)
|
||||
{
|
||||
self.station_scroll(d);
|
||||
@@ -11868,6 +12118,9 @@ 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);
|
||||
}
|
||||
@@ -12059,6 +12312,15 @@ 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()
|
||||
}
|
||||
@@ -12647,6 +12909,7 @@ impl<'a> Game<'a> {
|
||||
craft_table: None,
|
||||
inv_drag: None,
|
||||
tweaks: Default::default(),
|
||||
bundle_mouse: Default::default(),
|
||||
inv_clicks: Default::default(),
|
||||
last_inv_click: None,
|
||||
last_quick_moved: None,
|
||||
@@ -12759,6 +13022,8 @@ impl<'a> Game<'a> {
|
||||
command_source: None,
|
||||
titles: Default::default(),
|
||||
tool_highlight: Default::default(),
|
||||
saving_indicator: Default::default(),
|
||||
autosaving: false,
|
||||
applied_modifiers: Vec::new(),
|
||||
experience: Experience::default(),
|
||||
orbs: Vec::new(),
|
||||
@@ -12903,6 +13168,7 @@ 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(),
|
||||
@@ -12911,6 +13177,7 @@ 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,
|
||||
@@ -12937,6 +13204,7 @@ 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,
|
||||
|
||||
@@ -419,6 +419,9 @@ 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",
|
||||
@@ -707,9 +710,12 @@ 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!(
|
||||
|
||||
@@ -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.settings.entity_distance,
|
||||
self.view_scale(),
|
||||
);
|
||||
for b in &self.boats {
|
||||
let entity_start = out.len();
|
||||
|
||||
@@ -1009,9 +1009,12 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let indicator = |batch: &mut HudBatch, page: usize, count: usize| {
|
||||
let text = tr("book.pageIndicator", "Page %s of %s")
|
||||
.replacen("%s", &(page + 1).to_string(), 1)
|
||||
.replacen("%s", &count.max(1).to_string(), 1);
|
||||
// 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 tw = HudBatch::text_width(atlas, &text);
|
||||
batch.text(atlas, &text, left + 148.0 - tw, top + 16.0, BLACK, false);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! `BundleMouseActions`: the wheel over a bundle in a container screen selects
|
||||
//! one of the items it shows, which a click that takes one out of it then takes
|
||||
//! (`BundleContents.Mutable.removeOne`). Moving off the bundle clears the
|
||||
//! selection. On a server the selection is sent to it (`ServerboundSelectBundleItemPacket`).
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
bundle::{Contents, toggle_selected},
|
||||
mouse::ScrollWheelHandler,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct BundleMouse {
|
||||
wheel: ScrollWheelHandler,
|
||||
/// The slot under the cursor at the last tick (`AbstractContainerScreen.hoveredSlot`).
|
||||
hovered: Option<usize>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A slot a bundle can be selected in: the player's inventory, the off hand and a
|
||||
/// container's own slots. A server only knows these in the inventory and chest-like menus.
|
||||
fn bundle_slot(&mut self, slot: usize) -> bool {
|
||||
(slot < 36 || slot == 504 || (300..500).contains(&slot))
|
||||
&& (self.remote.is_none() || self.remote_storage_open())
|
||||
&& (slot < 36 || slot == 504 || self.slots().contains(&slot))
|
||||
}
|
||||
|
||||
/// `BundleMouseActions.onMouseScrolled`: the wheel over a bundle moves the selection
|
||||
/// through the items its tooltip shows. True when the bundle took the wheel.
|
||||
pub(super) fn bundle_scroll(&mut self, x: f64, y: f64) -> bool {
|
||||
let Some(slot) = self.inventory_target().0 else {
|
||||
return false;
|
||||
};
|
||||
if !self.bundle_slot(slot) {
|
||||
return false;
|
||||
}
|
||||
let stack = self.slot_mut(slot).clone();
|
||||
let items = self.res.items();
|
||||
let Some(bundle) = stack.filter(|s| items.get(&s.item).is_some_and(|i| i.bundle)) else {
|
||||
return false;
|
||||
};
|
||||
let shown = Contents::of(items, &bundle).map_or(0, |c| c.shown_count());
|
||||
if shown == 0 {
|
||||
return false;
|
||||
}
|
||||
let (wheel_x, wheel_y) = self.bundle_mouse.wheel.on_mouse_scroll(x, y);
|
||||
let wheel = if wheel_y == 0 { -wheel_x } else { wheel_y };
|
||||
if wheel != 0 {
|
||||
let selected = bundle.data().map_or(-1, |d| d.bundle_selected.0);
|
||||
let next = ScrollWheelHandler::next_selection(f64::from(wheel), selected, shown as i32);
|
||||
if next != selected {
|
||||
self.select_bundle(slot, next);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `BundleMouseActions.toggleSelectedBundleItem`: the stack here and, on a server, there.
|
||||
fn select_bundle(&mut self, slot: usize, index: i32) {
|
||||
if let Some(stack) = self.slot_mut(slot) {
|
||||
toggle_selected(stack, index);
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
let (_, menu) = self.remote_menu();
|
||||
if let Some(menu_slot) = Self::menu_slot_index(&menu, slot) {
|
||||
self.send(S::SelectBundleItem(play::SelectBundleItem {
|
||||
slot: menu_slot as i32,
|
||||
index,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `BundleMouseActions.unselectedBundleItem` for the bundle in `slot`, if it has a selection.
|
||||
pub(super) fn unselect_bundle(&mut self, slot: usize) {
|
||||
if !self.bundle_slot(slot) {
|
||||
return;
|
||||
}
|
||||
let selected = self
|
||||
.slot_mut(slot)
|
||||
.as_ref()
|
||||
.and_then(|s| s.data())
|
||||
.is_some_and(|d| d.bundle_selected.0 >= 0);
|
||||
if selected {
|
||||
self.select_bundle(slot, -1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractContainerScreen.onStopHovering` (and `onClose`): leaving a slot, or the
|
||||
/// screen, clears the bundle's selection.
|
||||
pub(super) fn tick_bundle_hover(&mut self) {
|
||||
let now = if self.screen == Screen::Inventory {
|
||||
self.inventory_target().0
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(left) = std::mem::replace(&mut self.bundle_mouse.hovered, now)
|
||||
&& Some(left) != now
|
||||
{
|
||||
self.unselect_bundle(left);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,12 @@
|
||||
//! on the game that has the window, in-frame or following a server, never on
|
||||
//! a server: nothing of them goes over the network.
|
||||
use super::Game;
|
||||
use horizon_client::blocks::RenderShape;
|
||||
use horizon_client::mobs::{MobEvent, ambient};
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_client::motion::{LAVA, WATER};
|
||||
use horizon_client::particles::{ParticleEngine, ParticleOptions, ParticleType};
|
||||
use horizon_client::pose::Pose;
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_client_ambient(&mut self) {
|
||||
@@ -33,5 +37,209 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.eye_particles();
|
||||
self.sprint_dust();
|
||||
self.effect_swirls();
|
||||
}
|
||||
|
||||
/// `Entity.baseTick`'s `spawnSprintParticle` on a client: dust of the
|
||||
/// block under a sprinting player's feet streams back from them every
|
||||
/// tick, for the window's own player and for the other players it sees.
|
||||
fn sprint_dust(&mut self) {
|
||||
// Each runner's feet, movement and supporting block.
|
||||
let mut runners: Vec<(Vec3, Vec3, Option<[i32; 3]>)> = Vec::new();
|
||||
// Entity.canSpawnSprintParticle.
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& p.sprinting
|
||||
&& !p.spectator
|
||||
&& p.pose != Pose::Crouching
|
||||
&& !p.health.dead_or_dying()
|
||||
&& !p.fluids.was_touching_water
|
||||
&& !p.is_in_lava()
|
||||
&& p.fluid_height(WATER) <= 0.0
|
||||
&& p.fluid_height(LAVA) <= 0.0
|
||||
{
|
||||
let support = p.ground.supporting_block.map(|b| [b.x, b.y, b.z]);
|
||||
runners.push((p.position, p.velocity, support));
|
||||
}
|
||||
let guests = self.lan.iter().flat_map(|host| host.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
// Another player's fluids aren't known here: none where they stand.
|
||||
let feet = [
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
];
|
||||
if m.extra.sprinting
|
||||
&& !m.extra.crouching
|
||||
&& m.alive()
|
||||
&& &*m.kind.id == "minecraft:player"
|
||||
&& self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(feet[0], feet[1], feet[2]))
|
||||
.fluid_level
|
||||
== 0
|
||||
{
|
||||
runners.push((m.position, m.position.subtract(m.previous), None));
|
||||
}
|
||||
}
|
||||
for (position, movement, support) in runners {
|
||||
// Entity.getOnPosLegacy: getOnPos(0.2).
|
||||
let below = (position.y - f64::from(0.2f32)).floor() as i32;
|
||||
let tall = |game: &Self, s: [i32; 3]| {
|
||||
let name = game.block_name(s);
|
||||
[
|
||||
"minecraft:fences",
|
||||
"minecraft:walls",
|
||||
"minecraft:fence_gates",
|
||||
]
|
||||
.iter()
|
||||
.any(|tag| game.block_in_tag(tag, &name))
|
||||
};
|
||||
let at = match support {
|
||||
Some(s) if tall(self, s) => s,
|
||||
Some(s) => [s[0], below, s[2]],
|
||||
None => [position.x.floor() as i32, below, position.z.floor() as i32],
|
||||
};
|
||||
let state = self.world.block(at[0], at[1], at[2]);
|
||||
if self.res.facts().get(state).render_shape == RenderShape::Invisible {
|
||||
continue;
|
||||
}
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
|
||||
continue;
|
||||
};
|
||||
let width = f64::from(0.6f32);
|
||||
let r = self.particles.random();
|
||||
let mut x = position.x + (r.next_double() - 0.5) * width;
|
||||
let mut z = position.z + (r.next_double() - 0.5) * width;
|
||||
// Clamped into the block when the feet are in another column.
|
||||
if position.x.floor() as i32 != at[0] {
|
||||
x = x.clamp(f64::from(at[0]), f64::from(at[0]) + 1.0);
|
||||
}
|
||||
if position.z.floor() as i32 != at[2] {
|
||||
z = z.clamp(f64::from(at[2]), f64::from(at[2]) + 1.0);
|
||||
}
|
||||
self.particles.add(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite, tint),
|
||||
[x, position.y + 0.1, z],
|
||||
[movement.x * -4.0, 1.5, movement.z * -4.0],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.tickEffects` on a client: the swirls around an entity
|
||||
/// with visible effects, from the particles its synched data lists. The
|
||||
/// window's player has its own effects, a server's mobs and players the
|
||||
/// list the server sent, and the mobs and guests of a game in the frame
|
||||
/// theirs.
|
||||
fn effect_swirls(&mut self) {
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let effects = &self.potions.effects.list;
|
||||
let list: Vec<_> = effects
|
||||
.iter()
|
||||
.filter(|e| e.visible)
|
||||
.filter_map(|e| effect_particle(e.id, e.ambient))
|
||||
.collect();
|
||||
let ambient = effects.iter().filter(|e| e.visible).all(|e| e.ambient);
|
||||
let invisible = effects.iter().any(|e| e.id == "invisibility");
|
||||
let size = (
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
);
|
||||
swirl(
|
||||
&mut self.particles,
|
||||
p.position,
|
||||
size,
|
||||
&list,
|
||||
ambient,
|
||||
invisible,
|
||||
);
|
||||
}
|
||||
let guests = self.lan.iter().flat_map(|host| host.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
if !m.alive() {
|
||||
continue;
|
||||
}
|
||||
let size = (f64::from(m.width()), f64::from(m.height()));
|
||||
let invisible = m.invisible || m.has_effect("minecraft:invisibility");
|
||||
if m.extra.effect_particles.is_empty() {
|
||||
// A mob of this game: its own effects, visible and not ambient.
|
||||
if m.extra.effects.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let list: Vec<_> = m
|
||||
.extra
|
||||
.effects
|
||||
.iter()
|
||||
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
|
||||
.filter_map(|e| effect_particle(e.0, false))
|
||||
.collect();
|
||||
swirl(
|
||||
&mut self.particles,
|
||||
m.position,
|
||||
size,
|
||||
&list,
|
||||
false,
|
||||
invisible,
|
||||
);
|
||||
} else {
|
||||
let list = &m.extra.effect_particles;
|
||||
let ambient = m.extra.effect_ambient;
|
||||
swirl(
|
||||
&mut self.particles,
|
||||
m.position,
|
||||
size,
|
||||
list,
|
||||
ambient,
|
||||
invisible,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `MobEffect.particleFactory`: the omens have particles of their own, any
|
||||
/// other effect an entity effect in its colour, faint when ambient.
|
||||
pub(super) fn effect_particle(effect: &str, ambient: bool) -> Option<ParticleOptions> {
|
||||
Some(match effect.trim_start_matches("minecraft:") {
|
||||
"trial_omen" => ParticleOptions::simple(ParticleType::TrialOmen),
|
||||
"raid_omen" => ParticleOptions::simple(ParticleType::RaidOmen),
|
||||
other => {
|
||||
let color = horizon_client::brewing::effect(other)?.color;
|
||||
let channel = |shift: u32| ((color >> shift) & 255) as f32 / 255.0;
|
||||
// MobEffect.AMBIENT_ALPHA is 38 of 255.
|
||||
let alpha = if ambient { 38.0 / 255.0 } else { 1.0 };
|
||||
ParticleOptions::colored(
|
||||
ParticleType::EntityEffect,
|
||||
[channel(16), channel(8), channel(0), alpha],
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// One tick of `LivingEntity.tickEffects`' client branch: a one in four
|
||||
/// chance (one in fifteen while invisible, a fifth of either when all the
|
||||
/// effects are ambient) of one of the particles at a random spot of the box.
|
||||
fn swirl(
|
||||
particles: &mut ParticleEngine,
|
||||
feet: Vec3,
|
||||
size: (f64, f64),
|
||||
list: &[ParticleOptions],
|
||||
ambient: bool,
|
||||
invisible: bool,
|
||||
) {
|
||||
if list.is_empty() {
|
||||
return;
|
||||
}
|
||||
let bound = if invisible { 15 } else { 4 } * if ambient { 5 } else { 1 };
|
||||
let r = particles.random();
|
||||
let Ok(0) = r.next_int_bound(bound) else {
|
||||
return;
|
||||
};
|
||||
// Util.getRandom, then getRandomX(0.5), getRandomY and getRandomZ.
|
||||
let pick = r.next_int_bound(list.len() as i32).unwrap_or_default() as usize;
|
||||
let x = feet.x + size.0 * (2.0 * r.next_double() - 1.0) * 0.5;
|
||||
let y = feet.y + size.1 * r.next_double();
|
||||
let z = feet.z + size.0 * (2.0 * r.next_double() - 1.0) * 0.5;
|
||||
particles.add(&list[pick], [x, y, z], [1.0; 3]);
|
||||
}
|
||||
@@ -10,6 +10,9 @@ struct Entry {
|
||||
fall_distance: f32,
|
||||
/// The causing entity: its id (for comparisons) and display name.
|
||||
attacker: Option<(u32, Text)>,
|
||||
/// The direct entity's display name when no entity caused the damage
|
||||
/// (`DamageSource.directEntity`: a potion with no thrower, a cloud).
|
||||
direct: Option<Text>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct CombatTracker {
|
||||
@@ -19,6 +22,8 @@ pub(super) struct CombatTracker {
|
||||
/// Attackers of damage dealt this tick, claimed by the matching
|
||||
/// `RecordedDamage` event.
|
||||
pending: Vec<(String, (u32, Text))>,
|
||||
/// `pending` for a direct entity with no causing entity: only its name.
|
||||
pending_direct: Vec<(String, Text)>,
|
||||
/// `LivingEntity.lastHurtByMob` and its timestamp (the kill credit).
|
||||
last_hurt_by: Option<((u32, Text), i64)>,
|
||||
/// The mob the player last hurt (`getLastHurtMob`) and when.
|
||||
@@ -66,6 +71,14 @@ impl Game<'_> {
|
||||
.pending
|
||||
.push((damage_type.to_owned(), (id, name)));
|
||||
}
|
||||
/// The `DamageSource.directEntity` of the next `damage_type` damage when
|
||||
/// nothing caused it (a splash potion nobody threw): the death message
|
||||
/// names it instead (`getLocalizedDeathMessage`). It is no kill credit.
|
||||
pub(super) fn note_direct_entity(&mut self, damage_type: &str, name: Text) {
|
||||
self.combat
|
||||
.pending_direct
|
||||
.push((damage_type.to_owned(), name));
|
||||
}
|
||||
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
|
||||
/// source's `DamageEffects` (`Player.getHurtSound`).
|
||||
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
|
||||
@@ -94,6 +107,15 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.position(|(t, _)| *t == name)
|
||||
.map(|i| self.combat.pending.remove(i).1);
|
||||
let direct = match attacker {
|
||||
Some(_) => None,
|
||||
None => self
|
||||
.combat
|
||||
.pending_direct
|
||||
.iter()
|
||||
.position(|(t, _)| *t == name)
|
||||
.map(|i| self.combat.pending_direct.remove(i).1),
|
||||
};
|
||||
let (fall_location, fall_distance) = self.fall_location();
|
||||
let now = self.world.game_time;
|
||||
if let Some(a) = &attacker {
|
||||
@@ -111,10 +133,12 @@ impl Game<'_> {
|
||||
fall_location,
|
||||
fall_distance,
|
||||
attacker,
|
||||
direct,
|
||||
});
|
||||
self.combat.last_damage = now;
|
||||
}
|
||||
self.combat.pending.clear();
|
||||
self.combat.pending_direct.clear();
|
||||
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
|
||||
}
|
||||
/// `FallLocation.getCurrentFallLocation` and the fall distance so far.
|
||||
@@ -224,6 +248,7 @@ impl Game<'_> {
|
||||
let entries = std::mem::take(&mut self.combat.entries);
|
||||
let killing = entries.last().expect("checked above");
|
||||
let killer = killing.attacker.clone();
|
||||
let direct = killing.direct.clone();
|
||||
if def.1 == "fall_variants"
|
||||
&& let Some(knock) = knock.and_then(|i| entries.get(i))
|
||||
{
|
||||
@@ -248,15 +273,22 @@ impl Game<'_> {
|
||||
.append(Text::literal("]"));
|
||||
return Text::translate(&format!("{key}.message"), &[me, link]);
|
||||
}
|
||||
// `getLocalizedDeathMessage`: the causing entity's name, or the
|
||||
// direct entity's when nothing caused the damage.
|
||||
if let Some((_, name)) = killer {
|
||||
return Text::translate(&key, &[me, name]);
|
||||
}
|
||||
if let Some(name) = direct {
|
||||
return Text::translate(&key, &[me, name]);
|
||||
}
|
||||
// getKillCredit: the last mob that hurt the player in the past 100 ticks.
|
||||
if let Some(((_, name), at)) = &self.combat.last_hurt_by
|
||||
&& self.world.game_time - at <= 100
|
||||
{
|
||||
return Text::translate(&format!("{key}.player"), &[me, name.clone()]);
|
||||
}
|
||||
// No entity at all: the one-argument message, as vanilla sends it (a
|
||||
// template that names a killer then shows as written).
|
||||
Text::translate(&key, &[me])
|
||||
}
|
||||
}
|
||||
@@ -541,8 +541,7 @@ impl Game<'_> {
|
||||
.enumerate()
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|(_, p)| {
|
||||
p.arrow.is_some()
|
||||
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
|
||||
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
|
||||
})
|
||||
.map(|(i, _)| Target::Projectile(i)),
|
||||
);
|
||||
|
||||
@@ -91,7 +91,12 @@ impl Game<'_> {
|
||||
}
|
||||
Target::Mob(id) => match self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
Some(m) => {
|
||||
m.add_effect(namespaced(effect.id), duration, amplifier);
|
||||
let name = namespaced(effect.id);
|
||||
m.add_effect(name, duration, amplifier);
|
||||
m.extra.hidden_effects.retain(|h| *h != name);
|
||||
if !particles {
|
||||
m.extra.hidden_effects.push(name);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
@@ -154,6 +159,10 @@ impl Game<'_> {
|
||||
m.extra
|
||||
.effects
|
||||
.retain(|x| e.is_some_and(|e| x.0 != namespaced(e.id)));
|
||||
let left = &m.extra.effects;
|
||||
m.extra
|
||||
.hidden_effects
|
||||
.retain(|h| left.iter().any(|x| x.0 == *h));
|
||||
m.extra.effects.len() != before
|
||||
}
|
||||
None => false,
|
||||
|
||||
@@ -640,6 +640,72 @@ impl Game<'_> {
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
// Leashable.getLeashHolder: a fence knot is not an entity here.
|
||||
Some("leasher") => match entity {
|
||||
Target::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.and_then(|m| m.extra.leash.map(|holder| (holder, m.extra.leash_player)))
|
||||
.and_then(|(holder, player)| match holder {
|
||||
// The player who tied it (the host's when unrecorded).
|
||||
horizon_client::mobs::leash::Holder::Player => {
|
||||
let uuid = player.or(self.level.player_uuid)?;
|
||||
self.target_by_uuid(horizon_client::lan::playerdata::ints_uuid(uuid))
|
||||
}
|
||||
horizon_client::mobs::leash::Holder::Mob(i) => Some(Target::Mob(i)),
|
||||
horizon_client::mobs::leash::Holder::Knot(_) => None,
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
// TraceableEntity.getOwner: who shot a projectile, who threw an
|
||||
// item, who summoned a vex.
|
||||
Some("origin") => match entity {
|
||||
Target::Projectile(i) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
.and_then(|p| match (p.owner?, p.guest) {
|
||||
// A guest's shot: `Who::Player` is them.
|
||||
(horizon_client::mobs::Who::Player, Some(conn))
|
||||
if self.lan_acting == Some(conn) =>
|
||||
{
|
||||
Some(Target::Player)
|
||||
}
|
||||
(horizon_client::mobs::Who::Player, Some(conn)) => {
|
||||
Some(Target::Guest(conn))
|
||||
}
|
||||
(horizon_client::mobs::Who::Player, None) => Some(Target::Player),
|
||||
(horizon_client::mobs::Who::Mob(id), _) => Some(Target::Mob(id)),
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
Target::Item(id) => self
|
||||
.dropped
|
||||
.iter()
|
||||
.find(|d| d.id == id)
|
||||
.and_then(|d| d.thrower)
|
||||
.and_then(|words| {
|
||||
let uuid = words
|
||||
.iter()
|
||||
.fold(0u128, |all, w| all << 32 | u128::from(*w as u32));
|
||||
self.target_by_uuid(uuid)
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
Target::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && m.kind.id == "minecraft:vex")
|
||||
.map(|m| m.extra.counter2 as u32)
|
||||
.filter(|owner| *owner != 0)
|
||||
.map(Target::Mob)
|
||||
.into_iter()
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Some("vehicle") => self.ride_vehicle(entity).into_iter().collect(),
|
||||
Some("controller") | Some("passengers") => match entity {
|
||||
Target::Mob(id) if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) => {
|
||||
@@ -841,10 +907,12 @@ 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);
|
||||
return Ok(flag(
|
||||
dimension == self.dim
|
||||
&& p.is_some_and(|p| self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4))),
|
||||
));
|
||||
// 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)));
|
||||
}
|
||||
if c.has("dimension") {
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
|
||||
@@ -14,10 +14,11 @@ impl Game<'_> {
|
||||
serde_json::from_str(json).map_err(|e| err("argument.component.invalid", &[&e]))?;
|
||||
// NbtContents.resolve: the path's values in the entity, block or
|
||||
// storage data, rich-styled (or plain, or interpreted), ", "-joined.
|
||||
// ScoreContents.resolve: the holder's score in the objective.
|
||||
let nbt_values = std::cell::RefCell::new(Vec::new());
|
||||
{
|
||||
let mut collect = |o: &serde_json::Map<String, serde_json::Value>| {
|
||||
if o.contains_key("nbt") {
|
||||
if o.contains_key("nbt") || o.contains_key("score") {
|
||||
nbt_values.borrow_mut().push(o.clone());
|
||||
}
|
||||
};
|
||||
@@ -40,7 +41,11 @@ impl Game<'_> {
|
||||
}
|
||||
let mut resolved: Vec<(serde_json::Map<String, serde_json::Value>, Text)> = Vec::new();
|
||||
for o in nbt_values.into_inner() {
|
||||
let t = self.resolve_nbt_component(&o);
|
||||
let t = if o.contains_key("score") {
|
||||
self.resolve_score_component(&o)?
|
||||
} else {
|
||||
self.resolve_nbt_component(&o)
|
||||
};
|
||||
resolved.push((o, t));
|
||||
}
|
||||
let mut resolve = |raw: &str| -> Text {
|
||||
@@ -73,6 +78,54 @@ impl Game<'_> {
|
||||
},
|
||||
))
|
||||
}
|
||||
/// The text of a `score` component (ScoreContents.resolve): the named
|
||||
/// holder's score, `*` being the player the command runs for.
|
||||
fn resolve_score_component(
|
||||
&mut self,
|
||||
o: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> R<Text> {
|
||||
let score = o.get("score");
|
||||
let field = |k: &str| score.and_then(|s| s.get(k)).and_then(|v| v.as_str());
|
||||
let (name, objective) = (
|
||||
field("name").unwrap_or(""),
|
||||
field("objective").unwrap_or(""),
|
||||
);
|
||||
// findTargetName: a selector that matches one entity is that entity,
|
||||
// one that matches none is just the name.
|
||||
let mut reader = horizon_client::command::Reader::new(name);
|
||||
let ty = horizon_client::command::args::ArgType::Entity {
|
||||
single: false,
|
||||
players: false,
|
||||
};
|
||||
let mut holder = name.to_owned();
|
||||
if let Ok(Arg::Entity(sel)) = ty.parse(&mut reader, &self.ui.registries)
|
||||
&& !reader.can_read()
|
||||
{
|
||||
let found = self.select(&sel);
|
||||
if found.len() > 1 {
|
||||
return Err(err("argument.entity.toomany", &[]));
|
||||
}
|
||||
if let Some(t) = found.first().and_then(|t| self.holder_name(*t)) {
|
||||
holder = t;
|
||||
}
|
||||
}
|
||||
if holder == "*" {
|
||||
holder = self.acting_player_name().to_owned();
|
||||
}
|
||||
let board = &self.score.board;
|
||||
let (Some(objective), Some(score)) =
|
||||
(board.objective(objective), board.score(&holder, objective))
|
||||
else {
|
||||
return Ok(Text::empty());
|
||||
};
|
||||
// numberFormatOrDefault(StyledFormat.NO_STYLE).
|
||||
let format = (score.number_format.clone())
|
||||
.or_else(|| objective.number_format.clone())
|
||||
.unwrap_or(horizon_client::scoreboard::NumberFormat::Styled(
|
||||
serde_json::json!({}),
|
||||
));
|
||||
Ok(format.format(score.value))
|
||||
}
|
||||
/// The text of an `nbt` component.
|
||||
fn resolve_nbt_component(&mut self, o: &serde_json::Map<String, serde_json::Value>) -> Text {
|
||||
use horizon_world::nbt::Tag;
|
||||
@@ -136,28 +189,40 @@ impl Game<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
fn player_targets(&mut self, ctx: &Context) -> R<usize> {
|
||||
// Only the local player can receive messages and titles.
|
||||
/// The players the command reaches: the window's own and the LAN guests.
|
||||
fn player_targets(&mut self, ctx: &Context) -> R<Vec<Target>> {
|
||||
let targets = self.entities(ctx, "targets", true)?;
|
||||
Ok(targets.iter().filter(|t| **t == Target::Player).count())
|
||||
Ok(targets
|
||||
.into_iter()
|
||||
.filter(|t| matches!(t, Target::Player | Target::Guest(_)))
|
||||
.collect())
|
||||
}
|
||||
pub(super) fn cmd_tellraw(&mut self, ctx: &Context) -> R<i32> {
|
||||
let n = self.player_targets(ctx)?;
|
||||
let players = self.player_targets(ctx)?;
|
||||
let message = self.component_arg(ctx, "message")?;
|
||||
if n > 0 {
|
||||
self.system_message(message);
|
||||
// Each player's chat (a guest's through the connection).
|
||||
for &t in &players {
|
||||
self.for_player(t, |g| g.system_message(message.clone()));
|
||||
}
|
||||
Ok(n as i32)
|
||||
Ok(players.len() as i32)
|
||||
}
|
||||
pub(super) fn cmd_title(&mut self, ctx: &Context) -> R<i32> {
|
||||
let n = self.player_targets(ctx)?;
|
||||
let name = self.target_name(Target::Player);
|
||||
let players = self.player_targets(ctx)?;
|
||||
let n = players.len();
|
||||
let name = players
|
||||
.first()
|
||||
.map_or_else(Text::empty, |t| self.target_name(*t));
|
||||
let single = n == 1;
|
||||
let key = if ctx.has("clear") {
|
||||
self.titles_or_packet(Titles::clear, C::ClearTitles(play::Bool1 { value: false }));
|
||||
self.titles_or_packet(
|
||||
&players,
|
||||
Titles::clear,
|
||||
C::ClearTitles(play::Bool1 { value: false }),
|
||||
);
|
||||
"cleared"
|
||||
} else if ctx.has("reset") {
|
||||
self.titles_or_packet(
|
||||
&players,
|
||||
|t| {
|
||||
t.clear();
|
||||
t.reset_times();
|
||||
@@ -172,6 +237,7 @@ impl Game<'_> {
|
||||
};
|
||||
let (fade_in, stay, fade_out) = (get("fadeIn"), get("stay"), get("fadeOut"));
|
||||
self.titles_or_packet(
|
||||
&players,
|
||||
|t| t.set_times(fade_in, stay, fade_out),
|
||||
C::SetTitlesAnimation(play::TitlesAnimation {
|
||||
fade_in,
|
||||
@@ -186,13 +252,17 @@ impl Game<'_> {
|
||||
text: component_nbt(&text.component()),
|
||||
};
|
||||
if ctx.has("subtitle") {
|
||||
self.titles_or_packet(|t| t.set_subtitle(text), C::SetSubtitleText(sent));
|
||||
self.titles_or_packet(&players, |t| t.set_subtitle(text), C::SetSubtitleText(sent));
|
||||
"show.subtitle"
|
||||
} else if ctx.has("actionbar") {
|
||||
self.titles_or_packet(|t| t.set_overlay(text, false), C::SetActionBarText(sent));
|
||||
self.titles_or_packet(
|
||||
&players,
|
||||
|t| t.set_overlay(text, false),
|
||||
C::SetActionBarText(sent),
|
||||
);
|
||||
"show.actionbar"
|
||||
} else {
|
||||
self.titles_or_packet(|t| t.set_title(text), C::SetTitleText(sent));
|
||||
self.titles_or_packet(&players, |t| t.set_title(text), C::SetTitleText(sent));
|
||||
"show.title"
|
||||
}
|
||||
};
|
||||
@@ -207,13 +277,27 @@ impl Game<'_> {
|
||||
self.feedback(message);
|
||||
Ok(n as i32)
|
||||
}
|
||||
/// The player the title is for: the guest acting now is sent the packet
|
||||
/// (TitleCommand's `connection.send`), the window's own player shows it.
|
||||
fn titles_or_packet(&mut self, local: impl FnOnce(&mut Titles), packet: C) {
|
||||
if self.lan_acting.is_some() {
|
||||
self.guest_packets.push(packet);
|
||||
} else {
|
||||
local(&mut self.titles);
|
||||
/// TitleCommand's `connection.send` for each player: a guest is sent the
|
||||
/// packet, the window's own player shows it.
|
||||
fn titles_or_packet(&mut self, players: &[Target], local: impl FnOnce(&mut Titles), packet: C) {
|
||||
let mut local = Some(local);
|
||||
for &t in players {
|
||||
match t {
|
||||
Target::Player if self.lan_acting.is_some() => {
|
||||
self.guest_packets.push(packet.clone());
|
||||
}
|
||||
Target::Player => {
|
||||
if let Some(local) = local.take() {
|
||||
local(&mut self.titles);
|
||||
}
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,27 +141,37 @@ impl Game<'_> {
|
||||
Some(Arg::Entity(_)) => self.entities(ctx, "entity", false)?.first().copied(),
|
||||
_ => None,
|
||||
};
|
||||
let cause = match ctx.get("cause") {
|
||||
Some(Arg::Entity(_)) => self.entities(ctx, "cause", false)?.first().copied(),
|
||||
_ => None,
|
||||
};
|
||||
let from = match ctx.get("location") {
|
||||
Some(Arg::Coords(_)) => self.position_arg(ctx, "location"),
|
||||
_ => by.and_then(|t| self.target_position(t)),
|
||||
};
|
||||
let hurt = match target {
|
||||
// The causing mob is the one the hit is credited to and thorns
|
||||
// answer; the source position gives the knockback its direction.
|
||||
Target::Player => {
|
||||
if self.creative || self.spectator() {
|
||||
// `DamageSource(type, cause, by)`: the death message names the
|
||||
// causing entity, else the direct one. A mob is noted by
|
||||
// `hurt_player_from`, a player here.
|
||||
if let Some(t @ (Target::Player | Target::Guest(_))) = cause.or(by) {
|
||||
let name = self.target_name(t);
|
||||
self.note_direct_entity(&ty, name);
|
||||
}
|
||||
if self.spectator() {
|
||||
false
|
||||
} else if let Some(player) = self.player.as_mut() {
|
||||
match self.motion.health.damage.id(&ty) {
|
||||
Some(id) => player
|
||||
.hurt(&self.motion.health, self.world.health_rules, id, amount)
|
||||
.unwrap_or(false),
|
||||
None => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
let attacker = match cause.or(by) {
|
||||
Some(Target::Mob(i)) => Some(Some(i)),
|
||||
_ => Some(None),
|
||||
};
|
||||
self.hurt_player_from(amount, from, &ty, attacker)
|
||||
}
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
let attacker = by.and_then(|t| match t {
|
||||
let attacker = cause.or(by).and_then(|t| match t {
|
||||
Target::Player => Some(Who::Player),
|
||||
Target::Mob(i) => Some(Who::Mob(i)),
|
||||
_ => None,
|
||||
@@ -302,9 +312,18 @@ impl Game<'_> {
|
||||
float("minVolume", 0.0),
|
||||
);
|
||||
let range = if volume > 1.0 { 16.0 * volume } else { 16.0 };
|
||||
// PlaySoundCommand: one seed for every player.
|
||||
let seed = self.world.random.next_long();
|
||||
let here = self.dim.id();
|
||||
let mut tracker = Tracker::default();
|
||||
for t in targets {
|
||||
if t != Target::Player {
|
||||
if !matches!(t, Target::Player | Target::Guest(_)) {
|
||||
continue;
|
||||
}
|
||||
// Only players in the source's level hear it.
|
||||
if let Target::Guest(conn) = t
|
||||
&& self.command_guest(conn).is_none_or(|g| g.dimension != here)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(p) = self.target_position(t) else {
|
||||
@@ -328,8 +347,26 @@ impl Game<'_> {
|
||||
} else {
|
||||
(pos, volume)
|
||||
};
|
||||
self.audio.play_on(source, &sound, Some(at), v, pitch);
|
||||
tracker.mark(&holder(Text::literal(self.acting_player_name())));
|
||||
// The connection is sent the sound; the window's own player
|
||||
// plays it.
|
||||
let guest = match t {
|
||||
Target::Guest(conn) => Some(conn),
|
||||
_ => self.lan_acting,
|
||||
};
|
||||
match guest.and_then(|conn| Some((conn, self.lan.as_ref()?))) {
|
||||
Some((conn, host)) => {
|
||||
let packet = host
|
||||
.hub
|
||||
.sound_packet(&sound, at, v, pitch, source as i32, seed);
|
||||
if t == Target::Player {
|
||||
self.guest_packets.push(packet);
|
||||
} else {
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
None => self.audio.play_on(source, &sound, Some(at), v, pitch),
|
||||
}
|
||||
tracker.mark(&holder(self.target_name(t)));
|
||||
}
|
||||
let name = Text::literal(sound.clone());
|
||||
let (message, total) = tracker.message(
|
||||
@@ -606,8 +643,10 @@ impl Game<'_> {
|
||||
let reached = reached.len() as i32;
|
||||
if !local {
|
||||
} else if count <= 0 {
|
||||
// No count: one particle with the delta as velocity.
|
||||
self.particles.create(&options, pos, delta);
|
||||
// handleParticleEvent, count 0: one particle, the speed times
|
||||
// the delta as velocity (both sent as floats, multiplied as floats).
|
||||
self.particles
|
||||
.create(&options, pos, delta.map(|d| f64::from(speed * d as f32)));
|
||||
} else {
|
||||
for _ in 0..count {
|
||||
let r = self.particles.random();
|
||||
|
||||
@@ -19,7 +19,17 @@ 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,
|
||||
|
||||
@@ -878,6 +878,9 @@ 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",
|
||||
|
||||
@@ -218,8 +218,9 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
/// `CreakingHeartBlock.playerWillDestroy`: the heart's creaking twitches to
|
||||
/// death and a natural heart drops experience.
|
||||
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3]) {
|
||||
/// death and a natural heart drops experience. `onExplosionHit` does the
|
||||
/// same for the creaking (`by_player` false), without the experience.
|
||||
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3], by_player: bool) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if !matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
@@ -242,7 +243,7 @@ impl Game<'_> {
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("natural").cloned())
|
||||
.is_some_and(|v| v == "true");
|
||||
if natural && !self.creative && !self.spectator() {
|
||||
if by_player && natural && !self.creative && !self.spectator() {
|
||||
let xp = 20 + self.world.random.next_int_bound(5).unwrap_or(0) as u32;
|
||||
self.spawn_orbs(
|
||||
[
|
||||
|
||||
@@ -18,6 +18,7 @@ use horizon_client::{
|
||||
spawning::{self, BiomeFacts},
|
||||
},
|
||||
projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3, traverse_blocks},
|
||||
@@ -532,6 +533,8 @@ impl Game<'_> {
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut guest_events = Vec::new();
|
||||
// The mobs whose bodies go this tick: (id, feet, width, height).
|
||||
let mut gone = Vec::new();
|
||||
let level_random;
|
||||
{
|
||||
// The level random, lent to the mobs (brain behaviours draw from
|
||||
@@ -604,6 +607,11 @@ impl Game<'_> {
|
||||
let from = events.len();
|
||||
let kept = m.tick(&w, &mut scratch, &mut events);
|
||||
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
|
||||
// LivingEntity.tickDeath: after twenty ticks the body is removed.
|
||||
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
|
||||
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
|
||||
gone.push((m.id, m.position, width, height));
|
||||
}
|
||||
if let Some(conn) = seen_guest {
|
||||
// ShulkerBullet.finalTarget is the guest's entity: the
|
||||
// bullet homes on it while the projectiles tick, where
|
||||
@@ -662,6 +670,12 @@ impl Game<'_> {
|
||||
}
|
||||
self.mobs.append(&mut mobs);
|
||||
self.died = died;
|
||||
// ... and entity event 60 goes out as it is: the poof.
|
||||
for (id, at, width, height) in gone {
|
||||
let at = [at.x, at.y, at.z];
|
||||
self.lan_entity_event(id as i32, 60, at);
|
||||
self.death_poof(at, width, height);
|
||||
}
|
||||
self.sync_riders();
|
||||
self.tick_skeleton_traps();
|
||||
self.tick_apparition();
|
||||
@@ -688,6 +702,22 @@ impl Game<'_> {
|
||||
self.handle_mob_events(queued)?;
|
||||
Ok(())
|
||||
}
|
||||
/// `LivingEntity.makePoofParticles` (entity event 60) about a mob's box.
|
||||
pub(super) fn death_poof(&mut self, at: [f64; 3], width: f64, height: f64) {
|
||||
let options = horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::Poof,
|
||||
);
|
||||
for _ in 0..20 {
|
||||
let r = self.particles.level_random();
|
||||
let xa = r.next_gaussian() * 0.02;
|
||||
let ya = r.next_gaussian() * 0.02;
|
||||
let za = r.next_gaussian() * 0.02;
|
||||
let x = at[0] + width * (2.0 * r.next_double() - 1.0) - xa * 10.0;
|
||||
let y = at[1] + height * r.next_double() - ya * 10.0;
|
||||
let z = at[2] + width * (2.0 * r.next_double() - 1.0) - za * 10.0;
|
||||
self.particles.add(&options, [x, y, z], [xa, ya, za]);
|
||||
}
|
||||
}
|
||||
/// LivingEntity.die / dropAllDeathLoot.
|
||||
fn mob_died(&mut self, m: &Mob) {
|
||||
let killer = self.last_hurt_by_player.remove(&m.id);
|
||||
@@ -950,6 +980,21 @@ 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.
|
||||
@@ -1084,6 +1129,11 @@ impl Game<'_> {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
|
||||
// LlamaSpit.recreateFromPacket: the spit's burst on the client.
|
||||
if p.kind == ProjectileKind::LlamaSpit {
|
||||
let v = p.velocity;
|
||||
self.particles.llama_spit_burst(at, [v.x, v.y, v.z]);
|
||||
}
|
||||
self.creatures.projectiles.push(p);
|
||||
self.projectile_event("minecraft:projectile_shoot", owner, at);
|
||||
}
|
||||
@@ -1646,11 +1696,34 @@ impl Game<'_> {
|
||||
pub(super) fn mob_hits_player_kind(&mut self, amount: f32, from: [f64; 3], kind: &str) {
|
||||
self.mob_hits_player(amount, from, kind);
|
||||
}
|
||||
/// Damage whose causing mob is known (`cause`, or none): not guessed
|
||||
/// from the mob nearest to `from`.
|
||||
pub(super) fn mob_hits_player_caused(
|
||||
&mut self,
|
||||
amount: f32,
|
||||
from: [f64; 3],
|
||||
kind: &str,
|
||||
cause: Option<u32>,
|
||||
) {
|
||||
self.hurt_player_from(amount, Some(from), kind, Some(cause));
|
||||
}
|
||||
pub(super) fn mob_hits_player(
|
||||
&mut self,
|
||||
amount: f32,
|
||||
from: [f64; 3],
|
||||
damage_type: &str,
|
||||
) -> bool {
|
||||
self.hurt_player_from(amount, Some(from), damage_type, None)
|
||||
}
|
||||
/// `hurtServer` on the player. `from` is the source's position (none: no
|
||||
/// direction), `known`: the causing mob when it is given (`/damage`),
|
||||
/// else the melee attacker is the mob nearest to `from`.
|
||||
pub(super) fn hurt_player_from(
|
||||
&mut self,
|
||||
amount: f32,
|
||||
from: Option<[f64; 3]>,
|
||||
damage_type: &str,
|
||||
known: Option<Option<u32>>,
|
||||
) -> bool {
|
||||
// ServerPlayer.canHarmPlayer refused another player's projectile.
|
||||
if self.creative || self.combat.refuse_player_hit {
|
||||
@@ -1663,23 +1736,26 @@ impl Game<'_> {
|
||||
.damage_tags
|
||||
.get("minecraft:is_projectile")
|
||||
.is_some_and(|t| t.contains(damage_type));
|
||||
let blocking = self.player_blocking(Some(from), damage_type, false);
|
||||
let blocking = self.player_blocking(from, damage_type, false);
|
||||
let raised = blocking.is_some();
|
||||
// Melee: the attacker stands at `from` (another player's hit has no mob behind it).
|
||||
let attacker = (!projectile && damage_type != "minecraft:player_attack")
|
||||
.then(|| {
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.min_by(|a, b| {
|
||||
let d = |m: &horizon_client::mobs::Mob| {
|
||||
(m.position.x - from[0]).powi(2) + (m.position.z - from[2]).powi(2)
|
||||
};
|
||||
d(a).total_cmp(&d(b))
|
||||
})
|
||||
.map(|m| m.id)
|
||||
})
|
||||
.flatten();
|
||||
let attacker = match known {
|
||||
Some(known) => known,
|
||||
None => from
|
||||
.filter(|_| !projectile && damage_type != "minecraft:player_attack")
|
||||
.and_then(|from| {
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.min_by(|a, b| {
|
||||
let d = |m: &horizon_client::mobs::Mob| {
|
||||
(m.position.x - from[0]).powi(2) + (m.position.z - from[2]).powi(2)
|
||||
};
|
||||
d(a).total_cmp(&d(b))
|
||||
})
|
||||
.map(|m| m.id)
|
||||
}),
|
||||
};
|
||||
// The causing entity for the death message.
|
||||
let owner = self.combat.projectile_owner.take();
|
||||
if let Some(a) = attacker.or(owner) {
|
||||
@@ -1707,7 +1783,10 @@ impl Game<'_> {
|
||||
// LivingEntity.hurtServer's dealDefaultKnockback: not inside the
|
||||
// hurt cooldown (a stronger hit there only adds its difference).
|
||||
if o.knockback {
|
||||
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
|
||||
// No source position: a random direction.
|
||||
let (xd, zd) = from.map_or((0.0, 0.0), |from| {
|
||||
(from[0] - player.position.x, from[2] - player.position.z)
|
||||
});
|
||||
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()
|
||||
@@ -2045,6 +2124,33 @@ impl Game<'_> {
|
||||
None => self.projectile_hit(p, hit),
|
||||
}
|
||||
}
|
||||
/// What an arrow or a thrown trident hits an entity of type `victim`
|
||||
/// for (`AbstractArrow.onHitEntity`, `ThrownTrident.onHitEntity`): a
|
||||
/// trident's 8 through `EnchantmentHelper.modifyDamage` of the trident
|
||||
/// (Impaling's `minecraft:damage` on `#sensitive_to_impaling`).
|
||||
fn shot_damage(&mut self, shot: &mut Projectile, victim: Option<&str>) -> f32 {
|
||||
if !matches!(shot.kind, ProjectileKind::Trident) {
|
||||
return arrow_damage(shot, &mut self.world.random) + shot.bonus.damage;
|
||||
}
|
||||
let Some(trident) = shot.arrow.as_ref().map(|a| a.pickup_item.clone()) else {
|
||||
return 8.0;
|
||||
};
|
||||
let ctx = horizon_client::enchant::EffectContext {
|
||||
this: victim,
|
||||
attacker: (shot.owner == Some(Who::Player)).then_some("minecraft:player"),
|
||||
direct_attacker: Some("minecraft:trident"),
|
||||
damage_type: Some("minecraft:trident"),
|
||||
items: Some(self.res.items()),
|
||||
..Default::default()
|
||||
};
|
||||
self.res.stations().enchantments.modify_value(
|
||||
Some(&trident),
|
||||
"minecraft:damage",
|
||||
&ctx,
|
||||
&mut self.world.random,
|
||||
8.0,
|
||||
)
|
||||
}
|
||||
/// What an arrow's tick asked for: its particles and sounds.
|
||||
fn arrow_event(&mut self, at: Vec3, event: horizon_client::arrows::ArrowEvent) {
|
||||
use horizon_client::arrows::{ArrowEvent, ArrowParticle, ArrowSound, BlockReaction};
|
||||
@@ -2073,6 +2179,8 @@ impl Game<'_> {
|
||||
ArrowSound::Splash => "minecraft:entity.generic.splash",
|
||||
ArrowSound::HitGround => "minecraft:entity.arrow.hit",
|
||||
ArrowSound::Extinguish => "minecraft:entity.generic.extinguish_fire",
|
||||
ArrowSound::TridentHitGround => "minecraft:item.trident.hit_ground",
|
||||
ArrowSound::TridentHit => "minecraft:item.trident.hit",
|
||||
};
|
||||
self.audio
|
||||
.play(name, Some([at.x, at.y, at.z]), volume, pitch);
|
||||
@@ -2222,11 +2330,11 @@ impl Game<'_> {
|
||||
&& id >= super::dragon::SPECIAL
|
||||
{
|
||||
let by_player = p.owner == Some(Who::Player);
|
||||
if let ProjectileKind::Arrow { .. } = p.kind {
|
||||
if p.arrow.is_some() {
|
||||
// AbstractArrow.onHitEntity: spent if the part or crystal took
|
||||
// it, else turned back (a perched dragon sets it alight first).
|
||||
let mut arrow = p.clone();
|
||||
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
|
||||
let amount = self.shot_damage(&mut arrow, None);
|
||||
let outcome = match self.hit_special(id, amount, by_player, true) {
|
||||
super::dragon::Hurt::Landed { .. } => EntityHit::Hurt,
|
||||
super::dragon::Hurt::Refused => EntityHit::Refused,
|
||||
@@ -2247,17 +2355,15 @@ impl Game<'_> {
|
||||
self.hit_special(id, amount, by_player, false);
|
||||
return Ok(());
|
||||
}
|
||||
if let (
|
||||
ProjectileKind::Arrow { .. },
|
||||
Hit::Entity {
|
||||
if p.arrow.is_some()
|
||||
&& let Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
},
|
||||
) = (&p.kind, hit)
|
||||
} = hit
|
||||
&& let Some(i) = self.carts.iter().position(|c| c.id == id)
|
||||
{
|
||||
// AbstractArrow.onHitEntity on a minecart.
|
||||
let mut arrow = p.clone();
|
||||
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
|
||||
let amount = self.shot_damage(&mut arrow, None);
|
||||
let burning = p.arrow.as_ref().is_some_and(|a| a.on_fire());
|
||||
let creative = p.owner == Some(Who::Player) && self.creative;
|
||||
self.arrow_hits_cart(i, amount, p.velocity, burning, creative);
|
||||
@@ -2268,7 +2374,7 @@ impl Game<'_> {
|
||||
// Projectile.onHit: the landing is heard.
|
||||
self.projectile_event("minecraft:projectile_land", p.owner, atp);
|
||||
let source_kind = match p.kind {
|
||||
ProjectileKind::Arrow { .. } => DamageKind::Arrow,
|
||||
ProjectileKind::Arrow { .. } | ProjectileKind::Trident => DamageKind::Arrow,
|
||||
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => {
|
||||
DamageKind::Fireball
|
||||
}
|
||||
@@ -2280,6 +2386,40 @@ impl Game<'_> {
|
||||
from: Some([p.previous.x, p.previous.y, p.previous.z]),
|
||||
};
|
||||
match (&p.kind, hit) {
|
||||
// ThrownTrident.onHitEntity: 8 damage (Impaling on aquatic mobs),
|
||||
// dealt whether or not the hurt lands, then it bounces off.
|
||||
(ProjectileKind::Trident, Hit::Entity { who, .. }) => {
|
||||
let mut trident = p.clone();
|
||||
let victim = match who {
|
||||
Who::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| m.kind.id.clone()),
|
||||
Who::Player => Some("minecraft:player".to_owned()),
|
||||
};
|
||||
let amount = self.shot_damage(&mut trident, victim.as_deref());
|
||||
let hurt = match who {
|
||||
Who::Player => self.mob_hits_player(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:trident",
|
||||
),
|
||||
Who::Mob(id) => self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id)
|
||||
.is_some_and(|m| m.damage(amount, source)),
|
||||
};
|
||||
let outcome = if hurt {
|
||||
EntityHit::Hurt
|
||||
} else {
|
||||
EntityHit::Refused
|
||||
};
|
||||
self.finish_arrow_hit(trident, outcome);
|
||||
}
|
||||
// AbstractArrow.onHitBlock plays its sound itself (ArrowEvent).
|
||||
(ProjectileKind::Trident, Hit::Block { .. }) => {}
|
||||
(ProjectileKind::Arrow { effect, .. }, Hit::Entity { who, .. }) => {
|
||||
let mut arrow = p.clone();
|
||||
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
|
||||
@@ -2501,14 +2641,30 @@ impl Game<'_> {
|
||||
if d2 < 16.0 && !self.creative {
|
||||
let scale = 1.0 - d2.sqrt() / 4.0;
|
||||
let t = (f64::from(*ticks) * scale + 0.5) as i32;
|
||||
if let Some(owner) = self.combat.projectile_owner {
|
||||
self.note_attacker("minecraft:indirect_magic", owner);
|
||||
}
|
||||
match *effect {
|
||||
"minecraft:instant_damage" => self.hurt_player_by(
|
||||
"minecraft:indirect_magic",
|
||||
6.0 * (1 << *amplifier) as f32 * scale as f32,
|
||||
),
|
||||
"minecraft:instant_damage" => {
|
||||
// indirectMagic(potion, owner), built where the
|
||||
// hurt happens: the thrower, or the potion
|
||||
// itself when it has none. A status effect
|
||||
// deals no damage, so it records no source.
|
||||
let owner = self
|
||||
.combat
|
||||
.projectile_owner
|
||||
.filter(|id| self.mob_display_name(*id).is_some());
|
||||
match owner {
|
||||
Some(owner) => {
|
||||
self.note_attacker("minecraft:indirect_magic", owner)
|
||||
}
|
||||
None => self.note_direct_entity(
|
||||
"minecraft:indirect_magic",
|
||||
Text::translate("entity.minecraft.splash_potion", &[]),
|
||||
),
|
||||
}
|
||||
self.hurt_player_by(
|
||||
"minecraft:indirect_magic",
|
||||
6.0 * (1 << *amplifier) as f32 * scale as f32,
|
||||
)
|
||||
}
|
||||
e if t > 20 => self.give_player_effect(e, t, *amplifier),
|
||||
_ => {}
|
||||
}
|
||||
@@ -3265,7 +3421,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 = f64::from(self.settings.entity_distance);
|
||||
let scale = self.view_scale();
|
||||
for mob in &self.mobs {
|
||||
let entity_start = out.len();
|
||||
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
|
||||
@@ -3274,9 +3430,16 @@ 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 entity distance option.
|
||||
// mean edge, scaled by the view scale. RemotePlayer's box counts
|
||||
// ten times.
|
||||
let (w, h, _) = mob.dims();
|
||||
let range = (w + h + w) / 3.0 * 64.0 * scale;
|
||||
let edge = (w + h + w) / 3.0
|
||||
* if &*mob.kind.id == "minecraft:player" {
|
||||
10.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let range = edge * 64.0 * scale;
|
||||
if d.length_squared() >= range * range {
|
||||
continue;
|
||||
}
|
||||
@@ -3417,6 +3580,30 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ThrownTridentRenderer.
|
||||
if matches!(a.kind, ProjectileKind::Trident) {
|
||||
let Some(model) = self.trident_model.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
|
||||
continue;
|
||||
};
|
||||
let m = glam::Mat4::from_translation(c)
|
||||
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
|
||||
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
|
||||
let poses = horizon_client::mobs::anim::Poses::new(model);
|
||||
horizon_client::mobs::render::draw(
|
||||
model,
|
||||
m,
|
||||
&poses,
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// WindChargeRenderer.
|
||||
if matches!(
|
||||
a.kind,
|
||||
|
||||
@@ -348,11 +348,15 @@ impl Game<'_> {
|
||||
let Some(state) = self.pack.assets.registry.default_state(&name) else {
|
||||
return;
|
||||
};
|
||||
let at = [
|
||||
c.position.x.floor() as i32,
|
||||
c.position.y.floor() as i32,
|
||||
c.position.z.floor() as i32,
|
||||
];
|
||||
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 Some((sprite, tint)) = self.particle_sprite(state, at) else {
|
||||
return;
|
||||
};
|
||||
@@ -361,14 +365,8 @@ impl Game<'_> {
|
||||
sprite,
|
||||
tint,
|
||||
);
|
||||
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,
|
||||
);
|
||||
self.particles
|
||||
.send_particles(&options, particle_at, [0.25, 0.0625, 0.25], 0.05, 10);
|
||||
}
|
||||
|
||||
/// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100.
|
||||
@@ -455,8 +453,7 @@ 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.settings.entity_distance);
|
||||
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
|
||||
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ 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},
|
||||
@@ -89,7 +90,9 @@ fn log_from(thread: &str, level: &str, message: &str) {
|
||||
}
|
||||
});
|
||||
// Log4j's %d{HH:mm:ss}: the local time, as in logs/latest.log.
|
||||
println!(
|
||||
// The launcher can close its stdout pipe while the server keeps running.
|
||||
let _ = writeln!(
|
||||
std::io::stdout().lock(),
|
||||
"[{}] [{thread}/{level}]: {message}",
|
||||
chrono::Local::now().format("%H:%M:%S")
|
||||
);
|
||||
@@ -618,6 +621,22 @@ 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,
|
||||
@@ -654,6 +673,9 @@ impl Game<'_> {
|
||||
// DedicatedServer.initServer ends with saveEverything(false, true, true).
|
||||
self.save_game()?;
|
||||
self.log_saved_levels();
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.entity_range_percent = props.entity_broadcast_range_percentage;
|
||||
}
|
||||
let mut state = ServerState {
|
||||
lists,
|
||||
console,
|
||||
@@ -1308,3 +1330,110 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -982,7 +982,7 @@ impl Game<'_> {
|
||||
| "minecraft:lingering_potion" => {
|
||||
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
|
||||
shot.shoot(&mut own, dir, power, uncertainty);
|
||||
self.launch_thrown(one, at, shot.velocity);
|
||||
self.launch_thrown(one, at, shot.velocity, None);
|
||||
}
|
||||
"minecraft:firework_rocket" => {
|
||||
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
|
||||
|
||||
@@ -2157,11 +2157,14 @@ impl Game<'_> {
|
||||
}
|
||||
/// Damage from the dragon, which is its causing entity
|
||||
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
|
||||
/// death message names it.
|
||||
/// death message names it, or the cloud once the dragon is gone.
|
||||
fn dragon_hurts_player(&mut self, damage_type: &str, amount: f32) {
|
||||
if let Some(id) = self.dragon.as_ref().map(|d| d.id) {
|
||||
let name = Text::translate("entity.minecraft.ender_dragon", &[]);
|
||||
self.note_named_attacker(damage_type, id, name);
|
||||
} else if damage_type == "minecraft:indirect_magic" {
|
||||
let name = Text::translate("entity.minecraft.area_effect_cloud", &[]);
|
||||
self.note_direct_entity(damage_type, name);
|
||||
}
|
||||
self.hurt_player_by(damage_type, amount);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ use horizon_core::{
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// ServerLevel.END_SPAWN_POINT.
|
||||
const END_SPAWN: [i32; 3] = [100, 50, 0];
|
||||
pub(super) 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,12 +60,27 @@ 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 entity distance option.
|
||||
pub(super) fn default_range(size: glam::DVec3, view_scale: f32) -> f64 {
|
||||
/// box's mean edge, scaled by the view scale.
|
||||
pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 {
|
||||
let edge = (size.x + size.y + size.z) / 3.0;
|
||||
let edge = if edge.is_nan() { 1.0 } else { edge };
|
||||
edge * 64.0 * f64::from(view_scale)
|
||||
edge * 64.0 * view_scale
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -572,16 +572,16 @@ impl Game<'_> {
|
||||
let pitch = (1.0 + (client_float() - client_float()) * 0.2) * 0.7;
|
||||
self.audio
|
||||
.play("minecraft:entity.generic.explode", Some(center), 4.0, pitch);
|
||||
let block_count = if interaction == BlockInteraction::Keep {
|
||||
0
|
||||
} else {
|
||||
targets.len()
|
||||
};
|
||||
// ServerLevel.explode sends toBlow.size() whatever the interaction, and
|
||||
// `ServerExplosion.isSmall` also covers an explosion that keeps blocks.
|
||||
let block_count = targets.len();
|
||||
let small = radius < 2.0 || interaction == BlockInteraction::Keep;
|
||||
let (world, facts) = (&self.world, self.res.facts());
|
||||
self.particles.explosion(center, radius, block_count, &|b| {
|
||||
facts.is_air(world.block(b[0], b[1], b[2]))
|
||||
});
|
||||
self.lan_explode(center, radius, block_count);
|
||||
self.particles
|
||||
.explosion(center, small, block_count, radius, &|b| {
|
||||
facts.is_air(world.block(b[0], b[1], b[2]))
|
||||
});
|
||||
self.lan_explode(center, small, radius, block_count);
|
||||
Ok(())
|
||||
}
|
||||
/// `BlockBehaviour.onExplosionHit` (TNT: `wasExploded` primes a short fuse).
|
||||
@@ -598,6 +598,11 @@ impl Game<'_> {
|
||||
}
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
let is_tnt = &*name == "minecraft:tnt";
|
||||
// CreakingHeartBlock.onExplosionHit: its creaking dies (no
|
||||
// experience, as Horizon does not track a TNT's owner).
|
||||
if &*name == "minecraft:creaking_heart" {
|
||||
self.creaking_heart_broken(pos, false);
|
||||
}
|
||||
// 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]);
|
||||
|
||||
@@ -103,12 +103,23 @@ impl Game<'_> {
|
||||
let b = horizon_core::geometry::Aabb::new(lo, hi);
|
||||
// Every player in the box, the server's players too.
|
||||
let pos = f.pos;
|
||||
// `EvokerFangs.dealDamageTo`: indirectMagic(fangs, owner) while the
|
||||
// owner lives, plain magic (nobody to name) once it is gone.
|
||||
let owner = self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.id == f.owner && m.alive())
|
||||
.then_some(f.owner);
|
||||
let bite = move |g: &mut Self| {
|
||||
if let Some(p) = g.player_view()
|
||||
&& p.alive
|
||||
&& p.bounds.intersects(b)
|
||||
{
|
||||
g.mob_hits_player_kind(6.0, pos, "minecraft:indirect_magic");
|
||||
let kind = match owner {
|
||||
Some(_) => "minecraft:indirect_magic",
|
||||
None => "minecraft:magic",
|
||||
};
|
||||
g.mob_hits_player_caused(6.0, pos, kind, owner);
|
||||
}
|
||||
};
|
||||
bite(self);
|
||||
|
||||
@@ -311,8 +311,9 @@ 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 100%.
|
||||
let range = 16.0 * 64.0 * f64::from(self.settings.entity_distance);
|
||||
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at a view scale
|
||||
// of 1.
|
||||
let range = 16.0 * 64.0 * self.view_scale();
|
||||
for f in &self.item_frames {
|
||||
let entity_start = out.len();
|
||||
let map = f.item.as_ref().and_then(super::maps::map_id);
|
||||
|
||||
@@ -20,7 +20,8 @@ use horizon_core::{
|
||||
};
|
||||
use horizon_world::{
|
||||
block_collision::BlockCollisionRegistry, collision_context::CollisionContext,
|
||||
heightmap::HeightmapType, worldgen::terrain::TerrainBlockStates,
|
||||
heightmap::HeightmapType, level::flags::UPDATE_CLIENTS, registry::BlockStateId,
|
||||
worldgen::terrain::TerrainBlockStates,
|
||||
};
|
||||
|
||||
/// The loaded world as the spawn searches query it.
|
||||
@@ -121,6 +122,96 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(Some([ox * 16 + 8, height, oz * 16 + 8]))
|
||||
}
|
||||
/// MinecraftServer.setInitialSpawn's bonus chest: BonusChestFeature.place
|
||||
/// in the spawn chunk, a chest with the spawn bonus loot and a torch on
|
||||
/// each side that can hold one, on the first free column of a shuffled
|
||||
/// walk over the chunk. Skipped while the chunk is not loaded.
|
||||
pub(super) fn place_bonus_chest(&mut self, origin: [i32; 3]) -> Result<()> {
|
||||
use horizon_client::containers::BlockEntity;
|
||||
let (cx, cz) = (origin[0] >> 4, origin[2] >> 4);
|
||||
let registry = &self.pack.assets.registry;
|
||||
let (Some(chest), Some(torch)) = (
|
||||
registry.default_state("minecraft:chest"),
|
||||
registry.default_state("minecraft:torch"),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
if !self.streaming.is_lit(cx, cz) {
|
||||
return Ok(());
|
||||
}
|
||||
// `level.getRandom()`. Util.toShuffledList of the chunk's x and z.
|
||||
let random = &mut self.world.random;
|
||||
let mut shuffled = |min: i32| -> [i32; 16] {
|
||||
let mut values: [i32; 16] = std::array::from_fn(|i| min + i as i32);
|
||||
for end in (2..=16usize).rev() {
|
||||
let other = random.next_int_bound(end as i32).unwrap_or(0) as usize;
|
||||
values.swap(end - 1, other);
|
||||
}
|
||||
values
|
||||
};
|
||||
let (xs, zs) = (shuffled(cx * 16), shuffled(cz * 16));
|
||||
for x in xs {
|
||||
for z in zs {
|
||||
let y = self
|
||||
.spawn_columns()
|
||||
.height(HeightmapType::MotionBlockingNoLeaves, x, z);
|
||||
if self.world.block(x, y, z) != self.world.air && !self.collision_empty([x, y, z]) {
|
||||
continue;
|
||||
}
|
||||
self.set_block_clients([x, y, z], chest)?;
|
||||
let seed = self.world.random.next_long();
|
||||
self.world
|
||||
.block_entities
|
||||
.insert((x, y, z), BlockEntity::Chest(Vec::new()));
|
||||
self.load_block_entity_nbt(
|
||||
[x, y, z],
|
||||
&format!(
|
||||
"{{LootTable:\"minecraft:chests/spawn_bonus_chest\",LootTableSeed:{seed}L}}"
|
||||
),
|
||||
);
|
||||
for [dx, dz] in [[0, -1], [1, 0], [0, 1], [-1, 0]] {
|
||||
let at = [x + dx, y, z + dz];
|
||||
let survives = self.with_level(|b, level, ticks| {
|
||||
b.can_survive(level, ticks, BlockPos::new(at[0], at[1], at[2]), torch)
|
||||
});
|
||||
if survives {
|
||||
self.set_block_clients(at, torch)?;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `Level.setBlock(pos, state, UPDATE_CLIENTS)`: no neighbour updates.
|
||||
fn set_block_clients(&mut self, pos: [i32; 3], state: BlockStateId) -> Result<()> {
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.set_block(
|
||||
level,
|
||||
ticks,
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
state,
|
||||
UPDATE_CLIENTS,
|
||||
);
|
||||
});
|
||||
self.process_level_events()
|
||||
}
|
||||
/// The block at `pos` has no collision shape.
|
||||
fn collision_empty(&self, pos: [i32; 3]) -> bool {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
self.collision
|
||||
.collision_shape_with_context(
|
||||
&mut scene,
|
||||
self.world.block(pos[0], pos[1], pos[2]),
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
&CollisionContext::empty(false),
|
||||
)
|
||||
.is_ok_and(|shape| shape.is_empty())
|
||||
}
|
||||
/// PlayerSpawnFinder.findSpawn around the world spawn: the feet of a
|
||||
/// player who has no position yet. None while a candidate's chunk loads.
|
||||
pub(super) fn find_player_spawn(&mut self, suggestion: [i32; 3]) -> Result<Option<Vec3>> {
|
||||
|
||||
@@ -52,6 +52,8 @@ struct Flags {
|
||||
/// server follows while it runs (IntegratedServer.tickServer).
|
||||
render_distance: AtomicI32,
|
||||
simulation_distance: AtomicI32,
|
||||
/// `MinecraftServer.isCurrentlySaving`: the server is autosaving.
|
||||
saving: AtomicBool,
|
||||
}
|
||||
|
||||
/// Work the window hands its server thread (`MinecraftServer.execute`).
|
||||
@@ -69,6 +71,10 @@ impl Integrated {
|
||||
pub fn smoothed_tick_ms(&self) -> f32 {
|
||||
f32::from_bits(self.flags.smoothed_tick_ms.load(Ordering::Relaxed))
|
||||
}
|
||||
/// `MinecraftServer.isCurrentlySaving`.
|
||||
pub fn is_saving(&self) -> bool {
|
||||
self.flags.saving.load(Ordering::Relaxed)
|
||||
}
|
||||
/// `IntegratedServer.tickServer` skips the world while the game is paused.
|
||||
pub fn set_paused(&self, paused: bool) {
|
||||
self.flags.paused.store(paused, Ordering::Relaxed);
|
||||
@@ -148,6 +154,12 @@ pub(super) struct Serving {
|
||||
/// is out of its place: applied after the tick.
|
||||
pending: Option<Publish>,
|
||||
}
|
||||
impl Serving {
|
||||
/// The window reads this as `MinecraftServer.isCurrentlySaving`.
|
||||
pub(super) fn set_saving(&self, saving: bool) {
|
||||
self.flags.saving.store(saving, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
enum Publish {
|
||||
Open(TcpListener, LanSettings),
|
||||
Close,
|
||||
|
||||
@@ -11,7 +11,7 @@ use horizon_client::{
|
||||
chunks::ChunkEncoder,
|
||||
data::SyncData,
|
||||
entity::{EntityState, States},
|
||||
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::{Mob, Who, leash::Holder},
|
||||
@@ -21,6 +21,7 @@ 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,
|
||||
@@ -191,6 +192,17 @@ fn mob_meta(
|
||||
meta.push((9, serializer::FLOAT, Value::Float(health)));
|
||||
}
|
||||
defaults.push((9, serializer::FLOAT, Value::Float(1.0)));
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID: the
|
||||
// swirls of its effects (the ones a mob has are visible, not ambient).
|
||||
meta.extend(
|
||||
hub.effect_meta(
|
||||
(m.extra.effects.iter())
|
||||
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
|
||||
.map(|e| (e.0, false)),
|
||||
),
|
||||
);
|
||||
defaults.push((10, serializer::PARTICLES, Value::Particles(Vec::new())));
|
||||
defaults.push((11, serializer::BOOLEAN, Value::Bool(false)));
|
||||
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4 (what
|
||||
// draws a vindicator's axe, a skeleton's drawn bow, a piglin's raised
|
||||
// weapon).
|
||||
@@ -236,6 +248,27 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// SnowGolem.DATA_PUMPKIN_ID: 16 while the pumpkin is on (the default).
|
||||
"minecraft:snow_golem" => {
|
||||
if e.sheared {
|
||||
meta.push((16, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(16)));
|
||||
}
|
||||
// MushroomCow.DATA_TYPE: 0 red, 1 brown.
|
||||
"minecraft:mooshroom" => {
|
||||
if e.variant == 1 {
|
||||
meta.push((17, serializer::INT, Value::Int(1)));
|
||||
}
|
||||
defaults.push((17, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
// Piglin.DATA_IS_DANCING (after the baby flag and the crossbow charge).
|
||||
"minecraft:piglin" => {
|
||||
if e.scratch[0] > 0 {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 when sheared.
|
||||
"minecraft:sheep" => {
|
||||
let value = (e.color & 15) | if e.sheared { 16 } else { 0 };
|
||||
@@ -350,6 +383,14 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// PolarBear.DATA_STANDING_ID (after AgeableMob's two): the bear
|
||||
// rears up; the client animates it.
|
||||
"minecraft:polar_bear" => {
|
||||
if e.flag {
|
||||
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
|
||||
"minecraft:evoker" | "minecraft:illusioner" => {
|
||||
if e.variant2 != 0 {
|
||||
@@ -675,6 +716,7 @@ 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(
|
||||
@@ -845,6 +887,16 @@ impl Game<'_> {
|
||||
living_flags: living_flags(self.using)
|
||||
| if p.auto_spin_attack_ticks > 0 { 4 } else { 0 },
|
||||
bed: self.sleeping,
|
||||
body: BodyFlags {
|
||||
gliding: p.fall_flying,
|
||||
swimming: p.swimming,
|
||||
crawling: p.pose == horizon_client::pose::Pose::Swimming && !p.swimming,
|
||||
crouching: p.pose == horizon_client::pose::Pose::Crouching,
|
||||
spin_attack: p.auto_spin_attack_ticks > 0,
|
||||
on_fire: p.fire_ticks > 0,
|
||||
invisible: self.potions.effects.get("invisibility").is_some(),
|
||||
glowing: self.potions.effects.get("glowing").is_some(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -962,6 +1014,16 @@ impl Game<'_> {
|
||||
host.hub.block_event(pos, b0, b1, state);
|
||||
}
|
||||
let entities = states.as_slice();
|
||||
// An entity that left the level since the last tick (a snowball
|
||||
// that hit) is dropped by the tracker below: its event goes out
|
||||
// first, as `Level.broadcastEntityEvent` precedes the discard.
|
||||
let (gone, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.lan_entity_events)
|
||||
.into_iter()
|
||||
.partition(|(id, ..)| !entities.iter().any(|e| e.id == *id));
|
||||
self.lan_entity_events = later;
|
||||
for (id, event, at) in gone {
|
||||
host.hub.entity_event(id, event, at);
|
||||
}
|
||||
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
|
||||
let events = {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
@@ -1207,8 +1269,9 @@ impl Game<'_> {
|
||||
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, bone meal's growth, or anvil result sounds
|
||||
// (these levelEvent calls name no excluded player).
|
||||
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
|
||||
// (these levelEvent calls name no excluded player), nor
|
||||
// the digger's own crack and hit sound (2019, 2020).
|
||||
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030 | 2019 | 2020) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
@@ -1243,8 +1306,9 @@ impl Game<'_> {
|
||||
self.lan_entity_events.push((id, event, at));
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.crit`: this window's emitter, and an animation for the
|
||||
/// attacker's client and observers once the hub is back in place.
|
||||
/// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's
|
||||
/// emitter, and an animation for the attacker's client and observers once
|
||||
/// the hub is back in place.
|
||||
pub(super) fn critical_hit(
|
||||
&mut self,
|
||||
attacker: Option<u32>,
|
||||
@@ -1252,21 +1316,50 @@ impl Game<'_> {
|
||||
feet: [f64; 3],
|
||||
width: f64,
|
||||
height: f64,
|
||||
magic: bool,
|
||||
) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
self.particles.tracking_emitter(
|
||||
ParticleOptions::simple(ParticleType::Crit),
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
let particle = if magic {
|
||||
ParticleType::EnchantedHit
|
||||
} else {
|
||||
ParticleType::Crit
|
||||
};
|
||||
self.particles
|
||||
.tracking_emitter(ParticleOptions::simple(particle), feet, width, height);
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_critical_hits.push((attacker, id));
|
||||
self.lan_critical_hits.push((attacker, id, magic));
|
||||
}
|
||||
}
|
||||
/// 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
|
||||
@@ -1339,13 +1432,13 @@ impl Game<'_> {
|
||||
seen.insert(m.id, (m.hurt_time, dead, swing));
|
||||
}
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
for (attacker, target) in critical_hits {
|
||||
for (attacker, target, magic) in critical_hits {
|
||||
let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| {
|
||||
host.hub.guest(conn).map(|g| g.entity_id)
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
host.hub.critical_hit(attacker, target);
|
||||
host.hub.critical_hit(attacker, target, magic);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1428,6 +1521,13 @@ impl Game<'_> {
|
||||
.each_ref()
|
||||
.map(|s| s.as_ref().map(|s| s.item.clone()));
|
||||
m.extra.crouching = g.sneaking;
|
||||
m.extra.sprinting = g.sprinting;
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
|
||||
let visible = || g.effects.list.iter().filter(|e| e.visible);
|
||||
m.extra.effect_particles = visible()
|
||||
.filter_map(|e| super::client_ambient::effect_particle(e.id, e.ambient))
|
||||
.collect();
|
||||
m.extra.effect_ambient = visible().all(|e| e.ambient);
|
||||
// Lying in its bed (LivingEntity.getBedOrientation).
|
||||
m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b));
|
||||
// LivingEntity.updateSwingTime: the arm swing lan_swings started.
|
||||
|
||||
@@ -165,6 +165,8 @@ 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.
|
||||
@@ -389,6 +391,7 @@ 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(
|
||||
@@ -449,6 +452,7 @@ impl Game<'_> {
|
||||
death_cause,
|
||||
acting,
|
||||
packets,
|
||||
entity_events,
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
@@ -508,6 +512,7 @@ impl Game<'_> {
|
||||
combat,
|
||||
acting,
|
||||
packets,
|
||||
entity_events,
|
||||
swings,
|
||||
projectiles,
|
||||
modifiers,
|
||||
@@ -529,6 +534,17 @@ 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(
|
||||
@@ -1216,6 +1232,12 @@ impl Game<'_> {
|
||||
{
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::SelectBundleItem(p) => {
|
||||
if !spectator && let Ok(slot) = usize::try_from(p.slot) {
|
||||
host.hub
|
||||
.select_bundle_item(conn, slot, p.index, &mut self.world);
|
||||
}
|
||||
}
|
||||
S::ContainerClick(p) => {
|
||||
let rules = ItemRules(self.res.items());
|
||||
let slots = host.hub.guests[gi]
|
||||
@@ -1910,21 +1932,8 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
let play = host.play.entry(conn).or_default();
|
||||
// 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);
|
||||
// ServerGamePacketListenerImpl.tick's dropSpamThrottler.
|
||||
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;
|
||||
@@ -1961,6 +1970,24 @@ 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];
|
||||
@@ -2319,7 +2346,35 @@ impl Game<'_> {
|
||||
for event in events {
|
||||
match event {
|
||||
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
|
||||
// LivingEntity.checkFallDamage: the crumbs reach every player.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
|
||||
state,
|
||||
position,
|
||||
count,
|
||||
speed,
|
||||
}) => self.landing_dust(Some(host), state, position, count, speed),
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
// HoneyBlock.maybeDoSlideEffects: the sound reaches the
|
||||
// others (Player.playSound excludes the player, whose client
|
||||
// plays it), the entity event 53 everyone around.
|
||||
MotionEvent::HoneySlide {
|
||||
sound, particles, ..
|
||||
} => {
|
||||
let Some((id, at)) = host.hub.guest(conn).map(|g| (g.entity_id, g.position))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if sound {
|
||||
let name = "minecraft:block.honey_block.slide";
|
||||
let source = horizon_client::settings::sound_source(name) as i32;
|
||||
self.audio.play_unshared(name, Some(at), 1.0, 1.0);
|
||||
host.hub.sound(name, at, 1.0, 1.0, source, Some(conn));
|
||||
}
|
||||
if particles {
|
||||
self.honey_slide_particles(at);
|
||||
host.hub.entity_event(id, 53, at);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +357,20 @@ impl Game<'_> {
|
||||
a.is_some_and(|a| a.in_ground) || p.stuck,
|
||||
));
|
||||
}
|
||||
// ThrownTrident.ID_LOYALTY (none for a mob's) and ID_FOIL, on
|
||||
// AbstractArrow's flags.
|
||||
K::Trident => {
|
||||
let a = p.arrow.as_deref();
|
||||
e.meta.extend(arrow_meta(
|
||||
false,
|
||||
a.is_some_and(|a| a.no_physics),
|
||||
0,
|
||||
a.is_some_and(|a| a.in_ground) || p.stuck,
|
||||
));
|
||||
if a.is_some_and(|a| a.pickup_item.has_any_enchantments()) {
|
||||
e.meta.push((12, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// WitherSkull.DATA_DANGEROUS.
|
||||
K::WitherSkull { dangerous: true } => {
|
||||
e.meta.push((8, serializer::BOOLEAN, Value::Bool(true)));
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Particles a server sends its players and its clients draw:
|
||||
//! `ClientboundLevelParticlesPacket` (`ServerLevel.sendParticles`, which
|
||||
//! `/particle` uses) and `ClientboundExplodePacket` (`ServerLevel.explode`).
|
||||
use super::Game;
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::lan::hub::Hub;
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_protocol::{
|
||||
@@ -81,6 +82,66 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.checkFallDamage`'s `ServerLevel.sendParticles` of the
|
||||
/// landing block's crumbs: drawn in this window, when it has one, and
|
||||
/// sent to each player within 32 blocks. `host` is the hub of a guest's
|
||||
/// turn, which is out of `self.lan` meanwhile.
|
||||
pub(super) fn landing_dust(
|
||||
&mut self,
|
||||
host: Option<&LanHost>,
|
||||
state: BlockStateId,
|
||||
at: Vec3,
|
||||
count: i32,
|
||||
speed: f64,
|
||||
) {
|
||||
let at = [at.x, at.y, at.z];
|
||||
if self.serving.is_none() && self.options.dedicated.is_none() {
|
||||
let block = [
|
||||
at[0].floor() as i32,
|
||||
(at[1] - 0.2).floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
];
|
||||
if let Some((sprite, tint)) = self.particle_sprite(state, block) {
|
||||
self.particles.send_particles(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite, tint),
|
||||
at,
|
||||
[0.0; 3],
|
||||
speed,
|
||||
count as u32,
|
||||
);
|
||||
}
|
||||
}
|
||||
let Some(hub) = host.or(self.lan.as_ref()).map(|h| &h.hub) else {
|
||||
return;
|
||||
};
|
||||
let Some(particle) = Self::hub_particle(
|
||||
hub,
|
||||
&ParticleOptions::simple(ParticleType::Block),
|
||||
Some(state),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: [0.0; 3],
|
||||
max_speed: [speed as f32; 3],
|
||||
count,
|
||||
randomization: 0,
|
||||
});
|
||||
for g in hub.guests.iter().filter(|g| hub.here(g)) {
|
||||
// ServerLevel compares the player's block centre, strictly within 32.
|
||||
let distance: f64 = (0..3)
|
||||
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
|
||||
.sum();
|
||||
if distance < 32.0 * 32.0 {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ParticleTypes.STREAM_CODEC`'s value for `options`: the type's
|
||||
/// network id and the data it carries (`state` for the block particles).
|
||||
/// None while not serving, or for a particle whose data isn't sent from
|
||||
@@ -89,13 +150,19 @@ impl Game<'_> {
|
||||
&self,
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
) -> Option<Particle> {
|
||||
Self::hub_particle(&self.lan.as_ref()?.hub, options, state)
|
||||
}
|
||||
|
||||
/// `network_particle` for a hub that is out of `self.lan` meanwhile (a
|
||||
/// guest's turn).
|
||||
fn hub_particle(
|
||||
hub: &Hub,
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
) -> Option<Particle> {
|
||||
let name = format!("minecraft:{}", options.ty.name());
|
||||
let id = self
|
||||
.lan
|
||||
.as_ref()?
|
||||
.hub
|
||||
.registry_id("minecraft:particle_type", &name)?;
|
||||
let id = hub.registry_id("minecraft:particle_type", &name)?;
|
||||
use ParticleType as T;
|
||||
let c = options.color;
|
||||
let data = match options.ty {
|
||||
@@ -254,8 +321,14 @@ impl Game<'_> {
|
||||
/// `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, center: [f64; 3], radius: f32, block_count: usize) {
|
||||
let emitter = if radius < 2.0 {
|
||||
pub(super) fn lan_explode(
|
||||
&self,
|
||||
center: [f64; 3],
|
||||
small: bool,
|
||||
radius: f32,
|
||||
block_count: usize,
|
||||
) {
|
||||
let emitter = if small {
|
||||
"minecraft:explosion"
|
||||
} else {
|
||||
"minecraft:explosion_emitter"
|
||||
|
||||
@@ -37,6 +37,8 @@ pub(super) struct Strike {
|
||||
amount: f32,
|
||||
scale: f32,
|
||||
crit: bool,
|
||||
/// `Player.attack`'s `magicBoost > 0`: the enchantments added damage.
|
||||
magic: bool,
|
||||
from: [f64; 3],
|
||||
sweep: Option<[f64; 3]>,
|
||||
/// `causeExtraKnockback`: its strength and direction (the attacker's
|
||||
@@ -109,7 +111,7 @@ impl Game<'_> {
|
||||
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];
|
||||
let (amount, scale, crit) = self.melee_damage("minecraft:player");
|
||||
let (amount, scale, crit, magic) = self.melee_damage("minecraft:player");
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
|
||||
let (xd, zd) = self.extra_knockback_direction();
|
||||
@@ -125,6 +127,7 @@ impl Game<'_> {
|
||||
amount,
|
||||
scale,
|
||||
crit,
|
||||
magic,
|
||||
from,
|
||||
sweep,
|
||||
knockback: (strength, xd, zd),
|
||||
@@ -257,7 +260,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
if hurt && s.crit {
|
||||
if hurt && (s.crit || s.magic) {
|
||||
let target = match victim {
|
||||
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
|
||||
Victim::Guest(conn) => host
|
||||
@@ -267,13 +270,17 @@ impl Game<'_> {
|
||||
};
|
||||
if let Some((id, p)) = target {
|
||||
let d = p.dimensions;
|
||||
self.critical_hit(
|
||||
s.attacker,
|
||||
id,
|
||||
let (feet, width, height) = (
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
f64::from(d.width),
|
||||
f64::from(d.height),
|
||||
);
|
||||
if s.crit {
|
||||
self.critical_hit(s.attacker, id, feet, width, height, false);
|
||||
}
|
||||
if s.magic {
|
||||
self.critical_hit(s.attacker, id, feet, width, height, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
// `Enchantment.doPostAttack`: thorns hurt the attacking player.
|
||||
|
||||
@@ -104,6 +104,9 @@ impl Game<'_> {
|
||||
};
|
||||
let m = &mut self.mobs[i];
|
||||
if let Some(h) = holder {
|
||||
if h == Holder::Player {
|
||||
m.extra.leash_player = Some(player_uuid);
|
||||
}
|
||||
m.extra.leash = Some(h);
|
||||
m.extra.leash_pending = None;
|
||||
} else if m.age > 100 {
|
||||
@@ -201,7 +204,9 @@ impl Game<'_> {
|
||||
if self.mobs[index].extra.leash.is_some() {
|
||||
self.break_leash(id, true, false);
|
||||
}
|
||||
let holder = self.acting_player_uuid();
|
||||
self.mobs[index].extra.leash = Some(Holder::Player);
|
||||
self.mobs[index].extra.leash_player = Some(holder);
|
||||
self.lead_sound("tied", at);
|
||||
// The lead is always used up.
|
||||
if let Some(stack) = self.held_mut().as_mut() {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
|
||||
//! off (3004) and scraping (3005).
|
||||
use super::Game;
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::particles::{self, ParticleOptions, ParticleType};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_world::level::LevelEvent;
|
||||
@@ -12,6 +13,12 @@ impl Game<'_> {
|
||||
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
|
||||
@@ -134,6 +141,92 @@ impl Game<'_> {
|
||||
// Each guest plays it from the global packet.
|
||||
self.audio.play_unshared(sound, at, volume, 1.0);
|
||||
}
|
||||
/// `ClientLevel.addBreakingBlockEffects`: the block's hit sound (only for
|
||||
/// `play_sound`) and one crumb on the face being mined; nothing for air.
|
||||
pub(super) fn breaking_block_effects(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
face: [i32; 3],
|
||||
play_sound: bool,
|
||||
) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if self.res.facts().is_air(state) {
|
||||
return;
|
||||
}
|
||||
if play_sound {
|
||||
let sounds = self.res.facts().get(state).sounds.clone();
|
||||
// Each guest plays it from the level event itself.
|
||||
self.audio.play_unshared(
|
||||
&sounds.hit,
|
||||
Some(pos.map(|v| f64::from(v) + 0.5)),
|
||||
(sounds.volume + 1.0) / 8.0,
|
||||
sounds.pitch * 0.5,
|
||||
);
|
||||
}
|
||||
self.crack_particle(pos, face, state);
|
||||
}
|
||||
/// `LevelEventHandler.potionSplashParticles` (2002 with `Effect`, 2007
|
||||
/// with `InstantEffect`): eight splash potion crumbs, and a hundred
|
||||
/// spell particles in the potion's colour `data`, flung out in a disc.
|
||||
pub(super) fn potion_splash_particles(&mut self, pos: BlockPos, data: i32, instant: bool) {
|
||||
let ty = if instant {
|
||||
ParticleType::InstantEffect
|
||||
} else {
|
||||
ParticleType::Effect
|
||||
};
|
||||
// Vec3.atBottomCenterOf.
|
||||
let at = [
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y),
|
||||
f64::from(pos.z) + 0.5,
|
||||
];
|
||||
let crumb = self.item_particle(&ItemStack::new("minecraft:splash_potion", 1));
|
||||
let r = self.particles.level_random();
|
||||
let crumbs: Vec<[f64; 3]> = (0..8)
|
||||
.map(|_| {
|
||||
[
|
||||
r.next_gaussian() * 0.15,
|
||||
r.next_double() * 0.2,
|
||||
r.next_gaussian() * 0.15,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
for v in crumbs {
|
||||
self.particles.add(&crumb, at, v);
|
||||
}
|
||||
let channel = |shift: i32| ((data >> shift) & 0xFF) as f32 / 255.0;
|
||||
let color = [channel(16), channel(8), channel(0)];
|
||||
for _ in 0..100 {
|
||||
let r = self.particles.level_random();
|
||||
let dist = r.next_double() * 4.0;
|
||||
let angle = r.next_double() * std::f64::consts::PI * 2.0;
|
||||
let velocity = [
|
||||
angle.cos() * dist,
|
||||
0.01 + r.next_double() * 0.5,
|
||||
angle.sin() * dist,
|
||||
];
|
||||
let brightness = 0.75 + r.next_float() * 0.25;
|
||||
let options = ParticleOptions {
|
||||
color: [
|
||||
color[0] * brightness,
|
||||
color[1] * brightness,
|
||||
color[2] * brightness,
|
||||
1.0,
|
||||
],
|
||||
scale: dist as f32,
|
||||
..ParticleOptions::simple(ty)
|
||||
};
|
||||
self.particles.add(
|
||||
&options,
|
||||
[
|
||||
at[0] + velocity[0] * 0.1,
|
||||
at[1] + 0.3,
|
||||
at[2] + velocity[2] * 0.1,
|
||||
],
|
||||
velocity,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Queue and play level event `id` at `pos` (`Level.levelEvent`).
|
||||
pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) {
|
||||
self.level_events.push(LevelEvent::LevelEvent {
|
||||
|
||||
@@ -75,7 +75,7 @@ pub(super) struct Parked {
|
||||
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
|
||||
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
|
||||
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
|
||||
lan_critical_hits: Vec<(Option<u32>, i32)>,
|
||||
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
|
||||
/// Only the per-level parts of these are used (`swap_level`).
|
||||
anims: BlockEntityAnims,
|
||||
raiding: Raiding,
|
||||
|
||||
@@ -90,6 +90,8 @@ 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;
|
||||
@@ -186,6 +188,7 @@ impl Minecart {
|
||||
fuse: -1,
|
||||
enabled: true,
|
||||
loot: None,
|
||||
first_tick: true,
|
||||
}
|
||||
}
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
@@ -536,6 +539,7 @@ 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);
|
||||
@@ -1340,9 +1344,8 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's mean
|
||||
// edge, scaled by the entity distance option.
|
||||
let range =
|
||||
(WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * f64::from(self.settings.entity_distance);
|
||||
// edge, scaled by the view scale.
|
||||
let range = (WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * self.view_scale();
|
||||
for c in &self.carts {
|
||||
let entity_start = out.len();
|
||||
let Some(model) = self.cart_models.models.get(&c.kind) else {
|
||||
@@ -1509,6 +1512,10 @@ 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));
|
||||
@@ -1558,6 +1565,7 @@ 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());
|
||||
}
|
||||
@@ -1582,4 +1590,30 @@ 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)));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Blocks mobs carry: block items in a hand (`ItemInHandLayer`) and the
|
||||
//! block an enderman holds (`CarriedBlockLayer`), drawn as shaded cubes like
|
||||
//! dropped block items.
|
||||
//! dropped block items. Also the block models mobs wear (`SnowGolemHeadLayer`,
|
||||
//! `MushroomCowMushroomLayer`).
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
@@ -218,6 +219,7 @@ impl Game<'_> {
|
||||
) {
|
||||
self.captain_banner_geometry(mob, camera, alpha, light, out);
|
||||
self.held_item_geometry(mob, camera, alpha, light, true, out);
|
||||
self.worn_block_geometry(mob, camera, alpha, light, out);
|
||||
if mob.species() == Species::Enderman
|
||||
&& let Some(state) = mob.extra.carried
|
||||
&& let Some((sprite, tint)) = self.block_face_sprite(state)
|
||||
@@ -244,6 +246,76 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `SnowGolemHeadLayer` and `MushroomCowMushroomLayer`: the carved pumpkin
|
||||
/// on a snow golem's head, and the mushrooms on an adult mooshroom's back
|
||||
/// and head, as baked block models.
|
||||
fn worn_block_geometry(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
use glam::{Mat4, Vec3};
|
||||
let species = mob.species();
|
||||
let on_head = match species {
|
||||
Species::SnowGolem => !mob.extra.sheared,
|
||||
Species::Mooshroom => !mob.is_baby(),
|
||||
_ => false,
|
||||
};
|
||||
// The layers draw nothing on an invisible mob (the glow outline is not drawn).
|
||||
if !on_head || horizon_client::mobs::trader::is_invisible(mob) {
|
||||
return;
|
||||
}
|
||||
let registry = &self.pack.assets.registry;
|
||||
let block = match species {
|
||||
Species::SnowGolem => "minecraft:carved_pumpkin",
|
||||
_ if mob.extra.variant == 1 => "minecraft:brown_mushroom",
|
||||
_ => "minecraft:red_mushroom",
|
||||
};
|
||||
let Some(state) = registry.default_state(block) else {
|
||||
return;
|
||||
};
|
||||
let alpha = alpha as f32;
|
||||
let Some(head) = render::part_transform(mob, alpha, camera, self.res.mob_data(), "head")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let unit = Mat4::from_translation(Vec3::splat(-0.5));
|
||||
let flip = Mat4::from_scale(Vec3::new(-1.0, -1.0, 1.0));
|
||||
let at = |x: f32, y: f32, z: f32| Mat4::from_translation(Vec3::new(x, y, z));
|
||||
let spin = |degrees: f32| Mat4::from_rotation_y(degrees.to_radians());
|
||||
let transforms = if species == Species::SnowGolem {
|
||||
vec![
|
||||
head * at(0.0, -0.34375, 0.0)
|
||||
* spin(180.0)
|
||||
* Mat4::from_scale(Vec3::new(0.625, -0.625, -0.625))
|
||||
* unit,
|
||||
]
|
||||
} else {
|
||||
let base = render::base_transform(mob, alpha, camera);
|
||||
vec![
|
||||
base * at(0.2, -0.35, 0.5) * spin(-48.0) * flip * unit,
|
||||
base * at(0.2, -0.35, 0.5)
|
||||
* spin(42.0)
|
||||
* at(0.1, 0.0, -0.6)
|
||||
* spin(-48.0)
|
||||
* flip
|
||||
* unit,
|
||||
head * at(0.0, -0.7, -0.2) * spin(-78.0) * flip * unit,
|
||||
]
|
||||
};
|
||||
// The hurt tint reaches these blocks as it does the body.
|
||||
let overlay = horizon_client::overlay::living(0.0, mob.hurt_time > 0 || mob.death_time > 0);
|
||||
for t in transforms {
|
||||
let start = out.len();
|
||||
self.block_model_vertices(state, 0, t, light, true, out);
|
||||
for vertex in &mut out[start..] {
|
||||
vertex.layer = overlay;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ItemInHandLayer`: both hands through the item model pipeline; `lit`
|
||||
/// applies the level's diffuse lighting (off for GUI previews, which
|
||||
/// light the projected quads themselves).
|
||||
|
||||
@@ -31,6 +31,9 @@ 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.
|
||||
@@ -51,6 +54,13 @@ 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.
|
||||
@@ -190,4 +200,12 @@ 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,6 +126,19 @@ 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
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! `Entity.setAsInsidePortal` / `handlePortal` with `PortalProcessor`,
|
||||
//! `NetherPortalBlock.getPortalTransitionTime` and `getPortalDestination`
|
||||
//! (`PortalForcer` through `horizon_world::level::portal_travel`), and
|
||||
//! `LocalPlayer.handlePortalTransitionEffect`.
|
||||
//! `LocalPlayer.handlePortalTransitionEffect`. Items and mobs also go through
|
||||
//! End portals here (`EndPortalBlock.entityInside` / `getPortalDestination`);
|
||||
//! the player's End portals are game/end.rs's.
|
||||
//!
|
||||
//! 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
|
||||
@@ -12,7 +14,7 @@ use super::{Dim, Game, Screen};
|
||||
use horizon_client::Result;
|
||||
use horizon_client::nether_portal::PortalState;
|
||||
use horizon_core::{
|
||||
geometry::{Axis, Vec3},
|
||||
geometry::{Aabb, Axis, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
@@ -40,6 +42,32 @@ 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.
|
||||
@@ -81,6 +109,89 @@ 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.
|
||||
@@ -146,8 +257,54 @@ 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.
|
||||
@@ -520,9 +677,10 @@ impl Game<'_> {
|
||||
|| self.ridden_by_guest(id)
|
||||
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
}
|
||||
/// `NetherPortalBlock.entityInside` for the items, mobs and empty boats
|
||||
/// and minecarts that moved this tick: the portal blocks their box swept
|
||||
/// through.
|
||||
/// `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).
|
||||
pub(super) fn find_entity_portals(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
@@ -570,7 +728,7 @@ impl Game<'_> {
|
||||
.set_inside(pos, ENTITY_DELAY);
|
||||
}
|
||||
}
|
||||
/// The first nether portal block an entity box swept through
|
||||
/// The first nether or End portal block an entity box swept through
|
||||
/// (`Entity.checkInsideBlocks`, whose first portal sets the entry).
|
||||
fn swept_portal(
|
||||
&self,
|
||||
@@ -604,11 +762,9 @@ impl Game<'_> {
|
||||
bounds,
|
||||
1_000_000,
|
||||
|p, _| {
|
||||
if self
|
||||
.res
|
||||
.behaviours()
|
||||
.portal_axis(self.world.block(p.x, p.y, p.z))
|
||||
.is_some()
|
||||
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))
|
||||
{
|
||||
found = Some(p);
|
||||
return false;
|
||||
@@ -618,8 +774,8 @@ impl Game<'_> {
|
||||
);
|
||||
found
|
||||
}
|
||||
/// Whether a section `reach` touches has a nether portal state in its
|
||||
/// palette: without one, no block there is a portal. Palette answers are
|
||||
/// 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
|
||||
/// kept per chunk across ticks (`portal_sections`); a box over many
|
||||
/// sections is not checked.
|
||||
fn may_hold_portal(
|
||||
@@ -656,9 +812,8 @@ impl Game<'_> {
|
||||
(z0..=z1).any(|cz| {
|
||||
chunks.get(&(cx, cz)).is_some_and(|chunk| {
|
||||
sections.get((cx, cz), chunk, |v| {
|
||||
behaviours
|
||||
.portal_axis(horizon_world::registry::BlockStateId(v))
|
||||
.is_some()
|
||||
let state = horizon_world::registry::BlockStateId(v);
|
||||
behaviours.portal_axis(state).is_some() || self.is_end_portal(state)
|
||||
}) & y_mask
|
||||
!= 0
|
||||
})
|
||||
@@ -670,7 +825,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.portal_plan(entry, e.position, 0.25, 0.25, false);
|
||||
let plan = self.entity_plan(entry, e.position, 0.25, 0.25, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
@@ -686,7 +841,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.portal_plan(entry, m.position, m.width(), m.height(), true);
|
||||
let plan = self.entity_plan(entry, m.position, m.width(), m.height(), true);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
@@ -716,7 +871,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.portal_plan(entry, b.position, width, height, false);
|
||||
let plan = self.entity_plan(entry, b.position, width, height, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
@@ -741,7 +896,7 @@ impl Game<'_> {
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
);
|
||||
let plan = self.portal_plan(entry, c.position, width, height, false);
|
||||
let plan = self.entity_plan(entry, c.position, width, height, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
@@ -754,8 +909,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// Entities waiting for the loaded dimension whose exit area is loaded:
|
||||
/// their exit portal (found or built), position, turned yaw and motion,
|
||||
/// and portal cooldown; they join the world with the chunk entities.
|
||||
/// 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.
|
||||
pub(super) fn resolve_arrivals(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
@@ -772,21 +927,28 @@ impl Game<'_> {
|
||||
}
|
||||
let Arrival { plan, mut tag } = self.portals.arrivals.remove(i);
|
||||
self.portals.dirty = true;
|
||||
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 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])
|
||||
};
|
||||
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 + rotation).to_bits()),
|
||||
Tag::Float(pitch.to_bits()),
|
||||
]),
|
||||
Tag::List(vec![Tag::Float(yaw.to_bits()), Tag::Float(pitch.to_bits())]),
|
||||
);
|
||||
// setPortalCooldown before the teleport, copied by restoreFrom.
|
||||
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
@@ -823,75 +985,117 @@ impl Game<'_> {
|
||||
let Some(Tag::List(list)) = root.get("arrivals") else {
|
||||
return;
|
||||
};
|
||||
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(),
|
||||
});
|
||||
}
|
||||
self.portals
|
||||
.arrivals
|
||||
.extend(list.iter().filter_map(arrival_from_tag));
|
||||
}
|
||||
/// 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(|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 list = self.portals.arrivals.iter().map(arrival_tag).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,6 +34,10 @@ 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,8 +267,7 @@ 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.settings.entity_distance);
|
||||
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
|
||||
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use horizon_client::{
|
||||
hud_layout, hud_overlays, inventory_effects,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
|
||||
@@ -26,6 +27,9 @@ pub(super) struct ThrownPotion {
|
||||
position: Vec3,
|
||||
velocity: Vec3,
|
||||
age: u32,
|
||||
/// `Projectile.getOwner`'s display name: the player who threw it (none
|
||||
/// for a dispenser's).
|
||||
owner: Option<Text>,
|
||||
}
|
||||
/// A mob's effect ids carry the `minecraft:` namespace.
|
||||
fn mob_effect_id(id: &str) -> Option<&'static str> {
|
||||
@@ -47,7 +51,13 @@ fn mob_effect_id(id: &str) -> Option<&'static str> {
|
||||
impl Game<'_> {
|
||||
/// 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(&mut self, stack: ItemStack, position: Vec3, velocity: Vec3) {
|
||||
pub(super) fn launch_thrown(
|
||||
&mut self,
|
||||
stack: ItemStack,
|
||||
position: Vec3,
|
||||
velocity: Vec3,
|
||||
owner: Option<Text>,
|
||||
) {
|
||||
self.potions.thrown.push(ThrownPotion {
|
||||
id: 0,
|
||||
stack,
|
||||
@@ -55,6 +65,7 @@ impl Game<'_> {
|
||||
position,
|
||||
velocity,
|
||||
age: 0,
|
||||
owner,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -135,6 +146,7 @@ impl PotionState {
|
||||
position: at,
|
||||
velocity,
|
||||
age: 0,
|
||||
owner: None,
|
||||
});
|
||||
}
|
||||
/// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it shows.
|
||||
@@ -237,6 +249,7 @@ impl Game<'_> {
|
||||
},
|
||||
Vec3::new(eye[0], eye[1] - 0.1, eye[2]),
|
||||
v,
|
||||
Some(self.player_display_text()),
|
||||
);
|
||||
self.swing_arm();
|
||||
self.consume_held();
|
||||
@@ -300,6 +313,7 @@ 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);
|
||||
@@ -548,15 +562,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if let Some(p) = hit {
|
||||
landed.push((t.stack.clone(), p));
|
||||
landed.push((t.stack.clone(), p, t.owner.clone()));
|
||||
return false;
|
||||
}
|
||||
t.position = next;
|
||||
t.age < 1200 && t.position.y > -128.0
|
||||
});
|
||||
self.potions.thrown = thrown;
|
||||
for (stack, at) in landed {
|
||||
self.splash(&stack, at);
|
||||
for (stack, at, owner) in landed {
|
||||
self.splash(&stack, at, owner);
|
||||
}
|
||||
}
|
||||
/// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which
|
||||
@@ -664,6 +678,7 @@ impl Game<'_> {
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
at: Vec3,
|
||||
scale: f64,
|
||||
source: &Text,
|
||||
) {
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let center = Vec3::new(
|
||||
@@ -679,6 +694,18 @@ impl Game<'_> {
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
@@ -694,68 +721,47 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash).
|
||||
fn splash(&mut self, stack: &ItemStack, at: Vec3) {
|
||||
fn splash(&mut self, stack: &ItemStack, at: Vec3, 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
|
||||
// event 2002) and 3 to 11 experience.
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.audio.play(
|
||||
"minecraft:entity.splash_potion.break",
|
||||
Some([at.x, at.y, at.z]),
|
||||
1.0,
|
||||
pitch,
|
||||
);
|
||||
for _ in 0..8 {
|
||||
let v = [
|
||||
f64::from(self.world.random.next_float() - 0.5) * 0.3,
|
||||
0.2,
|
||||
f64::from(self.world.random.next_float() - 0.5) * 0.3,
|
||||
];
|
||||
let p = horizon_client::particles::Particle::terrain(
|
||||
&mut self.world.random,
|
||||
[at.x, at.y, at.z],
|
||||
v,
|
||||
"item/splash_potion".into(),
|
||||
0x38_5DC6,
|
||||
);
|
||||
self.particles.push(p);
|
||||
}
|
||||
// event 2002, its sound 1053) and 3 to 11 experience.
|
||||
self.level_event_at(2002, block, 0xFF38_5DC6u32 as i32);
|
||||
self.level_event_at(1053, block, 0);
|
||||
let r = &mut self.world.random;
|
||||
let value = 3 + r.next_int_bound(5).unwrap_or(0) + r.next_int_bound(5).unwrap_or(0);
|
||||
self.spawn_orbs([at.x, at.y, at.z], value as u32);
|
||||
return;
|
||||
}
|
||||
let lingering = &*stack.item == "minecraft:lingering_potion";
|
||||
let event = if lingering {
|
||||
"minecraft:entity.lingering_potion.break"
|
||||
} else {
|
||||
"minecraft:entity.splash_potion.break"
|
||||
};
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.audio.play(event, Some([at.x, at.y, at.z]), 1.0, pitch);
|
||||
// AbstractThrownPotion.onHit: level event 2007 and sound 1054 for a
|
||||
// potion with instant effects, 2002 and 1053 otherwise.
|
||||
let potion = stack.potion().and_then(brewing::potion);
|
||||
let instant = potion.is_some_and(|d| {
|
||||
d.effects
|
||||
.iter()
|
||||
.any(|(id, ..)| brewing::effect(id).is_some_and(|e| e.instant))
|
||||
});
|
||||
let color = brewing::potion_color(stack.potion());
|
||||
for _ in 0..8 {
|
||||
let v = [
|
||||
f64::from(self.world.random.next_float() - 0.5) * 0.3,
|
||||
0.2,
|
||||
f64::from(self.world.random.next_float() - 0.5) * 0.3,
|
||||
];
|
||||
let p = horizon_client::particles::Particle::terrain(
|
||||
&mut self.world.random,
|
||||
[at.x, at.y, at.z],
|
||||
v,
|
||||
"item/splash_potion".into(),
|
||||
color,
|
||||
);
|
||||
self.particles.push(p);
|
||||
}
|
||||
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) = stack.potion().and_then(brewing::potion) else {
|
||||
let Some(def) = potion else {
|
||||
return;
|
||||
};
|
||||
// Every player in range (`ThrownSplashPotion.applySplash` over
|
||||
// `LivingEntity`s, the players among them).
|
||||
self.for_each_player(|g| g.splash_on_player(def.effects, at, scale));
|
||||
// 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));
|
||||
// Mobs take the instant effects (undead invert healing and harming).
|
||||
let undead = &self.res.stations().enchantments.entity_tags;
|
||||
let mut hits = Vec::new();
|
||||
|
||||
@@ -609,6 +609,7 @@ 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;
|
||||
}
|
||||
@@ -694,6 +695,7 @@ 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);
|
||||
@@ -714,6 +716,22 @@ 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,13 +117,17 @@ 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,
|
||||
/// 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,
|
||||
view_distance: i32,
|
||||
/// Options.serverRenderDistance: the server's view distance from the
|
||||
/// login or the last chunk-cache-radius packet.
|
||||
pub(super) view_distance: i32,
|
||||
sequence: i32,
|
||||
position_sender: horizon_client::player_sync::PositionSender,
|
||||
last_input: u8,
|
||||
@@ -189,6 +193,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)>,
|
||||
death_message: Option<String>,
|
||||
@@ -212,6 +220,9 @@ 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
|
||||
@@ -394,6 +405,9 @@ 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,
|
||||
@@ -428,6 +442,7 @@ 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"),
|
||||
@@ -473,6 +488,7 @@ impl Game<'_> {
|
||||
destroying: None,
|
||||
destroy_delay: 0,
|
||||
input: Default::default(),
|
||||
pending_keys: Vec::new(),
|
||||
stop_mining: false,
|
||||
placing: None,
|
||||
death_message: None,
|
||||
@@ -486,6 +502,7 @@ 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.
|
||||
@@ -862,6 +879,11 @@ 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) => {
|
||||
@@ -1560,7 +1582,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// Find a game slot in the menu's complete, contiguous slot mapping.
|
||||
fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
|
||||
pub(super) fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
|
||||
(0..)
|
||||
.map_while(|slot| Self::menu_slot(menu, slot))
|
||||
.position(|slot| slot == internal)
|
||||
@@ -1621,7 +1643,11 @@ 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;
|
||||
@@ -1639,8 +1665,10 @@ impl Game<'_> {
|
||||
self.send(S::ClientInformation(info));
|
||||
}
|
||||
C::Respawn(p) => {
|
||||
// A new player, without effects: the server sends them again.
|
||||
// A new player, without effects (the server sends them
|
||||
// again) and with no item in use.
|
||||
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() {
|
||||
@@ -1741,7 +1769,17 @@ impl Game<'_> {
|
||||
f64::from(p.y) / 8.0,
|
||||
f64::from(p.z) / 8.0,
|
||||
];
|
||||
self.audio.play(&name, Some(at), p.volume, p.pitch);
|
||||
// ClientPacketListener.handleSoundEvent plays with packet.getSource().
|
||||
match usize::try_from(p.source)
|
||||
.ok()
|
||||
.filter(|s| *s < horizon_client::settings::SOUND_SOURCES.len())
|
||||
{
|
||||
Some(source) => {
|
||||
self.audio
|
||||
.play_on(source, &name, Some(at), p.volume, p.pitch)
|
||||
}
|
||||
None => self.audio.play(&name, Some(at), p.volume, p.pitch),
|
||||
}
|
||||
}
|
||||
}
|
||||
C::StopSound(p) => self
|
||||
@@ -1975,9 +2013,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Entity.DATA_SHARED_FLAGS_ID's crouching bit: another
|
||||
// player's or a mannequin's crouch (their model and
|
||||
// Entity.isDiscrete's name tag).
|
||||
// Entity.DATA_SHARED_FLAGS_ID's crouching and sprinting bits:
|
||||
// another player's or a mannequin's crouch (their model and
|
||||
// Entity.isDiscrete's name tag), and the dust a runner raises.
|
||||
if (mannequin
|
||||
|| self
|
||||
.remote
|
||||
@@ -1988,6 +2026,7 @@ impl Game<'_> {
|
||||
for (index, _, value) in &p.values {
|
||||
if let (0, Value::Byte(flags)) = (index, value) {
|
||||
m.extra.crouching = flags & 2 != 0;
|
||||
m.extra.sprinting = flags & 8 != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2136,9 +2175,38 @@ impl Game<'_> {
|
||||
{
|
||||
m.health = 0.0;
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 60: the poof as the dead body goes.
|
||||
if p.event == 60
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == p.id as u32)
|
||||
{
|
||||
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
|
||||
self.death_poof([at.x, at.y, at.z], w, h);
|
||||
}
|
||||
if p.event == super::totem::TOTEM_EVENT {
|
||||
self.remote_totem(p.id);
|
||||
}
|
||||
// Entity event 53: the honey slide particles of a player
|
||||
// sliding down a honey block (HoneyBlock.showSlideParticles).
|
||||
if p.event == 53 {
|
||||
let at = if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
self.player
|
||||
.as_ref()
|
||||
.map(|q| [q.position.x, q.position.y, q.position.z])
|
||||
} else {
|
||||
self.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == p.id as u32)
|
||||
.map(|m| [m.position.x, m.position.y, m.position.z])
|
||||
};
|
||||
if let Some(at) = at {
|
||||
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
|
||||
@@ -2301,10 +2369,11 @@ 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) => {
|
||||
self.selected = p.slot.clamp(0, 8) as usize;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.carried = Some(self.selected);
|
||||
if (0..9).contains(&p.slot) {
|
||||
self.selected = p.slot as usize;
|
||||
}
|
||||
}
|
||||
C::ContainerSetContent(p) => {
|
||||
@@ -3136,12 +3205,8 @@ 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
|
||||
// is sent again.
|
||||
let view = client_view_distance(self.settings.render_distance);
|
||||
@@ -3175,6 +3240,14 @@ 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();
|
||||
@@ -3296,6 +3369,11 @@ impl Game<'_> {
|
||||
resources: self.motion,
|
||||
}
|
||||
.controlled_simulation_tick(keys)?;
|
||||
} else {
|
||||
// LivingEntity.baseTick counts hurtTime down for a dead or riding
|
||||
// player too (controlled_simulation_tick does it otherwise): left
|
||||
// at 10, the hurt tilt restarted every frame and shook the camera.
|
||||
player.health.advance_clock();
|
||||
}
|
||||
// LocalPlayer.onUpdateAbilities: flight turned on or off (a double
|
||||
// jump, a spectator, landing) is sent to the server.
|
||||
@@ -3332,6 +3410,13 @@ impl Game<'_> {
|
||||
horizon_client::motion::MotionEvent::InsidePortal { .. } => {
|
||||
self.client_inside_nether_portal();
|
||||
}
|
||||
// HoneyBlock.maybeDoSlideEffects on the client's own level:
|
||||
// the slide sound, heard here (LocalPlayer.playSound); the
|
||||
// particles come from the server's event 53.
|
||||
horizon_client::motion::MotionEvent::HoneySlide { sound: true, .. } => {
|
||||
self.audio
|
||||
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -3508,6 +3593,16 @@ impl Game<'_> {
|
||||
if m.species() == horizon_client::mobs::Species::Rabbit {
|
||||
horizon_client::mobs::rabbit::base_tick(m);
|
||||
}
|
||||
if matches!(
|
||||
m.species(),
|
||||
horizon_client::mobs::Species::Horse
|
||||
| horizon_client::mobs::Species::Donkey
|
||||
| horizon_client::mobs::Species::Mule
|
||||
| horizon_client::mobs::Species::SkeletonHorse
|
||||
| horizon_client::mobs::Species::ZombieHorse
|
||||
) {
|
||||
horizon_client::mobs::equine::tail_tick(m);
|
||||
}
|
||||
if m.species() == horizon_client::mobs::Species::Creeper {
|
||||
horizon_client::mobs::hostile::creeper_client_tick(m);
|
||||
}
|
||||
@@ -3518,6 +3613,10 @@ impl Game<'_> {
|
||||
) {
|
||||
horizon_client::mobs::guardian::client_tick(m);
|
||||
}
|
||||
// PolarBear.tick: the stand animation follows the synched flag.
|
||||
if m.species() == horizon_client::mobs::Species::PolarBear {
|
||||
horizon_client::mobs::polar_bear::client_tick(m);
|
||||
}
|
||||
if m.hurt_time > 0 {
|
||||
m.hurt_time -= 1;
|
||||
}
|
||||
@@ -3872,6 +3971,9 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.destroying = Some((hit.pos, face, 0.0));
|
||||
}
|
||||
// startDestroyBlock plays the first hit and crack itself; the
|
||||
// server's level events 2019 and 2020 do the rest.
|
||||
self.breaking_block_effects(hit.pos, hit.face, true);
|
||||
self.mining = Some(Mining {
|
||||
pos: hit.pos,
|
||||
progress: 0.0,
|
||||
@@ -3884,20 +3986,6 @@ impl Game<'_> {
|
||||
};
|
||||
let done = done + progress;
|
||||
let ticks = self.mining.as_ref().map_or(0, |m| m.ticks) + 1;
|
||||
if ticks % 4 == 1 {
|
||||
let c = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
self.audio.play(
|
||||
&facts.sounds.hit,
|
||||
Some(c),
|
||||
(facts.sounds.volume + 1.0) / 8.0,
|
||||
facts.sounds.pitch * 0.5,
|
||||
);
|
||||
}
|
||||
self.crack_particle(pos, hit.face, state);
|
||||
if done >= 1.0 {
|
||||
let seq = self.next_sequence();
|
||||
self.predict_break(pos, state, seq);
|
||||
@@ -4296,13 +4384,27 @@ impl Game<'_> {
|
||||
let Some(info) = self.res.items().get(&held.item).cloned() else {
|
||||
return false;
|
||||
};
|
||||
// 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));
|
||||
// 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);
|
||||
}
|
||||
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
|
||||
return true;
|
||||
}
|
||||
@@ -4374,14 +4476,25 @@ 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. The server eats it and
|
||||
/// sends the stack, the hunger and the sounds.
|
||||
/// 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.
|
||||
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;
|
||||
@@ -4406,20 +4519,51 @@ impl Game<'_> {
|
||||
self.using = None;
|
||||
return;
|
||||
}
|
||||
// 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) {
|
||||
// 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) {
|
||||
self.eat_particles(&stack, 5);
|
||||
}
|
||||
if ticks + 1 >= duration {
|
||||
self.using = None;
|
||||
if consumable.particles {
|
||||
self.eat_particles(&stack, 16);
|
||||
}
|
||||
} else {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
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 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<()> {
|
||||
@@ -4484,6 +4628,31 @@ 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;
|
||||
}
|
||||
@@ -4595,22 +4764,8 @@ impl Game<'_> {
|
||||
if self.spectator() {
|
||||
return Ok(true);
|
||||
}
|
||||
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);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.pending_keys.push(key);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -4618,12 +4773,9 @@ impl Game<'_> {
|
||||
// horse or llama, the server opens its inventory
|
||||
// (ServerboundPlayerCommandPacket OPEN_INVENTORY).
|
||||
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 let Some(r) = self.remote.as_mut() {
|
||||
r.pending_keys.push(key);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
KeyCode::KeyE => {
|
||||
@@ -4670,6 +4822,21 @@ 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.
|
||||
@@ -4703,7 +4870,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The server's open menu: its window id and menu type (0, "inventory"
|
||||
/// when none is open).
|
||||
fn remote_menu(&self) -> (i32, String) {
|
||||
pub(super) fn remote_menu(&self) -> (i32, String) {
|
||||
match self.remote.as_ref().and_then(|r| r.menu.clone()) {
|
||||
Some((id, name)) => (id, name),
|
||||
None => (0, "inventory".to_owned()),
|
||||
@@ -4793,26 +4960,6 @@ impl Game<'_> {
|
||||
}));
|
||||
}
|
||||
}
|
||||
/// A `ContainerClick` on game slot `internal` of the server's open menu
|
||||
/// (`input` is the ContainerInput ordinal). False when the menu has no
|
||||
/// such slot.
|
||||
pub(super) fn remote_slot_click(&mut self, internal: usize, button: i8, input: i32) -> bool {
|
||||
let (container, menu) = self.remote_menu();
|
||||
let slot = Self::menu_slot_index(&menu, internal);
|
||||
let Some(slot) = slot else { return false };
|
||||
let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s));
|
||||
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
|
||||
self.send(S::ContainerClick(play::ContainerClick {
|
||||
container,
|
||||
state,
|
||||
slot: slot as i16,
|
||||
button,
|
||||
input,
|
||||
changed: Vec::new(),
|
||||
carried,
|
||||
}));
|
||||
true
|
||||
}
|
||||
/// The server's open menu is the inventory or a chest-like container
|
||||
/// (chests, barrels, ender chests: `generic_9xN`; shulker boxes).
|
||||
pub(super) fn remote_storage_open(&self) -> bool {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
use super::Game;
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
use horizon_client::mobs::{Mob, Species, Who, bat, tame};
|
||||
use horizon_protocol::entity_data::Value;
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_protocol::entity_data::{Particle, ParticleData, Value};
|
||||
use horizon_protocol::registry::Builtin;
|
||||
|
||||
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
|
||||
@@ -34,6 +35,10 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
// LivingEntity.DATA_HEALTH_ID: the boss bars and the death
|
||||
// animation follow it.
|
||||
(9, Value::Float(health)) => m.health = *health,
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID:
|
||||
// the swirls of its effects (`tickEffects` draws them).
|
||||
(10, Value::Particles(list)) => m.extra.effect_particles = swirls(list, builtin),
|
||||
(11, Value::Bool(ambient)) => m.extra.effect_ambient = *ambient,
|
||||
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4.
|
||||
// Mob.isAggressive draws the illagers' and skeletons' attack
|
||||
// poses and the vindicator's axe.
|
||||
@@ -59,6 +64,8 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
Who::Mob(*target as u32)
|
||||
});
|
||||
}
|
||||
// PolarBear.DATA_STANDING_ID: the client animates the rearing.
|
||||
(Species::PolarBear, 18, Value::Bool(standing)) => m.extra.flag = *standing,
|
||||
// Bat.DATA_ID_FLAGS: resting in bit 0.
|
||||
(Species::Bat, 16, Value::Byte(flags)) => bat::set_resting(m, flags & 1 != 0),
|
||||
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared.
|
||||
@@ -66,6 +73,14 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
m.extra.color = (*wool & 15) as u8;
|
||||
m.extra.sheared = wool & 16 != 0;
|
||||
}
|
||||
// SnowGolem.DATA_PUMPKIN_ID: 16 while the pumpkin is on.
|
||||
(Species::SnowGolem, 16, Value::Byte(flags)) => m.extra.sheared = flags & 16 == 0,
|
||||
// MushroomCow.DATA_TYPE: 0 red, 1 brown.
|
||||
(Species::Mooshroom, 17, Value::Int(variant)) => {
|
||||
m.extra.variant = (*variant == 1) as u8
|
||||
}
|
||||
// Piglin.DATA_IS_DANCING: the dance pose.
|
||||
(Species::Piglin, 19, Value::Bool(dancing)) => m.extra.scratch[0] = i32::from(*dancing),
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
(Species::Wolf | Species::Cat | Species::Parrot, 18, Value::Byte(flags)) => {
|
||||
m.extra.sitting = flags & 1 != 0;
|
||||
@@ -100,6 +115,30 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
}
|
||||
}
|
||||
|
||||
/// The options of a synched particle list: an entity effect carries its ARGB
|
||||
/// colour, the omens none.
|
||||
fn swirls(list: &[Particle], builtin: &Builtin) -> Vec<ParticleOptions> {
|
||||
let channel = |argb: i32, shift: u32| ((argb >> shift) & 255) as f32 / 255.0;
|
||||
list.iter()
|
||||
.filter_map(|p| {
|
||||
let name = builtin.get("minecraft:particle_type")?.name(p.id)?;
|
||||
let ty = ParticleType::from_name(name)?;
|
||||
Some(match p.data {
|
||||
Some(ParticleData::Color(argb)) => ParticleOptions::colored(
|
||||
ty,
|
||||
[
|
||||
channel(argb, 16),
|
||||
channel(argb, 8),
|
||||
channel(argb, 0),
|
||||
channel(argb, 24),
|
||||
],
|
||||
),
|
||||
_ => ParticleOptions::simple(ty),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// VillagerData as the network sends it: the type and the profession by
|
||||
/// their built-in registry ids, then the level.
|
||||
fn villager_data(m: &mut Mob, data: &[i32], builtin: &Builtin) {
|
||||
|
||||
@@ -162,6 +162,10 @@ impl Game<'_> {
|
||||
let Some(k) = projectile_kind(kind) else {
|
||||
return false;
|
||||
};
|
||||
if k == ProjectileKind::LlamaSpit {
|
||||
self.particles
|
||||
.llama_spit_burst(position, [velocity.x, velocity.y, velocity.z]);
|
||||
}
|
||||
let mut p = Projectile::new(k, at, None);
|
||||
p.id = id;
|
||||
p.velocity = velocity;
|
||||
|
||||
@@ -7,6 +7,7 @@ use horizon_client::{
|
||||
biome::Biomes,
|
||||
blocks::{BlockFacts, Fluid},
|
||||
chunk::{HEIGHT, MIN_Y},
|
||||
containers::BlockEntity,
|
||||
particles::{ParticleLevel, ParticleOptions, ParticleType},
|
||||
world::World,
|
||||
};
|
||||
@@ -344,6 +345,10 @@ impl Game<'_> {
|
||||
&mut self.particles,
|
||||
&ctx,
|
||||
r.block_entities(),
|
||||
&|pos| match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
|
||||
Some(BlockEntity::Campfire(c)) => c.items.each_ref().map(Option::is_some),
|
||||
_ => [false; 4],
|
||||
},
|
||||
);
|
||||
}
|
||||
let level = ParticleLevel {
|
||||
|
||||
@@ -132,8 +132,16 @@ impl Game<'_> {
|
||||
match t {
|
||||
Target::Player => Some(self.acting_player_name().to_owned()),
|
||||
Target::Guest(conn) => self.command_guest(conn).map(|g| g.name.clone()),
|
||||
Target::Mob(id) | Target::Item(id) => Some(uuid_text(self.uuid_for(id))),
|
||||
_ => None,
|
||||
Target::Mob(id)
|
||||
| Target::Item(id)
|
||||
| Target::Stand(id)
|
||||
| Target::Frame(id)
|
||||
| Target::Painting(id)
|
||||
| Target::Boat(id)
|
||||
| Target::Cart(id)
|
||||
| Target::Other(id) => Some(uuid_text(self.uuid_for(id))),
|
||||
// Orbs and projectiles are addressed by index and have no UUID here.
|
||||
Target::Orb(_) | Target::Projectile(_) => None,
|
||||
}
|
||||
}
|
||||
fn holder_for(&mut self, t: Target) -> Option<Holder> {
|
||||
|
||||
@@ -19,6 +19,9 @@ impl Screen {
|
||||
| Self::Advancements
|
||||
| Self::WorldOptions
|
||||
| Self::Friends
|
||||
| Self::ServerLinks
|
||||
| Self::ConfirmLink
|
||||
| Self::DialogWarning
|
||||
| Self::Mod => true,
|
||||
Self::Playing
|
||||
| Self::GameModeSwitcher
|
||||
@@ -35,20 +38,35 @@ 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) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
|
||||
fn pause_buttons(
|
||||
w: f32,
|
||||
h: f32,
|
||||
remote: bool,
|
||||
links: 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);
|
||||
let (grid_w, grid_h) = (212.0, 166.0 + if links { 24.0 } else { 0.0 });
|
||||
let x0 = ((w - grid_w) * 0.5).floor();
|
||||
let y0 = ((h - grid_h) * 0.25).floor();
|
||||
vec![
|
||||
let down = if links { 24.0 } else { 0.0 };
|
||||
let mut v = vec![
|
||||
(x0 + 4.0, y0 + 50.0, 204.0, "menu.returnToGame", 0, true),
|
||||
(x0 + 4.0, y0 + 74.0, 98.0, "gui.advancements", 4, true),
|
||||
(x0 + 110.0, y0 + 74.0, 98.0, "gui.stats", 3, true),
|
||||
(x0 + 4.0, y0 + 122.0, 98.0, "menu.options", 1, 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 + 110.0,
|
||||
y0 + 122.0,
|
||||
y0 + 122.0 + down,
|
||||
98.0,
|
||||
"options.worldOptions.button",
|
||||
9,
|
||||
@@ -56,7 +74,7 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s
|
||||
),
|
||||
(
|
||||
x0 + 4.0,
|
||||
y0 + 146.0,
|
||||
y0 + 146.0 + down,
|
||||
204.0,
|
||||
if remote {
|
||||
"menu.disconnect"
|
||||
@@ -66,16 +84,17 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s
|
||||
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) -> [(f32, f32, &'static str); 4] {
|
||||
fn pause_icons(w: f32, h: f32, links: bool) -> [(f32, f32, &'static str); 4] {
|
||||
let x0 = ((w - 212.0) * 0.5).floor() + 60.0;
|
||||
let y = ((h - 166.0) * 0.25).floor() + 98.0;
|
||||
let y = ((h - 166.0 - if links { 24.0 } else { 0.0 }) * 0.25).floor() + 98.0;
|
||||
[
|
||||
(x0, y, "gui/sprites/pause_menu/bug"),
|
||||
(x0 + 24.0, y, "gui/sprites/pause_menu/social_interactions"),
|
||||
@@ -120,7 +139,9 @@ impl Game<'_> {
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
for (x, y, bw, key, _, active) in pause_buttons(w, h, self.external_server()) {
|
||||
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) {
|
||||
let hover = active && ui::hovered(self.cursor, x, y, bw, 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
@@ -133,9 +154,12 @@ impl Game<'_> {
|
||||
active,
|
||||
hover,
|
||||
);
|
||||
// PauseScreen's customAdditions button: CUSTOM_OPTIONS_TOOLTIP.
|
||||
if id == 11 && hover {
|
||||
tooltip = Some(Text::translate("menu.custom_options.tooltip", &[]));
|
||||
}
|
||||
}
|
||||
let mut tooltip = None;
|
||||
for (i, (x, y, sprite)) in pause_icons(w, h).into_iter().enumerate() {
|
||||
for (i, (x, y, sprite)) in pause_icons(w, h, links).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
|
||||
@@ -168,7 +192,13 @@ impl Game<'_> {
|
||||
}
|
||||
// SpriteIconButton.withTootip: the label as a tooltip.
|
||||
if let Some(tip) = tooltip {
|
||||
ui::tooltip(batch, atlas, &[tip], self.cursor);
|
||||
let lines: Vec<Text> = atlas
|
||||
.font
|
||||
.split_lines(&tip, batch.width)
|
||||
.into_iter()
|
||||
.map(|(line, _)| line)
|
||||
.collect();
|
||||
ui::tooltip(batch, atlas, &lines, self.cursor);
|
||||
}
|
||||
}
|
||||
pub(super) fn pause_click(&mut self) -> Result<()> {
|
||||
@@ -180,8 +210,9 @@ impl Game<'_> {
|
||||
let b = HudBatch::new(size);
|
||||
(b.width, b.height)
|
||||
};
|
||||
let friends = pause_icons(w, h)[FRIENDS_ICON];
|
||||
let hit = pause_buttons(w, h, self.external_server())
|
||||
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)
|
||||
.into_iter()
|
||||
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
|
||||
.map(|b| b.4)
|
||||
@@ -200,6 +231,11 @@ 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()?,
|
||||
_ => {}
|
||||
}
|
||||
@@ -357,7 +393,7 @@ impl Game<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Screen, pause_buttons};
|
||||
use super::{Screen, pause_buttons, pause_icons};
|
||||
|
||||
#[test]
|
||||
fn menu_blur_matches_vanilla_screen_backgrounds() {
|
||||
@@ -390,11 +426,32 @@ 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);
|
||||
let buttons = pause_buttons(427.0, 240.0, remote, false);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
//! `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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,6 +277,10 @@ fn argb(c: i32) -> u32 {
|
||||
)
|
||||
}
|
||||
|
||||
/// A text style's `0xRRGGBB` colour from a HUD colour (`rgba`).
|
||||
fn rgb_of(rgba: u32) -> u32 {
|
||||
(rgba & 0xFF) << 16 | (rgba >> 8 & 0xFF) << 8 | (rgba >> 16 & 0xFF)
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `Component.translatable`: the selected language (which `lang` layers
|
||||
/// over `en_us`), the caller's English text last.
|
||||
@@ -1690,20 +1694,34 @@ impl Game<'_> {
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
) -> Vec<(horizon_client::text::Text, u32)> {
|
||||
let e: &Enchantments = &self.res.stations().enchantments;
|
||||
let props = e.props(&stack.item);
|
||||
self.stack_tooltip_lines(stack, false)
|
||||
}
|
||||
/// `ItemStack.getRarity`'s colour for the name line and a `[name]` link.
|
||||
fn rarity_color(&self, stack: &ItemStack) -> u32 {
|
||||
let props = self.res.stations().enchantments.props(&stack.item);
|
||||
let rarity = horizon_client::item_tooltip::rarity(
|
||||
stack,
|
||||
props.map_or("common", |p| p.rarity.as_str()),
|
||||
);
|
||||
let color = match rarity {
|
||||
match rarity {
|
||||
"uncommon" => rgba(255, 255, 85, 255),
|
||||
"rare" => rgba(85, 255, 255, 255),
|
||||
"epic" => rgba(255, 85, 255, 255),
|
||||
_ => u32::MAX,
|
||||
};
|
||||
let name = if let Some(n) = stack.custom_name() {
|
||||
}
|
||||
}
|
||||
/// `ItemStack.getHoverName`: the custom name, a written book's title, the
|
||||
/// item name component, else the item's or potion's name.
|
||||
fn stack_hover_name(&self, stack: &ItemStack) -> String {
|
||||
if let Some(n) = stack.custom_name() {
|
||||
n.to_owned()
|
||||
} else if let Some(title) = stack
|
||||
.data()
|
||||
.and_then(|d| d.written.as_ref())
|
||||
.map(|w| w.title.as_str())
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
{
|
||||
title.to_owned()
|
||||
} else if let Some(n) = stack.item_name() {
|
||||
n
|
||||
} else if stack.data().is_some_and(|d| d.ominous_banner) {
|
||||
@@ -1719,16 +1737,47 @@ impl Game<'_> {
|
||||
.unwrap_or_else(|| self.pack.names.display_item(&stack.item))
|
||||
} else {
|
||||
self.pack.names.display_item(&stack.item)
|
||||
};
|
||||
}
|
||||
}
|
||||
/// `ItemStack.getTooltipLines`. A `nested` stack is a crossbow's
|
||||
/// projectile: `ChargedProjectiles.addProjectileTooltip` takes its details
|
||||
/// without the name, the player and the advanced flag.
|
||||
fn stack_tooltip_lines(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
nested: bool,
|
||||
) -> Vec<(horizon_client::text::Text, u32)> {
|
||||
let e: &Enchantments = &self.res.stations().enchantments;
|
||||
let color = self.rarity_color(stack);
|
||||
let advanced = !nested && self.options_advanced_tooltips();
|
||||
let display = horizon_client::item_tooltip::TooltipDisplay::for_stack(stack);
|
||||
let mut lines = Vec::new();
|
||||
if display.name_visible(self.screen == Screen::Creative) {
|
||||
lines.push((horizon_client::text::Text::legacy(&name), color));
|
||||
if !nested && display.name_visible(self.screen == Screen::Creative) {
|
||||
// `getStyledHoverName`: a custom name is italic.
|
||||
let name = self.stack_hover_name(stack);
|
||||
let name = if stack.custom_name().is_some() {
|
||||
horizon_client::text::Text::literal(name).italic()
|
||||
} else {
|
||||
horizon_client::text::Text::legacy(&name)
|
||||
};
|
||||
lines.push((name, color));
|
||||
}
|
||||
let translate = |k: &str| self.pack.names.display_get(k);
|
||||
let push = |lines: &mut Vec<(horizon_client::text::Text, u32)>, s: String, c: u32| {
|
||||
lines.push((horizon_client::text::Text::legacy(&s), c));
|
||||
};
|
||||
let gray = rgba(170, 170, 170, 255);
|
||||
let item_info = self.res.items().get(&stack.item);
|
||||
// InstrumentComponent.addToTooltip.
|
||||
if let Some(text) =
|
||||
horizon_client::item_tooltip::instrument_line(stack, &display, &translate)
|
||||
{
|
||||
push(&mut lines, text, gray);
|
||||
}
|
||||
// Bees.addToTooltip.
|
||||
if let Some(text) = horizon_client::item_tooltip::bees_line(stack, &display, &translate) {
|
||||
push(&mut lines, text, gray);
|
||||
}
|
||||
// ItemContainerContents.addToTooltip.
|
||||
let hover_name = |s: &ItemStack| {
|
||||
s.custom_name()
|
||||
@@ -1743,6 +1792,70 @@ impl Game<'_> {
|
||||
push(&mut lines, text, u32::MAX);
|
||||
}
|
||||
}
|
||||
// BannerPatternLayers.addToTooltip: up to six layer names.
|
||||
if display.shows("minecraft:banner_patterns") {
|
||||
for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) {
|
||||
let key = self.res.stations().banners.layer_key(layer);
|
||||
push(
|
||||
&mut lines,
|
||||
self.translate(&key, &key),
|
||||
rgba(170, 170, 170, 255),
|
||||
);
|
||||
}
|
||||
}
|
||||
// WrittenBookContent.addToTooltip.
|
||||
for text in horizon_client::item_tooltip::written_book_lines(stack, &display, &translate) {
|
||||
push(&mut lines, text, gray);
|
||||
}
|
||||
// ChargedProjectiles.addToTooltip: each run of equal projectiles, then
|
||||
// the projectile's own details indented under it.
|
||||
if display.shows("minecraft:charged_projectiles") {
|
||||
let charged = stack.data().map_or(&[][..], |d| &d.charged[..]);
|
||||
for run in charged.chunk_by(|a, b| a.same_item_same_components(b)) {
|
||||
let projectile = &run[0];
|
||||
let name = self.stack_hover_name(projectile);
|
||||
let line = if run.len() == 1 {
|
||||
// `getDisplayName`: `[name]` in the rarity colour.
|
||||
let mut shown = horizon_client::text::Text::literal(format!("[{name}]"))
|
||||
.with_color(rgb_of(self.rarity_color(projectile)));
|
||||
if projectile.custom_name().is_some() {
|
||||
shown = shown.italic();
|
||||
}
|
||||
horizon_client::text::format_translation(
|
||||
&self.translate(
|
||||
"item.minecraft.crossbow.projectile.single",
|
||||
"Projectile: %s",
|
||||
),
|
||||
&[shown],
|
||||
)
|
||||
} else {
|
||||
horizon_client::text::format_translation(
|
||||
&self.translate(
|
||||
"item.minecraft.crossbow.projectile.multiple",
|
||||
"Projectile: %s x %s",
|
||||
),
|
||||
&[
|
||||
horizon_client::text::Text::literal(run.len().to_string()),
|
||||
horizon_client::text::Text::literal(name),
|
||||
],
|
||||
)
|
||||
};
|
||||
lines.push((line, u32::MAX));
|
||||
for (text, c) in self.stack_tooltip_lines(projectile, true) {
|
||||
// `literal(" ").append(line).withStyle(GRAY)`: the line's
|
||||
// own colours win over the gray.
|
||||
let rgb = if c == u32::MAX {
|
||||
horizon_client::text::GRAY
|
||||
} else {
|
||||
rgb_of(c)
|
||||
};
|
||||
let indented = horizon_client::text::Text::literal(" ")
|
||||
.append(text)
|
||||
.with_color(rgb);
|
||||
lines.push((indented, u32::MAX));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fireworks / FireworkExplosion.addToTooltip.
|
||||
for text in horizon_client::item_tooltip::firework_lines(stack, &translate) {
|
||||
push(&mut lines, text, rgba(170, 170, 170, 255));
|
||||
@@ -1765,6 +1878,17 @@ impl Game<'_> {
|
||||
};
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
// The attribute modifiers of the effects (`When Applied:`).
|
||||
for (text, c) in horizon_client::item_tooltip::potion_modifier_lines(stack, &translate)
|
||||
{
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
}
|
||||
// JukeboxPlayable.addToTooltip.
|
||||
if let Some(text) =
|
||||
horizon_client::item_tooltip::jukebox_line(stack, item_info, &display, &translate)
|
||||
{
|
||||
push(&mut lines, text, gray);
|
||||
}
|
||||
if display.shows("minecraft:trim")
|
||||
&& let Some((material, pattern)) = stack.data().and_then(|d| d.trim.clone())
|
||||
@@ -1812,16 +1936,12 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
// BannerPatternLayers.addToTooltip: up to six layer names.
|
||||
if display.shows("minecraft:banner_patterns") {
|
||||
for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) {
|
||||
let key = self.res.stations().banners.layer_key(layer);
|
||||
push(
|
||||
&mut lines,
|
||||
self.translate(&key, &key),
|
||||
rgba(170, 170, 170, 255),
|
||||
);
|
||||
}
|
||||
// DyedItemColor.addToTooltip.
|
||||
if let Some((text, italic)) =
|
||||
horizon_client::item_tooltip::dyed_line(stack, &display, advanced, &translate)
|
||||
{
|
||||
let text = horizon_client::text::Text::legacy(&text);
|
||||
lines.push((if italic { text.italic() } else { text }, gray));
|
||||
}
|
||||
// ItemLore.addToTooltip: after the enchantments, before the attributes
|
||||
// (ItemStack.addDetailsToTooltip).
|
||||
@@ -1830,6 +1950,50 @@ impl Game<'_> {
|
||||
lines.push((text, u32::MAX));
|
||||
}
|
||||
}
|
||||
if display.shows("minecraft:attribute_modifiers") {
|
||||
// The item's own modifiers, then its enchantments' for each slot
|
||||
// group (`ItemStack.forEachModifier`).
|
||||
let mut modifiers = item_info.map(|i| i.modifiers.clone()).unwrap_or_default();
|
||||
modifiers.extend(e.modifier_rows(stack));
|
||||
let base = |name: &str, default| {
|
||||
self.player
|
||||
.as_ref()
|
||||
.and_then(|player| {
|
||||
let key = player.attributes.key(name)?;
|
||||
let handle = player.attributes.lookup(Some(&key))?;
|
||||
player
|
||||
.attributes
|
||||
.instance(&handle)
|
||||
.ok()
|
||||
.map(|value| value.base_value())
|
||||
})
|
||||
.unwrap_or(default)
|
||||
};
|
||||
let player_base = (!nested).then(|| {
|
||||
[
|
||||
base("minecraft:attack_damage", 1.0),
|
||||
base(
|
||||
"minecraft:attack_speed",
|
||||
horizon_client::attack_strength::BASE_ATTACK_SPEED,
|
||||
),
|
||||
]
|
||||
});
|
||||
for (text, c) in
|
||||
horizon_client::item_tooltip::attribute_lines(&modifiers, player_base, &translate)
|
||||
{
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
}
|
||||
// ItemStack.UNBREAKABLE_TOOLTIP.
|
||||
if let Some(text) =
|
||||
horizon_client::item_tooltip::unbreakable_line(stack, &display, &translate)
|
||||
{
|
||||
push(&mut lines, text, rgba(85, 85, 255, 255));
|
||||
}
|
||||
// BlockItemStateProperties.addToTooltip.
|
||||
if let Some(text) = horizon_client::item_tooltip::honey_line(stack, &display, &translate) {
|
||||
push(&mut lines, text, gray);
|
||||
}
|
||||
// Spawner.appendHoverText: what a spawner or trial spawner item spawns.
|
||||
if display.shows("minecraft:block_entity_data")
|
||||
&& matches!(
|
||||
@@ -1853,43 +2017,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if display.shows("minecraft:attribute_modifiers")
|
||||
&& let Some(info) = self.res.items().get(&stack.item)
|
||||
{
|
||||
let base = |name: &str, default| {
|
||||
self.player
|
||||
.as_ref()
|
||||
.and_then(|player| {
|
||||
let key = player.attributes.key(name)?;
|
||||
let handle = player.attributes.lookup(Some(&key))?;
|
||||
player
|
||||
.attributes
|
||||
.instance(&handle)
|
||||
.ok()
|
||||
.map(|value| value.base_value())
|
||||
})
|
||||
.unwrap_or(default)
|
||||
};
|
||||
let player_base = [
|
||||
base("minecraft:attack_damage", 1.0),
|
||||
base(
|
||||
"minecraft:attack_speed",
|
||||
horizon_client::attack_strength::BASE_ATTACK_SPEED,
|
||||
),
|
||||
];
|
||||
for (text, c) in horizon_client::item_tooltip::attribute_lines(
|
||||
&info.modifiers,
|
||||
Some(player_base),
|
||||
&translate,
|
||||
) {
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
}
|
||||
if display.shows("minecraft:damage")
|
||||
&& let Some(info) = self.res.items().get(&stack.item)
|
||||
&& let Some(info) = item_info
|
||||
&& info.max_damage > 0
|
||||
&& stack.damage > 0
|
||||
&& self.options_advanced_tooltips()
|
||||
&& advanced
|
||||
{
|
||||
push(
|
||||
&mut lines,
|
||||
@@ -1901,8 +2033,23 @@ impl Game<'_> {
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
// ItemStack.addDetailsToTooltipTail: with advanced tooltips the item id
|
||||
// and how many components the stack has.
|
||||
if advanced {
|
||||
let dark_gray = rgba(85, 85, 85, 255);
|
||||
push(&mut lines, stack.item.to_string(), dark_gray);
|
||||
let count = horizon_client::item_tooltip::component_count(stack, item_info);
|
||||
if count > 0 {
|
||||
let text = self.translate("item.components", "%s component(s)");
|
||||
push(
|
||||
&mut lines,
|
||||
text.replacen("%s", &count.to_string(), 1),
|
||||
dark_gray,
|
||||
);
|
||||
}
|
||||
}
|
||||
// ItemStack.OP_NBT_WARNING, for a player with game master permission.
|
||||
if self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) {
|
||||
if !nested && self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) {
|
||||
for (key, fallback) in [
|
||||
("item.op_warning.line1", "Warning:"),
|
||||
(
|
||||
@@ -2105,12 +2252,18 @@ impl Game<'_> {
|
||||
batch: &mut HudBatch,
|
||||
layout: &horizon_client::bundle::TooltipLayout,
|
||||
) {
|
||||
for (at, stack) in &layout.slots {
|
||||
for (i, (at, stack)) in layout.slots.iter().enumerate() {
|
||||
let selected = layout.selected == Some(i);
|
||||
let sprite = |name: &str| format!("gui/sprites/container/bundle/{name}");
|
||||
// Each draw takes the atlas on its own: the item's needs `&mut self`.
|
||||
horizon_client::ui::nine_slice(
|
||||
batch,
|
||||
&self.gui.atlas,
|
||||
"gui/sprites/container/bundle/slot_background",
|
||||
&sprite(if selected {
|
||||
"slot_highlight_back"
|
||||
} else {
|
||||
"slot_background"
|
||||
}),
|
||||
at[0],
|
||||
at[1],
|
||||
24.0,
|
||||
@@ -2120,6 +2273,18 @@ impl Game<'_> {
|
||||
let stack = stack.clone();
|
||||
let [x, y] = *at;
|
||||
self.draw_item(batch, &stack, x + 4.0, y + 4.0);
|
||||
if selected {
|
||||
horizon_client::ui::nine_slice(
|
||||
batch,
|
||||
&self.gui.atlas,
|
||||
&sprite("slot_highlight_front"),
|
||||
x,
|
||||
y,
|
||||
24.0,
|
||||
24.0,
|
||||
u32::MAX,
|
||||
);
|
||||
}
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
if let Some((at, hidden)) = layout.overflow {
|
||||
|
||||
@@ -164,11 +164,12 @@ 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 * f64::from(self.settings.entity_distance);
|
||||
let range = f64::from(d.view_range) * 64.0 * scale;
|
||||
if position.distance_squared(camera) >= range * range || d.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,22 @@ impl Game<'_> {
|
||||
draws.extend(self.titles.title(w, h, partial, font, background));
|
||||
hud_layout::paint(batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
/// `Hud.extractSavingIndicator`, under the screens: the option, then the
|
||||
/// fading "Saving world" text.
|
||||
pub(super) fn draw_saving_indicator(&self, batch: &mut HudBatch) {
|
||||
if !self.settings.autosave_indicator {
|
||||
return;
|
||||
}
|
||||
let draws = self.saving_indicator.draw(
|
||||
batch.width as i32,
|
||||
batch.height as i32,
|
||||
self.clock.fraction() as f32,
|
||||
&self.gui.atlas.font,
|
||||
Text::literal(self.translate("menu.savingLevel", "Saving world")),
|
||||
self.settings.text_background(0.0),
|
||||
);
|
||||
hud_layout::paint(batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
/// The selected stack as `Hud.tick` compares it for the name highlight.
|
||||
fn held_name(&self) -> Option<HeldName> {
|
||||
let stack = self.inventory.get(self.selected)?.as_ref()?;
|
||||
@@ -44,6 +60,12 @@ impl Game<'_> {
|
||||
/// The title, action bar and selected-item parts of `Hud.tick`.
|
||||
pub(super) fn tick_hud(&mut self) {
|
||||
self.titles.tick();
|
||||
// `MinecraftServer.isCurrentlySaving` of single player's server.
|
||||
let saving = self
|
||||
.integrated
|
||||
.as_ref()
|
||||
.map_or(self.autosaving, |server| server.is_saving());
|
||||
self.saving_indicator.tick(saving);
|
||||
let held = self.held_name();
|
||||
self.tool_highlight
|
||||
.tick(held, f64::from(self.settings.notification_display_time));
|
||||
|
||||
@@ -252,7 +252,7 @@ impl Game<'_> {
|
||||
}
|
||||
let range = super::entity_culling::default_range(
|
||||
glam::DVec3::splat(PrimedTnt::SIZE),
|
||||
self.settings.entity_distance,
|
||||
self.view_scale(),
|
||||
);
|
||||
for t in &self.tnts {
|
||||
// EntityRenderer.shouldRender.
|
||||
|
||||
@@ -495,7 +495,7 @@ impl Game<'_> {
|
||||
let range = 10.0
|
||||
* super::entity_culling::default_range(
|
||||
glam::DVec3::splat(2.0 * HALF),
|
||||
self.settings.entity_distance,
|
||||
self.view_scale(),
|
||||
);
|
||||
for t in &self.tridents {
|
||||
// EntityRenderer.shouldRender; ThrownTridentRenderer grows the box by 1.5.
|
||||
|
||||
@@ -70,8 +70,7 @@ impl Game<'_> {
|
||||
/// One ordinary click on a game slot. False when nothing took it.
|
||||
pub(super) fn tweak_click(&mut self, slot: usize, button: i32, input: Input) -> bool {
|
||||
if self.remote.is_some() {
|
||||
let ordinal = super::remote::input_ordinal(input);
|
||||
return self.remote_slot_click(slot, button as i8, ordinal);
|
||||
return self.remote_menu_ops(&[(Some(slot), button, input)]);
|
||||
}
|
||||
if !self.menu_click(Some(slot), button, input) {
|
||||
return false;
|
||||
|
||||
@@ -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 scRGB.
|
||||
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
|
||||
hdr: Option<hdr::Hdr>,
|
||||
/// Whether the window's surface can present scRGB.
|
||||
hdr_capable: bool,
|
||||
/// The HDR output the window's surface can present.
|
||||
hdr_output: Option<hdr::Output>,
|
||||
/// 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_capable) = if let Some(surface) = &surface {
|
||||
let (config, present_modes, hdr_output) = 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::supported(&caps))
|
||||
(config, caps.present_modes.clone(), hdr::output(&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(), false)
|
||||
(config, Vec::new(), None)
|
||||
};
|
||||
let offscreen = surface
|
||||
.is_none()
|
||||
@@ -1953,22 +1953,23 @@ impl Renderer {
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &entity_shader,
|
||||
entry_point: Some("fs_celestial"),
|
||||
entry_point: Some("fs_sky_celestial"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
// 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.
|
||||
// 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.
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
}),
|
||||
@@ -2456,7 +2457,7 @@ impl Renderer {
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
world_post,
|
||||
hdr: None,
|
||||
hdr_capable,
|
||||
hdr_output,
|
||||
pack_post: None,
|
||||
native: None,
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
@@ -2990,24 +2991,24 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
/// The swapchain's configuration: `config` (what every pipeline draws in), or
|
||||
/// scRGB while HDR output is on.
|
||||
/// HDR10 or scRGB while HDR output is on.
|
||||
fn surface_config(&self) -> wgpu::SurfaceConfiguration {
|
||||
let mut config = self.config.clone();
|
||||
if self.hdr.is_some() {
|
||||
config.format = hdr::FORMAT;
|
||||
config.view_formats = vec![hdr::FORMAT];
|
||||
config.color_space = hdr::COLOR_SPACE;
|
||||
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();
|
||||
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
|
||||
}
|
||||
config
|
||||
}
|
||||
/// HDR output (Horizon setting): scRGB at `paper_white` nits, the world's
|
||||
/// HDR output (Horizon setting): HDR10 or 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 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()));
|
||||
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));
|
||||
self.configure_surface();
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
//! HDR output (Horizon setting, off by default): the frame renders exactly as
|
||||
//! without it, into an 8-bit target in vanilla's gamma space, and one pass
|
||||
//! (`hdr.wgsl`) writes it to an scRGB swapchain at the player's paper white,
|
||||
//! expanding the world's highlights towards their peak brightness. The world
|
||||
//! is copied aside before the hand and HUD so only it is expanded.
|
||||
//! (`hdr.wgsl`) writes it to an HDR10 or scRGB swapchain at the player's paper
|
||||
//! white, expanding the world's highlights towards their peak brightness. The
|
||||
//! world is copied aside before the hand and HUD so only it is expanded.
|
||||
|
||||
/// The swapchain format and colour space HDR output presents in.
|
||||
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
pub const COLOR_SPACE: wgpu::SurfaceColorSpace = wgpu::SurfaceColorSpace::ExtendedSrgbLinear;
|
||||
/// The swapchain HDR output presents to.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Output {
|
||||
/// HDR10: 10-bit BT.2020 with the PQ curve, the display's own signal.
|
||||
Pq,
|
||||
/// scRGB: linear 16-bit float, BT.709 primaries, 1.0 = 80 nits.
|
||||
ScRgb,
|
||||
}
|
||||
|
||||
/// Whether `caps` can present scRGB.
|
||||
pub fn supported(caps: &wgpu::SurfaceCapabilities) -> bool {
|
||||
caps.color_spaces(FORMAT)
|
||||
.contains(wgpu::SurfaceColorSpaces::EXTENDED_SRGB_LINEAR)
|
||||
impl Output {
|
||||
pub fn format(self) -> wgpu::TextureFormat {
|
||||
match self {
|
||||
Self::Pq => wgpu::TextureFormat::Rgb10a2Unorm,
|
||||
Self::ScRgb => wgpu::TextureFormat::Rgba16Float,
|
||||
}
|
||||
}
|
||||
pub fn color_space(self) -> wgpu::SurfaceColorSpace {
|
||||
match self {
|
||||
Self::Pq => wgpu::SurfaceColorSpace::Bt2100Pq,
|
||||
Self::ScRgb => wgpu::SurfaceColorSpace::ExtendedSrgbLinear,
|
||||
}
|
||||
}
|
||||
fn space(self) -> wgpu::SurfaceColorSpaces {
|
||||
match self {
|
||||
Self::Pq => wgpu::SurfaceColorSpaces::BT2100_PQ,
|
||||
Self::ScRgb => wgpu::SurfaceColorSpaces::EXTENDED_SRGB_LINEAR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The HDR output `caps` can present: HDR10 where offered, else scRGB.
|
||||
/// `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only that one.
|
||||
pub fn output(caps: &wgpu::SurfaceCapabilities) -> Option<Output> {
|
||||
pick(caps, std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
|
||||
}
|
||||
fn pick(caps: &wgpu::SurfaceCapabilities, only: Option<&str>) -> Option<Output> {
|
||||
[(Output::Pq, "pq"), (Output::ScRgb, "scrgb")]
|
||||
.into_iter()
|
||||
.filter(|(_, name)| only.is_none_or(|o| o == *name))
|
||||
.map(|(output, _)| output)
|
||||
.find(|o| caps.color_spaces(o.format()).contains(o.space()))
|
||||
}
|
||||
|
||||
struct Targets {
|
||||
@@ -25,6 +58,7 @@ pub struct Hdr {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
params: wgpu::Buffer,
|
||||
pub output: Output,
|
||||
/// The frame's format: what every other pipeline draws in.
|
||||
format: wgpu::TextureFormat,
|
||||
targets: Option<Targets>,
|
||||
@@ -36,7 +70,7 @@ pub struct Hdr {
|
||||
}
|
||||
|
||||
impl Hdr {
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, output: Output) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
|
||||
@@ -90,7 +124,7 @@ impl Hdr {
|
||||
entry_point: Some("fs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: FORMAT,
|
||||
format: output.format(),
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
@@ -100,7 +134,7 @@ impl Hdr {
|
||||
});
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 16,
|
||||
size: 32,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
@@ -108,6 +142,7 @@ impl Hdr {
|
||||
layout,
|
||||
pipeline,
|
||||
params,
|
||||
output,
|
||||
format,
|
||||
targets: None,
|
||||
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
|
||||
@@ -198,6 +233,10 @@ impl Hdr {
|
||||
(self.peak / paper).max(1.0),
|
||||
f32::from(u8::from(world)),
|
||||
f32::from(u8::from(self.debug)),
|
||||
f32::from(u8::from(self.output == Output::Pq)),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
];
|
||||
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms));
|
||||
let view = target.create_view(&Default::default());
|
||||
@@ -220,3 +259,37 @@ impl Hdr {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, pick};
|
||||
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
|
||||
|
||||
fn caps(offered: &[(F, S)]) -> wgpu::SurfaceCapabilities {
|
||||
wgpu::SurfaceCapabilities {
|
||||
format_capabilities: offered
|
||||
.iter()
|
||||
.map(|&(format, color_spaces)| wgpu::SurfaceFormatCapabilities {
|
||||
format,
|
||||
color_spaces,
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr10_first_then_scrgb() {
|
||||
let both = caps(&[
|
||||
(F::Bgra8UnormSrgb, S::SRGB),
|
||||
(F::Rgb10a2Unorm, S::BT2100_PQ | S::BT2100_HLG),
|
||||
(F::Rgba16Float, S::EXTENDED_SRGB_LINEAR),
|
||||
]);
|
||||
assert_eq!(pick(&both, None), Some(Output::Pq));
|
||||
assert_eq!(pick(&both, Some("scrgb")), Some(Output::ScRgb));
|
||||
let scrgb = caps(&[(F::Rgba16Float, S::EXTENDED_SRGB_LINEAR)]);
|
||||
assert_eq!(pick(&scrgb, None), Some(Output::ScRgb));
|
||||
assert_eq!(pick(&scrgb, Some("pq")), None);
|
||||
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
|
||||
}
|
||||
}
|
||||
@@ -216,15 +216,19 @@ pub const INTEGRATED_LOD_DISTANCE: u32 = 128;
|
||||
pub fn lod_capped(info: &wgpu::AdapterInfo) -> bool {
|
||||
info.device_type == wgpu::DeviceType::IntegratedGpu && !info.name.starts_with("Apple")
|
||||
}
|
||||
/// A LOD distance as far terrain uses it on this adapter.
|
||||
pub fn lod_distance_capped(distance: u32, info: &wgpu::AdapterInfo) -> u32 {
|
||||
let distance = distance.min(1024);
|
||||
if lod_capped(info) {
|
||||
distance.min(INTEGRATED_LOD_DISTANCE)
|
||||
} else {
|
||||
distance
|
||||
}
|
||||
}
|
||||
impl Graphics {
|
||||
/// `lod_distance` as far terrain uses it on this adapter.
|
||||
pub fn lod_distance_on(&self, info: &wgpu::AdapterInfo) -> u32 {
|
||||
let distance = self.lod_distance.min(1024);
|
||||
if lod_capped(info) {
|
||||
distance.min(INTEGRATED_LOD_DISTANCE)
|
||||
} else {
|
||||
distance
|
||||
}
|
||||
lod_distance_capped(self.lod_distance, info)
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -255,7 +259,7 @@ pub struct Graphics {
|
||||
pub resolution_scale: u32,
|
||||
/// FXAA over the world.
|
||||
pub fxaa: bool,
|
||||
/// HDR output (scRGB), where the display path offers it.
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits.
|
||||
pub hdr_paper_white: u32,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Horizon's HDR output pass: the frame, drawn as without HDR in vanilla's gamma
|
||||
// space, decoded to linear light at the paper white and written to the scRGB
|
||||
// swapchain (1.0 = 80 nits). Bright pixels of light sources that still show the
|
||||
// space, decoded to linear light at the paper white and written to the swapchain:
|
||||
// HDR10 (BT.2020, PQ) or scRGB (1.0 = 80 nits). Bright pixels of light sources that still show the
|
||||
// world as it was before the hand and HUD are expanded towards the peak.
|
||||
@group(0) @binding(0) var frame: texture_2d<f32>;
|
||||
@group(0) @binding(1) var world: texture_2d<f32>;
|
||||
@@ -14,6 +14,8 @@ struct Params {
|
||||
world: f32,
|
||||
// 1 paints expanded pixels magenta (HORIZON_HDR_DEBUG=1).
|
||||
debug: f32,
|
||||
// 1 for an HDR10 swapchain, 0 for scRGB.
|
||||
pq: f32,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
|
||||
@@ -40,6 +42,19 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
return mean <= 1.01 && mean > 0.5 && all(abs(k - vec3(mean)) < vec3(0.03));
|
||||
}
|
||||
|
||||
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
|
||||
const BT709_TO_BT2020 = mat3x3<f32>(
|
||||
vec3(0.6274, 0.0691, 0.0164),
|
||||
vec3(0.3293, 0.9195, 0.0880),
|
||||
vec3(0.0433, 0.0114, 0.8956),
|
||||
);
|
||||
|
||||
// SMPTE ST 2084: nits (up to 10000) to the PQ signal.
|
||||
fn pq(nits: vec3<f32>) -> vec3<f32> {
|
||||
let y = pow(clamp(nits / 10000.0, vec3(0.0), vec3(1.0)), vec3(0.1593017578125));
|
||||
return pow((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y), vec3(78.84375));
|
||||
}
|
||||
|
||||
@vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
@@ -65,5 +80,9 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
return vec4(light * params.paper, 1.0);
|
||||
let scrgb = light * params.paper;
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(BT709_TO_BT2020 * scrgb * 80.0), 1.0);
|
||||
}
|
||||
return vec4(scrgb, 1.0);
|
||||
}
|
||||
@@ -191,6 +191,53 @@ pub fn effects(w: i32, demo: bool, effects: &[EffectIcon]) -> Vec<GuiDraw> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The saving indicator: `Hud.tickAutosaveIndicator` fades a value towards 1
|
||||
/// while the server is saving and `extractSavingIndicator` draws the text.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct SavingIndicator {
|
||||
last: f32,
|
||||
value: f32,
|
||||
}
|
||||
impl SavingIndicator {
|
||||
/// `Hud.tickAutosaveIndicator`: `Mth.lerp(0.2F, value, saving ? 1 : 0)`.
|
||||
pub fn tick(&mut self, saving: bool) {
|
||||
self.last = self.value;
|
||||
self.value += (if saving { 1.0 } else { 0.0 } - self.value) * 0.2;
|
||||
}
|
||||
/// `Hud.extractSavingIndicator` without the option check: the text in the
|
||||
/// bottom right corner.
|
||||
pub fn draw(
|
||||
&self,
|
||||
w: i32,
|
||||
h: i32,
|
||||
partial: f32,
|
||||
font: &Font,
|
||||
label: Text,
|
||||
background: u32,
|
||||
) -> Vec<GuiDraw> {
|
||||
let mut out = Vec::new();
|
||||
if self.value <= 0.0 && self.last <= 0.0 {
|
||||
return out;
|
||||
}
|
||||
let fade = (self.last + (self.value - self.last) * partial).clamp(0.0, 1.0);
|
||||
let alpha = (255.0 * fade).floor() as u32;
|
||||
if alpha > 0 {
|
||||
let label_width = width(font, &label);
|
||||
let color = crate::hud_layout::argb(alpha, 255, 255, 255);
|
||||
text_with_backdrop(
|
||||
&mut out,
|
||||
label,
|
||||
w - label_width - 5,
|
||||
h - 9 - 5,
|
||||
label_width,
|
||||
color,
|
||||
background,
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Titles and the action bar: `Hud.title`, `subtitle`, `titleTime`, the
|
||||
/// fade times and `overlayMessageString` / `overlayMessageTime`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct StackData {
|
||||
pub pot_decorations: Option<Box<[Option<ItemStack>; 4]>>,
|
||||
/// `minecraft:bundle_contents`.
|
||||
pub bundle_contents: Vec<ItemStack>,
|
||||
/// `BundleContents.selectedItem`: runtime only, never saved or sent.
|
||||
pub bundle_selected: BundleSelected,
|
||||
/// `minecraft:suspicious_stew_effects`: (effect id, duration).
|
||||
pub stew_effects: Vec<(Arc<str>, i32)>,
|
||||
/// `minecraft:map_post_processing`: 0 lock, 1 scale.
|
||||
@@ -60,6 +62,20 @@ pub struct StackData {
|
||||
/// max stack size...), kept as their saved values in key order.
|
||||
pub other: Vec<(String, Tag)>,
|
||||
}
|
||||
/// A bundle's selected item index (-1: none). Like vanilla's
|
||||
/// `BundleContents.equals` it is left out when stacks are compared.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct BundleSelected(pub i32);
|
||||
impl Default for BundleSelected {
|
||||
fn default() -> Self {
|
||||
Self(-1)
|
||||
}
|
||||
}
|
||||
impl PartialEq for BundleSelected {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
/// The components `components_tag` writes from modelled fields.
|
||||
const MODELLED: &[&str] = &[
|
||||
"minecraft:banner_patterns",
|
||||
@@ -305,7 +321,9 @@ fn read_text(t: &Tag) -> Option<String> {
|
||||
// A component's root `text` may be empty with the visible words in
|
||||
// `extra` (every legacy display name ViaVersion produces looks like
|
||||
// this), so flatten the whole component instead of reading one key.
|
||||
Tag::Compound(_) => Some(crate::entity_nbt::name_text(t).plain()),
|
||||
// ComponentSerialization.CODEC also takes a list (its first element
|
||||
// styling the rest), as servers behind ViaVersion send book pages.
|
||||
Tag::Compound(_) | Tag::List(_) => Some(crate::entity_nbt::name_text(t).plain()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -791,6 +809,7 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option<Box<StackData
|
||||
Arc::from(t.get("pattern").and_then(string)?.as_str()),
|
||||
))
|
||||
}),
|
||||
bundle_selected: BundleSelected::default(),
|
||||
other: Vec::new(),
|
||||
};
|
||||
let mut data = data;
|
||||
@@ -899,6 +918,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_book_pages_may_be_component_lists() {
|
||||
// A page as Hypixel sends it through ViaVersion: a list, not a compound.
|
||||
let page = Tag::List(vec![
|
||||
Tag::compound([("text", Tag::String("".into()))]),
|
||||
Tag::compound([("text", Tag::String("Please read ".into()))]),
|
||||
Tag::compound([("text", Tag::String("the rules".into()))]),
|
||||
]);
|
||||
let book = Tag::compound([
|
||||
(
|
||||
"title",
|
||||
Tag::compound([("raw", Tag::String("Rules".into()))]),
|
||||
),
|
||||
("author", Tag::String("Server".into())),
|
||||
(
|
||||
"pages",
|
||||
Tag::List(vec![
|
||||
page,
|
||||
Tag::compound([("raw", Tag::String("Two".into()))]),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
let components = Tag::compound([("minecraft:written_book_content", book)]);
|
||||
let (_, extra) = decode_components(Some(&components));
|
||||
let pages = extra.and_then(|d| d.written.clone()).map(|w| w.pages);
|
||||
assert_eq!(
|
||||
pages,
|
||||
Some(vec!["Please read the rules".to_owned(), "Two".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banner_layers_and_shield_base_round_trip() {
|
||||
let mut stack = ItemStack::new("minecraft:shield", 1);
|
||||
|
||||
@@ -653,6 +653,7 @@ pub fn tag_json(tag: &Tag) -> Value {
|
||||
/// The resolver's view of a client stack: its item, count and component patch.
|
||||
pub fn stack_view(stack: &crate::items::ItemStack) -> StackView {
|
||||
let mut view = StackView::new(&stack.item, stack.count as i32);
|
||||
view.bundle_selected = stack.data().map_or(-1, |d| d.bundle_selected.0);
|
||||
if let Some(Tag::Compound(c)) = crate::item_data::components_tag(stack.damage, stack.data()) {
|
||||
for (k, v) in &c {
|
||||
let key = k.to_string_lossy();
|
||||
|
||||
@@ -108,6 +108,32 @@ fn modifier_line(
|
||||
)
|
||||
}
|
||||
|
||||
/// `Attribute.getStyle`: the colour of a line for an attribute whose value
|
||||
/// rises or falls. Gravity, scale and the waypoint ranges are neutral, burning
|
||||
/// time and fall damage are negative, every other attribute is positive.
|
||||
fn attribute_color(attribute: &str, rises: bool) -> u32 {
|
||||
let (blue, red) = (rgba(85, 85, 255, 255), rgba(255, 85, 85, 255));
|
||||
match attribute {
|
||||
"burning_time" | "fall_damage_multiplier" => {
|
||||
if rises {
|
||||
red
|
||||
} else {
|
||||
blue
|
||||
}
|
||||
}
|
||||
"gravity" | "scale" | "waypoint_transmit_range" | "waypoint_receive_range" => {
|
||||
rgba(170, 170, 170, 255)
|
||||
}
|
||||
_ => {
|
||||
if rises {
|
||||
blue
|
||||
} else {
|
||||
red
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ItemStack.addAttributeTooltips`: slot groups, colors and optional player
|
||||
/// attack damage/speed bases. Recipe search has no player context.
|
||||
pub fn attribute_lines(
|
||||
@@ -150,10 +176,8 @@ pub fn attribute_lines(
|
||||
) {
|
||||
let (line, color) = if base.is_some() {
|
||||
(format!(" {line}"), rgba(0, 170, 0, 255))
|
||||
} else if *amount > 0.0 {
|
||||
(line, rgba(85, 85, 255, 255))
|
||||
} else {
|
||||
(line, rgba(255, 85, 85, 255))
|
||||
(line, attribute_color(path, *amount > 0.0))
|
||||
};
|
||||
lines.push((line, color));
|
||||
}
|
||||
@@ -231,6 +255,235 @@ pub fn rarity<'a>(stack: &ItemStack, default: &'a str) -> &'a str {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_beehive(path: &str) -> bool {
|
||||
matches!(path, "beehive" | "bee_nest")
|
||||
}
|
||||
|
||||
/// `InstrumentComponent.addToTooltip`: the instrument's description. A goat
|
||||
/// horn without the component has the item's default, the ponder horn.
|
||||
pub fn instrument_line(
|
||||
stack: &ItemStack,
|
||||
display: &TooltipDisplay,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<String> {
|
||||
if !display.shows("minecraft:instrument") {
|
||||
return None;
|
||||
}
|
||||
let id = match stack.data().and_then(|d| d.other("minecraft:instrument")) {
|
||||
Some(Tag::String(id)) => id.to_string_lossy(),
|
||||
// An inline instrument carries its own description.
|
||||
Some(inline) => {
|
||||
return inline
|
||||
.get("description")
|
||||
.map(|d| crate::entity_nbt::name_text(d).plain());
|
||||
}
|
||||
None if &*stack.item == "minecraft:goat_horn" => "minecraft:ponder_goat_horn".to_owned(),
|
||||
None => return None,
|
||||
};
|
||||
let key = format!("instrument.{}", id.replacen(':', ".", 1));
|
||||
Some(translate(&key).unwrap_or(key))
|
||||
}
|
||||
|
||||
/// `Bees.addToTooltip`: how many bees a hive item holds (a hive item without
|
||||
/// the component has the item's default, none).
|
||||
pub fn bees_line(
|
||||
stack: &ItemStack,
|
||||
display: &TooltipDisplay,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<String> {
|
||||
if !display.shows("minecraft:bees") {
|
||||
return None;
|
||||
}
|
||||
let bees = match stack.data().and_then(|d| d.other("minecraft:bees")) {
|
||||
Some(Tag::List(bees)) => bees.len(),
|
||||
Some(_) => return None,
|
||||
None if is_beehive(stack.item.trim_start_matches("minecraft:")) => 0,
|
||||
None => return None,
|
||||
};
|
||||
let text =
|
||||
translate("container.beehive.bees").unwrap_or_else(|| "container.beehive.bees".into());
|
||||
Some(
|
||||
text.replacen("%s", &bees.to_string(), 1)
|
||||
.replacen("%s", "3", 1),
|
||||
)
|
||||
}
|
||||
|
||||
/// `BlockItemStateProperties.addToTooltip`: a hive item's honey level (a hive
|
||||
/// item without the component has the item's default, 0).
|
||||
pub fn honey_line(
|
||||
stack: &ItemStack,
|
||||
display: &TooltipDisplay,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<String> {
|
||||
if !display.shows("minecraft:block_state") {
|
||||
return None;
|
||||
}
|
||||
let level = match stack.data().and_then(|d| d.other("minecraft:block_state")) {
|
||||
Some(Tag::Compound(state)) => {
|
||||
match state.get(&horizon_world::nbt::JavaString::from("honey_level")) {
|
||||
Some(Tag::String(level)) => level.to_string_lossy().parse::<u32>().ok()?,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(_) => return None,
|
||||
None if is_beehive(stack.item.trim_start_matches("minecraft:")) => 0,
|
||||
None => return None,
|
||||
};
|
||||
let text =
|
||||
translate("container.beehive.honey").unwrap_or_else(|| "container.beehive.honey".into());
|
||||
Some(
|
||||
text.replacen("%s", &level.to_string(), 1)
|
||||
.replacen("%s", "5", 1),
|
||||
)
|
||||
}
|
||||
|
||||
/// `WrittenBookContent.addToTooltip`: "by <author>" and the generation.
|
||||
pub fn written_book_lines(
|
||||
stack: &ItemStack,
|
||||
display: &TooltipDisplay,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<String> {
|
||||
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
|
||||
let Some(book) = stack
|
||||
.data()
|
||||
.and_then(|d| d.written.as_ref())
|
||||
.filter(|_| display.shows("minecraft:written_book_content"))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut lines = Vec::new();
|
||||
if !book.author.trim().is_empty() {
|
||||
lines.push(
|
||||
crate::text::format_translation(
|
||||
&text("book.byAuthor"),
|
||||
&[crate::text::Text::literal(&book.author)],
|
||||
)
|
||||
.plain(),
|
||||
);
|
||||
}
|
||||
lines.push(text(&format!("book.generation.{}", book.generation)));
|
||||
lines
|
||||
}
|
||||
|
||||
/// `JukeboxPlayable.addToTooltip`: the song's description.
|
||||
pub fn jukebox_line(
|
||||
stack: &ItemStack,
|
||||
info: Option<&ItemInfo>,
|
||||
display: &TooltipDisplay,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<String> {
|
||||
if !display.shows("minecraft:jukebox_playable") {
|
||||
return None;
|
||||
}
|
||||
let song = match stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:jukebox_playable"))
|
||||
{
|
||||
Some(Tag::String(song)) => song.to_string_lossy(),
|
||||
// An inline song carries its own description.
|
||||
Some(inline) => {
|
||||
return inline
|
||||
.get("description")
|
||||
.map(|d| crate::entity_nbt::name_text(d).plain());
|
||||
}
|
||||
None => info?.jukebox_song.clone()?,
|
||||
};
|
||||
let key = format!("jukebox_song.{}", song.replacen(':', ".", 1));
|
||||
Some(translate(&key).unwrap_or(key))
|
||||
}
|
||||
|
||||
/// `DyedItemColor.addToTooltip`: "Dyed" (italic, the flag), or with advanced
|
||||
/// tooltips the colour's hex code.
|
||||
pub fn dyed_line(
|
||||
stack: &ItemStack,
|
||||
display: &TooltipDisplay,
|
||||
advanced: bool,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<(String, bool)> {
|
||||
let rgb = stack.data()?.dyed_color?;
|
||||
if !display.shows("minecraft:dyed_color") {
|
||||
return None;
|
||||
}
|
||||
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
|
||||
Some(if advanced {
|
||||
let hex = format!("#{:06X}", rgb & 0xFF_FFFF);
|
||||
(text("item.color").replacen("%s", &hex, 1), false)
|
||||
} else {
|
||||
(text("item.dyed"), true)
|
||||
})
|
||||
}
|
||||
|
||||
/// `ItemStack.UNBREAKABLE_TOOLTIP`.
|
||||
pub fn unbreakable_line(
|
||||
stack: &ItemStack,
|
||||
display: &TooltipDisplay,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Option<String> {
|
||||
(stack.data()?.other("minecraft:unbreakable").is_some()
|
||||
&& display.shows("minecraft:unbreakable"))
|
||||
.then(|| translate("item.unbreakable").unwrap_or_else(|| "item.unbreakable".into()))
|
||||
}
|
||||
|
||||
/// `PotionContents.addPotionTooltip`'s attribute block: an empty line, "When
|
||||
/// Applied:" and the modifiers of the potion's effects (blue for a gain, red
|
||||
/// for a loss); nothing for a potion whose effects add none.
|
||||
pub fn potion_modifier_lines(
|
||||
stack: &ItemStack,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, u32)> {
|
||||
if !is_potion_item(&stack.item) {
|
||||
return Vec::new();
|
||||
}
|
||||
let applied: Vec<(String, u32)> = stack
|
||||
.potion()
|
||||
.and_then(crate::brewing::potion)
|
||||
.into_iter()
|
||||
.flat_map(|p| p.effects.iter())
|
||||
.filter_map(|(effect, _, amplifier)| {
|
||||
let (attribute, amount, operation) = effect_modifier(effect)?;
|
||||
let amount = amount * (*amplifier + 1) as f64;
|
||||
let line = modifier_line(translate, attribute, amount, operation, false)?;
|
||||
let color = if amount > 0.0 {
|
||||
rgba(85, 85, 255, 255)
|
||||
} else {
|
||||
rgba(255, 85, 85, 255)
|
||||
};
|
||||
Some((line, color))
|
||||
})
|
||||
.collect();
|
||||
if applied.is_empty() {
|
||||
return applied;
|
||||
}
|
||||
let mut lines = vec![
|
||||
(String::new(), u32::MAX),
|
||||
(
|
||||
translate("potion.whenDrank").unwrap_or_else(|| "potion.whenDrank".into()),
|
||||
rgba(170, 0, 170, 255),
|
||||
),
|
||||
];
|
||||
lines.extend(applied);
|
||||
lines
|
||||
}
|
||||
|
||||
/// `ItemStack.components.size()`: the item's default components with the
|
||||
/// stack's own changes applied.
|
||||
pub fn component_count(stack: &ItemStack, info: Option<&ItemInfo>) -> usize {
|
||||
let mut keys: std::collections::HashSet<String> = info
|
||||
.map(|i| i.components.iter().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
if let Some(Tag::Compound(patch)) = crate::item_data::components_tag(stack.damage, stack.data())
|
||||
{
|
||||
for key in patch.keys() {
|
||||
let key = key.to_string_lossy();
|
||||
match key.strip_prefix('!') {
|
||||
Some(removed) => keys.remove(removed),
|
||||
None => keys.insert(key),
|
||||
};
|
||||
}
|
||||
}
|
||||
keys.len()
|
||||
}
|
||||
|
||||
/// The lines of `stack` below its name that its components give, in
|
||||
/// `ItemStack.addDetailsToTooltip` order.
|
||||
pub fn detail_lines(
|
||||
@@ -265,19 +518,9 @@ pub fn detail_lines(
|
||||
))
|
||||
));
|
||||
}
|
||||
// BeehiveBlock: the block state components of the item.
|
||||
if path == "beehive" {
|
||||
lines.push(
|
||||
text("container.beehive.bees")
|
||||
.replacen("%s", "0", 1)
|
||||
.replacen("%s", "3", 1),
|
||||
);
|
||||
lines.push(
|
||||
text("container.beehive.honey")
|
||||
.replacen("%s", "0", 1)
|
||||
.replacen("%s", "5", 1),
|
||||
);
|
||||
}
|
||||
let display = TooltipDisplay::for_stack(stack);
|
||||
lines.extend(bees_line(stack, &display, translate));
|
||||
lines.extend(honey_line(stack, &display, translate));
|
||||
let data = stack.data();
|
||||
// PotDecorations.addToTooltip.
|
||||
if let Some(sherds) = data
|
||||
@@ -303,32 +546,13 @@ pub fn detail_lines(
|
||||
.into_iter()
|
||||
.map(|l| l.0),
|
||||
);
|
||||
let applied: Vec<String> = stack
|
||||
.potion()
|
||||
.and_then(crate::brewing::potion)
|
||||
.into_iter()
|
||||
.flat_map(|p| p.effects.iter())
|
||||
.filter_map(|(effect, _, amplifier)| {
|
||||
let (attribute, amount, operation) = effect_modifier(effect)?;
|
||||
modifier_line(
|
||||
translate,
|
||||
attribute,
|
||||
amount * (*amplifier + 1) as f64,
|
||||
operation,
|
||||
false,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if !applied.is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push(text("potion.whenDrank"));
|
||||
lines.extend(applied);
|
||||
}
|
||||
}
|
||||
// JukeboxPlayable.addToTooltip: the song's description.
|
||||
if path.starts_with("music_disc_") {
|
||||
lines.push(text(&format!("item.minecraft.{path}.desc")));
|
||||
lines.extend(
|
||||
potion_modifier_lines(stack, translate)
|
||||
.into_iter()
|
||||
.map(|l| l.0),
|
||||
);
|
||||
}
|
||||
lines.extend(jukebox_line(stack, info, &display, translate));
|
||||
// Trim.addToTooltip.
|
||||
if let Some((material, pattern)) = data.and_then(|d| d.trim.as_ref()) {
|
||||
let short = |s: &str| s.trim_start_matches("minecraft:").to_owned();
|
||||
@@ -342,10 +566,7 @@ pub fn detail_lines(
|
||||
text(&format!("trim_material.minecraft.{}", short(material)))
|
||||
));
|
||||
}
|
||||
// DyedItemColor.addToTooltip (without advanced tooltips).
|
||||
if data.is_some_and(|d| d.dyed_color.is_some()) {
|
||||
lines.push(text("item.dyed"));
|
||||
}
|
||||
lines.extend(dyed_line(stack, &display, false, translate).map(|l| l.0));
|
||||
if let Some(info) = info {
|
||||
lines.extend(
|
||||
attribute_lines(&info.modifiers, None, translate)
|
||||
|
||||
@@ -131,6 +131,8 @@ pub struct ItemInfo {
|
||||
pub block_transformer: Option<String>,
|
||||
/// Has a default `bundle_contents` (the bundles, `BundleItem`).
|
||||
pub bundle: bool,
|
||||
/// The ids of the item's default components (its prototype's keys).
|
||||
pub components: Vec<String>,
|
||||
}
|
||||
/// `Item.Properties.craftRemainder` (not a data component, so not in the
|
||||
/// default-component report): what an item leaves in the grid when crafted with.
|
||||
@@ -650,6 +652,10 @@ impl Items {
|
||||
cushion_color: c["minecraft:cushion/color"].as_str().map(str::to_owned),
|
||||
block_transformer: c["minecraft:block_transformer"].as_str().map(str::to_owned),
|
||||
bundle: c.get("minecraft:bundle_contents").is_some(),
|
||||
components: c
|
||||
.as_object()
|
||||
.map(|c| c.keys().cloned().collect())
|
||||
.unwrap_or_default(),
|
||||
crafting_remainder: CRAFT_REMAINDERS
|
||||
.iter()
|
||||
.find(|(item, _)| *item == name)
|
||||
|
||||
@@ -791,6 +791,16 @@ fn login_and_configure(
|
||||
},
|
||||
}),
|
||||
)?;
|
||||
// ServerConfigurationPacketListenerImpl.startConfiguration: links, only
|
||||
// when there are any (MinecraftServer.serverLinks is empty elsewhere).
|
||||
if !shared.config.server_links.is_empty() {
|
||||
send(
|
||||
c,
|
||||
cfg::Clientbound::ServerLinks(cfg::ServerLinks {
|
||||
links: shared.config.server_links.clone(),
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
send(
|
||||
c,
|
||||
cfg::Clientbound::UpdateEnabledFeatures(cfg::UpdateEnabledFeatures {
|
||||
|
||||
@@ -36,6 +36,30 @@ impl Hub {
|
||||
|
||||
/// ClientboundSoundPacket for a sound event name (an inline event when
|
||||
/// the registry does not know it).
|
||||
pub fn sound_packet(
|
||||
&self,
|
||||
name: &str,
|
||||
at: [f64; 3],
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
source: i32,
|
||||
seed: i64,
|
||||
) -> C {
|
||||
let sound = match self.sync.id("minecraft:sound_event", name) {
|
||||
Some(id) => (id, None),
|
||||
None => (-1, Some((name.to_owned(), None))),
|
||||
};
|
||||
C::Sound(play::Sound {
|
||||
sound,
|
||||
source,
|
||||
x: (at[0] * 8.0) as i32,
|
||||
y: (at[1] * 8.0) as i32,
|
||||
z: (at[2] * 8.0) as i32,
|
||||
volume,
|
||||
pitch,
|
||||
seed,
|
||||
})
|
||||
}
|
||||
pub fn sound(
|
||||
&self,
|
||||
name: &str,
|
||||
@@ -45,20 +69,7 @@ impl Hub {
|
||||
source: i32,
|
||||
except: Option<u32>,
|
||||
) {
|
||||
let sound = match self.sync.id("minecraft:sound_event", name) {
|
||||
Some(id) => (id, None),
|
||||
None => (-1, Some((name.to_owned(), None))),
|
||||
};
|
||||
let packet = C::Sound(play::Sound {
|
||||
sound,
|
||||
source,
|
||||
x: (at[0] * 8.0) as i32,
|
||||
y: (at[1] * 8.0) as i32,
|
||||
z: (at[2] * 8.0) as i32,
|
||||
volume,
|
||||
pitch,
|
||||
seed: self.ticks as i64,
|
||||
});
|
||||
let packet = self.sound_packet(name, at, volume, pitch, source, self.ticks as i64);
|
||||
self.broadcast_near(at, f64::from(volume.max(1.0)) * 16.0, &packet, except);
|
||||
}
|
||||
|
||||
@@ -120,12 +131,13 @@ impl Hub {
|
||||
);
|
||||
}
|
||||
|
||||
/// `ServerPlayer.crit`: send the victim's animation to the players tracking
|
||||
/// the attacker and the attacker themselves (`sendToTrackingPlayersAndSelf`).
|
||||
pub fn critical_hit(&self, attacker: i32, target: i32) {
|
||||
/// `ServerPlayer.crit` (`magicCrit` when `magic`): send the victim's
|
||||
/// animation to the players tracking the attacker and the attacker
|
||||
/// themselves (`sendToTrackingPlayersAndSelf`).
|
||||
pub fn critical_hit(&self, attacker: i32, target: i32, magic: bool) {
|
||||
let body = C::Animate(play::Animate {
|
||||
id: target,
|
||||
action: 1,
|
||||
action: if magic { 2 } else { 1 },
|
||||
})
|
||||
.encode();
|
||||
for g in self.guests.iter().filter(|g| {
|
||||
@@ -421,6 +433,42 @@ impl Hub {
|
||||
drops
|
||||
}
|
||||
|
||||
/// `AbstractContainerMenu.setSelectedBundleItemIndex` on the guest's open
|
||||
/// menu: a container's own slots, or the inventory's. A bundle's selection
|
||||
/// lives on the server's stack until the click that takes from it.
|
||||
pub fn select_bundle_item(&mut self, conn: u32, slot: usize, index: i32, world: &mut World) {
|
||||
let Some(g) = self.guests.iter_mut().find(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let open = g.menu.as_ref().map(|m| (m.size, m.slots));
|
||||
let mut items = match open {
|
||||
Some((size, source)) if slot < size => source.read(world, g),
|
||||
_ => None,
|
||||
};
|
||||
let stack = match open {
|
||||
Some((size, _)) if slot < size => items
|
||||
.as_mut()
|
||||
.and_then(|items| items.get_mut(slot))
|
||||
.and_then(Option::as_mut),
|
||||
Some((size, _)) => g
|
||||
.inventory
|
||||
.get_mut(player_slot(slot - size))
|
||||
.and_then(Option::as_mut),
|
||||
None => match slot {
|
||||
9..=35 => g.inventory.get_mut(slot).and_then(Option::as_mut),
|
||||
36..=44 => g.inventory.get_mut(slot - 36).and_then(Option::as_mut),
|
||||
45 => g.offhand.as_mut(),
|
||||
_ => None,
|
||||
},
|
||||
};
|
||||
if let Some(stack) = stack {
|
||||
crate::bundle::toggle_selected(stack, index);
|
||||
}
|
||||
if let (Some((_, source)), Some(items)) = (open, items) {
|
||||
source.write(world, g, items);
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on the inventory menu (window 0) the game applied itself
|
||||
/// (the crafting grid): the client is corrected like after any click.
|
||||
pub fn inventory_clicked(
|
||||
|
||||
@@ -611,8 +611,9 @@ pub struct Viewer<'a> {
|
||||
}
|
||||
|
||||
/// One entity as this tick's range checks read it: where it is
|
||||
/// horizontally, its id, its type's tracking range in chunks and the chunk
|
||||
/// it is in (an index into the tick's chunks).
|
||||
/// horizontally, its id, its tracking range in blocks (scaled by
|
||||
/// `entity-broadcast-range-percentage`) and the chunk it is in (an index
|
||||
/// into the tick's chunks).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Probe {
|
||||
x: f64,
|
||||
@@ -627,7 +628,7 @@ struct Probe {
|
||||
/// each of the tick's chunks whether the client has it (0: not looked up
|
||||
/// yet, 1: no, 2: yes), so each chunk is looked up once per client.
|
||||
fn sees(v: &Viewer, p: &Probe, chunks: &[(i32, i32)], held: &mut [u8]) -> bool {
|
||||
let range = f64::from(p.range.min(v.view_distance) * 16);
|
||||
let range = f64::from(p.range.min(v.view_distance * 16));
|
||||
let (dx, dz) = (v.position[0] - p.x, v.position[2] - p.z);
|
||||
dx * dx + dz * dz <= range * range && {
|
||||
let held = &mut held[p.chunk as usize];
|
||||
@@ -641,6 +642,9 @@ fn sees(v: &Viewer, p: &Probe, chunks: &[(i32, i32)], held: &mut [u8]) -> bool {
|
||||
/// One level's tracked entities (ChunkMap.entityMap).
|
||||
#[derive(Default)]
|
||||
pub struct Entities {
|
||||
/// `entity-broadcast-range-percentage` (None: 100), which scales every
|
||||
/// type's tracking range (MinecraftServer.getScaledTrackingDistance).
|
||||
pub range_percent: Option<i32>,
|
||||
states: crate::fast_hash::FxHashMap<i32, ServerEntity>,
|
||||
/// The level tick, stamped on every entity still in the level.
|
||||
tick: u32,
|
||||
@@ -732,7 +736,9 @@ impl Entities {
|
||||
x: e.position[0],
|
||||
z: e.position[2],
|
||||
id: e.id,
|
||||
range: s.range,
|
||||
range: self
|
||||
.range_percent
|
||||
.map_or(s.range * 16, |p| p * (s.range * 16) / 100),
|
||||
chunk,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::{
|
||||
use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape};
|
||||
use horizon_protocol::ids::play::clientbound as ids;
|
||||
use horizon_protocol::{
|
||||
entity_data::{Value, serializer},
|
||||
entity_data::{Particle, ParticleData, Value, serializer},
|
||||
packets::play::{self, Clientbound as C, Serverbound as S},
|
||||
text::{literal, translatable},
|
||||
};
|
||||
@@ -87,6 +87,29 @@ pub struct HostState {
|
||||
pub living_flags: i8,
|
||||
/// The bed the host sleeps in (`LivingEntity.SLEEPING_POS`).
|
||||
pub bed: Option<[i32; 3]>,
|
||||
pub body: BodyFlags,
|
||||
}
|
||||
|
||||
/// What the other players see of a player's body besides sneaking and
|
||||
/// sprinting: the pose and the rest of `Entity.DATA_SHARED_FLAGS_ID`.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct BodyFlags {
|
||||
/// `Entity.isFallFlying`: gliding with elytra.
|
||||
pub gliding: bool,
|
||||
/// `Entity.isSwimming` (the swimming flag).
|
||||
pub swimming: bool,
|
||||
/// The swimming pose of a player crawling under a block.
|
||||
pub crawling: bool,
|
||||
/// The crouching pose, held without sneaking under a low ceiling.
|
||||
pub crouching: bool,
|
||||
/// `LivingEntity.isAutoSpinAttack`: Riptide's spin.
|
||||
pub spin_attack: bool,
|
||||
/// `Entity.isOnFire`.
|
||||
pub on_fire: bool,
|
||||
/// `Entity.isInvisible`: the invisibility effect.
|
||||
pub invisible: bool,
|
||||
/// The glowing effect.
|
||||
pub glowing: bool,
|
||||
}
|
||||
|
||||
/// Everything the hub needs from the game for one tick.
|
||||
@@ -168,6 +191,8 @@ pub struct Hub {
|
||||
level_sync: std::collections::HashMap<String, LevelSync>,
|
||||
/// Operators and their permission levels (dedicated servers).
|
||||
pub ops: std::collections::HashMap<u128, u8>,
|
||||
/// `entity-broadcast-range-percentage` (dedicated servers).
|
||||
pub entity_range_percent: i32,
|
||||
chat_type: i32,
|
||||
dimension_type: i32,
|
||||
clock_ids: Vec<i32>,
|
||||
@@ -335,6 +360,7 @@ impl Hub {
|
||||
ticking: (20.0, false),
|
||||
stepping: 0,
|
||||
ops: Default::default(),
|
||||
entity_range_percent: 100,
|
||||
weather: (false, 0.0, 0.0),
|
||||
level_sync: Default::default(),
|
||||
chat_type,
|
||||
@@ -1527,6 +1553,49 @@ impl Hub {
|
||||
|
||||
// ---- entities ------------------------------------------------------
|
||||
|
||||
/// `LivingEntity.updateSynchronizedMobEffectParticles`: DATA_EFFECT_PARTICLES
|
||||
/// (10) and DATA_EFFECT_AMBIENCE_ID (11) for the visible `effects`, as
|
||||
/// (effect, ambient). The entries are left out when there is nothing to
|
||||
/// show, which is their default.
|
||||
pub fn effect_meta<'a>(
|
||||
&self,
|
||||
effects: impl IntoIterator<Item = (&'a str, bool)>,
|
||||
) -> Vec<(u8, i32, Value)> {
|
||||
let mut particles = Vec::new();
|
||||
let mut all_ambient = true;
|
||||
for (effect, ambient) in effects {
|
||||
let effect = effect.trim_start_matches("minecraft:");
|
||||
// MobEffect.particleFactory: the omens have their own particles,
|
||||
// the others an entity effect in their colour (faint if ambient).
|
||||
let (name, data) = match effect {
|
||||
"trial_omen" | "raid_omen" => (effect, None),
|
||||
_ => {
|
||||
let Some(def) = crate::brewing::effect(effect) else {
|
||||
continue;
|
||||
};
|
||||
let alpha = if ambient { 38 } else { 255 };
|
||||
let argb = (alpha << 24) | def.color as i32;
|
||||
("entity_effect", Some(ParticleData::Color(argb)))
|
||||
}
|
||||
};
|
||||
let Some(id) =
|
||||
self.registry_id("minecraft:particle_type", &format!("minecraft:{name}"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
particles.push(Particle { id, data });
|
||||
all_ambient &= ambient;
|
||||
}
|
||||
if particles.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut meta = vec![(10, serializer::PARTICLES, Value::Particles(particles))];
|
||||
if all_ambient {
|
||||
meta.push((11, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
meta
|
||||
}
|
||||
|
||||
fn player_state(
|
||||
&self,
|
||||
id: i32,
|
||||
@@ -1534,21 +1603,29 @@ impl Hub {
|
||||
position: [f64; 3],
|
||||
rot: (f32, f32, f32),
|
||||
on_ground: bool,
|
||||
flags: (bool, bool, bool, bool),
|
||||
flags: (bool, bool, bool),
|
||||
body: BodyFlags,
|
||||
living_flags: i8,
|
||||
bed: Option<[i32; 3]>,
|
||||
skin: u8,
|
||||
health: f32,
|
||||
equipment: Vec<(u8, Option<horizon_protocol::item::Stack>)>,
|
||||
attributes: Vec<play::AttributeSnapshot>,
|
||||
effects: &[EffectInstance],
|
||||
) -> EntityState {
|
||||
let (sneaking, sprinting, gliding, sleeping) = flags;
|
||||
let shared = shared_flags(sneaking, sprinting, gliding);
|
||||
let (sneaking, sprinting, sleeping) = flags;
|
||||
let shared = shared_flags(sneaking, sprinting, &body);
|
||||
// Player.getDesiredPose (and updatePlayerPose's crawl): sleeping,
|
||||
// swimming, gliding, Riptide's spin, crouching.
|
||||
let pose = if sleeping {
|
||||
2
|
||||
} else if gliding {
|
||||
} else if body.swimming || body.crawling {
|
||||
3
|
||||
} else if body.gliding {
|
||||
1
|
||||
} else if sneaking {
|
||||
} else if body.spin_attack {
|
||||
4
|
||||
} else if sneaking || body.crouching {
|
||||
5
|
||||
} else {
|
||||
0
|
||||
@@ -1574,6 +1651,14 @@ impl Hub {
|
||||
if skin != 0 {
|
||||
meta.push((16, serializer::BYTE, Value::Byte(skin as i8)));
|
||||
}
|
||||
meta.extend(
|
||||
self.effect_meta(
|
||||
effects
|
||||
.iter()
|
||||
.filter(|e| e.visible)
|
||||
.map(|e| (e.id, e.ambient)),
|
||||
),
|
||||
);
|
||||
EntityState {
|
||||
id,
|
||||
uuid,
|
||||
@@ -1586,7 +1671,11 @@ impl Hub {
|
||||
velocity: [0.0; 3],
|
||||
data: 0,
|
||||
meta,
|
||||
defaults: Vec::new(),
|
||||
// The effect swirls go back to none.
|
||||
defaults: vec![
|
||||
(10, serializer::PARTICLES, Value::Particles(Vec::new())),
|
||||
(11, serializer::BOOLEAN, Value::Bool(false)),
|
||||
],
|
||||
equipment,
|
||||
attributes,
|
||||
precise: false,
|
||||
@@ -1634,13 +1723,15 @@ impl Hub {
|
||||
h.position,
|
||||
(h.yaw, h.pitch, h.head_yaw),
|
||||
h.on_ground,
|
||||
(h.sneaking, h.sprinting, false, h.bed.is_some()),
|
||||
(h.sneaking, h.sprinting, h.bed.is_some()),
|
||||
h.body,
|
||||
h.living_flags,
|
||||
h.bed,
|
||||
0x7F,
|
||||
h.health,
|
||||
equipment,
|
||||
h.attributes.clone(),
|
||||
&[],
|
||||
));
|
||||
}
|
||||
let placed: Vec<usize> = (0..self.guests.len())
|
||||
@@ -1666,7 +1757,8 @@ impl Hub {
|
||||
g.position,
|
||||
(g.yaw, g.pitch, g.head_yaw),
|
||||
g.on_ground,
|
||||
(g.sneaking, g.sprinting, g.fall_flying, g.sleeping),
|
||||
(g.sneaking, g.sprinting, g.sleeping),
|
||||
guest_body(g),
|
||||
g.living_flags,
|
||||
g.bed,
|
||||
g.info.model_customisation,
|
||||
@@ -1676,6 +1768,7 @@ impl Hub {
|
||||
.as_ref()
|
||||
.map(|m| super::attributes::syncable(&m.attributes))
|
||||
.unwrap_or_default(),
|
||||
&g.effects.list,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1705,6 +1798,7 @@ impl Hub {
|
||||
out,
|
||||
})
|
||||
.collect();
|
||||
level.range_percent = Some(self.entity_range_percent);
|
||||
let (types, sync) = (&mut self.entity_types, &self.sync);
|
||||
level.tick(&entities, &self.riding, &mut viewers, |kind| {
|
||||
entity_type(types, sync, kind)
|
||||
@@ -1825,7 +1919,7 @@ impl Hub {
|
||||
// too, so the sprint a hit stops (Player.causeExtraKnockback) stops
|
||||
// on its client.
|
||||
let g = &mut self.guests[i];
|
||||
let shared = shared_flags(g.sneaking, g.sprinting, g.fall_flying);
|
||||
let shared = shared_flags(g.sneaking, g.sprinting, &guest_body(g));
|
||||
if shared != g.sent_shared {
|
||||
g.sent_shared = shared;
|
||||
g.send(&C::SetEntityData(play::SetEntityData {
|
||||
@@ -1974,6 +2068,7 @@ impl Hub {
|
||||
| S::PlayerLoaded(_)
|
||||
| S::ChunkBatchReceived(_)
|
||||
| S::ClientInformation(_)
|
||||
| S::RecipeBookChangeSettings(_)
|
||||
| S::ClientTickEnd(_)
|
||||
| S::KeepAlive(_) => false,
|
||||
S::MovePlayerPos(p) => self.moved_far(i, [p.x, p.y, p.z]),
|
||||
@@ -2040,6 +2135,15 @@ impl Hub {
|
||||
g.max_unacknowledged = 10;
|
||||
}
|
||||
S::ClientTickEnd(_) => self.guests[i].position_this_tick = false,
|
||||
// ServerGamePacketListenerImpl.handleRecipeBookChangeSettingsPacket.
|
||||
S::RecipeBookChangeSettings(p) => {
|
||||
if let Some(book) = crate::recipe_book::BookType::ALL.get(p.book_type as usize)
|
||||
{
|
||||
let settings = &mut self.guests[i].recipe_book;
|
||||
settings.open[book.index()] = p.open;
|
||||
settings.filtering[book.index()] = p.filtering;
|
||||
}
|
||||
}
|
||||
S::ClientInformation(info) => {
|
||||
let g = &mut self.guests[i];
|
||||
g.view_distance =
|
||||
@@ -2070,6 +2174,20 @@ impl Hub {
|
||||
S::Chat(c) => self.handle_chat(i, c, events),
|
||||
S::ChatAck(p) => self.chat_ack(i, p.offset, events),
|
||||
S::ChatSessionUpdate(p) => self.chat_session_update(i, p, events),
|
||||
// handlePlayerAction (a dig starting, stopping or finishing) is the
|
||||
// game's, run after this pass. A move queued behind it is checked
|
||||
// against the block the player just broke and falls through, so it
|
||||
// would be sent back; it waits for the next tick, when the block is
|
||||
// gone, as vanilla handles the two packets in order.
|
||||
S::PlayerAction(p) if p.action <= 3 => {
|
||||
events.push(HostEvent::Packet(conn, Box::new(S::PlayerAction(p))));
|
||||
if matches!(
|
||||
self.guests[i].inbox.front(),
|
||||
Some(S::MovePlayerPos(_) | S::MovePlayerPosRot(_))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
other => events.push(HostEvent::Packet(conn, Box::new(other))),
|
||||
}
|
||||
}
|
||||
@@ -2161,11 +2279,16 @@ impl Hub {
|
||||
packets = 1;
|
||||
}
|
||||
// shouldCheckPlayerMovement (never the single-player owner).
|
||||
// The server holds no velocity for a guest (getDeltaMovement),
|
||||
// so nothing is expected.
|
||||
if !owner && check && (!g.fall_flying || elytra_check) {
|
||||
let limit = if g.fall_flying { 300.0 } else { 100.0 };
|
||||
if moved > limit * f64::from(packets) {
|
||||
// `movedDist - expectedDist` (getDeltaMovement): the velocity
|
||||
// the server last simulated for the guest is expected
|
||||
// movement, not a violation — a landing glide keeps its speed.
|
||||
let expected = g.motion.as_ref().map_or(0.0, |m| {
|
||||
let v = m.velocity;
|
||||
v.x * v.x + v.y * v.y + v.z * v.z
|
||||
});
|
||||
if moved - expected > limit * f64::from(packets) {
|
||||
events.push(HostEvent::Warn(format!(
|
||||
"{} moved too quickly! {},{},{}",
|
||||
g.name,
|
||||
@@ -2605,16 +2728,44 @@ fn ticking_state(rate: f32, frozen: bool) -> C {
|
||||
raw(ids::TICKING_STATE, w.data)
|
||||
}
|
||||
|
||||
/// Entity.DATA_SHARED_FLAGS_ID of a player: crouching, sprinting, gliding.
|
||||
fn shared_flags(sneaking: bool, sprinting: bool, gliding: bool) -> i8 {
|
||||
/// What the others see of a guest's body, from its server-side state.
|
||||
fn guest_body(g: &Guest) -> BodyFlags {
|
||||
let m = g.motion.as_deref();
|
||||
BodyFlags {
|
||||
gliding: g.fall_flying,
|
||||
swimming: m.is_some_and(|m| m.swimming),
|
||||
crawling: m.is_some_and(|m| m.pose == crate::pose::Pose::Swimming && !m.swimming),
|
||||
crouching: m.is_some_and(|m| m.pose == crate::pose::Pose::Crouching),
|
||||
spin_attack: m.is_some_and(|m| m.auto_spin_attack_ticks > 0),
|
||||
on_fire: m.is_some_and(|m| m.fire_ticks > 0),
|
||||
invisible: g.effects.get("invisibility").is_some(),
|
||||
glowing: g.effects.get("glowing").is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity.DATA_SHARED_FLAGS_ID of a player: on fire 0x01, crouching 0x02,
|
||||
/// sprinting 0x08, swimming 0x10, invisible 0x20, glowing 0x40, gliding 0x80.
|
||||
fn shared_flags(sneaking: bool, sprinting: bool, body: &BodyFlags) -> i8 {
|
||||
let mut shared = 0i8;
|
||||
if body.on_fire {
|
||||
shared |= 0x01;
|
||||
}
|
||||
if sneaking {
|
||||
shared |= 0x02;
|
||||
}
|
||||
if sprinting {
|
||||
shared |= 0x08;
|
||||
}
|
||||
if gliding {
|
||||
if body.swimming {
|
||||
shared |= 0x10;
|
||||
}
|
||||
if body.invisible {
|
||||
shared |= 0x20;
|
||||
}
|
||||
if body.glowing {
|
||||
shared |= 0x40;
|
||||
}
|
||||
if body.gliding {
|
||||
shared |= -128;
|
||||
}
|
||||
shared
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! menu are returned for the game to drop.
|
||||
use super::guest::Guest;
|
||||
use crate::{
|
||||
bundle::{Contents, can_be_in_bundle},
|
||||
bundle::{Contents, can_be_in_bundle, toggle_selected},
|
||||
items::{ItemStack, Items},
|
||||
world::World,
|
||||
};
|
||||
@@ -514,7 +514,10 @@ fn bundle_click(view: &mut View, i: usize, right: bool, rules: &dyn Rules) -> bo
|
||||
}
|
||||
}
|
||||
(carried, _) => {
|
||||
// toggleSelectedItem(-1), then the click goes on.
|
||||
view.guest.carried = carried;
|
||||
toggle_selected(&mut bundle, -1);
|
||||
view.set(i, Some(bundle));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,10 @@ pub struct NetConfig {
|
||||
/// server.properties `resource-pack`: offered to every player during
|
||||
/// configuration (a dedicated server's; MinecraftServer.getServerResourcePack).
|
||||
pub resource_pack: Option<crate::server::properties::ResourcePackInfo>,
|
||||
/// MinecraftServer.serverLinks (a dedicated server's `bug-report-link`):
|
||||
/// sent in configuration when non-empty
|
||||
/// (ServerConfigurationPacketListenerImpl.startConfiguration).
|
||||
pub server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
|
||||
}
|
||||
impl NetConfig {
|
||||
/// MinecraftServer.enforceSecureProfile.
|
||||
@@ -168,6 +172,7 @@ impl NetConfig {
|
||||
online: None,
|
||||
log: None,
|
||||
resource_pack: None,
|
||||
server_links: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,10 +324,13 @@ impl BlockLandingWorld for MotionWorld<'_, '_, '_> {
|
||||
type DamageSource = LandingDamage;
|
||||
type Sound = SceneSound;
|
||||
fn is_server_level(&self) -> bool {
|
||||
true
|
||||
// A remote client's world is a client level: it never tramples,
|
||||
// the server's block change and teleport do it (FarmlandBlock.fallOn
|
||||
// and TurtleEggBlock.fallOn check `level instanceof ServerLevel`).
|
||||
!self.player.client_side
|
||||
}
|
||||
fn is_client_side(&mut self) -> Result<bool> {
|
||||
Ok(false)
|
||||
Ok(self.player.client_side)
|
||||
}
|
||||
fn random_float(&mut self) -> Result<f32> {
|
||||
Ok(self.scene.random.next_float())
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use crate::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, HEIGHT, Light, MIN_Y, SECTIONS, index},
|
||||
chunk::{Chunk, HEIGHT, Light, MIN_Y, SECTIONS, Section, index},
|
||||
};
|
||||
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
@@ -565,43 +565,130 @@ pub fn update_light_layers<L: AsRef<[u8]>>(
|
||||
|
||||
/// Resident chunks the incremental relighter reads and writes.
|
||||
pub trait LightWorld {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32>;
|
||||
/// Packed light (sky << 4 | block), None outside loaded chunks.
|
||||
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8>;
|
||||
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8);
|
||||
/// Section `s` of chunk (cx, cz), None unless the chunk is loaded and lit.
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>>;
|
||||
/// Replace the light of a section `section` returned.
|
||||
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: Light);
|
||||
}
|
||||
impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S> {
|
||||
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>> {
|
||||
self.get(&(cx, cz))
|
||||
.filter(|c| c.lit)
|
||||
.map(|c| &c.sections[s])
|
||||
}
|
||||
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: Light) {
|
||||
if let Some(chunk) = self.get_mut(&(cx, cz)) {
|
||||
Arc::make_mut(&mut Arc::make_mut(chunk).sections[s]).light = light;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The sections one relight touches, held for the whole flood fill so each
|
||||
/// cell access is an array index instead of a chunk lookup. Light a section
|
||||
/// gets written is copied out on the first write and stored back by `finish`.
|
||||
struct Region<'w> {
|
||||
world: &'w mut dyn LightWorld,
|
||||
slots: Vec<Slot>,
|
||||
/// Section key (cx, s, cz) to slot, `NONE` where the chunk isn't lit.
|
||||
index: FxHashMap<Pos, u32>,
|
||||
last: (Pos, u32),
|
||||
}
|
||||
struct Slot {
|
||||
key: Pos,
|
||||
/// The world's section; its light is the value before this relight.
|
||||
section: Arc<Section>,
|
||||
light: Option<Box<[u8; 4096]>>,
|
||||
}
|
||||
const NONE: u32 = u32::MAX;
|
||||
impl<'w> Region<'w> {
|
||||
fn new(world: &'w mut dyn LightWorld) -> Self {
|
||||
Self {
|
||||
world,
|
||||
slots: Vec::new(),
|
||||
index: FxHashMap::default(),
|
||||
last: ((i32::MIN, 0, 0), NONE),
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn slot(&mut self, x: i32, y: i32, z: i32) -> Option<usize> {
|
||||
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
|
||||
return None;
|
||||
}
|
||||
self.get(&(x >> 4, z >> 4))
|
||||
.filter(|c| c.lit)
|
||||
.map(|c| c.get(x & 15, y, z & 15))
|
||||
}
|
||||
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8> {
|
||||
if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) {
|
||||
return None;
|
||||
let key = (x >> 4, (y - MIN_Y) >> 4, z >> 4);
|
||||
if self.last.0 != key {
|
||||
self.last = (key, self.load(key));
|
||||
}
|
||||
self.get(&(x >> 4, z >> 4))
|
||||
.filter(|c| c.lit)
|
||||
.map(|c| c.light(x & 15, y, z & 15))
|
||||
(self.last.1 != NONE).then_some(self.last.1 as usize)
|
||||
}
|
||||
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8) {
|
||||
let Some(chunk) = self.get_mut(&(x >> 4, z >> 4)) else {
|
||||
fn load(&mut self, key: Pos) -> u32 {
|
||||
if let Some(&slot) = self.index.get(&key) {
|
||||
return slot;
|
||||
}
|
||||
let slot = match self.world.section(key.0, key.1 as usize, key.2) {
|
||||
Some(section) => {
|
||||
self.slots.push(Slot {
|
||||
key,
|
||||
section: section.clone(),
|
||||
light: None,
|
||||
});
|
||||
(self.slots.len() - 1) as u32
|
||||
}
|
||||
None => NONE,
|
||||
};
|
||||
self.index.insert(key, slot);
|
||||
slot
|
||||
}
|
||||
fn state(&mut self, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
let slot = self.slot(x, y, z)?;
|
||||
Some(self.slots[slot].section.get(index(x, y, z)))
|
||||
}
|
||||
/// Packed light (sky << 4 | block), None outside loaded, lit chunks.
|
||||
fn light(&mut self, x: i32, y: i32, z: i32) -> Option<u8> {
|
||||
let slot = self.slot(x, y, z)?;
|
||||
let slot = &self.slots[slot];
|
||||
let i = index(x, y, z);
|
||||
Some(match &slot.light {
|
||||
Some(light) => light[i],
|
||||
None => slot.section.light(i),
|
||||
})
|
||||
}
|
||||
/// State and packed light of a cell, with a single section lookup.
|
||||
fn cell(&mut self, x: i32, y: i32, z: i32) -> Option<(u32, u8)> {
|
||||
let slot = self.slot(x, y, z)?;
|
||||
let slot = &self.slots[slot];
|
||||
let i = index(x, y, z);
|
||||
let light = match &slot.light {
|
||||
Some(light) => light[i],
|
||||
None => slot.section.light(i),
|
||||
};
|
||||
Some((slot.section.get(i), light))
|
||||
}
|
||||
/// Rewrite a cell's packed light; nothing outside loaded, lit chunks.
|
||||
fn update_light(&mut self, x: i32, y: i32, z: i32, f: impl FnOnce(u8) -> u8) {
|
||||
let Some(slot) = self.slot(x, y, z) else {
|
||||
return;
|
||||
};
|
||||
let s = ((y - MIN_Y) >> 4) as usize;
|
||||
let slot = &mut self.slots[slot];
|
||||
let light = slot.light.get_or_insert_with(|| match &slot.section.light {
|
||||
Light::Uniform(v) => Box::new([*v; 4096]),
|
||||
Light::Full(a) => a.clone(),
|
||||
});
|
||||
let i = index(x, y, z);
|
||||
if chunk.sections[s].light(i) == packed {
|
||||
return;
|
||||
}
|
||||
let section = Arc::make_mut(&mut Arc::make_mut(chunk).sections[s]);
|
||||
if let Light::Uniform(v) = section.light {
|
||||
section.light = Light::Full(Box::new([v; 4096]));
|
||||
}
|
||||
if let Light::Full(a) = &mut section.light {
|
||||
a[i] = packed;
|
||||
light[i] = f(light[i]);
|
||||
}
|
||||
/// Store back the light of every section whose values changed.
|
||||
fn finish(self) {
|
||||
for slot in self.slots {
|
||||
let Some(light) = slot.light else {
|
||||
continue;
|
||||
};
|
||||
if (0..4096).all(|i| slot.section.light(i) == light[i]) {
|
||||
continue;
|
||||
}
|
||||
// Drop the clone first so the world's section isn't copied.
|
||||
drop(slot.section);
|
||||
let (cx, s, cz) = slot.key;
|
||||
self.world
|
||||
.set_section_light(cx, s as usize, cz, Light::Full(light));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -690,25 +777,23 @@ impl LightChanges {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn level(world: &impl LightWorld, sky: bool, p: Pos) -> Option<u8> {
|
||||
fn level(world: &mut Region, sky: bool, p: Pos) -> Option<u8> {
|
||||
world
|
||||
.light(p.0, p.1, p.2)
|
||||
.map(|v| if sky { v >> 4 } else { v & 15 })
|
||||
}
|
||||
fn set_level(world: &mut impl LightWorld, sky: bool, p: Pos, value: u8) {
|
||||
let Some(old) = world.light(p.0, p.1, p.2) else {
|
||||
return;
|
||||
};
|
||||
let packed = if sky {
|
||||
(old & 0x0F) | value << 4
|
||||
} else {
|
||||
(old & 0xF0) | value
|
||||
};
|
||||
world.set_light(p.0, p.1, p.2, packed);
|
||||
fn set_level(world: &mut Region, sky: bool, p: Pos, value: u8) {
|
||||
world.update_light(p.0, p.1, p.2, |old| {
|
||||
if sky {
|
||||
(old & 0x0F) | value << 4
|
||||
} else {
|
||||
(old & 0xF0) | value
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `ChunkSkyLightSources`: the Y of the upper block of the highest occluded
|
||||
/// edge in the column (every block at or above it is a sky source).
|
||||
pub fn lowest_sky_source(world: &impl LightWorld, tables: &LightTables, x: i32, z: i32) -> i32 {
|
||||
fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -> i32 {
|
||||
let mut y = MIN_Y + HEIGHT - 1;
|
||||
let Some(mut top) = world.state(x, y, z) else {
|
||||
return MIN_Y;
|
||||
@@ -739,34 +824,52 @@ pub fn relight(
|
||||
force: bool,
|
||||
changed: &mut LightChanges,
|
||||
) {
|
||||
relight_layer(world, tables, false, positions.to_vec(), force, changed);
|
||||
let mut region = Region::new(world);
|
||||
relight_region(&mut region, tables, sky_light, positions, force, changed);
|
||||
region.finish();
|
||||
}
|
||||
fn relight_region(
|
||||
world: &mut Region,
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
positions: &[Pos],
|
||||
force: bool,
|
||||
changed: &mut LightChanges,
|
||||
) {
|
||||
crate::spans::time("lt_block", || {
|
||||
relight_layer(world, tables, false, positions.to_vec(), force, changed)
|
||||
});
|
||||
if sky_light {
|
||||
let mut seeds = positions.to_vec();
|
||||
let mut columns: HashMap<(i32, i32), i32> = HashMap::new();
|
||||
for &(x, _, z) in positions {
|
||||
if columns.contains_key(&(x, z)) {
|
||||
continue;
|
||||
}
|
||||
let source = lowest_sky_source(world, tables, x, z);
|
||||
columns.insert((x, z), source);
|
||||
// Cells whose source status changed: non-15 at or above the new
|
||||
// lowest source, and old 15s (sources) below it.
|
||||
for y in source..MIN_Y + HEIGHT {
|
||||
if level(world, true, (x, y, z)).is_some_and(|l| l != 15) {
|
||||
let mut columns: FxHashMap<(i32, i32), i32> = FxHashMap::default();
|
||||
crate::spans::time("lt_columns", || {
|
||||
for &(x, _, z) in positions {
|
||||
if columns.contains_key(&(x, z)) {
|
||||
continue;
|
||||
}
|
||||
let source = lowest_sky_source(world, tables, x, z);
|
||||
columns.insert((x, z), source);
|
||||
// Cells whose source status changed: non-15 at or above the new
|
||||
// lowest source, and old 15s (sources) below it.
|
||||
for y in source..MIN_Y + HEIGHT {
|
||||
if level(world, true, (x, y, z)).is_some_and(|l| l != 15) {
|
||||
seeds.push((x, y, z));
|
||||
}
|
||||
}
|
||||
let mut y = source - 1;
|
||||
while y >= MIN_Y && level(world, true, (x, y, z)) == Some(15) {
|
||||
seeds.push((x, y, z));
|
||||
y -= 1;
|
||||
}
|
||||
}
|
||||
let mut y = source - 1;
|
||||
while y >= MIN_Y && level(world, true, (x, y, z)) == Some(15) {
|
||||
seeds.push((x, y, z));
|
||||
y -= 1;
|
||||
}
|
||||
}
|
||||
relight_layer(world, tables, true, seeds, force, changed);
|
||||
});
|
||||
crate::spans::time("lt_sky", || {
|
||||
relight_layer(world, tables, true, seeds, force, changed)
|
||||
});
|
||||
}
|
||||
}
|
||||
fn relight_layer(
|
||||
world: &mut impl LightWorld,
|
||||
world: &mut Region,
|
||||
tables: &LightTables,
|
||||
sky: bool,
|
||||
seeds: Vec<Pos>,
|
||||
@@ -809,42 +912,40 @@ fn relight_layer(
|
||||
}
|
||||
}
|
||||
// Sources inside the cleared area light up again.
|
||||
let mut sources: HashMap<(i32, i32), i32> = HashMap::new();
|
||||
for p in cleared {
|
||||
let seed = if sky {
|
||||
let lowest = *sources
|
||||
.entry((p.0, p.2))
|
||||
.or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2));
|
||||
if p.1 >= lowest { 15 } else { 0 }
|
||||
} else {
|
||||
world
|
||||
.state(p.0, p.1, p.2)
|
||||
.map_or(0, |s| tables.cells[s as usize].emission)
|
||||
};
|
||||
if seed > level(world, sky, p).unwrap_or(15) {
|
||||
set_level(world, sky, p, seed);
|
||||
changed.push(p);
|
||||
increase.push_back(p);
|
||||
let mut sources: FxHashMap<(i32, i32), i32> = FxHashMap::default();
|
||||
crate::spans::time("lt_sources", || {
|
||||
for p in cleared {
|
||||
let seed = if sky {
|
||||
let lowest = *sources
|
||||
.entry((p.0, p.2))
|
||||
.or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2));
|
||||
if p.1 >= lowest { 15 } else { 0 }
|
||||
} else {
|
||||
world
|
||||
.state(p.0, p.1, p.2)
|
||||
.map_or(0, |s| tables.cells[s as usize].emission)
|
||||
};
|
||||
if seed > level(world, sky, p).unwrap_or(15) {
|
||||
set_level(world, sky, p, seed);
|
||||
changed.push(p);
|
||||
increase.push_back(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
while let Some(p) = increase.pop_front() {
|
||||
let Some(l) = level(world, sky, p) else {
|
||||
let Some((from, packed)) = world.cell(p.0, p.1, p.2) else {
|
||||
continue;
|
||||
};
|
||||
let l = if sky { packed >> 4 } else { packed & 15 };
|
||||
if l <= 1 {
|
||||
continue;
|
||||
}
|
||||
let Some(from) = world.state(p.0, p.1, p.2) else {
|
||||
continue;
|
||||
};
|
||||
for (d, (ox, oy, oz)) in OFFSETS.iter().enumerate() {
|
||||
let q = (p.0 + ox, p.1 + oy, p.2 + oz);
|
||||
let Some(to) = world.state(q.0, q.1, q.2) else {
|
||||
continue;
|
||||
};
|
||||
let Some(ql) = level(world, sky, q) else {
|
||||
let Some((to, packed)) = world.cell(q.0, q.1, q.2) else {
|
||||
continue;
|
||||
};
|
||||
let ql = if sky { packed >> 4 } else { packed & 15 };
|
||||
let next = l.saturating_sub(tables.cells[to as usize].dampening.max(1));
|
||||
if next > ql && !tables.blocked(from as usize, to as usize, d) {
|
||||
set_level(world, sky, q, next);
|
||||
@@ -915,10 +1016,10 @@ mod tests {
|
||||
lit.into_iter().collect()
|
||||
}
|
||||
fn sky(world: &HashMap<(i32, i32), Arc<Chunk>>, x: i32, y: i32, z: i32) -> u8 {
|
||||
world.light(x, y, z).unwrap() >> 4
|
||||
world[&(x >> 4, z >> 4)].light(x & 15, y, z & 15) >> 4
|
||||
}
|
||||
fn block(world: &HashMap<(i32, i32), Arc<Chunk>>, x: i32, y: i32, z: i32) -> u8 {
|
||||
world.light(x, y, z).unwrap() & 15
|
||||
world[&(x >> 4, z >> 4)].light(x & 15, y, z & 15) & 15
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -954,6 +1055,108 @@ mod tests {
|
||||
assert_eq!(sky(&w, 8, 2, 8), 0, "the full bottom face stops sky light");
|
||||
}
|
||||
#[test]
|
||||
fn relight_keeps_unchanged_and_unlit_sections() {
|
||||
let mut w = world(|_| {});
|
||||
Arc::make_mut(w.get_mut(&(1, 0)).unwrap()).lit = false;
|
||||
let before = w.clone();
|
||||
let same = |w: &HashMap<(i32, i32), Arc<Chunk>>, key| {
|
||||
w[&key]
|
||||
.sections
|
||||
.iter()
|
||||
.zip(&before[&key].sections)
|
||||
.all(|(a, b)| Arc::ptr_eq(a, b))
|
||||
};
|
||||
// Cleared and lit back to the same values: nothing is stored.
|
||||
relight(
|
||||
&mut w,
|
||||
&tables(),
|
||||
true,
|
||||
&[(8, 5, 8)],
|
||||
false,
|
||||
&mut LightChanges::default(),
|
||||
);
|
||||
assert!(w.keys().all(|&key| same(&w, key)));
|
||||
// Light reaching an unlit chunk isn't written into it.
|
||||
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(15, 0, 8, TORCH);
|
||||
relight(
|
||||
&mut w,
|
||||
&tables(),
|
||||
true,
|
||||
&[(15, 0, 8)],
|
||||
false,
|
||||
&mut LightChanges::default(),
|
||||
);
|
||||
assert!(same(&w, (1, 0)));
|
||||
assert_eq!(block(&w, 14, 0, 8), 13);
|
||||
}
|
||||
#[test]
|
||||
fn random_border_relights_match_full_relight() {
|
||||
// Edits around every border of chunk (0, 0), at the top and bottom of
|
||||
// the world, in batches; a held clone of the chunk must not change.
|
||||
let mut w = world(|_| {});
|
||||
let held = w[&(0, 0)].clone();
|
||||
let mut rng = 0x9e37_79b9_u32;
|
||||
let mut next = |n: usize| {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 17;
|
||||
rng ^= rng << 5;
|
||||
rng as usize % n
|
||||
};
|
||||
let xz = [-2, -1, 0, 1, 7, 14, 15, 16, 17];
|
||||
let ys = [
|
||||
MIN_Y,
|
||||
MIN_Y + 1,
|
||||
-2,
|
||||
-1,
|
||||
0,
|
||||
1,
|
||||
15,
|
||||
16,
|
||||
MIN_Y + HEIGHT - 2,
|
||||
MIN_Y + HEIGHT - 1,
|
||||
];
|
||||
for _ in 0..60 {
|
||||
let positions: Vec<Pos> = (0..1 + next(6))
|
||||
.map(|_| (xz[next(xz.len())], ys[next(ys.len())], xz[next(xz.len())]))
|
||||
.collect();
|
||||
for &(x, y, z) in &positions {
|
||||
let state = [TORCH, SLAB, AIR, STONE][next(4)];
|
||||
Arc::make_mut(w.get_mut(&(x >> 4, z >> 4)).unwrap()).set(x & 15, y, z & 15, state);
|
||||
}
|
||||
relight(
|
||||
&mut w,
|
||||
&tables(),
|
||||
true,
|
||||
&positions,
|
||||
false,
|
||||
&mut LightChanges::default(),
|
||||
);
|
||||
}
|
||||
// Every chunk, the outer ring lit as `world` lit it (no neighbours
|
||||
// past the 3x3).
|
||||
for (&(cx, cz), chunk) in &w {
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
w.get(&(cx + dx as i32 - 1, cz + dz as i32 - 1))
|
||||
.map(|c| &**c)
|
||||
})
|
||||
});
|
||||
for (si, s) in light_chunk(®ion, &tables(), true).iter().enumerate() {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(
|
||||
s.get(i),
|
||||
chunk.sections[si].light(i),
|
||||
"chunk {cx},{cz} section {si} index {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let fresh = world(|_| {});
|
||||
for (a, b) in held.sections.iter().zip(&fresh[&(0, 0)].sections) {
|
||||
assert!((0..4096).all(|i| a.light(i) == b.light(i)));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn incremental_relight_matches_full_relight() {
|
||||
let mut w = world(|c| {
|
||||
c.set(8, 0, 8, TORCH);
|
||||
@@ -1319,4 +1522,37 @@ mod tests {
|
||||
"fixture heap payload {payload} bytes; old worker clones it plus 24 section Arcs; old application clones current payload and 98304 returned light bytes. New worker clones zero section payload; new application clones current payload only when shared."
|
||||
);
|
||||
}
|
||||
/// Relight cost for torches placed and removed near chunk borders with
|
||||
/// slabs flickering around them (fluids next to lava, in the game).
|
||||
/// `cargo test --profile fast -p horizon-client relight_bench -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn relight_bench() {
|
||||
let mut w: FxHashMap<_, _> = world(|_| {}).into_iter().collect();
|
||||
let t = tables();
|
||||
let mut rng = 0x2545_f491_u32;
|
||||
let mut next = |n: i32| {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 17;
|
||||
rng ^= rng << 5;
|
||||
(rng % n as u32) as i32
|
||||
};
|
||||
let start = std::time::Instant::now();
|
||||
let mut changed = LightChanges::default();
|
||||
for round in 0..400 {
|
||||
let state = [TORCH, SLAB, AIR][round % 3];
|
||||
let positions: Vec<Pos> = (0..32)
|
||||
.map(|_| (next(32) - 8, next(12), next(32) - 8))
|
||||
.collect();
|
||||
for &(x, y, z) in &positions {
|
||||
Arc::make_mut(w.get_mut(&(x >> 4, z >> 4)).unwrap()).set(x & 15, y, z & 15, state);
|
||||
}
|
||||
relight(&mut w, &t, true, &positions, false, &mut changed);
|
||||
}
|
||||
println!(
|
||||
"relight_bench: {:?}, {} sections",
|
||||
start.elapsed(),
|
||||
changed.sections().count()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! `BundleItem`'s click overrides in a PICKUP click
|
||||
//! (`tryItemClickBehaviourOverride`).
|
||||
use super::{Env, Menu, tidy};
|
||||
use crate::bundle::{Contents, INSERT, INSERT_FAIL, REMOVE_ONE, can_be_in_bundle};
|
||||
use crate::bundle::{Contents, INSERT, INSERT_FAIL, REMOVE_ONE, can_be_in_bundle, toggle_selected};
|
||||
|
||||
impl Menu {
|
||||
/// The carried stack's `overrideStackedOnOther`, then the clicked
|
||||
@@ -88,8 +88,11 @@ impl Menu {
|
||||
}
|
||||
}
|
||||
(carried, _) => {
|
||||
// toggleSelectedItem(-1): Horizon keeps no selected item.
|
||||
// toggleSelectedItem(-1), then the click goes on.
|
||||
self.carried = carried;
|
||||
toggle_selected(&mut bundle, -1);
|
||||
let slot = self.slots[s];
|
||||
self.containers[slot.container][slot.index] = Some(bundle);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ pub mod nether;
|
||||
pub mod path;
|
||||
pub mod phantom;
|
||||
pub mod piglin;
|
||||
pub mod polar_bear;
|
||||
pub mod rabbit;
|
||||
pub mod raider;
|
||||
pub mod ravager;
|
||||
@@ -645,6 +646,8 @@ pub struct Attrs {
|
||||
pub struct Extra {
|
||||
/// Players: crouching and riding (HumanoidModel's poses).
|
||||
pub crouching: bool,
|
||||
/// Players: sprinting (`Entity.DATA_SHARED_FLAGS_ID`), for the dust they raise.
|
||||
pub sprinting: bool,
|
||||
pub passenger: bool,
|
||||
/// AgeableMob.age: negative while a baby (a tadpole counts up to 24000).
|
||||
pub age: i32,
|
||||
@@ -726,6 +729,12 @@ pub struct Extra {
|
||||
pub vector: [f64; 3],
|
||||
/// Active status effects: (effect id, ticks left, amplifier).
|
||||
pub effects: Vec<(&'static str, i32, i32)>,
|
||||
/// The effects above given with `hideParticles`: no swirls for them.
|
||||
pub hidden_effects: Vec<&'static str>,
|
||||
/// `LivingEntity.DATA_EFFECT_PARTICLES` and `DATA_EFFECT_AMBIENCE_ID` as a
|
||||
/// server gave them: the swirls around it, and whether they are all faint.
|
||||
pub effect_particles: Vec<crate::particles::ParticleOptions>,
|
||||
pub effect_ambient: bool,
|
||||
/// An invisible illusioner's mirror images.
|
||||
pub illusions: Option<illusioner::Illusions>,
|
||||
/// A villager's claimed bed head and meeting bell.
|
||||
@@ -737,6 +746,8 @@ pub struct Extra {
|
||||
/// the spin the rope's torque gives it.
|
||||
pub leash: Option<leash::Holder>,
|
||||
pub leash_pending: Option<leash::Pending>,
|
||||
/// The UUID of the player behind `Holder::Player` (host or guest).
|
||||
pub leash_player: Option<[i32; 4]>,
|
||||
pub leash_spin: f64,
|
||||
/// More species scratch values.
|
||||
pub scratch: [i32; 4],
|
||||
@@ -770,8 +781,9 @@ pub struct Extra {
|
||||
pub drop_chances: Option<Box<[f32; 8]>>,
|
||||
/// `Mob.leftHanded`.
|
||||
pub left_handed: bool,
|
||||
/// The sound variant: index in the species' `*_sound_variant` registry.
|
||||
pub sound_variant: u8,
|
||||
/// The sound variant: index in the species' `*_sound_variant` registry
|
||||
/// (`finalize::sound_variants`); None is the default, `classic`.
|
||||
pub sound_variant: Option<u8>,
|
||||
/// `Zombie.canBreakDoors`.
|
||||
pub can_break_doors: bool,
|
||||
/// A goat's `IsScreamingGoat` and the horns it lost (left, right).
|
||||
@@ -972,6 +984,12 @@ pub enum MobEvent {
|
||||
target: Who,
|
||||
velocity: [f64; 3],
|
||||
},
|
||||
/// `Entity.move(MoverType.SHULKER, delta)`: shift a target's position
|
||||
/// (an opening shulker lifts what stands on its shell).
|
||||
Shift {
|
||||
target: Who,
|
||||
delta: [f64; 3],
|
||||
},
|
||||
/// `EvokerWololoSpellGoal`: a blue sheep turns red.
|
||||
Wololo {
|
||||
mob: u32,
|
||||
@@ -1959,8 +1977,15 @@ impl Mob {
|
||||
}
|
||||
pub fn add_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) {
|
||||
if let Some(e) = self.extra.effects.iter_mut().find(|e| e.0 == effect) {
|
||||
e.1 = e.1.max(ticks);
|
||||
e.2 = e.2.max(amplifier);
|
||||
// MobEffectInstance.update: a stronger effect replaces the old one
|
||||
// with its own duration, an equal one keeps the longer duration,
|
||||
// and a weaker one never changes the active effect.
|
||||
if amplifier > e.2 {
|
||||
e.1 = ticks;
|
||||
e.2 = amplifier;
|
||||
} else if amplifier == e.2 {
|
||||
e.1 = e.1.max(ticks);
|
||||
}
|
||||
} else {
|
||||
self.extra.effects.push((effect, ticks, amplifier));
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ pub enum Family {
|
||||
Strider,
|
||||
Bat,
|
||||
Bee,
|
||||
PolarBear,
|
||||
}
|
||||
pub fn family(s: Species) -> Family {
|
||||
use Species as S;
|
||||
@@ -167,6 +168,7 @@ pub fn family(s: Species) -> Family {
|
||||
S::Strider => Family::Strider,
|
||||
S::Bat => Family::Bat,
|
||||
S::Bee => Family::Bee,
|
||||
S::PolarBear => Family::PolarBear,
|
||||
_ => Family::Generic,
|
||||
}
|
||||
}
|
||||
@@ -227,6 +229,7 @@ pub fn register(m: &mut Mob) {
|
||||
Family::Strider => super::strider::register(m),
|
||||
Family::Bat => super::bat::register(m),
|
||||
Family::Bee => super::bee::register(m),
|
||||
Family::PolarBear => super::polar_bear::register(m),
|
||||
}
|
||||
// FloatGoal's constructor lets the navigation float, as do the
|
||||
// constructors of these brain-driven and flying mobs. A floater rises
|
||||
@@ -416,6 +419,7 @@ pub fn tick(
|
||||
super::axolotl::tick_animation(m);
|
||||
m.anim.end_tick(m.ai.swinging);
|
||||
tick_swing(m);
|
||||
flower_contact_effects(m, w);
|
||||
if m.species() == Species::CopperGolem {
|
||||
// CopperGolem.tick: updateWeathering after the living tick.
|
||||
super::copper_golem::update_weathering(m, w, level_random, events);
|
||||
@@ -674,7 +678,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
|
||||
}
|
||||
// AbstractHorse.aiStep: the tail swishes now and then (moveTail).
|
||||
if super::equine::is_equine(m.species()) {
|
||||
let _ = m.random.next_int_bound(200);
|
||||
super::equine::tail_tick(m);
|
||||
}
|
||||
// Monster.aiStep: updateNoActionTime (Raider: always +2).
|
||||
if is_monster(m.species())
|
||||
@@ -701,6 +705,9 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
|
||||
// serverAiStep has no goals, so it only falls and is pushed.
|
||||
let immobile = (m.species() == Species::Ravager && super::ravager::immobile(m))
|
||||
|| m.species() == Species::Mannequin;
|
||||
if m.species() == Species::Piglin && m.alive() {
|
||||
super::piglin::celebrate(m, w);
|
||||
}
|
||||
if m.alive() && !immobile {
|
||||
server_ai_step(m, w, level_random, events);
|
||||
} else {
|
||||
@@ -1025,6 +1032,7 @@ fn species_ai_step(
|
||||
Species::Wither => super::wither::ai_step(m, w, events),
|
||||
Species::Allay => super::allay::ai_step(m),
|
||||
Species::Bee => super::bee::ai_step(m, w),
|
||||
Species::PolarBear => super::polar_bear::ai_step(m, w),
|
||||
Species::Breeze => super::breeze::ai_step(m),
|
||||
Species::Sniffer => super::sniffer::ai_step(m, events),
|
||||
Species::HappyGhast => super::happy_ghast::ai_step(m, w),
|
||||
@@ -1517,6 +1525,56 @@ pub fn custom_travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<Mo
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.applyEffectsFromBlocks` for the flowers that give an effect:
|
||||
/// `WitherRoseBlock.entityInside` (wither for 40 ticks on a living mob that
|
||||
/// can take it, not in peaceful) and `EyeblossomBlock.entityInside` (poison
|
||||
/// for 25 ticks on a bee, when the open flower attracts bees). The blocks
|
||||
/// are those the mob's box touches where it started the tick and where it
|
||||
/// ended.
|
||||
fn flower_contact_effects(m: &mut Mob, w: &MobWorld) {
|
||||
if !m.alive() || w.difficulty.effective == 0.0 {
|
||||
return;
|
||||
}
|
||||
let half = f64::from(m.width()) / 2.0 - 1.0e-5;
|
||||
let height = f64::from(m.height()) - 1.0e-5;
|
||||
let mut visited: Vec<[i32; 3]> = Vec::new();
|
||||
for at in [m.previous, m.position] {
|
||||
let cells = |lo: f64, hi: f64| lo.floor() as i32..=hi.floor() as i32;
|
||||
for x in cells(at.x - half, at.x + half) {
|
||||
for y in cells(at.y + 1.0e-5, at.y + height) {
|
||||
for z in cells(at.z - half, at.z + half) {
|
||||
let p = [x, y, z];
|
||||
if visited.contains(&p) {
|
||||
continue;
|
||||
}
|
||||
match w.block_name(x, y, z) {
|
||||
"minecraft:wither_rose" => {
|
||||
// isInvulnerableTo(wither): the invulnerable time
|
||||
// after a hit; WitherBoss and WitherSkeleton
|
||||
// refuse the effect (`canBeAffected`).
|
||||
if m.invulnerable <= 0
|
||||
&& !m.invulnerable_flag
|
||||
&& !matches!(m.species(), Species::Wither | Species::WitherSkeleton)
|
||||
{
|
||||
m.add_effect("minecraft:wither", 40, 0);
|
||||
}
|
||||
}
|
||||
"minecraft:open_eyeblossom"
|
||||
if m.species() == Species::Bee
|
||||
&& super::bee::attracts_bees(w, p)
|
||||
&& !m.has_effect("minecraft:poison") =>
|
||||
{
|
||||
m.add_effect("minecraft:poison", 25, 0);
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
visited.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MobEffect ticking for the effects mobs receive (poison, wither, regeneration).
|
||||
fn tick_effects(m: &mut Mob) {
|
||||
let mut effects = std::mem::take(&mut m.extra.effects);
|
||||
@@ -1541,6 +1599,9 @@ fn tick_effects(m: &mut Mob) {
|
||||
*ticks -= 1;
|
||||
}
|
||||
effects.retain(|e| e.1 > 0);
|
||||
m.extra
|
||||
.hidden_effects
|
||||
.retain(|h| effects.iter().any(|e| e.0 == *h));
|
||||
m.extra.effects = effects;
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +197,8 @@ pub fn state(m: &Mob, alpha: f32) -> State {
|
||||
let walking = m.alive() && !m.extra.passenger;
|
||||
State {
|
||||
pos: if walking {
|
||||
m.limb_position - m.limb_speed * (1.0 - alpha)
|
||||
// WalkAnimationState.position(partial): babies' legs move 3 times as far.
|
||||
(m.limb_position - m.limb_speed * (1.0 - alpha)) * if m.is_baby() { 3.0 } else { 1.0 }
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
|
||||
@@ -959,7 +959,7 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool {
|
||||
let st = &m.anim.states;
|
||||
match m.species() {
|
||||
Species::Goat => goat(p, s, &Goat::from_mob(m)),
|
||||
Species::PolarBear => polar_bear(p, s, 0.0),
|
||||
Species::PolarBear => polar_bear(p, s, super::polar_bear::standing_scale(m, s.partial)),
|
||||
Species::Panda => panda(p, s, &Panda::default()),
|
||||
Species::Llama | Species::TraderLlama => {
|
||||
llama(p, s, m.extra.chested && !s.baby);
|
||||
|
||||
@@ -6,7 +6,7 @@ use horizon_core::geometry::trig;
|
||||
/// `2.5000003E-7F` widened: the squared distance controls treat as arrived.
|
||||
const ARRIVED: f64 = 2.500_000_3e-7_f32 as f64;
|
||||
/// `1.0E-5F` widened.
|
||||
const EPSILON: f64 = 1.0e-5_f32 as f64;
|
||||
pub(super) const EPSILON: f64 = 1.0e-5_f32 as f64;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Operation {
|
||||
@@ -90,6 +90,9 @@ pub enum LookKind {
|
||||
Fixed,
|
||||
/// `HappyGhast.HappyGhastLookControl`.
|
||||
HappyGhast,
|
||||
/// `Shulker.ShulkerLookControl`: the yaw turns in the attach face's
|
||||
/// plane and the head is never clamped to the body.
|
||||
Shulker,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LookControl {
|
||||
@@ -135,6 +138,9 @@ impl Mob {
|
||||
if self.species() == super::Species::Rabbit {
|
||||
return super::rabbit::move_control(self, w);
|
||||
}
|
||||
if self.species() == super::Species::Turtle {
|
||||
return super::turtle::move_control(self);
|
||||
}
|
||||
let attr_speed = self.movement_speed();
|
||||
match self.ai.mov.kind {
|
||||
MoveKind::Custom => super::ai::custom_move_control(self, w, events),
|
||||
@@ -345,6 +351,20 @@ impl Mob {
|
||||
self.body_yaw += 4.0;
|
||||
}
|
||||
}
|
||||
LookKind::Shulker => {
|
||||
// ShulkerLookControl: resetXRotOnTick, getXRotD 0, and the
|
||||
// yaw worked out in the attach face's plane (getYRotD).
|
||||
self.head_pitch = 0.0;
|
||||
if look.cooldown > 0 {
|
||||
self.ai.look.cooldown -= 1;
|
||||
if let Some(y) = super::shulker::look_yaw(self, look.wanted) {
|
||||
self.head_yaw = rotate_towards(self.head_yaw, y, look.y_max_rot_speed);
|
||||
}
|
||||
} else {
|
||||
self.head_yaw = rotate_towards(self.head_yaw, self.body_yaw, 10.0);
|
||||
}
|
||||
// clampHeadRotationToBody does nothing for a shulker.
|
||||
}
|
||||
LookKind::Default | LookKind::Fixed => {
|
||||
if look.kind == LookKind::Default {
|
||||
self.head_pitch = 0.0;
|
||||
|
||||
Loaded 100 of 238 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user