mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
484
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27c3559421 | ||
|
|
a91711afaf | ||
|
|
4df2461b65 | ||
|
|
35540bda73 | ||
|
|
f66e6a944e | ||
|
|
26547a5f29 | ||
|
|
41c2c2205c | ||
|
|
43134f4e41 | ||
|
|
85c8276e06 | ||
|
|
95bf2811fc | ||
|
|
cf62fe270b | ||
|
|
b784b15b8a | ||
|
|
38b508e5a7 | ||
|
|
de24a16ef4 | ||
|
|
9b53749263 | ||
|
|
cdccd03c7d | ||
|
|
27943eb6c7 | ||
|
|
28fbc7ff47 | ||
|
|
33ced209a0 | ||
|
|
8155baa6f3 | ||
|
|
423a87cf68 | ||
|
|
ff8a13c99a | ||
|
|
0e1fcaa3cc | ||
|
|
1b45824beb | ||
|
|
a5053a898c | ||
|
|
980807b0f5 | ||
|
|
610c2f3c10 | ||
|
|
7afb41a128 | ||
|
|
f6e96e484c | ||
|
|
ce64c617e9 | ||
|
|
1cfb4def89 | ||
|
|
b2fd94f49f | ||
|
|
b8eac21b6f | ||
|
|
fad711f55f | ||
|
|
4bc514829c | ||
|
|
b8ae7233e0 | ||
|
|
5001e3f57d | ||
|
|
f731ca1737 | ||
|
|
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 | ||
|
|
8d2b298253 | ||
|
|
9914a8af41 | ||
|
|
9972feb710 | ||
|
|
e776eb1716 | ||
|
|
78e635fe3e | ||
|
|
5e582420ed | ||
|
|
3ff797d73d | ||
|
|
1d52fe8101 | ||
|
|
2487d29318 | ||
|
|
3b8a8eeb91 | ||
|
|
063b6ee2b7 | ||
|
|
4414c3c1af | ||
|
|
6cf5b046fa | ||
|
|
d6ecd23a6e | ||
|
|
12a8782268 | ||
|
|
028f608816 | ||
|
|
0f8f8fce11 | ||
|
|
18b317186a | ||
|
|
708db4627e | ||
|
|
e93299fb80 | ||
|
|
e12d4c9d07 | ||
|
|
505d17a425 | ||
|
|
2d89e9ae5f | ||
|
|
14470343d1 | ||
|
|
9d9389310d | ||
|
|
8649edaf16 | ||
|
|
3a8706d0d7 | ||
|
|
0762f49f20 | ||
|
|
d980703786 | ||
|
|
efd7b94c15 | ||
|
|
9f2e4485ad | ||
|
|
bbd5c1c833 | ||
|
|
639ac0e4c6 | ||
|
|
76e3a0f7b7 | ||
|
|
47e6541731 | ||
|
|
361020fc23 | ||
|
|
417ee432c5 | ||
|
|
accd2718ac | ||
|
|
854bcb158f | ||
|
|
1bd74d1b2f | ||
|
|
967a9363ce | ||
|
|
ed10d376fa | ||
|
|
8d5424408c | ||
|
|
99ce9afd63 | ||
|
|
21ac444f28 | ||
|
|
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 | ||
|
|
0914229800 | ||
|
|
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 | ||
|
|
62c5a6d27b | ||
|
|
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 | ||
|
|
6145077d7e | ||
|
|
512e003cbd | ||
|
|
2a297a43eb | ||
|
|
7eaa5f592b | ||
|
|
be714b38bb | ||
|
|
1a5e5eb358 | ||
|
|
fba004eb3d | ||
|
|
2d70e55fbb | ||
|
|
d0df238972 | ||
|
|
d97b58fd4a | ||
|
|
e872d3ae54 | ||
|
|
fbdd04d817 | ||
|
|
aed4e26f59 | ||
|
|
b5adad15eb | ||
|
|
d17df3ca05 | ||
|
|
2d72e987c5 | ||
|
|
9ae3882c1a | ||
|
|
89c195fa6e | ||
|
|
de808e3612 | ||
|
|
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",
|
||||
|
||||
@@ -31,7 +31,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
|
||||
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
|
||||
|
||||
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)); Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -47,7 +47,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
@@ -158,7 +158,7 @@ them too (Rust uses Java's own float maths on each platform).
|
||||
|
||||
### Known issues
|
||||
|
||||
Horizon can't sign in to a Microsoft account yet, so it can't join online-mode servers (it joins offline-mode ones); skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -23,4 +24,5 @@ pub mod snbt;
|
||||
pub mod sound_source;
|
||||
pub mod text;
|
||||
pub mod uuid;
|
||||
pub mod who;
|
||||
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
@@ -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()));
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Who an action came from or is aimed at: the player or a mob by id.
|
||||
|
||||
/// Who caused damage or is being targeted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Who {
|
||||
Player,
|
||||
Mob(u32),
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! the rain, nor through a blocked front, unless the hive is emptied in an
|
||||
//! emergency), honey for every bee coming home with nectar. The host (the game
|
||||
//! or the mob AI probe) loads and adds the bees through [`HiveHost`].
|
||||
pub use crate::chunk::block_entity::{Beehive, Occupant};
|
||||
use crate::mobs::{Mob, Species, bee};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
@@ -38,20 +39,6 @@ const IGNORED_BEE_TAGS: [&str; 25] = [
|
||||
"UUID",
|
||||
];
|
||||
|
||||
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Beehive {
|
||||
pub bees: Vec<Occupant>,
|
||||
pub flower: Option<[i32; 3]>,
|
||||
}
|
||||
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
|
||||
/// the hive and the time it stays at least.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Occupant {
|
||||
pub entity_data: Tag,
|
||||
pub ticks_in_hive: i32,
|
||||
pub min_ticks_in_hive: i32,
|
||||
}
|
||||
/// `BeehiveBlockEntity.BeeReleaseStatus`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Release {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
//! Which states tick is a table by state id, built by the game from the block
|
||||
//! registry; whether a slot is made is decided when the slot is promoted at
|
||||
//! the start of the next tick, from the block state then.
|
||||
use crate::containers::BlockEntity;
|
||||
use crate::chunk::block_entity::BlockEntity;
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,13 @@
|
||||
//! in vanilla, ported as tables), data-driven `minecraft:brewing` recipes from
|
||||
//! `data/minecraft/recipe/brewing`, potion colours and tooltips, and
|
||||
//! `BrewingStandBlockEntity.serverTick`.
|
||||
pub use crate::chunk::block_entity::BrewingStand;
|
||||
use crate::{
|
||||
containers::Providers,
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashSet, fs, path::Path, sync::Arc};
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
|
||||
pub use horizon_client_core::potions::{
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
|
||||
@@ -190,18 +191,6 @@ impl Brewing {
|
||||
}
|
||||
}
|
||||
|
||||
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BrewingStand {
|
||||
pub items: [Option<ItemStack>; 5],
|
||||
pub brew_time: i32,
|
||||
pub total_brew_time: i32,
|
||||
pub fuel: i32,
|
||||
pub total_fuel: i32,
|
||||
pub speed: f32,
|
||||
pub ingredient: Option<Arc<str>>,
|
||||
pub last_bottles: Option<[bool; 3]>,
|
||||
}
|
||||
/// What a brewing tick asks the world to do.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct BrewTick {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,8 @@ use horizon_world::nbt::{Compound, Tag};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub mod block_entity;
|
||||
|
||||
pub const MIN_Y: i32 = -64;
|
||||
pub const HEIGHT: i32 = 384;
|
||||
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
|
||||
@@ -149,7 +151,7 @@ pub struct Chunk {
|
||||
/// Newly generated (not loaded from a save): eligible for generation-time spawns.
|
||||
pub fresh: bool,
|
||||
/// Block entities read from a save, moved into the world on arrival.
|
||||
pub block_entities: Vec<((i32, i32, i32), crate::containers::BlockEntity)>,
|
||||
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
|
||||
/// `CustomName` of the container block entities (shown as the screen title).
|
||||
pub block_names: Vec<((i32, i32, i32), String)>,
|
||||
/// Generation postprocessing marks (ProtoChunk.postProcessing) as world
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Block entity data: what a chunk stores for its block entities (`BlockEntity`
|
||||
//! and its payloads). Plain data, as vanilla's block entities are world data;
|
||||
//! their behaviour lives with the features that use them (`containers`,
|
||||
//! `brewing`, `signs`, ...), which re-export these types under their old paths.
|
||||
use horizon_client_core::{text::Text, who::Who};
|
||||
use horizon_items::{banner_layer::BannerLayer, items::ItemStack};
|
||||
use horizon_world::{
|
||||
level::sculk::SculkSpreader,
|
||||
nbt::{Compound, Tag},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// An entity's UUID as its four ints (`UUIDUtil.CODEC`).
|
||||
pub type Uuid = [i32; 4];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FurnaceKind {
|
||||
Furnace,
|
||||
BlastFurnace,
|
||||
Smoker,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Furnace {
|
||||
pub kind: FurnaceKind,
|
||||
/// input, fuel, result
|
||||
pub items: [Option<ItemStack>; 3],
|
||||
pub lit_remaining: i32,
|
||||
pub lit_total: i32,
|
||||
pub cooking_timer: i32,
|
||||
pub cooking_total: i32,
|
||||
pub speed: f32,
|
||||
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
|
||||
/// made since a player last took from its output. The experience is owed per recipe
|
||||
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
|
||||
pub recipes_used: HashMap<String, u32>,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BlockEntity {
|
||||
Chest(Vec<Option<ItemStack>>),
|
||||
Furnace(Furnace),
|
||||
Brewing(Box<BrewingStand>),
|
||||
/// Signs and hanging signs (`SignBlockEntity`).
|
||||
Sign(SignData),
|
||||
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
|
||||
Jukebox(Jukebox),
|
||||
/// `BeaconBlockEntity`.
|
||||
Beacon(Box<Beacon>),
|
||||
/// `LecternBlockEntity`: the book and the open page.
|
||||
Lectern(Lectern),
|
||||
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
|
||||
Sculk(Box<SculkEntity>),
|
||||
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
|
||||
CreakingHeart(Box<CreakingHeart>),
|
||||
/// `BannerBlockEntity`: the pattern layers.
|
||||
Banner(Banner),
|
||||
/// `CrafterBlockEntity`.
|
||||
Crafter(Crafter),
|
||||
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
|
||||
Spawner(Box<Spawner>),
|
||||
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
|
||||
TrialSpawner(Box<TrialSpawner>),
|
||||
/// `VaultBlockEntity` (`crate::vault`).
|
||||
Vault(Box<Vault>),
|
||||
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
|
||||
Beehive(Box<Beehive>),
|
||||
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
|
||||
CommandBlock(Box<CommandBlock>),
|
||||
/// `CampfireBlockEntity` of campfires and soul campfires.
|
||||
Campfire(Box<Campfire>),
|
||||
/// `SkullBlockEntity` of every skull and head block.
|
||||
Skull(Skull),
|
||||
/// `TheEndGatewayBlockEntity`: its age and configured destination.
|
||||
EndGateway(EndGateway),
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
}
|
||||
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ChiseledBookshelf {
|
||||
pub items: [Option<ItemStack>; 6],
|
||||
pub last_slot: i32,
|
||||
}
|
||||
impl Default for ChiseledBookshelf {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: Default::default(),
|
||||
last_slot: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EndGateway {
|
||||
pub age: i64,
|
||||
pub exit_portal: Option<[i32; 3]>,
|
||||
pub exact_teleport: bool,
|
||||
}
|
||||
/// `CampfireBlockEntity`: four cooking slots, each with its
|
||||
/// `cookingProgress` and `cookingTime` (the recipe's).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Campfire {
|
||||
pub items: [Option<ItemStack>; 4],
|
||||
pub progress: [i32; 4],
|
||||
pub total: [i32; 4],
|
||||
}
|
||||
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
|
||||
/// a compound, or a string when it names a player only), the note block
|
||||
/// sound and `custom_name`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Skull {
|
||||
pub profile: Option<horizon_world::nbt::Tag>,
|
||||
pub note_block_sound: Option<String>,
|
||||
pub custom_name: Option<horizon_world::nbt::Tag>,
|
||||
/// What `profile` draws, read from it once rather than every frame.
|
||||
pub skin: Option<SkullSkin>,
|
||||
}
|
||||
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
|
||||
/// with the `skins/<sha1>` sprite it downloads to, and the
|
||||
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
|
||||
/// profile with no id).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SkullSkin {
|
||||
pub download: Option<(String, String)>,
|
||||
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
|
||||
/// an id only): the key `skins::Resolver` resolves it by.
|
||||
pub dynamic: Option<NameOrId>,
|
||||
pub fallback: &'static str,
|
||||
}
|
||||
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
|
||||
/// by (`ResolvableProfile.nameOrId`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum NameOrId {
|
||||
Name(String),
|
||||
Id(u128),
|
||||
}
|
||||
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
|
||||
/// and the ticks left in the crafting animation.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Crafter {
|
||||
pub items: [Option<ItemStack>; 9],
|
||||
pub disabled: [bool; 9],
|
||||
pub triggered: bool,
|
||||
pub crafting_ticks: i32,
|
||||
/// The block state's `triggered` when last ticked (not saved): the block
|
||||
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
|
||||
pub state_triggered: Option<bool>,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Banner {
|
||||
pub layers: Vec<BannerLayer>,
|
||||
pub name: Option<String>,
|
||||
/// Components not consumed by BannerBlockEntity's implicit fields.
|
||||
pub components: horizon_world::nbt::Compound,
|
||||
}
|
||||
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CreakingHeart {
|
||||
/// The protector: its entity id once resolved, and its UUID (the saved form).
|
||||
pub creaking: Option<(Option<u32>, [i32; 4])>,
|
||||
pub ticks_existed: i64,
|
||||
pub ticker: i32,
|
||||
/// Ticks left of the hurt effect (`emitter`) and where it heads.
|
||||
pub emitter: i32,
|
||||
pub emitter_target: Option<[f64; 3]>,
|
||||
pub output_signal: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Lectern {
|
||||
pub book: Option<ItemStack>,
|
||||
pub page: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Beacon {
|
||||
pub levels: i32,
|
||||
/// Effect ids (`minecraft:speed`).
|
||||
pub primary: Option<String>,
|
||||
pub secondary: Option<String>,
|
||||
/// Beam sections (RGB colour, height) and the ones being rebuilt.
|
||||
pub sections: Vec<(u32, i32)>,
|
||||
pub checking: Vec<(u32, i32)>,
|
||||
pub last_check_y: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Jukebox {
|
||||
pub record: Option<ItemStack>,
|
||||
/// `ticks_since_song_started`, while a song plays.
|
||||
pub ticks: i64,
|
||||
pub playing: bool,
|
||||
}
|
||||
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BrewingStand {
|
||||
pub items: [Option<ItemStack>; 5],
|
||||
pub brew_time: i32,
|
||||
pub total_brew_time: i32,
|
||||
pub fuel: i32,
|
||||
pub total_fuel: i32,
|
||||
pub speed: f32,
|
||||
pub ingredient: Option<Arc<str>>,
|
||||
pub last_bottles: Option<[bool; 3]>,
|
||||
}
|
||||
/// One sign side (`SignText`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SignText {
|
||||
pub lines: [String; 4],
|
||||
/// `DyeColor` id.
|
||||
pub color: u8,
|
||||
pub glowing: bool,
|
||||
}
|
||||
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SignData {
|
||||
pub hanging: bool,
|
||||
pub front: SignText,
|
||||
pub back: SignText,
|
||||
pub waxed: bool,
|
||||
}
|
||||
/// `VibrationInfo`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Info {
|
||||
pub event: &'static str,
|
||||
pub distance: f32,
|
||||
pub pos: [f64; 3],
|
||||
pub source: Option<Who>,
|
||||
pub owner: Option<Who>,
|
||||
}
|
||||
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Selector {
|
||||
pub(crate) current: Option<(Info, i64)>,
|
||||
}
|
||||
/// `VibrationSystem.Data`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Data {
|
||||
pub current: Option<Info>,
|
||||
pub travel: i32,
|
||||
pub selector: Selector,
|
||||
}
|
||||
/// Which sculk block entity this is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SculkKind {
|
||||
Sensor,
|
||||
Calibrated,
|
||||
Shrieker,
|
||||
Catalyst,
|
||||
}
|
||||
/// `SculkSensorBlockEntity`, `CalibratedSculkSensorBlockEntity`,
|
||||
/// `SculkShriekerBlockEntity` and `SculkCatalystBlockEntity`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SculkEntity {
|
||||
pub kind: SculkKind,
|
||||
pub vibration: Data,
|
||||
/// `last_vibration_frequency` (sensors).
|
||||
pub last_frequency: i32,
|
||||
/// `warning_level` (shriekers).
|
||||
pub warning_level: i32,
|
||||
/// The catalyst's charge cursors.
|
||||
pub spreader: SculkSpreader,
|
||||
}
|
||||
/// `SpawnData.CustomSpawnRules`: the block and sky light the spawn block
|
||||
/// must have (inclusive ranges within 0..=15).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct CustomSpawnRules {
|
||||
pub block: (i32, i32),
|
||||
pub sky: (i32, i32),
|
||||
}
|
||||
/// `SpawnData`: the entity to spawn, optional light rules and an equipment
|
||||
/// table (kept as loaded).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct SpawnData {
|
||||
pub entity: Compound,
|
||||
pub custom_spawn_rules: Option<CustomSpawnRules>,
|
||||
pub equipment: Option<Tag>,
|
||||
}
|
||||
/// Which `SpawnData` is next: one of the potentials (the same object in
|
||||
/// vanilla, so changing it changes the potential) or its own.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Next {
|
||||
Potential(usize),
|
||||
Own(SpawnData),
|
||||
}
|
||||
/// `BaseSpawner`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Spawner {
|
||||
pub spawn_delay: i32,
|
||||
/// `SpawnPotentials`: data and weight.
|
||||
pub potentials: Vec<(SpawnData, i32)>,
|
||||
pub(crate) next: Option<Next>,
|
||||
pub min_spawn_delay: i32,
|
||||
pub max_spawn_delay: i32,
|
||||
pub spawn_count: i32,
|
||||
pub max_nearby_entities: i32,
|
||||
pub required_player_range: i32,
|
||||
pub spawn_range: i32,
|
||||
/// The client copy's delay (`spawnDelay` on the client level), which
|
||||
/// speeds the spin up as the next spawn nears.
|
||||
pub client_delay: i32,
|
||||
pub spin: f64,
|
||||
pub o_spin: f64,
|
||||
}
|
||||
/// `TrialSpawnerConfig`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TrialConfig {
|
||||
pub spawn_range: i32,
|
||||
pub total_mobs: f32,
|
||||
pub simultaneous_mobs: f32,
|
||||
pub total_mobs_added_per_player: f32,
|
||||
pub simultaneous_mobs_added_per_player: f32,
|
||||
pub ticks_between_spawn: i32,
|
||||
pub spawn_potentials: Vec<(SpawnData, i32)>,
|
||||
pub loot_tables_to_eject: Vec<(String, i32)>,
|
||||
pub items_to_drop_when_ominous: String,
|
||||
}
|
||||
/// A `Holder<TrialSpawnerConfig>` as saved: a registry id
|
||||
/// (`minecraft:trial_chamber/breeze/normal`) or an inline config.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ConfigSlot {
|
||||
/// The value as loaded, written back as is; None for the default.
|
||||
pub saved: Option<Tag>,
|
||||
pub config: TrialConfig,
|
||||
}
|
||||
/// `TrialSpawner` with its `TrialSpawnerStateData`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TrialSpawner {
|
||||
pub normal: ConfigSlot,
|
||||
pub ominous: ConfigSlot,
|
||||
pub target_cooldown_length: i32,
|
||||
pub required_player_range: i32,
|
||||
pub detected_players: Vec<Uuid>,
|
||||
pub current_mobs: Vec<Uuid>,
|
||||
pub cooldown_ends_at: i64,
|
||||
pub next_mob_spawns_at: i64,
|
||||
pub total_mobs_spawned: i32,
|
||||
pub next_spawn_data: Option<SpawnData>,
|
||||
pub ejecting_loot_table: Option<String>,
|
||||
/// `isOminous`, set from the block on every server tick.
|
||||
pub is_ominous: bool,
|
||||
/// The server's display entity exists (`getOrCreateDisplayEntity`): it is
|
||||
/// made once per loaded block entity and kept.
|
||||
pub has_display: bool,
|
||||
pub spin: f64,
|
||||
pub o_spin: f64,
|
||||
/// `getDispensingItems`, kept once rolled (not saved): the ominous items
|
||||
/// and their weights (the rolled counts).
|
||||
pub dispensing: Option<Vec<ItemStack>>,
|
||||
}
|
||||
/// `VaultConfig`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VaultConfig {
|
||||
pub loot_table: String,
|
||||
pub activation_range: f64,
|
||||
pub deactivation_range: f64,
|
||||
/// None when the config has no key (`ItemStack.EMPTY`): the vault never opens.
|
||||
pub key_item: Option<ItemStack>,
|
||||
pub override_loot_table_to_display: Option<String>,
|
||||
}
|
||||
/// `VaultBlockEntity`: its config, server data and shared data.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Vault {
|
||||
pub config: VaultConfig,
|
||||
/// `VaultServerData`, oldest rewarded player first.
|
||||
pub rewarded_players: Vec<Uuid>,
|
||||
pub state_updating_resumes_at: i64,
|
||||
/// Popped from the end.
|
||||
pub items_to_eject: Vec<ItemStack>,
|
||||
pub total_ejections_needed: i32,
|
||||
pub last_insert_fail_timestamp: i64,
|
||||
/// `VaultSharedData`.
|
||||
pub display_item: Option<ItemStack>,
|
||||
pub connected_players: Vec<Uuid>,
|
||||
pub connected_particles_range: f64,
|
||||
/// The shared data changed since the last tick sent it.
|
||||
pub shared_dirty: bool,
|
||||
/// `VaultClientData`: the display item's spin and the previous tick's, in degrees.
|
||||
pub spin: f32,
|
||||
pub o_spin: f32,
|
||||
}
|
||||
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Beehive {
|
||||
pub bees: Vec<Occupant>,
|
||||
pub flower: Option<[i32; 3]>,
|
||||
}
|
||||
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
|
||||
/// the hive and the time it stays at least.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Occupant {
|
||||
pub entity_data: Tag,
|
||||
pub ticks_in_hive: i32,
|
||||
pub min_ticks_in_hive: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CommandBlock {
|
||||
pub command: String,
|
||||
pub success_count: i32,
|
||||
/// `CustomName` as saved (a text component).
|
||||
pub custom_name: Option<Tag>,
|
||||
pub track_output: bool,
|
||||
pub last_output: Option<Text>,
|
||||
pub update_last_execution: bool,
|
||||
/// The game time of the last run, -1 for none.
|
||||
pub last_execution: i64,
|
||||
pub powered: bool,
|
||||
pub auto: bool,
|
||||
pub condition_met: bool,
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! `CommandBlockEntity` with its `BaseCommandBlock`: the command, the success
|
||||
//! count comparators read, the output it keeps, and the powered, automatic
|
||||
//! and condition flags `CommandBlock.tick` runs on.
|
||||
pub use crate::chunk::block_entity::CommandBlock;
|
||||
use crate::{
|
||||
save::{number, text},
|
||||
text::Text,
|
||||
@@ -76,22 +77,6 @@ pub fn automatic_block(name: &str) -> bool {
|
||||
name == "minecraft:chain_command_block"
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CommandBlock {
|
||||
pub command: String,
|
||||
pub success_count: i32,
|
||||
/// `CustomName` as saved (a text component).
|
||||
pub custom_name: Option<Tag>,
|
||||
pub track_output: bool,
|
||||
pub last_output: Option<Text>,
|
||||
pub update_last_execution: bool,
|
||||
/// The game time of the last run, -1 for none.
|
||||
pub last_execution: i64,
|
||||
pub powered: bool,
|
||||
pub auto: bool,
|
||||
pub condition_met: bool,
|
||||
}
|
||||
|
||||
impl Default for CommandBlock {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Container block entities: chests/barrels (27 slots) and the three furnace
|
||||
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
|
||||
//! from the data-driven `context_int_provider`/`context_float_provider` files.
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
|
||||
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
|
||||
};
|
||||
use crate::{
|
||||
crafting::Recipes,
|
||||
items::{ItemStack, Items},
|
||||
@@ -8,12 +12,6 @@ use crate::{
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FurnaceKind {
|
||||
Furnace,
|
||||
BlastFurnace,
|
||||
Smoker,
|
||||
}
|
||||
impl FurnaceKind {
|
||||
pub fn recipe_type(self) -> &'static str {
|
||||
match self {
|
||||
@@ -34,21 +32,6 @@ impl FurnaceKind {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Furnace {
|
||||
pub kind: FurnaceKind,
|
||||
/// input, fuel, result
|
||||
pub items: [Option<ItemStack>; 3],
|
||||
pub lit_remaining: i32,
|
||||
pub lit_total: i32,
|
||||
pub cooking_timer: i32,
|
||||
pub cooking_total: i32,
|
||||
pub speed: f32,
|
||||
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
|
||||
/// made since a player last took from its output. The experience is owed per recipe
|
||||
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
|
||||
pub recipes_used: HashMap<String, u32>,
|
||||
}
|
||||
/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey<Recipe>`
|
||||
/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so
|
||||
/// keeping both forms would write a key twice, and vanilla refuses such a furnace
|
||||
@@ -63,78 +46,6 @@ pub fn recipe_used_key(id: &str) -> String {
|
||||
/// A hopper's slots: the only plain container with five.
|
||||
pub const HOPPER_SLOTS: usize = 5;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BlockEntity {
|
||||
Chest(Vec<Option<ItemStack>>),
|
||||
Furnace(Furnace),
|
||||
Brewing(Box<crate::brewing::BrewingStand>),
|
||||
/// Signs and hanging signs (`SignBlockEntity`).
|
||||
Sign(crate::signs::SignData),
|
||||
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
|
||||
Jukebox(Jukebox),
|
||||
/// `BeaconBlockEntity`.
|
||||
Beacon(Box<Beacon>),
|
||||
/// `LecternBlockEntity`: the book and the open page.
|
||||
Lectern(Lectern),
|
||||
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
|
||||
Sculk(Box<crate::sculk::SculkEntity>),
|
||||
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
|
||||
CreakingHeart(Box<CreakingHeart>),
|
||||
/// `BannerBlockEntity`: the pattern layers.
|
||||
Banner(Banner),
|
||||
/// `CrafterBlockEntity`.
|
||||
Crafter(Crafter),
|
||||
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
|
||||
Spawner(Box<crate::mobs::spawner::Spawner>),
|
||||
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
|
||||
TrialSpawner(Box<crate::mobs::trial_spawner::TrialSpawner>),
|
||||
/// `VaultBlockEntity` (`crate::vault`).
|
||||
Vault(Box<crate::vault::Vault>),
|
||||
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
|
||||
Beehive(Box<crate::beehive::Beehive>),
|
||||
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
|
||||
CommandBlock(Box<crate::command_block::CommandBlock>),
|
||||
/// `CampfireBlockEntity` of campfires and soul campfires.
|
||||
Campfire(Box<Campfire>),
|
||||
/// `SkullBlockEntity` of every skull and head block.
|
||||
Skull(Skull),
|
||||
/// `TheEndGatewayBlockEntity`: its age and configured destination.
|
||||
EndGateway(EndGateway),
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
}
|
||||
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ChiseledBookshelf {
|
||||
pub items: [Option<ItemStack>; 6],
|
||||
pub last_slot: i32,
|
||||
}
|
||||
impl Default for ChiseledBookshelf {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: Default::default(),
|
||||
last_slot: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `TheEndGatewayBlockEntity`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EndGateway {
|
||||
pub age: i64,
|
||||
pub exit_portal: Option<[i32; 3]>,
|
||||
pub exact_teleport: bool,
|
||||
}
|
||||
/// `CampfireBlockEntity`: four cooking slots, each with its
|
||||
/// `cookingProgress` and `cookingTime` (the recipe's).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Campfire {
|
||||
pub items: [Option<ItemStack>; 4],
|
||||
pub progress: [i32; 4],
|
||||
pub total: [i32; 4],
|
||||
}
|
||||
impl Campfire {
|
||||
/// `placeFood`'s slot: the first empty one, given the item's
|
||||
/// `campfire_cooking` time; it takes one item of `held`.
|
||||
@@ -182,17 +93,6 @@ impl Campfire {
|
||||
changed
|
||||
}
|
||||
}
|
||||
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
|
||||
/// a compound, or a string when it names a player only), the note block
|
||||
/// sound and `custom_name`.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Skull {
|
||||
pub profile: Option<horizon_world::nbt::Tag>,
|
||||
pub note_block_sound: Option<String>,
|
||||
pub custom_name: Option<horizon_world::nbt::Tag>,
|
||||
/// What `profile` draws, read from it once rather than every frame.
|
||||
pub skin: Option<SkullSkin>,
|
||||
}
|
||||
impl Skull {
|
||||
pub fn new(
|
||||
profile: Option<horizon_world::nbt::Tag>,
|
||||
@@ -208,18 +108,6 @@ impl Skull {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
|
||||
/// with the `skins/<sha1>` sprite it downloads to, and the
|
||||
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
|
||||
/// profile with no id).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SkullSkin {
|
||||
pub download: Option<(String, String)>,
|
||||
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
|
||||
/// an id only): the key `skins::Resolver` resolves it by.
|
||||
pub dynamic: Option<crate::skins::NameOrId>,
|
||||
pub fallback: &'static str,
|
||||
}
|
||||
impl SkullSkin {
|
||||
pub fn of(profile: &horizon_world::nbt::Tag) -> Self {
|
||||
let download = crate::skins::profile_skin_url_nbt(profile).and_then(|url| {
|
||||
@@ -248,18 +136,6 @@ impl SkullSkin {
|
||||
fn is_skull(name: &str) -> bool {
|
||||
name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
|
||||
}
|
||||
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
|
||||
/// and the ticks left in the crafting animation.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Crafter {
|
||||
pub items: [Option<ItemStack>; 9],
|
||||
pub disabled: [bool; 9],
|
||||
pub triggered: bool,
|
||||
pub crafting_ticks: i32,
|
||||
/// The block state's `triggered` when last ticked (not saved): the block
|
||||
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
|
||||
pub state_triggered: Option<bool>,
|
||||
}
|
||||
impl Crafter {
|
||||
/// `slotCanBeDisabled`: only empty slots.
|
||||
pub fn slot_can_be_disabled(&self, slot: usize) -> bool {
|
||||
@@ -299,48 +175,6 @@ impl Crafter {
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Banner {
|
||||
pub layers: Vec<crate::banner::BannerLayer>,
|
||||
pub name: Option<String>,
|
||||
/// Components not consumed by BannerBlockEntity's implicit fields.
|
||||
pub components: horizon_world::nbt::Compound,
|
||||
}
|
||||
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CreakingHeart {
|
||||
/// The protector: its entity id once resolved, and its UUID (the saved form).
|
||||
pub creaking: Option<(Option<u32>, [i32; 4])>,
|
||||
pub ticks_existed: i64,
|
||||
pub ticker: i32,
|
||||
/// Ticks left of the hurt effect (`emitter`) and where it heads.
|
||||
pub emitter: i32,
|
||||
pub emitter_target: Option<[f64; 3]>,
|
||||
pub output_signal: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Lectern {
|
||||
pub book: Option<ItemStack>,
|
||||
pub page: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Beacon {
|
||||
pub levels: i32,
|
||||
/// Effect ids (`minecraft:speed`).
|
||||
pub primary: Option<String>,
|
||||
pub secondary: Option<String>,
|
||||
/// Beam sections (RGB colour, height) and the ones being rebuilt.
|
||||
pub sections: Vec<(u32, i32)>,
|
||||
pub checking: Vec<(u32, i32)>,
|
||||
pub last_check_y: i32,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Jukebox {
|
||||
pub record: Option<ItemStack>,
|
||||
/// `ticks_since_song_started`, while a song plays.
|
||||
pub ticks: i64,
|
||||
pub playing: bool,
|
||||
}
|
||||
impl BlockEntity {
|
||||
pub fn for_block(name: &str) -> Option<Self> {
|
||||
if let Some(hanging) = crate::signs::sign_kind(name) {
|
||||
|
||||
@@ -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,20 @@ 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.extra.owner == Some(acting)
|
||||
&& 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 +3716,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 +3750,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 +3759,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 +3784,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 +3870,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 +3895,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 +4276,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 +4522,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 +4539,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 +4564,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 +4584,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 +4600,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 +4627,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,14 +26,28 @@ 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
|
||||
// A tame wolf, cat or parrot sees its owner (`TamableAnimal.getOwner`), while they
|
||||
// are in play, whoever is nearer. Not while it targets or was just hurt by
|
||||
// a player: then `Who::Player` is that attacker, whoever it is, and the
|
||||
// owner goals ignore it until the wolf lets go.
|
||||
if !targeting
|
||||
&& matches!(m.species(), Species::Wolf | Species::Cat)
|
||||
&& matches!(m.species(), Species::Wolf | Species::Cat | Species::Parrot)
|
||||
&& m.extra.tame
|
||||
&& let Some(owner) = m.extra.owner
|
||||
{
|
||||
@@ -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);
|
||||
@@ -4703,7 +4910,10 @@ impl Game<'_> {
|
||||
if self.using.is_some() {
|
||||
return;
|
||||
}
|
||||
let bundle = self.res.items().get(&held.item).is_some_and(|i| i.bundle);
|
||||
let start = match &*held.item {
|
||||
// BundleItem.use: always starts; the server drops the contents.
|
||||
_ if bundle => true,
|
||||
// BowItem.use: with an arrow, or in creative.
|
||||
"minecraft:bow" => self.creative || self.projectile_slot("minecraft:bow").is_some(),
|
||||
// CrossbowItem.use: charging when not charged and with ammunition;
|
||||
@@ -4719,6 +4929,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 +4947,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 +4992,38 @@ 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;
|
||||
}
|
||||
// BundleItem.getUseDuration: 200 ticks, the server drops the contents.
|
||||
if stack
|
||||
.as_ref()
|
||||
.is_some_and(|s| self.res.items().get(&s.item).is_some_and(|i| i.bundle))
|
||||
{
|
||||
self.using = (in_hand && ticks + 1 < 200).then_some((slot, ticks + 1));
|
||||
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 +5165,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 +5196,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 +5268,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 +5890,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 +6194,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 +6265,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 +6285,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
|
||||
|
||||
Loaded 100 of 262 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user