mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
458
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf62fe270b | ||
|
|
de24a16ef4 | ||
|
|
9b53749263 | ||
|
|
cdccd03c7d | ||
|
|
27943eb6c7 | ||
|
|
28fbc7ff47 | ||
|
|
33ced209a0 | ||
|
|
8155baa6f3 | ||
|
|
423a87cf68 | ||
|
|
0e1fcaa3cc | ||
|
|
1b45824beb | ||
|
|
a5053a898c | ||
|
|
980807b0f5 | ||
|
|
f6e96e484c | ||
|
|
1cfb4def89 | ||
|
|
b8eac21b6f | ||
|
|
fad711f55f | ||
|
|
4bc514829c | ||
|
|
b8ae7233e0 | ||
|
|
5001e3f57d | ||
|
|
b124beafeb | ||
|
|
c7f448c523 | ||
|
|
c7937c5c20 | ||
|
|
ed652da572 | ||
|
|
b28797c534 | ||
|
|
42ecf0f673 | ||
|
|
cf2e9d1bc0 | ||
|
|
f8062e8fee | ||
|
|
7f760b8eb3 | ||
|
|
8baf3cad9e | ||
|
|
05f36b054c | ||
|
|
d89a8025f9 | ||
|
|
674596103f | ||
|
|
43d8bd01cd | ||
|
|
18156172a3 | ||
|
|
4c49c45a04 | ||
|
|
7bd8747909 | ||
|
|
40e66ea972 | ||
|
|
0acee0a978 | ||
|
|
324239ea84 | ||
|
|
93c4acf169 | ||
|
|
2f8c781f04 | ||
|
|
3cdd63e01d | ||
|
|
aace479a0c | ||
|
|
d54cab9565 | ||
|
|
a3c05538ca | ||
|
|
fddd6b3864 | ||
|
|
ee217acc19 | ||
|
|
18510648ea | ||
|
|
f65a4e45cf | ||
|
|
28c105fa9b | ||
|
|
2cd77f6d49 | ||
|
|
a045782e04 | ||
|
|
c5f0414a0a | ||
|
|
2c82bbee6a | ||
|
|
0529ae8250 | ||
|
|
1c876168bd | ||
|
|
48a3d8d175 | ||
|
|
f8040cb977 | ||
|
|
1af3b2ebd9 | ||
|
|
1e8396db70 | ||
|
|
6a031aa651 | ||
|
|
98d53642fa | ||
|
|
60bcab5d61 | ||
|
|
c083b9ed02 | ||
|
|
1ada2e33c8 | ||
|
|
2a6bf0fae5 | ||
|
|
5f14c125ed | ||
|
|
152afb5529 | ||
|
|
3972efa333 | ||
|
|
4c348837e4 | ||
|
|
521a761745 | ||
|
|
bf11ca8ac9 | ||
|
|
df08ffda39 | ||
|
|
7d3aeb0315 | ||
|
|
d4b7ef198c | ||
|
|
6b011e436d | ||
|
|
d98450a575 | ||
|
|
d860b9bfc0 | ||
|
|
7f4ae40cc5 | ||
|
|
7a53c8833b | ||
|
|
f8e7517393 | ||
|
|
42cbbb565e | ||
|
|
4c7ada0d60 | ||
|
|
1788bf8134 | ||
|
|
9914a8af41 | ||
|
|
9972feb710 | ||
|
|
e776eb1716 | ||
|
|
78e635fe3e | ||
|
|
5e582420ed | ||
|
|
3ff797d73d | ||
|
|
1d52fe8101 | ||
|
|
2487d29318 | ||
|
|
3b8a8eeb91 | ||
|
|
063b6ee2b7 | ||
|
|
6cf5b046fa | ||
|
|
d6ecd23a6e | ||
|
|
12a8782268 | ||
|
|
028f608816 | ||
|
|
0f8f8fce11 | ||
|
|
18b317186a | ||
|
|
708db4627e | ||
|
|
e93299fb80 | ||
|
|
e12d4c9d07 | ||
|
|
505d17a425 | ||
|
|
2d89e9ae5f | ||
|
|
14470343d1 | ||
|
|
9d9389310d | ||
|
|
8649edaf16 | ||
|
|
3a8706d0d7 | ||
|
|
0762f49f20 | ||
|
|
d980703786 | ||
|
|
efd7b94c15 | ||
|
|
9f2e4485ad | ||
|
|
639ac0e4c6 | ||
|
|
76e3a0f7b7 | ||
|
|
47e6541731 | ||
|
|
361020fc23 | ||
|
|
417ee432c5 | ||
|
|
accd2718ac | ||
|
|
854bcb158f | ||
|
|
1bd74d1b2f | ||
|
|
967a9363ce | ||
|
|
ed10d376fa | ||
|
|
8d5424408c | ||
|
|
99ce9afd63 | ||
|
|
7efd8ef33a | ||
|
|
c6633ea1bb | ||
|
|
6c0e262aa0 | ||
|
|
1e7e2ffb27 | ||
|
|
c9990e43e9 | ||
|
|
79eddec09c | ||
|
|
0ff1df58a2 | ||
|
|
58beb98327 | ||
|
|
96f84abfeb | ||
|
|
0879f3dbd2 | ||
|
|
27af1879d7 | ||
|
|
096814ade9 | ||
|
|
d177ad48e5 | ||
|
|
21123aaa3c | ||
|
|
65096c5b2e | ||
|
|
9948e9d3ee | ||
|
|
3c3884ff38 | ||
|
|
30c58b6591 | ||
|
|
fd2265dc19 | ||
|
|
84dcd156f8 | ||
|
|
5ca6bd7de6 | ||
|
|
2a6b4c0ae6 | ||
|
|
d5aa3f6e42 | ||
|
|
fbc7c628f6 | ||
|
|
c20565ee49 | ||
|
|
4e9c68e3da | ||
|
|
712144297a | ||
|
|
651ac2cfd5 | ||
|
|
2853015f0e | ||
|
|
e350b87b3c | ||
|
|
6567f95aa3 | ||
|
|
1361ae8338 | ||
|
|
d4412d160f | ||
|
|
38ddc48cab | ||
|
|
ef0d62e47e | ||
|
|
6f4b1bf4c2 | ||
|
|
691341b2b0 | ||
|
|
83979b38d6 | ||
|
|
b189f1b44c | ||
|
|
958db5f40f | ||
|
|
6b9d3309cc | ||
|
|
c5d737b536 | ||
|
|
57fe750035 | ||
|
|
3849e37393 | ||
|
|
9b091ed657 | ||
|
|
777e0e2eb5 | ||
|
|
04ecdee7a2 | ||
|
|
6ddc3226d4 | ||
|
|
1903865cd5 | ||
|
|
d5d54a8775 | ||
|
|
c27e2d2c2d | ||
|
|
d463d793a9 | ||
|
|
f10425675c | ||
|
|
aa8ab4253d | ||
|
|
fa56070097 | ||
|
|
dd5863edc2 | ||
|
|
37cabdcb02 | ||
|
|
a40ef4eee0 | ||
|
|
914a6956e9 | ||
|
|
698b49e3a5 | ||
|
|
ec067f1e39 | ||
|
|
6081a66629 | ||
|
|
ee69413a68 | ||
|
|
fc4e7af854 | ||
|
|
0e069c31d7 | ||
|
|
fbbff39e07 | ||
|
|
49c2000de3 | ||
|
|
f561d4845b | ||
|
|
d6c4e5a027 | ||
|
|
ad271604af | ||
|
|
c4a39757a1 | ||
|
|
7690ad27c2 | ||
|
|
74da2fe6b7 | ||
|
|
966c38092d | ||
|
|
6d4b5c345f | ||
|
|
8dcb4289a9 | ||
|
|
6df1dfd4da | ||
|
|
98ec18fcfe | ||
|
|
683ff6f73a | ||
|
|
a791368257 | ||
|
|
af548f5ae8 | ||
|
|
1e741d1528 | ||
|
|
8fad68ab3e | ||
|
|
32c8def114 | ||
|
|
7c1055df97 | ||
|
|
d25648f729 | ||
|
|
70dd250ae6 | ||
|
|
c6276dbbde | ||
|
|
a0b4721c5b | ||
|
|
d136f966f8 | ||
|
|
1f457ff81e | ||
|
|
9663d6f53e | ||
|
|
1bddc392b2 | ||
|
|
4a8cb8c4f9 | ||
|
|
6ef20dc9af | ||
|
|
e219a40ebc | ||
|
|
64ca4d485f | ||
|
|
66b9b3323c | ||
|
|
24d06f5ce2 | ||
|
|
19de683b66 | ||
|
|
ed8418a2ac | ||
|
|
c33a2a5d19 | ||
|
|
9b49a0b727 | ||
|
|
1b42652e96 | ||
|
|
1400da9637 | ||
|
|
62794a3fde | ||
|
|
c17da16907 | ||
|
|
9a9de6a936 | ||
|
|
fe9b20733a | ||
|
|
50f9255472 | ||
|
|
70181252d8 | ||
|
|
dc3762b533 | ||
|
|
4de85f000d | ||
|
|
4de42d0dc4 | ||
|
|
747c2031f1 | ||
|
|
14353e67d4 | ||
|
|
8027bda6bf | ||
|
|
0c5e30ae5e | ||
|
|
35689a58ba | ||
|
|
79efeab0ce | ||
|
|
be5d7ef703 | ||
|
|
ee49ec5071 | ||
|
|
e6b92995f3 | ||
|
|
3094896ad1 | ||
|
|
9f0c201851 | ||
|
|
6d55c17322 | ||
|
|
9305cc3439 | ||
|
|
8187f1ef66 | ||
|
|
05f3355c57 | ||
|
|
f90cdb04a5 | ||
|
|
78d4d7ede4 | ||
|
|
fff7116b51 | ||
|
|
ca351542ef | ||
|
|
d811847b2d | ||
|
|
0c58226e9c | ||
|
|
745a3ef110 | ||
|
|
eace14a013 | ||
|
|
5adf5c43a2 | ||
|
|
0075879d79 | ||
|
|
1727f95575 | ||
|
|
73a9345c43 | ||
|
|
0cecbef5c3 | ||
|
|
4dec228d0c | ||
|
|
9b4b2e2db3 | ||
|
|
d511139408 | ||
|
|
3ec521749b | ||
|
|
27671eac9b | ||
|
|
f9e11e7302 | ||
|
|
3093f8c594 | ||
|
|
831efabfbd | ||
|
|
cae7cc02d3 | ||
|
|
7d54dc4eaa | ||
|
|
9b98998991 | ||
|
|
d4137afb8c | ||
|
|
905fd0d193 | ||
|
|
ef54b481a7 | ||
|
|
3a5dd71152 | ||
|
|
2c783d099a | ||
|
|
e314c2fa81 | ||
|
|
3c85d2558b | ||
|
|
a05dc35c39 | ||
|
|
5d68512994 | ||
|
|
377fecbf79 | ||
|
|
a23a7ec35a | ||
|
|
50cd5576f9 | ||
|
|
8648470c47 | ||
|
|
eb664d50c1 | ||
|
|
e42ec2360c | ||
|
|
716cc7f102 | ||
|
|
c35436a7da | ||
|
|
fc06585938 | ||
|
|
f8582fb678 | ||
|
|
9777b525b7 | ||
|
|
97d119b4e8 | ||
|
|
bca4513187 | ||
|
|
28b72d46c5 | ||
|
|
cece5aca30 | ||
|
|
713de31f64 | ||
|
|
e2a48d1c36 | ||
|
|
9142f57bc8 | ||
|
|
306c6625e9 | ||
|
|
46f674f842 | ||
|
|
dca3bb89fe | ||
|
|
e74a1fe461 | ||
|
|
581828bf94 | ||
|
|
2c0d3c1a86 | ||
|
|
62556429dc | ||
|
|
eeceb9c2de | ||
|
|
2a1132387f | ||
|
|
ca83704cbb | ||
|
|
180e2f612a | ||
|
|
7b84a7ae91 | ||
|
|
ffad9e1f0a | ||
|
|
1131a2910d | ||
|
|
18217532af | ||
|
|
6d69bba15e | ||
|
|
71b2f110eb | ||
|
|
b6c77e12f3 | ||
|
|
5bbb61aec2 | ||
|
|
1f98f3ca89 | ||
|
|
0cd3410a2d | ||
|
|
4afb5748f2 | ||
|
|
6b1d376e6f | ||
|
|
008c4f91cd | ||
|
|
85f34393fe | ||
|
|
59bdb00599 | ||
|
|
29ab66935e | ||
|
|
80fd68e1c2 | ||
|
|
f92cf816b1 | ||
|
|
0c9bddecda | ||
|
|
0ee20da35c | ||
|
|
b7749dd0d2 | ||
|
|
18b544770f | ||
|
|
03cc30ae45 | ||
|
|
80e28dfe63 | ||
|
|
27f73e1db3 | ||
|
|
75bcbce441 | ||
|
|
2f489f54c5 | ||
|
|
7250944a7c | ||
|
|
ac33fb74ec | ||
|
|
5ac1c82eb9 | ||
|
|
5f73d0d02d | ||
|
|
024415e09d | ||
|
|
e9797d69d0 | ||
|
|
4b4361fbc0 | ||
|
|
b0ce58adea | ||
|
|
512e003cbd | ||
|
|
2a297a43eb | ||
|
|
7eaa5f592b | ||
|
|
be714b38bb | ||
|
|
1a5e5eb358 | ||
|
|
fba004eb3d | ||
|
|
2d70e55fbb | ||
|
|
d0df238972 | ||
|
|
d97b58fd4a | ||
|
|
e872d3ae54 | ||
|
|
fbdd04d817 | ||
|
|
aed4e26f59 | ||
|
|
b5adad15eb | ||
|
|
d17df3ca05 | ||
|
|
2d72e987c5 | ||
|
|
9ae3882c1a | ||
|
|
89c195fa6e | ||
|
|
4575f4f1df | ||
|
|
54b1c4f941 | ||
|
|
8a309c393d | ||
|
|
58a99a2ce9 | ||
|
|
23019a7bf9 | ||
|
|
78a5ee0e55 | ||
|
|
c30d51d65e | ||
|
|
c439b806b8 | ||
|
|
50fdcad10f | ||
|
|
2886b8f9ba | ||
|
|
9ca7162169 | ||
|
|
de2df51193 | ||
|
|
dc36089156 | ||
|
|
08c959807d | ||
|
|
796dc320d9 | ||
|
|
f1949c3bda | ||
|
|
4d9ead721e | ||
|
|
b8d0590d6d | ||
|
|
bc2dd35cc3 | ||
|
|
b5a735c8d1 | ||
|
|
8c0ed0e591 | ||
|
|
849b5b663b | ||
|
|
b65b0a6a7d | ||
|
|
f1181d4a25 | ||
|
|
c5a37c02de | ||
|
|
74dc389321 | ||
|
|
7d7a85d184 | ||
|
|
de1e5f7292 | ||
|
|
9bc05faf53 | ||
|
|
575cef1d26 | ||
|
|
0cc3f567fc | ||
|
|
cb97c5496e | ||
|
|
8ca15ae2ca | ||
|
|
10872bdd64 | ||
|
|
fc8e505c95 | ||
|
|
3b24aceb04 | ||
|
|
a4ccf205f7 | ||
|
|
452fca8bdd | ||
|
|
9dbd0a4baf | ||
|
|
9aabeb39ee | ||
|
|
605e8a1488 | ||
|
|
dfd0601163 | ||
|
|
034af45777 | ||
|
|
0b3e40f9d4 | ||
|
|
a44581eaf3 | ||
|
|
886a84dea0 | ||
|
|
79c3896449 | ||
|
|
6823e34451 | ||
|
|
f25fbb6f2e | ||
|
|
872409ea57 | ||
|
|
81b4c09ee6 | ||
|
|
a07c6d57dc | ||
|
|
3c94b5441e | ||
|
|
5b89508fe9 | ||
|
|
b92dd80566 | ||
|
|
4084bcdf75 | ||
|
|
5657080884 | ||
|
|
708702917c | ||
|
|
4c8ff02969 | ||
|
|
fe167781c4 | ||
|
|
0d02a4c722 | ||
|
|
9d4c984e03 | ||
|
|
e4cd56ed7b | ||
|
|
f2424043f3 | ||
|
|
45996d8d78 | ||
|
|
472da6509d | ||
|
|
b283f2ad31 | ||
|
|
6dcf4b3e8b | ||
|
|
bf508c2842 | ||
|
|
d18197a176 | ||
|
|
c987778e97 | ||
|
|
9841ce7824 | ||
|
|
83a6f5c7b9 | ||
|
|
775e5b19cb | ||
|
|
f56bf1ba38 | ||
|
|
ca119bb069 | ||
|
|
3c013dac75 | ||
|
|
e87e839c97 | ||
|
|
94592a35ac | ||
|
|
581dc08cf5 | ||
|
|
1b4fe0f067 | ||
|
|
d2105fe66c | ||
|
|
20630b1230 | ||
|
|
66637220ef | ||
|
|
27d750f1c9 | ||
|
|
d338bcd070 | ||
|
|
5d72fdd8c2 | ||
|
|
946f47b3ec | ||
|
|
408a75aeb3 |
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# Trusted reviewers: they pick the pull requests the Nightly holds with `/nightly 2640 2642` in any issue or pull
|
||||
# request (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). Ludovic is always allowed and isn't
|
||||
# listed. One GitHub login per line, with who added it and when. Removing a line revokes that login at once.
|
||||
# A line with only `paused` stops every reviewer's `/nightly` (Ludovic's still work).
|
||||
kacpherek # added by Ludovic, 2026-10-27
|
||||
rdbtCVS # added by Ludovic, 2026-10-27
|
||||
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
@@ -19,6 +19,15 @@ on:
|
||||
description: A nightly's "<date> <short sha>" (HORIZON_NIGHTLY), shown by the game; empty for a release
|
||||
type: string
|
||||
default: ''
|
||||
trusted:
|
||||
# Only release.yml says true: it builds main. A nightly can hold pull requests nobody reviewed yet, whose
|
||||
# build scripts and tests run here and could rewrite target/, the cargo home or a compiler cache. So an
|
||||
# untrusted build never runs on the release runner, never saves the Rust cache (its key is its own anyway),
|
||||
# and a trusted one never reads the machine's shared sccache, which pull requests' jobs write
|
||||
# (tests/test_nightly.py pins all of it).
|
||||
description: The source is main (a release); false for anything else (a nightly)
|
||||
type: boolean
|
||||
default: false
|
||||
developer_id:
|
||||
description: Sign the macOS programs with Developer ID and notarize the zip (the runner's keychain)
|
||||
type: boolean
|
||||
@@ -27,8 +36,10 @@ permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
exporters:
|
||||
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it.
|
||||
runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }}
|
||||
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it. An untrusted build
|
||||
# goes to the Mac mini runner that already runs pull requests' parity jobs (macOS + horizon-game:
|
||||
# ludovics-mac-mini), never the release runner (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
|
||||
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]') }}
|
||||
# Three full preparations (two isolated, one batched) besides the compile.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
@@ -39,6 +50,8 @@ jobs:
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: bash tools/ci/runner-env.sh
|
||||
env:
|
||||
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
|
||||
- run: bash tools/ci/vanilla-data.sh
|
||||
- name: Compile and validate original exporters against the pinned jars
|
||||
shell: bash
|
||||
@@ -90,7 +103,7 @@ jobs:
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
asset: horizon-macos-arm64.zip
|
||||
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
|
||||
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]')) || matrix.os }}
|
||||
# Notarizing the zip can take a few minutes.
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
env:
|
||||
@@ -117,7 +130,9 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
shared-key: release-${{ matrix.name }}
|
||||
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
|
||||
shared-key: ${{ inputs.trusted && 'release' || 'nightly' }}-${{ matrix.name }}
|
||||
save-if: ${{ inputs.trusted }}
|
||||
- name: ALSA and udev headers
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
@@ -173,6 +188,8 @@ jobs:
|
||||
done
|
||||
- name: Tools from the runner
|
||||
if: runner.environment == 'self-hosted'
|
||||
env:
|
||||
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Resources prepared from the runner's own Minecraft
|
||||
if: runner.environment == 'self-hosted'
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
name: nightly build
|
||||
# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night it builds the newest
|
||||
# union branch on origin (union/<agent>-<name>: main plus pull requests the contribution gate proved together), or
|
||||
# main when no union is newer, and publishes it as the `nightly` pre-release. Launchers install it only for players
|
||||
# who turn Nightly on. The previous nightly stays a week as nightly-<date>.
|
||||
run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.request) || '' }}
|
||||
# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night, and when a trusted
|
||||
# reviewer asks (`/nightly 2640 2642`, nightly-command.yml), it builds main plus the pull requests the reviewers
|
||||
# picked, merged in order (tools/ci/nightly_set.py). With none picked, it builds the newest union branch on origin
|
||||
# (union/<agent>-<name>: main plus pull requests the contribution gate proved together), or main when no union is
|
||||
# newer. It publishes the result as the `nightly` pre-release. Launchers install it only for players who turn Nightly
|
||||
# on. The previous nightly stays a week as nightly-<date>.
|
||||
#
|
||||
# Why the signing key never meets the union's code:
|
||||
# Why the signing key never meets the pull requests' code:
|
||||
# - `pick` merges the picked heads onto main with `git merge-tree --write-tree`: git objects only, no working tree,
|
||||
# so none of their files reaches the disk and no hook, filter or script of theirs runs. It refuses any pull
|
||||
# request that changes a path CODEOWNERS gives to Ludovic (this folder, tools/ci/, the signing and update code),
|
||||
# pushes the merge as nightly/build-<date>-<n> and saves the set on the nightly-state branch.
|
||||
# - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and
|
||||
# `uses: ./…` takes the caller's commit. The union's own .github/ is never read.
|
||||
# - `build` (game-build.yml) checks out and compiles the union with no secrets: its token reads contents only and
|
||||
# - `build` (game-build.yml, `trusted: false`) never reaches a release's tools: its macOS jobs run on the runner
|
||||
# that already runs pull requests' parity jobs, never the release runner; its Rust cache has its own key and is
|
||||
# never saved; and releases don't read the machine's shared sccache.
|
||||
# - `build` (game-build.yml) checks out and compiles the merge (or the union) with no secrets: its token reads contents only and
|
||||
# isn't kept in the checkout; the macOS programs are signed ad hoc (Developer ID needs the runner's keychain,
|
||||
# which stays locked for nightlies).
|
||||
# - `sign` is the only job with RELEASE_SIGNING_KEY. It checks out nothing but main's tools/ci/sign_release.sh, which
|
||||
@@ -31,18 +41,37 @@ on:
|
||||
description: Build and sign with a throwaway key, publish nothing
|
||||
type: boolean
|
||||
default: false
|
||||
issue:
|
||||
description: The issue or pull request whose `/nightly` comment asked for this build (answered there)
|
||||
type: string
|
||||
default: ''
|
||||
request:
|
||||
description: The command's id, in the run's name so it can link the run
|
||||
type: string
|
||||
default: ''
|
||||
prs:
|
||||
description: A dry run's own set, `2640@<head> 2642@<head>` (the saved set stays)
|
||||
type: string
|
||||
default: ''
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
# One build at a time. A newer request replaces one still waiting: it builds the set as it is by then, so no pick
|
||||
# is lost.
|
||||
group: nightly-build
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
pick:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write # nightly/build-<date>-<n> and the nightly-state branch (main is protected)
|
||||
issues: write # says in the thread when the picked pull requests conflict
|
||||
pull-requests: write
|
||||
outputs:
|
||||
build: ${{ steps.pick.outputs.build }}
|
||||
sha: ${{ steps.pick.outputs.sha }}
|
||||
short: ${{ steps.pick.outputs.short }}
|
||||
source: ${{ steps.pick.outputs.source }}
|
||||
date: ${{ steps.pick.outputs.date }}
|
||||
version: ${{ steps.pick.outputs.version }}
|
||||
@@ -60,9 +89,20 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FORCE: ${{ (inputs.force || steps.mode.outputs.dry_run == 'true') && 'yes' || '' }}
|
||||
DRY_RUN: ${{ steps.mode.outputs.dry_run }}
|
||||
NIGHTLY_ISSUE: ${{ inputs.issue }}
|
||||
NIGHTLY_PRS: ${{ inputs.prs }}
|
||||
run: |
|
||||
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Old nightly/build-* branches deleted (scheduled runs only)
|
||||
# Ludovic, 2026-10-27: older than 7 days, but never the newest build or one a published nightly points to;
|
||||
# nothing but nightly/build-* (nightly.py stale_builds, tests/test_nightly.py).
|
||||
if: github.event_name == 'schedule'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nightly-notes
|
||||
@@ -78,6 +118,7 @@ jobs:
|
||||
version: ${{ needs.pick.outputs.version }}
|
||||
nightly: ${{ needs.pick.outputs.nightly }}
|
||||
developer_id: false
|
||||
trusted: false # it can hold unreviewed pull requests: their own cache key, never saved, not the release runner
|
||||
|
||||
sign:
|
||||
needs: [pick, build]
|
||||
@@ -164,3 +205,33 @@ jobs:
|
||||
run: |
|
||||
echo "Would publish nightly at ${{ needs.pick.outputs.sha }} (${{ needs.pick.outputs.source }})"
|
||||
ls -l dist
|
||||
|
||||
answer:
|
||||
# Tells the thread that asked for this build how it ended. A conflict `pick` already explained there.
|
||||
needs: [pick, build, sign, publish]
|
||||
if: always() && inputs.issue != '' && needs.pick.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ISSUE: ${{ inputs.issue }}
|
||||
BUILD: ${{ needs.pick.outputs.build }}
|
||||
PUBLISHED: ${{ needs.publish.result }}
|
||||
DRY_RUN: ${{ needs.pick.outputs.dry_run }}
|
||||
NAME: Nightly ${{ needs.pick.outputs.date }} · ${{ needs.pick.outputs.short }}
|
||||
RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
if [[ "$BUILD" != true ]]; then
|
||||
text="nothing new: the published Nightly is already this build."
|
||||
elif [[ "$PUBLISHED" == success && "$DRY_RUN" == true ]]; then
|
||||
text="the dry run built and signed $NAME; nothing was published. $RUN"
|
||||
elif [[ "$PUBLISHED" == success ]]; then
|
||||
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers with Nightly on install it at their next start."
|
||||
else
|
||||
text="the build failed, nothing was published: $RUN"
|
||||
fi
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE/comments" -f body="🤖 Nightly: $text"
|
||||
@@ -0,0 +1,46 @@
|
||||
name: nightly command
|
||||
# `/nightly …` comments: the trusted reviewers in .github/nightly-reviewers.txt (and Ludovic) pick the pull requests
|
||||
# the Nightly holds (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). This job only checks who asked,
|
||||
# saves the set on the nightly-state branch, starts `nightly build` and answers in the thread.
|
||||
#
|
||||
# Why no pull request's code runs here, though the token can write:
|
||||
# - `issue_comment` always runs this file from main, whoever comments and wherever; a pull request's own copy of it
|
||||
# is never read.
|
||||
# - It checks out main and runs main's nightly_set.py. The comment's text and author reach it as env vars, never in
|
||||
# a shell line.
|
||||
# - It never checks out, fetches or merges a pull request: it reads their file lists through the API. The merge
|
||||
# happens in nightly-build.yml's `pick`, with git objects only; their code runs in its `build`, without secrets.
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
command:
|
||||
# Every other comment skips here, before a runner starts.
|
||||
if: startsWith(github.event.comment.body, '/nightly') && github.event.comment.user.type != 'Bot'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: write # the nightly-state branch (main is protected)
|
||||
actions: write # starts `nightly build`
|
||||
issues: write # answers in the thread
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github/CODEOWNERS
|
||||
.github/nightly-reviewers.txt
|
||||
tools/ci/nightly_set.py
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Run the command
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BODY: ${{ github.event.comment.body }}
|
||||
AUTHOR: ${{ github.event.comment.user.login }}
|
||||
ISSUE: ${{ github.event.issue.number }}
|
||||
COMMENT_URL: ${{ github.event.comment.html_url }}
|
||||
run: python3 tools/ci/nightly_set.py command
|
||||
@@ -90,6 +90,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
version: ${{ needs.decide.outputs.version }}
|
||||
trusted: true # main: the release runner, the release cache, no shared sccache
|
||||
|
||||
sign:
|
||||
needs: [decide, build]
|
||||
|
||||
@@ -35,7 +35,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~170 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
|
||||
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
|
||||
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
|
||||
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
|
||||
@@ -120,7 +121,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
|
||||
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
|
||||
|
||||
Generated
+12
@@ -1385,6 +1385,7 @@ dependencies = [
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-mods",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
@@ -1469,6 +1470,17 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-items"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-client-core",
|
||||
"horizon-core",
|
||||
"horizon-world",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-sdk"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -9,6 +9,7 @@ members = [
|
||||
"crates/horizon-client",
|
||||
"crates/horizon-client-core",
|
||||
"crates/horizon-command",
|
||||
"crates/horizon-items",
|
||||
"crates/horizon-server-files",
|
||||
"crates/horizon-server",
|
||||
"crates/horizon-bots",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
|
||||
//! value, `ComponentSerialization` turns it into styled text.
|
||||
use crate::text::{Style, Text, named_color};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// `SnbtGrammar`, enough for components: compounds, lists, quoted and
|
||||
@@ -190,19 +191,31 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
.filter(|p| *p >= 1)
|
||||
.map(crate::text::ClickEvent::ChangePage),
|
||||
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
|
||||
Some("run_command") => text("command").map(crate::text::ClickEvent::RunCommand),
|
||||
Some("copy_to_clipboard") => {
|
||||
text("value").map(crate::text::ClickEvent::CopyToClipboard)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
// HoverEvent.ShowText.
|
||||
if let Some(e) = o.get("hover_event").and_then(Value::as_object)
|
||||
&& e.get("action").and_then(Value::as_str) == Some("show_text")
|
||||
&& let Some(value) = e.get("value")
|
||||
{
|
||||
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
|
||||
// HoverEvent.ShowText and ShowItem.
|
||||
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
|
||||
match e.get("action").and_then(Value::as_str) {
|
||||
Some("show_text") => {
|
||||
if let Some(value) = e.get("value") {
|
||||
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
|
||||
}
|
||||
}
|
||||
Some("show_item") if e.get("id").is_some_and(Value::is_string) => {
|
||||
style.hover_item = Some(Box::new(Value::Object(e.clone())));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
style.insertion = o
|
||||
.get("insertion")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
style
|
||||
}
|
||||
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
|
||||
@@ -287,6 +300,15 @@ pub fn to_text_with(
|
||||
}
|
||||
}
|
||||
|
||||
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
|
||||
/// else is a component (`ComponentSerialization`).
|
||||
pub fn name_text(tag: &Tag) -> Text {
|
||||
match tag {
|
||||
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
|
||||
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod identifier;
|
||||
pub mod key_display;
|
||||
pub mod lang;
|
||||
pub mod nbt_path;
|
||||
pub mod nbt_value;
|
||||
pub mod particle_type;
|
||||
pub mod pattern;
|
||||
pub mod potions;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Reading and writing single NBT values the way vanilla's codecs accept them.
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
pub fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
pub fn text(tag: Option<&Tag>) -> Option<String> {
|
||||
match tag {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Any numeric tag as i64 (vanilla's `getIntOr` etc. accept every number type).
|
||||
pub fn number(tag: Option<&Tag>) -> Option<i64> {
|
||||
Some(match tag? {
|
||||
Tag::Byte(v) => i64::from(*v),
|
||||
Tag::Short(v) => i64::from(*v),
|
||||
Tag::Int(v) => i64::from(*v),
|
||||
Tag::Long(v) => *v,
|
||||
Tag::Float(b) => f32::from_bits(*b) as i64,
|
||||
Tag::Double(b) => f64::from_bits(*b) as i64,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -47,6 +47,8 @@ pub enum ClickEvent {
|
||||
/// `ClickEvent.ChangePage`: a book's page, counted from 1.
|
||||
ChangePage(i32),
|
||||
SuggestCommand(String),
|
||||
/// `ClickEvent.RunCommand`: the command, with or without its `/`.
|
||||
RunCommand(String),
|
||||
CopyToClipboard(String),
|
||||
/// A local file to open with the system handler.
|
||||
/// Only allow this in locally constructed messages, never server/LAN text.
|
||||
@@ -65,6 +67,10 @@ pub struct Style {
|
||||
pub click: Option<ClickEvent>,
|
||||
/// Hover text.
|
||||
pub hover: Option<Box<Text>>,
|
||||
/// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object.
|
||||
pub hover_item: Option<Box<Value>>,
|
||||
/// `Style.insertion`: the text a Shift-click puts in the chat box.
|
||||
pub insertion: Option<String>,
|
||||
/// The font (`FontDescription.Resource`), a full id like
|
||||
/// `minecraft:alt`; the default font when unset.
|
||||
pub font: Option<String>,
|
||||
@@ -94,6 +100,12 @@ impl Style {
|
||||
if self.hover.is_none() {
|
||||
self.hover.clone_from(&base.hover);
|
||||
}
|
||||
if self.hover_item.is_none() {
|
||||
self.hover_item.clone_from(&base.hover_item);
|
||||
}
|
||||
if self.insertion.is_none() {
|
||||
self.insertion.clone_from(&base.insertion);
|
||||
}
|
||||
if self.font.is_none() {
|
||||
self.font.clone_from(&base.font);
|
||||
}
|
||||
@@ -499,7 +511,7 @@ impl Text {
|
||||
}
|
||||
|
||||
/// The style fields of a component's JSON.
|
||||
const STYLE_KEYS: [&str; 9] = [
|
||||
const STYLE_KEYS: [&str; 10] = [
|
||||
"color",
|
||||
"bold",
|
||||
"italic",
|
||||
@@ -508,6 +520,7 @@ const STYLE_KEYS: [&str; 9] = [
|
||||
"obfuscated",
|
||||
"click_event",
|
||||
"hover_event",
|
||||
"insertion",
|
||||
"font",
|
||||
];
|
||||
/// Style.Serializer: the fields a style sets.
|
||||
@@ -541,6 +554,9 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
ClickEvent::CopyToClipboard(value) => {
|
||||
json!({ "action": "copy_to_clipboard", "value": value })
|
||||
}
|
||||
ClickEvent::RunCommand(command) => {
|
||||
json!({ "action": "run_command", "command": command })
|
||||
}
|
||||
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
|
||||
};
|
||||
out.push(("click_event".to_owned(), event));
|
||||
@@ -550,6 +566,13 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
"hover_event".to_owned(),
|
||||
json!({ "action": "show_text", "value": hover.component() }),
|
||||
));
|
||||
} else if let Some(Value::Object(item)) = style.hover_item.as_deref() {
|
||||
let mut event = item.clone();
|
||||
event.insert("action".into(), "show_item".into());
|
||||
out.push(("hover_event".to_owned(), Value::Object(event)));
|
||||
}
|
||||
if let Some(insertion) = &style.insertion {
|
||||
out.push(("insertion".to_owned(), insertion.clone().into()));
|
||||
}
|
||||
if let Some(font) = &style.font {
|
||||
out.push(("font".to_owned(), font.clone().into()));
|
||||
|
||||
@@ -11,6 +11,7 @@ description = "Horizon native development client"
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
@@ -105,5 +106,6 @@ path = "examples/probe/main.rs"
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
|
||||
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
|
||||
#[allow(dead_code)]
|
||||
#[path = "../src/blocks/decode.rs"]
|
||||
#[path = "../../horizon-items/src/blocks/decode.rs"]
|
||||
mod decode;
|
||||
|
||||
use std::{hint::black_box, path::Path, time::Instant};
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn main() -> Result<()> {
|
||||
match c["axis"].as_i64() {
|
||||
Some(axis @ 0..=2) => {
|
||||
let line = record("electric_spark");
|
||||
particles::along_axis(&mut random, axis as usize, p, |p, v| {
|
||||
particles::along_axis(&mut random, axis as usize, p, (10, 19), |p, v| {
|
||||
lines.push(line(p, v))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ fn stack(spec: &str) -> LootStack {
|
||||
exploration: None,
|
||||
ominous_amplifier: None,
|
||||
stew_effects: Vec::new(),
|
||||
names: Vec::new(),
|
||||
};
|
||||
for e in parts {
|
||||
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
|
||||
|
||||
@@ -223,6 +223,7 @@ fn decorations(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
display_health: int("displayHealth", 0),
|
||||
last_health_time: millis - i64::from(int("lastHealthAgo", 0)),
|
||||
health_blink_time: i64::from(int("healthBlinkTime", 0)),
|
||||
..HealthHud::default()
|
||||
};
|
||||
let kind = if has("minecraft:poison") {
|
||||
HeartKind::Poisoned
|
||||
|
||||
@@ -101,7 +101,7 @@ fn title(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
}
|
||||
let partial = float(c, "partial", 0.0);
|
||||
let bg = background(c);
|
||||
let mut out = t.overlay_message(w, h, partial, &atlas.font, bg);
|
||||
let mut out = t.overlay_message(w, h, 39, partial, &atlas.font, bg);
|
||||
out.extend(t.title(w, h, partial, &atlas.font, bg));
|
||||
out
|
||||
}
|
||||
@@ -129,7 +129,7 @@ fn selected(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
}
|
||||
let mode = c["gameMode"].as_str().unwrap_or("survival");
|
||||
let can_hurt = mode == "survival" || mode == "adventure";
|
||||
highlight.draw(w, h, can_hurt, &atlas.font, background(c))
|
||||
highlight.draw(w, h, 39, can_hurt, &atlas.font, background(c))
|
||||
}
|
||||
|
||||
fn boss(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
|
||||
@@ -74,7 +74,14 @@ pub fn main() -> Result<()> {
|
||||
biomes: &biomes,
|
||||
registry: assets.registry,
|
||||
raining_at: &|_| false,
|
||||
height_no_leaves: &|_| 0,
|
||||
firefly_bush_sounds: false,
|
||||
sky_darken: 0,
|
||||
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
|
||||
model_offset: &|_, _, _| None,
|
||||
gateway_particles: &|_, _| 0,
|
||||
thundering: false,
|
||||
ultrawarm: false,
|
||||
};
|
||||
engine.seed_randoms(1057, 0, 0);
|
||||
engine.requested = Some(Vec::new());
|
||||
|
||||
@@ -567,7 +567,13 @@ fn print_heart(
|
||||
/// The damage of a mob's direct hit on the player (others count as none).
|
||||
fn player_damage(e: &MobEvent) -> f32 {
|
||||
match e {
|
||||
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount,
|
||||
MobEvent::AttackPlayer(amount, _)
|
||||
| MobEvent::AttackPlayerWithEffect { amount, .. }
|
||||
| MobEvent::DamagePlayer { amount, .. } => *amount,
|
||||
MobEvent::SonicBoom {
|
||||
target: Who::Player,
|
||||
..
|
||||
} => 10.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
@@ -578,7 +584,13 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
|
||||
return false;
|
||||
};
|
||||
match e {
|
||||
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
|
||||
MobEvent::AttackPlayer(amount, _)
|
||||
| MobEvent::AttackPlayerWithEffect { amount, .. }
|
||||
| MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
|
||||
MobEvent::SonicBoom {
|
||||
target: Who::Player,
|
||||
..
|
||||
} => true,
|
||||
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
|
||||
MobEvent::Explode(at, radius, _) => {
|
||||
let d = Vec3::new(at[0], at[1], at[2])
|
||||
@@ -1045,6 +1057,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
alive: true,
|
||||
creative: s.creative,
|
||||
sneaking: false,
|
||||
swimming: false,
|
||||
look: [0.0, 0.0, 1.0],
|
||||
main_hand: s.hand.as_deref().map(Into::into),
|
||||
off_hand: None,
|
||||
@@ -1064,6 +1077,8 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
uuid: PLAYER_UUID,
|
||||
allies: Default::default(),
|
||||
remote: false,
|
||||
sleeping: false,
|
||||
sleep_timer: 0,
|
||||
passenger: false,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -208,7 +208,7 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
|
||||
)),
|
||||
MotionEvent::Emission {
|
||||
state,
|
||||
in_water: true,
|
||||
muffled: true,
|
||||
..
|
||||
} => Some(format!("muffled-step {}", state.0)),
|
||||
MotionEvent::BubbleColumnParticle { bubble, position } => Some(format!(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! conditions advancements use).
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
@@ -753,6 +753,41 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `KilledByArrowTrigger.TriggerInstance.matches`: the weapon, each listed
|
||||
/// victim condition matched by a distinct victim, and the count of entity types.
|
||||
pub fn killed_by_arrow(
|
||||
c: &Value,
|
||||
me: &EntityView,
|
||||
victims: &[EntityView],
|
||||
weapon: Option<&ItemView>,
|
||||
w: &dyn World,
|
||||
) -> bool {
|
||||
fn ctx<'a>(e: &'a EntityView, me: &EntityView) -> Entities<'a> {
|
||||
Entities {
|
||||
this: Some(e),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
}
|
||||
}
|
||||
if !conditions(&c["player"], &ctx(me, me), w) {
|
||||
return false;
|
||||
}
|
||||
if !c["fired_from_weapon"].is_null()
|
||||
&& !weapon.is_some_and(|it| item(&c["fired_from_weapon"], it, w))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut left: Vec<&EntityView> = victims.iter().collect();
|
||||
for want in c["victims"].as_array().into_iter().flatten() {
|
||||
let Some(i) = left.iter().position(|e| conditions(want, &ctx(e, me), w)) else {
|
||||
return false;
|
||||
};
|
||||
left.remove(i);
|
||||
}
|
||||
let kinds: BTreeSet<&str> = victims.iter().map(|e| e.kind.as_str()).collect();
|
||||
range_i(&c["unique_entity_types"], kinds.len() as i64)
|
||||
}
|
||||
/// A damage event as `DamagePredicate` sees it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DamageView {
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::{
|
||||
blocks::{BlockFacts, Fluid},
|
||||
command::args::GameMode,
|
||||
items::ItemStack,
|
||||
particles::{ParticleEngine, ParticleOptions, ParticleType as T},
|
||||
particles::{ParticleEngine, ParticleOptions, ParticleType as T, along_axis},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -75,8 +75,32 @@ enum Animate {
|
||||
waterlogged: bool,
|
||||
},
|
||||
EndPortal,
|
||||
/// A powered lever: its facing and the direction it is attached toward.
|
||||
Lever {
|
||||
facing: [i32; 3],
|
||||
connected: [i32; 3],
|
||||
},
|
||||
/// A powered repeater.
|
||||
Repeater {
|
||||
facing: [i32; 3],
|
||||
delay: i32,
|
||||
},
|
||||
/// A free-hanging stalactite tip that is not waterlogged.
|
||||
PointedDripstone,
|
||||
WitherRose,
|
||||
EndGateway,
|
||||
/// A lightning rod, by the axis it points along (0, 1, 2 for X, Y, Z).
|
||||
LightningRod(usize),
|
||||
/// `WetSpongeBlock.animateTick`.
|
||||
WetSponge,
|
||||
FireflyBush,
|
||||
/// An open eyeblossom (`Type.OPEN.emitSounds()`).
|
||||
OpenEyeblossom,
|
||||
PaleHangingMoss,
|
||||
/// `DryVegetationBlock`: a dead bush.
|
||||
DeadBush,
|
||||
/// `ShortDryGrassBlock` and `TallDryGrassBlock`.
|
||||
DryGrass,
|
||||
}
|
||||
fn step(facing: &str) -> [i32; 3] {
|
||||
match facing {
|
||||
@@ -91,6 +115,7 @@ fn step(facing: &str) -> [i32; 3] {
|
||||
fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
let lit = prop("lit").as_deref() == Some("true");
|
||||
let powered = prop("powered").as_deref() == Some("true");
|
||||
let facing = || step(&prop("facing").unwrap_or_default());
|
||||
match name {
|
||||
"torch" => Animate::Torch(T::Flame),
|
||||
@@ -172,10 +197,47 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
waterlogged: prop("waterlogged").as_deref() == Some("true"),
|
||||
},
|
||||
"end_portal" => Animate::EndPortal,
|
||||
"lever" if powered => Animate::Lever {
|
||||
facing: facing(),
|
||||
// FaceAttachedHorizontalDirectionalBlock.getConnectedDirection.
|
||||
connected: match prop("face").as_deref() {
|
||||
Some("ceiling") => [0, -1, 0],
|
||||
Some("floor") => [0, 1, 0],
|
||||
_ => facing(),
|
||||
},
|
||||
},
|
||||
"repeater" if powered => Animate::Repeater {
|
||||
facing: facing(),
|
||||
delay: prop("delay").and_then(|d| d.parse().ok()).unwrap_or(1),
|
||||
},
|
||||
"pointed_dripstone"
|
||||
if prop("vertical_direction").as_deref() == Some("down")
|
||||
&& prop("thickness").as_deref() == Some("tip")
|
||||
&& prop("waterlogged").as_deref() != Some("true") =>
|
||||
{
|
||||
Animate::PointedDripstone
|
||||
}
|
||||
"wither_rose" => Animate::WitherRose,
|
||||
"end_gateway" => Animate::EndGateway,
|
||||
_ if name == "lightning_rod" || name.ends_with("_lightning_rod") => {
|
||||
let [dx, dy, _] = facing();
|
||||
Animate::LightningRod(if dx != 0 {
|
||||
0
|
||||
} else if dy != 0 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
})
|
||||
}
|
||||
"wet_sponge" => Animate::WetSponge,
|
||||
// BrushableBlock has no dust colour of its own, so the dust is the map colour.
|
||||
"suspicious_sand" => Animate::Falling(0xF7E9A3, false),
|
||||
"suspicious_gravel" => Animate::Falling(0x707070, false),
|
||||
"firefly_bush" => Animate::FireflyBush,
|
||||
"open_eyeblossom" => Animate::OpenEyeblossom,
|
||||
"pale_hanging_moss" => Animate::PaleHangingMoss,
|
||||
"dead_bush" => Animate::DeadBush,
|
||||
"short_dry_grass" | "tall_dry_grass" => Animate::DryGrass,
|
||||
"sculk_sensor" | "calibrated_sculk_sensor"
|
||||
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
|
||||
{
|
||||
@@ -246,10 +308,16 @@ pub struct AnimateTables {
|
||||
desert_sand_sounds: Vec<bool>,
|
||||
/// `#required_for_poplar_leaf_ambience` (overworld natural logs).
|
||||
poplar_leaf_logs: Vec<bool>,
|
||||
/// `#triggers_ambient_desert_dry_vegetation_block_sounds`.
|
||||
desert_dry_vegetation_sounds: Vec<bool>,
|
||||
terracotta: Vec<bool>,
|
||||
pale_oak_logs: Vec<bool>,
|
||||
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
|
||||
burnable: Vec<bool>,
|
||||
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
|
||||
ambient: Vec<Vec<(T, f32)>>,
|
||||
/// Per state: 1 for a pointed dripstone, 2 for one whose tip points down.
|
||||
dripstone: Vec<u8>,
|
||||
}
|
||||
impl AnimateTables {
|
||||
pub fn load(root: &Path, registry: &BlockRegistry, biomes: &Biomes) -> crate::Result<Self> {
|
||||
@@ -315,14 +383,36 @@ impl AnimateTables {
|
||||
}
|
||||
}
|
||||
}
|
||||
let dripstone = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
registry.state(BlockStateId(id)).map_or(0, |s| {
|
||||
let name = s.name.strip_prefix("minecraft:").unwrap_or(&s.name);
|
||||
if name != "pointed_dripstone" {
|
||||
0
|
||||
} else if s.properties.get("vertical_direction").map(String::as_str)
|
||||
== Some("down")
|
||||
{
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Self {
|
||||
kinds,
|
||||
dripstone,
|
||||
impermeable: by_state("minecraft:impermeable"),
|
||||
power_provider: by_state("minecraft:enchantment_power_provider"),
|
||||
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
|
||||
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
|
||||
desert_sand_sounds: by_state("minecraft:triggers_ambient_desert_sand_block_sounds"),
|
||||
poplar_leaf_logs: by_state("minecraft:required_for_poplar_leaf_ambience"),
|
||||
desert_dry_vegetation_sounds: by_state(
|
||||
"minecraft:triggers_ambient_desert_dry_vegetation_block_sounds",
|
||||
),
|
||||
terracotta: by_state("minecraft:terracotta"),
|
||||
pale_oak_logs: by_state("minecraft:pale_oak_logs"),
|
||||
burnable,
|
||||
ambient,
|
||||
})
|
||||
@@ -348,8 +438,22 @@ pub struct AnimateContext<'a> {
|
||||
pub registry: &'a BlockRegistry,
|
||||
/// `Level.isRainingAt`.
|
||||
pub raining_at: &'a dyn Fn([i32; 3]) -> bool,
|
||||
/// `getHeight(MOTION_BLOCKING_NO_LEAVES, x, z)` at a position's column.
|
||||
pub height_no_leaves: &'a dyn Fn([i32; 3]) -> i32,
|
||||
/// `EnvironmentAttributes.FIREFLY_BUSH_SOUNDS` (overworld day timeline).
|
||||
pub firefly_bush_sounds: bool,
|
||||
/// `Level.getSkyDarken`, for `getMaxLocalRawBrightness`.
|
||||
pub sky_darken: i32,
|
||||
/// Creative main-hand marker block and a resolver for each placed state's sprite.
|
||||
pub marker: Option<(&'a str, &'a dyn Fn(BlockStateId) -> Option<ParticleOptions>)>,
|
||||
/// `BlockState.getOffset` of a state at (x, z), for blocks with a random model offset.
|
||||
pub model_offset: &'a dyn Fn(BlockStateId, i32, i32) -> Option<[f64; 3]>,
|
||||
/// `TheEndGatewayBlockEntity.getParticleAmount` of the gateway state at a position.
|
||||
pub gateway_particles: &'a dyn Fn(BlockStateId, [i32; 3]) -> usize,
|
||||
/// `Level.isThundering`.
|
||||
pub thundering: bool,
|
||||
/// The dimension has `water_evaporates` and drips lava from dripstone (the Nether).
|
||||
pub ultrawarm: bool,
|
||||
}
|
||||
|
||||
/// `ClientLevel.getMarkerParticleTarget`: only the selected main-hand item counts.
|
||||
@@ -398,6 +502,15 @@ impl AnimateContext<'_> {
|
||||
],
|
||||
))
|
||||
}
|
||||
/// `Level.getMaxLocalRawBrightness`: the brighter of block light and
|
||||
/// sky light less the sky darkening.
|
||||
fn max_local_raw_brightness(&self, p: [i32; 3]) -> i32 {
|
||||
let light = self.world.light(p[0], p[1], p[2]);
|
||||
i32::from(light & 15).max(i32::from(light >> 4) - self.sky_darken)
|
||||
}
|
||||
fn offset_of(&self, state: BlockStateId, p: [i32; 3]) -> [f64; 3] {
|
||||
(self.model_offset)(state, p[0], p[2]).unwrap_or([0.0; 3])
|
||||
}
|
||||
fn foliage(&self, p: [i32; 3]) -> u32 {
|
||||
let biome = self
|
||||
.world
|
||||
@@ -453,7 +566,7 @@ fn do_animate_tick(
|
||||
return;
|
||||
}
|
||||
let state = ctx.state(pos);
|
||||
block_animate_tick(tables, engine, ctx, state, pos);
|
||||
block_animate_tick(tables, engine, ctx, state, pos, center);
|
||||
let facts = ctx.facts.get(state);
|
||||
if facts.fluid != Fluid::None {
|
||||
fluid_animate_tick(
|
||||
@@ -609,6 +722,22 @@ fn beehive_drip(
|
||||
];
|
||||
add(engine, T::DrippingHoney, p, [0.0; 3]);
|
||||
}
|
||||
/// `AmbientDesertBlockSoundsPlayer.shouldPlayDesertDryVegetationBlockSounds`:
|
||||
/// sand or terracotta two blocks deep.
|
||||
fn desert_dry_vegetation_sounds(
|
||||
tables: &AnimateTables,
|
||||
ctx: &AnimateContext,
|
||||
below: [i32; 3],
|
||||
) -> bool {
|
||||
let triggers = |p: [i32; 3]| {
|
||||
tables
|
||||
.desert_dry_vegetation_sounds
|
||||
.get(ctx.state(p).0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
};
|
||||
triggers(below) && triggers([below[0], below[1] - 1, below[2]])
|
||||
}
|
||||
/// `WaterFluid.animateTick` / `LavaFluid.animateTick`.
|
||||
fn fluid_animate_tick(
|
||||
engine: &mut ParticleEngine,
|
||||
@@ -684,6 +813,7 @@ fn block_animate_tick(
|
||||
ctx: &AnimateContext,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
player: [i32; 3],
|
||||
) {
|
||||
let (fx, fy, fz) = (f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
|
||||
match tables.kind(state) {
|
||||
@@ -1073,12 +1203,198 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::FireflyBush => {
|
||||
// FireflyBushBlock.animateTick: the idle sound on a quiet night
|
||||
// under open sky, and fireflies in the dark within 5 blocks.
|
||||
if next_int(engine.random(), 30) == 0
|
||||
&& ctx.firefly_bush_sounds
|
||||
&& (ctx.height_no_leaves)(pos) <= pos[1]
|
||||
{
|
||||
let name = "minecraft:block.firefly_bush.idle";
|
||||
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
|
||||
}
|
||||
if ctx.max_local_raw_brightness(pos) <= 13 {
|
||||
let r = engine.random();
|
||||
if r.next_double() <= 0.7 {
|
||||
let p = [
|
||||
fx + r.next_double() * 10.0 - 5.0,
|
||||
fy + r.next_double() * 5.0,
|
||||
fz + r.next_double() * 10.0 - 5.0,
|
||||
];
|
||||
add(engine, T::Firefly, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::OpenEyeblossom => {
|
||||
// EyeblossomBlock.animateTick: pale moss below.
|
||||
if next_int(engine.random(), 700) == 0 {
|
||||
let below = ctx.state([pos[0], pos[1] - 1, pos[2]]);
|
||||
if ctx
|
||||
.registry
|
||||
.state(below)
|
||||
.is_some_and(|s| s.name == "minecraft:pale_moss_block")
|
||||
{
|
||||
let name = "minecraft:block.eyeblossom.idle";
|
||||
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::PaleHangingMoss => {
|
||||
// HangingMossBlock.animateTick: under pale oak logs or leaves.
|
||||
if next_int(engine.random(), 500) == 0 {
|
||||
let above = ctx.state([pos[0], pos[1] + 1, pos[2]]);
|
||||
let pale_leaves = ctx
|
||||
.registry
|
||||
.state(above)
|
||||
.is_some_and(|s| s.name == "minecraft:pale_oak_leaves");
|
||||
if pale_leaves
|
||||
|| tables
|
||||
.pale_oak_logs
|
||||
.get(above.0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let name = "minecraft:block.pale_hanging_moss.idle";
|
||||
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::DeadBush => {
|
||||
// AmbientDesertBlockSoundsPlayer.playAmbientDeadBushSounds.
|
||||
if next_int(engine.random(), 130) == 0 {
|
||||
let below = [pos[0], pos[1] - 1, pos[2]];
|
||||
let state = ctx.state(below);
|
||||
let red_sand = ctx
|
||||
.registry
|
||||
.state(state)
|
||||
.is_some_and(|s| s.name == "minecraft:red_sand");
|
||||
let terracotta = tables
|
||||
.terracotta
|
||||
.get(state.0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
if (red_sand || terracotta) && next_int(engine.random(), 3) != 0 {
|
||||
return;
|
||||
}
|
||||
if desert_dry_vegetation_sounds(tables, ctx, below) {
|
||||
let name = "minecraft:block.deadbush.idle";
|
||||
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::DryGrass => {
|
||||
// AmbientDesertBlockSoundsPlayer.playAmbientDryGrassSounds:
|
||||
// played at the player.
|
||||
if next_int(engine.random(), 200) == 0
|
||||
&& desert_dry_vegetation_sounds(tables, ctx, [pos[0], pos[1] - 1, pos[2]])
|
||||
{
|
||||
let at = [
|
||||
f64::from(player[0]) + 0.5,
|
||||
f64::from(player[1]),
|
||||
f64::from(player[2]) + 0.5,
|
||||
];
|
||||
sound(engine, "minecraft:block.dry_grass.ambient", at, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
Animate::EndPortal => {
|
||||
// EndPortalBlock.animateTick: one smoke particle above the surface.
|
||||
let r = engine.random();
|
||||
let p = [fx + r.next_double(), fy + 0.8, fz + r.next_double()];
|
||||
add(engine, T::Smoke, p, [0.0; 3]);
|
||||
}
|
||||
Animate::Lever { facing, connected } => {
|
||||
// LeverBlock.animateTick: a powered lever puffs red dust a quarter of the time.
|
||||
if engine.random().next_float() < 0.25 {
|
||||
// LeverBlock.makeParticle with scale 0.5.
|
||||
let at = |base: f64, i: usize| {
|
||||
base + 0.5 + 0.1 * f64::from(-facing[i]) + 0.2 * f64::from(-connected[i])
|
||||
};
|
||||
let p = [at(fx, 0), at(fy, 1), at(fz, 2)];
|
||||
engine.add(&ParticleOptions::dust([1.0, 0.0, 0.0], 0.5), p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
Animate::Repeater { facing, delay } => {
|
||||
// RepeaterBlock.animateTick: dust at one of the torches.
|
||||
let r = engine.random();
|
||||
let x = fx + 0.5 + (r.next_double() - 0.5) * 0.2;
|
||||
let y = fy + 0.4 + (r.next_double() - 0.5) * 0.2;
|
||||
let z = fz + 0.5 + (r.next_double() - 0.5) * 0.2;
|
||||
let mut offset = -5.0f32;
|
||||
if r.next_boolean() {
|
||||
offset = (delay * 2 - 1) as f32;
|
||||
}
|
||||
offset /= 16.0;
|
||||
let xo = f64::from(offset * facing[0] as f32);
|
||||
let zo = f64::from(offset * facing[2] as f32);
|
||||
redstone(engine, [x + xo, y, z + zo]);
|
||||
}
|
||||
Animate::PointedDripstone => dripstone_drip(tables, engine, ctx, state, pos),
|
||||
Animate::WitherRose => {
|
||||
// WitherRoseBlock.animateTick: smoke puffs at the centre of the shape.
|
||||
let center = ctx.shape_y(pos).map_or([0.5; 2], |s| {
|
||||
[(s.2[0] + s.2[1]) / 2.0, (s.2[2] + s.2[3]) / 2.0]
|
||||
});
|
||||
let offset = ctx.offset_of(state, pos);
|
||||
let x = fx + center[0] + offset[0];
|
||||
let z = fz + center[1] + offset[2];
|
||||
for _ in 0..3 {
|
||||
let r = engine.random();
|
||||
if r.next_boolean() {
|
||||
let p = [
|
||||
x + r.next_double() / 5.0,
|
||||
fy + (0.5 - r.next_double()),
|
||||
z + r.next_double() / 5.0,
|
||||
];
|
||||
add(engine, T::Smoke, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::EndGateway => {
|
||||
// EndGatewayBlock.animateTick: portal particles, one for each visible face.
|
||||
for _ in 0..(ctx.gateway_particles)(state, pos) {
|
||||
let r = engine.random();
|
||||
let mut p = [
|
||||
fx + r.next_double(),
|
||||
fy + r.next_double(),
|
||||
fz + r.next_double(),
|
||||
];
|
||||
let mut v = [
|
||||
(r.next_double() - 0.5) * 0.5,
|
||||
(r.next_double() - 0.5) * 0.5,
|
||||
(r.next_double() - 0.5) * 0.5,
|
||||
];
|
||||
let flip = next_int(r, 2) * 2 - 1;
|
||||
if r.next_boolean() {
|
||||
p[2] = fz + 0.5 + 0.25 * f64::from(flip);
|
||||
v[2] = f64::from(r.next_float() * 2.0 * flip as f32);
|
||||
} else {
|
||||
p[0] = fx + 0.5 + 0.25 * f64::from(flip);
|
||||
v[0] = f64::from(r.next_float() * 2.0 * flip as f32);
|
||||
}
|
||||
add(engine, T::Portal, p, v);
|
||||
}
|
||||
}
|
||||
Animate::LightningRod(axis) => {
|
||||
// LightningRodBlock.animateTick: in a thunderstorm, sparks along the
|
||||
// topmost rod, more often as the game time within 200 ticks grows.
|
||||
if ctx.thundering
|
||||
&& i64::from(next_int(engine.level_random(), 200)) <= ctx.world.game_time % 200
|
||||
&& pos[1]
|
||||
== ctx
|
||||
.world
|
||||
.world_surface(pos[0], pos[2], |id| ctx.facts.is_air(id))
|
||||
- 1
|
||||
{
|
||||
let mut sparks = Vec::new();
|
||||
along_axis(engine.level_random(), axis, pos, (1, 2), |p, v| {
|
||||
sparks.push((p, v));
|
||||
});
|
||||
let options = ParticleOptions::simple(T::ElectricSpark);
|
||||
for (p, v) in sparks {
|
||||
engine.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::EnderChest => {
|
||||
for _ in 0..3 {
|
||||
let r = engine.random();
|
||||
@@ -1382,6 +1698,68 @@ fn leaves_ambient_sound(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PointedDripstoneBlock.animateTick`: a free-hanging stalactite tip drips the
|
||||
/// fluid above the root of its stalactite.
|
||||
fn dripstone_drip(
|
||||
tables: &AnimateTables,
|
||||
engine: &mut ParticleEngine,
|
||||
ctx: &AnimateContext,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
) {
|
||||
let roll = engine.random().next_float();
|
||||
if roll > 0.12 {
|
||||
return;
|
||||
}
|
||||
// getFluidAboveStalactite: findRootBlock climbs at most ten blocks of
|
||||
// stalactite and stops at the first block that is not pointed dripstone.
|
||||
let mut root = None;
|
||||
for step in 1..11 {
|
||||
let p = [pos[0], pos[1] + step, pos[2]];
|
||||
match tables
|
||||
.dripstone
|
||||
.get(ctx.state(p).0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
{
|
||||
0 => {
|
||||
root = Some(p);
|
||||
break;
|
||||
}
|
||||
2 => {}
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
let Some(root) = root else {
|
||||
return;
|
||||
};
|
||||
let above = ctx.facts.get(ctx.state([root[0], root[1] + 1, root[2]]));
|
||||
// Mud above the root counts as a water source unless water evaporates here.
|
||||
let (fluid, source) = if &*above.name == "minecraft:mud" && !ctx.ultrawarm {
|
||||
(Fluid::Water, true)
|
||||
} else {
|
||||
(above.fluid, above.fluid_level >= 8 && !above.fluid_falling)
|
||||
};
|
||||
// canFillCauldron: only a water or lava source passes below a 2% roll.
|
||||
if roll >= 0.02 && !(fluid != Fluid::None && source) {
|
||||
return;
|
||||
}
|
||||
// getDripParticle: no fluid drips what the dimension's default says.
|
||||
let lava = fluid == Fluid::Lava || (fluid == Fluid::None && ctx.ultrawarm);
|
||||
let ty = if lava {
|
||||
T::DrippingDripstoneLava
|
||||
} else {
|
||||
T::DrippingDripstoneWater
|
||||
};
|
||||
let offset = ctx.offset_of(state, pos);
|
||||
// STALACTITE_DRIP_START_PIXEL is the tip's lowest point, 5/16.
|
||||
let p = [
|
||||
f64::from(pos[0]) + 0.5 + offset[0],
|
||||
f64::from(pos[1]) + 0.3125 - 0.0625,
|
||||
f64::from(pos[2]) + 0.5 + offset[2],
|
||||
];
|
||||
add(engine, ty, p, [0.0; 3]);
|
||||
}
|
||||
/// `AmbientDesertBlockSoundsPlayer.shouldPlayAmbientSandSound`: sand in the
|
||||
/// surface column eight blocks away on all four sides.
|
||||
fn sand_sound_nearby(tables: &AnimateTables, ctx: &AnimateContext, pos: [i32; 3]) -> bool {
|
||||
|
||||
@@ -159,6 +159,8 @@ pub struct ArrowState {
|
||||
pub pickup: Pickup,
|
||||
/// `pickupItemStack`: what picking the arrow up gives.
|
||||
pub pickup_item: crate::items::ItemStack,
|
||||
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
|
||||
pub fired_from_weapon: Option<crate::items::ItemStack>,
|
||||
pub no_physics: bool,
|
||||
/// `AbstractArrow.getPierceLevel`.
|
||||
pub pierce: u8,
|
||||
@@ -208,6 +210,7 @@ impl ArrowState {
|
||||
last_state: None,
|
||||
pickup: Pickup::Disallowed,
|
||||
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
|
||||
fired_from_weapon: None,
|
||||
no_physics: false,
|
||||
pierce: 0,
|
||||
pierced: Vec::new(),
|
||||
|
||||
@@ -234,3 +234,81 @@ impl RenderAssets {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::items::ConsumeEffect;
|
||||
|
||||
#[test]
|
||||
fn always_edible_and_consume_effects_loaded() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
|
||||
|
||||
let apple = items.get("minecraft:golden_apple").expect("golden apple");
|
||||
assert!(apple.can_always_eat);
|
||||
assert!(apple.food.is_some());
|
||||
assert!(!apple.consumable.as_ref().unwrap().effects.is_empty());
|
||||
|
||||
let enchanted = items
|
||||
.get("minecraft:enchanted_golden_apple")
|
||||
.expect("enchanted golden apple");
|
||||
assert!(enchanted.can_always_eat);
|
||||
assert!(enchanted.food.is_some());
|
||||
assert!(!enchanted.consumable.as_ref().unwrap().effects.is_empty());
|
||||
|
||||
let beef = items.get("minecraft:cooked_beef").expect("cooked beef");
|
||||
assert!(!beef.can_always_eat);
|
||||
assert!(beef.food.is_some());
|
||||
|
||||
// Consumables without food, their style and their remainders.
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
let milk = items.get("minecraft:milk_bucket").expect("milk bucket");
|
||||
let drink = milk.consumable.as_ref().unwrap();
|
||||
assert!(milk.food.is_none());
|
||||
assert_eq!(drink.effects, [ConsumeEffect::ClearAllEffects]);
|
||||
assert_eq!((drink.ticks(), drink.animation), (32, UseAnimation::Drink));
|
||||
assert_eq!(drink.sound, "minecraft:entity.generic.drink");
|
||||
assert!(!drink.particles);
|
||||
assert_eq!(milk.use_remainder.as_deref(), Some("minecraft:bucket"));
|
||||
let honey = items.get("minecraft:honey_bottle").expect("honey bottle");
|
||||
let sip = honey.consumable.as_ref().unwrap();
|
||||
assert_eq!(sip.ticks(), 40);
|
||||
assert_eq!(sip.sound, "minecraft:item.honey_bottle.drink");
|
||||
assert_eq!(
|
||||
sip.effects,
|
||||
[ConsumeEffect::RemoveEffects(vec![
|
||||
"minecraft:poison".into()
|
||||
])]
|
||||
);
|
||||
assert_eq!(
|
||||
honey.use_remainder.as_deref(),
|
||||
Some("minecraft:glass_bottle")
|
||||
);
|
||||
let kelp = items.get("minecraft:dried_kelp").unwrap();
|
||||
let bite = kelp.consumable.as_ref().unwrap();
|
||||
assert_eq!((bite.ticks(), bite.animation), (16, UseAnimation::Eat));
|
||||
assert_eq!(bite.sound, "minecraft:entity.generic.eat");
|
||||
assert!(bite.particles && kelp.use_remainder.is_none());
|
||||
let chorus = items.get("minecraft:chorus_fruit").unwrap();
|
||||
assert_eq!(
|
||||
chorus.consumable.as_ref().unwrap().effects,
|
||||
[ConsumeEffect::TeleportRandomly(
|
||||
horizon_world::teleport_consume::TeleportConsume {
|
||||
diameter: 16.0,
|
||||
directional_particles: true,
|
||||
}
|
||||
)]
|
||||
);
|
||||
let chicken = items.get("minecraft:chicken").unwrap();
|
||||
assert!(matches!(
|
||||
chicken.consumable.as_ref().unwrap().effects[..],
|
||||
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,10 @@
|
||||
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
|
||||
//! six-layer limit and the result stack).
|
||||
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
|
||||
/// `BannerPatternLayers.Layer`: a pattern id and the dye name it is painted in.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BannerLayer {
|
||||
pub pattern: Arc<str>,
|
||||
pub color: Arc<str>,
|
||||
}
|
||||
impl BannerLayer {
|
||||
pub fn new(pattern: &str, color: &str) -> Self {
|
||||
Self {
|
||||
pattern: Arc::from(pattern),
|
||||
color: Arc::from(color),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use horizon_items::banner_layer::{BannerLayer, layers_from_nbt, layers_to_nbt};
|
||||
|
||||
/// Layers a loom may stack on a banner (`LoomMenu`'s `hasMaxPatterns`).
|
||||
pub const MAX_LAYERS: usize = 6;
|
||||
@@ -224,44 +210,6 @@ pub fn has_max_layers(banner: &ItemStack) -> bool {
|
||||
layers(banner).len() >= MAX_LAYERS
|
||||
}
|
||||
|
||||
/// The `banner_patterns` NBT list of layers.
|
||||
pub fn layers_to_nbt(layers: &[BannerLayer]) -> Tag {
|
||||
Tag::List(
|
||||
layers
|
||||
.iter()
|
||||
.map(|l| {
|
||||
Tag::compound([
|
||||
("pattern", Tag::String((&*l.pattern).into())),
|
||||
("color", Tag::String((&*l.color).into())),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec<BannerLayer> {
|
||||
let Some(Tag::List(list)) = tag else {
|
||||
return Vec::new();
|
||||
};
|
||||
list.iter()
|
||||
.filter_map(|e| {
|
||||
let text = |k: &str| match e.get(k) {
|
||||
Some(Tag::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
let pattern = text("pattern")?;
|
||||
let color = text("color")?;
|
||||
Some(BannerLayer::new(
|
||||
&if pattern.contains(':') {
|
||||
pattern
|
||||
} else {
|
||||
format!("minecraft:{pattern}")
|
||||
},
|
||||
&color,
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -66,6 +66,13 @@ impl BiomeInfo {
|
||||
}
|
||||
.height_adjusted(pos, sea_level)
|
||||
}
|
||||
/// `Biome.getPrecipitationAt` in the Overworld (sea level 63): None
|
||||
/// without precipitation, else whether it falls as snow
|
||||
/// (`getHeightAdjustedTemperature` below 0.15).
|
||||
pub fn snows_at(&self, x: i32, y: i32, z: i32) -> Option<bool> {
|
||||
self.precipitation
|
||||
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
|
||||
}
|
||||
}
|
||||
pub struct Biomes {
|
||||
pub list: Vec<BiomeInfo>,
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
//! (`entity-models.json`); transforms, animation and textures follow each
|
||||
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
|
||||
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
|
||||
use crate::{hud::Rect, mesher::Vertex, world::World};
|
||||
use crate::{
|
||||
hud::Rect,
|
||||
mesher::{CULL_BACK, Vertex},
|
||||
world::World,
|
||||
};
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
use horizon_core::geometry::{
|
||||
Direction,
|
||||
@@ -938,6 +942,30 @@ impl BlockEntityRenderer {
|
||||
let lights = diffuse_lights(scene.nether);
|
||||
self.render_one(scene, pos, kind, base, packed, lights, false, false, out);
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity.shouldRenderFace` for each face of the gateway
|
||||
/// at `pos`: the faces its portal is drawn on.
|
||||
pub fn gateway_faces(
|
||||
&self,
|
||||
world: &World,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
) -> Vec<Direction> {
|
||||
Direction::ALL
|
||||
.into_iter()
|
||||
.filter(|&d| {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = world.block(pos[0] + dx, pos[1] + dy, pos[2] + dz);
|
||||
self.visibility.as_ref().is_none_or(|v| {
|
||||
v.lock()
|
||||
.map(|mut v| {
|
||||
v.should_render_face(state, neighbor, d, true)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Whether this block state has a block-entity renderer here.
|
||||
pub fn renders(&self, state: u32) -> bool {
|
||||
!matches!(self.kind(state), Kind::None)
|
||||
@@ -1392,6 +1420,7 @@ impl BlockEntityRenderer {
|
||||
m *= around(Mat4::from_rotation_y(turn * decay), pivot);
|
||||
}
|
||||
}
|
||||
let first = out.len();
|
||||
if let Some(paint) =
|
||||
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
|
||||
{
|
||||
@@ -1409,6 +1438,11 @@ impl BlockEntityRenderer {
|
||||
draw_part(part, side, m, &none, paint, out);
|
||||
}
|
||||
}
|
||||
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
|
||||
// back faces: the flat `top` and `bottom` planes show one side each.
|
||||
for vertex in &mut out[first..] {
|
||||
vertex.normal = CULL_BACK;
|
||||
}
|
||||
}
|
||||
Kind::EndPortal => {
|
||||
// TheEndPortalRenderer.TRANSFORMATION: y from 6/16 to 12/16; only the
|
||||
@@ -1439,22 +1473,8 @@ impl BlockEntityRenderer {
|
||||
out,
|
||||
);
|
||||
}
|
||||
// AbstractEndPortalRenderer.extractRenderState: TheEndGatewayBlockEntity.shouldRenderFace.
|
||||
let faces: Vec<Direction> = Direction::ALL
|
||||
.into_iter()
|
||||
.filter(|&d| {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = scene.world.block(pos[0] + dx, pos[1] + dy, pos[2] + dz);
|
||||
self.visibility.as_ref().is_none_or(|v| {
|
||||
v.lock()
|
||||
.map(|mut v| {
|
||||
v.should_render_face(BlockStateId(*state), neighbor, d, true)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// AbstractEndPortalRenderer.extractRenderState.
|
||||
let faces = self.gateway_faces(scene.world, BlockStateId(*state), pos);
|
||||
portal_cube(base, &faces, END_GATEWAY_LAYER, out);
|
||||
}
|
||||
Kind::Conduit => {
|
||||
@@ -2118,4 +2138,70 @@ mod tests {
|
||||
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
|
||||
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
|
||||
}
|
||||
/// `DecoratedPotRenderer` submits every part with `RenderTypes.entitySolid`,
|
||||
/// whose pipeline culls back faces. The pot's `top` and `bottom` are flat
|
||||
/// boxes, so without that cull both sides of each plane are drawn and they
|
||||
/// z-fight: the pot renders wrong.
|
||||
#[test]
|
||||
fn the_decorated_pot_culls_its_back_faces() {
|
||||
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("entity-models.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let registry = assets.registry;
|
||||
let renderer = BlockEntityRenderer::load(&root, registry).unwrap();
|
||||
let state = registry.default_state("minecraft:decorated_pot").unwrap();
|
||||
let sprites: HashMap<String, Rect> = [
|
||||
"entity/decorated_pot/decorated_pot_base",
|
||||
"entity/decorated_pot/decorated_pot_side",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
(
|
||||
name.to_owned(),
|
||||
Rect {
|
||||
u0: 0.25,
|
||||
v0: 0.125,
|
||||
u1: 0.375,
|
||||
v1: 0.25,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.5,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
|
||||
// Vanilla submits 28 quads for a plain pot, and the cull keeps them all.
|
||||
assert_eq!(
|
||||
verts.len() / 4,
|
||||
28,
|
||||
"the pot submits all of vanilla's quads"
|
||||
);
|
||||
assert!(
|
||||
verts.iter().all(|v| v.normal == CULL_BACK),
|
||||
"entitySolid culls the pot's back faces"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -556,26 +556,34 @@ impl Chat {
|
||||
pub fn hover_at(&self, font: &crate::font::Font, height: i32, x: f64, y: f64) -> Option<Text> {
|
||||
self.style_at(font, height, x, y)?.hover.map(|h| *h)
|
||||
}
|
||||
/// ChatComponent.getClickedComponentStyleAt.
|
||||
fn style_at(
|
||||
&self,
|
||||
font: &crate::font::Font,
|
||||
height: i32,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Option<crate::text::Style> {
|
||||
/// The wrapped line under a GUI-space cursor while chat is focused, and
|
||||
/// the cursor's x within it (the text starts at 0).
|
||||
fn line_at(&self, height: i32, x: f64, y: f64) -> Option<(&Line, f64)> {
|
||||
let scale = self.options.scale as f32;
|
||||
let mut x = x / f64::from(scale) - 4.0;
|
||||
let x = x / f64::from(scale) - 4.0;
|
||||
let bottom = hud_layout::floor((height - 40) as f32 / scale);
|
||||
let from_bottom = f64::from(bottom) - y / f64::from(scale);
|
||||
if x < 0.0 || x >= f64::from(self.max_width()) || from_bottom <= 0.0 {
|
||||
if x >= f64::from(self.max_width()) || from_bottom <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let row = (from_bottom / f64::from(self.options.line_height())).ceil() as usize - 1;
|
||||
if row >= self.lines_per_page(true) {
|
||||
return None;
|
||||
}
|
||||
let line = self.lines.get(self.scroll + row)?;
|
||||
Some((self.lines.get(self.scroll + row)?, x))
|
||||
}
|
||||
/// ChatComponent.getClickedComponentStyleAt.
|
||||
pub fn style_at(
|
||||
&self,
|
||||
font: &crate::font::Font,
|
||||
height: i32,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Option<crate::text::Style> {
|
||||
let (line, mut x) = self.line_at(height, x, y)?;
|
||||
if x < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let content = line.content.resolve_legacy();
|
||||
for (c, style) in content.chars() {
|
||||
x -= f64::from(font.advance(c, style));
|
||||
@@ -585,6 +593,13 @@ impl Chat {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Whether the cursor is on a line's indicator bar while chat is focused
|
||||
/// (`DrawingFocusedGraphicsAccess.handleTag`), whose tooltip is the
|
||||
/// message tag's text.
|
||||
pub fn indicator_at(&self, height: i32, x: f64, y: f64) -> bool {
|
||||
self.line_at(height, x, y)
|
||||
.is_some_and(|(line, x)| line.indicator.is_some() && (-4.0..-2.0).contains(&x))
|
||||
}
|
||||
/// Plain text of the newest messages (for tests and the control socket).
|
||||
pub fn recent_lines(&self, n: usize) -> Vec<String> {
|
||||
self.messages
|
||||
|
||||
@@ -63,13 +63,24 @@ impl Confirm {
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
labels: [&Text; 2],
|
||||
) {
|
||||
self.draw_active(batch, atlas, cursor, labels, true);
|
||||
}
|
||||
/// `draw` with the buttons greyed out while `setDelay` runs (`active` false).
|
||||
pub fn draw_active(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
labels: [&Text; 2],
|
||||
active: bool,
|
||||
) {
|
||||
for (line, x, y) in &self.lines {
|
||||
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
|
||||
}
|
||||
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
|
||||
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
|
||||
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, true, hover);
|
||||
let hover = active && ui::hovered(cursor, bx, by, 150.0, 20.0);
|
||||
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, active, hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,15 +129,16 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `InstantenousMobEffect.applyInstantenousEffect` (potions apply these directly).
|
||||
/// `HealOrHarmMobEffect.applyInstantaneousEffect` (potions apply these directly;
|
||||
/// the amount rounds to the nearest whole point).
|
||||
pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
|
||||
match id {
|
||||
"instant_health" => Some(EffectAction::Heal(
|
||||
(scale * f64::from(4 << amp.min(30))) as i32 as f32,
|
||||
(scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32,
|
||||
)),
|
||||
"instant_damage" => Some(EffectAction::Hurt(
|
||||
"minecraft:magic",
|
||||
(scale * f64::from(6 << amp.min(30))) as i32 as f32,
|
||||
(scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32,
|
||||
)),
|
||||
"saturation" => Some(EffectAction::Eat(amp + 1, 1.0)),
|
||||
_ => None,
|
||||
|
||||
@@ -19,6 +19,8 @@ pub struct ItemEntity {
|
||||
pub id: u32,
|
||||
pub stack: ItemStack,
|
||||
pub thrower: Option<[i32; 4]>,
|
||||
/// `ItemEntity.target`: the only player who may pick the item up.
|
||||
pub target: Option<[i32; 4]>,
|
||||
pub position: Vec3,
|
||||
pub previous: Vec3,
|
||||
pub velocity: Vec3,
|
||||
@@ -355,7 +357,10 @@ pub fn merge_with_neighbours(
|
||||
}
|
||||
// ItemEntity.areMergable: both fit one stack of the same item and components.
|
||||
let (this, other) = (&items[i].stack, &items[j].stack);
|
||||
if this.count + other.count > max_stack(other) || !this.same_item_same_components(other) {
|
||||
if items[i].target != items[j].target
|
||||
|| this.count + other.count > max_stack(other)
|
||||
|| !this.same_item_same_components(other)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (to, from) = if other.count < this.count {
|
||||
@@ -951,6 +956,7 @@ mod fire_environment_tests {
|
||||
let make = |id: &str| ItemEntity {
|
||||
id: 1,
|
||||
thrower: None,
|
||||
target: None,
|
||||
stack: ItemStack {
|
||||
item: std::sync::Arc::from(id),
|
||||
count: 1,
|
||||
@@ -1047,6 +1053,7 @@ mod merge_tests {
|
||||
ItemEntity {
|
||||
id: 0,
|
||||
thrower: None,
|
||||
target: None,
|
||||
stack: ItemStack {
|
||||
item: std::sync::Arc::from(id),
|
||||
count,
|
||||
|
||||
@@ -189,6 +189,7 @@ mod tests {
|
||||
extra: None,
|
||||
},
|
||||
thrower: None,
|
||||
target: None,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
|
||||
@@ -54,6 +54,8 @@ struct Out {
|
||||
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
|
||||
@location(11) glint_uv: vec2<f32>,
|
||||
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
|
||||
@location(12) @interpolate(flat) cull: u32,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
@@ -102,7 +104,10 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
// minecraft_mix_light: the vertex colour times the cardinal light only.
|
||||
var c = color;
|
||||
var back = c;
|
||||
if normal.w != 0.0 {
|
||||
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
|
||||
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
|
||||
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
|
||||
if abs(normal.w) >= 0.5 {
|
||||
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
|
||||
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
|
||||
}
|
||||
@@ -234,7 +239,7 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
// clouds.fsh: alpha fades out towards the cloud fog end.
|
||||
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
|
||||
}
|
||||
if color.a < 0.1 { discard; }
|
||||
if color.a < 0.1 || (in.cull != 0u && !front) { discard; }
|
||||
// entity.fsh calculateFinalColor: the overlay (hurt red, TNT white) over the
|
||||
// lit texture, then the lightmap, then the glint and fog.
|
||||
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
|
||||
|
||||
@@ -13,7 +13,6 @@ use crate::{
|
||||
doubles, float, floats, item_from_nbt, item_to_nbt, number, put, read_doubles, text,
|
||||
uuid_from_tag,
|
||||
},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
@@ -515,6 +514,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
"Variant".into(),
|
||||
Tag::Int(i32::from(m.extra.variant.min(4))),
|
||||
);
|
||||
if let Some(owner) = m.extra.owner {
|
||||
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
|
||||
} else {
|
||||
map.remove(&"Owner".into());
|
||||
}
|
||||
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
|
||||
}
|
||||
"minecraft:salmon" => {
|
||||
let size = SALMON_SIZES[usize::from(m.extra.variant).min(2)];
|
||||
@@ -552,7 +557,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
"memories",
|
||||
Tag::compound([(
|
||||
"minecraft:liked_player",
|
||||
Tag::compound([("value", Tag::IntArray(vec![0, 0, 0, 1]))]),
|
||||
Tag::compound([(
|
||||
"value",
|
||||
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
|
||||
)]),
|
||||
)]),
|
||||
)]),
|
||||
);
|
||||
@@ -606,6 +614,9 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.insert("life_ticks".into(), Tag::Int(m.extra.egg_time));
|
||||
}
|
||||
}
|
||||
"minecraft:endermite" => {
|
||||
map.insert("Lifetime".into(), Tag::Int(m.extra.counter));
|
||||
}
|
||||
"minecraft:shulker" => {
|
||||
map.insert("AttachFace".into(), Tag::Byte(m.extra.variant3 as i8));
|
||||
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
|
||||
@@ -712,6 +723,17 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
|
||||
continue;
|
||||
}
|
||||
// A wolf's armor keeps the durability it lost.
|
||||
if slot == "body"
|
||||
&& m.species() == crate::mobs::Species::Wolf
|
||||
&& let Some(id) = item
|
||||
{
|
||||
let mut stack = crate::items::ItemStack::new(id, 1);
|
||||
stack.damage = m.extra.body_damage.max(0) as u32;
|
||||
stack.extra = m.extra.body_extra.clone();
|
||||
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
|
||||
continue;
|
||||
}
|
||||
match item {
|
||||
Some(id) => {
|
||||
let keep = matches!(
|
||||
@@ -851,14 +873,7 @@ fn custom_name(tag: &Tag) -> Option<String> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
|
||||
/// else is a component (`ComponentSerialization`).
|
||||
pub fn name_text(tag: &Tag) -> Text {
|
||||
match tag {
|
||||
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
|
||||
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
|
||||
}
|
||||
}
|
||||
pub use horizon_client_core::component::name_text;
|
||||
/// Mob.finalizeSpawn's follow range bonus in the saved attributes.
|
||||
pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
|
||||
let Some(Tag::List(attrs)) = tag.get("attributes") else {
|
||||
@@ -1111,6 +1126,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.variant = gene("MainGene");
|
||||
m.extra.variant2 = gene("HiddenGene");
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Parrot {
|
||||
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");
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Wolf {
|
||||
let health = m.health;
|
||||
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
|
||||
@@ -1118,6 +1138,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
crate::mobs::tame::set_tame(&mut m, tame);
|
||||
m.health = health;
|
||||
m.extra.sitting = flag(tag, "Sitting");
|
||||
let armor = tag
|
||||
.get("equipment")
|
||||
.and_then(|e| e.get("body"))
|
||||
.and_then(crate::save::item_from_nbt);
|
||||
m.extra.body_damage = armor.as_ref().map_or(0, |s| s.damage as i32);
|
||||
m.extra.body_extra = armor.and_then(|s| s.extra);
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
}
|
||||
@@ -1148,11 +1174,14 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.ints[1] = number(first.get("count")).unwrap_or(1) as i32;
|
||||
}
|
||||
m.extra.egg_time = number(tag.get("DuplicationCooldown")).unwrap_or(0) as i32;
|
||||
m.extra.tame = tag
|
||||
let liked = tag
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
.and_then(|b| b.get("minecraft:liked_player"))
|
||||
.is_some();
|
||||
.and_then(|b| b.get("minecraft:liked_player"));
|
||||
m.extra.tame = liked.is_some();
|
||||
m.extra.owner = liked
|
||||
.and_then(|l| l.get("value"))
|
||||
.and_then(|v| crate::save::uuid_from_tag(Some(v)));
|
||||
}
|
||||
if crate::mobs::camel::is_camel(m.species()) {
|
||||
let sitting = matches!(tag.get("LastPoseTick"), Some(Tag::Long(v)) if *v < 0);
|
||||
@@ -1230,6 +1259,9 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32;
|
||||
m.extra.anim_ticks = number(tag.get("RoarTick")).unwrap_or(0) as i32;
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Endermite {
|
||||
m.extra.counter = number(tag.get("Lifetime")).unwrap_or(0) as i32;
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Vex {
|
||||
if let Some(Tag::IntArray(v)) = tag.get("bound_pos")
|
||||
&& v.len() == 3
|
||||
@@ -1320,6 +1352,11 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"Thrower".into());
|
||||
}
|
||||
if let Some(target) = e.target {
|
||||
put(&mut tag, "Owner", Tag::IntArray(target.to_vec()));
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"Owner".into());
|
||||
}
|
||||
tag
|
||||
}
|
||||
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
|
||||
@@ -1331,6 +1368,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
|
||||
id,
|
||||
stack,
|
||||
thrower: uuid_from_tag(tag.get("Thrower")),
|
||||
target: uuid_from_tag(tag.get("Owner")),
|
||||
position,
|
||||
previous: position,
|
||||
velocity: motion(tag),
|
||||
@@ -2240,6 +2278,7 @@ mod tests {
|
||||
health: 2,
|
||||
fire_ticks: 73,
|
||||
thrower: Some([5, 6, 7, 8]),
|
||||
target: None,
|
||||
id: 7,
|
||||
stack: ItemStack {
|
||||
item: Arc::from("minecraft:diamond_pickaxe"),
|
||||
|
||||
@@ -26,6 +26,13 @@ pub enum ItemPose {
|
||||
Bow {
|
||||
ticks_used: f32,
|
||||
},
|
||||
/// A spear being charged (`ItemUseAnimation.SPEAR`): the time held and
|
||||
/// the ticks since its last hit's feedback.
|
||||
Spear {
|
||||
kinetic: crate::spear::Kinetic,
|
||||
time_held: f32,
|
||||
ticks_since_hit: f32,
|
||||
},
|
||||
Crossbow {
|
||||
ticks_used: Option<f32>,
|
||||
charge_duration: f32,
|
||||
@@ -69,6 +76,16 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
|
||||
if matches!(pose, ItemPose::Using) {
|
||||
return m;
|
||||
}
|
||||
// The spear's own place in the hand, without the equip height.
|
||||
if let ItemPose::Spear {
|
||||
kinetic,
|
||||
time_held,
|
||||
ticks_since_hit,
|
||||
} = pose
|
||||
{
|
||||
return Mat4::from_translation(Vec3::new(invert * 0.56, -0.52, -0.72))
|
||||
* crate::spear::first_person_use(&kinetic, ticks_since_hit, time_held, !left);
|
||||
}
|
||||
if matches!(pose, ItemPose::SpinAttack) {
|
||||
return m
|
||||
* Mat4::from_translation(Vec3::new(invert * -0.4, 0.8, 0.3))
|
||||
@@ -426,18 +443,7 @@ impl UseResult {
|
||||
matches!(self, Self::Success { swing: true }) && before == after
|
||||
}
|
||||
}
|
||||
pub fn consumable_animation(v: &serde_json::Value) -> horizon_world::consumable::UseAnimation {
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
match v
|
||||
.get("animation")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("eat")
|
||||
{
|
||||
"eat" => UseAnimation::Eat,
|
||||
"drink" => UseAnimation::Drink,
|
||||
_ => UseAnimation::None,
|
||||
}
|
||||
}
|
||||
pub use horizon_items::use_animation::consumable_animation;
|
||||
/// What the click handling reads and does in the world.
|
||||
pub trait HandWorld {
|
||||
fn hit(&mut self) -> Hit;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -719,6 +719,19 @@ impl Game<'_> {
|
||||
adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w)
|
||||
});
|
||||
}
|
||||
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
|
||||
pub(super) fn trigger_killed_by_arrow(
|
||||
&mut self,
|
||||
victims: &[EntityView],
|
||||
weapon: Option<&ItemStack>,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let victims = victims.to_vec();
|
||||
let weapon = weapon.map(item_view);
|
||||
self.trigger("minecraft:killed_by_arrow", move |c, w| {
|
||||
adv::killed_by_arrow(c, &me, &victims, weapon.as_ref(), w)
|
||||
});
|
||||
}
|
||||
/// `LevitationTrigger`: how far the player rose from where it started
|
||||
/// levitating, and for how long.
|
||||
fn trigger_levitation(&mut self, start: [f64; 3], duration: i64) {
|
||||
@@ -863,6 +876,35 @@ impl Game<'_> {
|
||||
let it = item_view(stack);
|
||||
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
|
||||
}
|
||||
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
|
||||
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:construct_beacon", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w) && adv::range_i(&c["level"], i64::from(levels))
|
||||
});
|
||||
}
|
||||
/// `EnchantedItemTrigger`: the enchanted item and the levels it cost.
|
||||
pub(super) fn trigger_enchanted_item(&mut self, stack: &ItemStack, levels: i32) {
|
||||
let me = self.player_entity();
|
||||
let it = item_view(stack);
|
||||
self.trigger("minecraft:enchanted_item", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::item(&c["item"], &it, w)
|
||||
&& adv::range_i(&c["levels"], i64::from(levels))
|
||||
});
|
||||
}
|
||||
/// `BrewedPotionTrigger`: the potion contents of a stack taken from a
|
||||
/// brewing stand.
|
||||
pub(super) fn trigger_brewed_potion(&mut self, stack: &ItemStack) {
|
||||
|
||||
@@ -8,7 +8,6 @@ use horizon_core::random::RandomSource;
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_allays(&mut self) {
|
||||
let griefing = self.ui.rules.bool("mob_griefing");
|
||||
let player = self.player.as_ref().map(|p| p.position);
|
||||
// The playing jukeboxes, found at the first allay: no allay starts
|
||||
// or stops one, and the block entities run into the thousands.
|
||||
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
|
||||
@@ -58,7 +57,7 @@ impl Game<'_> {
|
||||
};
|
||||
let Some((j, _)) = wanted else {
|
||||
self.mobs[i].extra.point = None;
|
||||
self.allay_deliver(i, player);
|
||||
self.allay_deliver(i);
|
||||
continue;
|
||||
};
|
||||
let e = &self.dropped[j];
|
||||
@@ -90,14 +89,46 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AllayAi.getLikedPlayer`: the player the allay was given an item by,
|
||||
/// found by UUID among the host and the guests of the level (the window
|
||||
/// is a guest on the server thread), when they are not a spectator and
|
||||
/// within 64 blocks.
|
||||
fn allay_liked_player(&self, i: usize) -> Option<horizon_core::geometry::Vec3> {
|
||||
let m = &self.mobs[i];
|
||||
if !m.extra.tame {
|
||||
return None;
|
||||
}
|
||||
// An allay saved before its player was kept belongs to the host.
|
||||
let uuid = m
|
||||
.extra
|
||||
.owner
|
||||
.filter(|u| *u != [0, 0, 0, 1])
|
||||
.or(self.level.player_uuid)
|
||||
.unwrap_or([0, 0, 0, 1]);
|
||||
let host = self
|
||||
.player_view()
|
||||
.filter(|p| !p.spectator && self.level.player_uuid.unwrap_or([0, 0, 0, 1]) == uuid)
|
||||
.map(|p| p.position);
|
||||
let found = host.or_else(|| {
|
||||
self.lan_present()
|
||||
.into_iter()
|
||||
.find(|p| p.uuid == uuid && !p.spectator)
|
||||
.map(|p| {
|
||||
horizon_core::geometry::Vec3::new(p.position[0], p.position[1], p.position[2])
|
||||
})
|
||||
});
|
||||
found.filter(|p| p.distance_squared(m.position) < 64.0 * 64.0)
|
||||
}
|
||||
/// `GoAndGiveItemsToTarget` / `AllayAi.throwItem`: within 3 blocks of
|
||||
/// the deposit, one item a tick, each restarting the three-second pause
|
||||
/// before it picks anything up again.
|
||||
fn allay_deliver(&mut self, i: usize, player: Option<horizon_core::geometry::Vec3>) {
|
||||
fn allay_deliver(&mut self, i: usize) {
|
||||
let m = &self.mobs[i];
|
||||
if m.extra.ints[1] <= 0 {
|
||||
return;
|
||||
}
|
||||
let player = self.allay_liked_player(i);
|
||||
let m = &self.mobs[i];
|
||||
let target = match m.extra.home.filter(|_| m.extra.ints[2] > 0) {
|
||||
Some(h) => horizon_core::geometry::Vec3::new(
|
||||
f64::from(h[0]) + 0.5,
|
||||
@@ -167,11 +198,14 @@ impl Game<'_> {
|
||||
self.consume_held();
|
||||
return true;
|
||||
}
|
||||
let acting = self.acting_uuid();
|
||||
let holds = m.extra.main_hand.is_some();
|
||||
if !holds && !item.is_empty() {
|
||||
let m = &mut self.mobs[index];
|
||||
m.extra.main_hand = Some(item.into());
|
||||
m.extra.tame = true;
|
||||
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
|
||||
m.extra.owner = Some(acting);
|
||||
self.audio
|
||||
.play("minecraft:entity.allay.item_given", Some(at), 2.0, 1.0);
|
||||
self.consume_held();
|
||||
@@ -186,6 +220,7 @@ impl Game<'_> {
|
||||
.unwrap_or_else(|| "minecraft:air".into());
|
||||
let carried = std::mem::take(&mut m.extra.ints[1]);
|
||||
m.extra.tame = false;
|
||||
m.extra.owner = None;
|
||||
self.audio
|
||||
.play("minecraft:entity.allay.item_taken", Some(at), 2.0, 1.0);
|
||||
if carried > 0 {
|
||||
|
||||
@@ -164,11 +164,35 @@ impl Game<'_> {
|
||||
let (was, is) = (previous > 0, b.levels > 0);
|
||||
if !was && is {
|
||||
self.beacon_sound([x, y, z], "minecraft:block.beacon.activate");
|
||||
let levels = b.levels;
|
||||
self.for_each_player(|g| g.beacon_constructed([x, y, z], levels));
|
||||
} else if was && !is {
|
||||
self.beacon_sound([x, y, z], "minecraft:block.beacon.deactivate");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `BeaconBlockEntity.tick` after activating: `CONSTRUCT_BEACON` for a
|
||||
/// player in the box `AABB(x, y, z, x, y - 4, z).inflate(10, 5, 10)`.
|
||||
fn beacon_constructed(&mut self, p: [i32; 3], levels: i32) {
|
||||
let Some(bb) = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|pl| pl.dimensions.bounds(pl.position))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let [x, y, z] = p.map(f64::from);
|
||||
if lo.x < x + 10.0
|
||||
&& hi.x > x - 10.0
|
||||
&& lo.y < y + 5.0
|
||||
&& hi.y > y - 9.0
|
||||
&& lo.z < z + 10.0
|
||||
&& hi.z > z - 10.0
|
||||
{
|
||||
self.trigger_construct_beacon(levels);
|
||||
}
|
||||
}
|
||||
pub(super) fn beacon_sound(&mut self, p: [i32; 3], sound: &str) {
|
||||
let at = [
|
||||
f64::from(p[0]) + 0.5,
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
//! placed item gives its block entity.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
block_entity_item, block_entity_nbt, containers::BlockEntity, item_data, items::ItemStack, save,
|
||||
Result, block_entity_item, block_entity_nbt, containers::BlockEntity, item_data,
|
||||
items::ItemStack, save,
|
||||
};
|
||||
use horizon_world::{
|
||||
nbt::{Compound, Tag},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
|
||||
/// The component patch of `stack`.
|
||||
fn components(stack: &ItemStack) -> Compound {
|
||||
@@ -77,6 +81,42 @@ impl Game<'_> {
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// `BlockItem.updateBlockStateFromTag`: the properties of the item's
|
||||
/// `block_state` component (a hive's honey level, a statue's pose) set on
|
||||
/// the block just placed at `pos`. Returns the state it has now.
|
||||
pub(super) fn apply_item_block_state(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
state: BlockStateId,
|
||||
stack: &ItemStack,
|
||||
) -> Result<BlockStateId> {
|
||||
let Some(Tag::Compound(saved)) =
|
||||
stack.data().and_then(|d| d.other("minecraft:block_state"))
|
||||
else {
|
||||
return Ok(state);
|
||||
};
|
||||
let Some(current) = self.pack.assets.registry.state(state) else {
|
||||
return Ok(state);
|
||||
};
|
||||
// Only the properties the block has.
|
||||
let props: Vec<(String, String)> = saved
|
||||
.iter()
|
||||
.filter_map(|(k, v)| match v {
|
||||
Tag::String(v) => Some((k.to_string_lossy(), v.to_string_lossy())),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|(k, v)| current.properties.get(k).is_some_and(|old| old != v))
|
||||
.collect();
|
||||
let props: Vec<(&str, &str)> = props.iter().map(|(k, v)| (&**k, &**v)).collect();
|
||||
match self.with_props(state, &props) {
|
||||
Some(new) if !props.is_empty() && new != state => {
|
||||
self.level_set(pos, new)?;
|
||||
Ok(new)
|
||||
}
|
||||
_ => Ok(state),
|
||||
}
|
||||
}
|
||||
|
||||
/// `BlockItem.updateCustomBlockEntityTag` and `updateBlockEntityComponents`
|
||||
/// for the block entity just placed at `pos` from `stack`; `game_master`:
|
||||
/// the placer can use game master blocks (a dispenser can't).
|
||||
|
||||
@@ -1144,7 +1144,12 @@ impl Game<'_> {
|
||||
BLACK,
|
||||
);
|
||||
}
|
||||
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME);
|
||||
// BookSignScreen: `owner.getName()`, the account's name on a server.
|
||||
let owner = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map_or(super::chat::PLAYER_NAME, |r| r.profile_name.as_str());
|
||||
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", owner);
|
||||
let tw = HudBatch::text_width(atlas, &by);
|
||||
batch.text(
|
||||
atlas,
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Brushing suspicious sand and gravel: `BrushItem.onUseTick` and
|
||||
//! `BrushableBlockEntity`. The block's loot table stays in its saved block
|
||||
//! entity (like a decorated pot's) until the first counted stroke rolls it.
|
||||
use super::{Game, Hit, decorated_pot, spill, view_vector};
|
||||
use horizon_client::{
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
save,
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `BrushItem.USE_DURATION`.
|
||||
const USE_DURATION: u32 = 200;
|
||||
|
||||
/// A brushable block's counters. Vanilla never saves them: the block's
|
||||
/// `dusted` state is all that is left after a reload.
|
||||
#[derive(Default)]
|
||||
pub(super) struct BrushState {
|
||||
count: i32,
|
||||
resets_at: i64,
|
||||
cooldown_ends_at: i64,
|
||||
hit_face: Option<[i32; 3]>,
|
||||
}
|
||||
|
||||
/// `BrushableBlockEntity.getCompletionState`: the `dusted` property.
|
||||
fn completion_state(count: i32) -> i32 {
|
||||
match count {
|
||||
0 => 0,
|
||||
1..=2 => 1,
|
||||
3..=5 => 2,
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) type Brushing = HashMap<[i32; 3], BrushState>;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `BrushItem.onUseTick` for a brush used `ticks` ticks so far, the
|
||||
/// hand of `slot` acting.
|
||||
pub(super) fn tick_brush(&mut self, slot: usize, ticks: u32) {
|
||||
// No block under the crosshair: releaseUsingItem.
|
||||
let Some(hit) = self.pick() else {
|
||||
self.using = None;
|
||||
return;
|
||||
};
|
||||
let elapsed = ticks + 1;
|
||||
// The last tick before the backswing.
|
||||
if elapsed % 10 == 5 {
|
||||
self.brush_stroke(&hit, slot);
|
||||
}
|
||||
// The use ends after 200 ticks; the held button starts it again.
|
||||
self.using = (elapsed < USE_DURATION).then_some((slot, elapsed));
|
||||
}
|
||||
|
||||
/// One stroke on the block aimed at: dust (this window only), the brush
|
||||
/// sound, and the block entity's count. The brush wears when the block
|
||||
/// changed state.
|
||||
fn brush_stroke(&mut self, hit: &Hit, slot: usize) {
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
let (sound, brushable) = match &*name {
|
||||
"minecraft:suspicious_sand" => ("minecraft:item.brush.brushing.sand", true),
|
||||
"minecraft:suspicious_gravel" => ("minecraft:item.brush.brushing.gravel", true),
|
||||
_ => ("minecraft:item.brush.brushing.generic", false),
|
||||
};
|
||||
if self.lan_acting.is_none() {
|
||||
self.brush_dust(hit, slot == super::shield::OFFHAND_SLOT);
|
||||
}
|
||||
let at = hit.pos.map(|v| f64::from(v) + 0.5);
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
if brushable && self.brush_block(hit.pos, hit.face) {
|
||||
self.damage_held(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `BrushItem.spawnDustParticles`.
|
||||
pub(super) fn brush_dust(&mut self, hit: &Hit, off_hand: bool) {
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, hit.pos) else {
|
||||
return;
|
||||
};
|
||||
let options = ParticleOptions::block(ParticleType::Block, sprite, tint);
|
||||
// The arm that brushes: the main arm, or the other one for the off hand.
|
||||
let flip = if off_hand == self.settings.main_hand_left {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
};
|
||||
let view = view_vector(self.yaw, self.pitch);
|
||||
// DustParticlesDelta.fromDirection.
|
||||
let (dx, dz) = match hit.face {
|
||||
[0, _, 0] => (view[2], -view[0]),
|
||||
[0, 0, -1] => (1.0, -0.1),
|
||||
[0, 0, 1] => (-1.0, 0.1),
|
||||
[-1, 0, 0] => (-0.1, -1.0),
|
||||
_ => (0.1, 1.0),
|
||||
};
|
||||
let count = 7 + self.particles.level_random().next_int_bound(5).unwrap_or(0);
|
||||
let x = hit.point[0] - if hit.face == [-1, 0, 0] { 1.0e-6 } else { 0.0 };
|
||||
let z = hit.point[2] - if hit.face == [0, 0, -1] { 1.0e-6 } else { 0.0 };
|
||||
for _ in 0..count {
|
||||
let random = self.particles.level_random();
|
||||
let v = [
|
||||
dx * flip * 3.0 * random.next_double(),
|
||||
0.0,
|
||||
dz * flip * 3.0 * random.next_double(),
|
||||
];
|
||||
self.particles.add(&options, [x, hit.point[1], z], v);
|
||||
}
|
||||
}
|
||||
|
||||
/// `BrushableBlockEntity.brush`: whether the block changed (the brush wears).
|
||||
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3]) -> bool {
|
||||
let time = self.world.game_time;
|
||||
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
|
||||
brush.hit_face.get_or_insert(face);
|
||||
brush.resets_at = time + 40;
|
||||
if time < brush.cooldown_ends_at {
|
||||
self.brushing.insert(pos, brush);
|
||||
return false;
|
||||
}
|
||||
brush.cooldown_ends_at = time + 10;
|
||||
self.unpack_brush_loot(pos);
|
||||
let before = completion_state(brush.count);
|
||||
brush.count += 1;
|
||||
if brush.count >= 10 {
|
||||
self.brushing_completed(pos, brush.hit_face);
|
||||
return true;
|
||||
}
|
||||
self.schedule_brushable_tick(pos);
|
||||
let after = completion_state(brush.count);
|
||||
self.brushing.insert(pos, brush);
|
||||
if before != after {
|
||||
self.set_dusted(pos, after);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `BrushableBlockEntity.unpackLootTable`: the table's roll becomes the
|
||||
/// block entity's item.
|
||||
fn unpack_brush_loot(&mut self, pos: [i32; 3]) {
|
||||
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
|
||||
return;
|
||||
};
|
||||
if tag.get("LootTable").is_none() {
|
||||
return;
|
||||
}
|
||||
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
|
||||
decorated_pot::unpack_loot(
|
||||
&mut tag,
|
||||
loot.as_deref(),
|
||||
&mut self.world.random,
|
||||
&mut self.rand_sequences,
|
||||
self.level.seed,
|
||||
);
|
||||
spill::put_saved_entity(&mut self.world, pos, tag);
|
||||
}
|
||||
|
||||
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
|
||||
/// brushed face and the block turns into its sand or gravel.
|
||||
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let turns_into = match &*self.res.facts().get(state).name {
|
||||
"minecraft:suspicious_sand" => "minecraft:sand",
|
||||
"minecraft:suspicious_gravel" => "minecraft:gravel",
|
||||
_ => "minecraft:air",
|
||||
};
|
||||
// dropContent: the item at the middle of the block beside that face.
|
||||
let item = spill::saved_entity(&self.world, pos)
|
||||
.and_then(|tag| tag.get("item").and_then(save::item_from_nbt));
|
||||
if let Some(mut item) = item {
|
||||
let face = face.unwrap_or([0, 1, 0]);
|
||||
let take = 10 + self.world.random.next_int_bound(21).unwrap_or(0);
|
||||
item.count = item.count.min(take as u32);
|
||||
let at = [
|
||||
f64::from(pos[0] + face[0]) + 0.5,
|
||||
f64::from(pos[1] + face[1]) + 0.625,
|
||||
f64::from(pos[2] + face[2]) + 0.5,
|
||||
];
|
||||
self.spawn_item(item, at, [0.0; 3], 0);
|
||||
}
|
||||
// Level event 3008 for every player, then the block change.
|
||||
self.level_event_at(3008, pos, state.0 as i32);
|
||||
if let Some(base) = self.pack.assets.registry.default_state(turns_into) {
|
||||
let _ = self.level_set(pos, base);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_dusted(&mut self, pos: [i32; 3], dusted: i32) {
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(mut state) = registry
|
||||
.state(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
state.properties.insert("dusted".into(), dusted.to_string());
|
||||
if let Ok(state) = registry.resolve(&state) {
|
||||
let _ = self.level_set(pos, state);
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_brushable_tick(&mut self, pos: [i32; 3]) {
|
||||
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.schedule_tick(level, ticks, at, 2));
|
||||
}
|
||||
|
||||
/// `BrushableBlockEntity.checkReset`, from `BrushableBlock.tick`: a block
|
||||
/// left alone loses two counts, again every four ticks.
|
||||
pub(super) fn brushable_tick(&mut self, pos: [i32; 3]) {
|
||||
let time = self.world.game_time;
|
||||
let Some(mut brush) = self.brushing.remove(&pos) else {
|
||||
return;
|
||||
};
|
||||
let before = completion_state(brush.count);
|
||||
if brush.count != 0 && time >= brush.resets_at {
|
||||
brush.count = (brush.count - 2).max(0);
|
||||
brush.resets_at = time + 4;
|
||||
}
|
||||
let after = completion_state(brush.count);
|
||||
if brush.count != 0 {
|
||||
self.brushing.insert(pos, brush);
|
||||
self.schedule_brushable_tick(pos);
|
||||
}
|
||||
if before != after {
|
||||
self.set_dusted(pos, after);
|
||||
}
|
||||
}
|
||||
|
||||
/// Level event 3008 (`LevelEventHandler`): the finished block's sound
|
||||
/// and break particles.
|
||||
pub(super) fn brush_complete_event(&mut self, pos: [i32; 3], data: i32) {
|
||||
let state = horizon_world::registry::BlockStateId(data as u32);
|
||||
let sound = match &*self.res.facts().get(state).name {
|
||||
"minecraft:suspicious_sand" => "minecraft:item.brush.brushing.sand.complete",
|
||||
"minecraft:suspicious_gravel" => "minecraft:item.brush.brushing.gravel.complete",
|
||||
_ => "",
|
||||
};
|
||||
if !sound.is_empty() {
|
||||
let at = pos.map(|v| f64::from(v) + 0.5);
|
||||
// Each client plays it from the event itself.
|
||||
self.audio.play_unshared(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
self.destroy_particles(pos, state);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,60 @@
|
||||
//! shovels) put on a lit campfire puts it out.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::chunk::BlockState;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The lit campfire at `pos` that `held` puts out, when the item is in
|
||||
/// `#minecraft:douses_campfires` (`CampfireBlock.useItemOn`).
|
||||
fn campfire_to_douse(&self, pos: [i32; 3], held: Option<&ItemStack>) -> Option<BlockState> {
|
||||
held.filter(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:douses_campfires")
|
||||
.is_some_and(|members| members.contains(&*s.item))
|
||||
})?;
|
||||
self.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
|
||||
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// `CampfireBlock.useItemOn` as this client runs it before the server's
|
||||
/// answer (`MultiPlayerGameMode.useItemOn`): the twenty smoke puffs of
|
||||
/// `CampfireBlock.douse` and the campfire gone out, kept as predicted
|
||||
/// under `seq`. The level event's sound comes from the server. True when
|
||||
/// it doused (SUCCESS), which ends the click for both hands.
|
||||
pub(super) fn predict_campfire_douse(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
held: Option<&ItemStack>,
|
||||
seq: i32,
|
||||
) -> bool {
|
||||
// Player.isSecondaryUseActive: a held item means both hands count.
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) {
|
||||
return false;
|
||||
}
|
||||
let Some(mut state) = self.campfire_to_douse(pos, held) else {
|
||||
return false;
|
||||
};
|
||||
let signal = state.properties.get("signal_fire").map(String::as_str) == Some("true");
|
||||
for _ in 0..20 {
|
||||
make_campfire_smoke(&mut self.particles, pos, signal, true);
|
||||
}
|
||||
state.properties.insert("lit".into(), "false".into());
|
||||
if let Ok(unlit) = self.pack.assets.registry.resolve(&state) {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.predict_blocks(&vec![(at, unlit)], seq);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `CampfireBlock.useItemOn` with `held` in the hand that acts, the block
|
||||
/// at `pos` clicked: true when it doused a lit campfire (SUCCESS).
|
||||
pub(super) fn use_campfire_douse(
|
||||
@@ -11,24 +63,11 @@ impl Game<'_> {
|
||||
pos: [i32; 3],
|
||||
held: Option<&ItemStack>,
|
||||
) -> Result<bool> {
|
||||
let Some(tool) = held.filter(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:douses_campfires")
|
||||
.is_some_and(|members| members.contains(&*s.item))
|
||||
}) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(mut state) = registry
|
||||
.state(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
|
||||
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
|
||||
.cloned()
|
||||
let (Some(used), Some(mut state)) = (held.cloned(), self.campfire_to_douse(pos, held))
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let registry = self.pack.assets.registry;
|
||||
// Level event 1009 (SOUND_EXTINGUISH_FIRE) goes to every player.
|
||||
self.level_event_at(1009, pos, 0);
|
||||
// CampfireBlock.douse: the acting client's own smoke (twenty puffs),
|
||||
@@ -44,7 +83,6 @@ impl Game<'_> {
|
||||
if let Ok(unlit) = registry.resolve(&state) {
|
||||
self.level_set(pos, unlit)?;
|
||||
}
|
||||
let used = tool.clone();
|
||||
self.damage_held(1);
|
||||
// ServerPlayerGameMode.useItemOn: the action was consumed.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, &used);
|
||||
|
||||
@@ -81,6 +81,8 @@ pub struct UiState {
|
||||
pub chat_screen: Option<ChatScreen>,
|
||||
/// When the chat screen opened (its input took the focus: the cursor blink).
|
||||
pub chat_opened: Option<std::time::Instant>,
|
||||
/// The chat screen is `InBedChatScreen`: the sleeper's, with a Leave Bed button.
|
||||
pub in_bed_chat: bool,
|
||||
pub registries: Registries,
|
||||
pub rules: GameRules,
|
||||
pub clock: WorldClock,
|
||||
@@ -102,6 +104,9 @@ pub struct UiState {
|
||||
/// Death screen cause and when the player died (buttons wait 20 ticks).
|
||||
pub death_text: Option<Text>,
|
||||
pub died_at: Option<std::time::Instant>,
|
||||
/// `DeathScreen.TitleConfirmScreen` and when it opened (its buttons wait
|
||||
/// 20 ticks, `setDelay`).
|
||||
pub death_confirm: Option<std::time::Instant>,
|
||||
/// `LivingEntity.deathTime` of the window's player: ticks since it died.
|
||||
pub death_time: u32,
|
||||
}
|
||||
@@ -452,6 +457,12 @@ impl Game<'_> {
|
||||
self.capture(false);
|
||||
}
|
||||
pub(super) fn close_chat(&mut self) {
|
||||
// InBedChatScreen.onClose: leaving the screen means getting up; the
|
||||
// screen goes once the player has woken (`tick_in_bed_chat`).
|
||||
if self.ui.in_bed_chat && self.sleeping.is_some() {
|
||||
self.wake_up();
|
||||
return;
|
||||
}
|
||||
self.ui.chat_screen = None;
|
||||
self.ui.chat.reset_scroll();
|
||||
if self.screen == Screen::Chat {
|
||||
@@ -459,6 +470,35 @@ impl Game<'_> {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// `Gui.tick`: a sleeper gets `InBedChatScreen`; when the player has
|
||||
/// woken (`onPlayerWokeUp`) it closes, or turns into the plain chat
|
||||
/// screen if something was typed.
|
||||
pub(super) fn tick_in_bed_chat(&mut self) {
|
||||
if self.screen != Screen::Chat {
|
||||
self.ui.in_bed_chat = false;
|
||||
}
|
||||
if self.ui.in_bed_chat {
|
||||
if self.sleeping.is_none() {
|
||||
self.ui.in_bed_chat = false;
|
||||
let text = self
|
||||
.ui
|
||||
.chat_screen
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |s| s.value().to_owned());
|
||||
self.close_chat();
|
||||
if !text.is_empty() {
|
||||
self.open_chat(&text);
|
||||
}
|
||||
}
|
||||
} else if self.sleeping.is_some() && self.screen == Screen::Playing {
|
||||
self.open_chat("");
|
||||
self.ui.in_bed_chat = self.screen == Screen::Chat;
|
||||
}
|
||||
}
|
||||
/// The Leave Bed button of `InBedChatScreen`: its bounds.
|
||||
fn leave_bed_button(width: f32, height: f32) -> (i32, i32, i32, i32) {
|
||||
(width as i32 / 2 - 100, height as i32 - 40, 200, 20)
|
||||
}
|
||||
/// `Minecraft.computeChatAbilities` from the Chat option.
|
||||
pub(super) fn chat_abilities(&self) -> ChatAbilities {
|
||||
ChatAbilities::new(ChatVisibility::from_option(self.settings.chat_visibility))
|
||||
@@ -571,7 +611,15 @@ impl Game<'_> {
|
||||
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
|
||||
ChatAction::Close => self.close_chat(),
|
||||
ChatAction::Submit(message) => {
|
||||
self.close_chat();
|
||||
// InBedChatScreen sends without closing (`closeOnSubmit` is
|
||||
// false): the box empties and the screen stays.
|
||||
if self.ui.in_bed_chat && self.sleeping.is_some() {
|
||||
screen.suggest_command(&m, &c, "");
|
||||
self.ui.chat.reset_scroll();
|
||||
self.ui.chat_screen = Some(screen);
|
||||
} else {
|
||||
self.close_chat();
|
||||
}
|
||||
if !message.is_empty() {
|
||||
self.handle_chat_input(&message);
|
||||
}
|
||||
@@ -673,7 +721,8 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
// ChatScreen.mouseScrolled: `hasShiftDown`, either Shift key.
|
||||
let shift = self.has_shift_down();
|
||||
let amount = amount.clamp(-1.0, 1.0) * if shift { 1.0 } else { 7.0 };
|
||||
self.ui.chat.scroll_by(amount as i32, true);
|
||||
}
|
||||
@@ -687,6 +736,16 @@ impl Game<'_> {
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if self.ui.in_bed_chat && self.sleeping.is_some() {
|
||||
let width = self.renderer.as_ref().map_or(1280.0, |r| {
|
||||
HudBatch::new([r.config.width, r.config.height]).width
|
||||
});
|
||||
let (x, y, w, h) = Self::leave_bed_button(width, height);
|
||||
if horizon_client::screen_draw::inside((cursor.0 as i32, cursor.1 as i32), x, y, w, h) {
|
||||
self.wake_up();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
if screen.click(&m, &c, cursor.0 as f64, cursor.1 as f64) {
|
||||
return;
|
||||
@@ -694,12 +753,25 @@ impl Game<'_> {
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
match self.ui.chat.click_at(
|
||||
let Some(style) = self.ui.chat.style_at(
|
||||
&self.gui.atlas.font,
|
||||
height as i32,
|
||||
cursor.0 as f64,
|
||||
cursor.1 as f64,
|
||||
) {
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
// ChatScreen.handleComponentClicked: with Shift held only an
|
||||
// insertion acts, else only the click event.
|
||||
if self.has_shift_down() {
|
||||
if let Some(insertion) = style.insertion
|
||||
&& let Some(screen) = self.ui.chat_screen.as_mut()
|
||||
{
|
||||
screen.type_text(&m, &c, &insertion);
|
||||
}
|
||||
return;
|
||||
}
|
||||
match style.click {
|
||||
Some(ClickEvent::CopyToClipboard(value)) => {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&value);
|
||||
@@ -713,6 +785,7 @@ impl Game<'_> {
|
||||
screen.suggest_command(&m, &c, &command);
|
||||
}
|
||||
}
|
||||
Some(ClickEvent::RunCommand(command)) => self.click_command(&command),
|
||||
// Screen.defaultHandleClickEvent: a page link means nothing in chat.
|
||||
Some(ClickEvent::ChangePage(_)) | None => {}
|
||||
}
|
||||
@@ -781,6 +854,22 @@ impl Game<'_> {
|
||||
self.answer_link_prompt(yes);
|
||||
}
|
||||
}
|
||||
/// `Screen.clickCommandAction`: run a clicked command as the player
|
||||
/// without adding it to the chat history. A client mod's command goes
|
||||
/// first, as `ClientPacketListener.sendUnattendedCommand` lets client
|
||||
/// commands take it. (Vanilla asks first when the command fails to
|
||||
/// parse; here the server answers it.)
|
||||
fn click_command(&mut self, command: &str) {
|
||||
let command = command.strip_prefix('/').unwrap_or(command);
|
||||
if self.mod_command(command) {
|
||||
return;
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.send_chat(&format!("/{command}"));
|
||||
} else if !self.local_server_mod_command(command) {
|
||||
self.run_command(command);
|
||||
}
|
||||
}
|
||||
/// Chat lines over the HUD (focused while the chat screen is open).
|
||||
pub(super) fn draw_chat(&mut self, batch: &mut HudBatch) {
|
||||
let focused = self.screen == Screen::Chat;
|
||||
@@ -815,17 +904,59 @@ impl Game<'_> {
|
||||
let draws = screen.draw(&m, mouse, blink, background);
|
||||
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &draws);
|
||||
self.ui.chat_screen = Some(screen);
|
||||
// The hovered message part's tooltip, one row per line of its text.
|
||||
if let Some(hover) = self.ui.chat.hover_at(
|
||||
&self.gui.atlas.font,
|
||||
batch.height as i32,
|
||||
f64::from(self.cursor.0),
|
||||
f64::from(self.cursor.1),
|
||||
) {
|
||||
let lines = hover_rows(&hover);
|
||||
if self.ui.in_bed_chat && self.sleeping.is_some() {
|
||||
let (x, y, w, h) = Self::leave_bed_button(batch.width, batch.height);
|
||||
let label = Text::literal(horizon_client::lang::get_or(
|
||||
"multiplayer.stopSleeping",
|
||||
"Leave Bed",
|
||||
));
|
||||
let mut button = Vec::new();
|
||||
horizon_client::screen_draw::button(
|
||||
&mut button,
|
||||
&m,
|
||||
&label,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
true,
|
||||
horizon_client::screen_draw::inside(mouse, x, y, w, h),
|
||||
);
|
||||
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &button);
|
||||
}
|
||||
// The hovered message part's tooltip (`renderComponentHoverEffect`),
|
||||
// or the text of the message tag under the cursor.
|
||||
let height = batch.height as i32;
|
||||
let (cx, cy) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
|
||||
let wrap = (batch.width as i32 / 2).max(200);
|
||||
if let Some(style) = self.ui.chat.style_at(&self.gui.atlas.font, height, cx, cy) {
|
||||
if let Some(hover) = style.hover {
|
||||
let lines = self.wrapped_rows(&hover, wrap);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(stack) = style
|
||||
.hover_item
|
||||
.as_deref()
|
||||
.and_then(horizon_client::item_data::stack_from_hover)
|
||||
.filter(|s| self.res.items().get(&s.item).is_some())
|
||||
{
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
} else if self.ui.chat.indicator_at(height, cx, cy) {
|
||||
// GuiMessageTag.systemSinglePlayer's text, split at 210.
|
||||
let tag = Text::translate("chat.tag.system_single_player", &[]);
|
||||
let lines = self.wrapped_rows(&tag, 210);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
/// `Font.split`: a tooltip text wrapped at `width`, one white row per line.
|
||||
fn wrapped_rows(&self, text: &Text, width: i32) -> Vec<(Text, u32)> {
|
||||
let split = self
|
||||
.gui
|
||||
.atlas
|
||||
.font
|
||||
.split(&text.resolve_legacy(), width as f32);
|
||||
split.into_iter().map(|row| (row, u32::MAX)).collect()
|
||||
}
|
||||
/// Advance the overworld clock (ServerClockManager.tick) when
|
||||
/// `advance_time` allows it.
|
||||
pub(super) fn advance_clock(&mut self) {
|
||||
@@ -970,6 +1101,7 @@ impl Game<'_> {
|
||||
let cause = self.ui.death_cause.take();
|
||||
self.ui.died_at = Some(std::time::Instant::now());
|
||||
self.ui.death_text = None;
|
||||
self.ui.death_confirm = None;
|
||||
if !self.ui.rules.bool("show_death_messages") {
|
||||
return;
|
||||
}
|
||||
@@ -1078,26 +1210,3 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
|
||||
fn owner_permission(allow_commands: bool) -> u8 {
|
||||
if allow_commands { 4 } else { 0 }
|
||||
}
|
||||
|
||||
/// A hover text's rows for the tooltip: split at line breaks, each in the
|
||||
/// colour of its first styled character (white without one).
|
||||
fn hover_rows(hover: &horizon_client::text::Text) -> Vec<(String, u32)> {
|
||||
let mut rows = vec![(String::new(), None)];
|
||||
for (c, style) in hover.resolve_legacy().chars() {
|
||||
if c == '\n' {
|
||||
rows.push((String::new(), None));
|
||||
continue;
|
||||
}
|
||||
let row = rows.last_mut().expect("one row");
|
||||
if row.0.is_empty() {
|
||||
row.1 = style.color;
|
||||
}
|
||||
row.0.push(c);
|
||||
}
|
||||
rows.into_iter()
|
||||
.map(|(text, color)| {
|
||||
let [_, r, g, b] = color.unwrap_or(0xFF_FFFF).to_be_bytes();
|
||||
(text, horizon_client::hud::rgba(r, g, b, 255))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -42,6 +42,10 @@ pub(super) struct CombatTracker {
|
||||
/// The projectile hitting the player now is another player's that
|
||||
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
|
||||
pub(super) refuse_player_hit: bool,
|
||||
/// The player whose melee hit is landing on the player now: where they
|
||||
/// stand and the seconds their weapon disables a shield for
|
||||
/// (`getSecondsToDisableBlocking`).
|
||||
pub(super) player_attacker: Option<([f64; 3], f32)>,
|
||||
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
|
||||
/// fire decides whom the blast may hurt.
|
||||
pub(super) explosion_source: Option<u32>,
|
||||
|
||||
@@ -1162,6 +1162,17 @@ impl Game<'_> {
|
||||
// An air stack is empty: nothing is given (but the message still is).
|
||||
let empty = id == "minecraft:air";
|
||||
for &t in targets.iter().filter(|_| !empty) {
|
||||
// The player's UUID, which an item they could not hold is set to.
|
||||
let conn = match t {
|
||||
Target::Guest(conn) => Some(conn),
|
||||
_ => self.lan_acting,
|
||||
};
|
||||
let uuid = match conn {
|
||||
Some(conn) => self
|
||||
.command_guest(conn)
|
||||
.map(|g| horizon_client::lan::playerdata::uuid_ints(g.uuid)),
|
||||
None => Some(self.level.ensure_player_uuid()),
|
||||
};
|
||||
self.for_player(t, |g| {
|
||||
let mut remaining = count;
|
||||
while remaining > 0 {
|
||||
@@ -1178,8 +1189,19 @@ impl Game<'_> {
|
||||
.target_position(Target::Player)
|
||||
.unwrap_or(g.source_position());
|
||||
if let Some(rest) = left {
|
||||
// Inventory full: drop the rest at the player's feet without pickup delay.
|
||||
g.spawn_item(rest, [p[0], p[1] + 1.32, p[2]], [0.0, 0.0, 0.0], 0);
|
||||
// Inventory full: Player.createItemStackToDrop (thrown from
|
||||
// the eyes), then setNoPickUpDelay and setTarget(player).
|
||||
let eye = g.eye_position().unwrap_or([
|
||||
p[0],
|
||||
p[1] + g.target_eye_height(Target::Player),
|
||||
p[2],
|
||||
]);
|
||||
g.throw_from_eyes(rest, eye, false);
|
||||
if let Some(item) = g.dropped.last_mut() {
|
||||
item.thrower = None;
|
||||
item.pickup_delay = 0;
|
||||
item.target = uuid;
|
||||
}
|
||||
} else {
|
||||
let r = &mut g.world.random;
|
||||
let pitch = ((r.next_float() - r.next_float()) * 0.7 + 1.0) * 2.0;
|
||||
@@ -1447,7 +1469,7 @@ impl Game<'_> {
|
||||
{
|
||||
return Err(err("commands.teleport.invalidPosition", &[]));
|
||||
}
|
||||
let mut rotation = match ctx.get("rotation") {
|
||||
let rotation = match ctx.get("rotation") {
|
||||
Some(Arg::Coords(c)) => Some(c.rotation(self.source_rotation())),
|
||||
_ => None,
|
||||
};
|
||||
@@ -1462,21 +1484,31 @@ impl Game<'_> {
|
||||
(Some(f), _) => Some(f),
|
||||
(None, Some(_)) => {
|
||||
let e = self.entities(ctx, "facingEntity", false)?[0];
|
||||
// EntityAnchorArgument.Anchor.apply: the feet, or the eyes.
|
||||
let eyes = matches!(ctx.get("facingAnchor"), Some(Arg::Anchor(true)));
|
||||
let eye_height = if eyes { self.target_eye_height(e) } else { 0.0 };
|
||||
self.target_position(e)
|
||||
.map(|p| [p[0], p[1] + if eyes { 1.62 } else { 0.0 }, p[2]])
|
||||
.map(|p| [p[0], p[1] + eye_height, p[2]])
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(f) = facing {
|
||||
let eye = pos[1] + 1.62;
|
||||
// Entity.lookAt: from the source's anchor (the feet, unless `anchored
|
||||
// eyes`) on the teleported entity, so the eyes depend on the target.
|
||||
let anchored_eyes = self.command_source.is_some_and(|s| s.eyes);
|
||||
let look_at = |this: &Self, t: Target| {
|
||||
let f = facing?;
|
||||
let eye = if anchored_eyes {
|
||||
pos[1] + this.target_eye_height(t)
|
||||
} else {
|
||||
pos[1]
|
||||
};
|
||||
let (dx, dy, dz) = (f[0] - pos[0], f[1] - eye, f[2] - pos[2]);
|
||||
let horizontal = (dx * dx + dz * dz).sqrt();
|
||||
rotation = Some([
|
||||
Some([
|
||||
((dz.atan2(dx).to_degrees()) - 90.0) as f32,
|
||||
(-(dy.atan2(horizontal).to_degrees())) as f32,
|
||||
]);
|
||||
}
|
||||
])
|
||||
};
|
||||
// A server's player in another level than the source's (`execute
|
||||
// in`, the console's Overworld) moves there (ServerPlayer.teleport
|
||||
// into another level), after the command.
|
||||
@@ -1486,6 +1518,7 @@ impl Game<'_> {
|
||||
None => self.dim,
|
||||
};
|
||||
for &t in &targets {
|
||||
let rotation = look_at(self, t).or(rotation);
|
||||
if self.teleport_across(t, source_level, pos, rotation) {
|
||||
continue;
|
||||
}
|
||||
@@ -2210,7 +2243,8 @@ impl Game<'_> {
|
||||
let dmax = [dest[0] + size[0], dest[1] + size[1], dest[2] + size[2]];
|
||||
let overlap = (0..3).all(|i| dest[i] <= max[i] && dmax[i] >= min[i]);
|
||||
let moving = ctx.has("move");
|
||||
if !ctx.has("force") && overlap {
|
||||
// Mode.canOverlap: only the normal mode refuses overlapping regions.
|
||||
if !ctx.has("force") && !moving && overlap {
|
||||
return Err(err("commands.clone.overlap", &[]));
|
||||
}
|
||||
let area = (0..3).map(|i| i64::from(size[i] + 1)).product::<i64>();
|
||||
@@ -2419,26 +2453,54 @@ impl Game<'_> {
|
||||
let levels = ctx.has("levels");
|
||||
let unit = if levels { "levels" } else { "points" };
|
||||
if ctx.has("query") {
|
||||
let _ = self.entities(ctx, "target", true)?;
|
||||
let e = &self.experience;
|
||||
let value = if levels {
|
||||
e.level
|
||||
} else {
|
||||
(e.progress * horizon_client::entities::Experience::needed(e.level) as f32) as i32
|
||||
};
|
||||
let target = self.entities(ctx, "target", true)?[0];
|
||||
// ExperienceCommand.queryExperience: the target's own level or points.
|
||||
let value = self
|
||||
.for_player(target, |g| {
|
||||
let e = &g.experience;
|
||||
if levels {
|
||||
e.level
|
||||
} else {
|
||||
(e.progress * horizon_client::entities::Experience::needed(e.level) as f32)
|
||||
as i32
|
||||
}
|
||||
})
|
||||
.unwrap_or(0);
|
||||
self.system_message(Text::translate(
|
||||
&format!("commands.experience.query.{unit}"),
|
||||
&[
|
||||
Text::literal(self.acting_player_name()),
|
||||
Text::literal(value.to_string()),
|
||||
],
|
||||
&[self.target_name(target), Text::literal(value.to_string())],
|
||||
));
|
||||
return Ok(value);
|
||||
}
|
||||
let targets = self.entities(ctx, "target", true)?;
|
||||
let amount = Self::int(ctx, "amount").unwrap_or(0);
|
||||
let op = if ctx.has("add") { "add" } else { "set" };
|
||||
if op == "add" {
|
||||
let add = ctx.has("add");
|
||||
let op = if add { "add" } else { "set" };
|
||||
// Every target changes (a set of points can be refused for some).
|
||||
let changed: Vec<Target> = targets
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&t| {
|
||||
self.for_player(t, |g| g.change_experience(add, levels, amount)) == Some(true)
|
||||
})
|
||||
.collect();
|
||||
if changed.is_empty() {
|
||||
return Err(err("commands.experience.set.points.invalid", &[]));
|
||||
}
|
||||
let (key, who) = match changed[..] {
|
||||
[only] => ("single", self.target_name(only)),
|
||||
_ => ("multiple", Text::literal(changed.len().to_string())),
|
||||
};
|
||||
self.feedback(Text::translate(
|
||||
&format!("commands.experience.{op}.{unit}.success.{key}"),
|
||||
&[Text::literal(amount.to_string()), who],
|
||||
));
|
||||
Ok(changed.len() as i32)
|
||||
}
|
||||
/// ExperienceCommand's add and set on the player in place: false when
|
||||
/// the points to set do not fit the level.
|
||||
fn change_experience(&mut self, add: bool, levels: bool, amount: i32) -> bool {
|
||||
if add {
|
||||
if levels {
|
||||
self.experience.level = (self.experience.level + amount).max(0);
|
||||
if self.experience.level == 0 && amount < 0 {
|
||||
@@ -2452,26 +2514,11 @@ impl Game<'_> {
|
||||
} else {
|
||||
let needed = horizon_client::entities::Experience::needed(self.experience.level);
|
||||
if amount > needed - 1 {
|
||||
return Err(err("commands.experience.set.points.invalid", &[]));
|
||||
return false;
|
||||
}
|
||||
self.experience.progress = amount as f32 / needed as f32;
|
||||
}
|
||||
let key = format!(
|
||||
"commands.experience.{op}.{unit}.success.{}",
|
||||
if targets.len() == 1 {
|
||||
"single"
|
||||
} else {
|
||||
"multiple"
|
||||
}
|
||||
);
|
||||
self.feedback(Text::translate(
|
||||
&key,
|
||||
&[
|
||||
Text::literal(amount.to_string()),
|
||||
Text::literal(self.acting_player_name()),
|
||||
],
|
||||
));
|
||||
Ok(targets.len() as i32)
|
||||
true
|
||||
}
|
||||
fn cmd_gamerule(&mut self, ctx: &Context) -> R<i32> {
|
||||
let Some(name) = ctx.literals.get(1).cloned() else {
|
||||
@@ -2556,21 +2603,26 @@ impl Game<'_> {
|
||||
};
|
||||
let yaw = ((rot[0] + 180.0).rem_euclid(360.0)) - 180.0;
|
||||
let pitch = rot[1].clamp(-90.0, 90.0);
|
||||
// SetSpawnCommand: a forced point in the source's level.
|
||||
if self.dim == Dim::Overworld {
|
||||
self.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
self.respawn_config = None;
|
||||
} else {
|
||||
self.spawn = None;
|
||||
self.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: self.dim,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
// SetSpawnCommand: a forced point in the source's level, for each target.
|
||||
let level = self.dim;
|
||||
for &t in &targets {
|
||||
self.for_player(t, |g| {
|
||||
if level == Dim::Overworld {
|
||||
g.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
g.respawn_config = None;
|
||||
} else {
|
||||
g.spawn = None;
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: level,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
let dim = Text::literal(self.dim.id());
|
||||
@@ -2750,6 +2802,44 @@ impl Game<'_> {
|
||||
},
|
||||
};
|
||||
let max = Self::int(ctx, "maxCount").unwrap_or(-1);
|
||||
let counting = max == 0;
|
||||
// ClearInventoryCommands.clearInventory: each player on their own.
|
||||
let mut removed = 0i32;
|
||||
let mut found = Vec::new();
|
||||
for &t in &targets {
|
||||
let n = self
|
||||
.for_player(t, |g| g.clear_matching(&predicate, max))
|
||||
.unwrap_or(0);
|
||||
removed += n;
|
||||
if n != 0 {
|
||||
found.push(t);
|
||||
}
|
||||
}
|
||||
if removed == 0 {
|
||||
return Err(if targets.len() == 1 {
|
||||
CommandError::new(Text::translate(
|
||||
"clear.failed.single",
|
||||
&[self.target_name(targets[0])],
|
||||
))
|
||||
} else {
|
||||
err("clear.failed.multiple", &[&targets.len()])
|
||||
});
|
||||
}
|
||||
let kind = if counting { "test" } else { "success" };
|
||||
let (key, who) = match found[..] {
|
||||
[only] => ("single", self.target_name(only)),
|
||||
_ => ("multiple", Text::literal(found.len().to_string())),
|
||||
};
|
||||
self.feedback(Text::translate(
|
||||
&format!("commands.clear.{kind}.{key}"),
|
||||
&[Text::literal(removed.to_string()), who],
|
||||
));
|
||||
Ok(removed)
|
||||
}
|
||||
/// Inventory.clearOrCountMatchingItems for the player in place: `max`
|
||||
/// of the matching items go (all when negative, none and only counted
|
||||
/// when 0). Returns how many matched.
|
||||
fn clear_matching(&mut self, predicate: &ItemInput, max: i32) -> i32 {
|
||||
let counting = max == 0;
|
||||
// Inventory slots 0..40: main, armor feet to head, off hand;
|
||||
// then clearOrCountMatchingItems visits the crafting grid and carried stack.
|
||||
@@ -2765,10 +2855,7 @@ impl Game<'_> {
|
||||
};
|
||||
let matching: Vec<bool> = all(self)
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.as_ref()
|
||||
.is_some_and(|s| self.stack_matches(&predicate, s))
|
||||
})
|
||||
.map(|s| s.as_ref().is_some_and(|s| self.stack_matches(predicate, s)))
|
||||
.collect();
|
||||
let mut slots: Vec<&mut Option<ItemStack>> = Vec::new();
|
||||
slots.extend(self.inventory.iter_mut());
|
||||
@@ -2798,25 +2885,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if removed == 0 {
|
||||
return Err(if targets.len() == 1 {
|
||||
CommandError::new(Text::translate(
|
||||
"clear.failed.single",
|
||||
&[Text::literal(self.acting_player_name())],
|
||||
))
|
||||
} else {
|
||||
err("clear.failed.multiple", &[&targets.len()])
|
||||
});
|
||||
}
|
||||
let kind = if counting { "test" } else { "success" };
|
||||
self.feedback(Text::translate(
|
||||
&format!("commands.clear.{kind}.single"),
|
||||
&[
|
||||
Text::literal(removed.to_string()),
|
||||
Text::literal(self.acting_player_name()),
|
||||
],
|
||||
));
|
||||
Ok(removed)
|
||||
removed
|
||||
}
|
||||
/// `PoiTypes`: the point of interest type of a block state.
|
||||
fn poi_type(&self, state: horizon_world::registry::BlockStateId) -> Option<&'static str> {
|
||||
|
||||
@@ -105,6 +105,7 @@ impl Game<'_> {
|
||||
match ty.trim_start_matches("minecraft:") {
|
||||
"player_attack" => DamageKind::PlayerAttack,
|
||||
"mace_smash" => DamageKind::MaceSmash,
|
||||
"spear" => DamageKind::Spear,
|
||||
"mob_attack" | "mob_attack_no_aggro" => DamageKind::MobAttack,
|
||||
"arrow" => DamageKind::Arrow,
|
||||
"thrown" | "trident" | "mob_projectile" => DamageKind::Thrown,
|
||||
@@ -118,7 +119,8 @@ impl Game<'_> {
|
||||
"dry_out" => DamageKind::DryOut,
|
||||
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
|
||||
"sting" => DamageKind::Sting,
|
||||
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
|
||||
"cactus" => DamageKind::Cactus,
|
||||
"sweet_berry_bush" => DamageKind::SweetBerryBush,
|
||||
"freeze" => DamageKind::Freeze,
|
||||
"in_wall" => DamageKind::InWall,
|
||||
"thorns" => DamageKind::Thorns,
|
||||
@@ -1219,7 +1221,7 @@ impl Game<'_> {
|
||||
/// LivingEntity.stopRiding for a mob: off its vehicle
|
||||
/// (Entity.removePassenger), then to where the vehicle lets it out
|
||||
/// (LivingEntity.dismountVehicle).
|
||||
fn dismount_mob(&mut self, rider: u32) {
|
||||
pub(in crate::game) fn dismount_mob(&mut self, rider: u32) {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == rider) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -204,6 +204,64 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// ---- interactions -----------------------------------------------------
|
||||
/// `WeatheringCopperGolemStatueBlock.useItemOn`: an axe on an unoxidized
|
||||
/// statue turns it back into a golem (`CopperGolemStatueBlockEntity.removeStatue`),
|
||||
/// wearing the axe. Weathered statues are scraped by the axe's own
|
||||
/// transform instead.
|
||||
pub(super) fn use_golem_statue(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
if !held.is_some_and(|s| s.item.ends_with("_axe")) {
|
||||
return false;
|
||||
}
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(state) = registry.state(self.world.block(pos[0], pos[1], pos[2])) else {
|
||||
return false;
|
||||
};
|
||||
if state.name != "minecraft:copper_golem_statue" {
|
||||
return false;
|
||||
}
|
||||
// `Direction.toYRot` of the statue's facing.
|
||||
let yaw = match state.properties.get("facing").map(String::as_str) {
|
||||
Some("west") => 90.0,
|
||||
Some("north") => 180.0,
|
||||
Some("east") => 270.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
let waterlogged = state
|
||||
.properties
|
||||
.get("waterlogged")
|
||||
.is_some_and(|w| w == "true");
|
||||
// initCopperGolem: at the block's feet, facing as the statue does.
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let Some(id) = self.spawn_mob("minecraft:copper_golem", at, true) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.yaw = yaw;
|
||||
m.previous_yaw = yaw;
|
||||
m.body_yaw = yaw;
|
||||
m.previous_body_yaw = yaw;
|
||||
m.head_yaw = yaw;
|
||||
m.previous_head_yaw = yaw;
|
||||
}
|
||||
self.damage_held(1);
|
||||
self.audio
|
||||
.play("minecraft:entity.copper_golem.spawn", Some(at), 1.0, 1.0);
|
||||
// Level.removeBlock(pos, false): air, or the water the statue stood in.
|
||||
let replacement = registry.default_state(if waterlogged {
|
||||
"minecraft:water"
|
||||
} else {
|
||||
"minecraft:air"
|
||||
});
|
||||
if let Some(replacement) = replacement {
|
||||
let _ = self.level_set(pos, replacement);
|
||||
}
|
||||
self.world.block_entities.remove(&(pos[0], pos[1], pos[2]));
|
||||
true
|
||||
}
|
||||
/// `CopperGolem.mobInteract`; true when handled.
|
||||
pub(super) fn interact_copper_golem(&mut self, index: usize, item: &str) -> bool {
|
||||
if self.mobs[index].species() != Species::CopperGolem || !self.mobs[index].alive() {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! The creative toolbars, `hotbar.nbt` beside `options.txt`: hold the save or
|
||||
//! load key and press a hotbar key (`Minecraft.handleKeybinds`).
|
||||
use super::Game;
|
||||
use horizon_client::{hotbar, keybinds, text::Text};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether the key bound to `mapping` is held.
|
||||
pub(super) fn mapped_key_down(&self, mapping: &str) -> bool {
|
||||
keybinds::key_code(self.settings.keys.get(mapping))
|
||||
.is_some_and(|k| self.physical_keys.contains(&k))
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.handleHotbarLoadOrSave` for the hotbar key
|
||||
/// `index`: load puts the saved row in the hotbar (the creative slot
|
||||
/// packets follow from the inventory change), save stores the hotbar and
|
||||
/// says so on the action bar.
|
||||
pub(super) fn hotbar_load_or_save(&mut self, index: usize, load: bool) {
|
||||
let path = Self::options_path().with_file_name("hotbar.nbt");
|
||||
let known = |id: &str| self.res.items().get(id).is_some();
|
||||
let mut rows = hotbar::load(&path, &known);
|
||||
if load {
|
||||
for (slot, stack) in rows[index].iter().enumerate() {
|
||||
self.inventory[slot] = stack.clone();
|
||||
}
|
||||
return;
|
||||
}
|
||||
rows[index] = self.inventory[..hotbar::SIZE].to_vec();
|
||||
let _ = hotbar::save(&path, &rows);
|
||||
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping));
|
||||
let message = Text::translate(
|
||||
"inventory.hotbarSaved",
|
||||
&[
|
||||
key("key.loadToolbarActivator"),
|
||||
key(&format!("key.hotbar.{}", index + 1)),
|
||||
],
|
||||
);
|
||||
self.titles.set_overlay(message, false);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ use horizon_client::{
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3, traverse_blocks},
|
||||
geometry::{Aabb, Direction, Vec3, traverse_blocks},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
@@ -46,9 +46,16 @@ pub(super) struct Creatures {
|
||||
pub projectiles: Vec<Projectile>,
|
||||
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
|
||||
pub wind_bursts: Vec<super::wind::Burst>,
|
||||
/// The players a fishing hook can catch, taken as the hooks tick, and
|
||||
/// the pulls on them waiting for the hub (game/fishing.rs).
|
||||
pub hook_targets: Vec<super::fishing::HookTarget>,
|
||||
pub hook_pulls: Vec<(u32, horizon_core::geometry::Vec3)>,
|
||||
/// Mace smash knockback on the other players from a guest's turn, waiting
|
||||
/// for it to end (game/mace.rs).
|
||||
pub smash_pushes: Vec<super::mace::SmashPush>,
|
||||
/// Sweep attacks from a guest's turn, waiting for it to end
|
||||
/// (game/lan_pvp.rs).
|
||||
pub sweeps: Vec<super::lan_pvp::Sweep>,
|
||||
/// Explosion packets from a guest's turn, waiting for it to end
|
||||
/// (centre, small, radius, blocks hit; game/lan_particles.rs).
|
||||
pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>,
|
||||
@@ -56,6 +63,8 @@ pub(super) struct Creatures {
|
||||
pub elder_apparition: i32,
|
||||
/// The wandering trader spawner's clock.
|
||||
pub trader: super::wandering_trader::Spawner,
|
||||
/// When each player last put a parrot on a shoulder (`timeEntitySatOnShoulder`).
|
||||
pub shoulder_time: std::collections::HashMap<[i32; 4], i64>,
|
||||
/// The cat spawner's countdown (`CatSpawner.nextTick`).
|
||||
pub cat_tick: i32,
|
||||
/// Particles from a guest's turn, waiting for it to end
|
||||
@@ -201,6 +210,7 @@ impl Game<'_> {
|
||||
// EntitySelector.NO_CREATIVE_OR_SPECTATOR.
|
||||
creative: self.creative || self.spectator(),
|
||||
sneaking: self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft),
|
||||
swimming: p.swimming,
|
||||
look: view_vector(self.yaw, self.pitch),
|
||||
main_hand: self.held().map(|s| s.item.clone()),
|
||||
off_hand: None,
|
||||
@@ -220,6 +230,8 @@ impl Game<'_> {
|
||||
uuid: self.level.player_uuid.unwrap_or_default(),
|
||||
allies: std::sync::Arc::new(self.player_allies()),
|
||||
remote: false,
|
||||
sleeping: self.sleeping.is_some(),
|
||||
sleep_timer: self.sleep_timer,
|
||||
passenger: self.riding.is_some(),
|
||||
})
|
||||
}
|
||||
@@ -450,9 +462,10 @@ impl Game<'_> {
|
||||
teams: std::sync::Arc::new(self.mob_teams()),
|
||||
items: Some(self.res.items()),
|
||||
lid_openers: &self.lid_openers,
|
||||
// The first player in the level: a server has no player of its own.
|
||||
difficulty: self.local_difficulty(
|
||||
self.player
|
||||
.as_ref()
|
||||
self.level_players()
|
||||
.first()
|
||||
.map_or([0.0; 3], |p| [p.position.x, p.position.y, p.position.z]),
|
||||
),
|
||||
}
|
||||
@@ -507,6 +520,7 @@ impl Game<'_> {
|
||||
self.tick_wandering_trader();
|
||||
self.tick_patrol_spawner();
|
||||
self.tick_cat_spawner();
|
||||
self.tick_village_siege();
|
||||
}
|
||||
self.tick_raids();
|
||||
self.tick_creature_entities()
|
||||
@@ -772,11 +786,12 @@ impl Game<'_> {
|
||||
let by_player = killer.is_some() || m.ai.last_hurt_by_player > 0;
|
||||
let mut dying = m.clone();
|
||||
// LivingEntity.die: the death is heard, and a sculk catalyst nearby
|
||||
// takes the experience (before the loot is dropped).
|
||||
// takes the experience (before the loot is dropped). It takes what
|
||||
// would drop, whoever killed the mob.
|
||||
self.sculk_mob_died(m);
|
||||
let consumed =
|
||||
self.catalyst_takes_experience([m.position.x, m.position.y, m.position.z], |_| {
|
||||
if by_player {
|
||||
if horizon_client::mobs::drops::drops_experience(&dying) {
|
||||
horizon_client::mobs::drops::base_experience(&mut dying).max(0) as u32
|
||||
} else {
|
||||
0
|
||||
@@ -936,6 +951,55 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Melee or ranged damage to another mob; whether the hit landed.
|
||||
fn mob_hits_mob(&mut self, target: u32, amount: f32, source: Damage) -> bool {
|
||||
let mut killed = false;
|
||||
let mut hurt = false;
|
||||
let mut infested = false;
|
||||
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
|
||||
let hit = t.damage(amount, source);
|
||||
hurt = hit;
|
||||
if hit {
|
||||
killed = !t.alive();
|
||||
// The hurt or death sound the mob raised.
|
||||
let center = t.center();
|
||||
for event in std::mem::take(&mut t.outbox) {
|
||||
match event {
|
||||
MobEvent::Sound(s, _, volume, pitch) => {
|
||||
self.audio
|
||||
.play(&format!("minecraft:{s}"), Some(center), volume, pitch);
|
||||
}
|
||||
MobEvent::Infested { .. } => infested = true,
|
||||
other => {
|
||||
// Left for the end of the tick.
|
||||
t.outbox.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if infested {
|
||||
self.mob_infested(target);
|
||||
}
|
||||
if hurt
|
||||
&& let Some(Who::Mob(attacker)) = source.attacker
|
||||
&& let Some(m) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == attacker && m.species() == Species::Axolotl)
|
||||
{
|
||||
self.audio.play(
|
||||
"minecraft:entity.axolotl.attack",
|
||||
Some([m.position.x, m.position.y, m.position.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
if killed && let Some(Who::Mob(killer)) = source.attacker {
|
||||
self.zombie_killed(killer, target);
|
||||
}
|
||||
hurt
|
||||
}
|
||||
/// `InfestedMobEffect.onMobHurt` for a mob: silverfish from half its height up.
|
||||
pub(super) fn mob_infested(&mut self, mob: u32) {
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
|
||||
@@ -1015,6 +1079,20 @@ impl Game<'_> {
|
||||
MobEvent::EntityEvent { mob, event, at } => {
|
||||
self.lan_entity_event(mob as i32, event, at)
|
||||
}
|
||||
MobEvent::LevelParticles {
|
||||
kind,
|
||||
at,
|
||||
spread,
|
||||
speed,
|
||||
count,
|
||||
} => {
|
||||
if let Some(ty) = horizon_client::particles::ParticleType::from_name(kind) {
|
||||
let options = horizon_client::particles::ParticleOptions::simple(ty);
|
||||
self.particles
|
||||
.send_particles(&options, at, spread, speed, count);
|
||||
self.lan_level_particles(&options, at, spread, speed, count);
|
||||
}
|
||||
}
|
||||
MobEvent::Sound(name, at, volume, pitch)
|
||||
| MobEvent::LocalSound(name, at, volume, pitch) => {
|
||||
self.audio
|
||||
@@ -1043,6 +1121,18 @@ impl Game<'_> {
|
||||
MobEvent::AttackPlayer(amount, from) => {
|
||||
self.mob_hits_player(amount, from, "minecraft:mob_attack");
|
||||
}
|
||||
// doHurtTarget: the effect only when hurtServer took damage.
|
||||
MobEvent::AttackPlayerWithEffect {
|
||||
amount,
|
||||
from,
|
||||
effect,
|
||||
ticks,
|
||||
amplifier,
|
||||
} => {
|
||||
if self.mob_hits_player(amount, from, "minecraft:mob_attack") {
|
||||
self.give_player_effect(effect, ticks, amplifier);
|
||||
}
|
||||
}
|
||||
MobEvent::DamagePlayer { amount, from, kind } => {
|
||||
self.mob_hits_player(amount, from, kind);
|
||||
}
|
||||
@@ -1141,54 +1231,48 @@ impl Game<'_> {
|
||||
amount,
|
||||
source,
|
||||
} => {
|
||||
let mut killed = false;
|
||||
let mut hurt = false;
|
||||
let mut infested = false;
|
||||
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
|
||||
let hit = t.damage(amount, source);
|
||||
hurt = hit;
|
||||
if hit {
|
||||
killed = !t.alive();
|
||||
// The hurt or death sound the mob raised.
|
||||
let center = t.center();
|
||||
for event in std::mem::take(&mut t.outbox) {
|
||||
match event {
|
||||
MobEvent::Sound(s, _, volume, pitch) => {
|
||||
self.audio.play(
|
||||
&format!("minecraft:{s}"),
|
||||
Some(center),
|
||||
volume,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
MobEvent::Infested { .. } => infested = true,
|
||||
other => {
|
||||
// Left for the end of the tick.
|
||||
t.outbox.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.mob_hits_mob(target, amount, source);
|
||||
}
|
||||
MobEvent::SonicBoom {
|
||||
target,
|
||||
attacker,
|
||||
from,
|
||||
direction,
|
||||
} => {
|
||||
// SonicBoom: the push only follows a hit that landed, scaled
|
||||
// by the target's KNOCKBACK_RESISTANCE.
|
||||
let (hurt, resistance) = match target {
|
||||
Who::Player => {
|
||||
let hurt = self.mob_hits_player(10.0, from, "minecraft:sonic_boom");
|
||||
let resistance = self.player.as_mut().map_or(0.0, |p| {
|
||||
p.attribute("minecraft:knockback_resistance").unwrap_or(0.0)
|
||||
});
|
||||
(hurt, resistance)
|
||||
}
|
||||
}
|
||||
if infested {
|
||||
self.mob_infested(target);
|
||||
}
|
||||
if hurt
|
||||
&& let Some(Who::Mob(attacker)) = source.attacker
|
||||
&& let Some(m) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == attacker && m.species() == Species::Axolotl)
|
||||
{
|
||||
self.audio.play(
|
||||
"minecraft:entity.axolotl.attack",
|
||||
Some([m.position.x, m.position.y, m.position.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
if killed && let Some(Who::Mob(killer)) = source.attacker {
|
||||
self.zombie_killed(killer, target);
|
||||
Who::Mob(id) => {
|
||||
let source = Damage {
|
||||
kind: DamageKind::SonicBoom,
|
||||
attacker: Some(Who::Mob(attacker)),
|
||||
from: Some(from),
|
||||
};
|
||||
let hurt = self.mob_hits_mob(id, 10.0, source);
|
||||
let resistance = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map_or(0.0, |m| self.mob_knockback_resistance(m));
|
||||
(hurt, resistance)
|
||||
}
|
||||
};
|
||||
if hurt {
|
||||
let horizontal = 2.5 * (1.0 - resistance);
|
||||
let vertical = 0.5 * (1.0 - resistance);
|
||||
let velocity = [
|
||||
direction[0] * horizontal,
|
||||
direction[1] * vertical,
|
||||
direction[2] * horizontal,
|
||||
];
|
||||
self.handle_mob_events(vec![MobEvent::Push { target, velocity }])?;
|
||||
}
|
||||
}
|
||||
MobEvent::FinishAdmiring { mob } => self.finish_admiring(mob),
|
||||
@@ -1218,6 +1302,7 @@ impl Game<'_> {
|
||||
}
|
||||
MobEvent::BecomeStatue { mob } => self.golem_become_statue(mob),
|
||||
MobEvent::EnterHive { mob, hive } => self.bee_enter_hive(mob, hive),
|
||||
MobEvent::ParrotToShoulder { mob } => self.parrot_to_shoulder(mob),
|
||||
MobEvent::Infested { mob } => self.mob_infested(mob),
|
||||
MobEvent::Gift { target, item } => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == target)
|
||||
@@ -1236,6 +1321,14 @@ impl Game<'_> {
|
||||
m.ai.target = Some(target);
|
||||
}
|
||||
}
|
||||
MobEvent::HuntHoglin { mob, target, ticks } => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
m.extra.ints[0] = ticks;
|
||||
if m.ai.target.is_none() {
|
||||
m.ai.target = Some(Who::Mob(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::Projectile(mut p) => {
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
@@ -1269,6 +1362,22 @@ impl Game<'_> {
|
||||
[0.0, 0.2, 0.0],
|
||||
10,
|
||||
),
|
||||
MobEvent::DropGift { table, at } => {
|
||||
let stacks =
|
||||
self.res
|
||||
.items()
|
||||
.loot_table(table, false, None, &mut self.world.random);
|
||||
for stack in stacks {
|
||||
// The ItemEntity constructor's random motion.
|
||||
let r = &mut self.world.random;
|
||||
let v = [
|
||||
r.next_double() * 0.2 - 0.1,
|
||||
0.2,
|
||||
r.next_double() * 0.2 - 0.1,
|
||||
];
|
||||
self.spawn_item(stack, at, v, 10);
|
||||
}
|
||||
}
|
||||
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
|
||||
// above trampled farmland breaks and drops.
|
||||
MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?,
|
||||
@@ -1331,8 +1440,50 @@ impl Game<'_> {
|
||||
self.world.set(pos[0], pos[1], pos[2], state)?;
|
||||
}
|
||||
}
|
||||
MobEvent::Infest {
|
||||
pos,
|
||||
infested,
|
||||
poof,
|
||||
} => {
|
||||
let registry = &self.pack.assets.registry;
|
||||
let old = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if let Some(mut state) = registry.state(old).cloned() {
|
||||
let (from, to) = if infested {
|
||||
("minecraft:", "minecraft:infested_")
|
||||
} else {
|
||||
("minecraft:infested_", "minecraft:")
|
||||
};
|
||||
state.name = state.name.replacen(from, to, 1);
|
||||
if let Ok(id) = registry.resolve(&state) {
|
||||
self.world.set(pos[0], pos[1], pos[2], id)?;
|
||||
}
|
||||
}
|
||||
if let Some(mob) = poof {
|
||||
self.mob_poof(mob);
|
||||
}
|
||||
}
|
||||
MobEvent::BreakBlock { pos, drop } => {
|
||||
let old = self.world.block(pos[0], pos[1], pos[2]);
|
||||
// InfestedBlock.spawnAfterBreak: its silverfish comes out
|
||||
// when blocks drop.
|
||||
if drop
|
||||
&& self
|
||||
.res
|
||||
.facts()
|
||||
.get(old)
|
||||
.name
|
||||
.starts_with("minecraft:infested_")
|
||||
&& self.ui.rules.bool("block_drops")
|
||||
{
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
if let Some(id) = self.spawn_mob("minecraft:silverfish", at, false) {
|
||||
self.mob_poof(id);
|
||||
}
|
||||
}
|
||||
// Block.spawnDestroyParticles: fire breaks leave nothing.
|
||||
if !self.res.facts().get(old).has_class("BaseFireBlock") {
|
||||
self.destroy_particles(pos, old);
|
||||
@@ -1420,6 +1571,27 @@ impl Game<'_> {
|
||||
.list
|
||||
.retain(|e| e.id != "mining_fatigue");
|
||||
}
|
||||
MobEvent::DolphinTreasure { mob } => {
|
||||
// ServerLevel.findNearestMapStructure(#dolphin_located, 50).
|
||||
let Some(at) = self.mobs.iter().find(|m| m.id == mob).map(|m| {
|
||||
[
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
]
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let names = self.structure_tag("minecraft:dolphin_located");
|
||||
let found = self
|
||||
.end
|
||||
.locator()
|
||||
.filter(|_| !names.is_empty())
|
||||
.and_then(|t| t.locate(&names, at, 50));
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
m.extra.home = found.map(|(_, p)| p);
|
||||
}
|
||||
}
|
||||
MobEvent::EffectPlayer {
|
||||
effect,
|
||||
ticks,
|
||||
@@ -2344,6 +2516,22 @@ impl Game<'_> {
|
||||
8.0,
|
||||
)
|
||||
}
|
||||
/// `BellBlock.onProjectileHit`: a projectile that strikes the bell proper
|
||||
/// rings it, and a player's shot counts as the player's ring.
|
||||
fn bell_projectile_hit(&mut self, p: &Projectile, pos: [i32; 3], at: Vec3, face: [i32; 3]) {
|
||||
let Some(face) = Direction::ALL.into_iter().find(|d| d.normal() == face) else {
|
||||
return;
|
||||
};
|
||||
let block = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let rang = self
|
||||
.with_level(|b, level, ticks| b.bell_projectile_hit(level, ticks, block, face, at.y));
|
||||
if rang {
|
||||
let _ = self.process_level_events();
|
||||
if p.owner == Some(Who::Player) {
|
||||
self.stats.custom("bell_ring", 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 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};
|
||||
@@ -2486,6 +2674,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
|
||||
// Projectile.onHitBlock: BlockState.onProjectileHit, for every
|
||||
// projectile (a wind charge out of the world's top has no face).
|
||||
if let Hit::Block { pos, at, face } = hit
|
||||
&& face != [0; 3]
|
||||
{
|
||||
self.bell_projectile_hit(p, pos, at, face);
|
||||
}
|
||||
self.combat.projectile_owner = match p.owner {
|
||||
Some(Who::Mob(id)) => Some(id),
|
||||
_ => None,
|
||||
@@ -2660,6 +2855,17 @@ impl Game<'_> {
|
||||
};
|
||||
self.player_hit(&victim, "minecraft:arrow", Some(direct), amount);
|
||||
}
|
||||
// AbstractArrow.onHitEntity: KILLED_BY_ARROW for a kill
|
||||
// (piercing arrows are not modelled).
|
||||
if hurt
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
|
||||
&& !m.alive()
|
||||
{
|
||||
let victim = self.mob_entity(m);
|
||||
let weapon =
|
||||
p.arrow.as_ref().and_then(|a| a.fired_from_weapon.as_ref());
|
||||
self.trigger_killed_by_arrow(&[victim], weapon);
|
||||
}
|
||||
}
|
||||
hurt
|
||||
}
|
||||
@@ -2963,7 +3169,7 @@ impl Game<'_> {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
&& m.damage(4.0, source)
|
||||
{
|
||||
m.extra.levitation = 200;
|
||||
m.add_effect("minecraft:levitation", 200, 0);
|
||||
// Shulker.hurtServer: a shulker hit by a bullet.
|
||||
horizon_client::mobs::shulker::hit_by_bullet(m);
|
||||
}
|
||||
@@ -3127,6 +3333,10 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.interact_shear_mob(index, &item) {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
// AbstractHorse.mobInteract: feed, equip or ride.
|
||||
if self.interact_mount(index, &item) {
|
||||
self.swing_arm();
|
||||
@@ -3151,18 +3361,37 @@ impl Game<'_> {
|
||||
.item_tags
|
||||
.get("minecraft:creeper_igniters")
|
||||
.is_some_and(|t| t.contains(&*item));
|
||||
let fish = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:fishes")
|
||||
.is_some_and(|t| t.contains(&*item));
|
||||
let damageable = self
|
||||
.res
|
||||
.items()
|
||||
.get(&item)
|
||||
.is_some_and(|i| i.max_damage > 0);
|
||||
let parrot_poisonous = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:parrot_poisonous_food")
|
||||
.is_some_and(|t| t.contains(&*item));
|
||||
let player_at = self.eye_position();
|
||||
let acting = self.acting_player_uuid();
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|
||||
|| self.keys.contains(&winit::keyboard::KeyCode::ShiftRight);
|
||||
let held_damage = held.as_ref().map_or(0, |h| h.damage as i32);
|
||||
let held_extra = held.as_ref().and_then(|h| h.extra.clone());
|
||||
let m = &mut self.mobs[index];
|
||||
if !m.alive() {
|
||||
return Ok(false);
|
||||
}
|
||||
let species = m.species();
|
||||
let center = m.center();
|
||||
// Wolf armor the owner takes off with shears or leaves a dropped stack of.
|
||||
let mut armor_drop: Option<ItemStack> = None;
|
||||
let mut consume = false;
|
||||
let mut give: Option<&str> = None;
|
||||
let mut sound: Option<&str> = None;
|
||||
@@ -3247,6 +3476,23 @@ impl Game<'_> {
|
||||
handled = false;
|
||||
}
|
||||
}
|
||||
// Entity.interact: shears take a tamed wolf's armor off for its owner.
|
||||
Species::Wolf
|
||||
if item == "minecraft:shears"
|
||||
&& m.extra.tame
|
||||
&& m.extra.owner == Some(acting)
|
||||
&& m.extra.head.is_some()
|
||||
&& !sneaking =>
|
||||
{
|
||||
let mut stack = ItemStack::new("minecraft:wolf_armor", 1);
|
||||
stack.damage = m.extra.body_damage.max(0) as u32;
|
||||
stack.extra = m.extra.body_extra.take();
|
||||
armor_drop = Some(stack);
|
||||
m.extra.head = None;
|
||||
m.extra.body_damage = 0;
|
||||
eat = Some(("item.armor.unequip_wolf", 1.0, 1.0));
|
||||
self.damage_held_tool();
|
||||
}
|
||||
// Wolf.mobInteract: heal, dye the collar or sit/stand when tame;
|
||||
// a bone tames a wild one a third of the time.
|
||||
Species::Wolf if m.extra.tame && is_food && m.health < m.attrs.max_health => {
|
||||
@@ -3255,11 +3501,42 @@ impl Game<'_> {
|
||||
.items()
|
||||
.get(&item)
|
||||
.and_then(|i| i.food.as_ref())
|
||||
.map_or(2.0, |f| f.0 as f32);
|
||||
// TamableAnimal.feed: playEatingSound, which wolves leave empty.
|
||||
.map_or(2.0, |f| f.0 as f32 * 2.0);
|
||||
// TamableAnimal.feed(.., 2.0F, 2.0F): heals twice the nutrition,
|
||||
// then playEatingSound, which wolves leave empty.
|
||||
m.health = (m.health + heal).min(m.attrs.max_health);
|
||||
consume = true;
|
||||
}
|
||||
// Wolf.mobInteract: the owner puts wolf armor on a tamed adult.
|
||||
Species::Wolf
|
||||
if item == "minecraft:wolf_armor"
|
||||
&& m.extra.tame
|
||||
&& m.extra.owner == Some(acting)
|
||||
&& m.extra.head.is_none()
|
||||
&& !m.is_baby() =>
|
||||
{
|
||||
m.extra.head = Some(item.as_str().into());
|
||||
m.extra.body_damage = held_damage;
|
||||
m.extra.body_extra = held_extra;
|
||||
consume = true;
|
||||
eat = Some(("item.armor.equip_wolf", 1.0, 1.0));
|
||||
}
|
||||
// Wolf.mobInteract: an armadillo scute repairs a sitting wolf's
|
||||
// armor by an eighth of its durability.
|
||||
Species::Wolf
|
||||
if item == "minecraft:armadillo_scute"
|
||||
&& m.extra.tame
|
||||
&& m.extra.owner == Some(acting)
|
||||
&& m.extra.sitting
|
||||
&& m.extra.head.is_some()
|
||||
&& m.extra.body_damage > 0 =>
|
||||
{
|
||||
m.extra.body_damage = (m.extra.body_damage
|
||||
- (horizon_client::mobs::WOLF_ARMOR_DURABILITY as f32 * 0.125) as i32)
|
||||
.max(0);
|
||||
consume = true;
|
||||
eat = Some(("item.wolf_armor.repair", 1.0, 1.0));
|
||||
}
|
||||
Species::Wolf
|
||||
if m.extra.tame
|
||||
&& m.extra.owner == Some(acting)
|
||||
@@ -3379,6 +3656,15 @@ impl Game<'_> {
|
||||
m.position.z.floor() as i32 >> 4,
|
||||
));
|
||||
}
|
||||
// Parrot.mobInteract: the owner makes a tame parrot on the ground
|
||||
// sit or stand (poisonous food is handled apart).
|
||||
Species::Parrot if m.extra.tame && m.on_ground && !parrot_poisonous => {
|
||||
m.extra.sitting = !m.extra.sitting;
|
||||
self.world.modified.insert((
|
||||
m.position.x.floor() as i32 >> 4,
|
||||
m.position.z.floor() as i32 >> 4,
|
||||
));
|
||||
}
|
||||
Species::Cat if !m.extra.tame && is_food => {
|
||||
consume = true;
|
||||
m.persistent = true;
|
||||
@@ -3425,6 +3711,29 @@ impl Game<'_> {
|
||||
consume = true;
|
||||
}
|
||||
}
|
||||
// Parrot.mobInteract: #parrot_poisonous_food (a cookie) poisons the
|
||||
// parrot for 45 s and then hurts it with a player attack of
|
||||
// Float.MAX_VALUE, which an invulnerable one shrugs off.
|
||||
Species::Parrot
|
||||
if self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:parrot_poisonous_food")
|
||||
.is_some_and(|t| t.contains(&*item)) =>
|
||||
{
|
||||
consume = true;
|
||||
m.add_effect("minecraft:poison", 900, 0);
|
||||
let id = m.id;
|
||||
let hurt = m.damage(
|
||||
f32::MAX,
|
||||
horizon_client::mobs::Damage::player(player_at.unwrap_or(center)),
|
||||
);
|
||||
// The feeder gets the kill, as for a normal player attack.
|
||||
if hurt {
|
||||
self.last_hurt_by_player.insert(id, self.lan_acting);
|
||||
}
|
||||
}
|
||||
// Parrot.mobInteract: parrot food may tame a wild one (one time in
|
||||
// ten); a parrot never takes it as Animal food (isFood is false).
|
||||
Species::Parrot if !m.extra.tame && is_food => {
|
||||
@@ -3436,7 +3745,7 @@ impl Game<'_> {
|
||||
let tamed = m.random.next_int_bound(10).unwrap_or(1) == 0;
|
||||
if tamed {
|
||||
m.extra.tame = true;
|
||||
m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1]));
|
||||
m.extra.owner = Some(acting);
|
||||
}
|
||||
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
|
||||
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
|
||||
@@ -3445,6 +3754,18 @@ impl Game<'_> {
|
||||
self.trigger_entity("minecraft:tame_animal", "entity", &view);
|
||||
}
|
||||
}
|
||||
// Dolphin.mobInteract: a fish makes a baby grow by a tenth and an
|
||||
// adult remember it (`gotFish`), to swim to a treasure.
|
||||
Species::Dolphin if fish => {
|
||||
eat = Some(("entity.dolphin.eat", 1.0, 1.0));
|
||||
if m.extra.age < 0 {
|
||||
let secs = ((-m.extra.age / 20) as f32 * 0.1) as i32;
|
||||
horizon_client::mobs::ai::age_up(m, secs, true);
|
||||
} else {
|
||||
m.extra.flag = true;
|
||||
}
|
||||
consume = true;
|
||||
}
|
||||
// Tadpole.feed: frog food ages it by a tenth of the time left to
|
||||
// grow into a frog (it grows on its next tick once past 24000).
|
||||
Species::Tadpole if is_food && !m.extra.age_locked => {
|
||||
@@ -3458,6 +3779,22 @@ impl Game<'_> {
|
||||
horizon_client::particles::Burst::HORSE_EATING,
|
||||
);
|
||||
}
|
||||
// Bee.mobInteract: a wither rose or an open eyeblossom is eaten for
|
||||
// its `getBeeInteractionEffect` instead of breeding the bee.
|
||||
Species::Bee
|
||||
if is_food
|
||||
&& matches!(
|
||||
&*item,
|
||||
"minecraft:wither_rose" | "minecraft:open_eyeblossom"
|
||||
) =>
|
||||
{
|
||||
if &*item == "minecraft:wither_rose" {
|
||||
m.add_effect("minecraft:wither", 40, 0);
|
||||
} else {
|
||||
m.add_effect("minecraft:poison", 25, 0);
|
||||
}
|
||||
consume = true;
|
||||
}
|
||||
// HappyGhast.canFallInLove: never, so only a ghastling takes food.
|
||||
_ if is_food
|
||||
&& m.kind.flags.animal
|
||||
@@ -3528,6 +3865,9 @@ impl Game<'_> {
|
||||
self.spawn_item(rest, at, [0.0, 0.0, 0.0], 40);
|
||||
}
|
||||
}
|
||||
if let Some(stack) = armor_drop {
|
||||
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
|
||||
}
|
||||
for (item, count) in drops {
|
||||
for _ in 0..count {
|
||||
let v = [
|
||||
@@ -3550,6 +3890,39 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `SnowGolem.mobInteract` and `Bogged.mobInteract` with shears: the
|
||||
/// pumpkin or the mushrooms come off (the shearing loot table), the shear
|
||||
/// game event is posted and the tool takes one damage.
|
||||
fn interact_shear_mob(&mut self, index: usize, item: &str) -> bool {
|
||||
let m = &self.mobs[index];
|
||||
if item != "minecraft:shears" || !m.alive() || m.extra.sheared {
|
||||
return false;
|
||||
}
|
||||
let (sound, table, height) = match m.species() {
|
||||
Species::SnowGolem => (
|
||||
"minecraft:entity.snow_golem.shear",
|
||||
"minecraft:shearing/snow_golem",
|
||||
m.eye_height(),
|
||||
),
|
||||
Species::Bogged => (
|
||||
"minecraft:entity.bogged.shear",
|
||||
"minecraft:shearing/bogged",
|
||||
f64::from(m.height()),
|
||||
),
|
||||
_ => return false,
|
||||
};
|
||||
let at = m.position;
|
||||
let tool = self.held().cloned();
|
||||
self.audio.play(sound, Some([at.x, at.y, at.z]), 1.0, 1.0);
|
||||
self.mobs[index].extra.sheared = true;
|
||||
for stack in self.sequence_loot(table, table, tool.as_ref()) {
|
||||
self.spawn_at_location(at, height, stack);
|
||||
}
|
||||
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
|
||||
self.post_game_event("minecraft:shear", [at.x, at.y, at.z], ctx);
|
||||
self.damage_held_tool();
|
||||
true
|
||||
}
|
||||
/// `Armadillo.mobInteract`: a brush brushes a scute off an adult
|
||||
/// (`brushOffScute`) and loses 16 durability.
|
||||
fn interact_armadillo(&mut self, index: usize, item: &str) -> bool {
|
||||
@@ -3898,6 +4271,18 @@ impl Game<'_> {
|
||||
&armor,
|
||||
out,
|
||||
);
|
||||
if &*mob.kind.id == "minecraft:player" {
|
||||
// PlayerRenderer's WingsLayer and SpinAttackEffectLayer.
|
||||
self.player_elytra_geometry(
|
||||
mob,
|
||||
mob.extra.chest.as_deref(),
|
||||
camera,
|
||||
alpha as f32,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, out);
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
@@ -4132,6 +4517,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
|
||||
match kind {
|
||||
K::PlayerAttack => "minecraft:player_attack",
|
||||
K::MaceSmash => "minecraft:mace_smash",
|
||||
K::Spear => "minecraft:spear",
|
||||
K::MobAttack => "minecraft:mob_attack",
|
||||
K::Arrow => "minecraft:arrow",
|
||||
K::Thrown => "minecraft:thrown",
|
||||
@@ -4148,6 +4534,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
|
||||
K::Magic => "minecraft:magic",
|
||||
K::Sting => "minecraft:sting",
|
||||
K::Cactus => "minecraft:cactus",
|
||||
K::SweetBerryBush => "minecraft:sweet_berry_bush",
|
||||
K::Generic => "minecraft:generic",
|
||||
K::InWall => "minecraft:in_wall",
|
||||
K::Thorns => "minecraft:thorns",
|
||||
@@ -4172,6 +4559,7 @@ fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack {
|
||||
exploration: None,
|
||||
ominous_amplifier: None,
|
||||
stew_effects: Vec::new(),
|
||||
names: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A generated loot stack as a Horizon item stack.
|
||||
@@ -4191,6 +4579,9 @@ fn from_loot(stack: &horizon_world::loot::LootStack) -> ItemStack {
|
||||
if let Some(amplifier) = stack.ominous_amplifier.filter(|a| *a > 0) {
|
||||
item.data_mut().ominous_amplifier = Some(amplifier as u8);
|
||||
}
|
||||
for (id, text) in &stack.names {
|
||||
item.data_mut().set_loot_name(id, text);
|
||||
}
|
||||
item
|
||||
}
|
||||
impl Game<'_> {
|
||||
@@ -4204,13 +4595,10 @@ impl Game<'_> {
|
||||
f64::from((pos[0] >> 4) * 16 + 8),
|
||||
f64::from((pos[2] >> 4) * 16 + 8),
|
||||
];
|
||||
let spectator = self.spectator();
|
||||
let Some(close) = self.player.as_ref().filter(|_| !spectator).map(|p| {
|
||||
let close = self.level_players().iter().any(|p| {
|
||||
let (dx, dz) = (p.position.x - centre[0], p.position.z - centre[1]);
|
||||
dx * dx + dz * dz < 16384.0
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
!p.spectator && dx * dx + dz * dz < 16384.0
|
||||
});
|
||||
if !close {
|
||||
return;
|
||||
}
|
||||
@@ -4234,6 +4622,9 @@ impl Game<'_> {
|
||||
f64::from(y + 1),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
];
|
||||
let _ = self.spawn_mob("minecraft:zombified_piglin", at, false);
|
||||
// setPortalCooldown: the new piglin does not travel at once.
|
||||
if let Some(id) = self.spawn_mob("minecraft:zombified_piglin", at, false) {
|
||||
self.set_portal_cooldown(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,7 @@ impl Game<'_> {
|
||||
if let Some(a) = arrow.arrow.as_mut() {
|
||||
a.set_item(ammo);
|
||||
a.set_owner(Some(Who::Player));
|
||||
a.fired_from_weapon = Some(weapon.clone());
|
||||
a.pierce = pierce;
|
||||
}
|
||||
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
|
||||
@@ -235,6 +236,11 @@ impl Game<'_> {
|
||||
.play("minecraft:item.crossbow.shoot", Some(eye), 1.0, pitch);
|
||||
self.damage_held(if rocket { 3 } else { 1 });
|
||||
}
|
||||
// CrossbowItem.performShooting: SHOT_CROSSBOW with the crossbow, which
|
||||
// is an empty stack (no match) when the shots broke it.
|
||||
if let Some(stack) = self.held().cloned() {
|
||||
self.trigger_item("minecraft:shot_crossbow", &stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -244,7 +244,8 @@ impl Game<'_> {
|
||||
if on { "visible" } else { "hidden" }
|
||||
)
|
||||
};
|
||||
let remote = self.remote.is_some();
|
||||
// `DebugScreenOverlay`: the single-player variants for a local server.
|
||||
let remote = !self.has_singleplayer_server();
|
||||
let c = &self.debug_charts;
|
||||
[
|
||||
format!(
|
||||
@@ -463,7 +464,7 @@ impl Game<'_> {
|
||||
height,
|
||||
self.settings.max_fps,
|
||||
1000.0 / self.clock.rate(),
|
||||
self.remote.is_some(),
|
||||
!self.has_singleplayer_server(),
|
||||
);
|
||||
draws.push(hud_layout::GuiDraw::Push(vec![hud_layout::PoseOp::Scale(
|
||||
scale, scale,
|
||||
|
||||
@@ -412,6 +412,8 @@ struct ServerState {
|
||||
paused: bool,
|
||||
/// `broadcast-console-to-ops`.
|
||||
broadcast_console: bool,
|
||||
/// `op-permission-level`: what `/op` grants (operatorUserPermissions).
|
||||
op_level: i32,
|
||||
}
|
||||
|
||||
/// The dedicated-only commands' replies vanilla sends with `broadcast`
|
||||
@@ -573,6 +575,14 @@ impl Game<'_> {
|
||||
// DedicatedServer.initServer: worldData.setGameType; forceDifficulty.
|
||||
self.set_default_game_mode(game_mode);
|
||||
self.ui.difficulty = props.difficulty as u8;
|
||||
// DedicatedServer.initServer: a legacy `announce-player-achievements`
|
||||
// sets the show_advancement_messages rule once, as the file is read.
|
||||
if let Some(announce) = props.announce_player_achievements {
|
||||
self.ui.rules.set(
|
||||
"show_advancement_messages",
|
||||
horizon_client::gamerules::Value::Bool(announce),
|
||||
);
|
||||
}
|
||||
self.settings.render_distance = props.view_distance.clamp(2, 32);
|
||||
self.settings.simulation_distance = props.simulation_distance.clamp(2, 32);
|
||||
self.streaming.radius = self.settings.render_distance;
|
||||
@@ -663,6 +673,9 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
self.sync_ops(&lists);
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.spawn_protection = props.spawn_protection;
|
||||
}
|
||||
log(
|
||||
"INFO",
|
||||
&format!(
|
||||
@@ -685,6 +698,7 @@ impl Game<'_> {
|
||||
ticks: Default::default(),
|
||||
paused: false,
|
||||
broadcast_console: props.broadcast_console_to_ops,
|
||||
op_level: props.op_permission_level,
|
||||
};
|
||||
let pause_after = props.pause_when_empty_seconds * 20;
|
||||
let idle_timeout = props.player_idle_timeout;
|
||||
@@ -946,7 +960,7 @@ impl Game<'_> {
|
||||
let Some(user) = self.resolve(rest[0]) else {
|
||||
return;
|
||||
};
|
||||
let level = 4;
|
||||
let level = state.op_level;
|
||||
let changed = {
|
||||
let mut l = state.lists.lock().unwrap();
|
||||
if command == "op" {
|
||||
@@ -972,6 +986,10 @@ impl Game<'_> {
|
||||
self.console_reply(Text::translate(key, &[lit(&user.name)]));
|
||||
let lists = state.lists.clone();
|
||||
self.sync_ops(&lists);
|
||||
// DeOpCommands.deopPlayers.
|
||||
if command == "deop" && changed {
|
||||
self.kick_unlisted_players(&lists);
|
||||
}
|
||||
}
|
||||
"whitelist" => self.console_whitelist(state, &rest),
|
||||
"ban" if !rest.is_empty() => {
|
||||
@@ -1183,6 +1201,7 @@ impl Game<'_> {
|
||||
["on"] | ["off"] => {
|
||||
let on = rest[0] == "on";
|
||||
let mut l = state.lists.lock().unwrap();
|
||||
let turned_on = on && !l.use_whitelist;
|
||||
let key = match (on, l.use_whitelist) {
|
||||
(true, true) => "commands.whitelist.alreadyOn",
|
||||
(true, false) => "commands.whitelist.enabled",
|
||||
@@ -1193,6 +1212,10 @@ impl Game<'_> {
|
||||
drop(l);
|
||||
self.store_property("white-list", on.to_string());
|
||||
self.console_reply(Text::translate(key, &[]));
|
||||
if turned_on {
|
||||
let lists = state.lists.clone();
|
||||
self.kick_unlisted_players(&lists);
|
||||
}
|
||||
}
|
||||
["list"] => {
|
||||
let names: Vec<String> = state
|
||||
@@ -1216,6 +1239,8 @@ impl Game<'_> {
|
||||
["reload"] => {
|
||||
let _ = state.lists.lock().unwrap().white.load();
|
||||
self.console_reply(Text::translate("commands.whitelist.reloaded", &[]));
|
||||
let lists = state.lists.clone();
|
||||
self.kick_unlisted_players(&lists);
|
||||
}
|
||||
["add", name] | ["remove", name] => {
|
||||
let Some(user) = self.resolve(name) else {
|
||||
@@ -1241,6 +1266,10 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
self.console_reply(Text::translate(key, &args));
|
||||
if ok && rest[0] == "remove" {
|
||||
let lists = state.lists.clone();
|
||||
self.kick_unlisted_players(&lists);
|
||||
}
|
||||
}
|
||||
_ => self.console_reply(Text::translate("command.unknown.argument", &[])),
|
||||
}
|
||||
@@ -1256,6 +1285,38 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// MinecraftServer.kickUnlistedPlayers: with `enforce-whitelist` and the
|
||||
/// whitelist on, whoever is online and not on the whitelist file is
|
||||
/// disconnected (operators are not spared here).
|
||||
fn kick_unlisted_players(&mut self, lists: &Arc<Mutex<Lists>>) {
|
||||
let unlisted: Vec<u128> = {
|
||||
let l = lists.lock().unwrap();
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if !(l.enforce_whitelist && l.use_whitelist) {
|
||||
return;
|
||||
}
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| {
|
||||
!l.white.contains(&NameAndId {
|
||||
id: g.uuid,
|
||||
name: g.name.clone(),
|
||||
})
|
||||
})
|
||||
.map(|g| g.uuid)
|
||||
.collect()
|
||||
};
|
||||
for uuid in unlisted {
|
||||
self.kick_uuid(
|
||||
uuid,
|
||||
translatable("multiplayer.disconnect.not_whitelisted", &[]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn kick_uuid(&mut self, uuid: u128, reason: Tag) {
|
||||
if let Some(h) = self.lan.as_mut() {
|
||||
let conns: Vec<u32> = h
|
||||
|
||||
@@ -672,6 +672,7 @@ impl Game<'_> {
|
||||
}
|
||||
let source = match source.kind {
|
||||
horizon_client::mobs::DamageKind::MaceSmash => "minecraft:mace_smash",
|
||||
horizon_client::mobs::DamageKind::Spear => "minecraft:spear",
|
||||
_ => "minecraft:player_attack",
|
||||
};
|
||||
out.push(format!(
|
||||
|
||||
@@ -1548,6 +1548,7 @@ impl Game<'_> {
|
||||
return Ok((Some(stack), FAIL));
|
||||
};
|
||||
let target = (placed.x, placed.y, placed.z);
|
||||
let state = self.apply_item_block_state([target.0, target.1, target.2], state, &stack)?;
|
||||
if let Some(entity) = BlockEntity::for_block(&self.res.facts().get(state).name) {
|
||||
self.world.block_entities.insert(target, entity);
|
||||
self.apply_placed_item([target.0, target.1, target.2], &stack, false);
|
||||
|
||||
@@ -1122,14 +1122,11 @@ impl Game<'_> {
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
let modifiers = self
|
||||
.attack_item_info()
|
||||
.map(|i| (i.attack_damage, i.attack_speed))
|
||||
.unwrap_or((0.0, 0.0));
|
||||
let speed = (4.0 + modifiers.1).max(0.1);
|
||||
let scale = ((self.attack_ticks as f32 + 0.5) / (20.0 / speed)).clamp(0.0, 1.0);
|
||||
let hand_damage = self.attack_item_info().map_or(0.0, |i| i.attack_damage);
|
||||
let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed());
|
||||
let scale = ((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0);
|
||||
let mut amount =
|
||||
(1.0 + modifiers.0 + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8);
|
||||
(1.0 + hand_damage + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8);
|
||||
let falling = self
|
||||
.player
|
||||
.as_ref()
|
||||
@@ -1465,7 +1462,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let d2 = (pp.x - pos.x).powi(2) + (pp.y - pos.y).powi(2) + (pp.z - pos.z).powi(2);
|
||||
if d2 < 4096.0 {
|
||||
// Entity.hasLineOfSight: eye to eye (the dragon's eye is 0.85 of
|
||||
// its 8 block height), blocks only.
|
||||
let sees = d2 < 4096.0 && {
|
||||
let eye = Vec3::new(pos.x, pos.y + 6.8, pos.z);
|
||||
let target_eye =
|
||||
tracked.map_or(pp.y + 1.62, |p| p.bounds.max_position().y - 0.18);
|
||||
self.mob_world_base(&[])
|
||||
.line_of_sight(eye, Vec3::new(pp.x, target_eye, pp.z))
|
||||
};
|
||||
if sees {
|
||||
*charge += 1;
|
||||
let d = self.dragon.as_ref().expect("dragon");
|
||||
let r = d.yaw.to_radians();
|
||||
@@ -3020,14 +3026,11 @@ impl Game<'_> {
|
||||
let (a, b) = (room.min_position(), room.max_position());
|
||||
lo.x < b.x && hi.x > a.x && lo.y < b.y && hi.y > a.y && lo.z < b.z && hi.z > a.z
|
||||
};
|
||||
let player = self.player.as_ref().is_some_and(|p| {
|
||||
let w = f64::from(p.dimensions.width) / 2.0;
|
||||
let h = f64::from(p.dimensions.height);
|
||||
touches(
|
||||
Vec3::new(p.position.x - w, p.position.y, p.position.z - w),
|
||||
Vec3::new(p.position.x + w, p.position.y + h, p.position.z + w),
|
||||
)
|
||||
});
|
||||
// Level.getEntities: the host's player and every guest in the level.
|
||||
let player = self
|
||||
.fight_players()
|
||||
.iter()
|
||||
.any(|p| !p.spectator && touches(p.bounds.min_position(), p.bounds.max_position()));
|
||||
player
|
||||
|| self.crystals.iter().any(|c| {
|
||||
touches(
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
//! unbreaking (`minecraft:item_damage`), sharpness/smite/bane and power
|
||||
//! (`minecraft:damage`), knockback, fire aspect, infinity (`minecraft:ammo_use`),
|
||||
//! thorns (`minecraft:post_attack`), mending (`minecraft:repair_with_xp`) and
|
||||
//! the armor attribute effects (respiration, depth strider, ...) and Soul
|
||||
//! Speed's `location_changed` and `tick` effects.
|
||||
//! the armor attribute effects (respiration, depth strider, ...), Soul
|
||||
//! Speed's `location_changed` and `tick` effects and the
|
||||
//! `minecraft:location_changed` disk effect (frost walker).
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
enchant::{EffectContext, Enchantments, level_value},
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_world::{fluid::FluidKind, level::LevelAccess, registry::BlockStateId};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -69,7 +73,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `getEnchantedDamage - base`: damage bonus of the held weapon against a mob type.
|
||||
pub(super) fn enchant_damage_bonus(&mut self, target: &str) -> f32 {
|
||||
let Some(held) = self.inventory[self.selected].clone() else {
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return 0.0;
|
||||
};
|
||||
let ctx = EffectContext {
|
||||
@@ -102,7 +106,7 @@ impl Game<'_> {
|
||||
/// whether the hit is that knockback attack.
|
||||
pub(super) fn extra_knockback(&mut self, target_kind: &str, scale: f32) -> (f32, bool) {
|
||||
let knockback_attack = scale > 0.9 && self.player.as_ref().is_some_and(|p| p.sprinting);
|
||||
let enchanted = match self.inventory[self.selected].clone() {
|
||||
let enchanted = match self.held().cloned() {
|
||||
Some(held) => {
|
||||
let ctx = EffectContext {
|
||||
this: Some(target_kind),
|
||||
@@ -144,7 +148,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// After a successful melee hit: fire aspect and wind burst.
|
||||
pub(super) fn post_attack(&mut self, mob: usize) {
|
||||
let Some(held) = self.inventory[self.selected].clone() else {
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(m) = self.mobs.get(mob) else { return };
|
||||
@@ -211,6 +215,106 @@ impl Game<'_> {
|
||||
}
|
||||
self.post_attack_bursts(&held, &kind);
|
||||
}
|
||||
/// `EnchantmentHelper.doPostPiercingAttackEffects`: the
|
||||
/// `minecraft:post_piercing_attack` effects of the weapon in hand after a
|
||||
/// jab (Lunge's dash).
|
||||
pub(super) fn post_piercing_attack(&mut self) {
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return;
|
||||
};
|
||||
let mut effects = Vec::new();
|
||||
for (id, level) in held.enchantments() {
|
||||
let Some(def) = self.res.stations().enchantments.get(id) else {
|
||||
continue;
|
||||
};
|
||||
for entry in def.effects["minecraft:post_piercing_attack"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
effects.push((*level, entry["effect"].clone()));
|
||||
}
|
||||
}
|
||||
// Lunge's requirements: not riding, gliding or in water, and a
|
||||
// player outside creative with at least 7 food.
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if effects.is_empty()
|
||||
|| self.riding.is_some()
|
||||
|| p.fall_flying
|
||||
|| p.fluids.was_touching_water
|
||||
|| !(self.creative || p.health.food.food >= 7)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (level, effect) in effects {
|
||||
self.apply_piercing_effect(&effect, level);
|
||||
}
|
||||
}
|
||||
/// One `post_piercing_attack` effect on the player: `AllOf`,
|
||||
/// `ChangeItemDamage`, `ApplyExhaustion`, `ApplyEntityImpulse` and
|
||||
/// `PlaySoundEffect`.
|
||||
fn apply_piercing_effect(&mut self, effect: &serde_json::Value, level: u32) {
|
||||
let amount = || level_value(&effect["amount"], level);
|
||||
match effect["type"].as_str() {
|
||||
Some("minecraft:all_of") => {
|
||||
for e in effect["effects"].as_array().into_iter().flatten() {
|
||||
self.apply_piercing_effect(e, level);
|
||||
}
|
||||
}
|
||||
Some("minecraft:change_item_damage") => self.damage_held(amount() as u32),
|
||||
Some("minecraft:apply_exhaustion") => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.cause_food_exhaustion(amount());
|
||||
}
|
||||
}
|
||||
// The direction turned by the look (`Entity.getLookQuaternion`:
|
||||
// pitch about x, then yaw about y), scaled by coordinate_scale
|
||||
// and the magnitude, added to the velocity (the guest's client
|
||||
// is told of it by `guest_hit_by`).
|
||||
Some("minecraft:apply_impulse") => {
|
||||
let vec3 = |key: &str| [0, 1, 2].map(|i| effect[key][i].as_f64().unwrap_or(0.0));
|
||||
let (d, scale) = (vec3("direction"), vec3("coordinate_scale"));
|
||||
let magnitude = f64::from(level_value(&effect["magnitude"], level));
|
||||
let (yaw, pitch) = (
|
||||
f64::from(self.yaw).to_radians(),
|
||||
f64::from(self.pitch).to_radians(),
|
||||
);
|
||||
let (y1, z1) = (
|
||||
d[1] * pitch.cos() - d[2] * pitch.sin(),
|
||||
d[1] * pitch.sin() + d[2] * pitch.cos(),
|
||||
);
|
||||
let impulse = horizon_core::geometry::Vec3::new(
|
||||
(d[0] * yaw.cos() - z1 * yaw.sin()) * scale[0] * magnitude,
|
||||
y1 * scale[1] * magnitude,
|
||||
(d[0] * yaw.sin() + z1 * yaw.cos()) * scale[2] * magnitude,
|
||||
);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity = p.velocity.add_vector(impulse);
|
||||
}
|
||||
}
|
||||
// The sound for the level (a list picks by level), heard by all.
|
||||
Some("minecraft:play_sound") => {
|
||||
let sounds: Vec<&str> = match &effect["sound"] {
|
||||
serde_json::Value::Array(list) => {
|
||||
list.iter().filter_map(|s| s.as_str()).collect()
|
||||
}
|
||||
one => one.as_str().into_iter().collect(),
|
||||
};
|
||||
let index = (level as usize).saturating_sub(1);
|
||||
if let Some(sound) = sounds.get(index.min(sounds.len().saturating_sub(1))) {
|
||||
let float = |key: &str| effect[key].as_f64().unwrap_or(1.0) as f32;
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio.play(sound, at, float("volume"), float("pitch"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `ApplyMobEffect.apply`: one of `to_apply` (a single id or a list) for a
|
||||
/// random duration and amplifier between the level-based bounds.
|
||||
fn apply_mob_effect(&mut self, mob: usize, effect: &serde_json::Value, level: u32) {
|
||||
@@ -636,6 +740,173 @@ impl Game<'_> {
|
||||
self.apply_equipment();
|
||||
}
|
||||
}
|
||||
/// `EnchantmentHelper.runLocationChangedEffects` for the worn armor of the
|
||||
/// player, run on the server once the player has moved: `LivingEntity.baseTick`
|
||||
/// runs it when the block position changed, `checkFallDamage` when it lands.
|
||||
/// `from` and `was_on_ground` are where and how the tick began.
|
||||
pub(super) fn location_changed_effects(&mut self, from: [f64; 3], was_on_ground: bool) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let on_ground = p.ground.on_ground;
|
||||
let cell = |v: [f64; 3]| v.map(|c| c.floor() as i32);
|
||||
if cell(from) == cell(at) && !(on_ground && !was_on_ground) {
|
||||
return;
|
||||
}
|
||||
let riding = self.riding.is_some();
|
||||
let mut disks = Vec::new();
|
||||
for (stack, slot) in self.armor.iter().zip(["head", "chest", "legs", "feet"]) {
|
||||
let Some(stack) = stack else { continue };
|
||||
for (id, level) in stack.enchantments() {
|
||||
let Some(def) = self.res.stations().enchantments.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if !def
|
||||
.slots
|
||||
.iter()
|
||||
.any(|s| s == slot || s == "armor" || s == "any")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for effect in def.effects["minecraft:location_changed"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if effect["effect"]["type"] == "minecraft:replace_disk"
|
||||
&& location_requirements(&effect["requirements"], on_ground, riding)
|
||||
{
|
||||
disks.push((effect["effect"].clone(), *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (effect, level) in disks {
|
||||
self.replace_disk(&effect, level, at);
|
||||
}
|
||||
}
|
||||
/// `ReplaceDisk.apply`: the blocks of the disk around `at` that pass the
|
||||
/// predicate become the effect's state, and each placement is a game event.
|
||||
fn replace_disk(&mut self, effect: &Value, level: u32, at: [f64; 3]) {
|
||||
let registry = self.pack.assets.registry;
|
||||
let spec = &effect["block_state"];
|
||||
let Some(default) = spec["id"].as_str().and_then(|n| registry.default_state(n)) else {
|
||||
return;
|
||||
};
|
||||
let Some(mut state) = registry.state(default).cloned() else {
|
||||
return;
|
||||
};
|
||||
for (k, v) in spec["properties"].as_object().into_iter().flatten() {
|
||||
state
|
||||
.properties
|
||||
.insert(k.clone(), v.as_str().unwrap_or("").to_owned());
|
||||
}
|
||||
let new = registry.resolve(&state).unwrap_or(default);
|
||||
let radius = level_value(&effect["radius"], level) as i32;
|
||||
let height = level_value(&effect["height"], level) as i32;
|
||||
let offset = cell_offset(effect);
|
||||
let center = [0, 1, 2].map(|i| at[i].floor() as i32 + offset[i]);
|
||||
let event = effect["trigger_game_event"].as_str() == Some("minecraft:block_place");
|
||||
for dy in (height - 1).min(0)..=0 {
|
||||
for dz in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
let pos = [center[0] + dx, center[1] + dy, center[2] + dz];
|
||||
let (x, z) = (
|
||||
f64::from(pos[0]) + 0.5 - at[0],
|
||||
f64::from(pos[2]) + 0.5 - at[2],
|
||||
);
|
||||
if x * x + z * z >= f64::from(radius * radius)
|
||||
|| !self.disk_predicate(&effect["predicate"], pos, new)
|
||||
|| self.level_set(pos, new).is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if event {
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_place",
|
||||
pos,
|
||||
Some(Who::Player),
|
||||
Some(new),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The `BlockPredicate` subset the disk effect uses, tested at `pos`; no
|
||||
/// predicate matches everything.
|
||||
fn disk_predicate(&mut self, p: &Value, pos: [i32; 3], placed: BlockStateId) -> bool {
|
||||
if p.is_null() {
|
||||
return true;
|
||||
}
|
||||
let offset = cell_offset(p);
|
||||
let at = [0, 1, 2].map(|i| pos[i] + offset[i]);
|
||||
let state = self.world.block(at[0], at[1], at[2]);
|
||||
let b = self.res.behaviours();
|
||||
let names = |v: &Value| -> Vec<String> {
|
||||
match v {
|
||||
Value::String(s) => vec![s.clone()],
|
||||
Value::Array(a) => a
|
||||
.iter()
|
||||
.filter_map(|n| n.as_str().map(String::from))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
match p["type"].as_str().unwrap_or("") {
|
||||
"minecraft:all_of" => p["predicates"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.all(|q| self.disk_predicate(q, pos, placed)),
|
||||
// `MatchingBlockTagPredicate`
|
||||
"minecraft:matching_block_tag" => b
|
||||
.tag(p["tag"].as_str().unwrap_or("").trim_start_matches('#'))
|
||||
.is_some_and(|t| t.contains(b.block(state))),
|
||||
// `MatchingBlocksPredicate`
|
||||
"minecraft:matching_blocks" => {
|
||||
names(&p["blocks"]).iter().any(|n| n == b.table.name(state))
|
||||
}
|
||||
// `MatchingFluidsPredicate`: Fluids.WATER is the source.
|
||||
"minecraft:matching_fluids" => {
|
||||
let fluid = b.fluid(state);
|
||||
names(&p["fluids"]).iter().any(|n| match n.as_str() {
|
||||
"minecraft:water" => fluid.kind() == FluidKind::Water && fluid.is_source(),
|
||||
"minecraft:flowing_water" => {
|
||||
fluid.kind() == FluidKind::Water && !fluid.is_source()
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
// `UnobstructedPredicate`: no building-blocking entity in the block.
|
||||
"minecraft:unobstructed" => self.with_level(|_, level, _| {
|
||||
level.is_unobstructed(BlockPos::new(pos[0], pos[1], pos[2]), placed)
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// Whether a worn enchantment gives immunity to `#burn_from_stepping`
|
||||
/// damage (frost walker's `minecraft:damage_immunity`).
|
||||
pub(crate) fn wears_burn_from_stepping_immunity(&self) -> bool {
|
||||
self.armor
|
||||
.iter()
|
||||
.zip(["head", "chest", "legs", "feet"])
|
||||
.any(|(stack, slot)| {
|
||||
stack
|
||||
.iter()
|
||||
.flat_map(ItemStack::enchantments)
|
||||
.any(|(id, _)| {
|
||||
self.res.stations().enchantments.get(id).is_some_and(|def| {
|
||||
def.effects.get("minecraft:damage_immunity").is_some()
|
||||
&& def
|
||||
.slots
|
||||
.iter()
|
||||
.any(|s| s == slot || s == "armor" || s == "any")
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Enchantment attribute modifiers of worn armor (respiration, depth strider...).
|
||||
/// Each piece's modifier id ends with its slot (`EnchantmentAttributeEffect.idForSlot`),
|
||||
/// so the same enchantment on two pieces stacks instead of replacing itself.
|
||||
@@ -684,3 +955,35 @@ fn soul_speed_modifiers(
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An effect's or predicate's `offset` vector (zero when absent).
|
||||
fn cell_offset(v: &Value) -> [i32; 3] {
|
||||
let at = |i: usize| {
|
||||
v["offset"]
|
||||
.get(i)
|
||||
.and_then(Value::as_i64)
|
||||
.map_or(0, |n| n as i32)
|
||||
};
|
||||
[at(0), at(1), at(2)]
|
||||
}
|
||||
/// The loot-condition subset a `location_changed` effect requires of the
|
||||
/// wearer: standing on the ground and not riding (frost walker).
|
||||
fn location_requirements(req: &Value, on_ground: bool, riding: bool) -> bool {
|
||||
match req["type"].as_str().unwrap_or("") {
|
||||
"" if req.is_null() => true,
|
||||
"minecraft:all_of" => req["terms"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.all(|t| location_requirements(t, on_ground, riding)),
|
||||
"minecraft:inverted" => !location_requirements(&req["term"], on_ground, riding),
|
||||
"minecraft:entity_properties" => {
|
||||
let p = &req["predicate"];
|
||||
p["minecraft:flags"]["is_on_ground"]
|
||||
.as_bool()
|
||||
.is_none_or(|v| v == on_ground)
|
||||
&& (p["minecraft:vehicle"].is_null() || riding)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use horizon_client::{
|
||||
motion::PlayerMotion,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
vanillagen::VanillaTerrain,
|
||||
world_spawn::PlayerSpawnFinder,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
@@ -581,7 +582,15 @@ impl Game<'_> {
|
||||
let position = if let Some(job) = self.end.job.take() {
|
||||
self.resolve_gateway(job, target)?
|
||||
} else {
|
||||
let (position, yaw) = self.end_arrival(target)?;
|
||||
let mut finder = self.player_spawn.take();
|
||||
let arrival = self.end_arrival(target, &mut finder);
|
||||
self.player_spawn = finder;
|
||||
let Some((position, yaw)) = arrival? else {
|
||||
// A spawn candidate's chunk is loading: try again next tick.
|
||||
self.travelling = Some((player, target));
|
||||
self.end_travel = true;
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(yaw) = yaw {
|
||||
self.yaw = yaw;
|
||||
player.yaw = yaw;
|
||||
@@ -606,7 +615,13 @@ impl Game<'_> {
|
||||
/// Where an End portal's traveller lands once `target` has loaded, and
|
||||
/// the yaw it faces there: the platform below the End's spawn point,
|
||||
/// facing west, or the top of the Overworld's spawn column.
|
||||
pub(super) fn end_arrival(&mut self, target: Vec3) -> Result<(Vec3, Option<f32>)> {
|
||||
/// None while a candidate of the world spawn's search loads (`finder`
|
||||
/// keeps it, see `search_spawn`).
|
||||
pub(super) fn end_arrival(
|
||||
&mut self,
|
||||
target: Vec3,
|
||||
finder: &mut Option<PlayerSpawnFinder>,
|
||||
) -> Result<Option<(Vec3, Option<f32>)>> {
|
||||
if self.dim == Dim::End {
|
||||
// EndPlatformFeature.createEndPlatform below the spawn point.
|
||||
let origin = [END_SPAWN[0], END_SPAWN[1] - 1, END_SPAWN[2]];
|
||||
@@ -626,22 +641,32 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// Direction.WEST.toYRot().
|
||||
return Ok((target, Some(90.0)));
|
||||
return Ok(Some((target, Some(90.0))));
|
||||
}
|
||||
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: where the bed or
|
||||
// anchor puts the player, facing it; else onto the spawn column.
|
||||
let mut target = target;
|
||||
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
|
||||
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
|
||||
return Ok((at, Some(yaw)));
|
||||
return Ok(Some((at, Some(yaw))));
|
||||
}
|
||||
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
|
||||
target = Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5);
|
||||
}
|
||||
let y = self
|
||||
.land_column(target.x.floor() as i32, target.z.floor() as i32)
|
||||
.map_or(target.y, f64::from);
|
||||
Ok((Vec3::new(target.x, y, target.z), None))
|
||||
let (x, z) = (target.x.floor() as i32, target.z.floor() as i32);
|
||||
// TeleportTransition.missingRespawnBlock: PlayerSpawnFinder around
|
||||
// the world spawn.
|
||||
if let Some(world) = self.level.world_spawn.filter(|w| w[0] == x && w[2] == z) {
|
||||
return Ok(match self.search_spawn(world, finder) {
|
||||
Ok(at) => Some((at, None)),
|
||||
Err(chunk) => {
|
||||
self.spawn_wait.push(chunk);
|
||||
None
|
||||
}
|
||||
});
|
||||
}
|
||||
let y = self.land_column(x, z).map_or(target.y, f64::from);
|
||||
Ok(Some((Vec3::new(target.x, y, target.z), None)))
|
||||
}
|
||||
|
||||
/// Dying away from the Overworld respawns at the world spawn there.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Fishing: `FishingRodItem.use` (cast / reel in), the `FishingHook` (flight,
|
||||
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
|
||||
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
|
||||
use super::{Game, view_vector};
|
||||
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, view_vector};
|
||||
use horizon_client::advancements::EntityView;
|
||||
use horizon_client::lan::{entity::States, hub::Hub};
|
||||
use horizon_client::{
|
||||
@@ -15,6 +15,7 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3, trig},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_protocol::entity_data::{Value, serializer};
|
||||
@@ -46,6 +47,13 @@ pub(super) struct Hook {
|
||||
luck: i32,
|
||||
lure: i32,
|
||||
}
|
||||
/// A player a hook can catch (`canHitEntity`): their entity id, which
|
||||
/// guest they are (none: the host) and their box.
|
||||
pub(super) struct HookTarget {
|
||||
id: u32,
|
||||
conn: Option<u32>,
|
||||
bounds: Aabb,
|
||||
}
|
||||
const SIZE: f64 = 0.25;
|
||||
fn bounds(p: Vec3) -> Aabb {
|
||||
let h = SIZE / 2.0;
|
||||
@@ -167,6 +175,11 @@ impl Game<'_> {
|
||||
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
|
||||
item.velocity = pulled(item.velocity);
|
||||
hooked_item = true;
|
||||
} else if self.creatures.hook_targets.iter().any(|t| t.id == id) {
|
||||
// A player: the hub moves them when the tick ends.
|
||||
self.creatures
|
||||
.hook_pulls
|
||||
.push((id, Vec3::new(d.x * 0.1, d.y * 0.1, d.z * 0.1)));
|
||||
}
|
||||
// CriteriaTriggers.FISHING_ROD_HOOKED with no items.
|
||||
let hooked = self
|
||||
@@ -248,7 +261,7 @@ impl Game<'_> {
|
||||
item,
|
||||
[hook.position.x, hook.position.y, hook.position.z],
|
||||
v,
|
||||
10,
|
||||
0,
|
||||
);
|
||||
let xp = self.world.random.next_int_bound(6).unwrap_or(0) as u32 + 1;
|
||||
self.spawn_orbs([owner.x, owner.y + 0.5, owner.z + 0.5], xp);
|
||||
@@ -339,9 +352,12 @@ impl Game<'_> {
|
||||
let mut speed = 1;
|
||||
let above = [b[0], b[1] + 1, b[2]];
|
||||
let sky = self.world.light(above[0], above[1], above[2]) >> 4 == 15;
|
||||
let raining = self.world.weather.raining;
|
||||
let r = &mut self.world.random;
|
||||
if r.next_float() < 0.25 && raining && sky {
|
||||
// Level.isRainingAt: rain (not snow) reaches the position.
|
||||
if self.world.random.next_float() < 0.25
|
||||
&& self.with_level(|b, level, _| {
|
||||
b.is_raining_at(level, BlockPos::new(above[0], above[1], above[2]))
|
||||
})
|
||||
{
|
||||
speed += 1;
|
||||
}
|
||||
let r = &mut self.world.random;
|
||||
@@ -472,6 +488,15 @@ impl Game<'_> {
|
||||
if !self.holds_rod() || d2 > 1024.0 {
|
||||
return;
|
||||
}
|
||||
// The players it can catch, as they stand now (the host's own hook;
|
||||
// guests' hooks use the ones taken as their turns start).
|
||||
if self.lan_acting.is_none()
|
||||
&& matches!(h.state, State::Flying | State::Hooked(_))
|
||||
&& let Some(host) = self.lan.take()
|
||||
{
|
||||
self.refresh_hook_targets(&host);
|
||||
self.lan = Some(host);
|
||||
}
|
||||
h.previous = h.position;
|
||||
if h.on_ground {
|
||||
h.life += 1;
|
||||
@@ -497,8 +522,8 @@ impl Game<'_> {
|
||||
self.hook = Some(h);
|
||||
return;
|
||||
}
|
||||
// checkCollision: hook the first living mob it touches, or (via
|
||||
// `canHitEntity`) a dropped item.
|
||||
// checkCollision: hook the first living mob or other player it
|
||||
// touches, or (via `canHitEntity`) a dropped item.
|
||||
let bb = bounds(h.position);
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let touches = |b: Aabb| {
|
||||
@@ -510,6 +535,14 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|m| m.alive() && touches(m.bounds()))
|
||||
.map(|m| m.id)
|
||||
.or_else(|| {
|
||||
let owner = self.lan_acting;
|
||||
self.creatures
|
||||
.hook_targets
|
||||
.iter()
|
||||
.find(|t| t.conn != owner && touches(t.bounds))
|
||||
.map(|t| t.id)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
@@ -524,12 +557,25 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
State::Hooked(id) => {
|
||||
// updateHookedEntity: ride the hooked mob or item at 0.8 of its height.
|
||||
// updateHookedEntity: ride the hooked mob, player or item at 0.8 of its height.
|
||||
let hooked = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && m.alive())
|
||||
.map(|m| (m.position, f64::from(m.height())))
|
||||
.or_else(|| {
|
||||
self.creatures
|
||||
.hook_targets
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.map(|t| {
|
||||
let (lo, hi) = (t.bounds.min_position(), t.bounds.max_position());
|
||||
(
|
||||
Vec3::new((lo.x + hi.x) / 2.0, lo.y, (lo.z + hi.z) / 2.0),
|
||||
hi.y - lo.y,
|
||||
)
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
@@ -598,6 +644,59 @@ impl Game<'_> {
|
||||
);
|
||||
self.hook = Some(h);
|
||||
}
|
||||
/// The players a hook can catch: everyone but spectators and the dead
|
||||
/// (`Projectile.canHitEntity`), the host and the guests in the level.
|
||||
pub(super) fn refresh_hook_targets(&mut self, host: &LanHost) {
|
||||
let mut targets = Vec::new();
|
||||
if self.options.dedicated.is_none()
|
||||
&& !self.spectator()
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
&& !p.health.dead_or_dying()
|
||||
{
|
||||
targets.push(HookTarget {
|
||||
id: host.host_entity as u32,
|
||||
conn: None,
|
||||
bounds: p.bounds(),
|
||||
});
|
||||
}
|
||||
for g in &host.hub.guests {
|
||||
if host.hub.here(g) && g.loaded && !g.dead && !g.spectator() {
|
||||
targets.push(HookTarget {
|
||||
id: g.entity_id as u32,
|
||||
conn: Some(g.conn),
|
||||
bounds: guest_bounds(g),
|
||||
});
|
||||
}
|
||||
}
|
||||
self.creatures.hook_targets = targets;
|
||||
}
|
||||
/// `FishingHook.pullEntity` on the players the hooks reeled in this tick
|
||||
/// pulled: their velocity moves, and a guest's reaches their client.
|
||||
pub(super) fn flush_hook_pulls(&mut self) {
|
||||
let pulls = std::mem::take(&mut self.creatures.hook_pulls);
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
for (id, delta) in pulls {
|
||||
let push = |g: &mut Self| {
|
||||
if let Some(p) = g.player.as_mut() {
|
||||
p.velocity = p.velocity.add_vector(delta);
|
||||
}
|
||||
};
|
||||
if id == host.host_entity as u32 {
|
||||
push(self);
|
||||
} else if let Some(conn) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.entity_id as u32 == id)
|
||||
.map(|g| g.conn)
|
||||
{
|
||||
self.guest_hit_by(&mut host, conn, push);
|
||||
}
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
/// `FishingRodCast`: the stack in the arm holding the rod
|
||||
/// (`FishingHookRenderer.getHoldingArm`) while the hook is out.
|
||||
pub(super) fn cast_rod(&self) -> Option<&ItemStack> {
|
||||
|
||||
@@ -7,6 +7,7 @@ use horizon_assets::{bake::bake_model, model::CuboidModel, resolve::ModelLibrary
|
||||
use horizon_client::{entities::ItemFrame, items::ItemStack, mesher::Vertex};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
@@ -223,6 +224,7 @@ impl Game<'_> {
|
||||
}
|
||||
let p = self.item_frames[i].pos;
|
||||
self.world.modified.insert((p[0] >> 4, p[2] >> 4));
|
||||
self.frame_output_changed(p);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
@@ -255,8 +257,17 @@ impl Game<'_> {
|
||||
}
|
||||
self.drop_frame_items(&f, with_frame, true);
|
||||
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `ItemFrame.setItem` / `setRotation`: `updateNeighbourForOutputSignal`, so
|
||||
/// the comparators reading the frame take its new signal.
|
||||
fn frame_output_changed(&mut self, pos: [i32; 3]) {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
|
||||
}
|
||||
/// `ItemFrame.dropItem`: the frame (when breaking) and the held item.
|
||||
fn drop_frame_items(&mut self, f: &ItemFrame, with_frame: bool, by_player: bool) {
|
||||
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
|
||||
@@ -300,6 +311,9 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
|
||||
self.drop_frame_items(&f, true, false);
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,11 @@ impl Game<'_> {
|
||||
let harvest = !facts.requires_tool || self.res.items().correct_for_drops(held, &facts.name);
|
||||
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
|
||||
}
|
||||
/// A key for the main-hand stack (`destroyingItem`): its item and components.
|
||||
pub(super) fn main_hand_key(&self) -> String {
|
||||
self.held()
|
||||
.map_or(String::new(), |s| format!("{} {:?}", s.item, s.extra))
|
||||
}
|
||||
/// Whether an attack would hit an entity rather than the block behind
|
||||
/// it: the crosshair's `EntityHitResult`.
|
||||
pub(super) fn entity_targeted(&self) -> bool {
|
||||
@@ -347,12 +352,21 @@ impl Game<'_> {
|
||||
_ => name.ends_with("shulker_box"),
|
||||
}
|
||||
}
|
||||
/// `Attributes.ATTACK_SPEED` with the main-hand item's modifier.
|
||||
/// `Attributes.ATTACK_SPEED` with the main-hand item's modifier and the
|
||||
/// Haste and Mining Fatigue modifiers (`ADD_MULTIPLIED_TOTAL`), clamped to
|
||||
/// the attribute's range.
|
||||
pub(super) fn main_hand_attack_speed(&self) -> f64 {
|
||||
horizon_client::attack_strength::BASE_ATTACK_SPEED
|
||||
let value = horizon_client::attack_strength::BASE_ATTACK_SPEED
|
||||
+ self
|
||||
.attack_item_info()
|
||||
.map_or(0.0, |i| f64::from(i.attack_speed))
|
||||
.map_or(0.0, |i| f64::from(i.attack_speed));
|
||||
self.potions
|
||||
.effects
|
||||
.modifiers()
|
||||
.into_iter()
|
||||
.filter(|m| m.0 == "minecraft:attack_speed")
|
||||
.fold(value, |v, m| v * (1.0 + m.2))
|
||||
.clamp(0.0, 1024.0)
|
||||
}
|
||||
/// The item whose attack damage and speed modifiers the player has (`attack_item`).
|
||||
pub(super) fn attack_item_info(&self) -> Option<&horizon_client::items::ItemInfo> {
|
||||
@@ -389,6 +403,12 @@ struct GameHands<'g, 'a> {
|
||||
error: Option<Box<dyn std::error::Error>>,
|
||||
}
|
||||
impl GameHands<'_, '_> {
|
||||
/// The main-hand item's `kinetic_weapon` and `piercing_weapon`, a spear's.
|
||||
fn attack_kinetic(&self) -> Option<horizon_client::spear::Kinetic> {
|
||||
self.game
|
||||
.held()
|
||||
.and_then(|s| horizon_client::spear::Kinetic::of(&s.item))
|
||||
}
|
||||
fn keep(&mut self, r: Result<()>) {
|
||||
if let Err(e) = r
|
||||
&& self.error.is_none()
|
||||
@@ -456,18 +476,27 @@ impl HandWorld for GameHands<'_, '_> {
|
||||
self.game.creative
|
||||
}
|
||||
fn cannot_attack(&mut self) -> bool {
|
||||
false
|
||||
let ticker = self.game.hand_strength.ticker;
|
||||
self.game.cannot_attack_with_item(ticker, 0)
|
||||
}
|
||||
fn piercing_weapon(&mut self) -> bool {
|
||||
false
|
||||
self.attack_kinetic().is_some()
|
||||
}
|
||||
fn attack_animation(&mut self) -> SwingAnimation {
|
||||
SwingAnimation::DEFAULT
|
||||
self.attack_kinetic()
|
||||
.map_or(SwingAnimation::DEFAULT, |k| k.attack_animation())
|
||||
}
|
||||
fn interact_animation(&mut self, _: Hand) -> SwingAnimation {
|
||||
SwingAnimation::DEFAULT
|
||||
}
|
||||
fn piercing_attack(&mut self, _: SwingAnimation) {}
|
||||
fn piercing_attack(&mut self, _: SwingAnimation) {
|
||||
// The server's part (`PiercingWeapon.attack`), then the client's:
|
||||
// the sound `MultiPlayerGameMode.piercingAttack` plays.
|
||||
if let Some(sound) = self.game.jab() {
|
||||
self.game.play_at_feet(sound);
|
||||
}
|
||||
self.game.hand_strength.reset_attack();
|
||||
}
|
||||
fn attack_entity(&mut self) -> bool {
|
||||
self.game.attack()
|
||||
}
|
||||
@@ -503,9 +532,7 @@ impl HandWorld for GameHands<'_, '_> {
|
||||
true
|
||||
}
|
||||
fn main_hand_key(&mut self) -> String {
|
||||
self.game
|
||||
.held()
|
||||
.map_or(String::new(), |s| format!("{} {:?}", s.item, s.extra))
|
||||
self.game.main_hand_key()
|
||||
}
|
||||
fn destroy_stage(&mut self, _: [i32; 3], _: Option<i32>) {}
|
||||
fn swing(&mut self, hand: Hand, animation: SwingAnimation) {
|
||||
|
||||
@@ -212,39 +212,58 @@ impl Game<'_> {
|
||||
)
|
||||
.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>> {
|
||||
let mut finder = match self.player_spawn {
|
||||
Some(finder) => finder,
|
||||
None => self.spawn_finder(suggestion),
|
||||
};
|
||||
let found = loop {
|
||||
/// PlayerSpawnFinder.findSpawn around the world spawn, as far as the loaded
|
||||
/// chunks allow: the feet of the first free column within the respawn
|
||||
/// radius, else the suggestion's own column. The finder waits in `state`
|
||||
/// (None to begin); Err gives the chunk of the candidate being waited for,
|
||||
/// which the caller must have loaded (the finder tries at most 1024
|
||||
/// columns).
|
||||
pub(super) fn search_spawn(
|
||||
&self,
|
||||
suggestion: [i32; 3],
|
||||
state: &mut Option<PlayerSpawnFinder>,
|
||||
) -> std::result::Result<Vec3, (i32, i32)> {
|
||||
let mut finder = state
|
||||
.take()
|
||||
.unwrap_or_else(|| self.spawn_finder(suggestion));
|
||||
let suggestion = finder.suggestion;
|
||||
loop {
|
||||
let Some(candidate) = finder.next_candidate() else {
|
||||
break Some(self.fixup_spawn_height(suggestion));
|
||||
return Ok(self.fixup_spawn_height(suggestion));
|
||||
};
|
||||
let (x, z) = match candidate {
|
||||
SpawnCandidate::Column(x, z) => (x, z),
|
||||
SpawnCandidate::Suggestion => (suggestion[0], suggestion[2]),
|
||||
};
|
||||
if !self.streaming.is_lit(x >> 4, z >> 4) {
|
||||
self.streaming.center = (x >> 4, z >> 4);
|
||||
finder.retry();
|
||||
break None;
|
||||
*state = Some(finder);
|
||||
return Err((x >> 4, z >> 4));
|
||||
}
|
||||
match candidate {
|
||||
SpawnCandidate::Column(x, z) => {
|
||||
if let Some(pos) = level_respawn_pos(&self.spawn_columns(), x, z)
|
||||
&& self.player_fits(pos)
|
||||
{
|
||||
break Some(bottom_center(pos));
|
||||
return Ok(bottom_center(pos));
|
||||
}
|
||||
}
|
||||
SpawnCandidate::Suggestion => break Some(self.fixup_spawn_height(suggestion)),
|
||||
SpawnCandidate::Suggestion => return Ok(self.fixup_spawn_height(suggestion)),
|
||||
}
|
||||
};
|
||||
self.player_spawn = found.is_none().then_some(finder);
|
||||
Ok(found)
|
||||
}
|
||||
}
|
||||
/// 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>> {
|
||||
let mut state = self.player_spawn.take();
|
||||
match self.search_spawn(suggestion, &mut state) {
|
||||
Ok(feet) => Ok(Some(feet)),
|
||||
Err(chunk) => {
|
||||
self.streaming.center = chunk;
|
||||
self.player_spawn = state;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A new PlayerSpawnFinder around `suggestion`: the respawn radius, cut
|
||||
/// by the world border; the suggestion itself in adventure mode.
|
||||
|
||||
@@ -165,12 +165,18 @@ pub(super) fn owner_at(position: [f64; 3], yaw: f32, entity_type: &str) -> Owner
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// `UseDuration` of a stack: 72000 for drawn items, the consume time for food.
|
||||
/// `UseDuration` of a stack (`Item.getUseDuration`): 72000 for drawn items
|
||||
/// and for `blocks_attacks` items (shields), the consume time for food.
|
||||
fn use_duration(components: &Components) -> i32 {
|
||||
if let Some(c) = components.get("minecraft:consumable") {
|
||||
let seconds = c["consume_seconds"].as_f64().unwrap_or(1.6);
|
||||
return (seconds * 20.0) as i32;
|
||||
}
|
||||
if components.get("minecraft:blocks_attacks").is_some()
|
||||
|| components.get("minecraft:kinetic_weapon").is_some()
|
||||
{
|
||||
return 72_000;
|
||||
}
|
||||
let item_model = components
|
||||
.get("minecraft:item_model")
|
||||
.and_then(Value::as_str)
|
||||
@@ -180,8 +186,9 @@ fn use_duration(components: &Components) -> i32 {
|
||||
| "minecraft:crossbow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:spyglass"
|
||||
| "minecraft:goat_horn"
|
||||
| "minecraft:brush" => 72_000,
|
||||
| "minecraft:goat_horn" => 72_000,
|
||||
// BrushItem.USE_DURATION.
|
||||
"minecraft:brush" => 200,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -249,6 +256,11 @@ impl Game<'_> {
|
||||
owner.main_arm_left = self.settings.main_hand_left;
|
||||
owner.is_local_player = true;
|
||||
owner.is_view_entity = true;
|
||||
owner.last_death_location = self
|
||||
.level
|
||||
.last_death
|
||||
.clone()
|
||||
.map(|(dimension, pos)| GlobalPos { dimension, pos });
|
||||
if let (Some((slot, ticks)), Some(stack)) = (self.using, stack)
|
||||
&& self.stack_in(slot) == Some(stack)
|
||||
{
|
||||
|
||||
@@ -6,10 +6,15 @@ use super::Game;
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, Jukebox},
|
||||
items::ItemStack,
|
||||
mobs::Species,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::level::flags as level_flags;
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -97,7 +102,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
|
||||
fn start_song(&mut self, pos: [i32; 3], song: &str) {
|
||||
self.stop_song(pos);
|
||||
self.stop_sound(pos);
|
||||
let Some(s) = self.res.items().songs.get(song).cloned() else {
|
||||
return;
|
||||
};
|
||||
@@ -109,12 +114,38 @@ impl Game<'_> {
|
||||
self.titles
|
||||
.set_overlay(Text::translate("record.nowPlaying", &[description]), true);
|
||||
self.update_song_volumes();
|
||||
self.notify_record_playing(pos, true);
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_songs.push((pos, Some(song.to_owned())));
|
||||
}
|
||||
}
|
||||
fn stop_song(&mut self, pos: [i32; 3]) {
|
||||
fn stop_sound(&mut self, pos: [i32; 3]) {
|
||||
if let Some(id) = self.jukebox_sounds.remove(&(pos[0], pos[1], pos[2])) {
|
||||
self.audio.stop_and_release(id);
|
||||
}
|
||||
}
|
||||
/// Level event 1011: the song ends, here and for the guests.
|
||||
fn stop_song(&mut self, pos: [i32; 3]) {
|
||||
self.stop_sound(pos);
|
||||
self.notify_record_playing(pos, false);
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_songs.push((pos, None));
|
||||
}
|
||||
}
|
||||
/// `LevelEventHandler.notifyNearbyEntities` (level events 1010 and 1011):
|
||||
/// parrots within three blocks of the jukebox start or stop dancing.
|
||||
pub(super) fn notify_record_playing(&mut self, pos: [i32; 3], playing: bool) {
|
||||
let min = Vec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
|
||||
let area = Aabb::new(min, min.add_vector(Vec3::new(1.0, 1.0, 1.0)))
|
||||
.inflate(Vec3::new(3.0, 3.0, 3.0));
|
||||
for m in self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.species() == Species::Parrot && m.bounds().intersects(area))
|
||||
{
|
||||
m.extra.party_jukebox = playing.then_some(pos);
|
||||
}
|
||||
}
|
||||
/// `popOutTheItem`: the disc above the jukebox, and the song stops.
|
||||
fn eject_record(&mut self, pos: [i32; 3]) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
|
||||
@@ -14,7 +14,7 @@ use horizon_client::{
|
||||
hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::{Mob, Who, leash::Holder},
|
||||
mobs::{Mob, Species, Who, leash::Holder},
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -168,6 +168,7 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
| "minecraft:zombie_villager"
|
||||
| "minecraft:zombified_piglin"
|
||||
| "minecraft:hoglin"
|
||||
| "minecraft:zoglin"
|
||||
| "minecraft:goat"
|
||||
| "minecraft:ocelot"
|
||||
| "minecraft:turtle"
|
||||
@@ -324,9 +325,13 @@ fn mob_meta(
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
let kind = m.kind.id.as_str();
|
||||
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, glowing 0x40
|
||||
// (LivingEntity.updateGlowingStatus).
|
||||
let shared = i8::from(m.fire_ticks > 0) | i8::from(m.is_currently_glowing()) << 6;
|
||||
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, swimming 0x10 (a drowned
|
||||
// under water, `Drowned.setSwimming`), invisible 0x20 (the effect's flag)
|
||||
// and glowing 0x40 (LivingEntity.updateGlowingStatus).
|
||||
let shared = i8::from(m.fire_ticks > 0)
|
||||
| i8::from(m.species() == Species::Drowned && m.extra.flag) << 4
|
||||
| i8::from(m.invisible || horizon_client::mobs::trader::is_invisible(m)) << 5
|
||||
| i8::from(m.is_currently_glowing()) << 6;
|
||||
if shared != 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(shared)));
|
||||
}
|
||||
@@ -336,6 +341,12 @@ fn mob_meta(
|
||||
if pose != 0 {
|
||||
meta.push((6, serializer::POSE, Value::Id(i32::from(pose))));
|
||||
}
|
||||
// LivingEntity.DATA_SLEEPING_POS_ID: the bed a sleeping villager lies in.
|
||||
if horizon_client::mobs::villager::sleeping(m)
|
||||
&& let Some(bed) = m.extra.bed
|
||||
{
|
||||
meta.push((14, serializer::OPTIONAL_BLOCK_POS, Value::OptPos(Some(bed))));
|
||||
}
|
||||
if let Some(name) = &m.custom_name {
|
||||
meta.push((
|
||||
2,
|
||||
@@ -430,8 +441,14 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((17, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
// Piglin.DATA_IS_DANCING (after the baby flag and the crossbow charge).
|
||||
// AbstractPiglin.DATA_IMMUNE_TO_ZOMBIFICATION (16: with the dimension it
|
||||
// makes it shake) and Piglin.DATA_IS_DANCING (after the baby flag and
|
||||
// the crossbow charge).
|
||||
"minecraft:piglin" => {
|
||||
if e.flag2 {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
if e.scratch[0] > 0 {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
@@ -457,6 +474,13 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((19, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
"minecraft:parrot" => {
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
let flags = i8::from(e.sitting) | if e.tame { 4 } else { 0 };
|
||||
if flags != 0 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
}
|
||||
"minecraft:cat" | "minecraft:wolf" => {
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
let flags = i8::from(e.sitting) | if e.tame { 4 } else { 0 };
|
||||
@@ -478,6 +502,15 @@ fn mob_meta(
|
||||
if let Some(v) = id(registry, name) {
|
||||
meta.push((index, serializer, Value::Id(v)));
|
||||
}
|
||||
// Cat.IS_LYING and RELAX_STATE_ONE.
|
||||
if kind == "minecraft:cat" {
|
||||
for (index, on) in [(21, e.flag), (22, e.relax_one)] {
|
||||
if on {
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
}
|
||||
// Wolf.DATA_COLLAR_COLOR and Cat's, red by default.
|
||||
let collar = if kind == "minecraft:cat" { 23 } else { 21 };
|
||||
let red = i32::from(horizon_client::mobs::tame::RED);
|
||||
@@ -502,10 +535,16 @@ fn mob_meta(
|
||||
| "minecraft:skeleton_horse"
|
||||
| "minecraft:zombie_horse"
|
||||
| "minecraft:camel" => {
|
||||
// AbstractHorse.DATA_ID_FLAGS: tame 2.
|
||||
if e.tame {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(2)));
|
||||
// AbstractHorse.DATA_ID_FLAGS: tame 2, eating 16 (grazing), standing
|
||||
// 32 (rearing).
|
||||
let grazes = horizon_client::mobs::equine::grazes(m.species());
|
||||
let flags = if e.tame { 2 } else { 0 }
|
||||
| if grazes && e.flag { 16 } else { 0 }
|
||||
| if grazes && e.counter > 0 { 32 } else { 0 };
|
||||
if flags != 0 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
defaults.push((18, serializer::BYTE, Value::Byte(0)));
|
||||
if kind == "minecraft:horse" {
|
||||
let v = i32::from(e.variant) | i32::from(e.variant2) << 8;
|
||||
if v != 0 {
|
||||
@@ -527,9 +566,26 @@ fn mob_meta(
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// HappyGhast.STAYS_STILL (after AgeableMob's two and IS_LEASH_HOLDER),
|
||||
// set while `serverStillTimeout > 0` (`syncStayStillFlag`).
|
||||
"minecraft:happy_ghast" => {
|
||||
if horizon_client::mobs::happy_ghast::on_still_timeout(m) {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after Zombie's three).
|
||||
"minecraft:villager" | "minecraft:zombie_villager" => {
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
if kind == "minecraft:villager" {
|
||||
unhappy_counter(m, meta, defaults);
|
||||
} else {
|
||||
// ZombieVillager.DATA_CONVERTING_ID: the cure's shaking.
|
||||
if horizon_client::mobs::zombie_villager::is_converting(m) {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
let villager_type = VILLAGER_TYPES[e.variant as usize % VILLAGER_TYPES.len()];
|
||||
let profession = PROFESSIONS[e.variant2 as usize % PROFESSIONS.len()];
|
||||
if let (Some(t), Some(p)) = (
|
||||
@@ -724,7 +780,7 @@ fn mob_meta(
|
||||
defaults.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// Enderman.DATA_CARRY_STATE.
|
||||
// Enderman.DATA_CARRY_STATE and DATA_CREEPY (angry: the open mouth).
|
||||
"minecraft:enderman" => {
|
||||
if let Some(state) = e.carried {
|
||||
meta.push((
|
||||
@@ -733,6 +789,64 @@ fn mob_meta(
|
||||
Value::Id(state.0 as i32),
|
||||
));
|
||||
}
|
||||
if e.flag {
|
||||
meta.push((17, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
defaults.push((16, serializer::OPTIONAL_BLOCK_STATE, Value::Id(0)));
|
||||
}
|
||||
// Zombie.DATA_DROWNED_CONVERSION_ID (after DATA_BABY_ID and
|
||||
// DATA_SPECIAL_TYPE_ID): the shaking of the conversion under water.
|
||||
"minecraft:zombie" | "minecraft:husk" => {
|
||||
if e.counter2 > 0 {
|
||||
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two).
|
||||
"minecraft:wandering_trader" => unhappy_counter(m, meta, defaults),
|
||||
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
|
||||
// AbstractPiglin's, the first entry of its subclasses (the piglin's is
|
||||
// in its own arm): with the dimension it makes them shake.
|
||||
"minecraft:hoglin" | "minecraft:piglin_brute" => {
|
||||
let index = if kind == "minecraft:hoglin" { 18 } else { 16 };
|
||||
if e.flag2 {
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Blaze.DATA_FLAGS_ID: charged 1, which makes it burn.
|
||||
"minecraft:blaze" => {
|
||||
if e.flag {
|
||||
meta.push((16, serializer::BYTE, Value::Byte(1)));
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Turtle.HAS_EGG and LAYING_EGG (after AgeableMob's two).
|
||||
"minecraft:turtle" => {
|
||||
if e.flag {
|
||||
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
if e.flag2 {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// WitherBoss.DATA_TARGET_B and DATA_TARGET_C (after DATA_TARGET_A, the
|
||||
// main target): the entities the side heads look at, by id.
|
||||
"minecraft:wither" => {
|
||||
for (k, index) in [(0, 17), (1, 18)] {
|
||||
let target = match e.ints[k] {
|
||||
0 => None,
|
||||
-1 => player_entity,
|
||||
id => Some(id),
|
||||
};
|
||||
if let Some(target) = target {
|
||||
meta.push((index, serializer::INT, Value::Int(target)));
|
||||
}
|
||||
defaults.push((index, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -750,6 +864,19 @@ fn mob_meta(
|
||||
}
|
||||
}
|
||||
|
||||
/// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two): the head
|
||||
/// shake of a villager saying no counts it down from 40.
|
||||
fn unhappy_counter(
|
||||
m: &Mob,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
if m.extra.ints[0] > 0 {
|
||||
meta.push((18, serializer::INT, Value::Int(m.extra.ints[0])));
|
||||
}
|
||||
defaults.push((18, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
|
||||
/// What a client shakes a mob by: Entity.DATA_TICKS_FROZEN (7), whose full
|
||||
/// count is `isFullyFrozen`, and Strider.DATA_SUFFOCATING (19, after
|
||||
/// AgeableMob's two and DATA_BOOST_TIME).
|
||||
@@ -794,8 +921,17 @@ fn mob_equipment(
|
||||
let stack = ItemStack {
|
||||
item: item.clone(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
// A wolf's armor shows its cracks.
|
||||
damage: if slot == 6 && m.species() == horizon_client::mobs::Species::Wolf {
|
||||
e.body_damage.max(0) as u32
|
||||
} else {
|
||||
0
|
||||
},
|
||||
extra: if slot == 6 && m.species() == horizon_client::mobs::Species::Wolf {
|
||||
e.body_extra.clone()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
out.push((slot, hub.stack(&Some(stack))));
|
||||
}
|
||||
@@ -1014,7 +1150,8 @@ impl Game<'_> {
|
||||
let host_uuid = host.unwrap_or(0);
|
||||
self.next_entity += 1;
|
||||
let host_entity = self.next_entity as i32;
|
||||
let hub = Hub::new(lan, sync, encoder, config);
|
||||
let mut hub = Hub::new(lan, sync, encoder, config);
|
||||
hub.flat_world = self.world_flat;
|
||||
let port = hub.lan.port;
|
||||
self.world.block_log = Some(Vec::new());
|
||||
self.world.light_log = Some(Default::default());
|
||||
@@ -1077,6 +1214,7 @@ impl Game<'_> {
|
||||
self.world.light_log = None;
|
||||
self.audio.capture = None;
|
||||
self.lan_events.clear();
|
||||
self.lan_songs.clear();
|
||||
self.lan_critical_hits.clear();
|
||||
self.lan_cushion_breaks.clear();
|
||||
self.streaming.extra.clear();
|
||||
@@ -1299,6 +1437,9 @@ impl Game<'_> {
|
||||
&mut e.meta,
|
||||
&mut e.defaults,
|
||||
);
|
||||
// HappyGhast.aiStep: requiresPrecisePosition while on a still timeout.
|
||||
e.precise = m.species() == horizon_client::mobs::Species::HappyGhast
|
||||
&& horizon_client::mobs::happy_ghast::on_still_timeout(m);
|
||||
mob_freeze_meta(m, &mut e.meta, &mut e.defaults);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut e.attributes);
|
||||
@@ -1477,6 +1618,16 @@ impl Game<'_> {
|
||||
"INFO",
|
||||
&format!("System chat: {} joined the game", g.name),
|
||||
);
|
||||
} else if self.player.is_some()
|
||||
&& let Some(g) = host.hub.guest(conn)
|
||||
&& g.placed
|
||||
{
|
||||
// The host's chat hears of the newcomer as the guests do.
|
||||
let who = self.player_text(&g.name, g.uuid);
|
||||
self.system_message(
|
||||
Text::translate("multiplayer.player.joined", &[who])
|
||||
.with_color(horizon_client::text::YELLOW),
|
||||
);
|
||||
}
|
||||
}
|
||||
HostEvent::Left(mut guest) => {
|
||||
@@ -1511,6 +1662,13 @@ impl Game<'_> {
|
||||
"INFO",
|
||||
&format!("System chat: {} left the game", guest.name),
|
||||
);
|
||||
} else if self.player.is_some() && guest.placed {
|
||||
// ServerGamePacketListenerImpl.removePlayerFromWorld.
|
||||
let who = self.player_text(&guest.name, guest.uuid);
|
||||
self.system_message(
|
||||
Text::translate("multiplayer.player.left", &[who])
|
||||
.with_color(horizon_client::text::YELLOW),
|
||||
);
|
||||
}
|
||||
}
|
||||
HostEvent::Packet(conn, packet) => {
|
||||
@@ -1551,6 +1709,10 @@ impl Game<'_> {
|
||||
// A blast from a guest's attack, and a mace's wind burst.
|
||||
self.flush_lan_explosions();
|
||||
let _ = self.flush_wind_bursts();
|
||||
// A sweep attack's reach to the other players from a guest's attack.
|
||||
self.flush_sweeps();
|
||||
// A reeled-in hook pulling a player.
|
||||
self.flush_hook_pulls();
|
||||
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
|
||||
self.flush_lan_particles();
|
||||
}
|
||||
@@ -1653,10 +1815,11 @@ impl Game<'_> {
|
||||
match e {
|
||||
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), nor
|
||||
// the digger's own crack and hit sound (2019, 2020).
|
||||
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030 | 2019 | 2020) {
|
||||
// particles, bone meal's growth, anvil result sounds, or a
|
||||
// finished brushing (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 | 3008) {
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
@@ -1690,6 +1853,11 @@ impl Game<'_> {
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_entity_events.push((id, event, at));
|
||||
}
|
||||
// Event 21: a window that simulates the world itself is its own
|
||||
// client, and hears the guardian's hum too.
|
||||
if event == 21 && self.options.execution_role.is_client() {
|
||||
self.start_guardian_sound(id as u32);
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's
|
||||
/// emitter, and an animation for the attacker's client and observers once
|
||||
@@ -1718,6 +1886,7 @@ impl Game<'_> {
|
||||
/// Send the tick's level events, sounds and mob animations.
|
||||
fn lan_flush_effects(&mut self, host: &mut LanHost) {
|
||||
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
|
||||
let kinetic_hits = std::mem::take(&mut self.lan_kinetic_hits);
|
||||
for (state, at) in std::mem::take(&mut self.lan_cushion_breaks) {
|
||||
if let Some(id) = host
|
||||
.hub
|
||||
@@ -1752,6 +1921,16 @@ impl Game<'_> {
|
||||
}
|
||||
host.hub.level_event(kind, pos, data, acting);
|
||||
}
|
||||
// Level events 1010 and 1011: the guests' parrots dance or stop.
|
||||
for (pos, song) in std::mem::take(&mut self.lan_songs) {
|
||||
match song {
|
||||
Some(name) => {
|
||||
let id = host.hub.registry_id("minecraft:jukebox_song", &name);
|
||||
host.hub.level_event(1010, pos, id.unwrap_or(0), None);
|
||||
}
|
||||
None => host.hub.level_event(1011, pos, 0, None),
|
||||
}
|
||||
}
|
||||
for (pos, b0, b1, state) in std::mem::take(&mut self.lan_block_events) {
|
||||
host.hub.block_event(pos, b0, b1, state);
|
||||
}
|
||||
@@ -1796,7 +1975,12 @@ impl Game<'_> {
|
||||
// A projectile is the direct entity of its hit: not tracked here.
|
||||
let melee = matches!(
|
||||
source.kind,
|
||||
K::PlayerAttack | K::MaceSmash | K::MobAttack | K::Sting | K::SonicBoom
|
||||
K::PlayerAttack
|
||||
| K::MaceSmash
|
||||
| K::Spear
|
||||
| K::MobAttack
|
||||
| K::Sting
|
||||
| K::SonicBoom
|
||||
);
|
||||
let direct = cause.filter(|_| melee);
|
||||
host.hub.damage_event(m.id as i32, kind, cause, direct, at);
|
||||
@@ -1817,6 +2001,19 @@ impl Game<'_> {
|
||||
seen.insert(m.id, (m.hurt_time, dead, swing));
|
||||
}
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
// Level.broadcastEntityEvent(player, 2) of a spear's stab.
|
||||
for attacker in kinetic_hits {
|
||||
let hit = match attacker {
|
||||
None => self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| (host.host_entity, [p.position.x, p.position.y, p.position.z])),
|
||||
Some(conn) => host.hub.guest(conn).map(|g| (g.entity_id, g.position)),
|
||||
};
|
||||
if let Some((id, at)) = hit {
|
||||
host.hub.entity_event(id, 2, at);
|
||||
}
|
||||
}
|
||||
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)
|
||||
@@ -1881,10 +2078,18 @@ impl Game<'_> {
|
||||
m.head_yaw = g.head_yaw;
|
||||
m.head_pitch = g.pitch;
|
||||
let mut target = m.body_yaw;
|
||||
if dx * dx + dz * dz > 0.002_500_000_2 {
|
||||
target = (dz.atan2(dx) as f32).to_degrees() - 90.0;
|
||||
}
|
||||
let wrap = |a: f32| (a + 180.0).rem_euclid(360.0) - 180.0;
|
||||
if dx * dx + dz * dz > 0.002_500_000_2 {
|
||||
// LivingEntity.tick: walking backwards the body faces away
|
||||
// from the walk direction, not along it.
|
||||
let walk = (dz.atan2(dx) as f32).to_degrees() - 90.0;
|
||||
let facing = (wrap(g.yaw) - walk).abs();
|
||||
target = if 95.0 < facing && facing < 265.0 {
|
||||
walk - 180.0
|
||||
} else {
|
||||
walk
|
||||
};
|
||||
}
|
||||
m.body_yaw += wrap(target - m.body_yaw) * 0.3;
|
||||
let diff = wrap(g.yaw - m.body_yaw);
|
||||
if diff.abs() > 50.0 {
|
||||
@@ -1908,12 +2113,43 @@ impl Game<'_> {
|
||||
m.extra.crouching = g.sneaking;
|
||||
m.extra.avatar_pose = g.pose();
|
||||
m.extra.sprinting = g.sprinting;
|
||||
// Entity.DATA_POSE and the shared and living flags the others
|
||||
// get (hub::guest_body): gliding, swimming, Riptide's spin.
|
||||
let body = lan::hub::guest_body(g);
|
||||
m.extra.fall_flying = body.gliding;
|
||||
m.extra.synced_pose = if body.swimming || body.crawling { 3 } else { 0 };
|
||||
m.extra.auto_spin_attack = body.spin_attack;
|
||||
m.velocity = g
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or(Vec3::new(dx, dy, dz), |motion| motion.velocity);
|
||||
m.tick_avatar_pose(
|
||||
g.motion
|
||||
.as_ref()
|
||||
.is_some_and(|motion| motion.fluids.was_touching_water),
|
||||
);
|
||||
// CrossbowItem.isCharged: the held crossbow's projectiles.
|
||||
m.extra.charged = g
|
||||
.inventory
|
||||
.get(g.selected)
|
||||
.and_then(|s| s.as_ref())
|
||||
.is_some_and(|s| s.data().is_some_and(|d| !d.charged.is_empty()));
|
||||
// ClientInformation.mainHand (HumanoidArm: 0 left, 1 right).
|
||||
m.extra.main_hand_left = g.info.main_hand == 0;
|
||||
// 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);
|
||||
// Entity.isInvisible: the invisibility effect hides the body (the
|
||||
// model pass, the name tag rules and `trader::is_invisible` read it).
|
||||
m.extra.effects.retain(|e| e.0 != "minecraft:invisibility");
|
||||
if let Some(e) = g.effects.get("invisibility") {
|
||||
m.extra
|
||||
.effects
|
||||
.push(("minecraft:invisibility", e.duration, e.amplifier));
|
||||
}
|
||||
// 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.
|
||||
@@ -1924,6 +2160,18 @@ impl Game<'_> {
|
||||
let using = host.play.get(&g.conn).and_then(|p| p.using());
|
||||
m.extra.using_item = using.map_or(0, |(_, ticks)| ticks as i32 + 1);
|
||||
m.extra.using_off_hand = using.is_some_and(|(slot, _)| slot == OFFHAND_SLOT);
|
||||
// AvatarRenderer.getArmPose: the used item's BLOCK animation (an
|
||||
// item with `blocks_attacks`) raises the arm.
|
||||
m.extra.blocking = using
|
||||
.and_then(|(slot, _)| {
|
||||
if slot == OFFHAND_SLOT {
|
||||
m.extra.off_hand.as_ref()
|
||||
} else {
|
||||
m.extra.main_hand.as_ref()
|
||||
}
|
||||
})
|
||||
.and_then(|i| self.res.items().get(i))
|
||||
.is_some_and(|i| i.blocks_attacks.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1974,6 +2222,15 @@ impl Game<'_> {
|
||||
),
|
||||
out,
|
||||
);
|
||||
self.player_elytra_geometry(
|
||||
m,
|
||||
m.extra.chest.as_deref(),
|
||||
camera,
|
||||
alpha as f32,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
// LivingEntity.isCurrentlyGlowing: the guest's Glowing effect.
|
||||
let guest = host.hub.guest(*conn);
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
//! blocks, attacking and the commands all behave exactly as they do for the
|
||||
//! host, and every world change they cause reaches all players through the
|
||||
//! block log, the level events and the entity tracker.
|
||||
use super::{Game, GuestUse, Hit, Mining, Screen, lan::LanHost, respawn_anchor::RespawnConfig};
|
||||
use super::{
|
||||
Dim, Game, GuestUse, Hit, Mining, Screen, lan::LanHost, respawn_anchor::RespawnConfig,
|
||||
};
|
||||
use horizon_client::{
|
||||
command::vanilla,
|
||||
containers::BlockEntity,
|
||||
@@ -388,6 +390,7 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.adv.changed, &mut g.advancements_changed);
|
||||
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
|
||||
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
|
||||
std::mem::swap(&mut self.level.player_extra, &mut g.saved);
|
||||
std::mem::swap(&mut self.stats, &mut play.stats);
|
||||
std::mem::swap(&mut self.hook, &mut play.hook);
|
||||
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
|
||||
@@ -643,6 +646,7 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.adv.changed, &mut g.advancements_changed);
|
||||
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
|
||||
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
|
||||
std::mem::swap(&mut self.level.player_extra, &mut g.saved);
|
||||
std::mem::swap(&mut self.stats, &mut play.stats);
|
||||
std::mem::swap(&mut self.hook, &mut play.hook);
|
||||
self.adv.loaded = adv_loaded;
|
||||
@@ -971,6 +975,45 @@ impl Game<'_> {
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < range * range
|
||||
}
|
||||
|
||||
/// DedicatedServer.isUnderSpawnProtection: no operators, an operator or a
|
||||
/// radius of 0 or less is never under it; else a block within the radius
|
||||
/// of the world spawn (square, in the Overworld) is.
|
||||
fn under_spawn_protection(&self, host: &LanHost, gi: usize, pos: [i32; 3]) -> bool {
|
||||
let g = &host.hub.guests[gi];
|
||||
let radius = host.hub.spawn_protection;
|
||||
if self.options.dedicated.is_none() || radius <= 0 || g.dimension != "minecraft:overworld" {
|
||||
return false;
|
||||
}
|
||||
let Some(spawn) = self.level.world_spawn else {
|
||||
return false;
|
||||
};
|
||||
if host.hub.ops.is_empty() || host.hub.ops.contains_key(&g.uuid) {
|
||||
return false;
|
||||
}
|
||||
let dx = pos[0].abs_diff(spawn[0]);
|
||||
let dz = pos[2].abs_diff(spawn[2]);
|
||||
dx.max(dz) <= radius as u32
|
||||
}
|
||||
|
||||
/// ServerPlayer.sendSpawnProtectionMessage.
|
||||
fn spawn_protection_message(&self, host: &LanHost, conn: u32, pos: [i32; 3]) {
|
||||
let message = horizon_client::text::Text::translate(
|
||||
"build.spawn_protection",
|
||||
&[horizon_client::text::Text::literal(format!(
|
||||
"{}, {}, {}",
|
||||
pos[0], pos[1], pos[2]
|
||||
))],
|
||||
)
|
||||
.with_color(horizon_client::text::RED);
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&message.component()),
|
||||
overlay: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// A packet from a guest that the hub does not handle itself.
|
||||
pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
@@ -995,6 +1038,12 @@ impl Game<'_> {
|
||||
if spectator {
|
||||
return;
|
||||
}
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: no dig starts
|
||||
// under spawn protection (the ack puts the block back).
|
||||
if self.under_spawn_protection(host, gi, a.pos) {
|
||||
self.spawn_protection_message(host, conn, a.pos);
|
||||
return;
|
||||
}
|
||||
let face = direction(i32::from(a.direction));
|
||||
let play = host.play.entry(conn).or_default();
|
||||
play.digging = Some((a.pos, face));
|
||||
@@ -1086,6 +1135,8 @@ impl Game<'_> {
|
||||
g.player_on_equip("mainhand", offhand.as_ref(), main.as_ref());
|
||||
});
|
||||
}
|
||||
// STAB: `PiercingWeapon.attack` of a left click with a spear.
|
||||
8 if !spectator => self.guest_jab(host, conn),
|
||||
_ => {}
|
||||
},
|
||||
S::SetCarriedItem(p) => {
|
||||
@@ -1122,19 +1173,25 @@ impl Game<'_> {
|
||||
],
|
||||
};
|
||||
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
|
||||
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
|
||||
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
g.forced_hit = Some(hit);
|
||||
g.guest_use = Some(GuestUse::OnBlock(hand));
|
||||
let swings = g.swings_started;
|
||||
let _ = g.use_item();
|
||||
g.forced_hit = None;
|
||||
g.guest_use = None;
|
||||
g.swings_started != swings
|
||||
});
|
||||
self.lan_swing_back(host, conn, swung);
|
||||
self.guest_use_sounds(host, conn, logged, heard);
|
||||
// handleUseItemOn: under spawn protection nothing is used or
|
||||
// placed; the player is told and the blocks below go back.
|
||||
if self.under_spawn_protection(host, gi, p.pos) {
|
||||
self.spawn_protection_message(host, conn, p.pos);
|
||||
} else {
|
||||
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
|
||||
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
g.forced_hit = Some(hit);
|
||||
g.guest_use = Some(GuestUse::OnBlock(hand));
|
||||
let swings = g.swings_started;
|
||||
let _ = g.use_item();
|
||||
g.forced_hit = None;
|
||||
g.guest_use = None;
|
||||
g.swings_started != swings
|
||||
});
|
||||
self.lan_swing_back(host, conn, swung);
|
||||
self.guest_use_sounds(host, conn, logged, heard);
|
||||
}
|
||||
// The client predicted a result: correct it with what really is.
|
||||
for pos in [
|
||||
p.pos,
|
||||
@@ -1384,6 +1441,9 @@ impl Game<'_> {
|
||||
}
|
||||
self.sync_rules();
|
||||
}
|
||||
// handleTeleportToEntityPacket: a spectator goes to the player,
|
||||
// in whichever level they are.
|
||||
S::TeleportToEntity(p) if spectator => self.guest_spectate_teleport(host, gi, p.uuid),
|
||||
S::ChangeGameMode(p) if host.hub.guests[gi].permission >= 2 => {
|
||||
if let Some(mode) = usize::try_from(p.mode)
|
||||
.ok()
|
||||
@@ -1434,19 +1494,68 @@ impl Game<'_> {
|
||||
.unwrap_or_default();
|
||||
host.hub.send_stats(conn, &stats);
|
||||
}
|
||||
// handlePickItemFromBlock: a block within the block reach plus 1, its
|
||||
// data only with infinite materials.
|
||||
S::PickItemFromBlock(p) => {
|
||||
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
|
||||
let item = self.res.facts().get(state).item.clone();
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.creative() && &*item != "minecraft:air" {
|
||||
let slot = g.selected;
|
||||
g.inventory[slot] = Some(ItemStack::new(&item, 1));
|
||||
}
|
||||
self.guest_pick_item(host, conn, |g| {
|
||||
let eye = g.eye_position()?;
|
||||
let d2: f64 = (0..3)
|
||||
.map(|a| {
|
||||
let lo = f64::from(p.pos[a]);
|
||||
(eye[a] - eye[a].clamp(lo, lo + 1.0)).powi(2)
|
||||
})
|
||||
.sum();
|
||||
let range = g.reach() + 1.0;
|
||||
if d2 >= range * range {
|
||||
return None;
|
||||
}
|
||||
g.picked_stack(p.pos, g.creative && p.include_data)
|
||||
});
|
||||
}
|
||||
// handlePickItemFromEntity.
|
||||
S::PickItemFromEntity(p) => {
|
||||
self.guest_pick_item(host, conn, |g| {
|
||||
g.within_entity_range(p.id)
|
||||
.then(|| g.entity_pick_result(p.id))
|
||||
.flatten()
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.tryPickItem` for the guest: a matching stack
|
||||
/// is selected, else a creative player gets the item; the held slot is sent
|
||||
/// back and the changed slots follow with the tick's inventory changes.
|
||||
fn guest_pick_item(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
stack: impl FnOnce(&mut Self) -> Option<ItemStack>,
|
||||
) {
|
||||
let picked = self.as_guest(host, conn, |g| {
|
||||
let stack = stack(g)?;
|
||||
let infinite = g.creative;
|
||||
horizon_client::pick_item::pick(
|
||||
&mut g.inventory[..36],
|
||||
&mut g.selected,
|
||||
&stack,
|
||||
infinite,
|
||||
);
|
||||
Some(())
|
||||
});
|
||||
if picked.flatten().is_some()
|
||||
&& let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn)
|
||||
{
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SetHeldSlot(play::SetHeldSlot {
|
||||
slot: g.selected as i32,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn item_from_network(
|
||||
&self,
|
||||
host: &LanHost,
|
||||
@@ -1462,6 +1571,51 @@ impl Game<'_> {
|
||||
/// and `Player.remove`'s `inventoryMenu.removed` drops what the cursor
|
||||
/// held (`dropOrPlaceInInventory` for a removed player). Respawning
|
||||
/// sooner removes it too.
|
||||
/// `ServerGamePacketListenerImpl.handleTeleportToEntityPacket` for a
|
||||
/// player: the host's own, or a guest in the world.
|
||||
fn guest_spectate_teleport(&mut self, host: &mut LanHost, gi: usize, uuid: u128) {
|
||||
let own = host.hub.guests[gi].conn;
|
||||
let host_player = self
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|_| self.options.dedicated.is_none() && uuid == host.host_uuid)
|
||||
.map(|p| {
|
||||
(
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.dim,
|
||||
)
|
||||
});
|
||||
let target = host_player.or_else(|| {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && g.conn != own && g.uuid == uuid)
|
||||
.map(|g| (g.position, g.yaw, g.pitch, Dim::from_id(&g.dimension)))
|
||||
});
|
||||
let Some((at, yaw, pitch, dim)) = target else {
|
||||
return;
|
||||
};
|
||||
if Dim::from_id(&host.hub.guests[gi].dimension) != dim {
|
||||
// Only a dedicated server keeps its players in different levels.
|
||||
if self.options.dedicated.is_some() {
|
||||
self.level_teleports
|
||||
.push(super::server_travel::LevelTeleport {
|
||||
conn: own,
|
||||
dim,
|
||||
at,
|
||||
rotation: Some([yaw, pitch]),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
(g.yaw, g.pitch) = (yaw, pitch);
|
||||
// A new body there: no fall distance or motion carried over.
|
||||
g.motion = None;
|
||||
host.hub.teleport(gi, Some(at));
|
||||
}
|
||||
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.death_time += 1;
|
||||
@@ -1574,6 +1728,37 @@ impl Game<'_> {
|
||||
Some((centre, eye))
|
||||
}
|
||||
|
||||
/// `ServerGamePacketListenerImpl.handlePlayerAction` for STAB: the spear in
|
||||
/// the guest's main hand jabs once its charge allows it (with five ticks
|
||||
/// of tolerance). The guest's client is sent the dash Lunge gave them.
|
||||
fn guest_jab(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let at = g.position;
|
||||
// ServerPlayer.getKnownMovement: the move their client just sent.
|
||||
let movement = g
|
||||
.moves
|
||||
.last()
|
||||
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
|
||||
let sound = self
|
||||
.guest_hit_by(host, conn, |g| {
|
||||
g.known_movement = movement;
|
||||
if g.cannot_attack_with_item(g.attack_ticks as i32, 5) {
|
||||
None
|
||||
} else {
|
||||
g.jab()
|
||||
}
|
||||
})
|
||||
.flatten();
|
||||
// PiercingWeapon.makeSound: `playSound(causer, ...)`, so the guest's
|
||||
// own client plays it.
|
||||
if let Some(name) = sound {
|
||||
let source = horizon_client::settings::sound_source(name) as i32;
|
||||
host.hub.sound(name, at, 1.0, 1.0, source, Some(conn));
|
||||
}
|
||||
}
|
||||
|
||||
fn guest_attack(&mut self, host: &mut LanHost, conn: u32, id: i32) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
@@ -1614,9 +1799,21 @@ impl Game<'_> {
|
||||
if self.look_at_entity(host, gi, id, None).is_none() {
|
||||
return;
|
||||
}
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.attack();
|
||||
});
|
||||
// A mace's smash sets the attacker's velocity and sends it at once, before the
|
||||
// Wind Burst's push (MaceItem.hurtEnemy, then the post-attack effects).
|
||||
let sent = self
|
||||
.as_guest(host, conn, |g| {
|
||||
let marked = g
|
||||
.player
|
||||
.as_mut()
|
||||
.is_some_and(|p| std::mem::take(&mut p.health.sync_velocity));
|
||||
g.attack();
|
||||
let p = g.player.as_mut()?;
|
||||
let sent = std::mem::replace(&mut p.health.sync_velocity, marked);
|
||||
sent.then_some(p.velocity)
|
||||
})
|
||||
.flatten();
|
||||
self.send_guest_motion(host, conn, sent);
|
||||
// The mob now answers to this guest (game/lan_mobs.rs).
|
||||
if let Ok(id) = u32::try_from(id) {
|
||||
host.mob_players.insert(id, conn);
|
||||
@@ -1919,6 +2116,10 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(config) = elsewhere {
|
||||
self.respawn_trip(conn, config);
|
||||
} else if in_frame_bed.is_none() && host.hub.guests[i].spawn.is_none() {
|
||||
// TeleportTransition.createDefault: no point of its own, so the
|
||||
// world spawn's free column once its chunks have loaded.
|
||||
self.world_spawn_trip(conn);
|
||||
}
|
||||
if let Some(c) = in_frame_bed
|
||||
&& self.dim == super::Dim::Overworld
|
||||
@@ -1937,6 +2138,10 @@ impl Game<'_> {
|
||||
host.hub.guests[i].yaw = yaw;
|
||||
[at.x, at.y, at.z]
|
||||
});
|
||||
if found.is_none() {
|
||||
// TeleportTransition.missingRespawnBlock.
|
||||
self.world_spawn_trip(conn);
|
||||
}
|
||||
host.hub.teleport(i, Some(at));
|
||||
}
|
||||
if away && let Err(e) = self.enter_level(Some(host), back) {
|
||||
@@ -1959,6 +2164,10 @@ impl Game<'_> {
|
||||
.filter(|g| host.hub.here(g) && g.loaded)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
// The players the guests' hooks can catch.
|
||||
if host.play.values().any(|p| p.hook.is_some()) {
|
||||
self.refresh_hook_targets(host);
|
||||
}
|
||||
for conn in conns {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
continue;
|
||||
@@ -1982,7 +2191,13 @@ impl Game<'_> {
|
||||
let using = play.using.is_some();
|
||||
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
|
||||
if digging.is_some() || using || delayed {
|
||||
// ServerPlayer.getKnownMovement: the move their client just sent.
|
||||
let movement = host.hub.guests[gi]
|
||||
.moves
|
||||
.last()
|
||||
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.known_movement = movement;
|
||||
g.attack_ticks = g.attack_ticks.saturating_add(1);
|
||||
if let Some((pos, face)) = digging {
|
||||
g.forced_hit = Some(Hit {
|
||||
@@ -2054,6 +2269,7 @@ impl Game<'_> {
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.tick_fishing();
|
||||
g.tick_maps();
|
||||
g.tick_lodestone_compasses();
|
||||
g.count_tick_stats(sleeping);
|
||||
g.tick_advancements();
|
||||
g.update_player_attributes();
|
||||
@@ -2080,7 +2296,7 @@ impl Game<'_> {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
if g.health <= 0.0 && !g.dead {
|
||||
g.dead = true;
|
||||
g.last_death = Some(g.position.map(|v| v.floor() as i32));
|
||||
g.last_death = Some((g.dimension.clone(), g.position.map(|v| v.floor() as i32)));
|
||||
let (id, at) = (g.entity_id, g.position);
|
||||
let reward = Experience {
|
||||
level: g.xp_level,
|
||||
@@ -2161,11 +2377,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SleepStatus.areEnoughSleeping` and `areEnoughDeepSleeping` over the
|
||||
/// host's player and the guests: of the players not in spectator mode,
|
||||
/// players_sleeping_percentage of them (at least one) are in bed, and as
|
||||
/// many have slept 100 ticks.
|
||||
pub(super) fn enough_slept(&self) -> bool {
|
||||
/// `SleepStatus.update`'s counts over the host's player and the guests:
|
||||
/// the players not in spectator mode, how many are in bed, and how many
|
||||
/// of those have slept 100 ticks.
|
||||
fn sleep_counts(&self) -> (i64, i64, i64) {
|
||||
let (mut active, mut sleeping, mut deep) = (0i64, 0i64, 0i64);
|
||||
let mut count = |in_bed: bool, timer: u32| {
|
||||
active += 1;
|
||||
@@ -2189,11 +2404,64 @@ impl Game<'_> {
|
||||
count(g.bed.is_some(), g.sleep_timer);
|
||||
}
|
||||
}
|
||||
(active, sleeping, deep)
|
||||
}
|
||||
|
||||
/// `SleepStatus.sleepersNeeded`.
|
||||
fn sleepers_needed(&self, active: i64) -> i64 {
|
||||
let percent = i64::from(self.ui.rules.int("players_sleeping_percentage").max(0));
|
||||
let needed = ((active * percent + 99) / 100).max(1);
|
||||
((active * percent + 99) / 100).max(1)
|
||||
}
|
||||
|
||||
/// `SleepStatus.areEnoughSleeping` and `areEnoughDeepSleeping` over the
|
||||
/// host's player and the guests: of the players not in spectator mode,
|
||||
/// players_sleeping_percentage of them (at least one) are in bed, and as
|
||||
/// many have slept 100 ticks.
|
||||
pub(super) fn enough_slept(&self) -> bool {
|
||||
let (active, sleeping, deep) = self.sleep_counts();
|
||||
let needed = self.sleepers_needed(active);
|
||||
sleeping >= needed && deep >= needed
|
||||
}
|
||||
|
||||
/// `ServerLevel.updateSleepingPlayerList` and `announceSleepStatus`: when
|
||||
/// the players or the sleepers change, a published or dedicated world
|
||||
/// shows everyone "n/m players sleeping", or "Sleeping through this
|
||||
/// night" once enough are in bed, on the action bar.
|
||||
pub(super) fn announce_sleep_status(&mut self) {
|
||||
let (active, sleeping, _) = self.sleep_counts();
|
||||
let (old_active, old_sleeping) = self.sleep_seen;
|
||||
self.sleep_seen = (active, sleeping);
|
||||
let changed = (old_sleeping > 0 || sleeping > 0)
|
||||
&& (old_active != active || old_sleeping != sleeping);
|
||||
let published = self.lan.is_some() || self.options.dedicated.is_some();
|
||||
if !changed || !published {
|
||||
return;
|
||||
}
|
||||
// ServerLevel.canSleepThroughNights.
|
||||
if self.ui.rules.int("players_sleeping_percentage") > 100 {
|
||||
return;
|
||||
}
|
||||
let needed = self.sleepers_needed(active);
|
||||
let text = if sleeping >= needed {
|
||||
horizon_client::text::Text::translate("sleep.skipping_night", &[])
|
||||
} else {
|
||||
horizon_client::text::Text::translate(
|
||||
"sleep.players_sleeping",
|
||||
&[
|
||||
horizon_client::text::Text::literal(sleeping.to_string()),
|
||||
horizon_client::text::Text::literal(needed.to_string()),
|
||||
],
|
||||
)
|
||||
};
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub
|
||||
.overlay_message(horizon_client::lan::data::component_nbt(&text.component()));
|
||||
}
|
||||
if self.options.dedicated.is_none() {
|
||||
self.titles.set_overlay(text, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.wakeUpAllPlayers` for the guests.
|
||||
pub(super) fn wake_guests(&mut self) {
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
@@ -2249,16 +2517,19 @@ impl Game<'_> {
|
||||
if !g.effects.list.is_empty() {
|
||||
self.as_guest(host, conn, |game| game.tick_mob_effects());
|
||||
}
|
||||
self.as_guest(host, conn, |game| game.handle_shoulder_entities());
|
||||
return;
|
||||
}
|
||||
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
|
||||
let at = g.position;
|
||||
let (sneaking, sleeping) = (g.sneaking, g.sleeping);
|
||||
let riding = g.vehicle.is_some();
|
||||
let mut soul = None;
|
||||
let events = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
let freeze_immune = game.wears_freeze_immune();
|
||||
let burn_immune = game.wears_burn_from_stepping_immunity();
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
@@ -2273,6 +2544,12 @@ impl Game<'_> {
|
||||
w.player.health.advance_clock();
|
||||
let _ = w.fluid_base_tick();
|
||||
w.player.ground.on_ground = from_ground;
|
||||
// Player.aiStep: a flying player that isn't riding starts every tick
|
||||
// with no fall distance, so flying down in creative and switching to
|
||||
// survival lands without the flight's distance.
|
||||
if w.player.flying && !riding {
|
||||
w.player.fall_distance = 0.0;
|
||||
}
|
||||
let travelled: Vec<[f64; 3]> = moves.iter().map(|m| m.0).collect();
|
||||
// Entity.move's movementThisTick: each move, from where the
|
||||
// tick's first one started.
|
||||
@@ -2324,6 +2601,7 @@ impl Game<'_> {
|
||||
w.player.shift_key_down = sneaking;
|
||||
let _ = w.player.set_sleeping(sleeping);
|
||||
w.player.freeze_immune = freeze_immune;
|
||||
w.player.burn_from_stepping_immune = burn_immune;
|
||||
let _ = w.server_block_damage(start);
|
||||
let _ = w.update_soul_speed_block();
|
||||
let _ = w.tick_environment_damage();
|
||||
@@ -2352,12 +2630,16 @@ impl Game<'_> {
|
||||
for d in travelled {
|
||||
game.movement_stats(Vec3::new(d[0], d[1], d[2]));
|
||||
}
|
||||
// LivingEntity.baseTick: a new block position runs the worn
|
||||
// enchantments' location_changed effects (frost walker).
|
||||
game.location_changed_effects([start.x, start.y, start.z], from_ground);
|
||||
// LivingEntity.baseTick: outside the world border, then tickEffects.
|
||||
game.border_damage();
|
||||
game.tick_mob_effects();
|
||||
// Soul Speed's effects (LivingEntity.baseTick's location_changed
|
||||
// and tick effects); its particle goes to the players around.
|
||||
soul = game.tick_soul_speed();
|
||||
game.handle_shoulder_entities();
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
@@ -2396,7 +2678,10 @@ impl Game<'_> {
|
||||
if let Some((at, velocity)) = soul {
|
||||
super::lan_particles::send_soul_particle(host, at, velocity);
|
||||
}
|
||||
self.as_guest(host, conn, |game| game.tick_player_sculk_events());
|
||||
self.as_guest(host, conn, |game| {
|
||||
game.sculk_step_on_player();
|
||||
game.tick_player_sculk_events();
|
||||
});
|
||||
for event in events {
|
||||
match event {
|
||||
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
|
||||
@@ -2700,9 +2985,15 @@ impl Game<'_> {
|
||||
|
||||
/// Dropped items touching a guest go into their inventory.
|
||||
fn guest_pickups(&mut self, host: &mut LanHost, gi: usize) {
|
||||
let (conn, mode, position, id) = {
|
||||
let (conn, mode, position, id, uuid) = {
|
||||
let g = &host.hub.guests[gi];
|
||||
(g.conn, g.game_mode, g.position, g.entity_id)
|
||||
(
|
||||
g.conn,
|
||||
g.game_mode,
|
||||
g.position,
|
||||
g.entity_id,
|
||||
horizon_client::lan::playerdata::uuid_ints(g.uuid),
|
||||
)
|
||||
};
|
||||
if mode == SPECTATOR || self.dropped.is_empty() {
|
||||
return;
|
||||
@@ -2713,6 +3004,7 @@ impl Game<'_> {
|
||||
.enumerate()
|
||||
.filter(|(_, d)| {
|
||||
d.pickup_delay == 0
|
||||
&& d.target.is_none_or(|t| t == uuid)
|
||||
&& (d.position.x - position[0]).abs() < 1.5
|
||||
&& (d.position.z - position[2]).abs() < 1.5
|
||||
&& d.position.y > position[1] - 0.6
|
||||
@@ -2955,7 +3247,8 @@ impl Game<'_> {
|
||||
let packet = swing_packet(id, c.hand, c.animation);
|
||||
host.hub.broadcast_near(at, 128.0, &packet, Some(conn));
|
||||
if let Some(m) = host.models.get_mut(&conn) {
|
||||
m.swing();
|
||||
// The duration already counts the guest's haste or fatigue.
|
||||
m.swing_hand(c.hand == Hand::Off, c.duration_ticks);
|
||||
}
|
||||
}
|
||||
play.swing.tick();
|
||||
|
||||
@@ -24,6 +24,37 @@ fn slot_in_group(group: &str, slot: &str) -> bool {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `getAttributeValue(KNOCKBACK_RESISTANCE)` of a mob: its own value plus
|
||||
/// the modifiers of the items it wears or holds in matching slots.
|
||||
pub(super) fn mob_knockback_resistance(&self, m: &Mob) -> f64 {
|
||||
let e = &m.extra;
|
||||
let slots = [
|
||||
("mainhand", &e.main_hand),
|
||||
("offhand", &e.off_hand),
|
||||
("feet", &e.feet),
|
||||
("legs", &e.legs),
|
||||
("chest", &e.chest),
|
||||
("head", &e.head),
|
||||
];
|
||||
let mut extra = Vec::new();
|
||||
for (slot, item) in slots {
|
||||
let Some(info) = item.as_ref().and_then(|i| self.res.items().get(i)) else {
|
||||
continue;
|
||||
};
|
||||
for (attribute, _, amount, op, group) in &info.modifiers {
|
||||
if attribute == "minecraft:knockback_resistance" && slot_in_group(group, slot) {
|
||||
let op = match operation_named(op) {
|
||||
1 => Op::AddMultipliedBase,
|
||||
2 => Op::AddMultipliedTotal,
|
||||
_ => Op::AddValue,
|
||||
};
|
||||
extra.push((*amount, op));
|
||||
}
|
||||
}
|
||||
}
|
||||
finalize::value_with(m, Attr::KnockbackResistance, &extra)
|
||||
}
|
||||
|
||||
/// The registry id of a client-syncable attribute the entity type has
|
||||
/// (`AttributeMap.getInstance` is null for the others), by name.
|
||||
fn synced_attribute(&self, entity: &str, name: &str) -> Option<i32> {
|
||||
|
||||
@@ -425,6 +425,9 @@ impl Game<'_> {
|
||||
let mut e = state(t.id, "minecraft:trident", at(t.position), t.yaw, false);
|
||||
e.pitch = t.pitch;
|
||||
e.velocity = at(t.velocity);
|
||||
// Projectile.getAddEntityPacket: the owner's id; a trident here is
|
||||
// always the host player's.
|
||||
e.data = owner(Some(horizon_client::mobs::Who::Player));
|
||||
e.meta = arrow_meta(false, t.no_physics, 0, t.in_ground);
|
||||
// ThrownTrident.ID_LOYALTY and ID_FOIL.
|
||||
if t.loyalty != 0 {
|
||||
@@ -491,7 +494,9 @@ impl Game<'_> {
|
||||
// The dragon's breath and lingering potion clouds (AreaEffectCloud's DATA_RADIUS,
|
||||
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
|
||||
// client draws them from these (`tick_remote_clouds`).
|
||||
let breath = self.network_particle(
|
||||
// The hub is out of `self.lan` meanwhile, so it is passed in.
|
||||
let breath = Self::hub_particle(
|
||||
hub,
|
||||
&horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::DragonBreath,
|
||||
),
|
||||
|
||||
@@ -26,6 +26,20 @@ pub(super) fn player_for(
|
||||
guests: &GuestViews,
|
||||
bound: Option<u32>,
|
||||
) -> Option<u32> {
|
||||
// A tame cat reads its owner wherever they are while they sleep, or just
|
||||
// woke up (`CatRelaxOnOwnerGoal` uses `getOwner`, not the nearest player).
|
||||
if m.species() == Species::Cat
|
||||
&& m.extra.tame
|
||||
&& let Some(owner) = m.extra.owner
|
||||
{
|
||||
let asleep = |v: &PlayerView| v.uuid == owner && (v.sleeping || v.sleep_timer > 0);
|
||||
if host.is_some_and(asleep) {
|
||||
return None;
|
||||
}
|
||||
if let Some((conn, _)) = guests.iter().find(|(_, v)| asleep(v)) {
|
||||
return Some(*conn);
|
||||
}
|
||||
}
|
||||
let targeting = m.ai.target == Some(Who::Player)
|
||||
|| m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100;
|
||||
// A tame wolf or cat sees its owner (`TamableAnimal.getOwner`), while they
|
||||
@@ -81,7 +95,12 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
MobEvent::AttackPlayer(..)
|
||||
| MobEvent::AttackPlayerWithEffect { .. }
|
||||
| MobEvent::DamagePlayer { .. }
|
||||
| MobEvent::SonicBoom {
|
||||
target: Who::Player,
|
||||
..
|
||||
}
|
||||
| MobEvent::Push {
|
||||
target: Who::Player,
|
||||
..
|
||||
@@ -100,6 +119,7 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
|
||||
}
|
||||
| MobEvent::EffectPlayer { .. }
|
||||
| MobEvent::AxolotlSupportPlayer
|
||||
| MobEvent::ParrotToShoulder { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -373,6 +393,7 @@ mod tests {
|
||||
alive: true,
|
||||
creative,
|
||||
sneaking: false,
|
||||
swimming: false,
|
||||
look: [0.0; 3],
|
||||
main_hand: None,
|
||||
off_hand: None,
|
||||
@@ -392,6 +413,8 @@ mod tests {
|
||||
uuid: [0; 4],
|
||||
allies: Default::default(),
|
||||
remote: false,
|
||||
sleeping: false,
|
||||
sleep_timer: 0,
|
||||
passenger: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,40 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.sendParticles` of a mob's particles: a packet to each
|
||||
/// player within 32 blocks. The window draws its own copy.
|
||||
pub(super) fn lan_level_particles(
|
||||
&self,
|
||||
options: &ParticleOptions,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: u32,
|
||||
) {
|
||||
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, None)) else {
|
||||
return;
|
||||
};
|
||||
let packet = C::LevelParticles(play::LevelParticles {
|
||||
particle,
|
||||
override_limiter: false,
|
||||
always_show: false,
|
||||
pos: at,
|
||||
dist: spread.map(|d| d as f32),
|
||||
max_speed: [speed as f32; 3],
|
||||
count: count as i32,
|
||||
randomization: 0,
|
||||
});
|
||||
for g in host.hub.guests.iter().filter(|g| host.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `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
|
||||
@@ -156,7 +190,7 @@ impl Game<'_> {
|
||||
|
||||
/// `network_particle` for a hub that is out of `self.lan` meanwhile (a
|
||||
/// guest's turn).
|
||||
fn hub_particle(
|
||||
pub(super) fn hub_particle(
|
||||
hub: &Hub,
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
|
||||
@@ -12,6 +12,7 @@ use horizon_client::{
|
||||
mobs::Who,
|
||||
projectiles::{Hit, Projectile, ProjectileKind},
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// The player projectiles that meet other players: arrows (their hurt), and
|
||||
/// snowballs and eggs, which break on them (`Player.hurtServer` takes no 0
|
||||
@@ -24,7 +25,7 @@ pub(super) fn meets_players(kind: &ProjectileKind) -> bool {
|
||||
}
|
||||
|
||||
/// Who a player's attack lands on.
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Victim {
|
||||
Host,
|
||||
Guest(u32),
|
||||
@@ -40,6 +41,8 @@ pub(super) struct Strike {
|
||||
/// `Player.attack`'s `magicBoost > 0`: the enchantments added damage.
|
||||
magic: bool,
|
||||
from: [f64; 3],
|
||||
/// `getSecondsToDisableBlocking` of the attacker's weapon.
|
||||
disable_blocking: f32,
|
||||
sweep: Option<[f64; 3]>,
|
||||
/// `causeExtraKnockback`: its strength and direction (the attacker's
|
||||
/// yaw), and whether the hit is a sprinting knockback attack.
|
||||
@@ -47,6 +50,26 @@ pub(super) struct Strike {
|
||||
knockback_attack: bool,
|
||||
/// The attacker's team, for the victim's `Player.canHarmPlayer`.
|
||||
team: Option<horizon_client::scoreboard::Team>,
|
||||
/// The sweep's damage to the players around the victim.
|
||||
sweep_hit: Option<Sweep>,
|
||||
}
|
||||
|
||||
/// A sweep attack's reach to the other players (`Player.doSweepAttack`),
|
||||
/// worked out as the attacker.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Sweep {
|
||||
/// The attacking guest (none: the host).
|
||||
attacker: Option<u32>,
|
||||
from: [f64; 3],
|
||||
/// The damage after the enchantments and the attack strength.
|
||||
damage: f32,
|
||||
/// The push along the attacker's yaw.
|
||||
knockback: (f64, f64),
|
||||
team: Option<horizon_client::scoreboard::Team>,
|
||||
/// The target's box grown by (1, 0.25, 1).
|
||||
pub(super) hit_box: Aabb,
|
||||
/// The player the sweep started from, which it spares.
|
||||
skip: Option<Victim>,
|
||||
}
|
||||
|
||||
impl Strike {
|
||||
@@ -125,10 +148,22 @@ impl Game<'_> {
|
||||
let from = [p.position.x, p.position.y, p.position.z];
|
||||
let (amount, scale, crit, magic) = self.melee_damage("minecraft:player");
|
||||
let sweep = self.sweep_attack_position(scale, crit);
|
||||
let sweep_hit = sweep.map(|_| {
|
||||
let base = self.sweep_base_damage(scale);
|
||||
self.player_sweep(base, scale)
|
||||
});
|
||||
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
|
||||
let (xd, zd) = self.extra_knockback_direction();
|
||||
self.attack_ticks = 0;
|
||||
self.swing_arm();
|
||||
// LivingEntity.getSecondsToDisableBlocking: the held weapon's value,
|
||||
// none while another item is in use.
|
||||
let disable_blocking = if self.using.is_some() {
|
||||
0.0
|
||||
} else {
|
||||
self.attack_item_info()
|
||||
.map_or(0.0, |i| i.disable_blocking_seconds)
|
||||
};
|
||||
Some(Strike {
|
||||
attacker: self.lan_acting,
|
||||
amount,
|
||||
@@ -136,10 +171,12 @@ impl Game<'_> {
|
||||
crit,
|
||||
magic,
|
||||
from,
|
||||
disable_blocking,
|
||||
sweep,
|
||||
knockback: (strength, xd, zd),
|
||||
knockback_attack,
|
||||
team: self.player_team().cloned(),
|
||||
sweep_hit,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -250,7 +287,9 @@ impl Game<'_> {
|
||||
// Player.attack: the hurt (with its knockback away from the
|
||||
// attacker), then causeExtraKnockback along the attacker's yaw.
|
||||
let land = |g: &mut Self| {
|
||||
g.combat.player_attacker = Some((s.from, s.disable_blocking));
|
||||
let hurt = g.mob_hits_player(s.amount, s.from, "minecraft:player_attack");
|
||||
g.combat.player_attacker = None;
|
||||
let (strength, xd, zd) = s.knockback;
|
||||
if hurt && strength > 0.0 {
|
||||
g.knock_back_player(f64::from(strength), xd, zd);
|
||||
@@ -331,9 +370,118 @@ impl Game<'_> {
|
||||
};
|
||||
if let Some(at) = s.sweep.filter(|_| hurt) {
|
||||
self.sweep_attack(at, Some(s.from));
|
||||
// The other players next to the victim (Player.doSweepAttack).
|
||||
let bounds = match victim {
|
||||
Victim::Host => self.player.as_ref().map(|p| p.bounds()),
|
||||
Victim::Guest(conn) => host.hub.guest(conn).map(guest_bounds),
|
||||
};
|
||||
if let (Some(sweep), Some(bounds)) = (&s.sweep_hit, bounds) {
|
||||
self.sweep_players(
|
||||
host,
|
||||
&Sweep {
|
||||
hit_box: bounds.inflate(Vec3::new(1.0, 0.25, 1.0)),
|
||||
skip: Some(victim),
|
||||
..sweep.clone()
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.audio.play(sound, Some(s.from), 1.0, 1.0);
|
||||
}
|
||||
hurt
|
||||
}
|
||||
|
||||
/// The sweep of the player in place, for the players it reaches: the
|
||||
/// damage (`1 + ratio * baseDamage`, plus the enchantments against a
|
||||
/// player, scaled by the attack strength) and who hits. The box is set
|
||||
/// by the caller.
|
||||
pub(super) fn player_sweep(&mut self, base: f32, scale: f32) -> Sweep {
|
||||
let from = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map_or([0.0; 3], |p| [p.position.x, p.position.y, p.position.z]);
|
||||
let damage = (base + self.enchant_damage_bonus("minecraft:player")) * scale;
|
||||
Sweep {
|
||||
attacker: self.lan_acting,
|
||||
from,
|
||||
damage,
|
||||
knockback: self.extra_knockback_direction(),
|
||||
team: self.player_team().cloned(),
|
||||
hit_box: Aabb::new(Vec3::ZERO, Vec3::ZERO),
|
||||
skip: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A sweep from a mob hit reaches the other players. Inside a guest's
|
||||
/// turn the host holds the other players (`as_guest`), so it waits for
|
||||
/// the turn to end.
|
||||
pub(super) fn sweep_other_players(&mut self, sweep: Sweep) {
|
||||
if self.lan_acting.is_some() {
|
||||
self.creatures.sweeps.push(sweep);
|
||||
} else if let Some(mut host) = self.lan.take() {
|
||||
self.sweep_players(&mut host, &sweep);
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sweeps guests' turns put off.
|
||||
pub(super) fn flush_sweeps(&mut self) {
|
||||
for sweep in std::mem::take(&mut self.creatures.sweeps) {
|
||||
self.sweep_other_players(sweep);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.doSweepAttack` on the players: each other player in the box,
|
||||
/// closer than 3 blocks (`distanceToSqr < 9`) and not an ally
|
||||
/// (`isAlliedTo`), is hurt (`ServerPlayer.canHarmPlayer`: the `pvp` rule)
|
||||
/// and pushed 0.4 along the attacker's yaw.
|
||||
pub(super) fn sweep_players(&mut self, host: &mut LanHost, sweep: &Sweep) {
|
||||
let from = Vec3::new(sweep.from[0], sweep.from[1], sweep.from[2]);
|
||||
let mut victims = Vec::new();
|
||||
if sweep.attacker.is_some()
|
||||
&& self.options.dedicated.is_none()
|
||||
&& !self.spectator()
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
&& !p.health.dead_or_dying()
|
||||
&& p.bounds().intersects(sweep.hit_box)
|
||||
&& p.position.distance_squared(from) < 9.0
|
||||
{
|
||||
victims.push((Victim::Host, self.player_team().cloned()));
|
||||
}
|
||||
for g in &host.hub.guests {
|
||||
if host.hub.here(g)
|
||||
&& g.loaded
|
||||
&& !g.dead
|
||||
&& !g.spectator()
|
||||
&& sweep.attacker != Some(g.conn)
|
||||
&& guest_bounds(g).intersects(sweep.hit_box)
|
||||
&& Vec3::new(g.position[0], g.position[1], g.position[2]).distance_squared(from)
|
||||
< 9.0
|
||||
{
|
||||
let team = self.score.board.players_team(&g.name).cloned();
|
||||
victims.push((Victim::Guest(g.conn), team));
|
||||
}
|
||||
}
|
||||
let (xd, zd) = sweep.knockback;
|
||||
let land = |g: &mut Self| {
|
||||
let hurt = g.mob_hits_player(sweep.damage, sweep.from, "minecraft:player_attack");
|
||||
if hurt {
|
||||
g.knock_back_player(0.4, xd, zd);
|
||||
}
|
||||
};
|
||||
for (victim, team) in victims {
|
||||
if sweep.skip == Some(victim)
|
||||
|| horizon_client::scoreboard::teams_allied(team.as_ref(), sweep.team.as_ref())
|
||||
|| !self.ui.rules.bool("pvp")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match victim {
|
||||
Victim::Host => land(self),
|
||||
Victim::Guest(conn) => {
|
||||
self.guest_struck_by(host, conn, land);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,8 @@ impl Game<'_> {
|
||||
self.as_guest(host, conn, |game| {
|
||||
game.give_levels(-need);
|
||||
game.stats.custom("enchant_item", 1);
|
||||
// CriteriaTriggers.ENCHANTED_ITEM, with the levels it cost.
|
||||
game.trigger_enchanted_item(&enchanted, need);
|
||||
});
|
||||
host.hub.guests[gi].xp_seed = self.world.random.next_int();
|
||||
let env = self.menu_env();
|
||||
|
||||
@@ -46,7 +46,7 @@ impl Game<'_> {
|
||||
// 3002 with an axis: a lightning rod; otherwise copper cleaned by lightning.
|
||||
match usize::try_from(data) {
|
||||
Ok(axis) if id == 3002 && axis < 3 => {
|
||||
particles::along_axis(random, axis, p, |p, v| spawned.push((p, v)));
|
||||
particles::along_axis(random, axis, p, (10, 19), |p, v| spawned.push((p, v)));
|
||||
}
|
||||
_ => particles::on_block_faces(random, p, |p, v| spawned.push((p, v))),
|
||||
}
|
||||
|
||||
@@ -46,8 +46,12 @@ impl Game<'_> {
|
||||
if !self.with_level(|b, level, _| b.is_raining_at(level, p)) {
|
||||
continue;
|
||||
}
|
||||
// Skeleton trap: effective difficulty / 100 (regional difficulty approximated).
|
||||
let difficulty = f64::from(self.ui.difficulty) * 0.75;
|
||||
// Skeleton trap: the local effective difficulty / 100
|
||||
// (`getCurrentDifficultyAt(pos)`).
|
||||
let difficulty = f64::from(
|
||||
self.local_difficulty([f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])])
|
||||
.effective,
|
||||
);
|
||||
let below = &self
|
||||
.res
|
||||
.facts()
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
enchant::EffectContext,
|
||||
items::ItemStack,
|
||||
mobs::{Damage, Mob, Species},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
@@ -135,7 +136,7 @@ impl Game<'_> {
|
||||
/// `LivingEntity.hurtServer` of the mob at `index` by the player's hit:
|
||||
/// the weapon's `armor_effectiveness` enchantments (Breach) thin its armor.
|
||||
pub(super) fn hurt_by_player(&mut self, index: usize, amount: f32, source: Damage) -> bool {
|
||||
let held = self.inventory[self.selected].clone();
|
||||
let held = self.held().cloned();
|
||||
let kind = self.mobs[index].kind.id.clone();
|
||||
let ctx = EffectContext {
|
||||
this: Some(&kind),
|
||||
@@ -144,6 +145,7 @@ impl Game<'_> {
|
||||
is_direct: true,
|
||||
damage_type: Some(match source.kind {
|
||||
horizon_client::mobs::DamageKind::MaceSmash => "minecraft:mace_smash",
|
||||
horizon_client::mobs::DamageKind::Spear => "minecraft:spear",
|
||||
_ => "minecraft:player_attack",
|
||||
}),
|
||||
items: Some(self.res.items()),
|
||||
@@ -173,7 +175,7 @@ impl Game<'_> {
|
||||
/// the post-attack enchantments, then `postHurtEnemy` with the weapon's
|
||||
/// wear.
|
||||
pub(super) fn weapon_hit(&mut self, index: usize) {
|
||||
let Some(stack) = self.inventory[self.selected].clone() else {
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return;
|
||||
};
|
||||
let weapon = self
|
||||
@@ -193,16 +195,26 @@ impl Game<'_> {
|
||||
if smash && let Some(p) = self.player.as_mut() {
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
// ItemStack.postHurtEnemy: hurtAndBreak(weapon.itemDamagePerAttack).
|
||||
self.weapon_wear(&stack);
|
||||
}
|
||||
/// `ItemStack.postHurtEnemy`: `hurtAndBreak(weapon.itemDamagePerAttack)` of
|
||||
/// the weapon in the acting hand.
|
||||
pub(super) fn weapon_wear(&mut self, stack: &ItemStack) {
|
||||
let weapon = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.weapon_damage.map(|d| (d, i.max_damage)));
|
||||
if let Some((per_attack, max)) = weapon
|
||||
&& max > 0
|
||||
&& !self.creative
|
||||
{
|
||||
let loss = self.durability_loss(&stack, per_attack);
|
||||
if let Some(held) = self.inventory[self.selected].as_mut() {
|
||||
let loss = self.durability_loss(stack, per_attack);
|
||||
let cell = self.held_mut();
|
||||
if let Some(held) = cell.as_mut() {
|
||||
held.damage += loss;
|
||||
if held.damage >= max {
|
||||
self.inventory[self.selected] = None;
|
||||
*cell = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,6 +228,9 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
p.velocity = Vec3::new(p.velocity.x, f64::from(0.01f32), p.velocity.z);
|
||||
// A server player's client is told (ClientboundSetEntityMotionPacket): it moves by
|
||||
// itself, so without it a Wind Burst's push would add to the speed of the fall.
|
||||
p.health.sync_velocity = true;
|
||||
// calculateImpactPosition: an earlier impact below the player stays.
|
||||
let impact = match p.impulse.impact_position {
|
||||
Some(at) if at.y <= p.position.y => at,
|
||||
|
||||
@@ -648,27 +648,41 @@ impl Game<'_> {
|
||||
self.container_changed(pos);
|
||||
}
|
||||
}
|
||||
/// `HopperBlockEntity.ejectItems`.
|
||||
/// `HopperBlockEntity.ejectItems`: into the block container in front, else
|
||||
/// a container entity there (`getContainerAt`).
|
||||
fn hopper_eject(&mut self, pos: Pos, hopper: &View) -> bool {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let c = self.containers();
|
||||
let Some(facing) = c.prop(state, "facing").and_then(dir_from_name) else {
|
||||
return false;
|
||||
};
|
||||
let Some(target) = c.view(offset(pos, facing), false) else {
|
||||
let front = offset(pos, facing);
|
||||
let face = facing.opposite();
|
||||
if let Some(target) = c.view(front, false) {
|
||||
return self.eject_into(pos, hopper, &target, face);
|
||||
}
|
||||
let Some(id) = self.entity_container_at([
|
||||
f64::from(front.0) + 0.5,
|
||||
f64::from(front.1) + 0.5,
|
||||
f64::from(front.2) + 0.5,
|
||||
]) else {
|
||||
return false;
|
||||
};
|
||||
let face = facing.opposite();
|
||||
if c.is_full(&target, face) {
|
||||
self.with_entity_container(id, |g, target| g.eject_into(pos, hopper, target, face))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
/// The rest of `HopperBlockEntity.ejectItems`: one item into `target`.
|
||||
fn eject_into(&mut self, pos: Pos, hopper: &View, target: &View, face: Direction) -> bool {
|
||||
if self.containers().is_full(target, face) {
|
||||
return false;
|
||||
}
|
||||
for slot in 0..hopper.len() {
|
||||
let Some(one) = self.take_one(hopper, slot) else {
|
||||
continue;
|
||||
};
|
||||
match self.add_item(Some(pos), &target, one, Some(face)) {
|
||||
match self.add_item(Some(pos), target, one, Some(face)) {
|
||||
None => {
|
||||
self.view_changed(&target);
|
||||
self.view_changed(target);
|
||||
return true;
|
||||
}
|
||||
Some(rest) => self.put_back(hopper, slot, rest),
|
||||
@@ -679,7 +693,12 @@ impl Game<'_> {
|
||||
/// `HopperBlockEntity.suckInItems`: from the container above, or item entities.
|
||||
fn hopper_suck(&mut self, pos: Pos, hopper: &View) -> bool {
|
||||
let above = (pos.0, pos.1 + 1, pos.2);
|
||||
if let Some(took) = self.suck_from_container(above, hopper) {
|
||||
let at = [
|
||||
f64::from(pos.0) + 0.5,
|
||||
f64::from(pos.1) + 1.5,
|
||||
f64::from(pos.2) + 0.5,
|
||||
];
|
||||
if let Some(took) = self.suck_from_container(above, at, hopper) {
|
||||
return took;
|
||||
}
|
||||
let above_state = self.world.block(above.0, above.1, above.2);
|
||||
@@ -713,11 +732,16 @@ impl Game<'_> {
|
||||
self.suck_item_entities(hopper, min, max)
|
||||
}
|
||||
/// The container half of `HopperBlockEntity.suckInItems`: one item from the
|
||||
/// container at `above`, through its bottom face. `None` when there is no
|
||||
/// container there.
|
||||
fn suck_from_container(&mut self, above: Pos, hopper: &View) -> Option<bool> {
|
||||
/// container at `above`, through its bottom face; with no block container
|
||||
/// there, from a container entity whose box meets the one around `at`
|
||||
/// (`getSourceContainerOrHandler`). `None` when there is no container.
|
||||
fn suck_from_container(&mut self, above: Pos, at: [f64; 3], hopper: &View) -> Option<bool> {
|
||||
let c = self.containers();
|
||||
let source = c.view(above, false)?;
|
||||
let Some(source) = c.view(above, false) else {
|
||||
let id = self.entity_container_at(at)?;
|
||||
return self
|
||||
.with_entity_container(id, |g, source| g.suck_from_slots(None, source, hopper));
|
||||
};
|
||||
if let Kind::Composter(level) = source.kind {
|
||||
if level != 8 {
|
||||
return Some(false);
|
||||
@@ -736,26 +760,133 @@ impl Game<'_> {
|
||||
return Some(moved);
|
||||
}
|
||||
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
|
||||
Some(self.suck_from_slots(source_hopper, &source, hopper))
|
||||
}
|
||||
/// `HopperBlockEntity.tryTakeInItemFromSlot` over the `DOWN` face of `source`.
|
||||
fn suck_from_slots(
|
||||
&mut self,
|
||||
source_hopper: Option<Pos>,
|
||||
source: &View,
|
||||
hopper: &View,
|
||||
) -> bool {
|
||||
for slot in source.slots_for_face(Some(Direction::Down)) {
|
||||
let c = self.containers();
|
||||
let Some(stack) = c.get(&source, slot) else {
|
||||
let Some(stack) = c.get(source, slot) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(&source, slot, stack, Direction::Down) {
|
||||
if !c.can_take(source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
let Some(one) = self.take_one(&source, slot) else {
|
||||
let Some(one) = self.take_one(source, slot) else {
|
||||
continue;
|
||||
};
|
||||
match self.add_item(source_hopper, hopper, one, None) {
|
||||
None => {
|
||||
self.view_changed(&source);
|
||||
return Some(true);
|
||||
self.view_changed(source);
|
||||
return true;
|
||||
}
|
||||
Some(rest) => self.put_back(&source, slot, rest),
|
||||
Some(rest) => self.put_back(source, slot, rest),
|
||||
}
|
||||
}
|
||||
Some(false)
|
||||
false
|
||||
}
|
||||
/// `HopperBlockEntity.getEntityContainer`: one of the alive container
|
||||
/// entities (`EntitySelector.CONTAINER_ENTITY_SELECTOR`: chest and hopper
|
||||
/// minecarts, chest boats) whose box meets the 1x1x1 box around `at`,
|
||||
/// picked at random.
|
||||
fn entity_container_at(&mut self, at: [f64; 3]) -> Option<u32> {
|
||||
let meets = |b: horizon_core::geometry::Aabb| {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
lo.x < at[0] + 0.5
|
||||
&& hi.x > at[0] - 0.5
|
||||
&& lo.y < at[1] + 0.5
|
||||
&& hi.y > at[1] - 0.5
|
||||
&& lo.z < at[2] + 0.5
|
||||
&& hi.z > at[2] - 0.5
|
||||
};
|
||||
let mut found: Vec<u32> = self
|
||||
.carts
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
matches!(
|
||||
c.kind,
|
||||
super::minecarts::CartKind::Chest | super::minecarts::CartKind::Hopper
|
||||
) && meets(c.bounds())
|
||||
})
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
found.extend(
|
||||
self.boats
|
||||
.iter()
|
||||
.filter(|b| b.chest && meets(b.bounds()))
|
||||
.map(|b| b.id),
|
||||
);
|
||||
if found.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let pick = self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound(found.len() as i32)
|
||||
.unwrap_or(0);
|
||||
found.get(pick as usize).copied()
|
||||
}
|
||||
/// Runs `f` on a container entity's items as a container, under the
|
||||
/// placeholder key its menu uses (the open menu's copy when it is open),
|
||||
/// then hands the items back to the entity.
|
||||
fn with_entity_container<R>(
|
||||
&mut self,
|
||||
id: u32,
|
||||
f: impl FnOnce(&mut Self, &View) -> R,
|
||||
) -> Option<R> {
|
||||
let mut items = if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
|
||||
std::mem::take(&mut c.items)
|
||||
} else {
|
||||
std::mem::take(&mut self.boats.iter_mut().find(|b| b.id == id)?.items)
|
||||
};
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, id as i32);
|
||||
let (view, open) = self.lend_items(key, &mut items);
|
||||
let result = f(self, &view);
|
||||
self.reclaim_items(key, open, &mut items);
|
||||
if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
|
||||
c.items = items;
|
||||
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
|
||||
b.items = items;
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
/// A vehicle's slots as a container under its placeholder `key`. When its
|
||||
/// menu is open the placeholder is the live copy and stays as it is.
|
||||
/// Returns the view and whether the menu was open.
|
||||
fn lend_items(&mut self, key: Pos, items: &mut Vec<Option<ItemStack>>) -> (View, bool) {
|
||||
let open = self.world.block_entities.contains_key(&key);
|
||||
if !open {
|
||||
let slots = BlockEntity::Chest(std::mem::take(items));
|
||||
self.world.block_entities.insert(key, slots);
|
||||
}
|
||||
let size = self
|
||||
.world
|
||||
.block_entities
|
||||
.get(&key)
|
||||
.map_or(0, |e| e.slots().len());
|
||||
let view = View {
|
||||
parts: vec![(key, size)],
|
||||
kind: Kind::Generic,
|
||||
};
|
||||
(view, open)
|
||||
}
|
||||
/// Gives `lend_items`' slots back to the vehicle, so its own items stay
|
||||
/// the one source of truth: a menu copy that is still around (open, or
|
||||
/// left by a guest's close) gets the changed items copied to `items`.
|
||||
fn reclaim_items(&mut self, key: Pos, open: bool, items: &mut Vec<Option<ItemStack>>) {
|
||||
let slots = if open {
|
||||
self.world.block_entities.get(&key).cloned()
|
||||
} else {
|
||||
self.world.block_entities.remove(&key)
|
||||
};
|
||||
if let Some(BlockEntity::Chest(slots)) = slots {
|
||||
*items = slots;
|
||||
}
|
||||
}
|
||||
/// The item half of `HopperBlockEntity.suckInItems` (`getItemsAtAndAbove`):
|
||||
/// adds the item entities that meet the box, until one is taken whole.
|
||||
@@ -791,20 +922,7 @@ impl Game<'_> {
|
||||
// The cart's slots are edited as a container under the placeholder key
|
||||
// its menu uses; when that menu is open the placeholder is the live copy.
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, cart.id as i32);
|
||||
let open = self.world.block_entities.contains_key(&key);
|
||||
if !open {
|
||||
let items = BlockEntity::Chest(std::mem::take(&mut cart.items));
|
||||
self.world.block_entities.insert(key, items);
|
||||
}
|
||||
let size = self
|
||||
.world
|
||||
.block_entities
|
||||
.get(&key)
|
||||
.map_or(0, |e| e.slots().len());
|
||||
let view = View {
|
||||
parts: vec![(key, size)],
|
||||
kind: Kind::Generic,
|
||||
};
|
||||
let (view, open) = self.lend_items(key, &mut cart.items);
|
||||
let p = cart.position;
|
||||
// getLevelY is the cart's y + 0.5, so the container is the block at
|
||||
// y + 1.5, and Hopper.SUCK_AABB (moved by getLevel - 0.5) starts 11/16
|
||||
@@ -814,7 +932,8 @@ impl Game<'_> {
|
||||
(p.y + 1.5).floor() as i32,
|
||||
p.z.floor() as i32,
|
||||
);
|
||||
let took = match self.suck_from_container(above, &view) {
|
||||
let at = [p.x, p.y + 1.5, p.z];
|
||||
let took = match self.suck_from_container(above, at, &view) {
|
||||
Some(took) => took,
|
||||
None => self.suck_item_entities(
|
||||
&view,
|
||||
@@ -832,17 +951,7 @@ impl Game<'_> {
|
||||
[hi.x + 0.25, hi.y, hi.z + 0.25],
|
||||
);
|
||||
}
|
||||
// The cart's own items stay the one source of truth: a menu copy that
|
||||
// is still around (open, or left by a guest's close) gets the pulled
|
||||
// items back into `cart.items`, so reopening never restores a stale copy.
|
||||
let slots = if open {
|
||||
self.world.block_entities.get(&key).cloned()
|
||||
} else {
|
||||
self.world.block_entities.remove(&key)
|
||||
};
|
||||
if let Some(BlockEntity::Chest(items)) = slots {
|
||||
cart.items = items;
|
||||
}
|
||||
self.reclaim_items(key, open, &mut cart.items);
|
||||
}
|
||||
|
||||
// ---- dispensers and droppers ----
|
||||
@@ -1080,6 +1189,7 @@ mod item_column_tests {
|
||||
id,
|
||||
stack: ItemStack::new("minecraft:stone", 1),
|
||||
thrower: None,
|
||||
target: None,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
|
||||
@@ -721,6 +721,93 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `CompassItem.useOn`: a compass used on a lodestone locks onto it (a
|
||||
/// single one in survival changes in place, else one is split off).
|
||||
pub(super) fn use_lodestone_compass(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:compass" {
|
||||
return false;
|
||||
}
|
||||
let [x, y, z] = hit.pos;
|
||||
if self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(x, y, z))
|
||||
.name
|
||||
.as_ref()
|
||||
!= "minecraft:lodestone"
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let at = [x, y, z].map(|v| f64::from(v) + 0.5);
|
||||
self.audio
|
||||
.play("minecraft:item.lodestone_compass.lock", Some(at), 1.0, 1.0);
|
||||
let target = Tag::compound([(
|
||||
"target",
|
||||
Tag::compound([
|
||||
("dimension", Tag::String(self.dimension_id().into())),
|
||||
("pos", Tag::IntArray(vec![x, y, z])),
|
||||
]),
|
||||
)]);
|
||||
if !self.creative && stack.count == 1 {
|
||||
if let Some(held) = self.held_mut() {
|
||||
held.data_mut()
|
||||
.set_other("minecraft:lodestone_tracker", Some(target));
|
||||
}
|
||||
} else {
|
||||
let mut locked = stack.clone();
|
||||
locked.count = 1;
|
||||
locked
|
||||
.data_mut()
|
||||
.set_other("minecraft:lodestone_tracker", Some(target));
|
||||
self.consume_held();
|
||||
self.give_or_drop_stack(locked);
|
||||
}
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `CompassItem.inventoryTick` / `LodestoneTracker.tick`: a compass whose
|
||||
/// lodestone is gone loses its target. A target in an unloaded chunk waits.
|
||||
pub(super) fn tick_lodestone_compasses(&mut self) {
|
||||
let dim = self.dimension_id();
|
||||
let gone = |game: &Self, stack: &ItemStack| -> bool {
|
||||
let Some(tracker) = stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:lodestone_tracker"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let (Some(target), true) = (
|
||||
tracker.get("target"),
|
||||
horizon_client::save::number(tracker.get("tracked")).is_none_or(|v| v != 0),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let Some(Tag::IntArray(pos)) = target.get("pos") else {
|
||||
return false;
|
||||
};
|
||||
let [x, y, z] = [pos[0], pos[1], pos[2]];
|
||||
horizon_client::save::text(target.get("dimension")).as_deref() == Some(dim)
|
||||
&& game.world.chunks.contains_key(&(x >> 4, z >> 4))
|
||||
&& game
|
||||
.res
|
||||
.facts()
|
||||
.get(game.world.block(x, y, z))
|
||||
.name
|
||||
.as_ref()
|
||||
!= "minecraft:lodestone"
|
||||
};
|
||||
for slot in (0..self.inventory.len()).chain([super::shield::OFFHAND_SLOT]) {
|
||||
if self.stack_in(slot).is_some_and(|s| gone(self, s))
|
||||
&& let Some(s) = self.stack_in_mut(slot)
|
||||
{
|
||||
s.data_mut().set_other(
|
||||
"minecraft:lodestone_tracker",
|
||||
Some(Tag::Compound(Default::default())),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn give_or_drop_stack(&mut self, stack: ItemStack) {
|
||||
if let Some(rest) = self.give(stack)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
@@ -781,9 +868,8 @@ impl Game<'_> {
|
||||
(d * 2.0 + 0.5) as i8
|
||||
}
|
||||
};
|
||||
let y = f64::from(yaw);
|
||||
let adjusted = if y < 0.0 { y - 8.0 } else { y + 8.0 };
|
||||
let rot = ((adjusted * 16.0 / 360.0) as i32 & 15) as u8;
|
||||
// calculateRotation: the arrow spins in the Nether.
|
||||
let rot = decoration_rotation(nether, game_time, f64::from(yaw));
|
||||
let sprite = if inside {
|
||||
Some("map/decorations/player")
|
||||
} else if dx.abs() < 320.0 && dz.abs() < 320.0 {
|
||||
|
||||
@@ -5,11 +5,15 @@ use super::Game;
|
||||
use horizon_client::{
|
||||
mobs::{
|
||||
Species,
|
||||
drops::{self, SLOTS},
|
||||
finalize::equip_stack,
|
||||
gear::{self, ARMOR, MAIN},
|
||||
spawner::equipment_table,
|
||||
},
|
||||
raid::{self, RaiderType},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_world::{loot::EntityLootParams, nbt::Tag};
|
||||
|
||||
/// `EnchantmentsByCostWithDifficulty` of `mob_spawn_equipment`.
|
||||
const MIN_COST: i32 = 5;
|
||||
@@ -47,6 +51,58 @@ fn slot_item(m: &horizon_client::mobs::Mob, slot: usize) -> Option<std::sync::Ar
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Mob.equip(EquipmentTable)` for a spawner's `equipment`: the loot table
|
||||
/// (the mob as `this`, its random sequence) is rolled and each stack goes
|
||||
/// in the slot `EquipmentUser.resolveSlot` picks, with the table's drop
|
||||
/// chance for that slot.
|
||||
pub(super) fn equip_from_table(&mut self, id: u32, table: &Tag) {
|
||||
let Some((name, chances)) = equipment_table(table) else {
|
||||
return;
|
||||
};
|
||||
let Some(loot) = horizon_client::structure_blocks::loot_data(&self.options.resources)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
if !loot.has_table(&name) {
|
||||
return;
|
||||
}
|
||||
let params = EntityLootParams {
|
||||
this: drops::loot_entity(m, None),
|
||||
..EntityLootParams::default()
|
||||
};
|
||||
let seed = self.level.seed;
|
||||
let stacks = match loot.random_sequence(&name) {
|
||||
Some(sequence) => {
|
||||
let random = self.rand_sequences.get(sequence, seed);
|
||||
loot.roll_entity(&name, random, ¶ms)
|
||||
}
|
||||
None => loot.roll_entity(&name, &mut self.world.random, ¶ms),
|
||||
};
|
||||
let mut inserted: Vec<usize> = Vec::new();
|
||||
for stack in stacks.iter().filter(|s| !s.is_empty()) {
|
||||
// `resolveSlot`: an `equippable` item goes in its own slot, any
|
||||
// other in the main hand, each slot once.
|
||||
let slot = match self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.equip_slot.as_deref())
|
||||
{
|
||||
Some(name) => SLOTS.iter().position(|s| *s == name),
|
||||
None => Some(0),
|
||||
};
|
||||
let Some(slot) = slot.filter(|s| !inserted.contains(s)) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
equip_stack(m, slot, stack, chances[slot]);
|
||||
}
|
||||
inserted.push(slot);
|
||||
}
|
||||
}
|
||||
/// `Mob.populateDefaultEquipmentEnchantments`: the weapon one time in
|
||||
/// four and each piece of armour one time in two, scaled by the local
|
||||
/// difficulty, enchanted as if at an enchanting table of cost 5 to 22.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! The looping sound instances of mobs: a bee's buzz
|
||||
//! (`ClientPacketListener.postAddEntitySoundInstance`) and a guardian's
|
||||
//! attack hum (entity event 21). The sound engine ticks them with what
|
||||
//! `start_mob_sounds` reads off the mobs.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
mobs::{Species, bee, guardian},
|
||||
sound_engine::{MobSound, SoundEngine, SoundInstance, Sounds},
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Start the buzz of each bee that has none (or whose anger changed, which
|
||||
/// swaps the flying loop for the aggressive one and back), forget the mobs
|
||||
/// that are gone, and read what the instances follow.
|
||||
pub(super) fn start_mob_sounds(&mut self) -> Vec<MobSound> {
|
||||
let game_time = self.world.game_time;
|
||||
let mobs: Vec<MobSound> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
!m.removed
|
||||
&& matches!(
|
||||
m.species(),
|
||||
Species::Bee | Species::Guardian | Species::ElderGuardian
|
||||
)
|
||||
})
|
||||
.map(|m| MobSound {
|
||||
id: m.id,
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
// Vec3.horizontalDistance.
|
||||
speed: m.velocity.x.hypot(m.velocity.z),
|
||||
baby: m.is_baby(),
|
||||
// NeutralMob.isAngry: the anger end time is ahead.
|
||||
angry: m.species() == Species::Bee
|
||||
&& bee::get(m).is_some_and(|b| b.anger_end > 0 && b.anger_end - game_time > 0),
|
||||
silent: m.silent,
|
||||
attack_scale: guardian::attack_scale(m),
|
||||
})
|
||||
.collect();
|
||||
self.guardian_sounds
|
||||
.retain(|id| mobs.iter().any(|m| m.id == *id));
|
||||
self.bee_sounds
|
||||
.retain(|id, _| mobs.iter().any(|m| m.id == *id));
|
||||
for sound in &mobs {
|
||||
let is_bee = self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.id == sound.id && m.species() == Species::Bee);
|
||||
if is_bee && self.bee_sounds.get(&sound.id) != Some(&sound.angry) {
|
||||
self.bee_sounds.insert(sound.id, sound.angry);
|
||||
let instance = SoundInstance::bee(sound, sound.angry, SoundEngine::unseeded());
|
||||
self.audio.play_instance(instance, false);
|
||||
}
|
||||
}
|
||||
mobs
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleEntityEvent` 21: a guardian's attack
|
||||
/// hum starts (one loop per guardian; the loops of its earlier beams are
|
||||
/// the same loop, as each one follows the guardian's charge).
|
||||
pub(super) fn start_guardian_sound(&mut self, id: u32) {
|
||||
let Some(m) = self.mobs.iter().find(|m| {
|
||||
m.id == id && matches!(m.species(), Species::Guardian | Species::ElderGuardian)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
if !self.guardian_sounds.insert(id) {
|
||||
return;
|
||||
}
|
||||
let sound = MobSound {
|
||||
id,
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
speed: 0.0,
|
||||
baby: false,
|
||||
angry: false,
|
||||
silent: m.silent,
|
||||
attack_scale: guardian::attack_scale(m),
|
||||
};
|
||||
let instance = SoundInstance::guardian_attack(&sound, SoundEngine::unseeded());
|
||||
self.audio.play_instance(instance, false);
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,13 @@ impl Game<'_> {
|
||||
self.sound_paused = self.pause;
|
||||
}
|
||||
let (vehicle, carts) = self.start_cart_sounds();
|
||||
let mobs = self.start_mob_sounds();
|
||||
let ctx = TickContext {
|
||||
player_underwater: self.ambience.was_underwater(),
|
||||
player_removed: self.player.is_none(),
|
||||
vehicle,
|
||||
carts,
|
||||
mobs,
|
||||
frozen: !self.clock.runs_normally(),
|
||||
};
|
||||
self.audio.tick(self.pause, &ctx);
|
||||
|
||||
@@ -297,7 +297,8 @@ impl Game<'_> {
|
||||
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) {
|
||||
// In the End the arrival only builds the platform: no spawn search.
|
||||
if let Err(e) = self.end_arrival(centre, &mut None) {
|
||||
log::error!("End platform: {e}");
|
||||
}
|
||||
return centre;
|
||||
@@ -662,6 +663,17 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `Entity.setPortalCooldown` on an entity that has just spawned in a
|
||||
/// portal: it stands in the portal for 300 ticks without going through.
|
||||
pub(super) fn set_portal_cooldown(&mut self, id: u32) {
|
||||
self.portals.entities.insert(
|
||||
id,
|
||||
PortalState {
|
||||
cooldown: ENTITY_DELAY,
|
||||
process: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
/// `canUsePortal(false)` of a mob: alive, riding nothing, not asleep,
|
||||
/// not carrying the player, and not a boss that never travels.
|
||||
fn mob_can_use_portal(&self, m: &horizon_client::mobs::Mob) -> bool {
|
||||
|
||||
@@ -279,11 +279,13 @@ impl Game<'_> {
|
||||
/// horse is a trap for fifteen minutes; a player within ten blocks springs
|
||||
/// it.
|
||||
pub(super) fn tick_skeleton_traps(&mut self) {
|
||||
let player = self
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|p| !p.health.dead_or_dying())
|
||||
.map(|p| p.position);
|
||||
// `Level.hasNearbyAlivePlayer`: alive, not a spectator, in the level.
|
||||
let players: Vec<_> = self
|
||||
.level_players()
|
||||
.into_iter()
|
||||
.filter(|p| p.alive && !p.spectator)
|
||||
.map(|p| p.position)
|
||||
.collect();
|
||||
let mut sprung = Vec::new();
|
||||
for m in &mut self.mobs {
|
||||
if m.species() != Species::SkeletonHorse || !m.extra.trap || !m.alive() {
|
||||
@@ -296,7 +298,10 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if player.is_some_and(|p| m.distance_sqr(p) < TRAP_RANGE * TRAP_RANGE) {
|
||||
if players
|
||||
.iter()
|
||||
.any(|&p| m.distance_sqr(p) < TRAP_RANGE * TRAP_RANGE)
|
||||
{
|
||||
sprung.push(m.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,7 +381,15 @@ impl Game<'_> {
|
||||
m.head_pitch = pitch;
|
||||
let mut target = m.body_yaw;
|
||||
if dx * dx + dz * dz > 0.002_500_000_2 {
|
||||
target = (dz.atan2(dx) as f32).to_degrees() - 90.0;
|
||||
// LivingEntity.tick: walking backwards the body faces away from
|
||||
// the walk direction, not along it.
|
||||
let walk = (dz.atan2(dx) as f32).to_degrees() - 90.0;
|
||||
let facing = (wrap(yaw) - walk).abs();
|
||||
target = if 95.0 < facing && facing < 265.0 {
|
||||
walk - 180.0
|
||||
} else {
|
||||
walk
|
||||
};
|
||||
}
|
||||
if swinging {
|
||||
target = yaw;
|
||||
@@ -463,7 +471,14 @@ impl Game<'_> {
|
||||
&self.player_armor_layers(),
|
||||
out,
|
||||
);
|
||||
self.player_elytra_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.player_elytra_geometry(
|
||||
m,
|
||||
self.armor[1].as_ref().map(|s| &*s.item),
|
||||
camera,
|
||||
alpha as f32,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
// LivingEntity.isCurrentlyGlowing: the Glowing effect. A server's client is
|
||||
@@ -528,7 +543,14 @@ impl Game<'_> {
|
||||
&self.player_armor_layers(),
|
||||
&mut verts,
|
||||
);
|
||||
self.player_elytra_geometry(&m, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
self.player_elytra_geometry(
|
||||
&m,
|
||||
self.armor[1].as_ref().map(|s| &*s.item),
|
||||
camera,
|
||||
1.0,
|
||||
240 | 240 << 8,
|
||||
&mut verts,
|
||||
);
|
||||
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
let height = if m.extra.crouching { 1.5 } else { 1.8 };
|
||||
// PictureInPictureRenderer: the box centre, (size, size, -size);
|
||||
@@ -567,17 +589,18 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn player_elytra_geometry(
|
||||
/// `WingsLayer` for a player wearing `chest`.
|
||||
pub(super) fn player_elytra_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
chest: Option<&str>,
|
||||
camera: glam::DVec3,
|
||||
partial: f32,
|
||||
light: u32,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) {
|
||||
let Some(asset) = self.armor[1]
|
||||
.as_ref()
|
||||
.and_then(|s| self.res.items().get(&s.item))
|
||||
let Some(asset) = chest
|
||||
.and_then(|item| self.res.items().get(item))
|
||||
.and_then(|i| i.equip_asset.as_ref())
|
||||
else {
|
||||
return;
|
||||
@@ -617,7 +640,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `SpinAttackEffectLayer`: Riptide's two boxes turning around a spinning
|
||||
/// player (`SpinAttackEffectModel.setupAnim`).
|
||||
fn spin_attack_geometry(
|
||||
pub(super) fn spin_attack_geometry(
|
||||
&self,
|
||||
m: &Mob,
|
||||
camera: glam::DVec3,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Pick Block on an entity (`Minecraft.pickBlockOrEntity`'s entity case and
|
||||
//! `ServerGamePacketListenerImpl.handlePickItemFromEntity`): the client aims,
|
||||
//! the server answers with `Entity.getPickResult`.
|
||||
use super::Game;
|
||||
use horizon_client::items::ItemStack;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The entity the crosshair hits before any block, as `Entity.getId`; an
|
||||
/// entity with nothing to pick (a projectile, a player, the dragon) is
|
||||
/// `Some(None)`. `None` when the view hits a block or nothing.
|
||||
pub(super) fn pick_entity_hit(&self) -> Option<Option<i32>> {
|
||||
let mut best: Option<(f64, Option<i32>)> = None;
|
||||
let mut offer = |distance: f64, id: Option<i32>| {
|
||||
if self.before_block(distance) && best.is_none_or(|b| distance < b.0) {
|
||||
best = Some((distance, id));
|
||||
}
|
||||
};
|
||||
if let Some(hit) = self.remote_object_hit(true) {
|
||||
offer(hit.distance, Some(hit.id));
|
||||
}
|
||||
if let Some((i, d)) = self.mob_hit() {
|
||||
offer(d, Some(self.mobs[i].id as i32));
|
||||
}
|
||||
if let Some((_, d)) = self.guest_hit() {
|
||||
offer(d, None);
|
||||
}
|
||||
if let Some((_, d)) = self.redirectable_hit() {
|
||||
offer(d, None);
|
||||
}
|
||||
if let Some((_, d)) = self.dragon_hit() {
|
||||
offer(d, None);
|
||||
}
|
||||
best.map(|b| b.1)
|
||||
}
|
||||
|
||||
/// `Entity.getPickResult` of the entity `id`: a mob's spawn egg, the item
|
||||
/// of a boat, minecart, armor stand, cushion, painting, item frame (the
|
||||
/// framed item when it holds one) or end crystal.
|
||||
pub(super) fn entity_pick_result(&self, id: i32) -> Option<ItemStack> {
|
||||
let id = u32::try_from(id).ok()?;
|
||||
let item = if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
|
||||
// Mob.getPickResult: SpawnEggItem.byId.
|
||||
let egg = format!("{}_spawn_egg", m.kind.id);
|
||||
return self.res.items().get(&egg).map(|_| ItemStack::new(&egg, 1));
|
||||
} else if let Some(b) = self.boats.iter().find(|b| b.id == id) {
|
||||
b.entity_type()
|
||||
} else if let Some(c) = self.carts.iter().find(|c| c.id == id) {
|
||||
c.kind.id().to_owned()
|
||||
} else if self.armor_stands.iter().any(|a| a.id == id) {
|
||||
"minecraft:armor_stand".to_owned()
|
||||
} else if let Some(c) = self.cushions.iter().find(|c| c.id == id) {
|
||||
c.item()
|
||||
} else if let Some(f) = self.item_frames.iter().find(|f| f.id == id) {
|
||||
// ItemFrame.getPickResult: what it frames, else the frame itself.
|
||||
return Some(
|
||||
f.item
|
||||
.clone()
|
||||
.unwrap_or_else(|| ItemStack::new(f.kind(), 1)),
|
||||
);
|
||||
} else if self.paintings.iter().any(|p| p.id == id) {
|
||||
"minecraft:painting".to_owned()
|
||||
} else if self.crystals.iter().any(|c| c.id == id) {
|
||||
"minecraft:end_crystal".to_owned()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(ItemStack::new(&item, 1))
|
||||
}
|
||||
|
||||
/// `Player.isWithinEntityInteractionRange(entity, 3.0)`: the entity's box
|
||||
/// within the interaction range plus 3 of the eyes.
|
||||
pub(super) fn within_entity_range(&self, id: i32) -> bool {
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return false;
|
||||
};
|
||||
let mob = u32::try_from(id)
|
||||
.ok()
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|m| m.bounds());
|
||||
let Some(b) = self
|
||||
.object_target(id)
|
||||
.map(|(lo, hi, _)| (lo, hi))
|
||||
.or_else(|| {
|
||||
mob.or_else(|| self.tracked_special_box(id)).map(|b| {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
|
||||
})
|
||||
})
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let d2: f64 = (0..3)
|
||||
.map(|a| (eye[a] - eye[a].clamp(b.0[a], b.1[a])).powi(2))
|
||||
.sum();
|
||||
let range = if self.creative { 5.0 } else { 3.0 } + 3.0;
|
||||
d2 < range * range
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,22 @@ fn mob_can_be_affected(
|
||||
|| (effect != "regeneration" && effect != "poison")
|
||||
}
|
||||
}
|
||||
/// `ThrownSplashPotion.onHitAsPotion`: the squared distance from the potion's
|
||||
/// box at the hit point to an entity's box grown by the hit margin
|
||||
/// (`ProjectileUtil.computeMargin`), for entities that meet the potion's box
|
||||
/// grown by (4, 2, 4); none when it is 16 or more.
|
||||
fn splash_distance_sqr(at: Vec3, age: u32, bounds: Aabb) -> Option<f64> {
|
||||
let potion = Aabb::new(
|
||||
Vec3::new(at.x - 0.125, at.y, at.z - 0.125),
|
||||
Vec3::new(at.x + 0.125, at.y + 0.25, at.z + 0.125),
|
||||
);
|
||||
if !potion.inflate(Vec3::new(4.0, 2.0, 4.0)).intersects(bounds) {
|
||||
return None;
|
||||
}
|
||||
let margin = ((f64::from(age) - 2.0) / 20.0).clamp(0.0, 0.3);
|
||||
let d2 = potion.distance_squared_to_box(bounds.inflate(Vec3::new(margin, margin, margin)));
|
||||
(d2 < 16.0).then_some(d2)
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `LivingEntity.triggerOnDeathMobEffects` (`onMobRemoved` with the reason
|
||||
/// KILLED) for what Oozing, Weaving and Wind Charged do when their bearer
|
||||
@@ -804,15 +820,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if let Some((p, mob)) = hit {
|
||||
landed.push((t.stack.clone(), p, mob, t.owner.clone()));
|
||||
landed.push((t.stack.clone(), p, mob, t.age, t.owner.clone()));
|
||||
return false;
|
||||
}
|
||||
t.position = next;
|
||||
t.age < 1200 && t.position.y > -128.0
|
||||
});
|
||||
self.potions.thrown = thrown;
|
||||
for (stack, at, mob, owner) in landed {
|
||||
self.splash(&stack, at, mob, owner);
|
||||
for (stack, at, mob, age, owner) in landed {
|
||||
self.splash(&stack, at, mob, age, owner);
|
||||
}
|
||||
}
|
||||
/// `AreaEffectCloud.tick` for the lingering potions' clouds: the client
|
||||
@@ -1079,17 +1095,20 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
at: Vec3,
|
||||
age: u32,
|
||||
source: &Text,
|
||||
) {
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let center = Vec3::new(
|
||||
p.position.x,
|
||||
p.position.y + f64::from(p.dimensions.height) / 2.0,
|
||||
p.position.z,
|
||||
let w = f64::from(p.dimensions.width) / 2.0;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(p.position.x - w, p.position.y, p.position.z - w),
|
||||
Vec3::new(
|
||||
p.position.x + w,
|
||||
p.position.y + f64::from(p.dimensions.height),
|
||||
p.position.z + w,
|
||||
),
|
||||
);
|
||||
let d2 =
|
||||
(center.x - at.x).powi(2) + (center.y - at.y).powi(2) + (center.z - at.z).powi(2);
|
||||
if d2 < 16.0 && (center.y - at.y).abs() <= 2.0 + f64::from(p.dimensions.height) {
|
||||
if let Some(d2) = splash_distance_sqr(at, age, bounds) {
|
||||
let intensity = 1.0 - d2.sqrt() / 4.0;
|
||||
for (id, duration, amp) in effects {
|
||||
let instant = brewing::effect(id).is_some_and(|e| e.instant);
|
||||
@@ -1110,8 +1129,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `ThrownSplashPotion.onHitAsPotion`, or `ThrownLingeringPotion.onHitAsPotion`
|
||||
/// for a lingering potion; `mob` is where it hit a mob.
|
||||
fn splash(&mut self, stack: &ItemStack, at: Vec3, mob: Option<Vec3>, owner: Option<Text>) {
|
||||
/// for a lingering potion; `mob` is where it hit a mob, `age` its age.
|
||||
fn splash(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
at: Vec3,
|
||||
mob: Option<Vec3>,
|
||||
age: u32,
|
||||
owner: Option<Text>,
|
||||
) {
|
||||
let block = [at.x, at.y, at.z].map(|v| v.floor() as i32);
|
||||
if &*stack.item == "minecraft:experience_bottle" {
|
||||
// ThrownExperienceBottle.onHit: the water-coloured splash (level
|
||||
@@ -1169,15 +1195,14 @@ impl Game<'_> {
|
||||
// message names by the owner, else by the potion.
|
||||
let source =
|
||||
owner.unwrap_or_else(|| Text::translate("entity.minecraft.splash_potion", &[]));
|
||||
self.for_each_player(|g| g.splash_on_player(def.effects, at, &source));
|
||||
self.for_each_player(|g| g.splash_on_player(def.effects, at, age, &source));
|
||||
// Mobs take the instant effects (undead invert healing and harming).
|
||||
let undead = &self.res.stations().enchantments.entity_tags;
|
||||
let mut hits = Vec::new();
|
||||
for (i, m) in self.mobs.iter().enumerate() {
|
||||
let d2 = (m.position.x - at.x).powi(2)
|
||||
+ (m.position.y + m.kind.height / 2.0 - at.y).powi(2)
|
||||
+ (m.position.z - at.z).powi(2);
|
||||
if m.alive() && d2 < 16.0 {
|
||||
if m.alive()
|
||||
&& let Some(d2) = splash_distance_sqr(at, age, m.bounds())
|
||||
{
|
||||
let inverted = undead
|
||||
.get("minecraft:inverted_healing_and_harm")
|
||||
.is_some_and(|t| t.contains(&m.kind.id));
|
||||
@@ -1204,20 +1229,39 @@ impl Game<'_> {
|
||||
let m = &mut self.mobs[i];
|
||||
if harm {
|
||||
m.hurt(
|
||||
(intensity * f64::from(6 << amp)) as i32 as f32,
|
||||
(intensity * f64::from(6 << amp) + 0.5) as i32 as f32,
|
||||
Some([at.x, at.y, at.z]),
|
||||
&mut self.world.random,
|
||||
);
|
||||
} else {
|
||||
m.health = (m.health + (intensity * f64::from(4 << amp)) as i32 as f32)
|
||||
m.health = (m.health + (intensity * f64::from(4 << amp) + 0.5) as i32 as f32)
|
||||
.min(m.kind.max_health);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Hud.extractEffects`: icons top-right, beneficial row then harmful row.
|
||||
/// `Screen.showsActiveEffects`: the survival and creative inventories
|
||||
/// draw their own effect list when `EffectsInInventory.canSeeEffects`
|
||||
/// (32 or more pixels right of the screen).
|
||||
fn screen_shows_active_effects(&self, batch: &HudBatch) -> bool {
|
||||
let (ox, image_width) = match self.screen {
|
||||
Screen::Inventory
|
||||
if self.container.is_none() && self.craft_size == 2 && !self.mount_active() =>
|
||||
{
|
||||
(self.inventory_origin(batch).0, 176)
|
||||
}
|
||||
Screen::Creative => (
|
||||
Self::creative_origin(batch).0,
|
||||
horizon_client::creative::Creative::WIDTH as i32,
|
||||
),
|
||||
_ => return false,
|
||||
};
|
||||
batch.width as i32 - (ox as i32 + image_width + 2) >= 32
|
||||
}
|
||||
/// `Hud.extractEffects`: icons top-right, beneficial row then harmful row;
|
||||
/// every screen but the inventories keeps them.
|
||||
pub(super) fn draw_effect_icons(&self, batch: &mut HudBatch) {
|
||||
if self.screen != Screen::Playing || self.potions.effects.list.is_empty() {
|
||||
if self.potions.effects.list.is_empty() || self.screen_shows_active_effects(batch) {
|
||||
return;
|
||||
}
|
||||
let icons: Vec<hud_overlays::EffectIcon> = self
|
||||
|
||||
@@ -23,6 +23,7 @@ use std::collections::{HashMap, HashSet};
|
||||
#[derive(Default)]
|
||||
pub(super) struct Raiding {
|
||||
patrol: PatrolSpawner,
|
||||
siege: Siege,
|
||||
/// The raids of the dimension the player is in, and that dimension.
|
||||
raids: Raids,
|
||||
loaded: Option<Dim>,
|
||||
@@ -38,6 +39,17 @@ pub(super) struct Raiding {
|
||||
homes: HashMap<i32, (i64, std::sync::Arc<Vec<[i32; 3]>>)>,
|
||||
}
|
||||
|
||||
/// `VillageSiege`: whether tonight is a siege, whether the spot is chosen,
|
||||
/// and the zombies still to spawn there.
|
||||
#[derive(Default)]
|
||||
struct Siege {
|
||||
tonight: bool,
|
||||
set_up: bool,
|
||||
zombies_to_spawn: i32,
|
||||
next_spawn_time: i32,
|
||||
spot: [i32; 3],
|
||||
}
|
||||
|
||||
impl Raiding {
|
||||
/// Exchange the level-held parts (`ServerLevel.getRaids`) with another
|
||||
/// level's (game/levels.rs).
|
||||
@@ -102,11 +114,12 @@ impl RaidLevel for Lvl<'_, '_> {
|
||||
&& g.res.facts().is_air(g.world.block(x, y, z))
|
||||
}
|
||||
fn player(&self) -> Option<RaidPlayer> {
|
||||
let p = self.game.player.as_ref()?;
|
||||
// The first player in the level: a server has no player of its own.
|
||||
let p = self.game.level_players().into_iter().next()?;
|
||||
Some(RaidPlayer {
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
block: block_of(p.position),
|
||||
alive: !p.health.dead_or_dying(),
|
||||
alive: p.alive,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -174,12 +187,13 @@ impl PatrolLevel for PatrolCx<'_, '_, '_> {
|
||||
fn bright_outside(&self) -> bool {
|
||||
self.level.game.dim == Dim::Overworld && self.level.game.sky_darken() < 4
|
||||
}
|
||||
/// `PatrolSpawner.tick` picks from `level.players()`: the local player
|
||||
/// and every guest in the level.
|
||||
fn patrol_players(&self) -> Vec<([i32; 3], bool)> {
|
||||
let g = self.level.game;
|
||||
g.player
|
||||
.as_ref()
|
||||
.map(|p| (block_of(p.position), g.spectator()))
|
||||
.into_iter()
|
||||
self.level
|
||||
.players
|
||||
.iter()
|
||||
.map(|p| (block_of(p.position), p.spectator))
|
||||
.collect()
|
||||
}
|
||||
fn sections_to_village(&self, pos: [i32; 3]) -> i32 {
|
||||
@@ -633,7 +647,7 @@ impl Game<'_> {
|
||||
/// `Raid.playSound`: the horn, 13 blocks away from the player toward the
|
||||
/// wave, for players within 64 blocks or in the raid.
|
||||
fn raid_horn(&mut self, id: i32, pos: [i32; 3]) {
|
||||
let Some(p) = self.player.as_ref().map(|p| p.position) else {
|
||||
let Some(p) = self.level_players().first().map(|p| p.position) else {
|
||||
return;
|
||||
};
|
||||
let in_raid = self.raiding.raids.get(id).is_some_and(|r| r.bar.player);
|
||||
@@ -791,7 +805,7 @@ impl Game<'_> {
|
||||
let has_rider = ridden.contains(&m.id);
|
||||
let target_golem = matches!(m.ai.target, Some(horizon_client::mobs::Who::Mob(t)) if golems.contains(&t));
|
||||
let target_player = m.ai.target == Some(horizon_client::mobs::Who::Player);
|
||||
let (health, age, range) = (m.health, m.age, m.attrs.follow_range);
|
||||
let (health, range) = (m.health, m.attrs.follow_range);
|
||||
let raids = &mut self.raiding.raids;
|
||||
let s = m.extra.raider.get_or_insert_with(Box::default);
|
||||
s.rider = has_rider;
|
||||
@@ -821,14 +835,11 @@ impl Game<'_> {
|
||||
if !alive {
|
||||
continue;
|
||||
}
|
||||
// Raider.updateNoActionTime adds two per tick, and a raider
|
||||
// fighting a player or golem in a raid is never idle.
|
||||
if s.raid_id.is_some() {
|
||||
m.ai.no_action_time = if target_player || target_golem {
|
||||
0
|
||||
} else {
|
||||
m.ai.no_action_time + 1
|
||||
};
|
||||
// Raider.aiStep: a raider fighting a player or golem in a raid is
|
||||
// never idle. The per-tick growth (+1 serverAiStep, +2
|
||||
// Raider.updateNoActionTime) is added by the mob's own step.
|
||||
if s.raid_id.is_some() && (target_player || target_golem) {
|
||||
m.ai.no_action_time = 0;
|
||||
}
|
||||
// Raider.aiStep: join the raid the raider stands in.
|
||||
if s.raid_id.is_none()
|
||||
@@ -839,11 +850,9 @@ impl Game<'_> {
|
||||
{
|
||||
joins.push((m.id, id, health));
|
||||
}
|
||||
// PathfindToRaidGoal.recruitNearby.
|
||||
if s.active_raid
|
||||
&& !s.raid_over
|
||||
&& !s.in_village
|
||||
&& age % 20 == 0
|
||||
// PathfindToRaidGoal.tick: recruitNearby, only while the goal runs.
|
||||
if std::mem::take(&mut s.recruit_due)
|
||||
&& s.active_raid
|
||||
&& let Some(id) = s.raid_id
|
||||
{
|
||||
recruit.push((id, position));
|
||||
@@ -1221,6 +1230,140 @@ impl Game<'_> {
|
||||
self.spawn_event_mob("minecraft:pillager", at, m.leader, raider::Reason::Patrol);
|
||||
}
|
||||
}
|
||||
/// `VillageSiege.tick`: at midnight one night in ten, 20 zombies spawn
|
||||
/// two ticks apart near a village 32 blocks from a player standing in it.
|
||||
pub(super) fn tick_village_siege(&mut self) {
|
||||
if self.dim != Dim::Overworld {
|
||||
return;
|
||||
}
|
||||
let enemies = self.ui.rules.bool("spawn_mobs") && self.ui.rules.bool("spawn_monsters");
|
||||
if self.sky_darken() < 4 || !enemies {
|
||||
self.raiding.siege.tonight = false;
|
||||
self.raiding.siege.set_up = false;
|
||||
return;
|
||||
}
|
||||
// ClockTimeMarkers.ROLL_VILLAGE_SIEGE: tick 18000 of the 24000 tick day.
|
||||
if self.world.day_time.rem_euclid(24000) == 18000 {
|
||||
self.raiding.siege.tonight = self.world.random.next_int_bound(10).unwrap_or(1) == 0;
|
||||
}
|
||||
if !self.raiding.siege.tonight {
|
||||
return;
|
||||
}
|
||||
if !self.raiding.siege.set_up {
|
||||
if !self.try_to_setup_siege() {
|
||||
return;
|
||||
}
|
||||
self.raiding.siege.set_up = true;
|
||||
}
|
||||
if self.raiding.siege.next_spawn_time > 0 {
|
||||
self.raiding.siege.next_spawn_time -= 1;
|
||||
return;
|
||||
}
|
||||
self.raiding.siege.next_spawn_time = 2;
|
||||
if self.raiding.siege.zombies_to_spawn > 0 {
|
||||
self.try_spawn_siege_zombie();
|
||||
self.raiding.siege.zombies_to_spawn -= 1;
|
||||
} else {
|
||||
self.raiding.siege.tonight = false;
|
||||
}
|
||||
}
|
||||
/// `ServerLevel.isVillage`: within one section of a village.
|
||||
fn is_village(centers: &HashSet<(i32, i32, i32)>, pos: [i32; 3]) -> bool {
|
||||
sections_to_village(centers, section(pos)) <= 1
|
||||
}
|
||||
/// `VillageSiege.tryToSetupSiege`: the first player standing in a village
|
||||
/// picks a spot 32 blocks away (ten tries); true when one stood in a
|
||||
/// village, even if no spot was found.
|
||||
fn try_to_setup_siege(&mut self) -> bool {
|
||||
let centers = self.village_centers();
|
||||
for player in self.level_players() {
|
||||
if player.spectator {
|
||||
continue;
|
||||
}
|
||||
let center = block_of(player.position);
|
||||
if !Self::is_village(¢ers, center)
|
||||
|| self.natural_level().biome_tag(
|
||||
"minecraft:without_zombie_sieges",
|
||||
center[0],
|
||||
center[1],
|
||||
center[2],
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for _ in 0..10 {
|
||||
let angle = self.world.random.next_float() * std::f32::consts::TAU;
|
||||
let spot = [
|
||||
center[0] + (angle.cos() * 32.0).floor() as i32,
|
||||
center[1],
|
||||
center[2] + (angle.sin() * 32.0).floor() as i32,
|
||||
];
|
||||
if self.find_siege_spawn_pos(¢ers, spot).is_some() {
|
||||
self.raiding.siege.spot = spot;
|
||||
self.raiding.siege.next_spawn_time = 0;
|
||||
self.raiding.siege.zombies_to_spawn = 20;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `VillageSiege.trySpawn`: one zombie, in a random yaw.
|
||||
fn try_spawn_siege_zombie(&mut self) {
|
||||
let centers = self.village_centers();
|
||||
let spot = self.raiding.siege.spot;
|
||||
let Some(at) = self.find_siege_spawn_pos(¢ers, spot) else {
|
||||
return;
|
||||
};
|
||||
let Some(id) = self.spawn_event_mob("minecraft:zombie", at, false, raider::Reason::Event)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let yaw = self.world.random.next_float() * 360.0;
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
(m.yaw, m.previous_yaw, m.body_yaw, m.previous_body_yaw) = (yaw, yaw, yaw, yaw);
|
||||
(m.head_yaw, m.previous_head_yaw) = (yaw, yaw);
|
||||
}
|
||||
self.spawn_jockeys(id, false);
|
||||
}
|
||||
/// `VillageSiege.findRandomSpawnPos`: ten tries within 8 blocks of the
|
||||
/// spot for a village column where a zombie may spawn.
|
||||
fn find_siege_spawn_pos(
|
||||
&mut self,
|
||||
centers: &HashSet<(i32, i32, i32)>,
|
||||
spot: [i32; 3],
|
||||
) -> Option<[f64; 3]> {
|
||||
let mut random = std::mem::replace(
|
||||
&mut self.world.random,
|
||||
horizon_core::random::LegacyRandom::new(0),
|
||||
);
|
||||
let mut found = None;
|
||||
for _ in 0..10 {
|
||||
let x = spot[0] + random.next_int_bound(16).unwrap_or(0) - 8;
|
||||
let z = spot[2] + random.next_int_bound(16).unwrap_or(0) - 8;
|
||||
if !self.world.chunks.contains_key(&(x >> 4, z >> 4)) {
|
||||
continue;
|
||||
}
|
||||
let y = self.surface_height(x, z);
|
||||
if Self::is_village(centers, [x, y, z])
|
||||
&& spawn_rules::check_spawn_rules(
|
||||
Species::Zombie,
|
||||
&self.natural_level(),
|
||||
spawn_rules::Reason::Event,
|
||||
&mut random,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
)
|
||||
{
|
||||
found = Some([f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.world.random = random;
|
||||
found
|
||||
}
|
||||
/// A raider's ominous banner (drop chance 2.0: always dropped) and a
|
||||
/// patrol captain's ominous bottle (`entities/pillager`).
|
||||
/// `OminousBottleAmplifier.onConsume`: Bad Omen for 100 minutes at the
|
||||
|
||||
@@ -119,7 +119,7 @@ pub(super) struct Remote {
|
||||
pub(super) entity_id: i32,
|
||||
/// The item the player's use began with (`LivingEntity.useItem`).
|
||||
pub(super) use_item: Option<Arc<str>>,
|
||||
dimension: DimensionType,
|
||||
pub(super) dimension: DimensionType,
|
||||
/// What chunk packets decode with (the dimension's height and sky, the
|
||||
/// biome registry); replaced whenever one of them changes.
|
||||
pub(super) chunk_format: Arc<ChunkFormat>,
|
||||
@@ -160,6 +160,9 @@ pub(super) struct Remote {
|
||||
stepped: HashMap<i32, SteppedInterpolation>,
|
||||
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
|
||||
head_lerps: HashMap<i32, (f32, u32)>,
|
||||
/// The happy ghast the player just got off, until the server's teleport
|
||||
/// puts the player on top of it (`place_ghast_under`).
|
||||
pub(super) ghast_dismount: Option<u32>,
|
||||
/// Item entities waiting for their item data: where they are and their
|
||||
/// motion.
|
||||
pending_items: HashMap<i32, ([f64; 3], Vec3)>,
|
||||
@@ -194,6 +197,8 @@ pub(super) struct Remote {
|
||||
/// The local player's profile name: its score holder and team member.
|
||||
pub(super) profile_name: String,
|
||||
destroying: Option<([i32; 3], u8, f32)>,
|
||||
/// `MultiPlayerGameMode.destroyingItem`: the held item key the break began with.
|
||||
destroying_item: String,
|
||||
destroy_delay: u32,
|
||||
input: horizon_client::interaction_input::InteractionInput,
|
||||
/// Keys whose presses send a packet (swap hands, drop, a mount's
|
||||
@@ -202,7 +207,9 @@ pub(super) struct Remote {
|
||||
pub(super) pending_keys: Vec<KeyCode>,
|
||||
stop_mining: bool,
|
||||
placing: Option<([i32; 3], u64)>,
|
||||
death_message: Option<String>,
|
||||
/// `CommonPlayerSpawnInfo.isFlat`: the level is a superflat one.
|
||||
pub(super) flat: bool,
|
||||
death_message: Option<horizon_client::text::Text>,
|
||||
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
|
||||
show_death_screen: bool,
|
||||
player_type: Option<Arc<MobType>>,
|
||||
@@ -474,6 +481,7 @@ impl Game<'_> {
|
||||
lerps: HashMap::new(),
|
||||
stepped: HashMap::new(),
|
||||
head_lerps: HashMap::new(),
|
||||
ghast_dismount: None,
|
||||
pending_items: HashMap::new(),
|
||||
health: (20.0, 20, 5.0),
|
||||
flash_on_set_health: false,
|
||||
@@ -492,11 +500,13 @@ impl Game<'_> {
|
||||
profile: 0,
|
||||
profile_name: String::new(),
|
||||
destroying: None,
|
||||
destroying_item: String::new(),
|
||||
destroy_delay: 0,
|
||||
input: Default::default(),
|
||||
pending_keys: Vec::new(),
|
||||
stop_mining: false,
|
||||
placing: None,
|
||||
flat: false,
|
||||
death_message: None,
|
||||
show_death_screen: true,
|
||||
player_type,
|
||||
@@ -690,10 +700,14 @@ impl Game<'_> {
|
||||
}
|
||||
let text = content.plain();
|
||||
log::info!("[chat] {text}");
|
||||
// addServerSystemMessage and addClientSystemMessage both carry
|
||||
// GuiMessageTag.systemSinglePlayer: the grey bar at the line's left.
|
||||
let indicator = (source != horizon_client::chat::ChatSource::Player)
|
||||
.then_some(horizon_client::chat::SYSTEM_INDICATOR);
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
content,
|
||||
None,
|
||||
indicator,
|
||||
source,
|
||||
self.ticks,
|
||||
self.screen == Screen::Chat,
|
||||
@@ -1018,12 +1032,19 @@ impl Game<'_> {
|
||||
r.chunk_format = Arc::new(r.chunk_format.with_dimension(&dimension));
|
||||
r.dimension = dimension;
|
||||
r.game_mode = spawn.game_type;
|
||||
r.flat = spawn.flat;
|
||||
r.loaded_sent = false;
|
||||
// A replacement LocalPlayer has a fresh sendPosition baseline.
|
||||
r.position_sender = Default::default();
|
||||
r.predictions.clear();
|
||||
self.set_mode_id(spawn.game_type);
|
||||
self.dim = Dim::from_id(&spawn.dimension);
|
||||
// ClientPacketListener.handleLogin / handleRespawn: the server's
|
||||
// Player.getLastDeathLocation, for the recovery compass.
|
||||
self.level.last_death = spawn
|
||||
.last_death
|
||||
.as_ref()
|
||||
.map(|d| (d.dimension.clone(), d.pos));
|
||||
if !same {
|
||||
let mut streaming = Streaming::new(
|
||||
Arc::new(NoTerrain),
|
||||
@@ -1437,6 +1458,17 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
// A thrown trident is an arrow: placed at once, flown on by its tick.
|
||||
if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id as u32) {
|
||||
if path.is_some() {
|
||||
t.position = Vec3::new(position[0], position[1], position[2]);
|
||||
}
|
||||
if let Some((yaw, pitch)) = rotation {
|
||||
t.yaw = yaw;
|
||||
t.pitch = pitch;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// So does a firework rocket: its own tick flies it on.
|
||||
if path.is_some()
|
||||
&& let Some(f) = self.rockets.iter_mut().find(|f| f.id == id as u32)
|
||||
@@ -2065,6 +2097,13 @@ impl Game<'_> {
|
||||
m.extra.ints[1] = code;
|
||||
}
|
||||
}
|
||||
// Avatar.DATA_PLAYER_MAIN_HAND (HumanoidArm: 0 left, 1
|
||||
// right): the arm a swing of the main hand moves.
|
||||
if let (15, Value::Id(arm)) = (index, value)
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
{
|
||||
m.extra.main_hand_left = *arm == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): this
|
||||
@@ -2154,7 +2193,23 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// CrossbowItem.isCharged: the main hand's `charged_projectiles`
|
||||
// (AvatarRenderer.getArmPose holds a loaded crossbow up).
|
||||
let charged = p.slots.iter().find(|(slot, _)| *slot == 0).map(|(_, s)| {
|
||||
s.as_ref().is_some_and(|s| {
|
||||
matches!(
|
||||
s.component(horizon_protocol::item::component::CHARGED_PROJECTILES),
|
||||
Some(horizon_protocol::item::Component::Stacks(list))
|
||||
if !list.is_empty()
|
||||
)
|
||||
})
|
||||
});
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
if let Some(charged) = charged
|
||||
&& &*m.kind.id == "minecraft:player"
|
||||
{
|
||||
m.extra.charged = charged;
|
||||
}
|
||||
// The body slot (6: horse armour, llama carpet, harness)
|
||||
// is kept in `head`, as Horizon's own mobs do.
|
||||
let body = horizon_client::mobs::drops::head_is_body(m.species());
|
||||
@@ -2167,7 +2222,11 @@ impl Game<'_> {
|
||||
3 => e.legs = item,
|
||||
4 => e.chest = item,
|
||||
5 if !body => e.head = item,
|
||||
6 if body => e.head = item,
|
||||
6 if body => {
|
||||
// A wolf's armor shows its cracks.
|
||||
e.body_damage = stack.as_ref().map_or(0, |s| s.damage as i32);
|
||||
e.head = item;
|
||||
}
|
||||
7 => m.set_saddle(stack),
|
||||
_ => {}
|
||||
}
|
||||
@@ -2254,6 +2313,10 @@ impl Game<'_> {
|
||||
self.remote_firework_explosion(p.id);
|
||||
}
|
||||
self.remote_fangs_event(p.id as u32, p.event);
|
||||
// LivingEntity.handleEntityEvent 2: a spear's hit felt.
|
||||
if p.event == 2 {
|
||||
self.remote_kinetic_hit(p.id);
|
||||
}
|
||||
// LocalPlayer.setPermissions also refreshes an open World Options screen.
|
||||
if (24..=28).contains(&p.event)
|
||||
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
|
||||
@@ -2261,6 +2324,14 @@ impl Game<'_> {
|
||||
self.ui.registries.permission = (p.event - 24) as u8;
|
||||
self.remote_world_options_permission(p.event >= 26);
|
||||
}
|
||||
// Ravager.handleEntityEvent 69: the roar's puff and push.
|
||||
if p.event == 69 {
|
||||
self.remote_ravager_roar(p.id);
|
||||
}
|
||||
// ClientPacketListener.handleEntityEvent 21: a guardian's hum.
|
||||
if p.event == 21 {
|
||||
self.start_guardian_sound(p.id as u32);
|
||||
}
|
||||
if p.event == 1
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
|
||||
&& m.species() == horizon_client::mobs::Species::Rabbit
|
||||
@@ -2361,7 +2432,7 @@ impl Game<'_> {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
self.swing_hand(hand, animation);
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
|
||||
m.swing();
|
||||
m.swing_hand_modified(hand == Hand::Off, animation.duration);
|
||||
}
|
||||
}
|
||||
// The player's own instances: its maximum health (the hearts the
|
||||
@@ -2405,6 +2476,7 @@ impl Game<'_> {
|
||||
{
|
||||
self.screen = Screen::Dead;
|
||||
self.ui.death_time = 0;
|
||||
self.ui.death_confirm = None;
|
||||
self.capture(false);
|
||||
}
|
||||
}
|
||||
@@ -2412,7 +2484,7 @@ impl Game<'_> {
|
||||
// The component resolves in the client's current language, as
|
||||
// the chat line does, not in the language the session joined
|
||||
// with (`Remote.lang` is the vanilla server-language table).
|
||||
let message = remote_text(&p.message).plain();
|
||||
let message = remote_text(&p.message);
|
||||
let me = self.remote.as_ref().is_some_and(|r| r.entity_id == p.id);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.death_message = Some(message);
|
||||
@@ -2424,6 +2496,7 @@ impl Game<'_> {
|
||||
if self.remote.as_ref().is_none_or(|r| r.show_death_screen) {
|
||||
self.screen = Screen::Dead;
|
||||
self.ui.death_time = 0;
|
||||
self.ui.death_confirm = None;
|
||||
self.capture(false);
|
||||
} else {
|
||||
self.respawn_player()?;
|
||||
@@ -2549,6 +2622,20 @@ impl Game<'_> {
|
||||
r.state_id = p.state;
|
||||
}
|
||||
let stack = self.to_stack(&p.item);
|
||||
// ClientPacketListener.handleContainerSetSlot: a hotbar slot that
|
||||
// gains items squeezes for 5 ticks.
|
||||
if p.container == 0
|
||||
&& (36..=45).contains(&p.slot)
|
||||
&& let Some(new) = &stack
|
||||
{
|
||||
let (pop, old) = match p.slot {
|
||||
45 => (9, self.offhand.as_ref()),
|
||||
s => (s as usize - 36, self.inventory[s as usize - 36].as_ref()),
|
||||
};
|
||||
if old.is_none_or(|old| old.count < new.count) {
|
||||
self.hotbar_pop[pop] = 5;
|
||||
}
|
||||
}
|
||||
self.set_menu_slot(p.container, i32::from(p.slot), stack);
|
||||
self.remote_synced();
|
||||
}
|
||||
@@ -3237,6 +3324,7 @@ impl Game<'_> {
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
thrower: None,
|
||||
target: None,
|
||||
id: id as u32,
|
||||
stack,
|
||||
position: at,
|
||||
@@ -3273,6 +3361,8 @@ impl Game<'_> {
|
||||
p.velocity = v;
|
||||
} else if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
|
||||
b.velocity = v;
|
||||
} else if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id as u32) {
|
||||
t.velocity = v;
|
||||
} else if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id as u32) {
|
||||
r.velocity = v;
|
||||
} else if let Some(p) = self
|
||||
@@ -3297,6 +3387,37 @@ impl Game<'_> {
|
||||
p.health.food.saturation = saturation;
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.dismountTo` lands a rider at the ghast's own (x, maxY, z)
|
||||
/// (`HappyGhast.getDismountLocationForPassenger`). A vanilla client
|
||||
/// simulates the ghast it rides, so its copy is where the server has it;
|
||||
/// this window's copy trails the server's by the stepped interpolation and
|
||||
/// would rise into the rider's feet, which lets the rider fall through.
|
||||
/// Put the copy under the landing spot.
|
||||
fn place_ghast_under(&mut self, id: u32, spot: Vec3) {
|
||||
let Some(m) = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == id && m.species() == horizon_client::mobs::Species::HappyGhast)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let at = Vec3::new(spot.x, spot.y - f64::from(m.height()), spot.z);
|
||||
// Only a teleport that is that dismount: right above the ghast.
|
||||
if (at.x - m.position.x).powi(2) + (at.z - m.position.z).powi(2) > 25.0
|
||||
|| (at.y - m.position.y).abs() > 5.0
|
||||
{
|
||||
return;
|
||||
}
|
||||
m.position = at;
|
||||
m.previous = at;
|
||||
if let Some(handler) = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.and_then(|r| r.stepped.get_mut(&(id as i32)))
|
||||
{
|
||||
handler.cancel();
|
||||
}
|
||||
}
|
||||
/// ClientPacketListener.handleMovePlayer.
|
||||
fn remote_teleport(&mut self, p: &play::PlayerPosition) -> Result<()> {
|
||||
use play::relative as rel;
|
||||
@@ -3356,6 +3477,9 @@ impl Game<'_> {
|
||||
self.previous_position = position;
|
||||
self.yaw = yaw;
|
||||
self.pitch = pitch;
|
||||
if let Some(id) = self.remote.as_mut().and_then(|r| r.ghast_dismount.take()) {
|
||||
self.place_ghast_under(id, position);
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
// handleMovePlayer does not change LocalPlayer.sendPosition's
|
||||
// baseline: the next tick must still report the corrected state.
|
||||
@@ -3441,6 +3565,10 @@ impl Game<'_> {
|
||||
// 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();
|
||||
// Inventory.tick's ItemStack.inventoryTick: the pop time runs down.
|
||||
for pop in &mut self.hotbar_pop {
|
||||
*pop = pop.saturating_sub(1);
|
||||
}
|
||||
// ClientLevel.tick: the border's size change.
|
||||
self.border.tick();
|
||||
self.player_border();
|
||||
@@ -3583,12 +3711,10 @@ impl Game<'_> {
|
||||
let feet = [player.position.x, player.position.y, player.position.z];
|
||||
for event in events {
|
||||
match event {
|
||||
horizon_client::motion::MotionEvent::Emission {
|
||||
state, in_water, ..
|
||||
} => {
|
||||
horizon_client::motion::MotionEvent::Emission { state, muffled, .. } => {
|
||||
let sounds = self.res.facts().get(state).sounds.clone();
|
||||
// Player.playStepSound: muffled under the swim sound in water.
|
||||
let (volume, pitch) = if in_water {
|
||||
let (volume, pitch) = if muffled {
|
||||
(sounds.volume * 0.05, sounds.pitch * 0.8)
|
||||
} else {
|
||||
(sounds.volume * 0.15, sounds.pitch)
|
||||
@@ -3813,6 +3939,34 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let game_time = self.world.game_time;
|
||||
// `AbstractPiglin.isConverting`: the dimension is not piglin safe.
|
||||
let nether = self.dim == super::Dim::Nether;
|
||||
// What a wither's side heads can look at, by network entity id: the
|
||||
// mobs and the player (position and eye height).
|
||||
let wither_targets: Vec<(i32, [f64; 3], f64)> = if self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.species() == horizon_client::mobs::Species::Wither)
|
||||
{
|
||||
let own = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.zip(self.eye_position())
|
||||
.map(|(r, eye)| (r.entity_id, eye, eye[1]));
|
||||
self.mobs
|
||||
.iter()
|
||||
.map(|m| {
|
||||
(
|
||||
m.id as i32,
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.eye_y(),
|
||||
)
|
||||
})
|
||||
.chain(own)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let Some(r) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -3821,15 +3975,12 @@ impl Game<'_> {
|
||||
let head_lerps = &mut r.head_lerps;
|
||||
let players = &r.level_players;
|
||||
let mut using = Vec::new();
|
||||
let mut wither_heads = Vec::new();
|
||||
let mut mob_fx = Vec::new();
|
||||
for m in &mut self.mobs {
|
||||
let player = players.contains_key(&(m.id as i32));
|
||||
if !running && !player {
|
||||
continue;
|
||||
}
|
||||
if m.species() == horizon_client::mobs::Species::Wither {
|
||||
wither_heads.extend((0..3).map(|i| horizon_client::mobs::wither::head_pos(m, i)));
|
||||
}
|
||||
m.previous = m.position;
|
||||
m.previous_yaw = m.yaw;
|
||||
m.previous_body_yaw = m.body_yaw;
|
||||
@@ -3864,6 +4015,17 @@ impl Game<'_> {
|
||||
if m.species() == horizon_client::mobs::Species::PolarBear {
|
||||
horizon_client::mobs::polar_bear::client_tick(m);
|
||||
}
|
||||
// The particles these mobs' own ticks draw on the client side.
|
||||
{
|
||||
use horizon_client::mobs::{Species as S, cube, illusioner, nautilus, wither};
|
||||
match m.species() {
|
||||
S::Slime | S::MagmaCube | S::SulfurCube => cube::client_tick(m, &mut mob_fx),
|
||||
S::Nautilus | S::ZombieNautilus => nautilus::client_tick(m, &mut mob_fx),
|
||||
S::Illusioner => illusioner::client_tick(m, &mut mob_fx),
|
||||
S::Wither => wither::client_smoke(m, &mut mob_fx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if m.hurt_time > 0 {
|
||||
m.hurt_time -= 1;
|
||||
}
|
||||
@@ -3891,6 +4053,11 @@ impl Game<'_> {
|
||||
if m.species() == horizon_client::mobs::Species::Fox {
|
||||
horizon_client::mobs::fox::client_tick(m);
|
||||
}
|
||||
// Cat.tick: the lying and relaxing amounts follow the synched flags.
|
||||
if m.species() == horizon_client::mobs::Species::Cat {
|
||||
m.anim.vo = m.anim.v;
|
||||
horizon_client::mobs::tame::cat_amounts(m);
|
||||
}
|
||||
// Chicken.aiStep: the wings follow the server's on-ground flag.
|
||||
if m.species() == horizon_client::mobs::Species::Chicken {
|
||||
horizon_client::mobs::farm::chicken_flap(m);
|
||||
@@ -3898,6 +4065,20 @@ impl Game<'_> {
|
||||
// LivingEntity.updatingUsingItem: the ticks the item has been
|
||||
// used before this one. Another player's use particles; a mob's
|
||||
// drawn bow only needs the count (its pull follows it).
|
||||
if player {
|
||||
// AvatarRenderer.getArmPose: the used item's BLOCK animation
|
||||
// (an item with `blocks_attacks`) raises the arm.
|
||||
let used = if m.extra.using_off_hand {
|
||||
&m.extra.off_hand
|
||||
} else {
|
||||
&m.extra.main_hand
|
||||
};
|
||||
m.extra.blocking = m.extra.using_item > 0
|
||||
&& used
|
||||
.as_ref()
|
||||
.and_then(|i| self.res.items().get(i))
|
||||
.is_some_and(|i| i.blocks_attacks.is_some());
|
||||
}
|
||||
if m.extra.using_item > 0 {
|
||||
if player {
|
||||
let item = if m.extra.using_off_hand {
|
||||
@@ -3924,6 +4105,8 @@ impl Game<'_> {
|
||||
// a player's body following its walk and look.
|
||||
if player {
|
||||
remote_player_head_turn(m);
|
||||
// LivingEntity.tick: the glide count, wings and swim amount.
|
||||
m.tick_avatar_pose(m.fluids.in_water);
|
||||
} else if m.alive() {
|
||||
m.body_rotation_tick();
|
||||
}
|
||||
@@ -3942,8 +4125,26 @@ impl Game<'_> {
|
||||
}
|
||||
// The animation states a server computes in its own `ai_step`.
|
||||
horizon_client::mobs::sync::client_tick(m, game_time);
|
||||
if m.species() == horizon_client::mobs::Species::Panda {
|
||||
horizon_client::mobs::panda::client_tick(m, thundering);
|
||||
match m.species() {
|
||||
// WitherBoss.aiStep: the side heads turn toward their targets.
|
||||
horizon_client::mobs::Species::Wither => {
|
||||
horizon_client::mobs::wither::client_tick(m, |id| {
|
||||
wither_targets
|
||||
.iter()
|
||||
.find(|t| t.0 == id)
|
||||
.map(|t| (t.1, t.2))
|
||||
});
|
||||
}
|
||||
// Hoglin and piglin `isConverting` outside the Nether.
|
||||
horizon_client::mobs::Species::Hoglin
|
||||
| horizon_client::mobs::Species::Piglin
|
||||
| horizon_client::mobs::Species::PiglinBrute => {
|
||||
horizon_client::mobs::piglin::client_converting(m, nether);
|
||||
}
|
||||
horizon_client::mobs::Species::Panda => {
|
||||
horizon_client::mobs::panda::client_tick(m, thundering);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for o in self.orbs.iter_mut().filter(|_| running) {
|
||||
@@ -3969,19 +4170,7 @@ impl Game<'_> {
|
||||
d.tick(world, facts, collision, false, &self.res.motion().fluids);
|
||||
}
|
||||
head_lerps.retain(|_, (_, steps)| *steps > 0);
|
||||
// WitherBoss.aiStep runs on a client too: smoke around each head.
|
||||
let smoke = horizon_client::particles::ParticleOptions::simple(
|
||||
horizon_client::particles::ParticleType::Smoke,
|
||||
);
|
||||
for h in wither_heads {
|
||||
let r = self.particles.random();
|
||||
let at = [
|
||||
h[0] + r.next_gaussian() * 0.3,
|
||||
h[1] + r.next_gaussian() * 0.3,
|
||||
h[2] + r.next_gaussian() * 0.3,
|
||||
];
|
||||
self.particles.add(&smoke, at, [0.0; 3]);
|
||||
}
|
||||
let _ = self.handle_mob_events(mob_fx);
|
||||
for (item, ticks, at, eye, pitch, yaw) in using {
|
||||
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
|
||||
}
|
||||
@@ -4148,6 +4337,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `MultiPlayerGameMode.stopDestroyBlock`: aborts the break and starts
|
||||
/// the attack strength again.
|
||||
pub(super) fn stop_remote_mining(&mut self) {
|
||||
if let Some((pos, _, _)) = self.remote.as_mut().and_then(|r| r.destroying.take()) {
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
@@ -4156,6 +4347,7 @@ impl Game<'_> {
|
||||
direction: 0,
|
||||
sequence: 0,
|
||||
}));
|
||||
self.reset_attack_strength();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4209,11 +4401,11 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.destroy_progress(hit.pos)
|
||||
};
|
||||
let same = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| r.destroying)
|
||||
.is_some_and(|(p, _, _)| p == hit.pos);
|
||||
// sameDestroyTarget: the same block and still the item the break began with.
|
||||
let held = self.main_hand_key();
|
||||
let same = self.remote.as_ref().is_some_and(|r| {
|
||||
r.destroying.is_some_and(|(p, _, _)| p == hit.pos) && r.destroying_item == held
|
||||
});
|
||||
if start && same {
|
||||
return;
|
||||
}
|
||||
@@ -4240,6 +4432,7 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.destroying = Some((hit.pos, face, 0.0));
|
||||
r.destroying_item = held;
|
||||
}
|
||||
// startDestroyBlock plays the first hit and crack itself; the
|
||||
// server's level events 2019 and 2020 do the rest.
|
||||
@@ -4424,6 +4617,20 @@ impl Game<'_> {
|
||||
if reacts {
|
||||
self.predict_use(&changes, seq);
|
||||
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
|
||||
} else if self.predict_campfire_douse(hit.pos, held.as_ref(), seq) {
|
||||
// CampfireBlock.useItemOn succeeded: this hand swings and the
|
||||
// click ends, so a shield in the other hand is not raised.
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
} else if held.as_ref().is_some_and(|s| &*s.item == "minecraft:brush") {
|
||||
// BrushItem.useOn: brushing starts here (CONSUME, no swing); the
|
||||
// server brushes and the dust is this client's own.
|
||||
self.with_hand(hand, |g| {
|
||||
if g.using.is_none() {
|
||||
g.using = Some((g.held_slot(), 0));
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} else if let Some(stack) = held.as_ref()
|
||||
&& self.predict_item_on_block(stack, &hit)
|
||||
{
|
||||
@@ -4601,7 +4808,7 @@ impl Game<'_> {
|
||||
/// predicted under the use's sequence until the server acknowledges it
|
||||
/// (`BlockStatePredictionHandler.retainKnownServerState`): a use the
|
||||
/// server refused goes back.
|
||||
fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
|
||||
pub(super) fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
|
||||
for &(p, state) in changes {
|
||||
let pos = [p.x, p.y, p.z];
|
||||
let before = self.world.block(p.x, p.y, p.z);
|
||||
@@ -4719,6 +4926,8 @@ impl Game<'_> {
|
||||
}
|
||||
// SpyglassItem.use (its sound is the server's).
|
||||
"minecraft:spyglass" => true,
|
||||
// Item.use: a kinetic weapon starts being used.
|
||||
id if horizon_client::spear::Kinetic::of(id).is_some() => true,
|
||||
// ShieldItem.use: anything that blocks attacks, off cooldown.
|
||||
_ => {
|
||||
self.raise_shield(self.held_slot());
|
||||
@@ -4735,7 +4944,8 @@ impl Game<'_> {
|
||||
matches!(
|
||||
&*stack.item,
|
||||
"minecraft:bow" | "minecraft:crossbow" | "minecraft:trident" | "minecraft:spyglass"
|
||||
) || self.blocks_attacks(stack).is_some()
|
||||
) || horizon_client::spear::Kinetic::of(&stack.item).is_some()
|
||||
|| self.blocks_attacks(stack).is_some()
|
||||
}
|
||||
|
||||
/// The rest of Minecraft.startUseItem once the main hand's target passed:
|
||||
@@ -4779,6 +4989,30 @@ impl Game<'_> {
|
||||
// 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;
|
||||
// BrushItem.onUseTick: with no block aimed the use ends (the server
|
||||
// ends it too); a stroke shows its dust. Its sounds are the server's.
|
||||
if stack
|
||||
.as_ref()
|
||||
.is_some_and(|s| &*s.item == "minecraft:brush")
|
||||
{
|
||||
let elapsed = ticks + 1;
|
||||
let hit = self.pick().filter(|_| in_hand);
|
||||
if let Some(hit) = &hit
|
||||
&& elapsed % 10 == 5
|
||||
{
|
||||
self.brush_dust(hit, slot == super::shield::OFFHAND_SLOT);
|
||||
}
|
||||
if hit.is_none() && in_hand {
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: 6,
|
||||
pos: [0; 3],
|
||||
direction: 0,
|
||||
sequence: 0,
|
||||
}));
|
||||
}
|
||||
self.using = hit.and(Some((slot, elapsed))).filter(|_| elapsed < 200);
|
||||
return;
|
||||
}
|
||||
// A drawn bow, a charging crossbow, a raised shield...: held until
|
||||
// the button lets go.
|
||||
if stack.as_ref().is_some_and(|s| self.held_until_released(s)) {
|
||||
@@ -4920,7 +5154,9 @@ impl Game<'_> {
|
||||
match key {
|
||||
KeyCode::KeyF if !self.spectator() => self.remote_swap_hands(),
|
||||
KeyCode::KeyQ if !self.spectator() => {
|
||||
let all = self.keys.contains(&KeyCode::ControlLeft);
|
||||
// Minecraft.hasControlDown: the physical keys, so a toggled
|
||||
// sprint on Left Control does not make Q drop the stack.
|
||||
let all = self.has_control_down();
|
||||
self.remote_drop(all);
|
||||
}
|
||||
KeyCode::KeyE if self.riding_mount_with_menu() => {
|
||||
@@ -4949,6 +5185,28 @@ impl Game<'_> {
|
||||
if self.spectator() {
|
||||
return true;
|
||||
}
|
||||
// A spear jabs (`MultiPlayerGameMode.piercingAttack`): the server
|
||||
// stabs, this client swings, plays the sound and starts its charge
|
||||
// again.
|
||||
if let Some(kinetic) = self
|
||||
.held()
|
||||
.and_then(|s| horizon_client::spear::Kinetic::of(&s.item))
|
||||
{
|
||||
if self.cannot_attack_with_item(self.hand_strength.ticker, 0) {
|
||||
return false;
|
||||
}
|
||||
// ServerboundPlayerActionPacket.Action.STAB.
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: 8,
|
||||
pos: [0; 3],
|
||||
direction: 0,
|
||||
sequence: 0,
|
||||
}));
|
||||
self.hand_strength.reset_attack();
|
||||
self.swing_hand(Hand::Main, kinetic.attack_animation());
|
||||
self.play_at_feet(kinetic.attack_sound);
|
||||
return true;
|
||||
}
|
||||
let mut instant = false;
|
||||
// A ghast's fireball or a wind charge nearer than any mob: the
|
||||
// server turns it (game/deflect.rs).
|
||||
@@ -4999,7 +5257,13 @@ impl Game<'_> {
|
||||
if !down {
|
||||
self.clicks.miss_time = 0;
|
||||
}
|
||||
if self.clicks.miss_time > 0 || self.using.is_some() {
|
||||
// A piercing weapon neither breaks blocks nor swings on its own.
|
||||
if self.clicks.miss_time > 0
|
||||
|| self.using.is_some()
|
||||
|| self
|
||||
.held()
|
||||
.is_some_and(|s| horizon_client::spear::Kinetic::of(&s.item).is_some())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let entity = self.entity_targeted();
|
||||
@@ -5615,8 +5879,16 @@ impl Game<'_> {
|
||||
if self.screen == Screen::Dead
|
||||
&& let Some(message) = &r.death_message
|
||||
{
|
||||
let tw = HudBatch::text_width(atlas, message);
|
||||
batch.text(atlas, message, (w - tw) / 2.0, 85.0, u32::MAX, true);
|
||||
// DeathScreen draws the styled cause component.
|
||||
let tw = atlas.font.width_text(message);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
message,
|
||||
((w - tw) / 2.0).floor(),
|
||||
85.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Shared flag 6 of an entity on a server's client (`None` without a server).
|
||||
@@ -5911,7 +6183,8 @@ fn hand_id(hand: Hand) -> i32 {
|
||||
/// server decides; this is the client's guess of whether to go on.
|
||||
fn item_has_use(info: &horizon_client::items::ItemInfo) -> bool {
|
||||
let id = info.id.trim_start_matches("minecraft:");
|
||||
info.consumable.is_some()
|
||||
id.ends_with("_spear")
|
||||
|| info.consumable.is_some()
|
||||
|| info.equip_slot.is_some()
|
||||
|| info.blocks_attacks.is_some()
|
||||
|| horizon_client::items::bucket_contents(&info.id).is_some()
|
||||
@@ -5981,7 +6254,18 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
|
||||
direction: 0,
|
||||
sequence: 0,
|
||||
}));
|
||||
self.0.using = None;
|
||||
// MultiPlayerGameMode.releaseUsingItem also runs `Item.releaseUsing` here: the
|
||||
// player moves by itself, so a Riptide launch happens on the client.
|
||||
let hand = self.0.used_hand();
|
||||
if let Some((slot, ticks)) = self.0.using.take()
|
||||
&& let Some(hand) = hand
|
||||
&& self
|
||||
.0
|
||||
.stack_in(slot)
|
||||
.is_some_and(|s| &*s.item == "minecraft:trident")
|
||||
{
|
||||
self.0.with_hand(hand, |g| g.release_trident(ticks));
|
||||
}
|
||||
}
|
||||
fn start_attack(&mut self) -> bool {
|
||||
self.0.remote_start_attack()
|
||||
@@ -5990,7 +6274,16 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
|
||||
self.0.remote_use();
|
||||
}
|
||||
fn pick(&mut self) {
|
||||
if let Some(hit) = self.0.pick() {
|
||||
// Minecraft.pickBlockOrEntity: an entity before the block is asked
|
||||
// for (`handlePickItemFromEntity`), else the block.
|
||||
if let Some(entity) = self.0.pick_entity_hit() {
|
||||
if let Some(id) = entity {
|
||||
self.0.send(S::PickItemFromEntity(play::PickItemFromEntity {
|
||||
id,
|
||||
include_data: self.0.has_control_down(),
|
||||
}));
|
||||
}
|
||||
} else if let Some(hit) = self.0.pick() {
|
||||
self.0.send(S::PickItemFromBlock(play::PickItemFromBlock {
|
||||
pos: hit.pos,
|
||||
include_data: self.0.has_control_down(),
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
use super::Game;
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
use horizon_client::mobs::{
|
||||
Mob, Species, Who, armadillo, bat, bee, camel, creaking, finalize, panda, sniffer, sync, tame,
|
||||
Mob, Species, Who, armadillo, bat, bee, camel, creaking, equine, finalize, illusioner, panda,
|
||||
sniffer, sync, tame, zombie_villager,
|
||||
};
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_protocol::entity_data::{Particle, ParticleData, Value};
|
||||
use horizon_protocol::registry::{Builtin, Dynamic};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
|
||||
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
|
||||
@@ -17,6 +19,9 @@ use horizon_protocol::registry::{Builtin, Dynamic};
|
||||
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_entity: i32) {
|
||||
let species = m.species();
|
||||
let baby = super::lan::baby_index(&m.kind.id);
|
||||
if &*m.kind.id == "minecraft:player" {
|
||||
player_poses(m, values);
|
||||
}
|
||||
for (index, _, value) in values {
|
||||
match (*index, value) {
|
||||
// Entity.DATA_POSE: a player's or mannequin's box follows it.
|
||||
@@ -28,7 +33,21 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
// Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.isOnFire on a
|
||||
// client, which draws the flames. A client doesn't count the
|
||||
// fire down: the flag goes off when the server's fire is out.
|
||||
(0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1),
|
||||
(0, Value::Byte(flags)) => {
|
||||
m.fire_ticks = i32::from(flags & 1);
|
||||
// Drowned.setSwimming: the shared flag of swimming.
|
||||
if species == Species::Drowned {
|
||||
m.extra.flag = flags & 16 != 0;
|
||||
}
|
||||
// The shared flag of invisibility: Entity.isInvisible hides
|
||||
// the model (an illusioner shows its mirror images).
|
||||
let invisible = flags & 32 != 0;
|
||||
if species == Species::Illusioner {
|
||||
illusioner::set_invisible(m, invisible);
|
||||
} else if &*m.kind.id != "minecraft:player" {
|
||||
m.invisible = invisible;
|
||||
}
|
||||
}
|
||||
// Entity.DATA_POSE: the pose the species animates by.
|
||||
(6, Value::Id(pose)) => sync::set_pose(m, (*pose).clamp(0, 17) as u8),
|
||||
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the
|
||||
@@ -109,6 +128,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
m.extra.sitting = flags & 1 != 0;
|
||||
m.extra.tame = flags & 4 != 0;
|
||||
}
|
||||
// Cat.IS_LYING and RELAX_STATE_ONE.
|
||||
(Species::Cat, 21, Value::Bool(lying)) => m.extra.flag = *lying,
|
||||
(Species::Cat, 22, Value::Bool(relaxing)) => m.extra.relax_one = *relaxing,
|
||||
// Wolf.DATA_COLLAR_COLOR and Cat's.
|
||||
(Species::Wolf, 21, Value::Int(c)) | (Species::Cat, 23, Value::Int(c)) => {
|
||||
m.extra.color = (*c & 15) as u8;
|
||||
@@ -117,6 +139,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
|
||||
m.extra.size = (*size).clamp(1, 127);
|
||||
}
|
||||
// Enderman.DATA_CREEPY (after DATA_CARRY_STATE): the open mouth
|
||||
// and raised head of an angry enderman.
|
||||
(Species::Enderman, 17, Value::Bool(creepy)) => m.extra.flag = *creepy,
|
||||
// Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace.
|
||||
(Species::Warden, 16, Value::Int(anger)) => {
|
||||
if let Some(st) = m.extra.warden.as_mut() {
|
||||
@@ -209,9 +234,49 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
(Species::CopperGolem, 16, Value::Id(state)) => {
|
||||
m.extra.variant = (*state).clamp(0, 3) as u8;
|
||||
}
|
||||
// AbstractHorse.DATA_ID_FLAGS: eating 16 and standing 32 (the
|
||||
// graze and the rear), which `sync::client_tick` animates.
|
||||
(s, 18, Value::Byte(flags)) if equine::grazes(s) => equine::set_flags(m, *flags as u8),
|
||||
// Enderman.DATA_CARRY_STATE (0 for none): the block it holds.
|
||||
(Species::Enderman, 16, Value::Id(state)) => {
|
||||
m.extra.carried = (*state > 0).then_some(BlockStateId(*state as u32));
|
||||
}
|
||||
// Zombie.DATA_DROWNED_CONVERSION_ID (after DATA_BABY_ID and
|
||||
// DATA_SPECIAL_TYPE_ID): the conversion's shaking.
|
||||
(Species::Zombie | Species::Husk, 18, Value::Bool(converting)) => {
|
||||
m.extra.counter2 = i32::from(*converting);
|
||||
}
|
||||
// ZombieVillager.DATA_CONVERTING_ID (before its villager data).
|
||||
(Species::ZombieVillager, 19, Value::Bool(converting)) => {
|
||||
zombie_villager::set_converting(m, *converting);
|
||||
}
|
||||
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two):
|
||||
// the head shake of `no`, which the server counts down.
|
||||
(Species::Villager | Species::WanderingTrader, 18, Value::Int(ticks)) => {
|
||||
m.extra.ints[0] = *ticks;
|
||||
}
|
||||
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
|
||||
// AbstractPiglin's: `sync`'s conversion shake reads it.
|
||||
(Species::Hoglin, 18, Value::Bool(immune))
|
||||
| (Species::Piglin | Species::PiglinBrute, 16, Value::Bool(immune)) => {
|
||||
m.extra.flag2 = *immune;
|
||||
}
|
||||
// Blaze.DATA_FLAGS_ID: charged 1, which makes it burn.
|
||||
(Species::Blaze, 16, Value::Byte(flags)) => m.extra.flag = flags & 1 != 0,
|
||||
// Turtle.HAS_EGG and LAYING_EGG (after AgeableMob's two).
|
||||
(Species::Turtle, 18, Value::Bool(has_egg)) => m.extra.flag = *has_egg,
|
||||
(Species::Turtle, 19, Value::Bool(laying)) => m.extra.flag2 = *laying,
|
||||
// WitherBoss.DATA_TARGET_B and _C (after DATA_TARGET_A): the
|
||||
// entities the side heads turn toward in `wither::client_tick`.
|
||||
(Species::Wither, 17, Value::Int(id)) => m.extra.ints[0] = *id,
|
||||
(Species::Wither, 18, Value::Int(id)) => m.extra.ints[1] = *id,
|
||||
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
|
||||
// IS_CELEBRATING takes 16).
|
||||
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
|
||||
// HappyGhast.STAYS_STILL (after AgeableMob's two and IS_LEASH_HOLDER):
|
||||
// `isOnStillTimeout` on a client, which keeps the ghast solid to
|
||||
// the player (`canBeCollidedWith`).
|
||||
(Species::HappyGhast, 19, Value::Bool(still)) => m.extra.counter = i32::from(*still),
|
||||
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
|
||||
(Species::Evoker | Species::Illusioner, 17, Value::Byte(spell)) => {
|
||||
m.extra.variant2 = (*spell).max(0) as u8;
|
||||
@@ -280,6 +345,21 @@ fn apply_registry_data(
|
||||
}
|
||||
}
|
||||
|
||||
/// What another player's pose draws from: gliding (Entity.isFallFlying, flag
|
||||
/// 0x80 of the shared flags), the pose (swimming), Riptide's spin
|
||||
/// (LivingEntity.isAutoSpinAttack, flag 4) and a loaded crossbow in the
|
||||
/// hand (the held stack comes with SetEquipment).
|
||||
fn player_poses(m: &mut Mob, values: &[(u8, i32, Value)]) {
|
||||
for (index, _, value) in values {
|
||||
match (*index, value) {
|
||||
(0, Value::Byte(flags)) => m.extra.fall_flying = flags & -128 != 0,
|
||||
(6, Value::Id(pose)) => m.extra.synced_pose = (*pose).clamp(0, 17) as u8,
|
||||
(8, Value::Byte(flags)) => m.extra.auto_spin_attack = flags & 4 != 0,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -332,9 +412,20 @@ impl Game<'_> {
|
||||
let Some(r) = &self.remote else {
|
||||
return;
|
||||
};
|
||||
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): a villager lies
|
||||
// in its bed the way the bed faces, or gets up.
|
||||
let bed = values.iter().find_map(|v| match v {
|
||||
(14, _, Value::OptPos(bed)) => Some(bed.map_or(0, |b| self.bed_facing(b))),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
|
||||
apply(m, values, &r.builtin, r.entity_id);
|
||||
apply_registry_data(m, values, &r.registries, self.world.game_time);
|
||||
if let Some(code) = bed
|
||||
&& m.species() == Species::Villager
|
||||
{
|
||||
m.extra.ints[1] = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Particles a client of a server draws for a mob's entity event, which the
|
||||
//! server does not send as a particle packet.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
mobs::Species,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Ravager.handleEntityEvent(69)`: forty puffs of smoke from the middle
|
||||
/// of its box, then `applyRoarKnockbackClient`: this client's own player
|
||||
/// within 4 blocks is thrown away from it.
|
||||
pub(super) fn remote_ravager_roar(&mut self, id: i32) {
|
||||
let Some(m) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id as u32 && m.species() == Species::Ravager)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (center, at, area) = (
|
||||
m.center(),
|
||||
m.position,
|
||||
m.bounds().inflate(Vec3::new(4.0, 4.0, 4.0)),
|
||||
);
|
||||
let options = ParticleOptions::simple(ParticleType::Poof);
|
||||
for _ in 0..40 {
|
||||
let r = self.particles.random();
|
||||
let v = [
|
||||
r.next_gaussian() * 0.2,
|
||||
r.next_gaussian() * 0.2,
|
||||
r.next_gaussian() * 0.2,
|
||||
];
|
||||
self.particles.add(&options, center, v);
|
||||
}
|
||||
if let Some(view) = self.player_view()
|
||||
&& view.alive
|
||||
&& view.bounds.intersects(area)
|
||||
&& let Some(p) = self.player.as_mut()
|
||||
{
|
||||
// strongKnockback.
|
||||
let (xd, zd) = (p.position.x - at.x, p.position.z - at.z);
|
||||
let dd = (xd * xd + zd * zd).max(0.001);
|
||||
p.velocity = p
|
||||
.velocity
|
||||
.add_vector(Vec3::new(xd / dd * 4.0, 0.2, zd / dd * 4.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,8 @@ impl Game<'_> {
|
||||
) && !baby
|
||||
}
|
||||
Species::Sheep => item == "minecraft:shears" || item.ends_with("_dye"),
|
||||
// SnowGolem.mobInteract: shears take the pumpkin off.
|
||||
Species::SnowGolem => item == "minecraft:shears" && !m.extra.sheared,
|
||||
// Bucketable.bucketMobPickup.
|
||||
Species::Cod
|
||||
| Species::Salmon
|
||||
|
||||
@@ -207,6 +207,8 @@ impl Game<'_> {
|
||||
"minecraft:trident" => {
|
||||
let mut t = super::trident::ThrownTrident::remote(id, at, yaw, pitch);
|
||||
t.velocity = velocity;
|
||||
// Projectile.getAddEntityPacket: the owner's id is the data.
|
||||
t.owner = data as u32;
|
||||
self.tridents.push(t);
|
||||
}
|
||||
"minecraft:firework_rocket" => {
|
||||
@@ -350,6 +352,15 @@ impl Game<'_> {
|
||||
(9, Value::Bool(show)) => c.show_bottom = *show,
|
||||
_ => {}
|
||||
}
|
||||
} else if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id) {
|
||||
// AbstractArrow.ID_FLAGS (no physics) and IN_GROUND, and
|
||||
// ThrownTrident.ID_LOYALTY.
|
||||
match (index, value) {
|
||||
(8, Value::Byte(flags)) => t.no_physics = flags & 2 != 0,
|
||||
(10, Value::Bool(in_ground)) => t.in_ground = *in_ground,
|
||||
(11, Value::Byte(loyalty)) => t.loyalty = u32::from(*loyalty as u8),
|
||||
_ => {}
|
||||
}
|
||||
} else if let Some(p) = self.creatures.projectiles.iter_mut().find(|p| p.id == id) {
|
||||
match (&mut p.kind, index, value) {
|
||||
// AbstractArrow.ID_FLAGS (critical) and IN_GROUND.
|
||||
@@ -383,6 +394,25 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.getEyePosition` of a projectile's owner: this game's player or
|
||||
/// another player the server sent.
|
||||
fn remote_eye(&self, id: u32) -> Option<Vec3> {
|
||||
let me = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.and_then(|r| u32::try_from(r.entity_id).ok());
|
||||
if me == Some(id) {
|
||||
let eye = self.eye_position()?;
|
||||
return Some(Vec3::new(eye[0], eye[1], eye[2]));
|
||||
}
|
||||
let m = self.mobs.iter().find(|m| m.id == id)?;
|
||||
Some(Vec3::new(
|
||||
m.position.x,
|
||||
m.position.y + m.eye_height(),
|
||||
m.position.z,
|
||||
))
|
||||
}
|
||||
|
||||
/// One tick of interpolation, as `tick_remote_objects`.
|
||||
pub(super) fn tick_remote_projectiles(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
|
||||
self.tick_remote_fangs();
|
||||
@@ -416,6 +446,7 @@ impl Game<'_> {
|
||||
// updates (10 to 20 ticks apart); a block stops them there, and only
|
||||
// the server decides what they hit.
|
||||
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
|
||||
let mut tridents = std::mem::take(&mut self.tridents);
|
||||
{
|
||||
let w = self.mob_world_base(&[]);
|
||||
let world = super::creatures::ProjWorld {
|
||||
@@ -434,8 +465,12 @@ impl Game<'_> {
|
||||
_ => remote_projectile_tick(p, &world),
|
||||
}
|
||||
}
|
||||
for t in &mut tridents {
|
||||
t.remote_tick(&world, self.remote_eye(t.owner));
|
||||
}
|
||||
}
|
||||
self.creatures.projectiles = projectiles;
|
||||
self.tridents = tridents;
|
||||
let trails: Vec<Vec3> = self
|
||||
.creatures
|
||||
.projectiles
|
||||
@@ -444,10 +479,6 @@ impl Game<'_> {
|
||||
.map(|p| p.position)
|
||||
.collect();
|
||||
self.smoke_trails(trails);
|
||||
for t in &mut self.tridents {
|
||||
let id = t.id;
|
||||
t.remote_step(|at| step(id, at));
|
||||
}
|
||||
// FireworkRocketEntity.tick: a rocket attached to an entity follows it
|
||||
// and boosts it client-side too (attachedToEntity.isFallFlying), and
|
||||
// every rocket leaves its spark trail on a client.
|
||||
|
||||
@@ -128,6 +128,17 @@ impl Game<'_> {
|
||||
} else if self.riding == Some(vehicle_id) {
|
||||
// The server takes the player out, then teleports it.
|
||||
self.riding = None;
|
||||
// HappyGhast.removePassenger: a still timeout of 10 (the server's
|
||||
// STAYS_STILL confirms it), and the rider lands on top of the ghast
|
||||
// (`place_ghast_under`).
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| {
|
||||
m.id == vehicle_id && m.species() == horizon_client::mobs::Species::HappyGhast
|
||||
}) {
|
||||
m.extra.counter = 10;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.ghast_dismount = Some(vehicle_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,29 @@ use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
/// The first motion-blocking block at or below `from` (Heightmap MOTION_BLOCKING), plus one.
|
||||
fn motion_blocking(world: &World, facts: &BlockFacts, x: i32, z: i32, from: i32) -> i32 {
|
||||
motion_blocking_leaves(world, facts, x, z, from, true)
|
||||
}
|
||||
/// `motion_blocking` where leaves count only for MOTION_BLOCKING, not for
|
||||
/// MOTION_BLOCKING_NO_LEAVES.
|
||||
fn motion_blocking_leaves(
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
x: i32,
|
||||
z: i32,
|
||||
from: i32,
|
||||
leaves: bool,
|
||||
) -> i32 {
|
||||
let mut y = from.min(MIN_Y + HEIGHT - 1);
|
||||
while y > MIN_Y {
|
||||
let f = facts.get(world.block(x, y, z));
|
||||
if f.full_collision || f.fluid != Fluid::None || f.name.contains("leaves") {
|
||||
// #minecraft:leaves block motion, but MOTION_BLOCKING_NO_LEAVES skips them
|
||||
// (their water still counts).
|
||||
let blocks = if f.name.ends_with("_leaves") {
|
||||
leaves
|
||||
} else {
|
||||
f.full_collision
|
||||
};
|
||||
if blocks || f.fluid != Fluid::None {
|
||||
return y + 1;
|
||||
}
|
||||
y -= 1;
|
||||
@@ -34,16 +53,7 @@ fn rains_at(world: &World, biomes: &Biomes, x: i32, y: i32, z: i32) -> bool {
|
||||
let Some(chunk) = world.chunks.get(&(x >> 4, z >> 4)) else {
|
||||
return false;
|
||||
};
|
||||
let b = biomes.get(chunk.biome(x, y, z));
|
||||
if !b.precipitation {
|
||||
return false;
|
||||
}
|
||||
let t = if y > 80 {
|
||||
b.temperature - (y - 80) as f32 * 0.05 / 40.0
|
||||
} else {
|
||||
b.temperature
|
||||
};
|
||||
t >= 0.15
|
||||
biomes.get(chunk.biome(x, y, z)).snows_at(x, y, z) == Some(false)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -92,9 +102,8 @@ impl Game<'_> {
|
||||
distant_terrain: self.lod.is_some(),
|
||||
cloud_range_chunks: self.settings.cloud_range,
|
||||
camera_y: camera.y,
|
||||
// level.getMinY(): the Nether and the End start at y 0.
|
||||
min_y: if self.dim == Dim::Overworld { MIN_Y } else { 0 },
|
||||
flat: false,
|
||||
min_y: self.level_min_y(),
|
||||
flat: self.level_flat(),
|
||||
forward: forward.map(|c| c as f32),
|
||||
rain,
|
||||
thunder,
|
||||
@@ -108,9 +117,30 @@ impl Game<'_> {
|
||||
},
|
||||
)
|
||||
}
|
||||
/// `SkyRenderer.shouldRenderDarkDisc`: eye below the horizon (y 63) and not under water.
|
||||
/// `ClientLevelData.isFlat`: on the server thread, the world-wide flag.
|
||||
fn level_flat(&self) -> bool {
|
||||
match self.remote.as_ref() {
|
||||
Some(r) => r.flat,
|
||||
None => self.world_flat,
|
||||
}
|
||||
}
|
||||
/// `Level.getMinY`: the current dimension type's minimum y.
|
||||
fn level_min_y(&self) -> i32 {
|
||||
match self.remote.as_ref() {
|
||||
Some(r) => r.dimension.min_y,
|
||||
None => self.dim.min_y(),
|
||||
}
|
||||
}
|
||||
/// `SkyRenderer.shouldRenderDarkDisc`: eye below the horizon and not under
|
||||
/// water. `ClientLevelData.getHorizonHeight`: the bottom of a flat world,
|
||||
/// otherwise y 63.
|
||||
pub(super) fn dark_disc(&self, camera: glam::DVec3) -> bool {
|
||||
camera.y < 63.0
|
||||
let horizon = if self.level_flat() {
|
||||
f64::from(self.level_min_y())
|
||||
} else {
|
||||
63.0
|
||||
};
|
||||
camera.y < horizon
|
||||
&& fog_type(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
@@ -273,6 +303,21 @@ impl Game<'_> {
|
||||
&& motion_blocking(world, facts, p[0], p[2], p[1] + 64) <= p[1]
|
||||
&& rains_at(world, biomes, p[0], p[1], p[2])
|
||||
};
|
||||
let height_no_leaves =
|
||||
|p: [i32; 3]| motion_blocking_leaves(world, facts, p[0], p[2], i32::MAX, false);
|
||||
// The day timeline: firefly bush sounds from 12600 to 23400.
|
||||
let firefly_bush_sounds = self.dim == Dim::Overworld
|
||||
&& (12600..23401).contains(&self.world.day_time.rem_euclid(24000));
|
||||
let tables = &self.pack.context.tables;
|
||||
let model_offset = |state, x, z| tables.offset(state, x, z);
|
||||
let gateway_particles = |state, p| {
|
||||
self.res
|
||||
.block_entity_render()
|
||||
.gateway_faces(world, state, p)
|
||||
.len()
|
||||
};
|
||||
// Level.isThundering: a thunder level above 0.9 where the sky is open.
|
||||
let thundering = self.dim == Dim::Overworld && world.weather.levels(1.0).1 > 0.9;
|
||||
let ctx = AnimateContext {
|
||||
world,
|
||||
facts,
|
||||
@@ -280,7 +325,14 @@ impl Game<'_> {
|
||||
biomes,
|
||||
registry: self.pack.assets.registry,
|
||||
raining_at: &raining_at,
|
||||
height_no_leaves: &height_no_leaves,
|
||||
firefly_bush_sounds,
|
||||
sky_darken: self.sky_darken(),
|
||||
marker: marker.map(|name| (name, &marker_options as &dyn Fn(_) -> _)),
|
||||
model_offset: &model_offset,
|
||||
gateway_particles: &gateway_particles,
|
||||
thundering,
|
||||
ultrawarm: self.dim == Dim::Nether,
|
||||
};
|
||||
if let Some(e) = eye {
|
||||
let block = [
|
||||
|
||||
@@ -683,7 +683,7 @@ impl Game<'_> {
|
||||
let objective = board.display_objective(sb::SLOT_LIST);
|
||||
let shown = tab_list::visible(
|
||||
key,
|
||||
self.remote.is_none(),
|
||||
self.has_singleplayer_server(),
|
||||
players.len(),
|
||||
objective.is_some(),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
command::args::GameMode,
|
||||
confirm_screen::Confirm,
|
||||
hud::{HudBatch, rgba},
|
||||
text::{self, Text},
|
||||
ui,
|
||||
@@ -103,6 +104,14 @@ fn pause_icons(w: f32, h: f32, links: bool) -> [(f32, f32, &'static str); 4] {
|
||||
]
|
||||
}
|
||||
/// DeathScreen.init: a hardcore player can only spectate.
|
||||
/// `DeathScreen.TitleConfirmScreen`: its title and the yes and no labels.
|
||||
fn death_confirm_texts() -> (Text, [Text; 2]) {
|
||||
let t = |key: &str| Text::translate(key, &[]);
|
||||
(
|
||||
t("deathScreen.quit.confirm"),
|
||||
[t("deathScreen.titleScreen"), t("deathScreen.respawn")],
|
||||
)
|
||||
}
|
||||
fn death_buttons(w: f32, h: f32, hardcore: bool) -> [(f32, f32, &'static str, u8); 2] {
|
||||
let respawn = if hardcore {
|
||||
"deathScreen.spectate"
|
||||
@@ -267,6 +276,20 @@ impl Game<'_> {
|
||||
),
|
||||
);
|
||||
}
|
||||
// DeathScreen.handleExitToTitleScreen: the TitleConfirmScreen takes the
|
||||
// screen over, on the same background.
|
||||
if let Some(opened) = self.ui.death_confirm {
|
||||
let (title, labels) = death_confirm_texts();
|
||||
let size = self.gui_size();
|
||||
Confirm::new(&atlas.font, size, &title, &Text::literal("")).draw_active(
|
||||
batch,
|
||||
atlas,
|
||||
self.cursor,
|
||||
[&labels[0], &labels[1]],
|
||||
opened.elapsed().as_millis() >= 1000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let title = if self.level.hardcore {
|
||||
"deathScreen.title.hardcore"
|
||||
} else {
|
||||
@@ -382,6 +405,23 @@ impl Game<'_> {
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(opened) = self.ui.death_confirm {
|
||||
// ConfirmScreen.setDelay: both buttons wait 20 ticks.
|
||||
if opened.elapsed().as_millis() < 1000 {
|
||||
return Ok(());
|
||||
}
|
||||
let (title, _) = death_confirm_texts();
|
||||
let confirm = Confirm::new(
|
||||
&self.gui.atlas.font,
|
||||
self.gui_size(),
|
||||
&title,
|
||||
&Text::literal(""),
|
||||
);
|
||||
if let Some(yes) = confirm.answer_at(self.cursor) {
|
||||
self.death_confirm_answer(yes)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let size = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
@@ -391,6 +431,27 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.find(|d| ui::hovered(self.cursor, d.0, d.1, 200.0, 20.0))
|
||||
.map(|d| d.3);
|
||||
// handleExitToTitleScreen: outside hardcore, Title Screen asks first.
|
||||
if hit == Some(1) && !self.level.hardcore {
|
||||
self.ui.death_confirm = Some(std::time::Instant::now());
|
||||
self.audio.play_button_click();
|
||||
return Ok(());
|
||||
}
|
||||
self.death_action(hit)
|
||||
}
|
||||
/// The confirm screen's answer: yes leaves the world, no respawns
|
||||
/// (`DeathScreen.handleExitToTitleScreen`'s callback).
|
||||
pub(super) fn death_confirm_answer(&mut self, yes: bool) -> Result<()> {
|
||||
self.ui.death_confirm = None;
|
||||
if yes {
|
||||
self.audio.play_button_click();
|
||||
self.close_world()
|
||||
} else {
|
||||
self.death_action(Some(0))
|
||||
}
|
||||
}
|
||||
/// A death screen button: 0 respawns (or spectates in hardcore), 1 leaves.
|
||||
fn death_action(&mut self, hit: Option<u8>) -> Result<()> {
|
||||
match hit {
|
||||
Some(0) => self.respawn_player()?,
|
||||
Some(1) => self.close_world()?,
|
||||
|
||||
@@ -876,17 +876,26 @@ impl Game<'_> {
|
||||
/// `SculkSensorBlock.stepOn` and `SculkShriekerBlock.stepOn`: standing on
|
||||
/// them is heard (or seen) every tick.
|
||||
fn sculk_step_on(&mut self) {
|
||||
self.sculk_step_on_player();
|
||||
let mut standing: Vec<(Who, [f64; 3])> = Vec::new();
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& p.ground.on_ground
|
||||
{
|
||||
standing.push((Who::Player, [p.position.x, p.position.y, p.position.z]));
|
||||
}
|
||||
for m in &self.mobs {
|
||||
if m.alive() && m.on_ground {
|
||||
standing.push((Who::Mob(m.id), [m.position.x, m.position.y, m.position.z]));
|
||||
}
|
||||
}
|
||||
self.sculk_stand_on(standing);
|
||||
}
|
||||
/// `stepOn` for the player in place: the host, or a guest swapped in
|
||||
/// (`guest_server_tick`), as a server has no player of its own.
|
||||
pub(super) fn sculk_step_on_player(&mut self) {
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& p.ground.on_ground
|
||||
{
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.sculk_stand_on(vec![(Who::Player, at)]);
|
||||
}
|
||||
}
|
||||
fn sculk_stand_on(&mut self, standing: Vec<(Who, [f64; 3])>) {
|
||||
for (who, at) in standing {
|
||||
// getOnPosLegacy: 0.2 below the feet.
|
||||
let key = (
|
||||
@@ -1108,23 +1117,10 @@ impl Game<'_> {
|
||||
/// in the way (`isUnobstructed`), and no block collision.
|
||||
fn warden_fits(&self, mob: &Mob) -> bool {
|
||||
let bounds = mob.bounds();
|
||||
let player_blocks = !self.spectator()
|
||||
&& self.player.as_ref().is_some_and(|p| {
|
||||
let w = f64::from(p.dimensions.width) / 2.0;
|
||||
let b = horizon_core::geometry::Aabb::new(
|
||||
horizon_core::geometry::Vec3::new(
|
||||
p.position.x - w,
|
||||
p.position.y,
|
||||
p.position.z - w,
|
||||
),
|
||||
horizon_core::geometry::Vec3::new(
|
||||
p.position.x + w,
|
||||
p.position.y + f64::from(p.dimensions.height),
|
||||
p.position.z + w,
|
||||
),
|
||||
);
|
||||
!p.health.dead_or_dying() && b.intersects(bounds)
|
||||
});
|
||||
let player_blocks = self
|
||||
.level_players()
|
||||
.iter()
|
||||
.any(|p| p.alive && !p.spectator && p.bounds.intersects(bounds));
|
||||
if player_blocks
|
||||
|| self
|
||||
.mobs
|
||||
|
||||
@@ -11,7 +11,7 @@ use super::end::GatewayJob;
|
||||
use super::nether_portal::{PLAYER_DELAY, Plan};
|
||||
use super::respawn_anchor::RespawnConfig;
|
||||
use super::{Dim, Game, lan::LanHost};
|
||||
use horizon_client::{lan::hub::TickCtx, text::Text};
|
||||
use horizon_client::{lan::hub::TickCtx, text::Text, world_spawn::PlayerSpawnFinder};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
@@ -31,6 +31,11 @@ pub(super) struct Trip {
|
||||
/// A respawn at this point outside the Overworld, resolved through its
|
||||
/// block on arrival.
|
||||
respawn: Option<RespawnConfig>,
|
||||
/// A respawn at the world spawn, moved onto a free column within the
|
||||
/// respawn radius once the chunks around it have loaded.
|
||||
world_spawn: bool,
|
||||
/// The search for that column while a candidate's chunk loads.
|
||||
finder: Option<PlayerSpawnFinder>,
|
||||
/// Sent by a command: the player is already where it goes.
|
||||
placed: bool,
|
||||
}
|
||||
@@ -66,10 +71,11 @@ impl Game<'_> {
|
||||
/// in it (Nether portals count up their transition time, End portals
|
||||
/// take them at once).
|
||||
pub(super) fn tick_server_travel(&mut self, host: &mut LanHost) {
|
||||
// Outside a server only the respawns at the world spawn wait for chunks.
|
||||
self.arrive_server_trips(host);
|
||||
if self.options.dedicated.is_none() {
|
||||
return;
|
||||
}
|
||||
self.arrive_server_trips(host);
|
||||
let conns: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
@@ -253,6 +259,8 @@ impl Game<'_> {
|
||||
plan,
|
||||
gateway: None,
|
||||
respawn: None,
|
||||
world_spawn: false,
|
||||
finder: None,
|
||||
placed: false,
|
||||
});
|
||||
if let Err(e) = self.enter_level(Some(host), source) {
|
||||
@@ -272,6 +280,8 @@ impl Game<'_> {
|
||||
plan: None,
|
||||
gateway: None,
|
||||
respawn: Some(config),
|
||||
world_spawn: false,
|
||||
finder: None,
|
||||
placed: false,
|
||||
});
|
||||
}
|
||||
@@ -304,6 +314,28 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// TeleportTransition.createDefault and missingRespawnBlock: the player
|
||||
/// waits at the world spawn for its chunks, then PlayerSpawnFinder puts
|
||||
/// it on a free column within the respawn radius.
|
||||
pub(super) fn world_spawn_trip(&mut self, conn: u32) {
|
||||
let Some([x, y, z]) = self.level.world_spawn else {
|
||||
return;
|
||||
};
|
||||
self.server_trips.retain(|t| !t.is(conn));
|
||||
self.server_trips.push(Trip {
|
||||
conn,
|
||||
dim: Dim::Overworld,
|
||||
target: Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5),
|
||||
end: false,
|
||||
plan: None,
|
||||
gateway: None,
|
||||
respawn: None,
|
||||
world_spawn: true,
|
||||
finder: None,
|
||||
placed: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// TeleportTransition.missingRespawnBlock: the world spawn, in the
|
||||
/// Overworld.
|
||||
fn respawn_at_world_spawn(&mut self, host: &mut LanHost, conn: u32) {
|
||||
@@ -318,6 +350,7 @@ impl Game<'_> {
|
||||
rotation: None,
|
||||
});
|
||||
self.run_level_teleports(host);
|
||||
self.world_spawn_trip(conn);
|
||||
}
|
||||
|
||||
/// `TheEndGatewayBlockEntity.getPortalPosition` and the teleport within
|
||||
@@ -343,6 +376,8 @@ impl Game<'_> {
|
||||
plan: None,
|
||||
gateway: Some(job),
|
||||
respawn: None,
|
||||
world_spawn: false,
|
||||
finder: None,
|
||||
placed: false,
|
||||
});
|
||||
}
|
||||
@@ -369,7 +404,7 @@ impl Game<'_> {
|
||||
k += 1;
|
||||
continue;
|
||||
}
|
||||
let trip = self.server_trips.remove(k);
|
||||
let mut trip = self.server_trips.remove(k);
|
||||
if trip.placed {
|
||||
continue;
|
||||
}
|
||||
@@ -383,6 +418,22 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else if trip.world_spawn {
|
||||
// PlayerSpawnFinder.findSpawn around the world spawn.
|
||||
let suggestion =
|
||||
[trip.target.x, trip.target.y, trip.target.z].map(|v| v.floor() as i32);
|
||||
match self.search_spawn(suggestion, &mut trip.finder) {
|
||||
// The respawn already put the player on this column:
|
||||
// vanilla teleports once, so no second move.
|
||||
Ok(at) if host.hub.guests[i].position == [at.x, at.y, at.z] => continue,
|
||||
Ok(at) => (at, yaw),
|
||||
Err(chunk) => {
|
||||
self.spawn_wait.push(chunk);
|
||||
self.server_trips.insert(k, trip);
|
||||
k += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else if let Some(job) = trip.gateway {
|
||||
match self.resolve_gateway(job, trip.target) {
|
||||
Ok(at) => (at, yaw),
|
||||
@@ -392,12 +443,19 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
} else if trip.end {
|
||||
let arrival = match self.as_guest(host, trip.conn, |g| g.end_arrival(trip.target)) {
|
||||
let arrival = match self.as_guest(host, trip.conn, |g| {
|
||||
g.end_arrival(trip.target, &mut trip.finder)
|
||||
}) {
|
||||
Some(arrival) => arrival,
|
||||
None => self.end_arrival(trip.target),
|
||||
None => self.end_arrival(trip.target, &mut trip.finder),
|
||||
};
|
||||
match arrival {
|
||||
Ok((at, turned)) => (at, turned.unwrap_or(yaw)),
|
||||
Ok(Some((at, turned))) => (at, turned.unwrap_or(yaw)),
|
||||
Ok(None) => {
|
||||
self.server_trips.insert(k, trip);
|
||||
k += 1;
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("End arrival: {e}");
|
||||
(trip.target, yaw)
|
||||
@@ -458,6 +516,8 @@ impl Game<'_> {
|
||||
plan: None,
|
||||
gateway: None,
|
||||
respawn: None,
|
||||
world_spawn: false,
|
||||
finder: None,
|
||||
placed: true,
|
||||
});
|
||||
if let Err(e) = self.enter_level(Some(host), source) {
|
||||
|
||||
@@ -176,7 +176,14 @@ impl Game<'_> {
|
||||
if projectile {
|
||||
return;
|
||||
}
|
||||
let Some(id) = attacker else { return };
|
||||
let Some(id) = attacker else {
|
||||
// Another player's melee hit has no mob behind it.
|
||||
if let Some((from, disable)) = self.combat.player_attacker {
|
||||
let fully = outcome.blocked >= damage;
|
||||
self.blocked_by_attacker(&component, [from[0], from[2]], fully, disable, at);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let fully = outcome.blocked >= damage;
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
@@ -219,6 +226,21 @@ impl Game<'_> {
|
||||
let _ = self.handle_mob_events(events);
|
||||
return;
|
||||
}
|
||||
self.blocked_by_attacker(&component, [mx, mz], fully, disable, at);
|
||||
}
|
||||
|
||||
/// What a blocked melee hit does to the defender once the shield took
|
||||
/// it: `LivingEntity.blockedByItem` knocks them back unless fully
|
||||
/// blocked, then `Player.blockUsingItem` disables the shield.
|
||||
fn blocked_by_attacker(
|
||||
&mut self,
|
||||
component: &horizon_client::items::BlocksAttacks,
|
||||
attacker: [f64; 2],
|
||||
fully: bool,
|
||||
disable: f32,
|
||||
at: Option<[f64; 3]>,
|
||||
) {
|
||||
let [mx, mz] = attacker;
|
||||
// LivingEntity.blockedByItem: defender.knockback(0.5, defender - attacker),
|
||||
// which moves the defender along -(xd, zd).
|
||||
if !fully && let Some(p) = self.player.as_mut() {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Parrots on a player's shoulder: a tame parrot lands on its owner
|
||||
//! (`LandOnOwnersShoulderGoal`), is kept as the player's `ShoulderEntityLeft`
|
||||
//! or `ShoulderEntityRight`, and flies off again when the player is hurt,
|
||||
//! falls, swims, flies or sleeps (`ServerPlayer.handleShoulderEntities`).
|
||||
use super::Game;
|
||||
use horizon_client::mobs::Species;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
const KEYS: [&str; 2] = ["ShoulderEntityLeft", "ShoulderEntityRight"];
|
||||
|
||||
/// A shoulder slot holds a parrot when its compound is not empty.
|
||||
fn occupied(extra: &Tag, key: &str) -> bool {
|
||||
matches!(extra.get(key), Some(Tag::Compound(c)) if !c.is_empty())
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ServerPlayer.setEntityOnShoulder` for a parrot that touched its
|
||||
/// owner: stored in the first free slot, the parrot gone from the world.
|
||||
pub(super) fn parrot_to_shoulder(&mut self, mob: u32) {
|
||||
let Some(i) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.position(|m| m.id == mob && m.alive() && !m.removed && m.species() == Species::Parrot)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
// The acting player must be the saved owner (`getOwner`).
|
||||
if !horizon_client::mobs::tame::owned_by(self.mobs[i].extra.owner, self.acting_uuid()) {
|
||||
return;
|
||||
}
|
||||
if self.riding.is_some()
|
||||
|| !p.ground.on_ground
|
||||
|| p.fluids.was_touching_water
|
||||
|| p.in_powder_snow
|
||||
|| p.flying
|
||||
{
|
||||
return;
|
||||
}
|
||||
let extra = self
|
||||
.level
|
||||
.player_extra
|
||||
.get_or_insert_with(|| Tag::Compound(Default::default()));
|
||||
let Some(key) = KEYS.into_iter().find(|k| !occupied(extra, k)) else {
|
||||
return;
|
||||
};
|
||||
let tag = self.mob_tree_nbt(i);
|
||||
if let Some(Tag::Compound(map)) = self.level.player_extra.as_mut() {
|
||||
map.insert(key.into(), tag);
|
||||
}
|
||||
let (uuid, now) = (self.acting_uuid(), self.world.game_time);
|
||||
self.creatures.shoulder_time.insert(uuid, now);
|
||||
self.mobs[i].removed = true;
|
||||
}
|
||||
|
||||
/// `ServerPlayer.handleShoulderEntities`: the parrots leave the shoulder
|
||||
/// of a player who falls, swims, flies, sleeps, is hurt, dying, a
|
||||
/// spectator or spinning with a riptide.
|
||||
pub(super) fn handle_shoulder_entities(&mut self) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let any = self
|
||||
.level
|
||||
.player_extra
|
||||
.as_ref()
|
||||
.is_some_and(|e| KEYS.iter().any(|k| occupied(e, k)));
|
||||
if any
|
||||
&& (p.fall_distance > 0.5
|
||||
|| p.fluids.was_touching_water
|
||||
|| p.flying
|
||||
|| p.spectator
|
||||
|| p.in_powder_snow
|
||||
|| p.auto_spin_attack_ticks > 0
|
||||
|| p.health.hurt.hurt_time > 0
|
||||
|| p.health.dead_or_dying()
|
||||
|| self.sleeping.is_some())
|
||||
{
|
||||
self.remove_entities_on_shoulder();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerPlayer.removeEntitiesOnShoulder` (not within a second of the
|
||||
/// landing) and `respawnEntityOnShoulder`: the parrots come back at the
|
||||
/// player, still owned by them.
|
||||
fn remove_entities_on_shoulder(&mut self) {
|
||||
let (uuid, now) = (self.acting_uuid(), self.world.game_time);
|
||||
if self
|
||||
.creatures
|
||||
.shoulder_time
|
||||
.get(&uuid)
|
||||
.is_some_and(|t| t + 20 >= now)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(p) = self.player.as_ref().map(|p| p.position) else {
|
||||
return;
|
||||
};
|
||||
let Some(Tag::Compound(map)) = self.level.player_extra.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let tags: Vec<Tag> = KEYS
|
||||
.iter()
|
||||
.filter_map(|k| map.remove(&(*k).into()))
|
||||
.collect();
|
||||
let pos = [p.x, p.y + f64::from(0.7_f32), p.z];
|
||||
for mut tag in tags {
|
||||
let Tag::Compound(c) = &mut tag else {
|
||||
continue;
|
||||
};
|
||||
if c.is_empty() {
|
||||
continue;
|
||||
}
|
||||
c.insert(
|
||||
"Pos".into(),
|
||||
Tag::List(pos.iter().map(|v| Tag::Double(v.to_bits())).collect()),
|
||||
);
|
||||
c.insert("Owner".into(), Tag::IntArray(uuid.to_vec()));
|
||||
self.load_mob(tag, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user