mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
710
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18156172a3 | ||
|
|
4c49c45a04 | ||
|
|
7bd8747909 | ||
|
|
40e66ea972 | ||
|
|
0acee0a978 | ||
|
|
324239ea84 | ||
|
|
93c4acf169 | ||
|
|
2f8c781f04 | ||
|
|
3cdd63e01d | ||
|
|
aace479a0c | ||
|
|
d54cab9565 | ||
|
|
a3c05538ca | ||
|
|
fddd6b3864 | ||
|
|
ee217acc19 | ||
|
|
18510648ea | ||
|
|
f65a4e45cf | ||
|
|
28c105fa9b | ||
|
|
2cd77f6d49 | ||
|
|
a045782e04 | ||
|
|
c5f0414a0a | ||
|
|
2c82bbee6a | ||
|
|
0529ae8250 | ||
|
|
1c876168bd | ||
|
|
48a3d8d175 | ||
|
|
f8040cb977 | ||
|
|
1af3b2ebd9 | ||
|
|
1e8396db70 | ||
|
|
6a031aa651 | ||
|
|
98d53642fa | ||
|
|
60bcab5d61 | ||
|
|
c083b9ed02 | ||
|
|
1ada2e33c8 | ||
|
|
2a6bf0fae5 | ||
|
|
5f14c125ed | ||
|
|
152afb5529 | ||
|
|
3972efa333 | ||
|
|
4c348837e4 | ||
|
|
521a761745 | ||
|
|
bf11ca8ac9 | ||
|
|
df08ffda39 | ||
|
|
7d3aeb0315 | ||
|
|
d4b7ef198c | ||
|
|
6b011e436d | ||
|
|
d98450a575 | ||
|
|
d860b9bfc0 | ||
|
|
7f4ae40cc5 | ||
|
|
7a53c8833b | ||
|
|
f8e7517393 | ||
|
|
42cbbb565e | ||
|
|
4c7ada0d60 | ||
|
|
1788bf8134 | ||
|
|
9914a8af41 | ||
|
|
9972feb710 | ||
|
|
063b6ee2b7 | ||
|
|
d6ecd23a6e | ||
|
|
12a8782268 | ||
|
|
028f608816 | ||
|
|
18b317186a | ||
|
|
e93299fb80 | ||
|
|
e12d4c9d07 | ||
|
|
505d17a425 | ||
|
|
2d89e9ae5f | ||
|
|
14470343d1 | ||
|
|
8649edaf16 | ||
|
|
3a8706d0d7 | ||
|
|
d980703786 | ||
|
|
efd7b94c15 | ||
|
|
9f2e4485ad | ||
|
|
639ac0e4c6 | ||
|
|
76e3a0f7b7 | ||
|
|
47e6541731 | ||
|
|
361020fc23 | ||
|
|
417ee432c5 | ||
|
|
accd2718ac | ||
|
|
854bcb158f | ||
|
|
1bd74d1b2f | ||
|
|
967a9363ce | ||
|
|
ed10d376fa | ||
|
|
8d5424408c | ||
|
|
99ce9afd63 | ||
|
|
7efd8ef33a | ||
|
|
c6633ea1bb | ||
|
|
6c0e262aa0 | ||
|
|
c9990e43e9 | ||
|
|
79eddec09c | ||
|
|
0ff1df58a2 | ||
|
|
58beb98327 | ||
|
|
96f84abfeb | ||
|
|
0879f3dbd2 | ||
|
|
27af1879d7 | ||
|
|
096814ade9 | ||
|
|
d177ad48e5 | ||
|
|
21123aaa3c | ||
|
|
65096c5b2e | ||
|
|
9948e9d3ee | ||
|
|
3c3884ff38 | ||
|
|
30c58b6591 | ||
|
|
fd2265dc19 | ||
|
|
84dcd156f8 | ||
|
|
5ca6bd7de6 | ||
|
|
2a6b4c0ae6 | ||
|
|
fbc7c628f6 | ||
|
|
c20565ee49 | ||
|
|
4e9c68e3da | ||
|
|
712144297a | ||
|
|
651ac2cfd5 | ||
|
|
2853015f0e | ||
|
|
e350b87b3c | ||
|
|
6567f95aa3 | ||
|
|
1361ae8338 | ||
|
|
d4412d160f | ||
|
|
38ddc48cab | ||
|
|
ef0d62e47e | ||
|
|
6f4b1bf4c2 | ||
|
|
691341b2b0 | ||
|
|
83979b38d6 | ||
|
|
b189f1b44c | ||
|
|
958db5f40f | ||
|
|
6b9d3309cc | ||
|
|
c5d737b536 | ||
|
|
57fe750035 | ||
|
|
3849e37393 | ||
|
|
9b091ed657 | ||
|
|
777e0e2eb5 | ||
|
|
04ecdee7a2 | ||
|
|
6ddc3226d4 | ||
|
|
1903865cd5 | ||
|
|
d5d54a8775 | ||
|
|
c27e2d2c2d | ||
|
|
d463d793a9 | ||
|
|
f10425675c | ||
|
|
aa8ab4253d | ||
|
|
fa56070097 | ||
|
|
dd5863edc2 | ||
|
|
37cabdcb02 | ||
|
|
a40ef4eee0 | ||
|
|
914a6956e9 | ||
|
|
698b49e3a5 | ||
|
|
ec067f1e39 | ||
|
|
6081a66629 | ||
|
|
ee69413a68 | ||
|
|
fc4e7af854 | ||
|
|
0e069c31d7 | ||
|
|
fbbff39e07 | ||
|
|
49c2000de3 | ||
|
|
f561d4845b | ||
|
|
d6c4e5a027 | ||
|
|
ad271604af | ||
|
|
9e6cd2ed70 | ||
|
|
a813c4a755 | ||
|
|
e8e7254710 | ||
|
|
639100ac95 | ||
|
|
9cb12d984b | ||
|
|
b211519e79 | ||
|
|
23d7e03a5b | ||
|
|
9a8e0e36a9 | ||
|
|
d68895d0b1 | ||
|
|
59ed6869e3 | ||
|
|
1ac4bcbe9b | ||
|
|
9b487bdfe9 | ||
|
|
1ec2b888aa | ||
|
|
dbb396e599 | ||
|
|
966c38092d | ||
|
|
1e3a9d53a7 | ||
|
|
e5cd63b608 | ||
|
|
06522c11db | ||
|
|
6d4b5c345f | ||
|
|
8dcb4289a9 | ||
|
|
98ec18fcfe | ||
|
|
be0a93271f | ||
|
|
683ff6f73a | ||
|
|
7bdfe942a4 | ||
|
|
0ea243a1c7 | ||
|
|
9dd27667f2 | ||
|
|
8709026be3 | ||
|
|
2af3308892 | ||
|
|
a791368257 | ||
|
|
2b910fa16d | ||
|
|
1e741d1528 | ||
|
|
32c8def114 | ||
|
|
4483c2613c | ||
|
|
7c1055df97 | ||
|
|
d25648f729 | ||
|
|
70dd250ae6 | ||
|
|
c6276dbbde | ||
|
|
1f457ff81e | ||
|
|
9663d6f53e | ||
|
|
1bddc392b2 | ||
|
|
3b19c386cf | ||
|
|
4a8cb8c4f9 | ||
|
|
6ef20dc9af | ||
|
|
e219a40ebc | ||
|
|
64ca4d485f | ||
|
|
66b9b3323c | ||
|
|
24d06f5ce2 | ||
|
|
19de683b66 | ||
|
|
ed8418a2ac | ||
|
|
c33a2a5d19 | ||
|
|
9b49a0b727 | ||
|
|
1b42652e96 | ||
|
|
1400da9637 | ||
|
|
62794a3fde | ||
|
|
c17da16907 | ||
|
|
9a9de6a936 | ||
|
|
fe9b20733a | ||
|
|
50f9255472 | ||
|
|
70181252d8 | ||
|
|
dc3762b533 | ||
|
|
4de85f000d | ||
|
|
4de42d0dc4 | ||
|
|
747c2031f1 | ||
|
|
14353e67d4 | ||
|
|
8027bda6bf | ||
|
|
0c5e30ae5e | ||
|
|
b4b80eaeb2 | ||
|
|
71d0220215 | ||
|
|
c27e3287c5 | ||
|
|
c326afc7f1 | ||
|
|
fd6c1e1770 | ||
|
|
82d36d1f9b | ||
|
|
665c28588e | ||
|
|
a64c4c6562 | ||
|
|
588b2fd116 | ||
|
|
7a0610ade8 | ||
|
|
35689a58ba | ||
|
|
79efeab0ce | ||
|
|
b3fff5222b | ||
|
|
9281a0b5d2 | ||
|
|
38588ab7aa | ||
|
|
8521aaac41 | ||
|
|
bdd6b0ea12 | ||
|
|
c179c8753f | ||
|
|
19abf6182d | ||
|
|
f87bbb5342 | ||
|
|
7c7937438e | ||
|
|
6207624448 | ||
|
|
4f7a0f2ba1 | ||
|
|
24800ac54e | ||
|
|
66dabf51ee | ||
|
|
88aa505533 | ||
|
|
ab080a5407 | ||
|
|
be5d7ef703 | ||
|
|
d89e27113f | ||
|
|
e6b92995f3 | ||
|
|
f08a564fa6 | ||
|
|
7262304a6d | ||
|
|
3094896ad1 | ||
|
|
1d2c5db426 | ||
|
|
964fbe1c29 | ||
|
|
6d55c17322 | ||
|
|
9305cc3439 | ||
|
|
6ab1b40ad4 | ||
|
|
cd18488cc2 | ||
|
|
970ee43f8e | ||
|
|
e05847289f | ||
|
|
bc0c0fdf4a | ||
|
|
97437beaff | ||
|
|
869efb87d2 | ||
|
|
8187f1ef66 | ||
|
|
05f3355c57 | ||
|
|
2edb5193ef | ||
|
|
d9f49ecec0 | ||
|
|
73d0cd36e8 | ||
|
|
abda92d574 | ||
|
|
cacd39338f | ||
|
|
d7cd2ed5e8 | ||
|
|
ca63a998cf | ||
|
|
86b651405a | ||
|
|
cf9fb96efa | ||
|
|
f90cdb04a5 | ||
|
|
78d4d7ede4 | ||
|
|
6850ec1b3a | ||
|
|
c2d43643aa | ||
|
|
ab083f9005 | ||
|
|
94290b01ee | ||
|
|
fc6102738c | ||
|
|
cbbf6f8b2e | ||
|
|
f00228e76a | ||
|
|
cad80ba3aa | ||
|
|
f1d14c918f | ||
|
|
7953969510 | ||
|
|
b62c2854ad | ||
|
|
830b841850 | ||
|
|
ba01b418c2 | ||
|
|
5a3ea22419 | ||
|
|
cd43ad9a4a | ||
|
|
98a11014ea | ||
|
|
467e28f2eb | ||
|
|
069a4f7f23 | ||
|
|
f78ba8c75f | ||
|
|
fdb763d5e3 | ||
|
|
9aca5c35ca | ||
|
|
a16d84d15d | ||
|
|
35fddca7e6 | ||
|
|
90a3fc82aa | ||
|
|
84c631a88b | ||
|
|
83bdb31eea | ||
|
|
8d1df095e7 | ||
|
|
1ac76ac103 | ||
|
|
fff7116b51 | ||
|
|
ca351542ef | ||
|
|
4a82c014bb | ||
|
|
cd4ff3828b | ||
|
|
2a6b5836a9 | ||
|
|
380c40562d | ||
|
|
f010708103 | ||
|
|
d811847b2d | ||
|
|
9c62aba384 | ||
|
|
7d08cdf860 | ||
|
|
c6c2ec5f9f | ||
|
|
0c58226e9c | ||
|
|
01e6dae6f7 | ||
|
|
745a3ef110 | ||
|
|
ede89f4d37 | ||
|
|
033d704ac5 | ||
|
|
eace14a013 | ||
|
|
5adf5c43a2 | ||
|
|
0075879d79 | ||
|
|
1727f95575 | ||
|
|
fec49b4482 | ||
|
|
86e5db17b5 | ||
|
|
73a9345c43 | ||
|
|
0cecbef5c3 | ||
|
|
9f04b02e12 | ||
|
|
4dec228d0c | ||
|
|
0239df6f9d | ||
|
|
de1a7b6954 | ||
|
|
bcd92633ac | ||
|
|
f065d0d11d | ||
|
|
32a7a4c730 | ||
|
|
1b9f8cab58 | ||
|
|
9b4b2e2db3 | ||
|
|
d511139408 | ||
|
|
3ec521749b | ||
|
|
d558a4abf3 | ||
|
|
27671eac9b | ||
|
|
f9e11e7302 | ||
|
|
95000c906a | ||
|
|
512545ade7 | ||
|
|
33afb63acb | ||
|
|
26b172eb1d | ||
|
|
ac42f11d7a | ||
|
|
3690dba775 | ||
|
|
7f01cb4f10 | ||
|
|
3093f8c594 | ||
|
|
627ab546b9 | ||
|
|
831efabfbd | ||
|
|
cae7cc02d3 | ||
|
|
7d54dc4eaa | ||
|
|
9b98998991 | ||
|
|
751f242824 | ||
|
|
6817cb4af0 | ||
|
|
c2fb0b6730 | ||
|
|
d4137afb8c | ||
|
|
905fd0d193 | ||
|
|
6a56f7e39f | ||
|
|
ef54b481a7 | ||
|
|
3a5dd71152 | ||
|
|
2c783d099a | ||
|
|
f2070cef54 | ||
|
|
648060047c | ||
|
|
92f33c89fb | ||
|
|
bcb539057d | ||
|
|
e2b35bc827 | ||
|
|
8601d34f20 | ||
|
|
d812712ded | ||
|
|
ce94056986 | ||
|
|
d50a5e1f64 | ||
|
|
a05dc35c39 | ||
|
|
86d90c38a9 | ||
|
|
ae713f7bd8 | ||
|
|
1d018614ac | ||
|
|
653e3a4ee8 | ||
|
|
fd915f8a5d | ||
|
|
0504663c41 | ||
|
|
9203f21b99 | ||
|
|
9ac405e9cc | ||
|
|
903b427006 | ||
|
|
2ce21900ae | ||
|
|
03cbb48fa4 | ||
|
|
42cff7bbc2 | ||
|
|
604025a53b | ||
|
|
56b6229ef4 | ||
|
|
b7d802d7a6 | ||
|
|
cb246e7528 | ||
|
|
05d150349f | ||
|
|
712306d3b9 | ||
|
|
ea5f288a1f | ||
|
|
db228099ab | ||
|
|
fed7ea2275 | ||
|
|
9ce9282b21 | ||
|
|
2da8d6bf28 | ||
|
|
5b435bb03a | ||
|
|
a44d5c81a2 | ||
|
|
101d4689f6 | ||
|
|
c7c890dd69 | ||
|
|
62b8f33671 | ||
|
|
48fd9f4ea5 | ||
|
|
d77569c672 | ||
|
|
937dcba5b8 | ||
|
|
0f581c669e | ||
|
|
36f6ab9839 | ||
|
|
d3b28edfb6 | ||
|
|
9c49424b4d | ||
|
|
f4271d7c16 | ||
|
|
6a65ee1124 | ||
|
|
ad70f755d7 | ||
|
|
d2ca443d6f | ||
|
|
716cc7f102 | ||
|
|
78dba05d8f | ||
|
|
c35436a7da | ||
|
|
0e927e6f11 | ||
|
|
2eb96ddb7c | ||
|
|
97dd6d982a | ||
|
|
393b8ef7e6 | ||
|
|
15be176380 | ||
|
|
8905aaf0dd | ||
|
|
bbc1ffb408 | ||
|
|
4c78d32e36 | ||
|
|
fc06585938 | ||
|
|
e5e1b74fd8 | ||
|
|
9809bf38b6 | ||
|
|
5ea88533e0 | ||
|
|
95eb81bfe5 | ||
|
|
c752c21246 | ||
|
|
594394ab30 | ||
|
|
c3ef0a06e7 | ||
|
|
531212c812 | ||
|
|
2e9827a259 | ||
|
|
9777b525b7 | ||
|
|
2f6d259615 | ||
|
|
6c769f38ae | ||
|
|
475f64b79d | ||
|
|
bca4513187 | ||
|
|
28b72d46c5 | ||
|
|
e2a48d1c36 | ||
|
|
b22c42ea85 | ||
|
|
a21cf57a11 | ||
|
|
6c36da40e0 | ||
|
|
306c6625e9 | ||
|
|
46f674f842 | ||
|
|
dca3bb89fe | ||
|
|
e74a1fe461 | ||
|
|
581828bf94 | ||
|
|
2c0d3c1a86 | ||
|
|
4940a24423 | ||
|
|
62556429dc | ||
|
|
765cef8a4e | ||
|
|
29616ad502 | ||
|
|
79fca90886 | ||
|
|
f918fc711a | ||
|
|
cc0f8f8a50 | ||
|
|
c4427d2a14 | ||
|
|
ca83704cbb | ||
|
|
180e2f612a | ||
|
|
f2db04bd64 | ||
|
|
7b84a7ae91 | ||
|
|
ffad9e1f0a | ||
|
|
1131a2910d | ||
|
|
18217532af | ||
|
|
6d69bba15e | ||
|
|
71b2f110eb | ||
|
|
b6c77e12f3 | ||
|
|
5bbb61aec2 | ||
|
|
1f98f3ca89 | ||
|
|
0cd3410a2d | ||
|
|
03d8ffe2c6 | ||
|
|
4afb5748f2 | ||
|
|
6b1d376e6f | ||
|
|
008c4f91cd | ||
|
|
85f34393fe | ||
|
|
59bdb00599 | ||
|
|
29ab66935e | ||
|
|
80fd68e1c2 | ||
|
|
f92cf816b1 | ||
|
|
ff28fd8390 | ||
|
|
0c9bddecda | ||
|
|
a19cad22cb | ||
|
|
18b544770f | ||
|
|
36c18fd8ea | ||
|
|
c352c80c96 | ||
|
|
9ab0e2b6a8 | ||
|
|
9cebcc8d7f | ||
|
|
6384234e37 | ||
|
|
3caf28c4d6 | ||
|
|
2c894783aa | ||
|
|
9266e0c7c9 | ||
|
|
909f15b909 | ||
|
|
da57350af5 | ||
|
|
66607c9eab | ||
|
|
400d9e6115 | ||
|
|
140958fa58 | ||
|
|
04b0b9aed5 | ||
|
|
5e27fd3421 | ||
|
|
e544924504 | ||
|
|
29754b857d | ||
|
|
8144385ce6 | ||
|
|
11e1ff12d0 | ||
|
|
25c53cc803 | ||
|
|
43e407562f | ||
|
|
a45d0de43e | ||
|
|
f6db8b1f56 | ||
|
|
426b3c83cf | ||
|
|
a146754b0b | ||
|
|
bafd010865 | ||
|
|
024415e09d | ||
|
|
8f78fdeb7b | ||
|
|
d2a8cf3bdf | ||
|
|
ed9fdb5208 | ||
|
|
2929bb6dba | ||
|
|
6120c8c0ad | ||
|
|
53ea3e7224 | ||
|
|
462787aee1 | ||
|
|
b90f9ec1ef | ||
|
|
e9797d69d0 | ||
|
|
4b4361fbc0 | ||
|
|
77fe538e09 | ||
|
|
d8b8246622 | ||
|
|
35a02c79a8 | ||
|
|
db985f717c | ||
|
|
e41c602359 | ||
|
|
512e003cbd | ||
|
|
daee960ba2 | ||
|
|
b33ae69938 | ||
|
|
7eaa5f592b | ||
|
|
be714b38bb | ||
|
|
1a5e5eb358 | ||
|
|
951b88a3c2 | ||
|
|
414a2b82a8 | ||
|
|
9958fe0e49 | ||
|
|
fba004eb3d | ||
|
|
2d70e55fbb | ||
|
|
d0df238972 | ||
|
|
cdafcd756f | ||
|
|
3fe301aaa1 | ||
|
|
d97b58fd4a | ||
|
|
dca6ebba0e | ||
|
|
6af81b600f | ||
|
|
851789830a | ||
|
|
e872d3ae54 | ||
|
|
317f30427e | ||
|
|
91f1f839ab | ||
|
|
1c6c6c0b97 | ||
|
|
a1761dcb65 | ||
|
|
cf6b91a710 | ||
|
|
fbdd04d817 | ||
|
|
aed4e26f59 | ||
|
|
b5adad15eb | ||
|
|
d17df3ca05 | ||
|
|
2d72e987c5 | ||
|
|
9ae3882c1a | ||
|
|
89c195fa6e | ||
|
|
4575f4f1df | ||
|
|
54b1c4f941 | ||
|
|
8a309c393d | ||
|
|
58a99a2ce9 | ||
|
|
2b2aecd975 | ||
|
|
8aad511579 | ||
|
|
94600b266f | ||
|
|
78a5ee0e55 | ||
|
|
63ee634ee0 | ||
|
|
752138e866 | ||
|
|
c30d51d65e | ||
|
|
c439b806b8 | ||
|
|
c74626448c | ||
|
|
2886b8f9ba | ||
|
|
9ca7162169 | ||
|
|
de2df51193 | ||
|
|
dc36089156 | ||
|
|
d248ab2605 | ||
|
|
08c959807d | ||
|
|
796dc320d9 | ||
|
|
f1949c3bda | ||
|
|
4d9ead721e | ||
|
|
10d2b202d5 | ||
|
|
b8d0590d6d | ||
|
|
bc2dd35cc3 | ||
|
|
b5a735c8d1 | ||
|
|
8c0ed0e591 | ||
|
|
849b5b663b | ||
|
|
b65b0a6a7d | ||
|
|
97f8bca9da | ||
|
|
f1181d4a25 | ||
|
|
c5a37c02de | ||
|
|
74dc389321 | ||
|
|
7d7a85d184 | ||
|
|
de1e5f7292 | ||
|
|
9bc05faf53 | ||
|
|
575cef1d26 | ||
|
|
0cc3f567fc | ||
|
|
cb97c5496e | ||
|
|
484cc945aa | ||
|
|
8ca15ae2ca | ||
|
|
10872bdd64 | ||
|
|
3b24aceb04 | ||
|
|
a4ccf205f7 | ||
|
|
452fca8bdd | ||
|
|
ab40cd63e6 | ||
|
|
9dbd0a4baf | ||
|
|
00f2663e7b | ||
|
|
9aabeb39ee | ||
|
|
605e8a1488 | ||
|
|
e6587dce6a | ||
|
|
dfd0601163 | ||
|
|
034af45777 | ||
|
|
0b3e40f9d4 | ||
|
|
a44581eaf3 | ||
|
|
886a84dea0 | ||
|
|
79c3896449 | ||
|
|
d75f767925 | ||
|
|
a45c73bb36 | ||
|
|
6823e34451 | ||
|
|
f25fbb6f2e | ||
|
|
91d6bdba85 | ||
|
|
4248048ab6 | ||
|
|
872409ea57 | ||
|
|
2277c85fc3 | ||
|
|
e98d939919 | ||
|
|
81b4c09ee6 | ||
|
|
b92dd80566 | ||
|
|
4084bcdf75 | ||
|
|
5657080884 | ||
|
|
708702917c | ||
|
|
4c8ff02969 | ||
|
|
fe167781c4 | ||
|
|
fe8565b874 | ||
|
|
0d02a4c722 | ||
|
|
9d4c984e03 | ||
|
|
e4cd56ed7b | ||
|
|
b32989cf1e | ||
|
|
f2424043f3 | ||
|
|
fece9d85e3 | ||
|
|
45996d8d78 | ||
|
|
7569f9615a | ||
|
|
90501f9ab4 | ||
|
|
c1844dfe57 | ||
|
|
bf527e7a13 | ||
|
|
472da6509d | ||
|
|
dbef78bcb8 | ||
|
|
b283f2ad31 | ||
|
|
6dcf4b3e8b | ||
|
|
bf508c2842 | ||
|
|
564b230ab1 | ||
|
|
555ddb94bf | ||
|
|
e92937d956 | ||
|
|
d62a1e5d20 | ||
|
|
1862395e4b | ||
|
|
077da2e06b | ||
|
|
a6d2eb9d64 | ||
|
|
0300ad8922 | ||
|
|
7a3a19697a | ||
|
|
e8ae26c270 | ||
|
|
e50eb7c71f | ||
|
|
b62095dfcb | ||
|
|
ba3b567f54 | ||
|
|
5d3e9d218d | ||
|
|
36ef05b0b9 | ||
|
|
49ad61035b | ||
|
|
3842d851d8 | ||
|
|
c987778e97 | ||
|
|
b28108128d | ||
|
|
c50557ea0b | ||
|
|
f364c4f9c6 | ||
|
|
9841ce7824 | ||
|
|
83a6f5c7b9 | ||
|
|
18f4dd92ae | ||
|
|
47654b62a3 | ||
|
|
2f3dade42f | ||
|
|
af49fd7dc3 | ||
|
|
af6faa88e5 | ||
|
|
e71d4281ac | ||
|
|
7102727197 | ||
|
|
70868f3208 | ||
|
|
aff673a08d | ||
|
|
2e642857fc | ||
|
|
147e5269c7 | ||
|
|
b3a499d85d | ||
|
|
3c013dac75 | ||
|
|
b913dab5e5 | ||
|
|
e87e839c97 | ||
|
|
8c8f857313 | ||
|
|
9c427dfae0 | ||
|
|
ef711f8b1f | ||
|
|
601e7918a8 | ||
|
|
dd3697bb4d | ||
|
|
273729aac8 | ||
|
|
94592a35ac | ||
|
|
53501ab94e | ||
|
|
581dc08cf5 | ||
|
|
85a6ce0921 | ||
|
|
1b4fe0f067 | ||
|
|
8691d9a8e8 | ||
|
|
d2105fe66c | ||
|
|
20630b1230 | ||
|
|
8d77913ea5 | ||
|
|
66637220ef | ||
|
|
5fa423169f | ||
|
|
183382cd2b | ||
|
|
329ce1b724 | ||
|
|
2a0824cf5f | ||
|
|
cc39d57e07 | ||
|
|
7cc8def23f | ||
|
|
2f6f0e3e0f | ||
|
|
d338bcd070 | ||
|
|
4825e537cc | ||
|
|
2e91bd84e9 | ||
|
|
5d72fdd8c2 | ||
|
|
3287a74d64 | ||
|
|
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
|
||||
levzzz5154 # added by Ludovic, 2026-10-27
|
||||
@@ -0,0 +1,291 @@
|
||||
name: game build
|
||||
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
|
||||
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json). Called by
|
||||
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
|
||||
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
|
||||
# which never runs what this one built. See docs/release-flow.md.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: The commit to build
|
||||
type: string
|
||||
required: true
|
||||
version:
|
||||
description: The version built in (HORIZON_VERSION), e.g. 0.2.81 or nightly-2026-10-28
|
||||
type: string
|
||||
required: true
|
||||
nightly:
|
||||
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
|
||||
default: true
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
exporters:
|
||||
# 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:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it
|
||||
# prepares afresh.
|
||||
cargo build --release --locked -p horizon-prepare
|
||||
# Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs
|
||||
# write those (vanilla-data.sh links .local/exporters to them).
|
||||
rm -rf .local/exporters
|
||||
bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1)
|
||||
mkdir -p exporters
|
||||
cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
|
||||
# Only our classes and their manifest leave this runner.
|
||||
stray=$(find exporters -type f ! -path exporters/manifest.json \
|
||||
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
|
||||
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
|
||||
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
build:
|
||||
needs: exporters
|
||||
name: build ${{ matrix.name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux
|
||||
os: ubuntu-22.04
|
||||
dir: horizon-linux-x86_64
|
||||
asset: horizon-linux-x86_64.tar.gz
|
||||
# The server build too: packaging/server/install.sh installs it on Linux.
|
||||
server_dir: horizon-server-linux-x86_64
|
||||
server_asset: horizon-server-linux-x86_64.tar.gz
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
dir: horizon-windows-x64
|
||||
asset: horizon-windows-x64.zip
|
||||
exe: .exe
|
||||
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: macos
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
asset: horizon-macos-arm64.zip
|
||||
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:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
|
||||
# telemetry say it. No version-bump commits.
|
||||
HORIZON_VERSION: ${{ inputs.version }}
|
||||
# A nightly's date and commit ("2026-10-28 0c02067"), which the game shows; empty for a release.
|
||||
HORIZON_NIGHTLY: ${{ inputs.nightly }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
|
||||
# parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output)
|
||||
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
|
||||
# keeps its preparations outside the checkout.
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
# 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
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
|
||||
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
done
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
env:
|
||||
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
|
||||
run: packaging/macos/keychain.sh unlock
|
||||
- name: Signed with Developer ID and the hardened runtime
|
||||
# Before the checks below, so they run the programs as players will.
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
env:
|
||||
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
|
||||
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
|
||||
codesign --verify --strict --verbose=2 "target/release/$binary"
|
||||
done
|
||||
- name: Only system libraries
|
||||
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
|
||||
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
file="target/release/$binary${{ matrix.exe }}"
|
||||
if [[ "$RUNNER_OS" == macOS ]]; then
|
||||
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
|
||||
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
|
||||
else
|
||||
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
|
||||
fi
|
||||
done
|
||||
- name: Every program starts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin=target/release
|
||||
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
|
||||
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
|
||||
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
|
||||
# The version is the release's.
|
||||
for binary in horizon-prepare horizon-server; do
|
||||
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
|
||||
echo "$out"
|
||||
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
|
||||
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'
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: The game runs (offscreen, a new world, 1500 frames)
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
|
||||
home="$RUNNER_TEMP/horizon-smoke"
|
||||
rm -rf "$home" && mkdir -p "$home"
|
||||
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
|
||||
client="$PWD/target/release/horizon-client"
|
||||
resources="$PWD/.local/horizon-client/$version"
|
||||
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
|
||||
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
|
||||
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
|
||||
python3 - "$home/smoke.json" <<'EOF'
|
||||
import json, sys
|
||||
report = json.load(open(sys.argv[1]))
|
||||
print(json.dumps(report))
|
||||
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
|
||||
or not report.get("meshed_sections") or not report.get("player"):
|
||||
sys.exit("the smoke run did not draw a world")
|
||||
EOF
|
||||
# Published with the release; the publish job checks it again before it publishes anything.
|
||||
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: runner.environment == 'self-hosted'
|
||||
with:
|
||||
name: smoke-test.json
|
||||
path: ${{ runner.temp }}/smoke-test.json
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters
|
||||
- name: Package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
cd dist
|
||||
rm -f "${{ matrix.asset }}"
|
||||
case "${{ matrix.asset }}" in
|
||||
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
|
||||
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
|
||||
else
|
||||
# Without resource forks and extended attributes: plain ditto adds an
|
||||
# AppleDouble `._` file beside every file that has any.
|
||||
ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"
|
||||
if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then
|
||||
echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1
|
||||
fi
|
||||
fi ;;
|
||||
esac
|
||||
ls -l "${{ matrix.asset }}"
|
||||
- name: Notarized
|
||||
# A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs.
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
|
||||
done
|
||||
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: dist/${{ matrix.asset }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: The server build (the game without sound or controllers, as horizon-engine)
|
||||
if: matrix.server_asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
|
||||
# and with code mods like the client.
|
||||
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
|
||||
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
|
||||
folder="dist/${{ matrix.server_dir }}"
|
||||
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
|
||||
"$folder/horizon-engine" --help | grep -q -- '--resources'
|
||||
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
|
||||
cd dist
|
||||
rm -f "${{ matrix.server_asset }}"
|
||||
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
|
||||
ls -l "${{ matrix.server_asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: matrix.server_asset
|
||||
with:
|
||||
name: ${{ matrix.server_asset }}
|
||||
path: dist/${{ matrix.server_asset }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: The signing keychain, locked again
|
||||
if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
run: packaging/macos/keychain.sh lock
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
name: nightly build
|
||||
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 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, `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
|
||||
# runs the runner's sha256sum and OpenSSL over the archives: it hashes them, never runs them. The signed message
|
||||
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
|
||||
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
|
||||
# the tag. Like sign, it never executes what build produced.
|
||||
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
|
||||
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
|
||||
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 20 * * *' # 20:17 UTC: after the day's waves, before the 03:07 Paris parity audit takes the Mac mini
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: Build and publish even if tonight's pick is the commit already published
|
||||
type: boolean
|
||||
default: false
|
||||
dry_run:
|
||||
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 }}
|
||||
nightly: ${{ steps.pick.outputs.nightly }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- id: mode
|
||||
# A dry run never publishes; a hand-started run off main is always one.
|
||||
run: echo "dry_run=${{ github.event_name == 'workflow_dispatch' && (inputs.dry_run || github.ref != 'refs/heads/main') }}" >> "$GITHUB_OUTPUT"
|
||||
- id: pick
|
||||
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"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nightly-notes
|
||||
path: nightly-notes.md
|
||||
retention-days: 3
|
||||
|
||||
build:
|
||||
needs: pick
|
||||
if: needs.pick.outputs.build == 'true'
|
||||
uses: ./.github/workflows/game-build.yml
|
||||
with:
|
||||
ref: ${{ needs.pick.outputs.sha }}
|
||||
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]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# The key's only job: main's sign_release.sh hashes the archives and signs with the runner's OpenSSL.
|
||||
environment: ${{ needs.pick.outputs.dry_run != 'true' && 'release' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: tools/ci/sign_release.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
pattern: horizon-*
|
||||
merge-multiple: true
|
||||
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig) for the nightly channel
|
||||
env:
|
||||
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
path: dist/SHA256SUMS*
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: needs.pick.outputs.dry_run == 'true'
|
||||
with:
|
||||
name: ephemeral.pub
|
||||
path: dist/ephemeral.pub
|
||||
retention-days: 3
|
||||
|
||||
publish:
|
||||
needs: [pick, build, sign]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: The signature, checked as launchers check it
|
||||
env:
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist
|
||||
- name: The smoke test drew a world
|
||||
# No release-blocker check: a nightly is how blockers get tested.
|
||||
run: python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json
|
||||
- name: Publish as the `nightly` pre-release
|
||||
if: needs.pick.outputs.dry_run != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SHA: ${{ needs.pick.outputs.sha }}
|
||||
DATE: ${{ needs.pick.outputs.date }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 tools/ci/nightly.py rotate
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# A pre-release, never GitHub's latest: launchers on the release channel never see it.
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" \
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
python3 tools/ci/nightly.py prune --days 7
|
||||
- name: Dry run, the nightly that would be published
|
||||
if: needs.pick.outputs.dry_run == 'true'
|
||||
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
|
||||
+11
-245
@@ -40,7 +40,10 @@ permissions:
|
||||
# The open release-blocker issues (decide).
|
||||
issues: read
|
||||
concurrency:
|
||||
group: game-release
|
||||
# One release at a time. A hand-started dry run publishes nothing, so it can't race a real release: it gets a
|
||||
# group of its own. In the shared group, a release queued behind it replaced it as the pending run and GitHub
|
||||
# cancelled it (#2779's dry run, 2026-10-27), which happens all day while releases go out in batches.
|
||||
group: game-release${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '-dry-run' || '' }}
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
decide:
|
||||
@@ -79,252 +82,15 @@ jobs:
|
||||
# A dry run builds whatever the decision, to test the builds.
|
||||
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
|
||||
|
||||
exporters:
|
||||
build:
|
||||
needs: decide
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it.
|
||||
runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }}
|
||||
# Three full preparations (two isolated, one batched) besides the compile.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
persist-credentials: false
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: bash tools/ci/runner-env.sh
|
||||
- run: bash tools/ci/vanilla-data.sh
|
||||
- name: Compile and validate original exporters against the pinned jars
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it
|
||||
# prepares afresh.
|
||||
cargo build --release --locked -p horizon-prepare
|
||||
# Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs
|
||||
# write those (vanilla-data.sh links .local/exporters to them).
|
||||
rm -rf .local/exporters
|
||||
bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1)
|
||||
mkdir -p exporters
|
||||
cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
|
||||
# Only our classes and their manifest leave this runner.
|
||||
stray=$(find exporters -type f ! -path exporters/manifest.json \
|
||||
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
|
||||
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
|
||||
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
build:
|
||||
needs: [decide, exporters]
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
name: build ${{ matrix.name }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux
|
||||
os: ubuntu-22.04
|
||||
dir: horizon-linux-x86_64
|
||||
asset: horizon-linux-x86_64.tar.gz
|
||||
# The server build too: packaging/server/install.sh installs it on Linux.
|
||||
server_dir: horizon-server-linux-x86_64
|
||||
server_asset: horizon-server-linux-x86_64.tar.gz
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
dir: horizon-windows-x64
|
||||
asset: horizon-windows-x64.zip
|
||||
exe: .exe
|
||||
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: macos
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
asset: horizon-macos-arm64.zip
|
||||
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
|
||||
# Notarizing the zip can take a few minutes.
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
|
||||
# telemetry say it. No version-bump commits.
|
||||
HORIZON_VERSION: ${{ needs.decide.outputs.version }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
persist-credentials: false
|
||||
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
|
||||
# parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output)
|
||||
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
|
||||
# keeps its preparations outside the checkout.
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
shared-key: release-${{ matrix.name }}
|
||||
- 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
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
|
||||
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
done
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
env:
|
||||
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
|
||||
run: packaging/macos/keychain.sh unlock
|
||||
- name: Signed with Developer ID and the hardened runtime
|
||||
# Before the checks below, so they run the programs as players will.
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
env:
|
||||
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
|
||||
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
|
||||
codesign --verify --strict --verbose=2 "target/release/$binary"
|
||||
done
|
||||
- name: Only system libraries
|
||||
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
|
||||
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
file="target/release/$binary${{ matrix.exe }}"
|
||||
if [[ "$RUNNER_OS" == macOS ]]; then
|
||||
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
|
||||
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
|
||||
else
|
||||
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
|
||||
fi
|
||||
done
|
||||
- name: Every program starts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin=target/release
|
||||
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
|
||||
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
|
||||
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
|
||||
# The version is the release's.
|
||||
for binary in horizon-prepare horizon-server; do
|
||||
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
|
||||
echo "$out"
|
||||
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
|
||||
done
|
||||
- name: Tools from the runner
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: bash tools/ci/runner-env.sh
|
||||
- name: Resources prepared from the runner's own Minecraft
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: bash tools/ci/vanilla-data.sh
|
||||
- name: The game runs (offscreen, a new world, 1500 frames)
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
|
||||
home="$RUNNER_TEMP/horizon-smoke"
|
||||
rm -rf "$home" && mkdir -p "$home"
|
||||
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
|
||||
client="$PWD/target/release/horizon-client"
|
||||
resources="$PWD/.local/horizon-client/$version"
|
||||
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
|
||||
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
|
||||
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
|
||||
python3 - "$home/smoke.json" <<'EOF'
|
||||
import json, sys
|
||||
report = json.load(open(sys.argv[1]))
|
||||
print(json.dumps(report))
|
||||
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
|
||||
or not report.get("meshed_sections") or not report.get("player"):
|
||||
sys.exit("the smoke run did not draw a world")
|
||||
EOF
|
||||
# Published with the release; the publish job checks it again before it publishes anything.
|
||||
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: runner.environment == 'self-hosted'
|
||||
with:
|
||||
name: smoke-test.json
|
||||
path: ${{ runner.temp }}/smoke-test.json
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters
|
||||
- name: Package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
cd dist
|
||||
rm -f "${{ matrix.asset }}"
|
||||
case "${{ matrix.asset }}" in
|
||||
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
|
||||
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
|
||||
else
|
||||
# Without resource forks and extended attributes: plain ditto adds an
|
||||
# AppleDouble `._` file beside every file that has any.
|
||||
ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"
|
||||
if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then
|
||||
echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1
|
||||
fi
|
||||
fi ;;
|
||||
esac
|
||||
ls -l "${{ matrix.asset }}"
|
||||
- name: Notarized
|
||||
# A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs.
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
|
||||
done
|
||||
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: dist/${{ matrix.asset }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: The server build (the game without sound or controllers, as horizon-engine)
|
||||
if: matrix.server_asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
|
||||
# and with code mods like the client.
|
||||
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
|
||||
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
|
||||
folder="dist/${{ matrix.server_dir }}"
|
||||
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
|
||||
"$folder/horizon-engine" --help | grep -q -- '--resources'
|
||||
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
|
||||
cd dist
|
||||
rm -f "${{ matrix.server_asset }}"
|
||||
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
|
||||
ls -l "${{ matrix.server_asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: matrix.server_asset
|
||||
with:
|
||||
name: ${{ matrix.server_asset }}
|
||||
path: dist/${{ matrix.server_asset }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- name: The signing keychain, locked again
|
||||
if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
|
||||
run: packaging/macos/keychain.sh lock
|
||||
# Every platform's archive, and macOS's smoke test (game-build.yml).
|
||||
uses: ./.github/workflows/game-build.yml
|
||||
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]
|
||||
|
||||
@@ -61,6 +61,16 @@ fn f32_tag(v: f32) -> Tag {
|
||||
Tag::Float(v.to_bits())
|
||||
}
|
||||
|
||||
/// `TheEndGatewayBlockEntity.loadAdditional`: BlockPos.CODEC and Level.isInSpawnableBounds.
|
||||
pub fn gateway_exit(tag: &Tag) -> Option<[i32; 3]> {
|
||||
let Tag::IntArray(v) = tag else { return None };
|
||||
let p: [i32; 3] = v.as_slice().try_into().ok()?;
|
||||
((-30_000_000..30_000_000).contains(&p[0])
|
||||
&& (-20_000_000..20_000_000).contains(&p[1])
|
||||
&& (-30_000_000..30_000_000).contains(&p[2]))
|
||||
.then_some(p)
|
||||
}
|
||||
|
||||
/// What a block entity reads and writes, in `saveAdditional` order.
|
||||
struct Fields<'a> {
|
||||
input: &'a Tag,
|
||||
@@ -424,10 +434,7 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
}
|
||||
"minecraft:end_gateway" => {
|
||||
f.long("Age", 0);
|
||||
f.keep("exit_portal", |t| {
|
||||
is_pos(t)
|
||||
&& matches!(t, Tag::IntArray(v) if v[1].abs() <= 30_000_000 && v[0].abs() <= 30_000_000 && v[2].abs() <= 30_000_000)
|
||||
});
|
||||
f.keep("exit_portal", |t| gateway_exit(t).is_some());
|
||||
if flag(tag.get("ExactTeleport")) == Some(true) {
|
||||
f.put("ExactTeleport", boolean(true));
|
||||
}
|
||||
|
||||
@@ -173,20 +173,48 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
if let Some(e) = o.get("click_event").and_then(Value::as_object) {
|
||||
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
style.click = match e.get("action").and_then(Value::as_str) {
|
||||
// ClickEvent.OpenUrl.CODEC: ExtraCodecs.UNTRUSTED_URI allows http and
|
||||
// https only, the scheme in any case (Util.parseAndValidateUntrustedUri).
|
||||
Some("open_url") => text("url")
|
||||
.filter(|u| {
|
||||
u.split_once(':').is_some_and(|(scheme, _)| {
|
||||
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
|
||||
})
|
||||
})
|
||||
.map(crate::text::ClickEvent::OpenUrl),
|
||||
// ClickEvent.ChangePage.CODEC: ExtraCodecs.POSITIVE_INT.
|
||||
Some("change_page") => e
|
||||
.get("page")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|p| i32::try_from(p).ok())
|
||||
.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
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! The client's lowest layer, below commands and gameplay: chat text and
|
||||
//! translations, SNBT and NBT paths, text components, the scoreboard, boss
|
||||
//! bars, game rules, block entity NBT tables, and the registry, particle and
|
||||
//! sound source tables commands name. horizon-client re-exports each module
|
||||
//! under its old path (`horizon_client::text`, ...).
|
||||
//! sound source tables commands name, and the `pack.mcmeta` filter regex.
|
||||
//! horizon-client re-exports each module under its old path
|
||||
//! (`horizon_client::text`, ...).
|
||||
#![forbid(unsafe_code)]
|
||||
pub mod block_entity_nbt;
|
||||
pub mod bossbar;
|
||||
@@ -14,6 +15,8 @@ pub mod key_display;
|
||||
pub mod lang;
|
||||
pub mod nbt_path;
|
||||
pub mod particle_type;
|
||||
pub mod pattern;
|
||||
pub mod potions;
|
||||
pub mod registries;
|
||||
pub mod scoreboard;
|
||||
pub mod snbt;
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,249 @@
|
||||
//! The `MobEffects` and `Potions` registries (code in vanilla, ported as
|
||||
//! tables): each effect's colour and category, each potion's effects, and the
|
||||
//! potion colour mix. horizon-client re-exports them from `brewing`.
|
||||
use std::{collections::HashMap, sync::OnceLock};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Category {
|
||||
Beneficial,
|
||||
Harmful,
|
||||
Neutral,
|
||||
}
|
||||
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EffectDef {
|
||||
pub id: &'static str,
|
||||
pub color: u32,
|
||||
pub category: Category,
|
||||
pub instant: bool,
|
||||
}
|
||||
const fn fx(id: &'static str, color: u32, category: Category) -> EffectDef {
|
||||
EffectDef {
|
||||
id,
|
||||
color,
|
||||
category,
|
||||
instant: false,
|
||||
}
|
||||
}
|
||||
use Category::{Beneficial as B, Harmful as H, Neutral as N};
|
||||
/// `MobEffects` in registration order.
|
||||
pub const EFFECTS: &[EffectDef] = &[
|
||||
fx("speed", 3402751, B),
|
||||
fx("slowness", 9154528, H),
|
||||
fx("haste", 14270531, B),
|
||||
fx("mining_fatigue", 4866583, H),
|
||||
fx("strength", 16762624, B),
|
||||
EffectDef {
|
||||
id: "instant_health",
|
||||
color: 16262179,
|
||||
category: B,
|
||||
instant: true,
|
||||
},
|
||||
EffectDef {
|
||||
id: "instant_damage",
|
||||
color: 11101546,
|
||||
category: H,
|
||||
instant: true,
|
||||
},
|
||||
fx("jump_boost", 16646020, B),
|
||||
fx("nausea", 5578058, H),
|
||||
fx("regeneration", 13458603, B),
|
||||
fx("resistance", 9520880, B),
|
||||
fx("fire_resistance", 16750848, B),
|
||||
fx("water_breathing", 10017472, B),
|
||||
fx("invisibility", 16185078, B),
|
||||
fx("blindness", 2039587, H),
|
||||
fx("night_vision", 12779366, B),
|
||||
fx("hunger", 5797459, H),
|
||||
fx("weakness", 4738376, H),
|
||||
fx("poison", 8889187, H),
|
||||
fx("wither", 7561558, H),
|
||||
fx("health_boost", 16284963, B),
|
||||
fx("absorption", 2445989, B),
|
||||
EffectDef {
|
||||
id: "saturation",
|
||||
color: 16262179,
|
||||
category: B,
|
||||
instant: true,
|
||||
},
|
||||
fx("glowing", 9740385, N),
|
||||
fx("levitation", 13565951, H),
|
||||
fx("luck", 5882118, B),
|
||||
fx("unluck", 12624973, H),
|
||||
fx("slow_falling", 15978425, B),
|
||||
fx("conduit_power", 1950417, B),
|
||||
fx("dolphins_grace", 8954814, B),
|
||||
fx("bad_omen", 745784, N),
|
||||
fx("hero_of_the_village", 4521796, B),
|
||||
fx("darkness", 2696993, H),
|
||||
fx("trial_omen", 1484454, N),
|
||||
fx("raid_omen", 14565464, N),
|
||||
fx("wind_charged", 12438015, H),
|
||||
fx("weaving", 7891290, H),
|
||||
fx("oozing", 10092451, H),
|
||||
fx("infested", 9214860, H),
|
||||
fx("breath_of_the_nautilus", 65518, B),
|
||||
];
|
||||
pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
let id = id.trim_start_matches("minecraft:");
|
||||
EFFECTS.iter().find(|e| e.id == id)
|
||||
}
|
||||
/// Namespaced effect ids for mob effect storage.
|
||||
pub fn namespaced(id: &'static str) -> &'static str {
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
IDS.get_or_init(|| {
|
||||
EFFECTS
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
e.id,
|
||||
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(id)
|
||||
.copied()
|
||||
.unwrap_or(id)
|
||||
}
|
||||
/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.<name>`) and
|
||||
/// effects as (effect, duration ticks, amplifier).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct PotionDef {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub effects: &'static [(&'static str, i32, i32)],
|
||||
}
|
||||
const fn p(
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
) -> PotionDef {
|
||||
PotionDef { id, name, effects }
|
||||
}
|
||||
/// `Potions` in registration order.
|
||||
pub const POTIONS: &[PotionDef] = &[
|
||||
p("water", "water", &[]),
|
||||
p("mundane", "mundane", &[]),
|
||||
p("thick", "thick", &[]),
|
||||
p("awkward", "awkward", &[]),
|
||||
p("night_vision", "night_vision", &[("night_vision", 3600, 0)]),
|
||||
p(
|
||||
"long_night_vision",
|
||||
"night_vision",
|
||||
&[("night_vision", 9600, 0)],
|
||||
),
|
||||
p("invisibility", "invisibility", &[("invisibility", 3600, 0)]),
|
||||
p(
|
||||
"long_invisibility",
|
||||
"invisibility",
|
||||
&[("invisibility", 9600, 0)],
|
||||
),
|
||||
p("leaping", "leaping", &[("jump_boost", 3600, 0)]),
|
||||
p("long_leaping", "leaping", &[("jump_boost", 9600, 0)]),
|
||||
p("strong_leaping", "leaping", &[("jump_boost", 1800, 1)]),
|
||||
p(
|
||||
"fire_resistance",
|
||||
"fire_resistance",
|
||||
&[("fire_resistance", 3600, 0)],
|
||||
),
|
||||
p(
|
||||
"long_fire_resistance",
|
||||
"fire_resistance",
|
||||
&[("fire_resistance", 9600, 0)],
|
||||
),
|
||||
p("swiftness", "swiftness", &[("speed", 3600, 0)]),
|
||||
p("long_swiftness", "swiftness", &[("speed", 9600, 0)]),
|
||||
p("strong_swiftness", "swiftness", &[("speed", 1800, 1)]),
|
||||
p("slowness", "slowness", &[("slowness", 1800, 0)]),
|
||||
p("long_slowness", "slowness", &[("slowness", 4800, 0)]),
|
||||
p("strong_slowness", "slowness", &[("slowness", 400, 3)]),
|
||||
p(
|
||||
"turtle_master",
|
||||
"turtle_master",
|
||||
&[("slowness", 400, 3), ("resistance", 400, 2)],
|
||||
),
|
||||
p(
|
||||
"long_turtle_master",
|
||||
"turtle_master",
|
||||
&[("slowness", 800, 3), ("resistance", 800, 2)],
|
||||
),
|
||||
p(
|
||||
"strong_turtle_master",
|
||||
"turtle_master",
|
||||
&[("slowness", 400, 5), ("resistance", 400, 3)],
|
||||
),
|
||||
p(
|
||||
"water_breathing",
|
||||
"water_breathing",
|
||||
&[("water_breathing", 3600, 0)],
|
||||
),
|
||||
p(
|
||||
"long_water_breathing",
|
||||
"water_breathing",
|
||||
&[("water_breathing", 9600, 0)],
|
||||
),
|
||||
p("healing", "healing", &[("instant_health", 1, 0)]),
|
||||
p("strong_healing", "healing", &[("instant_health", 1, 1)]),
|
||||
p("harming", "harming", &[("instant_damage", 1, 0)]),
|
||||
p("strong_harming", "harming", &[("instant_damage", 1, 1)]),
|
||||
p("poison", "poison", &[("poison", 900, 0)]),
|
||||
p("long_poison", "poison", &[("poison", 1800, 0)]),
|
||||
p("strong_poison", "poison", &[("poison", 432, 1)]),
|
||||
p("regeneration", "regeneration", &[("regeneration", 900, 0)]),
|
||||
p(
|
||||
"long_regeneration",
|
||||
"regeneration",
|
||||
&[("regeneration", 1800, 0)],
|
||||
),
|
||||
p(
|
||||
"strong_regeneration",
|
||||
"regeneration",
|
||||
&[("regeneration", 450, 1)],
|
||||
),
|
||||
p("strength", "strength", &[("strength", 3600, 0)]),
|
||||
p("long_strength", "strength", &[("strength", 9600, 0)]),
|
||||
p("strong_strength", "strength", &[("strength", 1800, 1)]),
|
||||
p("weakness", "weakness", &[("weakness", 1800, 0)]),
|
||||
p("long_weakness", "weakness", &[("weakness", 4800, 0)]),
|
||||
p("luck", "luck", &[("luck", 6000, 0)]),
|
||||
p("slow_falling", "slow_falling", &[("slow_falling", 1800, 0)]),
|
||||
p(
|
||||
"long_slow_falling",
|
||||
"slow_falling",
|
||||
&[("slow_falling", 4800, 0)],
|
||||
),
|
||||
p("wind_charged", "wind_charged", &[("wind_charged", 3600, 0)]),
|
||||
p("weaving", "weaving", &[("weaving", 3600, 0)]),
|
||||
p("oozing", "oozing", &[("oozing", 3600, 0)]),
|
||||
p("infested", "infested", &[("infested", 3600, 0)]),
|
||||
];
|
||||
pub fn potion(id: &str) -> Option<&'static PotionDef> {
|
||||
let id = id.trim_start_matches("minecraft:");
|
||||
POTIONS.iter().find(|p| p.id == id)
|
||||
}
|
||||
/// `PotionContents.BASE_POTION_COLOR` (ARGB -13083194) as RGB.
|
||||
pub const BASE_POTION_COLOR: u32 = 0x385DC6;
|
||||
/// `PotionContents.getColor`: amplifier-weighted mix of visible effect colours.
|
||||
pub fn potion_color(id: Option<&str>) -> u32 {
|
||||
let Some(def) = id.and_then(potion) else {
|
||||
return BASE_POTION_COLOR;
|
||||
};
|
||||
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
|
||||
for (e, _, amp) in def.effects {
|
||||
let Some(e) = effect(e) else { continue };
|
||||
let w = *amp as u32 + 1;
|
||||
r += w * (e.color >> 16 & 255);
|
||||
g += w * (e.color >> 8 & 255);
|
||||
b += w * (e.color & 255);
|
||||
total += w;
|
||||
}
|
||||
match (
|
||||
r.checked_div(total),
|
||||
g.checked_div(total),
|
||||
b.checked_div(total),
|
||||
) {
|
||||
(Some(r), Some(g), Some(b)) => (r << 16) | (g << 8) | b,
|
||||
_ => BASE_POTION_COLOR,
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,13 @@ pub fn named_color(name: &str) -> Option<u32> {
|
||||
/// Click actions carried by locally constructed text components.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ClickEvent {
|
||||
/// `ClickEvent.OpenUrl`: an http or https link.
|
||||
OpenUrl(String),
|
||||
/// `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.
|
||||
@@ -61,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>,
|
||||
@@ -90,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);
|
||||
}
|
||||
@@ -495,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",
|
||||
@@ -504,6 +520,7 @@ const STYLE_KEYS: [&str; 9] = [
|
||||
"obfuscated",
|
||||
"click_event",
|
||||
"hover_event",
|
||||
"insertion",
|
||||
"font",
|
||||
];
|
||||
/// Style.Serializer: the fields a style sets.
|
||||
@@ -529,12 +546,17 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
}
|
||||
if let Some(click) = &style.click {
|
||||
let event = match click {
|
||||
ClickEvent::OpenUrl(url) => json!({ "action": "open_url", "url": url }),
|
||||
ClickEvent::ChangePage(page) => json!({ "action": "change_page", "page": page }),
|
||||
ClickEvent::SuggestCommand(command) => {
|
||||
json!({ "action": "suggest_command", "command": command })
|
||||
}
|
||||
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));
|
||||
@@ -544,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()));
|
||||
|
||||
@@ -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,9 +584,15 @@ 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) => {
|
||||
MobEvent::Explode(at, radius, _) => {
|
||||
let d = Vec3::new(at[0], at[1], at[2])
|
||||
.distance_squared(p.position)
|
||||
.sqrt();
|
||||
@@ -1045,17 +1057,21 @@ 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,
|
||||
head: None,
|
||||
armor_cover: 0.0,
|
||||
invisible: false,
|
||||
spectator: false,
|
||||
gold_armor: false,
|
||||
hurt_by: None,
|
||||
last_hurt: None,
|
||||
velocity: Vec3::ZERO,
|
||||
yaw: 0.0,
|
||||
sprinting: false,
|
||||
riding: false,
|
||||
hero: false,
|
||||
effects: Vec::new(),
|
||||
uuid: PLAYER_UUID,
|
||||
@@ -1071,6 +1087,8 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
for (i, m) in mobs.iter().enumerate() {
|
||||
print(&mut out, 0, i, m, s.creative, world.game_time);
|
||||
}
|
||||
// No player has a chest open in the scenarios.
|
||||
let lid_openers = std::collections::HashMap::new();
|
||||
// The MobWorld every step sees, over a snapshot of the mobs.
|
||||
macro_rules! mob_world {
|
||||
($snapshot:expr) => {
|
||||
@@ -1082,6 +1100,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
world: &world,
|
||||
registry,
|
||||
facts: &facts,
|
||||
fluid_tables: &motion.fluids,
|
||||
movement: &motion.blocks,
|
||||
collision: &collision,
|
||||
data: &data,
|
||||
@@ -1099,12 +1118,14 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
block_tags: &items.block_tags,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
mob_drops: true,
|
||||
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
|
||||
end: false,
|
||||
world_seed: 0,
|
||||
// FlatLevelSource.getSeaLevel.
|
||||
sea_level: -63,
|
||||
items: Some(&items),
|
||||
lid_openers: &lid_openers,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -437,6 +437,8 @@ pub struct EntityView {
|
||||
/// mainhand, offhand, head, chest, legs, feet, body.
|
||||
pub equipment: [Option<ItemView>; 7],
|
||||
pub vehicle: Option<Box<EntityView>>,
|
||||
/// Player: what it looks at within 100 blocks (`PlayerPredicate.lookingAt`).
|
||||
pub looking_at: Option<Box<EntityView>>,
|
||||
pub effects: Vec<(String, i32, i32)>,
|
||||
/// Player: game mode name.
|
||||
pub gamemode: Option<String>,
|
||||
@@ -458,7 +460,7 @@ fn range_f(v: &Value, x: f64) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn range_i(v: &Value, x: i64) -> bool {
|
||||
pub fn range_i(v: &Value, x: i64) -> bool {
|
||||
range_f(v, x as f64)
|
||||
}
|
||||
/// A registry id, `#tag`, or list matched against one id.
|
||||
@@ -473,6 +475,51 @@ pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// `PotionsPredicate`: the potion in a `HolderSet`, plus a `CollectionPredicate`
|
||||
/// over the potion's effects (`contains`, `count`, `size`).
|
||||
pub fn potions(v: &Value, potion: Option<&str>, w: &dyn World) -> bool {
|
||||
if v.is_null() {
|
||||
return true;
|
||||
}
|
||||
if !v["potions"].is_null() && !potion.is_some_and(|p| id_matches(&v["potions"], "potion", p, w))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let c = &v["effects"];
|
||||
if c.is_null() {
|
||||
return true;
|
||||
}
|
||||
let effects: Vec<(String, i32, i32)> = potion
|
||||
.and_then(crate::brewing::potion)
|
||||
.map(|d| {
|
||||
d.effects
|
||||
.iter()
|
||||
.map(|(id, dur, amp)| (format!("minecraft:{id}"), *amp, *dur))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// `MobEffectsPredicate.test`: the one listed effect, amplifier and duration.
|
||||
let test = |pred: &Value, e: &(String, i32, i32)| {
|
||||
pred.as_object().is_some_and(|m| {
|
||||
m.get(&e.0).is_some_and(|want| {
|
||||
range_i(&want["amplifier"], i64::from(e.1))
|
||||
&& range_i(&want["duration"], i64::from(e.2))
|
||||
&& want["ambient"].as_bool().is_none_or(|b| !b)
|
||||
&& want["visible"].as_bool().is_none_or(|b| b)
|
||||
})
|
||||
})
|
||||
};
|
||||
c["contains"]
|
||||
.as_array()
|
||||
.is_none_or(|l| l.iter().all(|p| effects.iter().any(|e| test(p, e))))
|
||||
&& c["count"].as_array().is_none_or(|l| {
|
||||
l.iter().all(|entry| {
|
||||
let n = effects.iter().filter(|e| test(&entry["test"], e)).count();
|
||||
range_i(&entry["count"], n as i64)
|
||||
})
|
||||
})
|
||||
&& range_i(&c["size"], effects.len() as i64)
|
||||
}
|
||||
/// `ItemPredicate`.
|
||||
pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
|
||||
if v.is_null() {
|
||||
@@ -614,6 +661,18 @@ fn components(expected: &Value, have: &[(String, String)]) -> bool {
|
||||
})
|
||||
})
|
||||
}
|
||||
/// `DistancePredicate.matches` between two points.
|
||||
pub fn distance(v: &Value, a: [f64; 3], b: [f64; 3]) -> bool {
|
||||
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
range_f(&v["x"], d[0].abs())
|
||||
&& range_f(&v["y"], d[1].abs())
|
||||
&& range_f(&v["z"], d[2].abs())
|
||||
&& range_f(&v["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt())
|
||||
&& range_f(
|
||||
&v["absolute"],
|
||||
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
|
||||
)
|
||||
}
|
||||
/// `EntityPredicate` (26.x keyed sub-predicates).
|
||||
pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool {
|
||||
if v.is_null() {
|
||||
@@ -629,21 +688,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
"minecraft:stepping_on" => {
|
||||
location(p, [e.position[0], e.position[1] - 0.5, e.position[2]], w)
|
||||
}
|
||||
"minecraft:distance" | "distance" => {
|
||||
let d = [
|
||||
e.position[0] - origin[0],
|
||||
e.position[1] - origin[1],
|
||||
e.position[2] - origin[2],
|
||||
];
|
||||
range_f(&p["x"], d[0].abs())
|
||||
&& range_f(&p["y"], d[1].abs())
|
||||
&& range_f(&p["z"], d[2].abs())
|
||||
&& range_f(&p["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt())
|
||||
&& range_f(
|
||||
&p["absolute"],
|
||||
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
|
||||
)
|
||||
}
|
||||
"minecraft:distance" | "distance" => distance(p, e.position, origin),
|
||||
"minecraft:flags" | "flags" => {
|
||||
let f = |k: &str, have: bool| p[k].as_bool().is_none_or(|want| want == have);
|
||||
f("is_on_fire", e.on_fire)
|
||||
@@ -678,11 +723,16 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
})
|
||||
})
|
||||
}),
|
||||
"minecraft:type_specific/player" => p["gamemode"].as_array().is_none_or(|modes| {
|
||||
e.gamemode
|
||||
.as_deref()
|
||||
.is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g)))
|
||||
}),
|
||||
"minecraft:type_specific/player" => {
|
||||
p["gamemode"].as_array().is_none_or(|modes| {
|
||||
e.gamemode
|
||||
.as_deref()
|
||||
.is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g)))
|
||||
}) && (p["looking_at"].is_null()
|
||||
|| e.looking_at
|
||||
.as_ref()
|
||||
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
|
||||
}
|
||||
"minecraft:type_specific/lightning" => {
|
||||
range_i(&p["blocks_set_on_fire"], 0)
|
||||
&& (p["entity_struck"].is_null()
|
||||
@@ -703,6 +753,87 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `KilledByArrowTrigger.TriggerInstance.matches`: the weapon, each listed
|
||||
/// victim condition matched by a distinct victim, and the count of entity types.
|
||||
pub fn killed_by_arrow(
|
||||
c: &Value,
|
||||
me: &EntityView,
|
||||
victims: &[EntityView],
|
||||
weapon: Option<&ItemView>,
|
||||
w: &dyn World,
|
||||
) -> bool {
|
||||
fn ctx<'a>(e: &'a EntityView, me: &EntityView) -> Entities<'a> {
|
||||
Entities {
|
||||
this: Some(e),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
}
|
||||
}
|
||||
if !conditions(&c["player"], &ctx(me, me), w) {
|
||||
return false;
|
||||
}
|
||||
if !c["fired_from_weapon"].is_null()
|
||||
&& !weapon.is_some_and(|it| item(&c["fired_from_weapon"], it, w))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut left: Vec<&EntityView> = victims.iter().collect();
|
||||
for want in c["victims"].as_array().into_iter().flatten() {
|
||||
let Some(i) = left.iter().position(|e| conditions(want, &ctx(e, me), w)) else {
|
||||
return false;
|
||||
};
|
||||
left.remove(i);
|
||||
}
|
||||
let kinds: BTreeSet<&str> = victims.iter().map(|e| e.kind.as_str()).collect();
|
||||
range_i(&c["unique_entity_types"], kinds.len() as i64)
|
||||
}
|
||||
/// A damage event as `DamagePredicate` sees it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DamageView {
|
||||
/// The damage type id (`minecraft:arrow`).
|
||||
pub kind: String,
|
||||
/// `DamageSource.getDirectEntity` and `getEntity`.
|
||||
pub direct: Option<EntityView>,
|
||||
pub source: Option<EntityView>,
|
||||
/// `DamageSource.isDirect`: the direct entity is the causing one.
|
||||
pub is_direct: bool,
|
||||
/// The damage before and after the shield took its share.
|
||||
pub original: f32,
|
||||
pub actual: f32,
|
||||
pub blocked: bool,
|
||||
}
|
||||
/// `DamagePredicate`, with its `DamageSourcePredicate` as `type`.
|
||||
pub fn damage(v: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> bool {
|
||||
if v.is_null() {
|
||||
return true;
|
||||
}
|
||||
let by = |key: &str, e: &Option<EntityView>| {
|
||||
v[key].is_null() || e.as_ref().is_some_and(|e| entity(&v[key], e, w, origin))
|
||||
};
|
||||
if !range_f(&v["dealt"], f64::from(d.original))
|
||||
|| !range_f(&v["taken"], f64::from(d.actual))
|
||||
|| !by("source_entity", &d.source)
|
||||
|| v["blocked"].as_bool().is_some_and(|b| b != d.blocked)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let ty = &v["type"];
|
||||
let tags_ok = ty["tags"].as_array().into_iter().flatten().all(|t| {
|
||||
let tag = t["id"].as_str().unwrap_or("");
|
||||
let tag = tag.strip_prefix('#').unwrap_or(tag);
|
||||
w.in_tag("damage_type", tag, &d.kind) == t["expected"].as_bool().unwrap_or(true)
|
||||
});
|
||||
let direct_ok = ty["direct_entity"].is_null()
|
||||
|| d.direct
|
||||
.as_ref()
|
||||
.is_some_and(|e| entity(&ty["direct_entity"], e, w, origin));
|
||||
let source_ok = ty["source_entity"].is_null()
|
||||
|| d.source
|
||||
.as_ref()
|
||||
.is_some_and(|e| entity(&ty["source_entity"], e, w, origin));
|
||||
tags_ok && direct_ok && source_ok && ty["is_direct"].as_bool().is_none_or(|b| b == d.is_direct)
|
||||
}
|
||||
/// The entities a loot-style condition list may refer to.
|
||||
pub struct Entities<'a> {
|
||||
pub this: Option<&'a EntityView>,
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::{
|
||||
blocks::{BlockFacts, Fluid},
|
||||
command::args::GameMode,
|
||||
items::ItemStack,
|
||||
particles::{ParticleEngine, ParticleOptions, ParticleType as T},
|
||||
particles::{ParticleEngine, ParticleOptions, ParticleType as T, along_axis},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -36,8 +36,9 @@ enum Animate {
|
||||
spawn_particles: bool,
|
||||
},
|
||||
/// Leaves: optional falling-leaf particle and chance; a tinted leaf takes
|
||||
/// the biome foliage colour unless a fixed colour is given (azalea).
|
||||
Leaves(Option<(T, f32)>, Option<u32>),
|
||||
/// the biome foliage colour unless a fixed colour is given (azalea); and
|
||||
/// whether they have the poplar ambient sound (`AmbientLeavesBlockSoundPlayer`).
|
||||
Leaves(Option<(T, f32)>, Option<u32>, bool),
|
||||
NetherPortal,
|
||||
EnchantingTable,
|
||||
EndRod([i32; 3]),
|
||||
@@ -48,8 +49,9 @@ enum Animate {
|
||||
CandleCake,
|
||||
BrewingStand,
|
||||
EnderChest,
|
||||
/// FallingBlock with its dust colour.
|
||||
Falling(u32),
|
||||
/// FallingBlock with its dust colour; sand also has the ambient desert sound
|
||||
/// (`SandBlock.animateTick`).
|
||||
Falling(u32, bool),
|
||||
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
|
||||
Beehive,
|
||||
RedstoneOre,
|
||||
@@ -73,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 {
|
||||
@@ -89,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),
|
||||
@@ -109,17 +136,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
spawn_particles: false,
|
||||
},
|
||||
"oak_leaves" | "birch_leaves" | "jungle_leaves" | "acacia_leaves" | "dark_oak_leaves"
|
||||
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None),
|
||||
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None),
|
||||
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None),
|
||||
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None),
|
||||
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None),
|
||||
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None),
|
||||
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None, false),
|
||||
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None, false),
|
||||
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None, false),
|
||||
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None, true),
|
||||
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None, true),
|
||||
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None, true),
|
||||
// The azalea blocks drop tinted leaves of the fixed colour -9399763.
|
||||
"azalea_leaves" | "flowering_azalea_leaves" => {
|
||||
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D))
|
||||
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D), false)
|
||||
}
|
||||
"spruce_leaves" => Animate::Leaves(None, None),
|
||||
"spruce_leaves" => Animate::Leaves(None, None, false),
|
||||
"nether_portal" => Animate::NetherPortal,
|
||||
"enchanting_table" => Animate::EnchantingTable,
|
||||
"end_rod" => Animate::EndRod(facing()),
|
||||
@@ -128,16 +155,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
"mycelium" => Animate::Mycelium,
|
||||
"brewing_stand" => Animate::BrewingStand,
|
||||
"ender_chest" => Animate::EnderChest,
|
||||
"sand" => Animate::Falling(0xDBD3A0),
|
||||
"red_sand" => Animate::Falling(0xA95821),
|
||||
"gravel" => Animate::Falling(0x807C7B),
|
||||
"sand" => Animate::Falling(0xDBD3A0, true),
|
||||
"red_sand" => Animate::Falling(0xA95821, true),
|
||||
"gravel" => Animate::Falling(0x807C7B, false),
|
||||
// AnvilBlock/ConcretePowderBlock.getDustColor is the map colour;
|
||||
// DragonEggBlock's is black.
|
||||
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7),
|
||||
"dragon_egg" => Animate::Falling(0x000000),
|
||||
_ if name.ends_with("_concrete_powder") => Animate::Falling(concrete_powder_color(
|
||||
name.trim_end_matches("_concrete_powder"),
|
||||
)),
|
||||
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7, false),
|
||||
"dragon_egg" => Animate::Falling(0x000000, false),
|
||||
_ if name.ends_with("_concrete_powder") => Animate::Falling(
|
||||
concrete_powder_color(name.trim_end_matches("_concrete_powder")),
|
||||
false,
|
||||
),
|
||||
"beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive,
|
||||
"redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre,
|
||||
"redstone_wire" => {
|
||||
@@ -169,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),
|
||||
"suspicious_gravel" => Animate::Falling(0x707070),
|
||||
"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") =>
|
||||
{
|
||||
@@ -239,10 +304,20 @@ pub struct AnimateTables {
|
||||
power_transmitter: Vec<bool>,
|
||||
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
|
||||
dried_ghast_sounds: Vec<bool>,
|
||||
/// `#triggers_ambient_desert_sand_block_sounds` (sand, red sand).
|
||||
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> {
|
||||
@@ -308,12 +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,
|
||||
})
|
||||
@@ -339,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.
|
||||
@@ -389,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
|
||||
@@ -402,6 +524,10 @@ impl AnimateContext<'_> {
|
||||
fn add(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
|
||||
engine.add(&ParticleOptions::simple(ty), p, v);
|
||||
}
|
||||
/// `Level.addAlwaysVisibleParticle`: still shown under the Minimal option now and then.
|
||||
fn add_visible(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
|
||||
engine.add_with(&ParticleOptions::simple(ty), p, v, false, true);
|
||||
}
|
||||
fn sound(engine: &mut ParticleEngine, name: &'static str, p: [f64; 3], volume: f32, pitch: f32) {
|
||||
engine.sounds.push((name, p, volume, pitch));
|
||||
}
|
||||
@@ -440,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(
|
||||
@@ -596,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,
|
||||
@@ -671,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) {
|
||||
@@ -777,7 +920,7 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::Leaves(falling, fixed_tint) => {
|
||||
Animate::Leaves(falling, fixed_tint, poplar) => {
|
||||
let below = [pos[0], pos[1] - 1, pos[2]];
|
||||
// LeavesBlock.makeDrippingWaterParticles.
|
||||
if (ctx.raining_at)([pos[0], pos[1] + 1, pos[2]]) && next_int(engine.random(), 15) == 1
|
||||
@@ -789,6 +932,9 @@ fn block_animate_tick(
|
||||
add(engine, T::DrippingWater, p, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
if poplar {
|
||||
leaves_ambient_sound(tables, engine, ctx, state, pos);
|
||||
}
|
||||
// FallingParticlesLeavesBlock.makeFallingLeavesParticles.
|
||||
if let Some((ty, chance)) = falling
|
||||
&& engine.random().next_float() < chance
|
||||
@@ -961,7 +1107,7 @@ fn block_animate_tick(
|
||||
fy + 1.0 + f64::from(r.next_float()),
|
||||
fz + f64::from(r.next_float()),
|
||||
];
|
||||
add(engine, T::SulfurBubbles, p, [0.0; 3]);
|
||||
add_visible(engine, T::SulfurBubbles, p, [0.0; 3]);
|
||||
}
|
||||
if next_int(engine.random(), 10) == 0 {
|
||||
let name = "minecraft:block.potent_sulfur.noxious_gas";
|
||||
@@ -1057,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();
|
||||
@@ -1081,7 +1413,7 @@ fn block_animate_tick(
|
||||
add(engine, T::Portal, p, v);
|
||||
}
|
||||
}
|
||||
Animate::Falling(color) => {
|
||||
Animate::Falling(color, sand) => {
|
||||
if next_int(engine.random(), 16) == 0 {
|
||||
let below = ctx.facts.get(ctx.state([pos[0], pos[1] - 1, pos[2]]));
|
||||
// FallingBlock.isFree: air, fire, liquids or replaceable.
|
||||
@@ -1098,6 +1430,14 @@ fn block_animate_tick(
|
||||
);
|
||||
}
|
||||
}
|
||||
// AmbientDesertBlockSoundsPlayer.playAmbientSandSounds: only for the air block.
|
||||
if sand
|
||||
&& ctx.state([pos[0], pos[1] + 1, pos[2]]) == ctx.world.air
|
||||
&& next_int(engine.random(), 2100) == 0
|
||||
&& sand_sound_nearby(tables, ctx, pos)
|
||||
{
|
||||
sound(engine, "minecraft:block.sand.idle", [fx, fy, fz], 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
Animate::Beehive => {
|
||||
// BeehiveBlock.animateTick: nextInt(1) + 1 attempts.
|
||||
@@ -1215,7 +1555,7 @@ fn block_animate_tick(
|
||||
}
|
||||
Animate::BubbleColumn { drag } => {
|
||||
if drag {
|
||||
add(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
|
||||
add_visible(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
|
||||
let r = engine.random();
|
||||
if next_int(r, 200) == 0 {
|
||||
let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15);
|
||||
@@ -1228,7 +1568,7 @@ fn block_animate_tick(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
add(
|
||||
add_visible(
|
||||
engine,
|
||||
T::BubbleColumnUp,
|
||||
[fx + 0.5, fy, fz + 0.5],
|
||||
@@ -1240,7 +1580,7 @@ fn block_animate_tick(
|
||||
fy + f64::from(r.next_float()),
|
||||
fz + f64::from(r.next_float()),
|
||||
];
|
||||
add(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
|
||||
add_visible(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
|
||||
let r = engine.random();
|
||||
if next_int(r, 200) == 0 {
|
||||
let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15);
|
||||
@@ -1318,6 +1658,153 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `AmbientLeavesBlockSoundPlayer.playAmbientLeavesSounds` for the poplar leaves:
|
||||
/// one log and three leaves of the same kind around the block.
|
||||
fn leaves_ambient_sound(
|
||||
tables: &AnimateTables,
|
||||
engine: &mut ParticleEngine,
|
||||
ctx: &AnimateContext,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
) {
|
||||
if next_int(engine.random(), 300) != 0 {
|
||||
return;
|
||||
}
|
||||
let Some(name) = ctx.registry.state(state).map(|s| &s.name) else {
|
||||
return;
|
||||
};
|
||||
let (mut logs, mut leaves) = (0, 0);
|
||||
for d in [[0, -1], [1, 0], [0, 1], [-1, 0]] {
|
||||
let neighbor = ctx.state([pos[0] + d[0], pos[1], pos[2] + d[1]]);
|
||||
if tables
|
||||
.poplar_leaf_logs
|
||||
.get(neighbor.0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
{
|
||||
logs += 1;
|
||||
}
|
||||
if ctx
|
||||
.registry
|
||||
.state(neighbor)
|
||||
.is_some_and(|s| &s.name == name)
|
||||
{
|
||||
leaves += 1;
|
||||
}
|
||||
if logs == 1 && leaves == 3 {
|
||||
let p = pos.map(f64::from);
|
||||
sound(engine, "minecraft:block.poplar_leaves.ambient", p, 1.0, 1.0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `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 {
|
||||
let is_sand = |p: [i32; 3]| {
|
||||
tables
|
||||
.desert_sand_sounds
|
||||
.get(ctx.state(p).0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let (mut found, mut checked) = (0, 0);
|
||||
for d in [[0, -8], [8, 0], [0, 8], [-8, 0]] {
|
||||
let (x, z) = (pos[0] + d[0], pos[2] + d[1]);
|
||||
// columnContainsTriggeringBlock.
|
||||
let surface = ctx.world.world_surface(x, z, |s| ctx.facts.is_air(s)) - 1;
|
||||
let matches = if (surface - pos[1]).abs() > 5 {
|
||||
let mut y = pos[1] + 6;
|
||||
let mut above_air = ctx.is_air([x, y, z]);
|
||||
y -= 1;
|
||||
let mut hit = false;
|
||||
for _ in 0..10 {
|
||||
if above_air && is_sand([x, y, z]) {
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
above_air = ctx.is_air([x, y, z]);
|
||||
y -= 1;
|
||||
}
|
||||
hit
|
||||
} else {
|
||||
ctx.is_air([x, surface + 1, z]) && is_sand([x, surface, z])
|
||||
};
|
||||
if matches {
|
||||
found += 1;
|
||||
if found > 3 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
checked += 1;
|
||||
if 4 - checked + found < 3 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `AbstractCandleBlock.addParticlesAndSound`.
|
||||
fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
|
||||
let r = engine.random();
|
||||
|
||||
@@ -159,6 +159,8 @@ pub struct ArrowState {
|
||||
pub pickup: Pickup,
|
||||
/// `pickupItemStack`: what picking the arrow up gives.
|
||||
pub pickup_item: crate::items::ItemStack,
|
||||
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
|
||||
pub fired_from_weapon: Option<crate::items::ItemStack>,
|
||||
pub no_physics: bool,
|
||||
/// `AbstractArrow.getPierceLevel`.
|
||||
pub pierce: u8,
|
||||
@@ -208,6 +210,7 @@ impl ArrowState {
|
||||
last_state: None,
|
||||
pickup: Pickup::Disallowed,
|
||||
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
|
||||
fired_from_weapon: None,
|
||||
no_physics: false,
|
||||
pierce: 0,
|
||||
pierced: Vec::new(),
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! `filter`, `unstitch` and `paletted_permutations` sources run in order
|
||||
//! against the resource tree; a later source replaces an earlier sprite of
|
||||
//! the same id and a filter removes the matching ones.
|
||||
use crate::packs::pattern::Pattern;
|
||||
use horizon_assets::palette::apply_palette;
|
||||
use horizon_client_core::pattern::Pattern;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
|
||||
@@ -179,7 +179,7 @@ fn agent() -> ureq::Agent {
|
||||
}
|
||||
|
||||
/// The discovery document, fetched once per game (a failure is retried next time).
|
||||
fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
|
||||
pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
|
||||
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
|
||||
if let Some(d) = CACHE.lock().unwrap().clone() {
|
||||
return Ok(d);
|
||||
|
||||
@@ -66,6 +66,13 @@ impl BiomeInfo {
|
||||
}
|
||||
.height_adjusted(pos, sea_level)
|
||||
}
|
||||
/// `Biome.getPrecipitationAt` in the Overworld (sea level 63): None
|
||||
/// without precipitation, else whether it falls as snow
|
||||
/// (`getHeightAdjustedTemperature` below 0.15).
|
||||
pub fn snows_at(&self, x: i32, y: i32, z: i32) -> Option<bool> {
|
||||
self.precipitation
|
||||
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
|
||||
}
|
||||
}
|
||||
pub struct Biomes {
|
||||
pub list: Vec<BiomeInfo>,
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
//! (`entity-models.json`); transforms, animation and textures follow each
|
||||
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
|
||||
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
|
||||
use crate::{hud::Rect, mesher::Vertex, world::World};
|
||||
use crate::{
|
||||
hud::Rect,
|
||||
mesher::{CULL_BACK, Vertex},
|
||||
world::World,
|
||||
};
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
use horizon_core::geometry::{
|
||||
Direction,
|
||||
@@ -149,6 +153,8 @@ pub fn draw_part(
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -650,6 +656,8 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
layer: 0,
|
||||
color: c,
|
||||
light: call.light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -736,7 +744,7 @@ pub struct Scene<'a> {
|
||||
/// The skin sprite a `player_head` shows at a position (`SkullBlockRenderer`
|
||||
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
|
||||
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<&'a str>,
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
|
||||
/// Font glyph cell (none for blank glyphs such as the space) and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
|
||||
}
|
||||
@@ -934,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)
|
||||
@@ -1288,11 +1320,12 @@ impl BlockEntityRenderer {
|
||||
};
|
||||
// SkullBlockRenderer.getRenderType: the profile's skin when it
|
||||
// resolves, `SKIN_BY_TYPE` (steve) otherwise.
|
||||
let texture = if *kind == SkullType::Player {
|
||||
(scene.skull)(pos).unwrap_or(texture)
|
||||
let skin = if *kind == SkullType::Player {
|
||||
(scene.skull)(pos)
|
||||
} else {
|
||||
texture
|
||||
None
|
||||
};
|
||||
let texture = skin.as_deref().unwrap_or(texture);
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
else {
|
||||
@@ -1387,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)
|
||||
{
|
||||
@@ -1404,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
|
||||
@@ -1434,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 => {
|
||||
@@ -1716,6 +1741,8 @@ impl BeamPart {
|
||||
layer: 6,
|
||||
color,
|
||||
light: self.wrap,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1786,6 +1813,7 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
|
||||
layer,
|
||||
color: 0,
|
||||
light: 0,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ pub fn geometry(
|
||||
color: 0xffff_ffff,
|
||||
light: stage_light(stage),
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,257 +7,12 @@ use crate::{
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
path::Path,
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
use std::{collections::HashSet, fs, path::Path, sync::Arc};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Category {
|
||||
Beneficial,
|
||||
Harmful,
|
||||
Neutral,
|
||||
}
|
||||
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EffectDef {
|
||||
pub id: &'static str,
|
||||
pub color: u32,
|
||||
pub category: Category,
|
||||
pub instant: bool,
|
||||
}
|
||||
const fn fx(id: &'static str, color: u32, category: Category) -> EffectDef {
|
||||
EffectDef {
|
||||
id,
|
||||
color,
|
||||
category,
|
||||
instant: false,
|
||||
}
|
||||
}
|
||||
use Category::{Beneficial as B, Harmful as H, Neutral as N};
|
||||
/// `MobEffects` in registration order.
|
||||
pub const EFFECTS: &[EffectDef] = &[
|
||||
fx("speed", 3402751, B),
|
||||
fx("slowness", 9154528, H),
|
||||
fx("haste", 14270531, B),
|
||||
fx("mining_fatigue", 4866583, H),
|
||||
fx("strength", 16762624, B),
|
||||
EffectDef {
|
||||
id: "instant_health",
|
||||
color: 16262179,
|
||||
category: B,
|
||||
instant: true,
|
||||
},
|
||||
EffectDef {
|
||||
id: "instant_damage",
|
||||
color: 11101546,
|
||||
category: H,
|
||||
instant: true,
|
||||
},
|
||||
fx("jump_boost", 16646020, B),
|
||||
fx("nausea", 5578058, H),
|
||||
fx("regeneration", 13458603, B),
|
||||
fx("resistance", 9520880, B),
|
||||
fx("fire_resistance", 16750848, B),
|
||||
fx("water_breathing", 10017472, B),
|
||||
fx("invisibility", 16185078, B),
|
||||
fx("blindness", 2039587, H),
|
||||
fx("night_vision", 12779366, B),
|
||||
fx("hunger", 5797459, H),
|
||||
fx("weakness", 4738376, H),
|
||||
fx("poison", 8889187, H),
|
||||
fx("wither", 7561558, H),
|
||||
fx("health_boost", 16284963, B),
|
||||
fx("absorption", 2445989, B),
|
||||
EffectDef {
|
||||
id: "saturation",
|
||||
color: 16262179,
|
||||
category: B,
|
||||
instant: true,
|
||||
},
|
||||
fx("glowing", 9740385, N),
|
||||
fx("levitation", 13565951, H),
|
||||
fx("luck", 5882118, B),
|
||||
fx("unluck", 12624973, H),
|
||||
fx("slow_falling", 15978425, B),
|
||||
fx("conduit_power", 1950417, B),
|
||||
fx("dolphins_grace", 8954814, B),
|
||||
fx("bad_omen", 745784, N),
|
||||
fx("hero_of_the_village", 4521796, B),
|
||||
fx("darkness", 2696993, H),
|
||||
fx("trial_omen", 1484454, N),
|
||||
fx("raid_omen", 14565464, N),
|
||||
fx("wind_charged", 12438015, H),
|
||||
fx("weaving", 7891290, H),
|
||||
fx("oozing", 10092451, H),
|
||||
fx("infested", 9214860, H),
|
||||
fx("breath_of_the_nautilus", 65518, B),
|
||||
];
|
||||
pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
let id = id.trim_start_matches("minecraft:");
|
||||
EFFECTS.iter().find(|e| e.id == id)
|
||||
}
|
||||
/// Namespaced effect ids for mob effect storage.
|
||||
pub fn namespaced(id: &'static str) -> &'static str {
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
IDS.get_or_init(|| {
|
||||
EFFECTS
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
e.id,
|
||||
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(id)
|
||||
.copied()
|
||||
.unwrap_or(id)
|
||||
}
|
||||
/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.<name>`) and
|
||||
/// effects as (effect, duration ticks, amplifier).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct PotionDef {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub effects: &'static [(&'static str, i32, i32)],
|
||||
}
|
||||
const fn p(
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
) -> PotionDef {
|
||||
PotionDef { id, name, effects }
|
||||
}
|
||||
/// `Potions` in registration order.
|
||||
pub const POTIONS: &[PotionDef] = &[
|
||||
p("water", "water", &[]),
|
||||
p("mundane", "mundane", &[]),
|
||||
p("thick", "thick", &[]),
|
||||
p("awkward", "awkward", &[]),
|
||||
p("night_vision", "night_vision", &[("night_vision", 3600, 0)]),
|
||||
p(
|
||||
"long_night_vision",
|
||||
"night_vision",
|
||||
&[("night_vision", 9600, 0)],
|
||||
),
|
||||
p("invisibility", "invisibility", &[("invisibility", 3600, 0)]),
|
||||
p(
|
||||
"long_invisibility",
|
||||
"invisibility",
|
||||
&[("invisibility", 9600, 0)],
|
||||
),
|
||||
p("leaping", "leaping", &[("jump_boost", 3600, 0)]),
|
||||
p("long_leaping", "leaping", &[("jump_boost", 9600, 0)]),
|
||||
p("strong_leaping", "leaping", &[("jump_boost", 1800, 1)]),
|
||||
p(
|
||||
"fire_resistance",
|
||||
"fire_resistance",
|
||||
&[("fire_resistance", 3600, 0)],
|
||||
),
|
||||
p(
|
||||
"long_fire_resistance",
|
||||
"fire_resistance",
|
||||
&[("fire_resistance", 9600, 0)],
|
||||
),
|
||||
p("swiftness", "swiftness", &[("speed", 3600, 0)]),
|
||||
p("long_swiftness", "swiftness", &[("speed", 9600, 0)]),
|
||||
p("strong_swiftness", "swiftness", &[("speed", 1800, 1)]),
|
||||
p("slowness", "slowness", &[("slowness", 1800, 0)]),
|
||||
p("long_slowness", "slowness", &[("slowness", 4800, 0)]),
|
||||
p("strong_slowness", "slowness", &[("slowness", 400, 3)]),
|
||||
p(
|
||||
"turtle_master",
|
||||
"turtle_master",
|
||||
&[("slowness", 400, 3), ("resistance", 400, 2)],
|
||||
),
|
||||
p(
|
||||
"long_turtle_master",
|
||||
"turtle_master",
|
||||
&[("slowness", 800, 3), ("resistance", 800, 2)],
|
||||
),
|
||||
p(
|
||||
"strong_turtle_master",
|
||||
"turtle_master",
|
||||
&[("slowness", 400, 5), ("resistance", 400, 3)],
|
||||
),
|
||||
p(
|
||||
"water_breathing",
|
||||
"water_breathing",
|
||||
&[("water_breathing", 3600, 0)],
|
||||
),
|
||||
p(
|
||||
"long_water_breathing",
|
||||
"water_breathing",
|
||||
&[("water_breathing", 9600, 0)],
|
||||
),
|
||||
p("healing", "healing", &[("instant_health", 1, 0)]),
|
||||
p("strong_healing", "healing", &[("instant_health", 1, 1)]),
|
||||
p("harming", "harming", &[("instant_damage", 1, 0)]),
|
||||
p("strong_harming", "harming", &[("instant_damage", 1, 1)]),
|
||||
p("poison", "poison", &[("poison", 900, 0)]),
|
||||
p("long_poison", "poison", &[("poison", 1800, 0)]),
|
||||
p("strong_poison", "poison", &[("poison", 432, 1)]),
|
||||
p("regeneration", "regeneration", &[("regeneration", 900, 0)]),
|
||||
p(
|
||||
"long_regeneration",
|
||||
"regeneration",
|
||||
&[("regeneration", 1800, 0)],
|
||||
),
|
||||
p(
|
||||
"strong_regeneration",
|
||||
"regeneration",
|
||||
&[("regeneration", 450, 1)],
|
||||
),
|
||||
p("strength", "strength", &[("strength", 3600, 0)]),
|
||||
p("long_strength", "strength", &[("strength", 9600, 0)]),
|
||||
p("strong_strength", "strength", &[("strength", 1800, 1)]),
|
||||
p("weakness", "weakness", &[("weakness", 1800, 0)]),
|
||||
p("long_weakness", "weakness", &[("weakness", 4800, 0)]),
|
||||
p("luck", "luck", &[("luck", 6000, 0)]),
|
||||
p("slow_falling", "slow_falling", &[("slow_falling", 1800, 0)]),
|
||||
p(
|
||||
"long_slow_falling",
|
||||
"slow_falling",
|
||||
&[("slow_falling", 4800, 0)],
|
||||
),
|
||||
p("wind_charged", "wind_charged", &[("wind_charged", 3600, 0)]),
|
||||
p("weaving", "weaving", &[("weaving", 3600, 0)]),
|
||||
p("oozing", "oozing", &[("oozing", 3600, 0)]),
|
||||
p("infested", "infested", &[("infested", 3600, 0)]),
|
||||
];
|
||||
pub fn potion(id: &str) -> Option<&'static PotionDef> {
|
||||
let id = id.trim_start_matches("minecraft:");
|
||||
POTIONS.iter().find(|p| p.id == id)
|
||||
}
|
||||
/// `PotionContents.BASE_POTION_COLOR` (ARGB -13083194) as RGB.
|
||||
pub const BASE_POTION_COLOR: u32 = 0x385DC6;
|
||||
/// `PotionContents.getColor`: amplifier-weighted mix of visible effect colours.
|
||||
pub fn potion_color(id: Option<&str>) -> u32 {
|
||||
let Some(def) = id.and_then(potion) else {
|
||||
return BASE_POTION_COLOR;
|
||||
};
|
||||
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
|
||||
for (e, _, amp) in def.effects {
|
||||
let Some(e) = effect(e) else { continue };
|
||||
let w = *amp as u32 + 1;
|
||||
r += w * (e.color >> 16 & 255);
|
||||
g += w * (e.color >> 8 & 255);
|
||||
b += w * (e.color & 255);
|
||||
total += w;
|
||||
}
|
||||
match (
|
||||
r.checked_div(total),
|
||||
g.checked_div(total),
|
||||
b.checked_div(total),
|
||||
) {
|
||||
(Some(r), Some(g), Some(b)) => (r << 16) | (g << 8) | b,
|
||||
_ => BASE_POTION_COLOR,
|
||||
}
|
||||
}
|
||||
pub use horizon_client_core::potions::{
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
|
||||
potion, potion_color,
|
||||
};
|
||||
/// `MobEffectUtil.formatDuration` at 20 TPS: m:ss or h:mm:ss.
|
||||
pub fn format_duration(ticks: i32) -> String {
|
||||
let secs = ticks / 20;
|
||||
|
||||
@@ -211,6 +211,17 @@ impl Contents {
|
||||
self.weight.n >= self.weight.d
|
||||
}
|
||||
|
||||
/// `BundleItem.isBarVisible`, `getBarWidth` and `getBarColor`: the fullness bar
|
||||
/// (width, RGB), blue until the bundle is full, then red.
|
||||
pub fn bar(&self) -> Option<(i32, u32)> {
|
||||
if self.weight.n <= 0 {
|
||||
return None;
|
||||
}
|
||||
let width = (1 + self.weight.n * 12 / self.weight.d).min(13) as i32;
|
||||
let color = if self.is_full() { 0xFF5454 } else { 0x7087FF };
|
||||
Some((width, color))
|
||||
}
|
||||
|
||||
/// `toImmutable`, written back into the bundle's component.
|
||||
pub fn store(self, bundle: &mut ItemStack) {
|
||||
let data = bundle.data_mut();
|
||||
|
||||
@@ -16,6 +16,9 @@ use crate::{
|
||||
#[path = "chat_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// `CommandHistory.PERSISTED_COMMANDS_FILE_NAME` and its size cap.
|
||||
const COMMAND_HISTORY_FILE: &str = "command_history.txt";
|
||||
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
|
||||
/// GuiMessageTag.systemSinglePlayer's indicator colour.
|
||||
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
|
||||
|
||||
@@ -80,6 +83,10 @@ impl ChatAbilities {
|
||||
ChatSource::Client => true,
|
||||
}
|
||||
}
|
||||
/// `ChatAbilities.hasAnyRestrictions`: the Chat option takes something.
|
||||
pub fn any_restriction(&self) -> bool {
|
||||
*self != Self::new(ChatVisibility::Full)
|
||||
}
|
||||
/// Whether the chat screen may send `input` (`hasAllowedInput`).
|
||||
pub fn allows(&self, input: &str) -> bool {
|
||||
if input.starts_with('/') {
|
||||
@@ -185,6 +192,10 @@ pub struct Chat {
|
||||
lines: Vec<Line>,
|
||||
/// Sent messages and commands (ChatComponent.recentChat).
|
||||
pub recent: Vec<String>,
|
||||
/// `CommandHistory`: the last commands, kept in `command_history.txt`
|
||||
/// in the game folder once `load_history` has set it.
|
||||
commands: Vec<String>,
|
||||
history_file: Option<std::path::PathBuf>,
|
||||
scroll: usize,
|
||||
new_since_scroll: bool,
|
||||
pub options: ChatOptions,
|
||||
@@ -310,6 +321,17 @@ impl Chat {
|
||||
self.lines.clear();
|
||||
self.scroll = 0;
|
||||
}
|
||||
/// `new CommandHistory(gameFolder)`: the commands kept from earlier runs
|
||||
/// start the chat's history (`ChatComponent`'s constructor).
|
||||
pub fn load_history(&mut self, game_folder: &std::path::Path) {
|
||||
let file = game_folder.join(COMMAND_HISTORY_FILE);
|
||||
if let Ok(text) = std::fs::read_to_string(&file) {
|
||||
self.commands = text.lines().map(str::to_owned).collect();
|
||||
self.recent.extend(self.commands.iter().cloned());
|
||||
}
|
||||
self.history_file = Some(file);
|
||||
}
|
||||
/// `ChatComponent.addRecentChat`: a command also goes to the saved history.
|
||||
pub fn add_recent(&mut self, message: &str) {
|
||||
if self.recent.last().map(String::as_str) != Some(message) {
|
||||
if self.recent.len() >= 100 {
|
||||
@@ -317,6 +339,21 @@ impl Chat {
|
||||
}
|
||||
self.recent.push(message.to_owned());
|
||||
}
|
||||
if message.starts_with('/') && self.commands.last().map(String::as_str) != Some(message) {
|
||||
if self.commands.len() >= MAX_PERSISTED_COMMAND_HISTORY {
|
||||
self.commands.remove(0);
|
||||
}
|
||||
self.commands.push(message.to_owned());
|
||||
if let Some(file) = &self.history_file {
|
||||
let mut text = self.commands.join("\n");
|
||||
text.push('\n');
|
||||
if let Err(e) = std::fs::write(file, text) {
|
||||
log::error!(
|
||||
"Failed to write {COMMAND_HISTORY_FILE}, command history will be missing: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn reset_scroll(&mut self) {
|
||||
self.scroll = 0;
|
||||
@@ -438,10 +475,71 @@ impl Chat {
|
||||
out.push(GuiDraw::Pop);
|
||||
out
|
||||
}
|
||||
/// Draws [`Chat::layout`] into the HUD batch.
|
||||
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
|
||||
/// open the restricted prompt above the lines.
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
|
||||
let draws = self.layout(batch.height as i32, ticks, focused);
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
if focused && self.abilities.any_restriction() {
|
||||
let prompt = self.restricted_prompt(&atlas.font, batch.height as i32);
|
||||
hud_layout::paint(batch, atlas, &prompt);
|
||||
}
|
||||
}
|
||||
/// `ChatComponent.extractRenderState` for `FOREGROUND_RESTRICTED`: a
|
||||
/// background row and the red, underlined "Chat is restricted" line in
|
||||
/// the row above the messages, clipped to the chat's width.
|
||||
fn restricted_prompt(&self, font: &crate::font::Font, height: i32) -> Vec<GuiDraw> {
|
||||
let o = &self.options;
|
||||
let scale = o.scale as f32;
|
||||
let max_width = hud_layout::ceil(o.width_px() as f32 / scale);
|
||||
let chat_bottom = hud_layout::floor((height - 40) as f32 / scale);
|
||||
let entry_height = o.line_height();
|
||||
let bottom_to_text =
|
||||
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
|
||||
let shown = self
|
||||
.lines
|
||||
.len()
|
||||
.saturating_sub(self.scroll)
|
||||
.min(self.lines_per_page(true));
|
||||
let top = chat_bottom - (shown as i32 + 1) * entry_height;
|
||||
let mut prompt = Text::translate("chat_screen.restricted", &[]).with_style(&Style {
|
||||
color: Some(text::RED),
|
||||
underlined: Some(true),
|
||||
..Style::default()
|
||||
});
|
||||
// ComponentRenderUtils.clipText.
|
||||
let mut width = 0.0;
|
||||
let fits = prompt
|
||||
.chars()
|
||||
.take_while(|(c, style)| {
|
||||
width += font.advance(*c, style);
|
||||
width <= max_width as f32
|
||||
})
|
||||
.count();
|
||||
if fits < prompt.chars().count() {
|
||||
prompt = prompt.slice(0, fits);
|
||||
}
|
||||
vec![
|
||||
GuiDraw::Push(vec![
|
||||
PoseOp::Scale(scale, scale),
|
||||
PoseOp::Translate(4.0, 0.0),
|
||||
]),
|
||||
hud_layout::fill(
|
||||
-2,
|
||||
top,
|
||||
max_width + 4 + 4,
|
||||
top + entry_height,
|
||||
hud_layout::as8(o.background_opacity as f32) << 24,
|
||||
),
|
||||
GuiDraw::Text {
|
||||
text: prompt,
|
||||
x: 0,
|
||||
y: top + entry_height - bottom_to_text - 1,
|
||||
color: hud_layout::white(o.opacity as f32 * 0.9 + 0.1),
|
||||
shadow: true,
|
||||
},
|
||||
GuiDraw::Pop,
|
||||
]
|
||||
}
|
||||
/// The click action under a GUI-space cursor while chat is focused.
|
||||
pub fn click_at(
|
||||
@@ -458,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));
|
||||
@@ -487,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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! threads can mesh and light immutable snapshots while the game thread edits
|
||||
//! (copy-on-write through `Arc::make_mut`).
|
||||
use horizon_core::paletted::{PalettedContainer, Strategy};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
@@ -155,7 +156,7 @@ pub struct Chunk {
|
||||
/// positions, applied once by LevelChunk.postProcessGeneration.
|
||||
pub postprocess: Vec<[i32; 3]>,
|
||||
/// Saved data Horizon keeps but does not simulate (save.rs).
|
||||
pub saved: Option<Arc<crate::save::ChunkExtras>>,
|
||||
pub saved: Option<Arc<ChunkExtras>>,
|
||||
/// Entities read from a save (vanilla entity NBT), spawned on arrival.
|
||||
pub entities: Vec<horizon_world::nbt::Tag>,
|
||||
/// Structure starts reaching into this chunk (`structures.References`):
|
||||
@@ -309,6 +310,76 @@ impl Chunk {
|
||||
}
|
||||
}
|
||||
|
||||
/// A proto chunk saved before it became final. Decoration writes into a
|
||||
/// chunk's neighbours, and decorated chunks wait for theirs, so the chunks
|
||||
/// past the finished ones hold writes that generating them again would lose
|
||||
/// (whole structure pieces, trees, ores). Vanilla saves its proto chunks too.
|
||||
pub struct SavedProto {
|
||||
/// Blocks, scheduled ticks (in `saved`) and post-processing marks.
|
||||
pub column: Chunk,
|
||||
/// Decorated itself (`features`), not only written to by its neighbours
|
||||
/// (`terrain`).
|
||||
pub decorated: bool,
|
||||
/// Block entity data in placement order, with `x`, `y` and `z`.
|
||||
pub block_entities: Vec<Tag>,
|
||||
/// Entities placed by structures, with absolute `Pos`.
|
||||
pub entities: Vec<Tag>,
|
||||
}
|
||||
|
||||
/// A container's loot table and what Horizon rolled from it. Vanilla keeps
|
||||
/// `LootTable` and `LootTableSeed` until the container is first opened;
|
||||
/// Horizon rolls when it generates or loads the chunk, and saves the table
|
||||
/// again while the container still holds exactly the rolled items.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RolledLoot {
|
||||
pub pos: (i32, i32, i32),
|
||||
pub table: String,
|
||||
/// The saved `LootTableSeed`, written back unless 0 (trySaveLootTable).
|
||||
pub seed: Option<i64>,
|
||||
/// The rolled `Items`.
|
||||
pub items: Tag,
|
||||
}
|
||||
impl RolledLoot {
|
||||
/// Puts the loot table back in place of `Items` while they are still the
|
||||
/// roll; false once the container holds anything else.
|
||||
pub fn restore(&self, map: &mut Compound) -> bool {
|
||||
if map.get(&"Items".into()) != Some(&self.items) {
|
||||
return false;
|
||||
}
|
||||
map.remove(&"Items".into());
|
||||
map.insert("LootTable".into(), Tag::String(self.table.as_str().into()));
|
||||
if let Some(seed) = self.seed.filter(|s| *s != 0) {
|
||||
map.insert("LootTableSeed".into(), Tag::Long(seed));
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Save data loaded with a chunk that Horizon keeps but does not simulate.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ChunkExtras {
|
||||
/// Chunk root keys written back as loaded (structures, PostProcessing,
|
||||
/// InhabitedTime, blending data, ...).
|
||||
pub root: Compound,
|
||||
/// Block entities Horizon does not model.
|
||||
pub block_entities: Vec<Tag>,
|
||||
/// The NBT of the modelled block entities as loaded, to restore the fields and
|
||||
/// detail Horizon does not model when it saves them (`block_entity_nbt::overlay`).
|
||||
pub block_entity_sources: Vec<((i32, i32, i32), Tag)>,
|
||||
/// Every saved block entity's position in the order of the save's list,
|
||||
/// the order `SerializableChunkData` puts them in the chunk's maps.
|
||||
pub block_entity_order: Vec<(i32, i32, i32)>,
|
||||
/// Scheduled ticks (`block_ticks`/`fluid_ticks`): delays relative to the
|
||||
/// save time. Registered with the level's tick scheduler on arrival and
|
||||
/// replaced with the scheduler's pending ticks when saving.
|
||||
pub block_ticks: Vec<horizon_core::ticks::SavedTick>,
|
||||
pub fluid_ticks: Vec<horizon_core::ticks::SavedTick>,
|
||||
/// Game time written as `LastUpdate`.
|
||||
pub last_update: i64,
|
||||
/// Containers still holding their loot table's roll.
|
||||
pub rolled_loot: Vec<RolledLoot>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -179,6 +179,8 @@ impl Mesh {
|
||||
layer: 4,
|
||||
color: u32::MAX,
|
||||
light: shade,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,6 +54,27 @@ fn strider_liquid_shape() -> VoxelShape {
|
||||
VoxelShape::from_cube([1, 2, 1], &[true, false]).expect("a valid half-block column")
|
||||
}
|
||||
|
||||
// Vanilla scopes this direction to Entity.move while a piston pushes it.
|
||||
thread_local! {
|
||||
static PISTON_NOCLIP: std::cell::Cell<Option<horizon_core::geometry::Direction>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Suppress the moving part of pistons moving in this direction during a push.
|
||||
pub fn with_piston_noclip<T>(
|
||||
direction: horizon_core::geometry::Direction,
|
||||
f: impl FnOnce() -> T,
|
||||
) -> T {
|
||||
struct Reset(Option<horizon_core::geometry::Direction>);
|
||||
impl Drop for Reset {
|
||||
fn drop(&mut self) {
|
||||
PISTON_NOCLIP.with(|value| value.set(self.0));
|
||||
}
|
||||
}
|
||||
let previous = PISTON_NOCLIP.with(|value| value.replace(Some(direction)));
|
||||
let _reset = Reset(previous);
|
||||
f()
|
||||
}
|
||||
|
||||
pub struct SceneCollisions<'w, 'r> {
|
||||
pub world: &'w World,
|
||||
pub binding: &'w BlockCollisionRegistry<'r>,
|
||||
@@ -140,8 +161,21 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
|
||||
border.collision_shape().map(Some).map_err(io::Error::other)
|
||||
}
|
||||
}
|
||||
impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
|
||||
/// `SceneCollisions::resolve` that also stops at the entity boxes.
|
||||
impl<'a, W> EntityCollisions<'a, W>
|
||||
where
|
||||
W: BlockCollisionWorld<Error = io::Error> + EntityMovementWorld<Error = io::Error>,
|
||||
{
|
||||
/// `EntityCollisions` for a body the world border also stops: the border
|
||||
/// and the body's own x and z (`WorldBorder.isInsideCloseToBorder`).
|
||||
pub fn with_border(blocks: W, border: &'a BorderGeometry, at: [f64; 2]) -> Self {
|
||||
Self {
|
||||
blocks,
|
||||
entities: &[],
|
||||
border: Some((border, at)),
|
||||
}
|
||||
}
|
||||
/// `SceneCollisions::resolve` that also stops at the entity boxes and,
|
||||
/// with a border, at the world border's walls.
|
||||
pub fn resolve(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
@@ -149,12 +183,11 @@ impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
) -> Result<Vec3> {
|
||||
let context = self.blocks.context(bounds);
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context,
|
||||
context: CollisionContext::position(bounds.min_position().y),
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
@@ -324,7 +357,7 @@ impl DynamicSupportWorld for SceneCollisions<'_, '_> {
|
||||
return Ok(None);
|
||||
};
|
||||
pistons
|
||||
.collision_shape(self, p, piston, None)
|
||||
.collision_shape(self, p, piston, PISTON_NOCLIP.with(|value| value.get()))
|
||||
.map(Some)
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
@@ -388,6 +421,42 @@ impl PowderSnowWorld for SceneCollisions<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A collision world without blocks: no chunk is resident, so only the
|
||||
/// world border stops a body.
|
||||
pub struct EmptyCollisions;
|
||||
|
||||
impl BlockCollisionWorld for EmptyCollisions {
|
||||
type Error = io::Error;
|
||||
type Chunk = ChunkPos;
|
||||
fn collision_chunk(&mut self, _: ChunkPos) -> io::Result<Option<ChunkPos>> {
|
||||
Ok(None)
|
||||
}
|
||||
fn collision_block(&mut self, _: ChunkPos, _: BlockPos) -> io::Result<CollisionBlock> {
|
||||
Err(io::Error::other("no chunk is resident"))
|
||||
}
|
||||
fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
fn context_shape(
|
||||
&mut self,
|
||||
_: CollisionBlock,
|
||||
_: BlockPos,
|
||||
_: &CollisionContext<'_>,
|
||||
) -> io::Result<VoxelShape> {
|
||||
Ok(VoxelShape::empty())
|
||||
}
|
||||
}
|
||||
|
||||
impl EntityMovementWorld for EmptyCollisions {
|
||||
type Error = io::Error;
|
||||
fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Vec<VoxelShape>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Option<VoxelShape>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Collision queries over any block lookup (test worlds without chunks):
|
||||
/// every chunk is present, nothing is suffocating, no entities collide.
|
||||
pub struct LookupCollisions<'w, 'r> {
|
||||
@@ -475,3 +544,70 @@ impl PowderSnowWorld for LookupCollisions<'_, '_> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod piston_noclip_tests {
|
||||
use super::*;
|
||||
use horizon_core::geometry::Direction;
|
||||
|
||||
#[test]
|
||||
fn piston_noclip_is_scoped_and_restored_after_unwind() {
|
||||
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
|
||||
with_piston_noclip(Direction::East, || {
|
||||
assert_eq!(
|
||||
PISTON_NOCLIP.with(|value| value.get()),
|
||||
Some(Direction::East)
|
||||
);
|
||||
let _ =
|
||||
std::panic::catch_unwind(|| with_piston_noclip(Direction::West, || panic!("test")));
|
||||
assert_eq!(
|
||||
PISTON_NOCLIP.with(|value| value.get()),
|
||||
Some(Direction::East)
|
||||
);
|
||||
});
|
||||
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod border_movement_tests {
|
||||
use super::*;
|
||||
|
||||
/// A border 10 wide around the origin: its wall's inner face is at x = 5.
|
||||
fn border() -> BorderGeometry {
|
||||
BorderGeometry::new([0.0, 0.0], 10.0, 29_999_984).expect("a border")
|
||||
}
|
||||
|
||||
/// `WorldBorder.isInsideCloseToBorder` and `WorldBorder.getCollisionShape`:
|
||||
/// a body inside close to the border collides with the border's walls.
|
||||
#[test]
|
||||
fn a_body_next_to_the_border_stops_at_its_wall() {
|
||||
// A body 0.6 wide, 0.4 blocks short of the wall, moved east into it.
|
||||
let bounds = Aabb::new(Vec3::new(4.0, 0.0, 0.0), Vec3::new(4.6, 1.8, 0.6));
|
||||
let border = border();
|
||||
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.3, 0.3]);
|
||||
let moved = collisions
|
||||
.resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false)
|
||||
.expect("the movement resolves");
|
||||
// The wall stops the body 0.4 blocks short of the whole move.
|
||||
assert!(
|
||||
(moved.x - 0.4).abs() < 1.0e-9,
|
||||
"the body was not stopped by the border: {moved:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Away from the border, the same body moves the whole way.
|
||||
#[test]
|
||||
fn a_body_away_from_the_border_moves_whole() {
|
||||
let bounds = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.6, 1.8, 0.6));
|
||||
let border = border();
|
||||
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [0.3, 0.3]);
|
||||
let moved = collisions
|
||||
.resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false)
|
||||
.expect("the movement resolves");
|
||||
assert!(
|
||||
(moved.x - 0.51).abs() < 1.0e-9,
|
||||
"the body did not move the whole way: {moved:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,9 +98,34 @@ pub enum BlockEntity {
|
||||
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).
|
||||
@@ -190,6 +215,9 @@ impl Skull {
|
||||
#[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 {
|
||||
@@ -198,10 +226,21 @@ impl SkullSkin {
|
||||
let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?);
|
||||
Some((url, location))
|
||||
});
|
||||
// A `Dynamic` profile (a name or id only) resolves through the
|
||||
// services when the skull is drawn.
|
||||
let dynamic = crate::skins::dynamic_nbt(profile);
|
||||
// DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
|
||||
let fallback = crate::skins::profile_uuid_nbt(profile)
|
||||
.or_else(|| {
|
||||
crate::skins::nbt_text(profile.get("name"))
|
||||
.map(|name| horizon_protocol::crypto::offline_uuid(&name))
|
||||
})
|
||||
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
|
||||
Self { download, fallback }
|
||||
Self {
|
||||
download,
|
||||
dynamic,
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
|
||||
@@ -366,6 +405,9 @@ impl BlockEntity {
|
||||
if is_skull(name) {
|
||||
return Some(Self::Skull(Skull::default()));
|
||||
}
|
||||
if name == "minecraft:end_gateway" {
|
||||
return Some(Self::EndGateway(EndGateway::default()));
|
||||
}
|
||||
if name == "minecraft:daylight_detector" {
|
||||
return Some(Self::DaylightDetector);
|
||||
}
|
||||
@@ -381,6 +423,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
if name == "minecraft:chiseled_bookshelf" {
|
||||
return Some(Self::ChiseledBookshelf(Box::default()));
|
||||
}
|
||||
let size = match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
|
||||
n if n.ends_with("copper_chest") => 27,
|
||||
@@ -418,8 +463,10 @@ impl BlockEntity {
|
||||
Self::Beehive(_) => "minecraft:beehive",
|
||||
Self::CommandBlock(_) => "minecraft:command_block",
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::EndGateway(_) => "minecraft:end_gateway",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -436,6 +483,7 @@ impl BlockEntity {
|
||||
| Self::Vault(_)
|
||||
| Self::Beehive(_)
|
||||
| Self::CommandBlock(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
@@ -443,6 +491,7 @@ impl BlockEntity {
|
||||
Self::Banner(_) => Vec::new(),
|
||||
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
|
||||
}
|
||||
}
|
||||
pub fn slots(&self) -> &[Option<ItemStack>] {
|
||||
@@ -456,9 +505,11 @@ impl BlockEntity {
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::ChiseledBookshelf(b) => &b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
@@ -480,9 +531,11 @@ impl BlockEntity {
|
||||
| Self::Lectern(_)
|
||||
| Self::Sculk(_)
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::ChiseledBookshelf(b) => &mut b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
|
||||
@@ -105,9 +105,13 @@ impl Report {
|
||||
}
|
||||
}
|
||||
|
||||
/// The release this binary is (`v0.2.3`); for a release from before 0.2, its tag and the client's
|
||||
/// crate version; for a build from source, that version alone.
|
||||
/// The release this binary is (`v0.2.3`); a nightly's label (`Nightly 2026-10-28 · 0c02067`); for a
|
||||
/// release from before 0.2, its tag and the client's crate version; for a build from source, that
|
||||
/// version alone.
|
||||
pub fn version() -> String {
|
||||
if let Some(label) = horizon_core::release::nightly_label() {
|
||||
return label;
|
||||
}
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
match (horizon_core::release::RELEASE, release_tag()) {
|
||||
(Some(_), Some(tag)) => tag,
|
||||
@@ -117,8 +121,12 @@ pub fn version() -> String {
|
||||
}
|
||||
|
||||
/// The release this binary came with: the version release.yml built in (docs/versioning.md), else
|
||||
/// the folder the launcher installed it in, named after the tag (releases from before 0.2).
|
||||
/// the folder the launcher installed it in, named after the tag (releases from before 0.2). A
|
||||
/// nightly is its version as the launcher installs it (`nightly-2026-10-28-0c02067`).
|
||||
pub fn release_tag() -> Option<String> {
|
||||
if let Some(nightly) = horizon_core::release::nightly_version() {
|
||||
return Some(nightly);
|
||||
}
|
||||
if let Some(version) = horizon_core::release::RELEASE {
|
||||
return Some(format!("v{version}"));
|
||||
}
|
||||
@@ -525,6 +533,11 @@ mod tests {
|
||||
#[test]
|
||||
fn a_release_reports_its_built_in_version() {
|
||||
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
|
||||
if let Some(label) = horizon_core::release::nightly_label() {
|
||||
assert_eq!(super::version(), label);
|
||||
assert_eq!(release_tag(), horizon_core::release::nightly_version());
|
||||
return;
|
||||
}
|
||||
match horizon_core::release::RELEASE {
|
||||
Some(version) => {
|
||||
assert_eq!(release_tag(), Some(format!("v{version}")));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! add/update with hidden effects, per-tick application of the periodic
|
||||
//! effects (regeneration, poison, wither, hunger, saturation, instant
|
||||
//! effects), and the derived flags the movement and damage code read.
|
||||
use crate::brewing;
|
||||
use horizon_client_core::potions;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EffectInstance {
|
||||
@@ -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,
|
||||
@@ -183,7 +184,7 @@ impl PlayerEffects {
|
||||
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
|
||||
let mut effects = Self::default();
|
||||
for (id, duration, amplifier, ambient, particles) in saved {
|
||||
if let Some(def) = brewing::effect(id) {
|
||||
if let Some(def) = potions::effect(id) {
|
||||
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
|
||||
e.ambient = *ambient;
|
||||
e.visible = *particles;
|
||||
@@ -306,10 +307,10 @@ impl PlayerEffects {
|
||||
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
|
||||
let mut lasting = Vec::new();
|
||||
let mut now = Vec::new();
|
||||
if let Some(def) = potion.and_then(brewing::potion) {
|
||||
if let Some(def) = potion.and_then(potions::potion) {
|
||||
for (id, duration, amp) in def.effects {
|
||||
let id = brewing::effect(id).map_or("speed", |e| e.id);
|
||||
if brewing::effect(id).is_some_and(|e| e.instant) {
|
||||
let id = potions::effect(id).map_or("speed", |e| e.id);
|
||||
if potions::effect(id).is_some_and(|e| e.instant) {
|
||||
now.extend(instant(id, *amp, 1.0));
|
||||
} else {
|
||||
// withScaledDuration: max(floor(d * scale), 1).
|
||||
|
||||
@@ -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 {
|
||||
@@ -771,9 +776,24 @@ pub struct ArmorStand {
|
||||
pub name_visible: bool,
|
||||
/// Game time of the last hit (the wobble and the second-hit window).
|
||||
pub last_hit: i64,
|
||||
/// `DATA_HEAD_POSE` to `DATA_RIGHT_LEG_POSE`: head, body, left arm, right
|
||||
/// arm, left leg, right leg, in degrees (`POSE_KEYS` order).
|
||||
pub pose: [[f32; 3]; 6],
|
||||
}
|
||||
impl ArmorStand {
|
||||
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
/// The `Pose` compound's keys.
|
||||
pub const POSE_KEYS: [&'static str; 6] =
|
||||
["Head", "Body", "LeftArm", "RightArm", "LeftLeg", "RightLeg"];
|
||||
/// `ArmorStand.DEFAULT_*_POSE`.
|
||||
pub const DEFAULT_POSE: [[f32; 3]; 6] = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 0.0],
|
||||
[-10.0, 0.0, -10.0],
|
||||
[-15.0, 0.0, 10.0],
|
||||
[-1.0, 0.0, -1.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
];
|
||||
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
fire_ticks: 0,
|
||||
@@ -792,6 +812,7 @@ impl ArmorStand {
|
||||
custom_name: None,
|
||||
name_visible: false,
|
||||
last_hit: i64::MIN / 2,
|
||||
pose: Self::DEFAULT_POSE,
|
||||
}
|
||||
}
|
||||
pub fn environment_tick(
|
||||
@@ -935,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,
|
||||
@@ -1031,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,
|
||||
|
||||
@@ -14,7 +14,8 @@ struct Globals {
|
||||
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
|
||||
end_portal_sprites: array<vec4<f32>, 2>,
|
||||
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
|
||||
// y: Nether cardinal lighting, z: how many dynamic_lights are set.
|
||||
// y: Nether cardinal lighting, z: how many dynamic_lights are set,
|
||||
// w: the glint scroll clock (`Glint::scroll_ticks`).
|
||||
time: vec4<f32>,
|
||||
crumbling_sprite: vec4<f32>,
|
||||
// Selection outline parameters (read by terrain.wgsl).
|
||||
@@ -27,6 +28,12 @@ struct Globals {
|
||||
@group(0) @binding(2) var atlas_sampler: sampler;
|
||||
@group(0) @binding(3) var lightmap: texture_2d<f32>;
|
||||
@group(0) @binding(4) var lightmap_sampler: sampler;
|
||||
// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler).
|
||||
@group(0) @binding(5) var glint_tex: texture_2d<f32>;
|
||||
@group(0) @binding(6) var glint_sampler: sampler;
|
||||
// The armour layers' GlintSampler: vanilla samples enchanted_glint_armor with
|
||||
// the same repeating, linearly filtered sampler as the item glint.
|
||||
@group(0) @binding(7) var glint_armor_tex: texture_2d<f32>;
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@@ -44,6 +51,11 @@ struct Out {
|
||||
// entity.vsh's lightMapColor, multiplied in after the overlay.
|
||||
@location(9) light_color: vec4<f32>,
|
||||
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
|
||||
// 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 {
|
||||
@@ -92,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);
|
||||
}
|
||||
@@ -103,18 +118,23 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
out.spherical = select(length(position), 0.0, overlay >= 2u && overlay <= 4u);
|
||||
out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u);
|
||||
out.overlay = overlay;
|
||||
out.glint_uv = vec2(0.0);
|
||||
if overlay == 4u { out.spherical = length(position); }
|
||||
return out;
|
||||
}
|
||||
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
|
||||
return entity_vertex(position, uv, overlay, color, light, normal);
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
|
||||
var out = entity_vertex(position, uv, overlay, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
return out;
|
||||
}
|
||||
// Retained bolt-local geometry, translated using the same f64 camera
|
||||
// subtraction as the former CPU path before adding each local f32 position.
|
||||
// The bolt offset is instance attribute 7: 6 is the entity vertex's glint UV.
|
||||
@vertex fn vs_lightning(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>,
|
||||
@location(6) offset: vec3<f32>) -> Out {
|
||||
@location(7) offset: vec3<f32>) -> Out {
|
||||
return entity_vertex(offset + position, uv, overlay, color, light, normal);
|
||||
}
|
||||
struct CloudInfo {
|
||||
@@ -166,6 +186,27 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
if d >= end { return 1.0; }
|
||||
return (d - start) / (end - start);
|
||||
}
|
||||
// TextureTransform.setupGlintTexturing(0.16), ARMOR_ENTITY_GLINT_TEXTURING.
|
||||
// globals.time.w is (Util.getMillis() * glintSpeed * 8) modulo 330000, the
|
||||
// common period of both offsets.
|
||||
fn armor_glint_uv(model: vec2<f32>) -> vec2<f32> {
|
||||
let ticks = globals.time.w;
|
||||
let offset0 = (ticks % 110000.0) / 110000.0;
|
||||
let offset1 = (ticks % 30000.0) / 30000.0;
|
||||
let angle = 0.17453292;
|
||||
let c = cos(angle);
|
||||
let s = sin(angle);
|
||||
let scaled = model * 0.16;
|
||||
return vec2(scaled.x * c - scaled.y * s - offset0,
|
||||
scaled.x * s + scaled.y * c + offset1);
|
||||
}
|
||||
// RenderPipelines.GLINT: the source colour is scaled by itself, so `GlintAlpha`
|
||||
// is squared along with the sample.
|
||||
fn glint_over(color: vec4<f32>, glint: vec4<f32>) -> vec4<f32> {
|
||||
let alpha = globals.fog_ends.w;
|
||||
let g = alpha * glint;
|
||||
return vec4(color.rgb + g.rgb * g.rgb, max(color.a, alpha));
|
||||
}
|
||||
// CRUMBLING_UV_BEGIN
|
||||
fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>, dimensions: vec2<u32>) -> vec2<f32> {
|
||||
let size = vec2<f32>(dimensions);
|
||||
@@ -198,27 +239,35 @@ 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);
|
||||
color *= in.light_color;
|
||||
if in.overlay == 7u || in.overlay == 8u || in.overlay == 33u {
|
||||
// Enchantment glint (overlay 7, 8 with the hurt tint): the purple
|
||||
// enchanted_glint sheen drifting diagonally over the item's shape.
|
||||
let px = in.uv * vec2<f32>(textureDimensions(atlas));
|
||||
let t = globals.misc.y * 0.05;
|
||||
let band = sin((px.x + px.y) * 0.9 - t * 6.0) * 0.5 + 0.5;
|
||||
let band2 = sin((px.x - px.y) * 0.6 + t * 4.0) * 0.5 + 0.5;
|
||||
let shimmer = pow(band * 0.6 + band2 * 0.4, 2.0);
|
||||
let glint = vec3(0.50, 0.25, 0.80) * (0.25 + 0.75 * shimmer);
|
||||
color = vec4(min(color.rgb + glint * 0.6, vec3(1.0)), color.a);
|
||||
// 33: a translucent item with the glint.
|
||||
// Enchantment glint: item.fsh's GLINT path, sampled from
|
||||
// enchanted_glint_item at the `GLINT_TEXTURING` UV.
|
||||
color = glint_over(color, textureSample(glint_tex, glint_sampler, in.glint_uv));
|
||||
} else if in.overlay == 13u {
|
||||
// Enchanted armour: enchanted_glint_armor at the scrolling model UV.
|
||||
color = glint_over(
|
||||
color,
|
||||
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
|
||||
);
|
||||
}
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
|
||||
// rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no
|
||||
// normal, so no cardinal light) wherever the texture is not fully clear.
|
||||
@fragment fn fs_outline(in: Out) -> @location(0) vec4<f32> {
|
||||
if textureSample(atlas, atlas_sampler, in.uv).a == 0.0 { discard; }
|
||||
return vec4(in.color.rgb, 1.0);
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
}
|
||||
@@ -237,6 +286,20 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
}
|
||||
|
||||
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
|
||||
// the UV and repeated within itself, times the layer colour 0xFF808080, unlit; the pipeline adds
|
||||
// it to the frame. The light bytes carry the sprite's origin and the colour bytes its size, both
|
||||
// as breaking::stage_light packs them.
|
||||
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
|
||||
let bytes = round(in.color * 255.0);
|
||||
let size = vec2(bytes.x + bytes.y * 256.0, bytes.z + bytes.w * 256.0) / 65535.0;
|
||||
let color = textureSample(atlas, atlas_sampler, crumbling_uv(in.uv, in.wrap, size, textureDimensions(atlas)));
|
||||
if color.a < 0.1 { discard; }
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb * (128.0 / 255.0), globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
|
||||
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
|
||||
@fragment fn fs_water_mask() -> @location(0) vec4<f32> {
|
||||
return vec4(0.0);
|
||||
|
||||
@@ -186,6 +186,8 @@ mod tests {
|
||||
color: u32::MAX,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
};
|
||||
let prior = vertex([0.0; 3]);
|
||||
let body = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
|
||||
|
||||
@@ -339,6 +339,18 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
|
||||
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
|
||||
}
|
||||
"minecraft:panda" => {
|
||||
use crate::mobs::panda::GENES;
|
||||
for (key, gene) in [
|
||||
("MainGene", m.extra.variant),
|
||||
("HiddenGene", m.extra.variant2),
|
||||
] {
|
||||
map.insert(
|
||||
key.into(),
|
||||
string(GENES[usize::from(gene).min(GENES.len() - 1)]),
|
||||
);
|
||||
}
|
||||
}
|
||||
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
|
||||
// Only Piglin.addAdditionalSaveData writes CannotHunt.
|
||||
if id == "minecraft:piglin_brute" {
|
||||
@@ -503,6 +515,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)];
|
||||
@@ -540,7 +558,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()),
|
||||
)]),
|
||||
)]),
|
||||
)]),
|
||||
);
|
||||
@@ -577,6 +598,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
map.insert("anger".into(), Tag::compound(Vec::<(&str, Tag)>::new()));
|
||||
}
|
||||
}
|
||||
// Armadillo.addAdditionalSaveData: the time left to the next scute.
|
||||
"minecraft:armadillo" => {
|
||||
map.insert("scute_time".into(), Tag::Int(m.extra.egg_time));
|
||||
}
|
||||
"minecraft:ravager" => {
|
||||
map.insert("AttackTick".into(), Tag::Int(m.extra.counter));
|
||||
map.insert("StunTick".into(), Tag::Int(m.extra.counter2));
|
||||
@@ -590,6 +615,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));
|
||||
@@ -696,6 +724,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!(
|
||||
@@ -1090,6 +1129,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Panda {
|
||||
let gene = |key| text(tag.get(key)).map_or(0, |n| crate::mobs::panda::gene_from_name(&n));
|
||||
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"));
|
||||
@@ -1097,6 +1146,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);
|
||||
}
|
||||
@@ -1127,11 +1182,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);
|
||||
@@ -1198,11 +1256,20 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
st.vibration = crate::sculk::Data::from_nbt(listener);
|
||||
}
|
||||
}
|
||||
// Armadillo.readAdditionalSaveData: keeps the constructor's time when absent.
|
||||
if m.species() == crate::mobs::Species::Armadillo
|
||||
&& let Some(t) = number(tag.get("scute_time"))
|
||||
{
|
||||
m.extra.egg_time = t as i32;
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Ravager {
|
||||
m.extra.counter = number(tag.get("AttackTick")).unwrap_or(0) as i32;
|
||||
m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32;
|
||||
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
|
||||
@@ -1293,6 +1360,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> {
|
||||
@@ -1304,6 +1376,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),
|
||||
@@ -1822,6 +1895,15 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
}
|
||||
put(&mut tag, "Health", Tag::Float(a.health.to_bits()));
|
||||
put(&mut tag, "Fire", Tag::Short(a.fire_ticks as i16));
|
||||
// ArmorStand.addAdditionalSaveData: the `Pose` parts that are not default.
|
||||
let pose: Vec<(&str, Tag)> = ArmorStand::POSE_KEYS
|
||||
.into_iter()
|
||||
.zip(a.pose)
|
||||
.zip(ArmorStand::DEFAULT_POSE)
|
||||
.filter(|((_, rotation), default)| rotation != default)
|
||||
.map(|((key, rotation), _)| (key, floats(&rotation)))
|
||||
.collect();
|
||||
put(&mut tag, "Pose", Tag::compound(pose));
|
||||
// LivingEntity.addAdditionalSaveData and ArmorStand's own fields.
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
for (key, value) in [
|
||||
@@ -1861,6 +1943,19 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
|
||||
a.invisible = flag(tag, "Invisible");
|
||||
a.marker = flag(tag, "Marker");
|
||||
a.on_ground = flag(tag, "OnGround");
|
||||
// ArmorStand.readAdditionalSaveData: each part of `Pose` that is a list of
|
||||
// three numbers, the others staying at their defaults.
|
||||
if let Some(pose) = tag.get("Pose") {
|
||||
for (rotation, key) in a.pose.iter_mut().zip(ArmorStand::POSE_KEYS) {
|
||||
if let Some(Tag::List(values)) = pose.get(key)
|
||||
&& values.len() == 3
|
||||
{
|
||||
for (r, v) in rotation.iter_mut().zip(values) {
|
||||
*r = float(Some(v)).unwrap_or(0.0) as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(a)
|
||||
}
|
||||
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_chest_raft`...):
|
||||
@@ -2191,6 +2286,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"),
|
||||
|
||||
@@ -48,6 +48,8 @@ pub fn flames(
|
||||
color: 0xffff_ffff,
|
||||
light: 0xf000 | (u32::from(scene_light >> 4) * 16),
|
||||
normal: [0; 4],
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
remaining -= 0.45;
|
||||
|
||||
@@ -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))
|
||||
|
||||
+641
-227
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@ use horizon_client::{
|
||||
text::{self, Style, Text},
|
||||
toasts::Slide,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, VecDeque},
|
||||
@@ -21,6 +22,9 @@ pub(super) struct Toast {
|
||||
frame: Frame,
|
||||
title: Text,
|
||||
slide: Slide,
|
||||
/// `ToastInstance.firstSlotIndex`: the 32-pixel slot it stacks in once it
|
||||
/// leaves the queue.
|
||||
slot: Option<usize>,
|
||||
/// A `RecipeToast`: the crafting station and result of each display
|
||||
/// unlocked while it lasts (`recipeItems`); empty for an advancement.
|
||||
recipes: Vec<(String, ItemStack)>,
|
||||
@@ -36,6 +40,7 @@ impl Toast {
|
||||
frame: Frame::Task,
|
||||
title: Text::literal(""),
|
||||
slide: Slide::default(),
|
||||
slot: None,
|
||||
recipes: items,
|
||||
changed: true,
|
||||
last_changed: 0,
|
||||
@@ -82,6 +87,8 @@ pub(super) struct AdvState {
|
||||
pub(super) quiet: bool,
|
||||
/// The inventory last seen by `inventory_changed`.
|
||||
pub(super) last_inventory: u64,
|
||||
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
|
||||
pub(super) levitation: Option<([f64; 3], u64)>,
|
||||
/// While the game acts for a network player (`as_guest`): the
|
||||
/// advancements whose progress changed (`progressChanged`), the recipes
|
||||
/// unlocked and the announcements, for their client and everyone's.
|
||||
@@ -180,6 +187,14 @@ impl adv::World for View<'_, '_> {
|
||||
"block" => items.block_tags.get(tag).is_some_and(|t| t.contains(id)),
|
||||
"entity_type" => self.0.res.mob_data().entity_has(tag, id),
|
||||
"worldgen/biome" => self.0.res.mob_data().biome_has(tag, id),
|
||||
"damage_type" => self
|
||||
.0
|
||||
.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.damage_tags
|
||||
.get(tag)
|
||||
.is_some_and(|t| t.contains(id)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -524,6 +539,7 @@ impl Game<'_> {
|
||||
frame: d.frame,
|
||||
title: text_of(&d.title),
|
||||
slide: Slide::default(),
|
||||
slot: None,
|
||||
recipes: Vec::new(),
|
||||
changed: false,
|
||||
last_changed: 0,
|
||||
@@ -647,6 +663,28 @@ impl Game<'_> {
|
||||
};
|
||||
adv::conditions(&c["player"], &ctx, w)
|
||||
});
|
||||
// ServerPlayer.onEffectAdded and onEffectsRemoved keep the start of a
|
||||
// levitation; tick fires LEVITATION while there is one.
|
||||
let levitating = self
|
||||
.potions
|
||||
.effects
|
||||
.list
|
||||
.iter()
|
||||
.any(|e| e.id == "levitation");
|
||||
match (levitating, self.adv.levitation) {
|
||||
(true, None) => {
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.adv.levitation = at.map(|at| (at, self.ticks));
|
||||
}
|
||||
(false, Some(_)) => self.adv.levitation = None,
|
||||
_ => {}
|
||||
}
|
||||
if let Some((start, since)) = self.adv.levitation {
|
||||
self.trigger_levitation(start, (self.ticks - since) as i64);
|
||||
}
|
||||
if self.ticks.is_multiple_of(20) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:location", |c, w| {
|
||||
@@ -681,6 +719,136 @@ 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) {
|
||||
let me = self.player_entity();
|
||||
self.trigger("minecraft:levitation", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& (c["distance"].is_null() || adv::distance(&c["distance"], start, me.position))
|
||||
&& adv::range_i(&c["duration"], duration)
|
||||
});
|
||||
}
|
||||
/// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use, with
|
||||
/// what the player looks at (`PlayerPredicate.lookingAt`).
|
||||
pub(super) fn trigger_using_item(&mut self, stack: &ItemStack) {
|
||||
if self.remote.is_some() || self.no_player_here() {
|
||||
return;
|
||||
}
|
||||
let mut me = self.player_entity();
|
||||
me.looking_at = self.looking_at_entity().map(Box::new);
|
||||
let it = item_view(stack);
|
||||
self.trigger("minecraft:using_item", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w) && adv::item(&c["item"], &it, w)
|
||||
});
|
||||
}
|
||||
/// The mob a 100 block ray from the eyes meets first, if no block is in
|
||||
/// the way (`ProjectileUtil.getEntityHitResult` and `hasLineOfSight`).
|
||||
fn looking_at_entity(&self) -> Option<EntityView> {
|
||||
let eye = self.eye_position()?;
|
||||
let dir = super::view_vector(self.yaw, self.pitch);
|
||||
let (m, _) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.filter_map(|m| Some((m, super::ray_box(eye, dir, 100.0, m.bounds())?)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
// `LivingEntity.hasLineOfSight`: eye to the target's eye height,
|
||||
// `ClipContext.Block.COLLIDER`, no fluids, within 128 blocks.
|
||||
let from = Vec3::new(eye[0], eye[1], eye[2]);
|
||||
let to = Vec3::new(m.position.x, m.eye_y(), m.position.z);
|
||||
let world = self.mob_world_base(&[]);
|
||||
let clear = to.distance(from) <= 128.0 && world.clear_between(from, to, false);
|
||||
clear.then(|| self.mob_entity(m))
|
||||
}
|
||||
/// `PlayerHurtEntityTrigger`: the player, the damage and the victim.
|
||||
pub(super) fn trigger_player_hurt_entity(
|
||||
&mut self,
|
||||
victim: &EntityView,
|
||||
damage: &adv::DamageView,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let (victim, damage) = (victim.clone(), damage.clone());
|
||||
self.trigger("minecraft:player_hurt_entity", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let vctx = adv::Entities {
|
||||
this: Some(&victim),
|
||||
..pctx
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::damage(&c["damage"], &damage, w, me.position)
|
||||
&& adv::conditions(&c["entity"], &vctx, w)
|
||||
});
|
||||
}
|
||||
/// The player's hit on `victim` as `LivingEntity.hurtServer` reports it:
|
||||
/// `kind` damage of `amount` from `direct` (the player's own hand when
|
||||
/// `None`), nothing blocked.
|
||||
pub(super) fn player_hit(
|
||||
&mut self,
|
||||
victim: &EntityView,
|
||||
kind: &str,
|
||||
direct: Option<EntityView>,
|
||||
amount: f32,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
self.trigger_player_hurt_entity(
|
||||
victim,
|
||||
&adv::DamageView {
|
||||
kind: kind.to_owned(),
|
||||
is_direct: direct.is_none(),
|
||||
direct: direct.or_else(|| Some(me.clone())),
|
||||
source: Some(me),
|
||||
original: amount,
|
||||
actual: amount,
|
||||
blocked: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
/// `EntityHurtPlayerTrigger`: the player and the damage it took.
|
||||
pub(super) fn trigger_entity_hurt_player(&mut self, damage: &adv::DamageView) {
|
||||
let me = self.player_entity();
|
||||
let damage = damage.clone();
|
||||
self.trigger("minecraft:entity_hurt_player", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::damage(&c["damage"], &damage, w, me.position)
|
||||
});
|
||||
}
|
||||
/// A player condition plus one item (`item` key).
|
||||
pub(super) fn trigger_item(&mut self, name: &str, stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
@@ -708,6 +876,51 @@ 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) {
|
||||
let me = self.player_entity();
|
||||
let potion = stack.potion().map(str::to_owned);
|
||||
self.trigger("minecraft:brewed_potion", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::potions(&c["potion"], potion.as_deref(), w)
|
||||
});
|
||||
}
|
||||
/// `TradeTrigger`: the villager and the item the trade gave.
|
||||
pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
@@ -729,6 +942,35 @@ impl Game<'_> {
|
||||
&& adv::item(&c["item"], &it, w)
|
||||
});
|
||||
}
|
||||
/// `FishingRodHookedTrigger`: the rod, what the hook is in (or the hook)
|
||||
/// and the items it caught.
|
||||
pub(super) fn trigger_fishing_rod_hooked(
|
||||
&mut self,
|
||||
rod: &ItemStack,
|
||||
hooked: &EntityView,
|
||||
items: &[ItemStack],
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let rod = item_view(rod);
|
||||
let hooked = hooked.clone();
|
||||
let items: Vec<ItemView> = items.iter().map(item_view).collect();
|
||||
self.trigger("minecraft:fishing_rod_hooked", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let ectx = adv::Entities {
|
||||
this: Some(&hooked),
|
||||
..pctx
|
||||
};
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::item(&c["rod"], &rod, w)
|
||||
&& adv::conditions(&c["entity"], &ectx, w)
|
||||
&& (c["item"].is_null() || items.iter().any(|i| adv::item(&c["item"], i, w)))
|
||||
});
|
||||
}
|
||||
/// `EnterBlockTrigger`: the block, its state properties and the player.
|
||||
pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) {
|
||||
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
|
||||
@@ -827,58 +1069,105 @@ impl Game<'_> {
|
||||
c["from"].as_str().is_none_or(|f| f == from) && c["to"].as_str().is_none_or(|t| t == to)
|
||||
});
|
||||
}
|
||||
/// `ToastManager` with the `AdvancementToast` and the `RecipeToast`,
|
||||
/// one at a time.
|
||||
pub(super) fn draw_toasts(&mut self, batch: &mut HudBatch) {
|
||||
/// `ToastManager.SLOT_COUNT`.
|
||||
pub(super) const TOAST_SLOTS: usize = 5;
|
||||
/// `ToastManager.update` and `extractRenderState` for the `AdvancementToast`s and
|
||||
/// `RecipeToast`s: up to five stack in 32-pixel slots, the rest queue. `hidden` (F1)
|
||||
/// keeps them timing out without drawing them.
|
||||
pub(super) fn draw_toasts(&mut self, batch: &mut HudBatch, hidden: bool) {
|
||||
let multiplier = f64::from(self.settings.notification_display_time);
|
||||
let Some(t) = self.adv.toasts.front_mut() else {
|
||||
return;
|
||||
};
|
||||
let first = t.slide.origin.is_none();
|
||||
let origin = *t.slide.origin.get_or_insert_with(Instant::now);
|
||||
let now = origin.elapsed().as_millis() as i64;
|
||||
let mut slide = std::mem::take(&mut t.slide);
|
||||
let (sound, finished) = slide.update(now, |fully| t.wants_show(fully, multiplier));
|
||||
t.slide = slide;
|
||||
if finished {
|
||||
self.adv.toasts.pop_front();
|
||||
let mut sound_played = false;
|
||||
let mut i = 0;
|
||||
while i < self.adv.toasts.len() {
|
||||
let t = &mut self.adv.toasts[i];
|
||||
if t.slot.is_none() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let origin = *t.slide.origin.get_or_insert_with(Instant::now);
|
||||
let now = origin.elapsed().as_millis() as i64;
|
||||
let mut slide = std::mem::take(&mut t.slide);
|
||||
let (sound, finished) = slide.update(now, |fully| t.wants_show(fully, multiplier));
|
||||
t.slide = slide;
|
||||
if finished {
|
||||
self.adv.toasts.remove(i);
|
||||
continue;
|
||||
}
|
||||
// Only the first visibility change of an update plays its sound.
|
||||
if let Some(show) = sound
|
||||
&& !std::mem::replace(&mut sound_played, true)
|
||||
{
|
||||
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
|
||||
self.audio
|
||||
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// The queued toasts take the free slots in order; a challenge's own sound
|
||||
// plays once as it leaves the queue.
|
||||
let mut used = self.toast_slots_used();
|
||||
let mut challenge_sound = false;
|
||||
for t in &mut self.adv.toasts {
|
||||
if t.slot.is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some(slot) = (0..Self::TOAST_SLOTS).find(|s| used & (1 << s) == 0) else {
|
||||
break;
|
||||
};
|
||||
used |= 1 << slot;
|
||||
t.slot = Some(slot);
|
||||
challenge_sound |= !t.is_recipe() && t.frame == Frame::Challenge;
|
||||
}
|
||||
if challenge_sound {
|
||||
self.audio
|
||||
.play("minecraft:ui.toast.challenge_complete", None, 1.0, 1.0);
|
||||
}
|
||||
if hidden {
|
||||
return;
|
||||
}
|
||||
for i in 0..self.adv.toasts.len() {
|
||||
if let Some(slot) = self.adv.toasts[i].slot {
|
||||
self.draw_toast(batch, i, 32.0 * slot as f32, multiplier);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The slots held by shown advancement, recipe and pack toasts, as bits.
|
||||
pub(super) fn toast_slots_used(&self) -> u32 {
|
||||
let packs = self.server_packs.toast_slots();
|
||||
self.adv
|
||||
.toasts
|
||||
.iter()
|
||||
.filter_map(|t| t.slot)
|
||||
.chain(packs)
|
||||
.fold(0, |used, slot| used | 1 << slot)
|
||||
}
|
||||
/// The `AdvancementToast` or `RecipeToast` at `index` in its slot at `y`.
|
||||
fn draw_toast(&mut self, batch: &mut HudBatch, index: usize, y: f32, multiplier: f64) {
|
||||
let t = &self.adv.toasts[index];
|
||||
let x = batch.width - 160.0 * t.slide.portion;
|
||||
let fully_for = t.slide.fully_for;
|
||||
let (title, frame, icon) = (t.title.clone(), t.frame, t.icon.clone());
|
||||
let recipe = t
|
||||
.is_recipe()
|
||||
.then(|| t.recipes[t.displayed_recipe(fully_for, multiplier)].clone());
|
||||
// ToastManager.update: the sound of the new visibility, and a
|
||||
// challenge's own sound as it leaves the queue.
|
||||
if let Some(show) = sound {
|
||||
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
|
||||
self.audio
|
||||
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
|
||||
}
|
||||
if first && recipe.is_none() && frame == Frame::Challenge {
|
||||
self.audio
|
||||
.play("minecraft:ui.toast.challenge_complete", None, 1.0, 1.0);
|
||||
}
|
||||
if let Some((station, item)) = recipe {
|
||||
// RecipeToast: black description under a purple title, the
|
||||
// station at 0.6 scale in the corner over the cycling result.
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.sprite(atlas, "gui/sprites/toast/recipe", x, 0.0);
|
||||
batch.sprite(atlas, "gui/sprites/toast/recipe", x, y);
|
||||
let title = self.translate("recipe.toast.title", "New Recipe(s) Unlocked!");
|
||||
let description = self.translate("recipe.toast.description", "Check your recipe book");
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.text(atlas, &title, x + 30.0, 7.0, 0xFF50_0050, false);
|
||||
batch.text(atlas, &description, x + 30.0, 18.0, 0xFF00_0000, false);
|
||||
batch.text(atlas, &title, x + 30.0, y + 7.0, 0xFF50_0050, false);
|
||||
batch.text(atlas, &description, x + 30.0, y + 18.0, 0xFF00_0000, false);
|
||||
let start = batch.vertices.len();
|
||||
self.draw_item_model(batch, &ItemStack::new(&station, 1), x + 3.0, 3.0);
|
||||
batch.scale_since(start, x, 0.0, 0.6, 0.6);
|
||||
self.draw_item_model(batch, &item, x + 8.0, 8.0);
|
||||
self.draw_item_model(batch, &ItemStack::new(&station, 1), x + 3.0, y + 3.0);
|
||||
batch.scale_since(start, x, y, 0.6, 0.6);
|
||||
self.draw_item_model(batch, &item, x + 8.0, y + 8.0);
|
||||
return;
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
batch.sprite(atlas, "gui/sprites/toast/advancement", x, 0.0);
|
||||
batch.sprite(atlas, "gui/sprites/toast/advancement", x, y);
|
||||
let (key, color) = match frame {
|
||||
Frame::Task => ("advancements.toast.task", 0xFF00_FFFF),
|
||||
Frame::Goal => ("advancements.toast.goal", 0xFF00_FFFF),
|
||||
@@ -888,12 +1177,12 @@ impl Game<'_> {
|
||||
atlas,
|
||||
&Text::translate(key, &[]),
|
||||
x + 30.0,
|
||||
7.0,
|
||||
y + 7.0,
|
||||
color,
|
||||
false,
|
||||
);
|
||||
batch.text_styled(atlas, &title, x + 30.0, 18.0, u32::MAX, false);
|
||||
self.draw_item(batch, &ItemStack::new(&icon, 1), x + 8.0, 8.0);
|
||||
batch.text_styled(atlas, &title, x + 30.0, y + 18.0, u32::MAX, false);
|
||||
self.draw_item(batch, &ItemStack::new(&icon, 1), x + 8.0, y + 8.0);
|
||||
}
|
||||
}
|
||||
/// `Advancement.name`: the bracketed title in the frame's colour, with the
|
||||
@@ -1074,6 +1363,7 @@ impl Game<'_> {
|
||||
frame: d.frame,
|
||||
title: text_of(&d.title),
|
||||
slide: Slide::default(),
|
||||
slot: None,
|
||||
recipes: Vec::new(),
|
||||
changed: false,
|
||||
last_changed: 0,
|
||||
|
||||
@@ -568,6 +568,22 @@ impl Game<'_> {
|
||||
"generate_structures": options.structures,
|
||||
"bonus_chest": options.bonus_chest(),
|
||||
}),
|
||||
Mode::CreateFlat {
|
||||
name,
|
||||
seed,
|
||||
options,
|
||||
flat,
|
||||
..
|
||||
} => json!({
|
||||
"mode": "customize-flat",
|
||||
"fields": { "name": name, "seed": seed },
|
||||
"superflat": options.flat,
|
||||
"biome": flat.settings().biome,
|
||||
"layers": flat.settings().layers.iter().map(|layer| json!({
|
||||
"block": layer.block,
|
||||
"height": layer.height,
|
||||
})).collect::<Vec<_>>(),
|
||||
}),
|
||||
Mode::Edit {
|
||||
world,
|
||||
name,
|
||||
|
||||
@@ -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,
|
||||
@@ -161,17 +192,20 @@ impl Game<'_> {
|
||||
}
|
||||
// Allay.duplicateAllay: entity event 18, three spawnHeartParticle.
|
||||
let id = self.mobs[index].id;
|
||||
self.particles_around_mob(id, "heart", horizon_client::particles::Burst::ALLAY);
|
||||
self.mob_event_particles(id, 18);
|
||||
self.audio
|
||||
.play("minecraft:block.amethyst_block.chime", Some(at), 2.0, 1.0);
|
||||
self.consume_held();
|
||||
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 {
|
||||
|
||||
@@ -627,21 +627,8 @@ impl Game<'_> {
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
|
||||
let mut poses = Poses::new(model);
|
||||
// ArmorStand.DEFAULT_*_POSE and ArmorStandModel part visibility.
|
||||
let deg = |v: f32| v.to_radians();
|
||||
for (name, rot) in [
|
||||
("left_arm", [-10.0, 0.0, -10.0]),
|
||||
("right_arm", [-15.0, 0.0, 10.0]),
|
||||
("left_leg", [-1.0, 0.0, -1.0]),
|
||||
("right_leg", [1.0, 0.0, 1.0]),
|
||||
] {
|
||||
if poses.has(name) {
|
||||
let pose = poses.get(name);
|
||||
pose[3] = deg(rot[0]);
|
||||
pose[4] = deg(rot[1]);
|
||||
pose[5] = deg(rot[2]);
|
||||
}
|
||||
}
|
||||
// ArmorStandModel.setupAnim: the stand's pose and part visibility.
|
||||
apply_pose(&mut poses, &a.pose);
|
||||
// Item layers pose from the hidden arms too (`visible` only skips drawing).
|
||||
let posed = poses.clone();
|
||||
let mut hide = |name: &str| {
|
||||
@@ -711,7 +698,23 @@ impl Game<'_> {
|
||||
let color = undyed.map_or(0xFFFF_FFFF, |c| {
|
||||
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
|
||||
});
|
||||
let layer_poses = Poses::new(part);
|
||||
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
|
||||
// when `stack.hasFoil()`, as on a living entity.
|
||||
let item_glint = self
|
||||
.res
|
||||
.items()
|
||||
.enchantments
|
||||
.props
|
||||
.get(&stack.item)
|
||||
.is_some_and(|p| p.glint);
|
||||
let overlay = if stack.has_foil(item_glint) {
|
||||
horizon_client::mobs::render::ARMOR_GLINT
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// ArmorStandArmorModel.setupAnim: the same pose as the stand.
|
||||
let mut layer_poses = Poses::new(part);
|
||||
apply_pose(&mut layer_poses, &a.pose);
|
||||
horizon_client::mobs::render::draw(
|
||||
part,
|
||||
base,
|
||||
@@ -719,7 +722,7 @@ impl Game<'_> {
|
||||
uv,
|
||||
color,
|
||||
packed,
|
||||
0,
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
// EquipmentLayerRenderer: the trim layer over the armour.
|
||||
@@ -734,7 +737,7 @@ impl Game<'_> {
|
||||
trim,
|
||||
0xFFFF_FFFF,
|
||||
packed,
|
||||
0,
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
}
|
||||
@@ -743,6 +746,33 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The `Pose` rotations on the parts of an armor stand model or its armor
|
||||
/// (`ArmorStandArmorModel.setupAnim`); the body also turns the sticks.
|
||||
fn apply_pose(poses: &mut Poses, pose: &[[f32; 3]; 6]) {
|
||||
let parts: [&[&str]; 6] = [
|
||||
&["head"],
|
||||
&[
|
||||
"body",
|
||||
"right_body_stick",
|
||||
"left_body_stick",
|
||||
"shoulder_stick",
|
||||
],
|
||||
&["left_arm"],
|
||||
&["right_arm"],
|
||||
&["left_leg"],
|
||||
&["right_leg"],
|
||||
];
|
||||
for (names, rotation) in parts.into_iter().zip(pose) {
|
||||
for name in names {
|
||||
if poses.has(name) {
|
||||
let part = poses.get(name);
|
||||
part[3] = rotation[0].to_radians();
|
||||
part[4] = rotation[1].to_radians();
|
||||
part[5] = rotation[2].to_radians();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn overlaps(a: Aabb, b: Aabb) -> bool {
|
||||
let (a0, a1, b0, b1) = (
|
||||
a.min_position(),
|
||||
@@ -877,6 +907,7 @@ impl Game<'_> {
|
||||
lights: Some(horizon_client::block_entity_render::diffuse_lights(
|
||||
self.dim == super::Dim::Nether,
|
||||
)),
|
||||
glint: self.glint(),
|
||||
};
|
||||
let profile = stack
|
||||
.extra
|
||||
|
||||
@@ -164,11 +164,35 @@ impl Game<'_> {
|
||||
let (was, is) = (previous > 0, b.levels > 0);
|
||||
if !was && is {
|
||||
self.beacon_sound([x, y, z], "minecraft:block.beacon.activate");
|
||||
let levels = b.levels;
|
||||
self.for_each_player(|g| g.beacon_constructed([x, y, z], levels));
|
||||
} else if was && !is {
|
||||
self.beacon_sound([x, y, z], "minecraft:block.beacon.deactivate");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `BeaconBlockEntity.tick` after activating: `CONSTRUCT_BEACON` for a
|
||||
/// player in the box `AABB(x, y, z, x, y - 4, z).inflate(10, 5, 10)`.
|
||||
fn beacon_constructed(&mut self, p: [i32; 3], levels: i32) {
|
||||
let Some(bb) = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|pl| pl.dimensions.bounds(pl.position))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let [x, y, z] = p.map(f64::from);
|
||||
if lo.x < x + 10.0
|
||||
&& hi.x > x - 10.0
|
||||
&& lo.y < y + 5.0
|
||||
&& hi.y > y - 9.0
|
||||
&& lo.z < z + 10.0
|
||||
&& hi.z > z - 10.0
|
||||
{
|
||||
self.trigger_construct_beacon(levels);
|
||||
}
|
||||
}
|
||||
pub(super) fn beacon_sound(&mut self, p: [i32; 3], sound: &str) {
|
||||
let at = [
|
||||
f64::from(p[0]) + 0.5,
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
//! player breaks the hive or fire reaches it.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
beehive::{self, Beehive, HiveHost, Release},
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
mobs::{Mob, Who, bee},
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -194,19 +196,139 @@ impl Game<'_> {
|
||||
}
|
||||
self.anger_nearby_bees(at);
|
||||
}
|
||||
/// `BeehiveBlock.useItemOn` with `held` in the acting hand: shears take
|
||||
/// the honeycombs and a glass bottle a honey bottle from a full hive
|
||||
/// (honey level 5), then the honey level resets and, unless a campfire
|
||||
/// smokes the hive, the bees come out angry. True when it took the click.
|
||||
pub(super) fn use_beehive(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> Result<bool> {
|
||||
let Some(held) = held else { return Ok(false) };
|
||||
let shears = &*held.item == "minecraft:shears";
|
||||
if !shears && &*held.item != "minecraft:glass_bottle" {
|
||||
return Ok(false);
|
||||
}
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !matches!(
|
||||
&*self.res.facts().get(state).name,
|
||||
"minecraft:beehive" | "minecraft:bee_nest"
|
||||
) {
|
||||
return Ok(false);
|
||||
}
|
||||
let prop = |key: &str| {
|
||||
self.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get(key).cloned())
|
||||
};
|
||||
if prop("honey_level").and_then(|v| v.parse::<i32>().ok()) != Some(5) {
|
||||
return Ok(false);
|
||||
}
|
||||
let facing = beehive::facing_step(prop("facing").as_deref());
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
if shears {
|
||||
// dropHoneycomb (harvest/beehive), the shear sound, hurtAndBreak.
|
||||
for stack in self.sequence_loot(
|
||||
"minecraft:harvest/beehive",
|
||||
"minecraft:harvest/beehive",
|
||||
Some(held),
|
||||
) {
|
||||
self.pop_resource(pos, stack);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:block.beehive.shear", at, 1.0, 1.0);
|
||||
self.damage_held(1);
|
||||
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
|
||||
} else {
|
||||
// shrink(1), then the honey bottle in the hand, the inventory or
|
||||
// on the ground.
|
||||
self.audio.play("minecraft:item.bottle.fill", at, 1.0, 1.0);
|
||||
let cell = self.held_mut();
|
||||
if let Some(s) = cell.as_mut() {
|
||||
s.count -= 1;
|
||||
if s.count == 0 {
|
||||
*cell = None;
|
||||
}
|
||||
}
|
||||
let bottle = ItemStack::new("minecraft:honey_bottle", 1);
|
||||
if self.held().is_none() {
|
||||
*self.held_mut() = Some(bottle);
|
||||
} else if let Some(rest) = self.give(bottle)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40);
|
||||
}
|
||||
self.post_block_game_event("minecraft:fluid_pickup", pos, self.sculk_actor(), None);
|
||||
}
|
||||
self.stats.add("used", &held.item, 1);
|
||||
let smokey = beehive::smokey(&self.mob_world_base(&[]), pos);
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if !smokey
|
||||
&& matches!(self.world.block_entities.get(&key), Some(BlockEntity::Beehive(h)) if !h.bees.is_empty())
|
||||
{
|
||||
self.anger_nearby_bees(pos);
|
||||
}
|
||||
// resetHoneyLevel.
|
||||
if let Some(reset) = self.with_props(state, &[("honey_level", "0")]) {
|
||||
self.level_set(pos, reset)?;
|
||||
}
|
||||
if !smokey
|
||||
&& let Some(BlockEntity::Beehive(mut hive)) = self.world.block_entities.take(&key)
|
||||
{
|
||||
// emptyAllLivingFromHive: the bees within 4 blocks of the player
|
||||
// go for them.
|
||||
let released = hive.release_all(
|
||||
pos,
|
||||
facing,
|
||||
None,
|
||||
Release::Emergency,
|
||||
&mut GameHive { game: self },
|
||||
);
|
||||
self.world
|
||||
.block_entities
|
||||
.put(key, BlockEntity::Beehive(hive));
|
||||
if let Some(p) = self.player_view().map(|p| p.position) {
|
||||
for m in self.mobs.iter_mut().filter(|m| released.contains(&m.id)) {
|
||||
if m.distance_sqr(p) <= 16.0 {
|
||||
m.ai.target = Some(Who::Player);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ServerPlayerGameMode.useItemOn: the action was consumed.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, held);
|
||||
Ok(true)
|
||||
}
|
||||
/// `BeehiveBlock.angerNearbyBees`: bees within 8 blocks (6 up and down)
|
||||
/// without a target go for a player in the same box.
|
||||
fn anger_nearby_bees(&mut self, at: [i32; 3]) {
|
||||
/// without a target go for a random player in the same box, the window's
|
||||
/// own or a guest (`None` is the window's player).
|
||||
pub(super) fn anger_nearby_bees(&mut self, at: [i32; 3]) {
|
||||
let min = Vec3::new(
|
||||
f64::from(at[0]) - 8.0,
|
||||
f64::from(at[1]) - 6.0,
|
||||
f64::from(at[2]) - 8.0,
|
||||
);
|
||||
let area = Aabb::new(min, Vec3::new(min.x + 17.0, min.y + 13.0, min.z + 17.0));
|
||||
if !self
|
||||
let mut players: Vec<Option<u32>> = Vec::new();
|
||||
if self
|
||||
.player_view()
|
||||
.is_some_and(|p| p.bounds.intersects(area))
|
||||
{
|
||||
players.push(None);
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
players.extend(
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.filter(|g| super::lan_mobs::guest_bounds(g).intersects(area))
|
||||
.map(|g| Some(g.conn)),
|
||||
);
|
||||
}
|
||||
if players.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut bees: Vec<usize> = (0..self.mobs.len())
|
||||
@@ -219,9 +341,20 @@ impl Game<'_> {
|
||||
.collect();
|
||||
bees.retain(|&i| self.mobs[i].ai.target.is_none());
|
||||
for i in bees {
|
||||
// Util.getRandom over the one player.
|
||||
let _ = self.world.random.next_int_bound(1);
|
||||
// Util.getRandom over the players.
|
||||
let pick = self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound(players.len() as i32)
|
||||
.unwrap_or(0) as usize;
|
||||
self.mobs[i].ai.target = Some(Who::Player);
|
||||
// The player a mob sees is its guest binding, else the window's.
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
match players[pick] {
|
||||
Some(conn) => host.mob_players.insert(self.mobs[i].id, conn),
|
||||
None => host.mob_players.remove(&self.mobs[i].id),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,23 +76,24 @@ impl Game<'_> {
|
||||
/// A player opened (`open`) or closed the container at `key`: the opener
|
||||
/// count block event (`ContainerOpenersCounter` / `ShulkerBoxBlockEntity`
|
||||
/// `startOpen`/`stopOpen`) for chests, ender chests and shulker boxes,
|
||||
/// on both halves of a double chest (`CompoundContainer`).
|
||||
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
/// on both halves of a double chest (`CompoundContainer`). True when the
|
||||
/// count went from 0 to 1 or back to 0: the only moments the container
|
||||
/// plays its open or close sound.
|
||||
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) -> bool {
|
||||
// Spectators never count as openers.
|
||||
if !self.spectator() {
|
||||
self.container_openers_changed(key, open);
|
||||
}
|
||||
!self.spectator() && self.container_openers_changed(key, open)
|
||||
}
|
||||
/// `container_opener_event` for a player who is not a spectator (a guest).
|
||||
pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
|
||||
let pos = [key.0, key.1, key.2];
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]).0;
|
||||
match self.be_anims.ticker(state) {
|
||||
Some(Ticker::Lid) => {
|
||||
self.lid_openers_changed(key, open);
|
||||
let edge = self.lid_openers_changed(key, open);
|
||||
if let Some(other) = self.chest_other_half(key) {
|
||||
self.lid_openers_changed(other, open);
|
||||
}
|
||||
edge
|
||||
}
|
||||
_ if self
|
||||
.res
|
||||
@@ -102,26 +103,27 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
== "minecraft:barrel" =>
|
||||
{
|
||||
self.barrel_openers_changed(key, open);
|
||||
self.barrel_openers_changed(key, open)
|
||||
}
|
||||
Some(Ticker::Shulker { .. }) => {
|
||||
let shulker = self.be_anims.shulkers.entry(pos).or_default();
|
||||
let (count, _) = if open {
|
||||
let (count, edge) = if open {
|
||||
shulker.start_open()
|
||||
} else {
|
||||
shulker.stop_open()
|
||||
};
|
||||
self.block_entity_event(pos, 1, count);
|
||||
self.lan_block_event(pos, 1, count);
|
||||
edge
|
||||
}
|
||||
_ => {}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// `ContainerOpenersCounter.incrementOpeners` / `decrementOpeners` of the
|
||||
/// chest at `key` (the host and the guests are counted), then
|
||||
/// `ChestBlockEntity.signalOpenCount`: block event 1 with the count, here
|
||||
/// and for the guests.
|
||||
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
/// and for the guests. True when the count crossed zero.
|
||||
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
|
||||
let (before, count) = self.count_opener(key, open);
|
||||
let pos = [key.0, key.1, key.2];
|
||||
self.block_entity_event(pos, 1, count);
|
||||
@@ -139,12 +141,15 @@ impl Game<'_> {
|
||||
b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block);
|
||||
});
|
||||
}
|
||||
(before == 0) != (count == 0)
|
||||
}
|
||||
/// `BarrelBlockEntity.openersCounter`: the first opener opens the barrel
|
||||
/// (`open=true`), the last one to leave closes it (`updateBlockState`).
|
||||
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
|
||||
/// True when the count crossed zero.
|
||||
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
|
||||
let (before, count) = self.count_opener(key, open);
|
||||
if (before == 0) != (count == 0) {
|
||||
let edge = (before == 0) != (count == 0);
|
||||
if edge {
|
||||
self.set_bool_prop(
|
||||
key,
|
||||
"open",
|
||||
@@ -152,6 +157,7 @@ impl Game<'_> {
|
||||
horizon_world::level::flags::UPDATE_ALL,
|
||||
);
|
||||
}
|
||||
edge
|
||||
}
|
||||
/// One opener more (`open`) or less at `key`: the count before and after
|
||||
/// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`).
|
||||
@@ -725,21 +731,38 @@ impl Game<'_> {
|
||||
/// the player head at `pos`. The skin download is started here; while it
|
||||
/// is in flight (or when it cannot be got) the profile's default skin
|
||||
/// stands in. A head with no profile answers None: `SKIN_BY_TYPE`.
|
||||
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<&str> {
|
||||
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<String> {
|
||||
let Some(BlockEntity::Skull(skull)) =
|
||||
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let skin = skull.skin.as_ref()?;
|
||||
if let Some((url, location)) = &skin.download {
|
||||
self.skin_downloads
|
||||
.borrow_mut()
|
||||
.state(skins::Kind::Skin, url);
|
||||
if self.gui.atlas.sprites.contains_key(location) {
|
||||
// The texture a packed or resolved `textures` property fetches: the
|
||||
// download of a static profile, or the one the services answer for a
|
||||
// `Dynamic` (name- or id-only) profile.
|
||||
let download = skin.download.clone().or_else(|| {
|
||||
let key = skin.dynamic.as_ref()?;
|
||||
let skins::Resolve::Done(Some(profile)) = self.skin_profiles.borrow_mut().state(key)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let (value, signature) = profile.textures()?;
|
||||
let url = skins::unpack(&value, signature, &skins::TEXTURE_URIS).skin?;
|
||||
let location = skins::skin_location(&skins::texture_hash(&url)?);
|
||||
Some((url, location))
|
||||
});
|
||||
if let Some((url, location)) = download {
|
||||
let mut downloads = self.skin_downloads.borrow_mut();
|
||||
if self.gui.atlas.sprites.contains_key(&location) {
|
||||
let _ = downloads.state(skins::Kind::Skin, &url);
|
||||
return Some(location);
|
||||
}
|
||||
// Its slot was evicted: offer the image again.
|
||||
if downloads.state(skins::Kind::Skin, &url) == skins::Download::Ready {
|
||||
downloads.again(&location);
|
||||
}
|
||||
}
|
||||
Some(skin.fallback)
|
||||
Some(skin.fallback.to_owned())
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -8,7 +8,7 @@ use horizon_client::{
|
||||
containers::{BlockEntity, Lectern},
|
||||
first_person::Hand,
|
||||
hud::{HudBatch, rgba},
|
||||
item_data::WrittenBook,
|
||||
item_data::{StackData, WrittenBook},
|
||||
items::ItemStack,
|
||||
menu::{Kind, Menu},
|
||||
text::Text,
|
||||
@@ -59,14 +59,86 @@ pub(super) enum BookScreen {
|
||||
page_edit: ui::EditBox,
|
||||
},
|
||||
View {
|
||||
pages: Vec<String>,
|
||||
pages: Vec<Text>,
|
||||
page: usize,
|
||||
lectern: Option<(i32, i32, i32)>,
|
||||
},
|
||||
}
|
||||
/// The pages a book screen reads: a written book's components (so their
|
||||
/// click events stay), else a writable book's text.
|
||||
fn view_pages(data: Option<&StackData>) -> Vec<Text> {
|
||||
let Some(data) = data else { return Vec::new() };
|
||||
let Some(w) = &data.written else {
|
||||
return data.writable_pages.iter().map(Text::literal).collect();
|
||||
};
|
||||
let tagged = w.page_tags.len() == w.pages.len();
|
||||
w.pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, page)| match tagged.then(|| &w.page_tags[i]) {
|
||||
Some(tag @ horizon_world::nbt::Tag::Compound(_)) => {
|
||||
horizon_client::entity_nbt::name_text(tag)
|
||||
}
|
||||
_ => Text::literal(page),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
impl Game<'_> {
|
||||
fn book_lines(&self, page: &str) -> Vec<Text> {
|
||||
self.gui.atlas.font.split(&Text::literal(page), 114.0)
|
||||
self.page_lines(&Text::literal(page))
|
||||
}
|
||||
fn page_lines(&self, page: &Text) -> Vec<Text> {
|
||||
self.gui.atlas.font.split(page, 114.0)
|
||||
}
|
||||
/// `BookViewScreen.mouseClicked`: the click event of the text under the
|
||||
/// cursor on the page.
|
||||
fn book_link_at(&self, page: &Text, left: f32) -> Option<horizon_client::text::ClickEvent> {
|
||||
let (mx, my) = self.cursor;
|
||||
let row = ((my - 32.0) / 9.0).floor();
|
||||
if row < 0.0 || row >= LINES as f32 {
|
||||
return None;
|
||||
}
|
||||
let lines = self.page_lines(page);
|
||||
let line = lines.get(row as usize)?.resolve_legacy();
|
||||
let mut x = mx - (left + 36.0);
|
||||
if x < 0.0 {
|
||||
return None;
|
||||
}
|
||||
for (c, style) in line.chars() {
|
||||
x -= self.gui.atlas.font.advance(c, style);
|
||||
if x < 0.0 {
|
||||
return style.click.clone();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `BookViewScreen.handleClickEvent`: a page link turns to its page
|
||||
/// (`forcePage`), a web link goes through the confirm-link flow.
|
||||
fn book_follow_link(
|
||||
&mut self,
|
||||
link: horizon_client::text::ClickEvent,
|
||||
count: usize,
|
||||
lectern: Option<(i32, i32, i32)>,
|
||||
) {
|
||||
use horizon_client::text::ClickEvent;
|
||||
match link {
|
||||
ClickEvent::ChangePage(target) => {
|
||||
let target = (target - 1).clamp(0, count.saturating_sub(1) as i32);
|
||||
let Some(BookScreen::View { page, .. }) = self.book.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some(key) = lectern {
|
||||
// LecternScreen.forcePage: the menu's button 100 + page.
|
||||
if target != *page as i32 {
|
||||
self.lectern_button(key, 100 + target);
|
||||
}
|
||||
} else if target != *page as i32 {
|
||||
*page = target as usize;
|
||||
}
|
||||
}
|
||||
ClickEvent::OpenUrl(url) => self.click_url(url),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
|
||||
/// A server's player writes in its own client's editor
|
||||
@@ -115,11 +187,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
"minecraft:written_book" => BookScreen::View {
|
||||
pages: stack
|
||||
.data()
|
||||
.and_then(|d| d.written.as_ref())
|
||||
.map(|w| w.pages.clone())
|
||||
.unwrap_or_default(),
|
||||
pages: view_pages(stack.data()),
|
||||
page: 0,
|
||||
lectern: None,
|
||||
},
|
||||
@@ -215,6 +283,7 @@ impl Game<'_> {
|
||||
author,
|
||||
generation: 0,
|
||||
pages,
|
||||
page_tags: Vec::new(),
|
||||
});
|
||||
*stack = written;
|
||||
} else {
|
||||
@@ -685,11 +754,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
};
|
||||
let Some(book) = &l.book else { return false };
|
||||
let data = book.data();
|
||||
let pages = data
|
||||
.and_then(|d| d.written.as_ref().map(|w| w.pages.clone()))
|
||||
.or_else(|| data.map(|d| d.writable_pages.clone()))
|
||||
.unwrap_or_default();
|
||||
let pages = view_pages(book.data());
|
||||
let page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1));
|
||||
self.stats.custom("interact_with_lectern", 1);
|
||||
self.open_book(BookScreen::View {
|
||||
@@ -811,11 +876,7 @@ impl Game<'_> {
|
||||
page,
|
||||
}) = self.book.as_mut()
|
||||
{
|
||||
let data = book.as_ref().and_then(ItemStack::data);
|
||||
*pages = data
|
||||
.and_then(|d| d.written.as_ref().map(|w| w.pages.clone()))
|
||||
.or_else(|| data.map(|d| d.writable_pages.clone()))
|
||||
.unwrap_or_default();
|
||||
*pages = view_pages(book.as_ref().and_then(ItemStack::data));
|
||||
*page = (*page).min(pages.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
@@ -955,7 +1016,10 @@ impl Game<'_> {
|
||||
lectern,
|
||||
}) => {
|
||||
let (page, count, lectern) = (*page, pages.len(), *lectern);
|
||||
if page + 1 < count && inside(left + 116.0, 159.0, 23.0, 13.0) {
|
||||
// The text's click event first, then the buttons.
|
||||
if let Some(link) = pages.get(page).and_then(|t| self.book_link_at(t, left)) {
|
||||
self.book_follow_link(link, count, lectern);
|
||||
} else if page + 1 < count && inside(left + 116.0, 159.0, 23.0, 13.0) {
|
||||
self.turn_page(true);
|
||||
} else if page > 0 && inside(left + 43.0, 159.0, 23.0, 13.0) {
|
||||
self.turn_page(false);
|
||||
@@ -1080,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,
|
||||
@@ -1198,7 +1267,7 @@ impl Game<'_> {
|
||||
} => {
|
||||
indicator(batch, *page, pages.len());
|
||||
if let Some(text) = pages.get(*page) {
|
||||
for (i, line) in self.book_lines(text).iter().take(LINES).enumerate() {
|
||||
for (i, line) in self.page_lines(text).iter().take(LINES).enumerate() {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
line,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! `CatSpawner`: every 1200 ticks a cat may appear near a random player,
|
||||
//! in a village with enough occupied beds or inside a swamp hut.
|
||||
use super::{
|
||||
Dim, Game,
|
||||
raids::{section, sections_to_village},
|
||||
};
|
||||
use horizon_client::mobs::{Species, spawn_rules};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
|
||||
const TICK_DELAY: i32 = 1200;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CatSpawner.tick` (the overworld's custom spawner).
|
||||
pub(super) fn tick_cat_spawner(&mut self) {
|
||||
if self.dim != Dim::Overworld {
|
||||
return;
|
||||
}
|
||||
self.creatures.cat_tick -= 1;
|
||||
if self.creatures.cat_tick > 0 {
|
||||
return;
|
||||
}
|
||||
self.creatures.cat_tick = TICK_DELAY;
|
||||
// ServerLevel.getRandomPlayer: one of the living players.
|
||||
let alive: Vec<Vec3> = self
|
||||
.level_players()
|
||||
.into_iter()
|
||||
.filter(|p| p.alive)
|
||||
.map(|p| p.position)
|
||||
.collect();
|
||||
if alive.is_empty() {
|
||||
return;
|
||||
}
|
||||
let pick = self
|
||||
.world
|
||||
.random
|
||||
.next_int_bound(alive.len() as i32)
|
||||
.unwrap_or(0);
|
||||
let p = alive[pick as usize];
|
||||
let mut offset = || {
|
||||
let n = 8 + self.world.random.next_int_bound(24).unwrap_or(0);
|
||||
if self.world.random.next_boolean() {
|
||||
-n
|
||||
} else {
|
||||
n
|
||||
}
|
||||
};
|
||||
let x = offset();
|
||||
let z = offset();
|
||||
let pos = [
|
||||
p.x.floor() as i32 + x,
|
||||
p.y.floor() as i32,
|
||||
p.z.floor() as i32 + z,
|
||||
];
|
||||
// hasChunksAt(pos +- 10)
|
||||
let loaded = ((pos[0] - 10) >> 4..=(pos[0] + 10) >> 4).all(|cx| {
|
||||
((pos[2] - 10) >> 4..=(pos[2] + 10) >> 4)
|
||||
.all(|cz| self.world.chunks.contains_key(&(cx, cz)))
|
||||
});
|
||||
if !loaded {
|
||||
return;
|
||||
}
|
||||
let ok = spawn_rules::is_spawn_position_ok(
|
||||
Species::Cat,
|
||||
&self.mob_world_base(&[]),
|
||||
pos[0],
|
||||
pos[1],
|
||||
pos[2],
|
||||
);
|
||||
if !ok {
|
||||
return;
|
||||
}
|
||||
if sections_to_village(&self.village_centers(), section(pos)) <= 2 {
|
||||
self.spawn_cat_in_village(pos);
|
||||
} else {
|
||||
let hut = self.structure_tag("minecraft:cats_spawn_in");
|
||||
if self.structures_at(pos).iter().any(|s| hut.contains(s)) {
|
||||
self.spawn_cat_in_hut(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `spawnInVillage`: more than four occupied beds within 48 blocks and
|
||||
/// fewer than five cats around.
|
||||
fn spawn_cat_in_village(&mut self, pos: [i32; 3]) {
|
||||
// PoiManager.getCountInRange(HOME, IS_OCCUPIED): beds villagers have claimed.
|
||||
let mut beds: Vec<[i32; 3]> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.species() == Species::Villager && m.alive())
|
||||
.filter_map(|m| m.extra.bed)
|
||||
.filter(|b| {
|
||||
let d = |i: usize| i64::from(b[i] - pos[i]);
|
||||
d(0).abs() <= 48
|
||||
&& d(2).abs() <= 48
|
||||
&& d(0).pow(2) + d(1).pow(2) + d(2).pow(2) <= 48 * 48
|
||||
})
|
||||
.collect();
|
||||
beds.sort_unstable();
|
||||
beds.dedup();
|
||||
if beds.len() > 4 && self.cats_near(pos, 48.0) < 5 {
|
||||
self.spawn_cat(pos, false);
|
||||
}
|
||||
}
|
||||
/// `spawnInHut`: no cat within 16 blocks; the cat stays.
|
||||
fn spawn_cat_in_hut(&mut self, pos: [i32; 3]) {
|
||||
if self.cats_near(pos, 16.0) == 0 {
|
||||
self.spawn_cat(pos, true);
|
||||
}
|
||||
}
|
||||
/// Cats in `AABB(pos).inflate(radius, 8, radius)`.
|
||||
fn cats_near(&self, pos: [i32; 3], radius: f64) -> usize {
|
||||
let [x, y, z] = pos.map(f64::from);
|
||||
let min = Vec3::new(x - radius, y - 8.0, z - radius);
|
||||
let max = Vec3::new(x + 1.0 + radius, y + 9.0, z + 1.0 + radius);
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.species() == Species::Cat && m.alive() && m.bounds().intersects_corners(min, max)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
/// `spawnCat`: finalized for a natural spawn, black inside
|
||||
/// `#cats_spawn_as_black` structures (`Cat.finalizeSpawn`).
|
||||
fn spawn_cat(&mut self, pos: [i32; 3], persistent: bool) {
|
||||
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:cat", at, persistent) else {
|
||||
return;
|
||||
};
|
||||
let black = self.structure_tag("minecraft:cats_spawn_as_black");
|
||||
let in_black = self.structures_at(pos).iter().any(|s| black.contains(s));
|
||||
if in_black && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.extra.variant = 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,10 +104,25 @@ 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,
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
/// The state a window starts with: the chat remembers the commands kept
|
||||
/// in the game folder (`ChatComponent`'s constructor).
|
||||
pub fn new() -> Self {
|
||||
let mut ui = Self::default();
|
||||
if let Ok(dir) = std::env::current_dir() {
|
||||
ui.chat.load_history(&dir);
|
||||
}
|
||||
ui
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `PlayerList.isOp` for the single-player owner: the world's Allow Commands.
|
||||
pub(super) fn allow_commands(&self) -> bool {
|
||||
@@ -440,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 {
|
||||
@@ -447,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))
|
||||
@@ -559,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);
|
||||
}
|
||||
@@ -661,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);
|
||||
}
|
||||
@@ -675,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;
|
||||
@@ -682,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);
|
||||
@@ -695,12 +779,95 @@ impl Game<'_> {
|
||||
Some(ClickEvent::OpenFile(path)) => {
|
||||
super::packs::open_folder(std::path::Path::new(&path));
|
||||
}
|
||||
Some(ClickEvent::OpenUrl(url)) => self.click_url(url),
|
||||
Some(ClickEvent::SuggestCommand(command)) => {
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
screen.suggest_command(&m, &c, &command);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
Some(ClickEvent::RunCommand(command)) => self.click_command(&command),
|
||||
// Screen.defaultHandleClickEvent: a page link means nothing in chat.
|
||||
Some(ClickEvent::ChangePage(_)) | None => {}
|
||||
}
|
||||
}
|
||||
/// `Screen.clickUrlAction`: nothing with Web Links off, else the link
|
||||
/// opens, after the confirmation unless Prompt on Links is off.
|
||||
pub(super) fn click_url(&mut self, url: String) {
|
||||
if !self.settings.chat_links {
|
||||
return;
|
||||
}
|
||||
if self.settings.file.bool("chatLinksPrompt") {
|
||||
self.link_prompt = Some(url);
|
||||
} else {
|
||||
super::packs::open_uri(&url);
|
||||
}
|
||||
}
|
||||
/// `ConfirmLinkScreen` (untrusted): the link and its warning over the
|
||||
/// chat, answered yes to open it.
|
||||
fn link_prompt_layout(
|
||||
&self,
|
||||
url: &str,
|
||||
size: (f32, f32),
|
||||
) -> horizon_client::confirm_screen::Confirm {
|
||||
let warning = Text::translate("chat.link.warning", &[]).with_color(0xFF_CCCC);
|
||||
let message = Text::literal(url)
|
||||
.append(Text::literal("\n"))
|
||||
.append(warning);
|
||||
horizon_client::confirm_screen::Confirm::new(
|
||||
&self.gui.atlas.font,
|
||||
size,
|
||||
&Text::translate("chat.link.confirm", &[]),
|
||||
&message,
|
||||
)
|
||||
}
|
||||
pub(super) fn draw_link_prompt(&self, batch: &mut HudBatch) {
|
||||
let Some(url) = &self.link_prompt else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let (yes, no) = (
|
||||
Text::translate("gui.yes", &[]),
|
||||
Text::translate("gui.no", &[]),
|
||||
);
|
||||
self.link_prompt_layout(url, (w, h))
|
||||
.draw(batch, atlas, self.cursor, [&yes, &no]);
|
||||
}
|
||||
/// The prompt's answer: the link opens on yes; either way the chat is back.
|
||||
pub(super) fn answer_link_prompt(&mut self, yes: bool) {
|
||||
if let Some(url) = self.link_prompt.take()
|
||||
&& yes
|
||||
{
|
||||
super::packs::open_uri(&url);
|
||||
}
|
||||
}
|
||||
pub(super) fn link_prompt_click(&mut self) {
|
||||
let Some(url) = &self.link_prompt else {
|
||||
return;
|
||||
};
|
||||
let answer = self
|
||||
.link_prompt_layout(url, self.gui_size())
|
||||
.answer_at(self.cursor);
|
||||
if let Some(yes) = answer {
|
||||
self.audio.play_button_click();
|
||||
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).
|
||||
@@ -737,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) {
|
||||
@@ -892,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;
|
||||
}
|
||||
@@ -1000,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()
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Chiseled bookshelves: `ChiseledBookShelfBlock.useItemOn` / `useWithoutItem`
|
||||
//! (a book into the slot under the crosshair, or out of it) and
|
||||
//! `ChiseledBookShelfBlockEntity.updateState`, which also follows the books a
|
||||
//! hopper or dropper moved (`machines::container_changed`). The slots' books
|
||||
//! show through the block state's `slot_N_occupied`, which reaches a server's
|
||||
//! clients with the block update.
|
||||
use super::{Game, Hit, machines::dir_from_name};
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack, mobs::Who};
|
||||
use horizon_core::{
|
||||
geometry::{Direction, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::level::{flags, hit_slot};
|
||||
|
||||
const BLOCK: &str = "minecraft:chiseled_bookshelf";
|
||||
|
||||
impl Game<'_> {
|
||||
/// `useItemOn` with `held` in the acting hand, then `useWithoutItem` on
|
||||
/// the main hand's turn: None when the block passes the click, else
|
||||
/// whether the use succeeded and the arm swings (a click on an empty slot
|
||||
/// without a book only consumes it).
|
||||
pub(super) fn use_chiseled_bookshelf(
|
||||
&mut self,
|
||||
hit: &Hit,
|
||||
held: Option<&ItemStack>,
|
||||
) -> Option<bool> {
|
||||
let pos = hit.pos;
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let info = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.filter(|s| s.name == BLOCK)?;
|
||||
let facing = info
|
||||
.properties
|
||||
.get("facing")
|
||||
.and_then(|f| dir_from_name(f))?;
|
||||
let face = Direction::ALL
|
||||
.into_iter()
|
||||
.find(|d| d.normal() == hit.face)?;
|
||||
let at = Vec3::new(hit.point[0], hit.point[1], hit.point[2]);
|
||||
let slot = hit_slot(
|
||||
facing,
|
||||
face,
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
at,
|
||||
2,
|
||||
3,
|
||||
)?;
|
||||
let occupied = info
|
||||
.properties
|
||||
.get(&format!("slot_{slot}_occupied"))
|
||||
.map(String::as_str)
|
||||
== Some("true");
|
||||
let book = held.filter(|s| {
|
||||
self.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:bookshelf_books")
|
||||
.is_some_and(|m| m.contains(&*s.item))
|
||||
});
|
||||
let centre = pos.map(|v| f64::from(v) + 0.5);
|
||||
if let Some(book) = book
|
||||
&& !occupied
|
||||
{
|
||||
// addBook: one book goes in (a creative player keeps theirs).
|
||||
let book = book.clone();
|
||||
self.stats.add("used", &book.item, 1);
|
||||
let sound = if &*book.item == "minecraft:enchanted_book" {
|
||||
"minecraft:block.chiseled_bookshelf.insert.enchanted"
|
||||
} else {
|
||||
"minecraft:block.chiseled_bookshelf.insert"
|
||||
};
|
||||
self.bookshelf_items(pos)?[slot] = Some(ItemStack {
|
||||
count: 1,
|
||||
..book.clone()
|
||||
});
|
||||
self.consume_held();
|
||||
self.chiseled_bookshelf_update_state(pos, slot);
|
||||
self.audio.play(sound, Some(centre), 1.0, 1.0);
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, &book);
|
||||
return Some(true);
|
||||
}
|
||||
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
|
||||
if self.hand != horizon_client::first_person::Hand::Main {
|
||||
return None;
|
||||
}
|
||||
if !occupied {
|
||||
return Some(false);
|
||||
}
|
||||
// removeBook: the book goes to the inventory, or is dropped.
|
||||
let retrieved = self.bookshelf_items(pos)?[slot].take()?;
|
||||
self.chiseled_bookshelf_update_state(pos, slot);
|
||||
let sound = if &*retrieved.item == "minecraft:enchanted_book" {
|
||||
"minecraft:block.chiseled_bookshelf.pickup.enchanted"
|
||||
} else {
|
||||
"minecraft:block.chiseled_bookshelf.pickup"
|
||||
};
|
||||
self.audio.play(sound, Some(centre), 1.0, 1.0);
|
||||
self.give_or_drop(retrieved);
|
||||
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
|
||||
Some(true)
|
||||
}
|
||||
|
||||
/// The bookshelf's six slots (made if the block has no block entity yet).
|
||||
fn bookshelf_items(&mut self, pos: [i32; 3]) -> Option<&mut [Option<ItemStack>; 6]> {
|
||||
let entity = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_or_insert_with((pos[0], pos[1], pos[2]), || {
|
||||
BlockEntity::ChiseledBookshelf(Box::default())
|
||||
});
|
||||
match entity {
|
||||
BlockEntity::ChiseledBookshelf(b) => Some(&mut b.items),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChiseledBookShelfBlockEntity.updateState`: `slot` becomes the last
|
||||
/// one touched, the block state takes the slots' occupancy
|
||||
/// (`setBlockAndUpdate`) and `BLOCK_CHANGE` goes out.
|
||||
fn chiseled_bookshelf_update_state(&mut self, pos: [i32; 3], slot: usize) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(BlockEntity::ChiseledBookshelf(b)) = self.world.block_entities.get_mut(&key)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
b.last_slot = slot as i32;
|
||||
let occupied: Vec<bool> = b.items.iter().map(Option::is_some).collect();
|
||||
let registry = self.pack.assets.registry;
|
||||
let Some(mut state) = registry
|
||||
.state(self.world.block(pos[0], pos[1], pos[2]))
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for (i, occupied) in occupied.into_iter().enumerate() {
|
||||
state
|
||||
.properties
|
||||
.insert(format!("slot_{i}_occupied"), occupied.to_string());
|
||||
}
|
||||
let Some(new) = registry.id(&state) else {
|
||||
return;
|
||||
};
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, new, flags::UPDATE_ALL));
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
|
||||
}
|
||||
|
||||
/// The books a hopper or dropper put in or took out: the slot whose
|
||||
/// occupancy no longer matches the block state is the one `setItem` or
|
||||
/// `removeItem` updated.
|
||||
pub(super) fn chiseled_bookshelf_synced(&mut self, pos: (i32, i32, i32)) {
|
||||
let Some(BlockEntity::ChiseledBookshelf(b)) = self.world.block_entities.get(&pos) else {
|
||||
return;
|
||||
};
|
||||
let Some(info) = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(self.world.block(pos.0, pos.1, pos.2))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let moved = (0..6).rev().find(|i| {
|
||||
let shown = info
|
||||
.properties
|
||||
.get(&format!("slot_{i}_occupied"))
|
||||
.map(String::as_str);
|
||||
b.items[*i].is_some() != (shown == Some("true"))
|
||||
});
|
||||
if let Some(slot) = moved {
|
||||
self.chiseled_bookshelf_update_state([pos.0, pos.1, pos.2], slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,10 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
let random = self.particles.level_random();
|
||||
for m in self.mobs.iter_mut().filter(|m| !m.removed) {
|
||||
ambient::client_ai_step(m, &mut events);
|
||||
ambient::client_particles(m, random, &mut events);
|
||||
}
|
||||
for event in events {
|
||||
match event {
|
||||
@@ -37,6 +39,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.eye_particles();
|
||||
self.panda_effects();
|
||||
self.sprint_dust();
|
||||
self.effect_swirls();
|
||||
}
|
||||
|
||||
@@ -42,9 +42,20 @@ 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>,
|
||||
/// The blast now going off comes from a primed TNT, creeper, wither
|
||||
/// skull, wither or TNT minecart, the sources that empty a hive they
|
||||
/// destroy (`BeehiveBlock.getDrops`).
|
||||
pub(super) hive_emptying_blast: bool,
|
||||
/// The mob that owns the projectile exploding now (`getIndirectSourceEntity`):
|
||||
/// the mobs the blast hurts record it as their attacker.
|
||||
pub(super) explosion_owner: Option<u32>,
|
||||
/// Charged creepers whose blast just went off and that have not dropped
|
||||
/// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list,
|
||||
/// but the mobs they killed still ask for their loot.
|
||||
@@ -131,6 +142,19 @@ impl Game<'_> {
|
||||
self.recheck_combat();
|
||||
for e in events {
|
||||
let (name, amount) = match e {
|
||||
// LivingEntity.getDamageAfterMagicAbsorb (Resistance) and
|
||||
// Player.actuallyHurt (absorption hearts) and
|
||||
// LivingEntity.hurtServer (the shield, only for an accepted
|
||||
// hit), in tenths.
|
||||
MotionEvent::Health(HealthEvent::Statistic { name, amount }) => {
|
||||
match *name {
|
||||
"resisted" => self.stats.custom("damage_resisted", *amount),
|
||||
"absorbed" => self.stats.custom("damage_absorbed", *amount),
|
||||
"blocked" => self.stats.custom("damage_blocked_by_shield", *amount),
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
MotionEvent::Health(HealthEvent::RecordedDamage { source, amount }) => {
|
||||
let Some(d) = self.motion.health.damage.definition(*source) else {
|
||||
continue;
|
||||
@@ -177,6 +201,10 @@ impl Game<'_> {
|
||||
direct,
|
||||
});
|
||||
self.combat.last_damage = now;
|
||||
// InfestedMobEffect.onMobHurt.
|
||||
if self.potions.effects.get("infested").is_some() {
|
||||
self.player_infested();
|
||||
}
|
||||
}
|
||||
self.combat.pending.clear();
|
||||
self.combat.pending_direct.clear();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2049,8 +2082,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
self.world.block_entities.insert(key, new);
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], state);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
true
|
||||
}
|
||||
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
|
||||
@@ -2211,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>();
|
||||
@@ -2420,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 {
|
||||
@@ -2453,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 {
|
||||
@@ -2557,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());
|
||||
@@ -2751,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.
|
||||
@@ -2766,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());
|
||||
@@ -2799,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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
@@ -27,6 +27,8 @@ pub(super) const SPECIAL: u32 = u32::MAX - 4096;
|
||||
const CRYSTAL_IDS: u32 = SPECIAL + 16;
|
||||
const FIGHT_FILE: &str = "data/minecraft/ender_dragon_fight.dat";
|
||||
const MAX_HEALTH: f32 = 200.0;
|
||||
/// `Mob.getAmbientSoundInterval`.
|
||||
const AMBIENT_INTERVAL: i32 = 80;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Fight {
|
||||
@@ -48,6 +50,9 @@ pub(super) struct Fight {
|
||||
respawn: Option<(Respawn, i32)>,
|
||||
/// The four crystals on the exit portal driving the respawn.
|
||||
respawn_crystals: Vec<Vec3>,
|
||||
/// The player behind the crystal blast under way, whose `explosion`
|
||||
/// damage source names them (`EndCrystal.hurtServer`).
|
||||
blast_by: Option<FightWho>,
|
||||
dirty: bool,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -262,6 +267,9 @@ pub(super) struct Dragon {
|
||||
/// only damage above the last hit lands, and only the excess.
|
||||
invulnerable: i32,
|
||||
last_hurt: f32,
|
||||
/// `lastHurtByPlayer` with its 100 tick memory: the player credited with
|
||||
/// the kill, and the dragon tick of their last hit.
|
||||
hurt_by_player: Option<(FightWho, i32)>,
|
||||
pub death_time: i32,
|
||||
pub flap: f32,
|
||||
pub old_flap: f32,
|
||||
@@ -270,6 +278,8 @@ pub(super) struct Dragon {
|
||||
flame_count: i32,
|
||||
sitting_damage: f32,
|
||||
growl: i32,
|
||||
/// `Mob.ambientSoundTime`.
|
||||
ambient_time: i32,
|
||||
ticks: i32,
|
||||
/// DragonFlightHistory: (y, yRot) samples, newest at `head`.
|
||||
history: [(f64, f32); 64],
|
||||
@@ -385,6 +395,7 @@ impl Dragon {
|
||||
hurt_time: 0,
|
||||
invulnerable: 0,
|
||||
last_hurt: 0.0,
|
||||
hurt_by_player: None,
|
||||
death_time: 0,
|
||||
flap: 0.0,
|
||||
old_flap: 0.0,
|
||||
@@ -397,6 +408,7 @@ impl Dragon {
|
||||
flame_count: 0,
|
||||
sitting_damage: 0.0,
|
||||
growl: 100,
|
||||
ambient_time: 0,
|
||||
ticks: 0,
|
||||
history: [(position.y, yaw); 64],
|
||||
head: 0,
|
||||
@@ -426,6 +438,10 @@ impl Dragon {
|
||||
pub fn alive(&self) -> bool {
|
||||
self.health > 0.0
|
||||
}
|
||||
/// `isDeadOrDying` for the killing blow: the dying flight, or no health.
|
||||
fn down(&self) -> bool {
|
||||
matches!(self.phase, Phase::Dying { .. }) || !self.alive()
|
||||
}
|
||||
fn part_box(&self, i: usize) -> Aabb {
|
||||
let (w, h) = PART_SIZES[i];
|
||||
let p = self.parts[i].0;
|
||||
@@ -893,8 +909,12 @@ impl Game<'_> {
|
||||
.any(|p| p.distance_squared(c.position) < 0.01);
|
||||
if damage == CrystalDamage::Direct {
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
// The blast's damage source carries the player, so the dragon
|
||||
// remembers them before the blast can kill it.
|
||||
self.fight.blast_by = by;
|
||||
let _ =
|
||||
self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false);
|
||||
self.fight.blast_by = None;
|
||||
}
|
||||
if respawn_crystal {
|
||||
self.abort_respawn();
|
||||
@@ -905,7 +925,7 @@ impl Game<'_> {
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
if d.nearest_crystal == Some(index) {
|
||||
d.nearest_crystal = None;
|
||||
self.hurt_dragon(0, 10.0, true, false);
|
||||
self.hurt_dragon(0, 10.0, true, by, false);
|
||||
} else if let Some(n) = d.nearest_crystal.as_mut()
|
||||
&& *n > index
|
||||
{
|
||||
@@ -977,18 +997,46 @@ impl Game<'_> {
|
||||
self.destroy_crystal(index, by, CrystalDamage::Direct);
|
||||
Hurt::Landed { full: true }
|
||||
} else if id >= SPECIAL {
|
||||
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, arrow)
|
||||
let by = by_player.then(|| self.acting_player());
|
||||
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, by, arrow)
|
||||
} else {
|
||||
Hurt::Refused
|
||||
}
|
||||
}
|
||||
/// `EnderDragon.hurt(part, source, damage)` on the dragon in the level.
|
||||
fn hurt_dragon(&mut self, part: usize, damage: f32, counts: bool, arrow: bool) -> Hurt {
|
||||
/// `by` is the player behind the hit, remembered for the kill credit.
|
||||
fn hurt_dragon(
|
||||
&mut self,
|
||||
part: usize,
|
||||
damage: f32,
|
||||
counts: bool,
|
||||
by: Option<FightWho>,
|
||||
arrow: bool,
|
||||
) -> Hurt {
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
return Hurt::Refused;
|
||||
};
|
||||
let was_down = d.down();
|
||||
let (cooldown, last_hurt) = (d.invulnerable, d.last_hurt);
|
||||
let hurt = d.hurt(part, damage, counts, arrow);
|
||||
// LivingEntity.hurtServer: damage that is applied sets lastHurtByPlayer
|
||||
// (100 ticks); a hit the damage cooldown absorbs returns before that.
|
||||
if counts
|
||||
&& matches!(hurt, Hurt::Landed { .. })
|
||||
&& (d.invulnerable != cooldown || d.last_hurt != last_hurt)
|
||||
&& let Some(who) = by
|
||||
{
|
||||
d.hurt_by_player = Some((who, d.ticks));
|
||||
}
|
||||
if !was_down && d.down() {
|
||||
self.credit_dragon_kill();
|
||||
}
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
return hurt;
|
||||
};
|
||||
if hurt == (Hurt::Landed { full: true }) {
|
||||
// Mob.playHurtSound: the ambient sound clock starts again.
|
||||
d.ambient_time = -AMBIENT_INTERVAL;
|
||||
let at = [d.position.x, d.position.y, d.position.z];
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.3;
|
||||
self.audio
|
||||
@@ -996,6 +1044,47 @@ impl Game<'_> {
|
||||
}
|
||||
hurt
|
||||
}
|
||||
/// `LivingEntity.die`: `getKillCredit().awardKillScore`, for the player
|
||||
/// who hurt the dragon within the last 100 ticks. A guest's kill is
|
||||
/// theirs: their statistics, scores and advancements.
|
||||
fn credit_dragon_kill(&mut self) {
|
||||
let Some(d) = self.dragon.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some((who, _)) = d.hurt_by_player.filter(|(_, hit)| d.ticks - hit <= 100) else {
|
||||
return;
|
||||
};
|
||||
let view = horizon_client::advancements::EntityView {
|
||||
kind: "minecraft:ender_dragon".to_owned(),
|
||||
position: [d.position.x, d.position.y, d.position.z],
|
||||
..Default::default()
|
||||
};
|
||||
let id = d.id;
|
||||
let guest = match who {
|
||||
FightWho::Guest(conn) if self.lan_acting.is_none() => Some(conn),
|
||||
_ => None,
|
||||
};
|
||||
let credited = guest.is_some_and(|conn| {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return false;
|
||||
};
|
||||
let done = self
|
||||
.as_guest(&mut host, conn, |g| g.award_dragon_kill(id, &view))
|
||||
.is_some();
|
||||
self.lan = Some(host);
|
||||
done
|
||||
});
|
||||
if !credited {
|
||||
self.award_dragon_kill(id, &view);
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.awardKillScore` for the dragon, on the killer's state.
|
||||
fn award_dragon_kill(&mut self, id: u32, view: &horizon_client::advancements::EntityView) {
|
||||
self.stats.add("killed", "minecraft:ender_dragon", 1);
|
||||
self.stats.custom("mob_kills", 1);
|
||||
self.score_player_killed(super::commands::Target::Mob(id));
|
||||
self.trigger_entity("minecraft:player_killed_entity", "entity", view);
|
||||
}
|
||||
/// Player melee on the dragon's parts or a crystal under the crosshair.
|
||||
/// The dragon part under the crosshair within reach: its id and distance.
|
||||
pub(super) fn dragon_hit(&self) -> Option<(u32, f64)> {
|
||||
@@ -1033,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()
|
||||
@@ -1376,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();
|
||||
@@ -1661,6 +1756,24 @@ impl Game<'_> {
|
||||
self.tick_dragon_death();
|
||||
return;
|
||||
}
|
||||
// Mob.baseTick: the ambient sound, rarer right after one.
|
||||
let roll = d.random.next_int_bound(1000).unwrap_or(999);
|
||||
let waited = d.ambient_time;
|
||||
d.ambient_time += 1;
|
||||
if roll < waited {
|
||||
d.ambient_time = -AMBIENT_INTERVAL;
|
||||
let at = [d.position.x, d.position.y, d.position.z];
|
||||
// LivingEntity.getVoicePitch, from the dragon's own random.
|
||||
let r = &mut d.random;
|
||||
let pitch = (r.next_float() - r.next_float()) * 0.2 + 1.0;
|
||||
self.audio.play(
|
||||
"minecraft:entity.ender_dragon.ambient",
|
||||
Some(at),
|
||||
5.0,
|
||||
pitch,
|
||||
);
|
||||
}
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
// Flapping, growls and the flap sound.
|
||||
let flap_was = (d.old_flap * std::f32::consts::TAU).cos();
|
||||
let v = d.velocity;
|
||||
@@ -2140,10 +2253,12 @@ impl Game<'_> {
|
||||
c.at[1],
|
||||
c.at[2] + f64::from(a.sin() * d),
|
||||
];
|
||||
self.particles.add(
|
||||
self.particles.add_with(
|
||||
&ParticleOptions::simple(ParticleType::DragonBreath),
|
||||
at,
|
||||
[0.0, 0.0, 0.0],
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
// AreaEffectCloud: after its 20-tick wait, every 20 ticks per entity.
|
||||
@@ -2512,6 +2627,8 @@ impl Game<'_> {
|
||||
layer: 2,
|
||||
color: c,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2577,6 +2694,8 @@ impl Game<'_> {
|
||||
layer: 6,
|
||||
color: c,
|
||||
light: wrap_light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
(last_sin, last_cos, last_u) = (sin, cos, uu);
|
||||
@@ -2622,6 +2741,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: 240 | 240 << 8,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2747,8 +2868,9 @@ impl Game<'_> {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
|
||||
});
|
||||
let by = self.fight.blast_by;
|
||||
for (part, amount) in hits {
|
||||
self.hurt_dragon(part, amount, true, false);
|
||||
self.hurt_dragon(part, amount, true, by, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2904,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 };
|
||||
@@ -157,8 +161,10 @@ impl Game<'_> {
|
||||
items: Some(self.res.items()),
|
||||
..Default::default()
|
||||
};
|
||||
// minecraft:post_attack ignite effects of the attacker's weapon (fire aspect).
|
||||
// minecraft:post_attack effects of the attacker's weapon on the victim:
|
||||
// ignite (fire aspect) and apply_mob_effect (bane of arthropods).
|
||||
let mut ignite = 0.0f32;
|
||||
let mut mob_effects = Vec::new();
|
||||
for (id, level) in held.enchantments() {
|
||||
let Some(def) = self.res.stations().enchantments.get(id) else {
|
||||
continue;
|
||||
@@ -168,24 +174,33 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if effect["enchanted"] == "attacker"
|
||||
&& effect["affected"] == "victim"
|
||||
&& effect["effect"]["type"] == "minecraft:ignite"
|
||||
let e = &effect["effect"];
|
||||
let kind_of = e["type"].as_str();
|
||||
if effect["enchanted"] != "attacker"
|
||||
|| effect["affected"] != "victim"
|
||||
|| !matches!(
|
||||
kind_of,
|
||||
Some("minecraft:ignite" | "minecraft:apply_mob_effect")
|
||||
)
|
||||
{
|
||||
let mut random = self.world.random.clone();
|
||||
let ok = effect.get("requirements").is_none_or(|r| {
|
||||
self.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.condition(r, *level, &ctx, &mut random)
|
||||
});
|
||||
self.world.random = random;
|
||||
if ok {
|
||||
ignite = ignite.max(horizon_client::enchant::level_value(
|
||||
&effect["effect"]["duration"],
|
||||
*level,
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut random = self.world.random.clone();
|
||||
let ok = effect.get("requirements").is_none_or(|r| {
|
||||
self.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.condition(r, *level, &ctx, &mut random)
|
||||
});
|
||||
self.world.random = random;
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
if kind_of == Some("minecraft:ignite") {
|
||||
ignite =
|
||||
ignite.max(horizon_client::enchant::level_value(&e["duration"], *level));
|
||||
} else {
|
||||
mob_effects.push((e.clone(), *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,8 +210,41 @@ impl Game<'_> {
|
||||
{
|
||||
m.ignite_for_ticks((ignite * 20.0) as i32);
|
||||
}
|
||||
for (effect, level) in mob_effects {
|
||||
self.apply_mob_effect(mob, &effect, level);
|
||||
}
|
||||
self.post_attack_bursts(&held, &kind);
|
||||
}
|
||||
/// `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) {
|
||||
use horizon_client::enchant::level_value;
|
||||
let Some(m) = self.mobs.get_mut(mob) else {
|
||||
return;
|
||||
};
|
||||
let ids: Vec<&str> = match &effect["to_apply"] {
|
||||
serde_json::Value::String(id) => vec![id.as_str()],
|
||||
serde_json::Value::Array(list) => list.iter().filter_map(|v| v.as_str()).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
// HolderSet.getRandomElement, then Mth.randomBetween for both values.
|
||||
let pick = m.random.next_int_bound(ids.len() as i32).unwrap_or(0) as usize;
|
||||
let mut between = |min: &str, max: &str| {
|
||||
let (lo, hi) = (
|
||||
level_value(&effect[min], level),
|
||||
level_value(&effect[max], level),
|
||||
);
|
||||
m.random.next_float() * (hi - lo) + lo
|
||||
};
|
||||
let ticks = (between("min_duration", "max_duration") * 20.0).round() as i32;
|
||||
let amplifier = (between("min_amplifier", "max_amplifier").round() as i32).max(0);
|
||||
if let Some(id) = super::potions::mob_effect_id(ids[pick]) {
|
||||
m.add_effect(id, ticks, amplifier);
|
||||
}
|
||||
}
|
||||
/// `minecraft:post_attack` explode effects on the attacker (wind burst):
|
||||
/// a burst at the player's feet that only knocks back and triggers
|
||||
/// blocks (`ExplodeEffect` with `block_interaction: trigger`).
|
||||
@@ -307,8 +355,10 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
hits.push(1.0 + self.world.random.next_float() * 4.0);
|
||||
// ChangeItemDamage -> ItemStack.hurtAndBreak: unbreaking applies.
|
||||
let loss = self.durability_loss(&stack, 2);
|
||||
if let Some(s) = self.armor[slot].as_mut() {
|
||||
s.damage += 2;
|
||||
s.damage += loss;
|
||||
if self
|
||||
.res
|
||||
.items()
|
||||
@@ -590,6 +640,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.
|
||||
@@ -638,3 +855,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,
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
containers::{BlockEntity, EndGateway},
|
||||
environment::Environment,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
motion::PlayerMotion,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
vanillagen::VanillaTerrain,
|
||||
world_spawn::PlayerSpawnFinder,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
@@ -27,14 +29,64 @@ const GATEWAY_Y: i32 = 75;
|
||||
const GATEWAY_FILE: &str = "horizon-end-gateways.json";
|
||||
|
||||
/// A gateway teleport waiting for its destination chunks.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.findOrCreateValidTeleportPos from this gateway.
|
||||
Find { gateway: [i32; 3] },
|
||||
/// `exact` is the gateway's own `ExactTeleport`, read while its chunk is loaded.
|
||||
Find { gateway: [i32; 3], exact: bool },
|
||||
/// A known exit (exact, or findExitPosition around it).
|
||||
Known { exit: [i32; 3], exact: bool },
|
||||
}
|
||||
|
||||
impl GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
|
||||
fn for_gateway(
|
||||
gateway: [i32; 3],
|
||||
entity: Option<&BlockEntity>,
|
||||
legacy: Option<([i32; 3], bool)>,
|
||||
) -> Self {
|
||||
let configured = match entity {
|
||||
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
|
||||
_ => None,
|
||||
};
|
||||
match configured.or(legacy) {
|
||||
Some((exit, exact)) => Self::Known { exit, exact },
|
||||
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
|
||||
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => Self::Known {
|
||||
exit: END_SPAWN,
|
||||
exact: true,
|
||||
},
|
||||
None => Self::Find {
|
||||
gateway,
|
||||
exact: exact_teleport(entity),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `TheEndGatewayBlockEntity.exactTeleport` of the block entity at a gateway
|
||||
/// (false without one).
|
||||
fn exact_teleport(entity: Option<&BlockEntity>) -> bool {
|
||||
matches!(entity, Some(BlockEntity::EndGateway(g)) if g.exact_teleport)
|
||||
}
|
||||
|
||||
/// Moves an exit that only the side file knew into the gateway's block entity, unless the block
|
||||
/// entity has its own; true when it took the saved one.
|
||||
fn adopt_saved_exit(entity: &mut EndGateway, (exit, exact): ([i32; 3], bool)) -> bool {
|
||||
if entity.exit_portal.is_some() {
|
||||
return false;
|
||||
}
|
||||
entity.exit_portal = Some(exit);
|
||||
entity.exact_teleport = exact;
|
||||
true
|
||||
}
|
||||
|
||||
/// The end of `getPortalPosition`: the exit itself when the teleport is exact,
|
||||
/// else the spot [`Game::resolve_gateway`] finds around it (`findExitPosition`).
|
||||
fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32; 3]) -> [i32; 3] {
|
||||
if exact { exit } else { find(exit) }
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EndState {
|
||||
/// Gateway position -> (exit, exact teleport); loaded lazily from the save.
|
||||
@@ -247,6 +299,8 @@ impl Game<'_> {
|
||||
layer: 2,
|
||||
color,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -528,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;
|
||||
@@ -553,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]];
|
||||
@@ -573,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.
|
||||
@@ -606,7 +684,9 @@ impl Game<'_> {
|
||||
|
||||
/// EnderDragonFight without a dragon: once the centre of the main island
|
||||
/// is loaded, place the active exit portal (EndPodiumFeature) and the
|
||||
/// first gateway (spawnNewGateway) if the portal is not there yet.
|
||||
/// first gateway (spawnNewGateway) if the portal is not there yet. Like
|
||||
/// scanState and spawnExitPortal, only while the fight has no exit portal
|
||||
/// location: a podium that is later broken stays broken.
|
||||
fn ensure_exit_portal(&mut self) -> Result<()> {
|
||||
if self.travelling.is_some()
|
||||
|| !(-1..=0).all(|cz| (-1..=0).all(|cx| self.streaming.is_lit(cx, cz)))
|
||||
@@ -616,8 +696,14 @@ impl Game<'_> {
|
||||
}
|
||||
let won = self.fight.previously_killed;
|
||||
let gateway = self.first_gateway();
|
||||
if (0..256).any(|y| &*self.block_name([0, y, 0]) == "minecraft:bedrock") {
|
||||
// The podium exists; the gateway may have been out of range when it was placed.
|
||||
if self.exit_portal().is_none() {
|
||||
// findExitPortal remembers the location of a podium that is already there.
|
||||
if let Some(level) = self.podium_level() {
|
||||
self.set_exit_portal([0, level, 0]);
|
||||
}
|
||||
}
|
||||
if self.exit_portal().is_some() {
|
||||
// The gateway may have been out of range when it was placed.
|
||||
if won
|
||||
&& self.world.loaded(gateway[0], gateway[2])
|
||||
&& &*self.block_name(gateway) != "minecraft:end_gateway"
|
||||
@@ -648,17 +734,40 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The portal level of the podium around the centre column, read from the
|
||||
/// bedrock that EndPodiumFeature places (rim at the portal level, disc
|
||||
/// below it, the torch column) so a broken column does not hide it.
|
||||
fn podium_level(&self) -> Option<i32> {
|
||||
let mut offsets: Vec<[i32; 3]> = (0..4).map(|dy| [0, dy, 0]).collect();
|
||||
for z in -4..=4i32 {
|
||||
for x in -4..=4i32 {
|
||||
let flat = f64::from(x * x + z * z);
|
||||
if (6.25..12.25).contains(&flat) {
|
||||
offsets.push([x, 0, z]);
|
||||
} else if flat + 1.0 < 6.25 {
|
||||
offsets.push([x, -1, z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
(1..253)
|
||||
.map(|y| {
|
||||
let score = offsets
|
||||
.iter()
|
||||
.filter(|o| &*self.block_name([o[0], y + o[1], o[2]]) == "minecraft:bedrock")
|
||||
.count();
|
||||
(score, y)
|
||||
})
|
||||
.filter(|&(score, _)| score >= 3)
|
||||
.max()
|
||||
.map(|(_, y)| y)
|
||||
}
|
||||
/// `spawnExitPortal(true)` after the dragon dies.
|
||||
pub(super) fn activate_exit_portal(&mut self) -> Result<()> {
|
||||
let origin = match self.exit_portal() {
|
||||
Some(o) => o,
|
||||
None => {
|
||||
// The rim's level: the top of the bedrock column minus three.
|
||||
let top = (0..256)
|
||||
.rev()
|
||||
.find(|&y| &*self.block_name([0, y, 0]) == "minecraft:bedrock")
|
||||
.unwrap_or(64);
|
||||
[0, top - 3, 0]
|
||||
// The rim's level, found without assuming the centre column is intact.
|
||||
[0, self.podium_level().unwrap_or(61), 0]
|
||||
}
|
||||
};
|
||||
self.place_podium(origin, true)
|
||||
@@ -761,6 +870,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.world
|
||||
.block_entities
|
||||
.get_or_insert_with((origin[0], origin[1], origin[2]), || {
|
||||
BlockEntity::EndGateway(Default::default())
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -782,8 +896,7 @@ impl Game<'_> {
|
||||
self.end.exits.as_mut().expect("loaded exits")
|
||||
}
|
||||
|
||||
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
|
||||
self.gateway_exits().insert(gateway, (exit, exact));
|
||||
fn save_gateway_exits(&mut self) -> Result<()> {
|
||||
let list: Vec<_> = self
|
||||
.gateway_exits()
|
||||
.iter()
|
||||
@@ -795,6 +908,35 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drops a gateway's entry from the side file: its block entity is the source of truth.
|
||||
fn forget_gateway_exit(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
if self.gateway_exits().remove(&gateway).is_some() {
|
||||
self.save_gateway_exits()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
|
||||
// TheEndGatewayBlockEntity.setExitPosition (also EndGatewayFeature.knownExit).
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
|
||||
// The gateway's chunk can be unloaded by now (travel waits for the destination): the
|
||||
// exit then waits in the side file until the gateway is used again (`gateway_job`).
|
||||
if self.world.chunks.contains_key(&chunk)
|
||||
&& let BlockEntity::EndGateway(g) = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
|
||||
{
|
||||
g.exit_portal = Some(exit);
|
||||
g.exact_teleport = exact;
|
||||
self.world.modified.insert(chunk);
|
||||
return self.forget_gateway_exit(gateway);
|
||||
}
|
||||
self.gateway_exits().insert(gateway, (exit, exact));
|
||||
self.save_gateway_exits()
|
||||
}
|
||||
|
||||
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
|
||||
/// travel within the End, waiting for the destination chunks to load.
|
||||
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
@@ -814,25 +956,39 @@ impl Game<'_> {
|
||||
/// its exit, or the search for one, and the spot to load before either
|
||||
/// is resolved ([`Game::resolve_gateway`]).
|
||||
pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) {
|
||||
let known = self.gateway_exits().get(&gateway).copied();
|
||||
let job = match known {
|
||||
Some((exit, exact)) => GatewayJob::Known { exit, exact },
|
||||
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
|
||||
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => {
|
||||
GatewayJob::Known {
|
||||
exit: END_SPAWN,
|
||||
exact: true,
|
||||
}
|
||||
let mut known = self.gateway_exits().get(&gateway).copied();
|
||||
// Once the gateway's chunk is loaded its block entity is the source of truth: an exit the
|
||||
// side file kept for it moves there, and the entry is dropped, so removing or replacing
|
||||
// the exit (`/data remove block`) is not undone by an old entry.
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
|
||||
if let Some(saved) = known
|
||||
&& self.world.chunks.contains_key(&chunk)
|
||||
&& let BlockEntity::EndGateway(g) = self
|
||||
.world
|
||||
.block_entities
|
||||
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
|
||||
{
|
||||
if adopt_saved_exit(g, saved) {
|
||||
self.world.modified.insert(chunk);
|
||||
}
|
||||
None => GatewayJob::Find { gateway },
|
||||
};
|
||||
known = None;
|
||||
let _ = self.forget_gateway_exit(gateway);
|
||||
}
|
||||
let job = GatewayJob::for_gateway(
|
||||
gateway,
|
||||
self.world
|
||||
.block_entities
|
||||
.get(&(gateway[0], gateway[1], gateway[2])),
|
||||
known,
|
||||
);
|
||||
let destination = match job {
|
||||
GatewayJob::Known { exit, .. } => Vec3::new(
|
||||
f64::from(exit[0]) + 0.5,
|
||||
f64::from(exit[1]),
|
||||
f64::from(exit[2]) + 0.5,
|
||||
),
|
||||
GatewayJob::Find { gateway } => {
|
||||
GatewayJob::Find { gateway, .. } => {
|
||||
let d = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
|
||||
.normalize_or_zero()
|
||||
* 1024.0;
|
||||
@@ -889,12 +1045,10 @@ impl Game<'_> {
|
||||
[p[0], p[1] + 1, p[2]]
|
||||
};
|
||||
match job {
|
||||
GatewayJob::Known { exit, exact } => Ok(bottom_center(if exact {
|
||||
exit
|
||||
} else {
|
||||
find_exit(self, exit)
|
||||
})),
|
||||
GatewayJob::Find { gateway } => {
|
||||
GatewayJob::Known { exit, exact } => Ok(bottom_center(exit_target(exit, exact, |e| {
|
||||
find_exit(self, e)
|
||||
}))),
|
||||
GatewayJob::Find { gateway, exact } => {
|
||||
// findExitPortalXZPosTentative over the loaded chunks.
|
||||
let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
|
||||
.normalize_or_zero();
|
||||
@@ -954,8 +1108,11 @@ impl Game<'_> {
|
||||
let exit = [tallest[0], tallest[1] + 10, tallest[2]];
|
||||
self.place_gateway(exit)?;
|
||||
self.set_gateway_exit(exit, gateway, false)?;
|
||||
self.set_gateway_exit(gateway, exit, false)?;
|
||||
Ok(bottom_center(find_exit(self, exit)))
|
||||
// setExitPosition keeps the gateway's own exactTeleport.
|
||||
self.set_gateway_exit(gateway, exit, exact)?;
|
||||
Ok(bottom_center(exit_target(exit, exact, |e| {
|
||||
find_exit(self, e)
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1247,8 +1404,103 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: full,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client::containers::EndGateway;
|
||||
|
||||
#[test]
|
||||
fn a_saved_exit_moves_into_a_gateway_without_one() {
|
||||
let saved = ([8, 100, 0], true);
|
||||
let mut bare = EndGateway::default();
|
||||
assert!(adopt_saved_exit(&mut bare, saved));
|
||||
assert_eq!(bare.exit_portal, Some([8, 100, 0]));
|
||||
assert!(bare.exact_teleport);
|
||||
// The block entity's own exit wins over a stale entry.
|
||||
let mut own = EndGateway {
|
||||
exit_portal: Some([1, 2, 3]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!adopt_saved_exit(&mut own, saved));
|
||||
assert_eq!(own.exit_portal, Some([1, 2, 3]));
|
||||
assert!(!own.exact_teleport);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_exit_keeps_the_gateways_exact_teleport() {
|
||||
let exact = BlockEntity::EndGateway(EndGateway {
|
||||
exact_teleport: true,
|
||||
..Default::default()
|
||||
});
|
||||
let plain = BlockEntity::EndGateway(EndGateway::default());
|
||||
assert!(exact_teleport(Some(&exact)));
|
||||
assert!(!exact_teleport(Some(&plain)));
|
||||
assert!(!exact_teleport(None));
|
||||
assert!(!exact_teleport(Some(&BlockEntity::DaylightDetector)));
|
||||
let exit = [8, 100, 0];
|
||||
// Exact: the exit itself, nothing is searched for.
|
||||
assert_eq!(exit_target(exit, true, |_| panic!("searched")), exit);
|
||||
assert_eq!(
|
||||
exit_target(exit, false, |e| [e[0], e[1] + 11, e[2]]),
|
||||
[8, 111, 0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_gateway_job_prefers_block_entity_then_legacy_then_heuristic() {
|
||||
let gateway = [702, 100, 0];
|
||||
let exit = [8, 100, 0];
|
||||
for exact in [true, false] {
|
||||
let entity = BlockEntity::EndGateway(EndGateway {
|
||||
exit_portal: Some(exit),
|
||||
exact_teleport: exact,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(gateway, Some(&entity), Some((END_SPAWN, !exact))),
|
||||
GatewayJob::Known { exit, exact },
|
||||
);
|
||||
}
|
||||
let empty = BlockEntity::EndGateway(EndGateway::default());
|
||||
for entity in [None, Some(&empty)] {
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(gateway, entity, Some((exit, false))),
|
||||
GatewayJob::Known { exit, exact: false }
|
||||
);
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(gateway, entity, None),
|
||||
GatewayJob::Known {
|
||||
exit: END_SPAWN,
|
||||
exact: true
|
||||
}
|
||||
);
|
||||
let nearby = [96, 75, 0];
|
||||
let exact = BlockEntity::EndGateway(EndGateway {
|
||||
exact_teleport: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(nearby, Some(&exact), None),
|
||||
GatewayJob::Find {
|
||||
gateway: nearby,
|
||||
exact: true
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
GatewayJob::for_gateway(nearby, entity, None),
|
||||
GatewayJob::Find {
|
||||
gateway: nearby,
|
||||
exact: false
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,6 +307,12 @@ impl Game<'_> {
|
||||
)
|
||||
{
|
||||
outcome = o;
|
||||
// dealDefaultKnockback's `indicateDamage` points at the blast
|
||||
// (the source position), not the no-direction fallback.
|
||||
if o.knockback && o.blocked <= 0.0 {
|
||||
p.health
|
||||
.indicate_damage(center[0] - feet[0], center[2] - feet[2], p.yaw);
|
||||
}
|
||||
}
|
||||
if !spectator && !(creative && flying) {
|
||||
p.velocity = Vec3::new(
|
||||
@@ -394,7 +400,14 @@ impl Game<'_> {
|
||||
// it kills drop its charged-creeper loot (`Creeper.killedEntity`).
|
||||
let source = horizon_client::mobs::Damage {
|
||||
kind: horizon_client::mobs::DamageKind::Explosion,
|
||||
attacker: exploding.map(horizon_client::mobs::Who::Mob),
|
||||
// A projectile's owner (`getIndirectSourceEntity`), else
|
||||
// the exploding mob itself.
|
||||
attacker: self
|
||||
.combat
|
||||
.explosion_owner
|
||||
.or(exploding)
|
||||
.filter(|&o| o != m.id)
|
||||
.map(horizon_client::mobs::Who::Mob),
|
||||
from: Some(center),
|
||||
};
|
||||
if hurtable && !m.damage(damage(dist, exposure), source) {
|
||||
@@ -490,6 +503,15 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Runs an explosion whose source empties the hives it destroys
|
||||
/// (`BeehiveBlock.getDrops`: primed TNT, creeper, wither skull, wither,
|
||||
/// TNT minecart).
|
||||
pub(super) fn with_hive_emptying_blast<R>(&mut self, blast: impl FnOnce(&mut Self) -> R) -> R {
|
||||
self.combat.hive_emptying_blast = true;
|
||||
let result = blast(self);
|
||||
self.combat.hive_emptying_blast = false;
|
||||
result
|
||||
}
|
||||
/// `Level.explode` on the server: positions, entities, blocks, fire; then
|
||||
/// the client's explosion sound.
|
||||
pub(super) fn server_explode(
|
||||
@@ -606,6 +628,22 @@ impl Game<'_> {
|
||||
// Block.onExplosionHit: the drops copy from the block entity, which
|
||||
// then spills its contents as the block turns to air.
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let is_hive = matches!(&*name, "minecraft:beehive" | "minecraft:bee_nest");
|
||||
// BeehiveBlock.getDrops: the bees of a hive blown up by TNT, a
|
||||
// creeper, a wither skull, a wither or a TNT minecart come out.
|
||||
if is_hive && self.combat.hive_emptying_blast {
|
||||
let facing = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("facing").cloned());
|
||||
self.empty_hive(
|
||||
pos,
|
||||
horizon_client::beehive::facing_step(facing.as_deref()),
|
||||
horizon_client::beehive::Release::Emergency,
|
||||
);
|
||||
}
|
||||
if let Some(entity) = self.world.block_entities.get(&key).cloned() {
|
||||
let custom = self.world.block_names.get(&key).cloned();
|
||||
self.pending.record(pos, &entity, &name, custom);
|
||||
@@ -638,6 +676,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.pending.clear(pos);
|
||||
self.level_set(pos, self.world.air)?;
|
||||
// BeehiveBlock.onExplosionHit: the bees about turn on the player.
|
||||
if is_hive {
|
||||
self.anger_nearby_bees(pos);
|
||||
}
|
||||
// TntBlock.wasExploded: new PrimedTnt (its push drawn first), then
|
||||
// getRandomShortFuse(80); no game event.
|
||||
if is_tnt && self.ui.rules.bool("tnt_explodes") {
|
||||
|
||||
@@ -62,11 +62,56 @@ impl Rocket {
|
||||
item,
|
||||
}
|
||||
}
|
||||
/// One tick of a server's client: the previous position, then `step`.
|
||||
pub(super) fn remote_step(&mut self, step: impl FnOnce(Vec3) -> Vec3) {
|
||||
/// One tick of a server's client: the rocket flies on by itself between
|
||||
/// the server's updates (a rocket that follows an entity is placed by the
|
||||
/// caller).
|
||||
pub(super) fn remote_step(&mut self, scene: &mut SceneCollisions) {
|
||||
self.previous = self.position;
|
||||
if self.attached_to.is_none() {
|
||||
self.free_flight(scene);
|
||||
}
|
||||
self.life += 1;
|
||||
self.position = step(self.position);
|
||||
}
|
||||
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
|
||||
/// up (an angled one keeps its motion) and rise, then move. True when a
|
||||
/// block stopped it.
|
||||
fn free_flight(&mut self, scene: &mut SceneCollisions) -> bool {
|
||||
let h = 0.125;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
|
||||
Vec3::new(
|
||||
self.position.x + h,
|
||||
self.position.y + 2.0 * h,
|
||||
self.position.z + h,
|
||||
),
|
||||
);
|
||||
let wanted = if self.angled {
|
||||
self.velocity
|
||||
} else {
|
||||
Vec3::new(
|
||||
self.velocity.x * 1.15,
|
||||
self.velocity.y + 0.04,
|
||||
self.velocity.z * 1.15,
|
||||
)
|
||||
};
|
||||
let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else {
|
||||
return false;
|
||||
};
|
||||
let blocked = (moved.x - wanted.x).abs() > 1e-7
|
||||
|| (moved.y - wanted.y).abs() > 1e-7
|
||||
|| (moved.z - wanted.z).abs() > 1e-7;
|
||||
let horizontal = (moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
self.velocity = if horizontal {
|
||||
Vec3::new(self.velocity.x, wanted.y, self.velocity.z)
|
||||
} else {
|
||||
wanted
|
||||
};
|
||||
blocked
|
||||
}
|
||||
/// Position and motion (the dispenser parity fixture).
|
||||
pub(super) fn motion(&self) -> (Vec3, Vec3) {
|
||||
@@ -191,46 +236,14 @@ impl Game<'_> {
|
||||
r.velocity = p.velocity;
|
||||
}
|
||||
} else {
|
||||
// Not shot at an angle: accelerate (unless blocked sideways) and rise.
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let h = 0.125;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(r.position.x - h, r.position.y, r.position.z - h),
|
||||
Vec3::new(r.position.x + h, r.position.y + 2.0 * h, r.position.z + h),
|
||||
);
|
||||
let wanted = if r.angled {
|
||||
r.velocity
|
||||
} else {
|
||||
Vec3::new(
|
||||
r.velocity.x * 1.15,
|
||||
r.velocity.y + 0.04,
|
||||
r.velocity.z * 1.15,
|
||||
)
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) {
|
||||
let blocked = (moved.x - wanted.x).abs() > 1e-7
|
||||
|| (moved.y - wanted.y).abs() > 1e-7
|
||||
|| (moved.z - wanted.z).abs() > 1e-7;
|
||||
if r.angled && blocked {
|
||||
// hitTargetOrDeflectSelf: explode on the block.
|
||||
r.life = r.lifetime;
|
||||
}
|
||||
let horizontal =
|
||||
(moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
|
||||
r.position = Vec3::new(
|
||||
r.position.x + moved.x,
|
||||
r.position.y + moved.y,
|
||||
r.position.z + moved.z,
|
||||
);
|
||||
r.velocity = if horizontal {
|
||||
Vec3::new(r.velocity.x, wanted.y, r.velocity.z)
|
||||
} else {
|
||||
wanted
|
||||
};
|
||||
if r.free_flight(&mut scene) && r.angled {
|
||||
// hitTargetOrDeflectSelf: explode on the block.
|
||||
r.life = r.lifetime;
|
||||
}
|
||||
}
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
@@ -383,7 +396,12 @@ impl Game<'_> {
|
||||
/// lit at the rocket's light probe.
|
||||
pub(super) fn rocket_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
let orientation = glam::Mat4::from_quat(self.camera_orientation());
|
||||
for r in self.rockets.iter().filter(|r| !r.attached) {
|
||||
// shouldRender: not while attached to an entity (`isAttachedToEntity`).
|
||||
for r in self
|
||||
.rockets
|
||||
.iter()
|
||||
.filter(|r| !r.attached && r.attached_to.is_none())
|
||||
{
|
||||
// FireworkRocketEntity.shouldRenderAtSqrDistance: within 64 blocks;
|
||||
// EntityRenderer.shouldRender: its 0.25 box in the view.
|
||||
if !self.standing_entity_in_view(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
|
||||
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
|
||||
use super::{Game, view_vector};
|
||||
use horizon_client::advancements::EntityView;
|
||||
use horizon_client::lan::{entity::States, hub::Hub};
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
@@ -14,6 +15,7 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3, trig},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_protocol::entity_data::{Value, serializer};
|
||||
@@ -77,7 +79,7 @@ impl Game<'_> {
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
|
||||
if self.hook.is_some() {
|
||||
let damage = self.retrieve_hook();
|
||||
let damage = self.retrieve_hook(stack);
|
||||
self.damage_held(damage);
|
||||
self.audio.play(
|
||||
"minecraft:entity.fishing_bobber.retrieve",
|
||||
@@ -143,7 +145,7 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
/// `FishingHook.retrieve`: the rod damage it costs.
|
||||
fn retrieve_hook(&mut self) -> u32 {
|
||||
fn retrieve_hook(&mut self, rod: &ItemStack) -> u32 {
|
||||
let Some(hook) = self.hook.take() else {
|
||||
return 0;
|
||||
};
|
||||
@@ -167,6 +169,15 @@ impl Game<'_> {
|
||||
item.velocity = pulled(item.velocity);
|
||||
hooked_item = true;
|
||||
}
|
||||
// CriteriaTriggers.FISHING_ROD_HOOKED with no items.
|
||||
let hooked = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| self.mob_entity(m));
|
||||
if let Some(hooked) = hooked {
|
||||
self.trigger_fishing_rod_hooked(rod, &hooked, &[]);
|
||||
}
|
||||
// `hookedIn instanceof ItemEntity ? 3 : 5`.
|
||||
damage = if hooked_item { 3 } else { 5 };
|
||||
} else if hook.nibble > 0 {
|
||||
@@ -191,6 +202,7 @@ impl Game<'_> {
|
||||
Some(hook.open_water),
|
||||
);
|
||||
self.world.random = random;
|
||||
let mut caught = Vec::new();
|
||||
for stack in items {
|
||||
// FishingHook.retrieve: fish_caught for each catch in #minecraft:fishes.
|
||||
if self
|
||||
@@ -222,6 +234,7 @@ impl Game<'_> {
|
||||
if let Some(potion) = &stack.potion {
|
||||
item = item.with_potion(potion);
|
||||
}
|
||||
caught.push(item.clone());
|
||||
let (dx, dy, dz) = (
|
||||
owner.x - hook.position.x,
|
||||
owner.y - hook.position.y,
|
||||
@@ -236,11 +249,20 @@ 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);
|
||||
}
|
||||
// CriteriaTriggers.FISHING_ROD_HOOKED: the hook itself is the entity.
|
||||
let bobber = EntityView {
|
||||
kind: "minecraft:fishing_bobber".into(),
|
||||
position: [hook.position.x, hook.position.y, hook.position.z],
|
||||
on_ground: hook.on_ground,
|
||||
fishing_open_water: Some(hook.open_water),
|
||||
..Default::default()
|
||||
};
|
||||
self.trigger_fishing_rod_hooked(rod, &bobber, &caught);
|
||||
damage = 1;
|
||||
}
|
||||
if hook.on_ground {
|
||||
@@ -318,9 +340,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;
|
||||
@@ -645,6 +670,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ impl Game<'_> {
|
||||
living: Some(HurtBob {
|
||||
hurt_time: p.health.hurt_animation_time as f32 - partial,
|
||||
hurt_duration: hurt.hurt_duration,
|
||||
hurt_dir: 0.0,
|
||||
hurt_dir: p.health.hurt_dir,
|
||||
death_time: self.death_time().map(|t| t as f32 + partial),
|
||||
}),
|
||||
player: Some((
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -378,6 +392,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Glowing entities (`EntityRenderer.extractRenderState`'s outlineColor): which entities
|
||||
//! appear glowing, in which team colour, and their silhouettes for the renderer's entity
|
||||
//! outline (`OutlineBufferSource`).
|
||||
use super::{Game, Vertex};
|
||||
use horizon_client::{mobs::Mob, scoreboard as sb};
|
||||
|
||||
/// `Entity.getTeamColor`: the team's colour, else white.
|
||||
pub(super) fn team_color(team: Option<&sb::Team>) -> u32 {
|
||||
team.and_then(|t| t.color.as_deref())
|
||||
.and_then(sb::team_color_rgb)
|
||||
.unwrap_or(0xFF_FFFF)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Entity.isCurrentlyGlowing` of an entity that is not living (an item, a projectile):
|
||||
/// its `Glowing` tag, or shared flag 6 as a server sent it.
|
||||
pub(super) fn entity_glowing(&self, id: u32) -> bool {
|
||||
self.remote_glowing(id).unwrap_or_else(|| {
|
||||
self.persist_raw(id)
|
||||
.and_then(|t| horizon_client::save::number(t.get("Glowing")))
|
||||
.is_some_and(|b| b != 0)
|
||||
})
|
||||
}
|
||||
|
||||
/// `Minecraft.shouldEntityAppearGlowing` of a mob or another player, with its outline
|
||||
/// colour (`getTeamColor`; another player's team goes by name).
|
||||
pub(super) fn mob_outline(&self, m: &Mob) -> Option<u32> {
|
||||
if !m.is_currently_glowing() {
|
||||
return None;
|
||||
}
|
||||
let team = if &*m.kind.id == "minecraft:player" {
|
||||
let names = self.remote_player_names();
|
||||
let name = names.into_iter().find(|(id, _)| *id == m.id);
|
||||
name.and_then(|(_, name)| self.score.board.players_team(&name))
|
||||
} else {
|
||||
self.mob_team(m.id)
|
||||
};
|
||||
Some(team_color(team))
|
||||
}
|
||||
|
||||
/// Adds one glowing entity's geometry to the outline pass in `rgb`, the textures'
|
||||
/// alpha kept (`OutlineBufferSource`'s team-coloured copy of each draw).
|
||||
pub(super) fn mark_outline(&self, vertices: &[Vertex], rgb: u32) {
|
||||
let color = horizon_client::mobs::render::abgr(rgb);
|
||||
self.glowing_vertices
|
||||
.borrow_mut()
|
||||
.extend(vertices.iter().map(|v| Vertex {
|
||||
color,
|
||||
normal: [0; 4],
|
||||
..*v
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `continueAttack`, `MultiPlayerGameMode` block breaking), the arm swing
|
||||
//! (`LivingEntity.SwingState`) and the first-person equip heights
|
||||
//! (`FirstPersonHandsAndItems`) with the item swap ticker they read.
|
||||
use super::{Game, Mining, Screen};
|
||||
use super::{Game, Mining, Screen, minecarts::CartKind};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
attack_strength::HeldStack,
|
||||
@@ -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 {
|
||||
@@ -289,12 +294,79 @@ impl Game<'_> {
|
||||
// Only alive mobs are hit, so a picked living entity is alive.
|
||||
Some(true)
|
||||
}
|
||||
/// `Attributes.ATTACK_SPEED` with the main-hand item's modifier.
|
||||
/// `Hud.canRenderCrosshairForSpectator` for the nearest hit in reach: an
|
||||
/// entity that is a `MenuProvider` (a chest or hopper minecart, a chest
|
||||
/// boat), or a block whose `showAsInteractableInSpectatorMode` holds.
|
||||
pub(super) fn spectator_crosshair(&self) -> bool {
|
||||
let cart = self.cart_hit().map(|(i, d)| {
|
||||
let kind = &self.carts[i].kind;
|
||||
(d, matches!(kind, CartKind::Chest | CartKind::Hopper))
|
||||
});
|
||||
let boat = self.boat_hit().map(|(i, d)| (d, self.boats[i].chest));
|
||||
let others = [
|
||||
self.mob_hit().map(|h| h.1),
|
||||
self.armor_stand_hit().map(|h| h.1),
|
||||
self.cushion_hit().map(|h| h.1),
|
||||
self.painting_hit().map(|h| h.1),
|
||||
self.frame_hit().map(|h| h.1),
|
||||
self.dragon_hit().map(|h| h.1),
|
||||
];
|
||||
let nearest = cart
|
||||
.into_iter()
|
||||
.chain(boat)
|
||||
.chain(others.into_iter().flatten().map(|d| (d, false)))
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0));
|
||||
if let Some((distance, menu)) = nearest
|
||||
&& self.before_block(distance)
|
||||
{
|
||||
return menu;
|
||||
}
|
||||
let Some(hit) = self.pick() else {
|
||||
return false;
|
||||
};
|
||||
let name = &*self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
|
||||
.name;
|
||||
let name = name.trim_start_matches("minecraft:");
|
||||
match name {
|
||||
// BlockBehaviour.getMenuProvider and `instanceof Portal`.
|
||||
"crafting_table" | "enchanting_table" | "anvil" | "chipped_anvil" | "damaged_anvil"
|
||||
| "stonecutter" | "loom" | "cartography_table" | "grindstone" | "smithing_table"
|
||||
| "barrel" | "furnace" | "blast_furnace" | "smoker" | "brewing_stand" | "hopper"
|
||||
| "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" | "nether_portal"
|
||||
| "end_portal" | "end_gateway" => true,
|
||||
// ChestBlock.getMenuProvider: none while a redstone conductor
|
||||
// sits on top of either half (a sitting cat aside).
|
||||
"chest" | "trapped_chest" => {
|
||||
let key = (hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let blocked = |p: (i32, i32, i32)| {
|
||||
let above = self.world.block(p.0, p.1 + 1, p.2);
|
||||
self.res.behaviours().facts(above).flags
|
||||
& horizon_world::level::data::state_flags::CONDUCTOR
|
||||
!= 0
|
||||
};
|
||||
!blocked(key) && self.chest_other_half(key).is_none_or(|o| !blocked(o))
|
||||
}
|
||||
_ => name.ends_with("shulker_box"),
|
||||
}
|
||||
}
|
||||
/// `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> {
|
||||
@@ -445,9 +517,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.
|
||||
|
||||
@@ -420,6 +420,8 @@ impl Game<'_> {
|
||||
s.flags.published.store(0, Ordering::Relaxed);
|
||||
}
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
// PlayerList.removeAll: the owner and every guest leave the game.
|
||||
host.award_leave_game_to_all();
|
||||
for g in &host.hub.guests {
|
||||
self.save_guest(&host, g);
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ pub(super) struct Host<'a> {
|
||||
banners: &'a horizon_client::banner::Banners,
|
||||
sprites: &'a HashMap<String, Rect>,
|
||||
skins: &'a RefCell<horizon_client::skins::Downloader>,
|
||||
profiles: &'a RefCell<horizon_client::skins::Resolver>,
|
||||
}
|
||||
impl SpecialHost for Host<'_> {
|
||||
fn banner_texture(&self, pattern: &str) -> Option<String> {
|
||||
@@ -144,14 +145,14 @@ impl SpecialHost for Host<'_> {
|
||||
fn player_skin(&self, profile: &Value) -> Option<String> {
|
||||
// SkullBlockRenderer.getPlayerSkin: SkinManager.getInsecureSkinInformation
|
||||
// accepts a texture property without a signature, unlike the tab list.
|
||||
let url = horizon_client::skins::profile_skin_url(profile)?;
|
||||
let location =
|
||||
horizon_client::skins::skin_location(&horizon_client::skins::texture_hash(&url)?);
|
||||
let _ = self
|
||||
.skins
|
||||
.borrow_mut()
|
||||
.state(horizon_client::skins::Kind::Skin, &url);
|
||||
self.sprites.contains_key(&location).then_some(location)
|
||||
// A `Dynamic` profile (a name or id only) resolves through the
|
||||
// services (`PlayerSkinRenderCache.getOrDefault`).
|
||||
Some(horizon_client::skins::head_skin(
|
||||
profile,
|
||||
&mut self.profiles.borrow_mut(),
|
||||
&mut self.skins.borrow_mut(),
|
||||
|location| self.sprites.contains_key(location),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +171,9 @@ fn use_duration(components: &Components) -> i32 {
|
||||
let seconds = c["consume_seconds"].as_f64().unwrap_or(1.6);
|
||||
return (seconds * 20.0) as i32;
|
||||
}
|
||||
if components.get("minecraft:kinetic_weapon").is_some() {
|
||||
return 72_000;
|
||||
}
|
||||
let item_model = components
|
||||
.get("minecraft:item_model")
|
||||
.and_then(Value::as_str)
|
||||
@@ -179,8 +183,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,
|
||||
}
|
||||
}
|
||||
@@ -194,6 +199,7 @@ impl Game<'_> {
|
||||
banners: &self.res.stations().banners,
|
||||
sprites: &self.gui.atlas.sprites,
|
||||
skins: &self.skin_downloads,
|
||||
profiles: &self.skin_profiles,
|
||||
}
|
||||
}
|
||||
/// The client level as item models read it: worked out once a frame (a
|
||||
@@ -247,6 +253,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)
|
||||
{
|
||||
@@ -296,8 +307,32 @@ impl Game<'_> {
|
||||
x,
|
||||
y,
|
||||
self.started.elapsed().as_secs_f32(),
|
||||
self.settings.file.double("glintSpeed") as f32,
|
||||
self.settings.file.double("glintStrength") as f32,
|
||||
);
|
||||
}
|
||||
/// The `GLINT_TEXTURING` inputs the atlas and options provide.
|
||||
pub(super) fn glint(&self) -> Option<item_render::Glint> {
|
||||
self.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get("misc/enchanted_glint_item")
|
||||
.copied()
|
||||
.map(|_| item_render::Glint {
|
||||
speed: self.settings.file.double("glintSpeed") as f32,
|
||||
alpha: self.settings.file.double("glintStrength") as f32,
|
||||
millis: self.started.elapsed().as_secs_f32() * 1000.0,
|
||||
scale: 0.5,
|
||||
})
|
||||
}
|
||||
/// The glint scroll phase shared by world items and armour
|
||||
/// (`item_render::Glint::scroll_ticks`).
|
||||
pub(super) fn glint_ticks(&self) -> f32 {
|
||||
item_render::Glint::scroll_ticks(
|
||||
self.started.elapsed().as_secs_f32() * 1000.0,
|
||||
self.settings.file.double("glintSpeed") as f32,
|
||||
)
|
||||
}
|
||||
/// Submit a resolved item under `pose` (camera-relative) at `light`
|
||||
/// (packed lightmap coordinates).
|
||||
pub(super) fn submit_item_world(
|
||||
@@ -311,6 +346,7 @@ impl Game<'_> {
|
||||
out,
|
||||
light,
|
||||
lights: Some(diffuse_lights(self.dim == Dim::Nether)),
|
||||
glint: self.glint(),
|
||||
};
|
||||
state.submit(
|
||||
&self.pack.item_renderer,
|
||||
@@ -332,6 +368,7 @@ impl Game<'_> {
|
||||
out,
|
||||
light,
|
||||
lights: None,
|
||||
glint: self.glint(),
|
||||
};
|
||||
state.submit(
|
||||
&self.pack.item_renderer,
|
||||
@@ -405,7 +442,12 @@ impl Game<'_> {
|
||||
) {
|
||||
self.submit_item_world(&state, copy, vertex_light(packed), out);
|
||||
}
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
if self.entity_glowing(item.id) {
|
||||
let rgb = super::glowing::team_color(self.mob_team(item.id));
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
} else {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<'_> {
|
||||
@@ -91,11 +96,13 @@ impl Game<'_> {
|
||||
self.set_has_record(pos, true);
|
||||
self.start_song(pos, &song);
|
||||
self.jukebox_changed(pos);
|
||||
// ServerPlayerGameMode.useItemOn: the action was consumed.
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, stack);
|
||||
true
|
||||
}
|
||||
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
|
||||
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;
|
||||
};
|
||||
@@ -107,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]);
|
||||
|
||||
@@ -92,6 +92,33 @@ pub(super) struct LanHost {
|
||||
states: lan::entity::States,
|
||||
}
|
||||
|
||||
impl LanHost {
|
||||
/// `PlayerList.remove`: `player.awardStat(Stats.LEAVE_GAME)` before the
|
||||
/// player is saved. Once per guest, however often they are saved.
|
||||
pub(super) fn award_leave_game(&mut self, conn: u32) {
|
||||
if let Some(play) = self.play.get_mut(&conn)
|
||||
&& !play.left_game
|
||||
{
|
||||
play.left_game = true;
|
||||
play.stats.custom("leave_game", 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `PlayerList.removeAll`: every player who joined leaves with the world.
|
||||
pub(super) fn award_leave_game_to_all(&mut self) {
|
||||
let conns: Vec<u32> = self
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed)
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in conns {
|
||||
self.award_leave_game(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The player who owns an integrated server's world: a guest whose state is
|
||||
/// the world's own player (`players/data/<singleplayer_uuid>.dat`), as in
|
||||
/// single player.
|
||||
@@ -154,6 +181,136 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The variant and look entries of `mob_meta`'s mobs (registry holders, the
|
||||
/// fish's, the bee's, the copper golem's weathering...), in a match of their
|
||||
/// own: `mob_meta`'s arms hold these mobs' other entries.
|
||||
fn mob_variant_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
let (kind, e) = (m.kind.id.as_str(), &m.extra);
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
match kind {
|
||||
// Axolotl.DATA_VARIANT (after AgeableMob's two).
|
||||
"minecraft:axolotl" => {
|
||||
if e.variant != 0 {
|
||||
meta.push((18, serializer::INT, Value::Int(i32::from(e.variant))));
|
||||
}
|
||||
}
|
||||
// Frog.DATA_VARIANT_ID, Pig's, Cow's, Chicken's and ZombieNautilus's
|
||||
// (after the entries of their parents): registry holders.
|
||||
"minecraft:frog"
|
||||
| "minecraft:pig"
|
||||
| "minecraft:cow"
|
||||
| "minecraft:chicken"
|
||||
| "minecraft:zombie_nautilus" => {
|
||||
let (index, serializer, registry) = match kind {
|
||||
"minecraft:frog" => (18, serializer::FROG_VARIANT, "minecraft:frog_variant"),
|
||||
"minecraft:pig" => (19, serializer::PIG_VARIANT, "minecraft:pig_variant"),
|
||||
"minecraft:cow" => (18, serializer::COW_VARIANT, "minecraft:cow_variant"),
|
||||
"minecraft:chicken" => {
|
||||
(18, serializer::CHICKEN_VARIANT, "minecraft:chicken_variant")
|
||||
}
|
||||
_ => (
|
||||
21,
|
||||
serializer::ZOMBIE_NAUTILUS_VARIANT,
|
||||
"minecraft:zombie_nautilus_variant",
|
||||
),
|
||||
};
|
||||
// Each registry's default (`define(DATA_VARIANT_ID, ... TEMPERATE)`)
|
||||
// stays out of the pairing data, as other default values do.
|
||||
let name = horizon_client::mobs::finalize::variant_name(m.species(), e.variant);
|
||||
let name = name.trim_start_matches("minecraft:");
|
||||
if let Some(v) = id(registry, name)
|
||||
&& name != "temperate"
|
||||
{
|
||||
meta.push((index, serializer, Value::Id(v)));
|
||||
}
|
||||
if let Some(v) = id(registry, "temperate") {
|
||||
defaults.push((index, serializer, Value::Id(v)));
|
||||
}
|
||||
}
|
||||
// Rabbit.DATA_TYPE_ID (after AgeableMob's two) and
|
||||
// Parrot.DATA_VARIANT_ID (after TamableAnimal's two and its flags and
|
||||
// owner). The fox's type goes with its flags (#2538).
|
||||
"minecraft:rabbit" | "minecraft:parrot" => {
|
||||
let index = if kind == "minecraft:parrot" { 20 } else { 18 };
|
||||
if e.variant != 0 {
|
||||
meta.push((index, serializer::INT, Value::Int(i32::from(e.variant))));
|
||||
}
|
||||
}
|
||||
// Bee.DATA_FLAGS_ID (rolling 2, stung 4, nectar 8) and
|
||||
// DATA_ANGER_END_TIME: the texture shows the nectar and the anger.
|
||||
"minecraft:bee" => {
|
||||
let bee = horizon_client::mobs::bee::get(m);
|
||||
let flags = bee.map_or(0, |b| b.flags);
|
||||
if flags != 0 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(flags as i8)));
|
||||
}
|
||||
defaults.push((18, serializer::BYTE, Value::Byte(0)));
|
||||
let anger_end = bee.map_or(-1, |b| b.anger_end);
|
||||
if anger_end != -1 {
|
||||
meta.push((19, serializer::LONG, Value::Long(anger_end)));
|
||||
}
|
||||
defaults.push((19, serializer::LONG, Value::Long(-1)));
|
||||
}
|
||||
// AbstractFish.FROM_BUCKET takes 16: Salmon.DATA_TYPE (small 0,
|
||||
// medium 1, large 2; medium is the data's default, so always sent),
|
||||
// Pufferfish.PUFF_STATE and TropicalFish.DATA_ID_TYPE_VARIANT: the
|
||||
// pattern's packed id (size, then index << 8), base colour << 16 and
|
||||
// pattern colour << 24.
|
||||
"minecraft:salmon" => {
|
||||
meta.push((17, serializer::INT, Value::Int(i32::from(e.variant.min(2)))));
|
||||
}
|
||||
"minecraft:pufferfish" => {
|
||||
if e.size > 0 {
|
||||
meta.push((17, serializer::INT, Value::Int(e.size)));
|
||||
}
|
||||
defaults.push((17, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
"minecraft:tropical_fish" => {
|
||||
let pattern = e.variant.min(11);
|
||||
let packed = i32::from(pattern >= 6)
|
||||
| i32::from(pattern % 6) << 8
|
||||
| i32::from(e.variant2 & 15) << 16
|
||||
| i32::from(e.variant3 & 15) << 24;
|
||||
if packed != 0 {
|
||||
meta.push((17, serializer::INT, Value::Int(packed)));
|
||||
}
|
||||
}
|
||||
// CopperGolem.DATA_WEATHER_STATE: unaffected 0 to oxidized 3, and it
|
||||
// goes back down when the golem is scraped.
|
||||
"minecraft:copper_golem" => {
|
||||
if e.variant != 0 {
|
||||
meta.push((
|
||||
16,
|
||||
serializer::WEATHERING_COPPER_STATE,
|
||||
Value::Id(i32::from(e.variant.min(3))),
|
||||
));
|
||||
}
|
||||
defaults.push((16, serializer::WEATHERING_COPPER_STATE, Value::Id(0)));
|
||||
}
|
||||
// Ghast.DATA_IS_CHARGING: the shooting texture.
|
||||
"minecraft:ghast" => {
|
||||
if e.flag {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// WitherBoss.DATA_ID_INV (after the three head targets): the
|
||||
// invulnerable ticks, the blinking texture and the growing scale.
|
||||
"minecraft:wither" => {
|
||||
if e.counter > 0 {
|
||||
meta.push((19, serializer::INT, Value::Int(e.counter)));
|
||||
}
|
||||
defaults.push((19, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mob's synched data as ServerEntity sends it (only the values that differ
|
||||
/// from the defaults the client constructs), with vanilla's entry indices:
|
||||
/// Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses.
|
||||
@@ -162,12 +319,22 @@ fn mob_meta(
|
||||
m: &Mob,
|
||||
hub: &Hub,
|
||||
player_entity: Option<i32>,
|
||||
game_time: i64,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
let kind = m.kind.id.as_str();
|
||||
if m.fire_ticks > 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(1)));
|
||||
// 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;
|
||||
if shared != 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(shared)));
|
||||
}
|
||||
// Entity.DATA_POSE: the pose the species keeps in its own fields (a
|
||||
// pose back at standing goes out by the entity's own default).
|
||||
let pose = horizon_client::mobs::sync::pose_id(m);
|
||||
if pose != 0 {
|
||||
meta.push((6, serializer::POSE, Value::Id(i32::from(pose))));
|
||||
}
|
||||
if let Some(name) = &m.custom_name {
|
||||
meta.push((
|
||||
@@ -221,6 +388,7 @@ fn mob_meta(
|
||||
}
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
mob_variant_meta(m, hub, meta, defaults);
|
||||
match kind {
|
||||
"minecraft:guardian" | "minecraft:elder_guardian" => {
|
||||
// Guardian.DATA_ID_MOVING and DATA_ID_ATTACK_TARGET.
|
||||
@@ -277,6 +445,25 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((18, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Fox.DATA_TYPE_ID and DATA_FLAGS_ID (after AgeableMob's two; sleeping 32, ...).
|
||||
"minecraft:fox" => {
|
||||
if e.variant != 0 {
|
||||
meta.push((18, serializer::INT, Value::Int(i32::from(e.variant))));
|
||||
}
|
||||
defaults.push((18, serializer::INT, Value::Int(0)));
|
||||
let flags = (e.ints[0] & 0xff) as u8 as i8;
|
||||
if flags != 0 {
|
||||
meta.push((19, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
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 };
|
||||
@@ -305,6 +492,14 @@ fn mob_meta(
|
||||
meta.push((collar, serializer::INT, Value::Int(i32::from(e.color & 15))));
|
||||
}
|
||||
defaults.push((collar, serializer::INT, Value::Int(red)));
|
||||
// Wolf.DATA_ANGER_END_TIME: the game time the anger ends, -1 when calm.
|
||||
if kind == "minecraft:wolf" {
|
||||
if e.anger > 0 {
|
||||
let end = game_time + i64::from(e.anger);
|
||||
meta.push((22, serializer::LONG, Value::Long(end)));
|
||||
}
|
||||
defaults.push((22, serializer::LONG, Value::Long(-1)));
|
||||
}
|
||||
}
|
||||
"minecraft:horse"
|
||||
| "minecraft:donkey"
|
||||
@@ -339,6 +534,14 @@ fn mob_meta(
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// HappyGhast.STAYS_STILL (after AgeableMob's two and IS_LEASH_HOLDER),
|
||||
// set while `serverStillTimeout > 0` (`syncStayStillFlag`).
|
||||
"minecraft:happy_ghast" => {
|
||||
if horizon_client::mobs::happy_ghast::on_still_timeout(m) {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after Zombie's three).
|
||||
"minecraft:villager" | "minecraft:zombie_villager" => {
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
@@ -383,6 +586,29 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Panda: UNHAPPY_COUNTER, SNEEZE_COUNTER and EAT_COUNTER (after
|
||||
// AgeableMob's two), MAIN_GENE_ID, HIDDEN_GENE_ID and DATA_ID_FLAGS;
|
||||
// the client animates the states from them.
|
||||
"minecraft:panda" => {
|
||||
let (flags, counters) = horizon_client::mobs::panda::get(m)
|
||||
.map_or((0, [0; 3]), |s| (s.flags, [s.unhappy, s.sneeze, s.eat]));
|
||||
for (index, value) in (18..).zip(counters) {
|
||||
if value != 0 {
|
||||
meta.push((index, serializer::INT, Value::Int(value)));
|
||||
}
|
||||
defaults.push((index, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
for (index, gene) in [(21, e.variant), (22, e.variant2)] {
|
||||
if gene != 0 {
|
||||
meta.push((index, serializer::BYTE, Value::Byte(gene as i8)));
|
||||
}
|
||||
defaults.push((index, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
if flags != 0 {
|
||||
meta.push((23, serializer::BYTE, Value::Byte(flags as i8)));
|
||||
}
|
||||
defaults.push((23, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// PolarBear.DATA_STANDING_ID (after AgeableMob's two): the bear
|
||||
// rears up; the client animates it.
|
||||
"minecraft:polar_bear" => {
|
||||
@@ -398,7 +624,122 @@ fn mob_meta(
|
||||
}
|
||||
defaults.push((17, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Enderman.DATA_CARRY_STATE.
|
||||
// Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace.
|
||||
"minecraft:warden" => {
|
||||
let anger = e.warden.as_ref().map_or(0, |w| w.client_anger);
|
||||
if anger != 0 {
|
||||
meta.push((16, serializer::INT, Value::Int(anger)));
|
||||
}
|
||||
defaults.push((16, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
// Sniffer.DATA_STATE (after AgeableMob's two).
|
||||
"minecraft:sniffer" => {
|
||||
if e.variant != 0 {
|
||||
meta.push((
|
||||
18,
|
||||
serializer::SNIFFER_STATE,
|
||||
Value::Id(i32::from(e.variant)),
|
||||
));
|
||||
}
|
||||
defaults.push((18, serializer::SNIFFER_STATE, Value::Id(0)));
|
||||
}
|
||||
// Shulker.DATA_ATTACH_FACE_ID, DATA_PEEK_ID and DATA_COLOR_ID (16
|
||||
// for none).
|
||||
"minecraft:shulker" => {
|
||||
if e.variant3 != 0 {
|
||||
meta.push((16, serializer::DIRECTION, Value::Id(i32::from(e.variant3))));
|
||||
}
|
||||
defaults.push((16, serializer::DIRECTION, Value::Id(0)));
|
||||
if e.counter != 0 {
|
||||
meta.push((17, serializer::BYTE, Value::Byte(e.counter as i8)));
|
||||
}
|
||||
defaults.push((17, serializer::BYTE, Value::Byte(0)));
|
||||
if e.color != 16 {
|
||||
meta.push((18, serializer::BYTE, Value::Byte(e.color as i8)));
|
||||
}
|
||||
defaults.push((18, serializer::BYTE, Value::Byte(16)));
|
||||
}
|
||||
// Vex.DATA_FLAGS_ID: charging in bit 0 (the arms and the glowing
|
||||
// texture).
|
||||
"minecraft:vex" => {
|
||||
if e.flag {
|
||||
meta.push((16, serializer::BYTE, Value::Byte(1)));
|
||||
}
|
||||
defaults.push((16, serializer::BYTE, Value::Byte(0)));
|
||||
}
|
||||
// Creaking.CAN_MOVE, IS_ACTIVE and IS_TEARING_DOWN.
|
||||
"minecraft:creaking" => {
|
||||
if e.flag2 {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
if e.flag {
|
||||
meta.push((17, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
if horizon_client::mobs::creaking::tearing_down(m) {
|
||||
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// Allay.DATA_DANCING and DATA_CAN_DUPLICATE (the held item is its
|
||||
// main hand equipment).
|
||||
"minecraft:allay" => {
|
||||
if e.flag {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
if e.egg_time > 0 {
|
||||
meta.push((17, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
defaults.push((17, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
// Axolotl.DATA_PLAYING_DEAD (after DATA_VARIANT).
|
||||
"minecraft:axolotl" => {
|
||||
if horizon_client::mobs::axolotl::is_playing_dead(m) {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
// CopperGolem.COPPER_GOLEM_STATE (after DATA_WEATHER_STATE).
|
||||
"minecraft:copper_golem" => {
|
||||
if e.variant3 != 0 {
|
||||
meta.push((
|
||||
17,
|
||||
serializer::COPPER_GOLEM_STATE,
|
||||
Value::Id(i32::from(e.variant3)),
|
||||
));
|
||||
}
|
||||
defaults.push((17, serializer::COPPER_GOLEM_STATE, Value::Id(0)));
|
||||
}
|
||||
// Armadillo.ARMADILLO_STATE (after AgeableMob's two).
|
||||
"minecraft:armadillo" => {
|
||||
let state = horizon_client::mobs::armadillo::state(m);
|
||||
if state != 0 {
|
||||
meta.push((18, serializer::ARMADILLO_STATE, Value::Id(i32::from(state))));
|
||||
}
|
||||
defaults.push((18, serializer::ARMADILLO_STATE, Value::Id(0)));
|
||||
}
|
||||
// Frog.DATA_TONGUE_TARGET_ID (after the variant): the entity id plus
|
||||
// one, 0 for none.
|
||||
"minecraft:frog" => {
|
||||
if let Some(target) = e.frog.tongue_target {
|
||||
let id = i32::try_from(target).map_or(0, |t| t + 1);
|
||||
meta.push((19, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(id)));
|
||||
}
|
||||
defaults.push((19, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(0)));
|
||||
}
|
||||
// Goat.DATA_HAS_LEFT_HORN and DATA_HAS_RIGHT_HORN (after
|
||||
// DATA_IS_SCREAMING_GOAT): both true by default.
|
||||
"minecraft:goat" => {
|
||||
for (index, lost) in [(19, e.lost_horns[0]), (20, e.lost_horns[1])] {
|
||||
if lost {
|
||||
meta.push((index, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
defaults.push((index, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
// Enderman.DATA_CARRY_STATE and DATA_CREEPY (angry: the open mouth).
|
||||
"minecraft:enderman" => {
|
||||
if let Some(state) = e.carried {
|
||||
meta.push((
|
||||
@@ -407,9 +748,25 @@ 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)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Camel.DASH and LAST_POSE_CHANGE_TICK (after AbstractHorse's flags).
|
||||
if horizon_client::mobs::camel::is_camel(m.species()) {
|
||||
if e.flag2 {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
|
||||
let tick = horizon_client::mobs::camel::synced_pose_tick(m, game_time);
|
||||
if tick != 0 {
|
||||
meta.push((20, serializer::LONG, Value::Long(tick)));
|
||||
}
|
||||
defaults.push((20, serializer::LONG, Value::Long(0)));
|
||||
}
|
||||
}
|
||||
|
||||
/// What a client shakes a mob by: Entity.DATA_TICKS_FROZEN (7), whose full
|
||||
@@ -456,8 +813,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))));
|
||||
}
|
||||
@@ -676,7 +1042,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());
|
||||
@@ -728,6 +1095,7 @@ impl Game<'_> {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
};
|
||||
host.award_leave_game_to_all();
|
||||
for g in &host.hub.guests {
|
||||
self.save_guest(&host, g);
|
||||
}
|
||||
@@ -738,6 +1106,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();
|
||||
@@ -935,7 +1304,7 @@ impl Game<'_> {
|
||||
out.clear();
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
// The lead's holder: the player the mob sees (a guest, else the
|
||||
// host) or another mob. A fence knot isn't an entity here.
|
||||
// host), another mob or a fence knot (game/lan_entities.rs).
|
||||
let player_entity = mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
@@ -944,7 +1313,7 @@ impl Game<'_> {
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => player_entity,
|
||||
Holder::Mob(id) => Some(id as i32),
|
||||
Holder::Knot(_) => None,
|
||||
Holder::Knot(p) => self.knot_ids.get(&p).map(|&id| id as i32),
|
||||
});
|
||||
let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str());
|
||||
e.position = [m.position.x, m.position.y, m.position.z];
|
||||
@@ -952,7 +1321,17 @@ impl Game<'_> {
|
||||
e.on_ground = m.on_ground;
|
||||
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
|
||||
e.needs_sync = m.needs_sync;
|
||||
mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults);
|
||||
mob_meta(
|
||||
m,
|
||||
hub,
|
||||
player_entity,
|
||||
self.world.game_time,
|
||||
&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);
|
||||
@@ -964,6 +1343,10 @@ impl Game<'_> {
|
||||
e.position = [d.position.x, d.position.y, d.position.z];
|
||||
e.on_ground = d.on_ground;
|
||||
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
|
||||
// Entity.DATA_SHARED_FLAGS_ID: glowing 0x40 (the Glowing tag).
|
||||
if self.entity_glowing(d.id) {
|
||||
e.meta.push((0, serializer::BYTE, Value::Byte(0x40)));
|
||||
}
|
||||
e.meta.push((8, serializer::ITEM_STACK, Value::Item(stack)));
|
||||
}
|
||||
for o in &self.orbs {
|
||||
@@ -1073,6 +1456,8 @@ impl Game<'_> {
|
||||
self.ui.rules.bool("player_movement_check"),
|
||||
self.ui.rules.bool("elytra_movement_check"),
|
||||
);
|
||||
host.hub
|
||||
.set_immediate_respawn(self.ui.rules.bool("immediate_respawn"));
|
||||
// A server with several levels runs a pass for each
|
||||
// (`tick_server`): the network is read once, before the first.
|
||||
let pass = self.level_pass;
|
||||
@@ -1125,6 +1510,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) => {
|
||||
@@ -1143,6 +1538,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.lan_crafting_left(&mut host, &mut guest);
|
||||
if guest.placed {
|
||||
host.award_leave_game(guest.conn);
|
||||
}
|
||||
self.save_guest(&host, &guest);
|
||||
host.models.remove(&guest.conn);
|
||||
host.play.remove(&guest.conn);
|
||||
@@ -1156,6 +1554,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) => {
|
||||
@@ -1196,6 +1601,8 @@ 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();
|
||||
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
|
||||
self.flush_lan_particles();
|
||||
}
|
||||
@@ -1298,10 +1705,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
|
||||
@@ -1363,6 +1771,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
|
||||
@@ -1397,6 +1806,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);
|
||||
}
|
||||
@@ -1441,7 +1860,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);
|
||||
@@ -1462,6 +1886,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)
|
||||
@@ -1553,12 +1990,41 @@ 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()));
|
||||
// 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.
|
||||
@@ -1619,8 +2085,22 @@ 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);
|
||||
if !m.glowing {
|
||||
// LivingEntity.isCurrentlyGlowing: the guest's Glowing effect.
|
||||
let guest = host.hub.guest(*conn);
|
||||
if m.glowing || guest.is_some_and(|g| g.effects.get("glowing").is_some()) {
|
||||
let team = guest.and_then(|g| self.score.board.players_team(&g.name));
|
||||
self.mark_outline(&out[entity_start..], super::glowing::team_color(team));
|
||||
} else {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,12 @@ pub(super) struct GuestPlay {
|
||||
potions: super::potions::BodyPotions,
|
||||
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
|
||||
pub(super) stats: super::stats::Stats,
|
||||
/// `Stats.LEAVE_GAME` is counted: a guest is saved more than once on the
|
||||
/// way out, the statistic is awarded once (`LanHost::award_leave_game`).
|
||||
pub(super) left_game: bool,
|
||||
last_inventory: u64,
|
||||
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
|
||||
levitation: Option<([f64; 3], u64)>,
|
||||
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
|
||||
pub(super) portal: horizon_client::nether_portal::PortalState,
|
||||
/// `ServerPlayer.wonGame`: the End poem is showing, the player waits for
|
||||
@@ -158,6 +163,7 @@ pub(super) struct GuestSwap {
|
||||
host_ender: Option<(i32, i32, i32)>,
|
||||
adv_loaded: bool,
|
||||
last_inventory: u64,
|
||||
levitation: Option<([f64; 3], u64)>,
|
||||
death_cause: Option<String>,
|
||||
/// The guest's own damage record, in place while they act.
|
||||
combat: super::combat::CombatTracker,
|
||||
@@ -382,10 +388,12 @@ 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);
|
||||
let last_inventory = std::mem::replace(&mut self.adv.last_inventory, play.last_inventory);
|
||||
let levitation = std::mem::replace(&mut self.adv.levitation, play.levitation);
|
||||
let death_cause = std::mem::replace(&mut self.ui.death_cause, play.death_cause.take());
|
||||
let acting = (
|
||||
self.lan_acting.replace(conn),
|
||||
@@ -457,6 +465,7 @@ impl Game<'_> {
|
||||
host_ender,
|
||||
adv_loaded,
|
||||
last_inventory,
|
||||
levitation,
|
||||
death_cause,
|
||||
acting,
|
||||
packets,
|
||||
@@ -516,6 +525,7 @@ impl Game<'_> {
|
||||
host_ender,
|
||||
adv_loaded,
|
||||
last_inventory,
|
||||
levitation,
|
||||
death_cause,
|
||||
combat,
|
||||
acting,
|
||||
@@ -634,10 +644,12 @@ 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;
|
||||
play.last_inventory = std::mem::replace(&mut self.adv.last_inventory, last_inventory);
|
||||
play.levitation = std::mem::replace(&mut self.adv.levitation, levitation);
|
||||
let stat_events = std::mem::take(&mut play.stats.events);
|
||||
let name = g.name.clone();
|
||||
self.potions.swap_player(&mut g.effects, &mut play.potions);
|
||||
@@ -681,6 +693,13 @@ impl Game<'_> {
|
||||
g.sprinting = false;
|
||||
}
|
||||
m.sprinting = false;
|
||||
// ServerPlayer.indicateDamage: the hit's direction to the player.
|
||||
if let Some(yaw) = m.health.indicated_dir.take() {
|
||||
g.send(&C::HurtAnimation(play::HurtAnimation {
|
||||
id: g.entity_id,
|
||||
yaw,
|
||||
}));
|
||||
}
|
||||
let teleported = std::mem::take(&mut m.teleported);
|
||||
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
|
||||
g.motion = Some(Box::new(m));
|
||||
@@ -882,13 +901,17 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// The guest's open container is closing (`AbstractContainerMenu.removed`):
|
||||
/// it no longer counts among the openers of the lid.
|
||||
/// it no longer counts among the openers of the lid, and the lid's close
|
||||
/// sound is played for everyone around (`ChestBlockEntity.onClose`, a
|
||||
/// `playSound` with no player), the guest included, once the last opener
|
||||
/// has left.
|
||||
pub(super) fn guest_container_closed(&mut self, host: &mut LanHost, conn: u32) {
|
||||
if let Some(g) = host.hub.guest(conn)
|
||||
&& let Some(pos) = g.menu.as_ref().and_then(|m| m.pos)
|
||||
&& !g.spectator()
|
||||
&& self.container_openers_changed(pos, false)
|
||||
{
|
||||
self.container_openers_changed(pos, false);
|
||||
self.container_edge_sound(pos, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -950,6 +973,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 {
|
||||
@@ -974,6 +1036,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));
|
||||
@@ -982,6 +1050,20 @@ impl Game<'_> {
|
||||
// The pause between two blocks is the client's (MultiPlayerGameMode's
|
||||
// destroyDelay); the server digs from the START it receives.
|
||||
play.destroy_delay = 0;
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: a survival START
|
||||
// attacks the block at once, so a tap released in the same tick
|
||||
// still plays a note. The dig is marked begun for tick_mining.
|
||||
let pos = a.pos;
|
||||
self.as_guest(host, conn, |g| {
|
||||
if g.may_build() && !g.creative {
|
||||
let _ = g.attack_block(pos);
|
||||
g.mining = Some(Mining {
|
||||
pos,
|
||||
progress: 0.0,
|
||||
ticks: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
1 => {}
|
||||
3 => {
|
||||
@@ -1087,19 +1169,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,
|
||||
@@ -1399,19 +1487,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,
|
||||
@@ -1884,6 +2021,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
|
||||
@@ -1902,6 +2043,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) {
|
||||
@@ -1947,7 +2092,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 {
|
||||
@@ -2019,6 +2170,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();
|
||||
@@ -2045,7 +2197,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,
|
||||
@@ -2126,11 +2278,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;
|
||||
@@ -2154,11 +2305,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() {
|
||||
@@ -2214,6 +2418,7 @@ 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());
|
||||
@@ -2224,6 +2429,7 @@ impl 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);
|
||||
};
|
||||
@@ -2289,6 +2495,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();
|
||||
@@ -2317,12 +2524,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);
|
||||
};
|
||||
@@ -2361,7 +2572,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),
|
||||
@@ -2665,9 +2879,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;
|
||||
@@ -2678,6 +2898,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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -127,9 +127,18 @@ impl Game<'_> {
|
||||
}
|
||||
let dropped = std::mem::take(&mut menu.dropped);
|
||||
let equipped = std::mem::take(&mut menu.equipped);
|
||||
let brewed = std::mem::take(&mut menu.brewed);
|
||||
menu.drops.clear();
|
||||
menu.crafted.clear();
|
||||
store_player(menu, &mut host.hub.guests[gi]);
|
||||
if !brewed.is_empty() {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
self.as_guest(host, conn, |g| {
|
||||
for stack in &brewed {
|
||||
g.trigger_brewed_potion(stack);
|
||||
}
|
||||
});
|
||||
}
|
||||
if !equipped.is_empty() {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
self.as_guest(host, conn, |g| {
|
||||
@@ -576,16 +585,18 @@ impl Game<'_> {
|
||||
/// A guest left: what their crafting grids and workstation held goes back into their
|
||||
/// inventory before it is saved (dropped where they stood if full).
|
||||
pub(super) fn lan_crafting_left(&mut self, host: &mut LanHost, g: &mut Guest) {
|
||||
let play = host.play.remove(&g.conn);
|
||||
// The guest's play state stays: their statistics are awarded and saved
|
||||
// after this (`HostEvent::Left`), which removes it.
|
||||
let play = host.play.get_mut(&g.conn);
|
||||
let mut grid: Vec<ItemStack> = g.craft_grid.iter_mut().filter_map(Option::take).collect();
|
||||
g.craft_result = None;
|
||||
if let Some(play) = play {
|
||||
if let Some((_, _, menu, _)) = play.crafting.table {
|
||||
if let Some((_, _, menu, _)) = play.crafting.table.take() {
|
||||
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
|
||||
}
|
||||
// A workstation's inputs too.
|
||||
if let Some(station) = &play.station {
|
||||
grid.extend(super::lan_stations::station_inputs(station));
|
||||
if let Some(station) = play.station.take() {
|
||||
grid.extend(super::lan_stations::station_inputs(&station));
|
||||
}
|
||||
}
|
||||
if grid.is_empty() {
|
||||
|
||||
@@ -11,18 +11,6 @@ use horizon_client::lan::{
|
||||
use horizon_protocol::entity_data::{Value, serializer};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `ArmorStand`'s default poses (head, body, left arm, right arm, left leg,
|
||||
/// right leg), DATA_HEAD_POSE to DATA_RIGHT_LEG_POSE (entries 16 to 21),
|
||||
/// under their `Pose` keys.
|
||||
const STAND_POSES: [(&str, [f32; 3]); 6] = [
|
||||
("Head", [0.0, 0.0, 0.0]),
|
||||
("Body", [0.0, 0.0, 0.0]),
|
||||
("LeftArm", [-10.0, 0.0, -10.0]),
|
||||
("RightArm", [-15.0, 0.0, 10.0]),
|
||||
("LeftLeg", [-1.0, 0.0, -1.0]),
|
||||
("RightLeg", [1.0, 0.0, 1.0]),
|
||||
];
|
||||
|
||||
fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) -> EntityState {
|
||||
EntityState {
|
||||
id: id as i32,
|
||||
@@ -61,28 +49,21 @@ fn vehicle_meta(hurt_time: i32, hurt_dir: i32, damage: f32) -> Vec<(u8, i32, Val
|
||||
meta
|
||||
}
|
||||
|
||||
/// An armor stand's pose from its compound (`ArmorStandPose`), each part
|
||||
/// that differs from the default.
|
||||
fn stand_poses(raw: Option<&Tag>) -> Vec<(u8, i32, Value)> {
|
||||
let Some(pose) = raw.and_then(|t| t.get("Pose")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
/// An armor stand's pose (`ArmorStandPose`), each part that differs from the
|
||||
/// default, as DATA_HEAD_POSE to DATA_RIGHT_LEG_POSE (entries 16 to 21).
|
||||
fn stand_poses(a: &horizon_client::entities::ArmorStand) -> Vec<(u8, i32, Value)> {
|
||||
let mut meta = Vec::new();
|
||||
for (i, (key, default)) in STAND_POSES.iter().enumerate() {
|
||||
let Some(Tag::List(values)) = pose.get(key) else {
|
||||
continue;
|
||||
};
|
||||
let mut r = *default;
|
||||
for (k, v) in values.iter().take(3).enumerate() {
|
||||
if let Tag::Float(bits) = v {
|
||||
r[k] = f32::from_bits(*bits);
|
||||
}
|
||||
}
|
||||
if r != *default {
|
||||
for (i, (rotation, default)) in a
|
||||
.pose
|
||||
.iter()
|
||||
.zip(horizon_client::entities::ArmorStand::DEFAULT_POSE)
|
||||
.enumerate()
|
||||
{
|
||||
if *rotation != default {
|
||||
meta.push((
|
||||
16 + i as u8,
|
||||
serializer::ROTATIONS,
|
||||
Value::Floats(r.to_vec()),
|
||||
Value::Floats(rotation.to_vec()),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -185,7 +166,19 @@ impl Game<'_> {
|
||||
if flags != 0 {
|
||||
e.meta.push((15, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
e.meta.extend(stand_poses(self.persist.raw.get(&a.id)));
|
||||
e.meta.extend(stand_poses(a));
|
||||
// A pose put back to its default (`/data`) is sent as that default.
|
||||
e.defaults.push((15, serializer::BYTE, Value::Byte(0)));
|
||||
for (i, default) in horizon_client::entities::ArmorStand::DEFAULT_POSE
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
e.defaults.push((
|
||||
16 + i as u8,
|
||||
serializer::ROTATIONS,
|
||||
Value::Floats(default.to_vec()),
|
||||
));
|
||||
}
|
||||
// Stored main hand, off hand, feet, legs, chest, head: the wire's slots 0 to 5.
|
||||
for (slot, item) in a.equipment.iter().enumerate() {
|
||||
if item.is_some() {
|
||||
@@ -225,6 +218,16 @@ impl Game<'_> {
|
||||
}
|
||||
out.push(e);
|
||||
}
|
||||
for (p, &id) in &self.knot_ids {
|
||||
// LeashFenceKnotEntity.getAddEntityPacket: the fence's block.
|
||||
out.push(state(
|
||||
id,
|
||||
"minecraft:leash_knot",
|
||||
p.map(f64::from),
|
||||
0.0,
|
||||
false,
|
||||
));
|
||||
}
|
||||
for p in &self.paintings {
|
||||
let mut e = state(p.id, "minecraft:painting", p.pos.map(f64::from), 0.0, false);
|
||||
// Direction.get2DDataValue (south 0, west 1, north 2, east 3) to its
|
||||
@@ -270,6 +273,24 @@ impl Game<'_> {
|
||||
/// Gives the entities Horizon keeps without an id one (`Entity.id`, from
|
||||
/// the same counter as the rest) before they are first tracked.
|
||||
pub(super) fn lan_assign_ids(&mut self) {
|
||||
// LeashFenceKnotEntity: one per fence post with a lead on it
|
||||
// (`notifyLeasheeRemoved` discards it once the last lead is gone).
|
||||
let knots: Vec<[i32; 3]> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| !m.removed)
|
||||
.filter_map(|m| match m.extra.leash {
|
||||
Some(horizon_client::mobs::leash::Holder::Knot(p)) => Some(p),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
self.knot_ids.retain(|p, _| knots.contains(p));
|
||||
for p in knots {
|
||||
self.knot_ids.entry(p).or_insert_with(|| {
|
||||
self.next_entity += 1;
|
||||
self.next_entity
|
||||
});
|
||||
}
|
||||
let next = &mut self.next_entity;
|
||||
let mut give = |id: &mut u32| {
|
||||
if *id == 0 {
|
||||
@@ -404,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 {
|
||||
@@ -470,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,
|
||||
),
|
||||
@@ -532,11 +558,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn armor_stand_poses_send_only_what_differs() {
|
||||
let tag =
|
||||
horizon_world::nbt::parse_snbt("{Pose:{Head:[10f,20f,0f],LeftArm:[-10f,0f,-10f]}}")
|
||||
.unwrap()
|
||||
.0;
|
||||
let meta = stand_poses(Some(&tag));
|
||||
let mut stand = horizon_client::entities::ArmorStand::new(1, Default::default(), 0.0);
|
||||
stand.pose[0] = [10.0, 20.0, 0.0];
|
||||
let meta = stand_poses(&stand);
|
||||
assert_eq!(
|
||||
meta,
|
||||
vec![(
|
||||
@@ -545,6 +569,7 @@ mod tests {
|
||||
Value::Floats(vec![10.0, 20.0, 0.0])
|
||||
)]
|
||||
);
|
||||
assert!(stand_poses(None).is_empty());
|
||||
let plain = horizon_client::entities::ArmorStand::new(1, Default::default(), 0.0);
|
||||
assert!(stand_poses(&plain).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//! (hits, pushes, fire, its projectiles) runs with that guest swapped in.
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
mobs::{Mob, MobEvent, PlayerView, Who},
|
||||
mobs::{Mob, MobEvent, PlayerView, Species, Who},
|
||||
projectiles::{Hit, Projectile},
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
@@ -28,6 +28,22 @@ pub(super) fn player_for(
|
||||
) -> Option<u32> {
|
||||
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
|
||||
// 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)
|
||||
&& m.extra.tame
|
||||
&& let Some(owner) = m.extra.owner
|
||||
{
|
||||
if host.is_some_and(|v| v.alive && v.uuid == owner) {
|
||||
return None;
|
||||
}
|
||||
if let Some((conn, _)) = guests.iter().find(|(_, v)| v.alive && v.uuid == owner) {
|
||||
return Some(*conn);
|
||||
}
|
||||
}
|
||||
choose(m.position, targeting, host, guests, bound)
|
||||
}
|
||||
|
||||
@@ -65,11 +81,20 @@ 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,
|
||||
..
|
||||
}
|
||||
| MobEvent::Knockback {
|
||||
target: Who::Player,
|
||||
..
|
||||
}
|
||||
| MobEvent::Ignite {
|
||||
target: Who::Player,
|
||||
..
|
||||
@@ -80,6 +105,7 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
|
||||
}
|
||||
| MobEvent::EffectPlayer { .. }
|
||||
| MobEvent::AxolotlSupportPlayer
|
||||
| MobEvent::ParrotToShoulder { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -353,17 +379,21 @@ mod tests {
|
||||
alive: true,
|
||||
creative,
|
||||
sneaking: false,
|
||||
swimming: false,
|
||||
look: [0.0; 3],
|
||||
main_hand: None,
|
||||
off_hand: None,
|
||||
head: None,
|
||||
armor_cover: 0.0,
|
||||
invisible: false,
|
||||
spectator: false,
|
||||
gold_armor: false,
|
||||
hurt_by: None,
|
||||
last_hurt: None,
|
||||
velocity: Vec3::ZERO,
|
||||
yaw: 0.0,
|
||||
sprinting: false,
|
||||
riding: false,
|
||||
hero: false,
|
||||
effects: Vec::new(),
|
||||
uuid: [0; 4],
|
||||
|
||||
@@ -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>,
|
||||
@@ -247,14 +281,10 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let (d, s) = (p.dist.map(f64::from), p.max_speed.map(f64::from));
|
||||
// ClientLevel.addParticle: the camera's 32 blocks unless the packet
|
||||
// (`force`) or the type overrides the limiter.
|
||||
// ClientLevel.addParticle: the camera's 32 blocks and the Particles
|
||||
// option, unless the packet or the type overrides the limiter.
|
||||
let add = |particles: &mut horizon_client::particles::ParticleEngine, at, v| {
|
||||
if p.override_limiter {
|
||||
particles.create(&options, at, v);
|
||||
} else {
|
||||
particles.add(&options, at, v);
|
||||
}
|
||||
particles.add_with(&options, at, v, p.override_limiter, p.always_show);
|
||||
};
|
||||
if p.count == 0 {
|
||||
add(
|
||||
|
||||
@@ -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);
|
||||
@@ -267,6 +306,30 @@ impl Game<'_> {
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
// ServerPlayer.awardKillScore: the blow that kills a player.
|
||||
if hurt {
|
||||
let died = match victim {
|
||||
// A totem that fired is applied on the next tick_totem, so the
|
||||
// victim is still at zero health but is not dying.
|
||||
Victim::Host => self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.and_then(|g| g.motion.as_deref())
|
||||
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
|
||||
};
|
||||
if died {
|
||||
match s.attacker {
|
||||
Some(conn) => {
|
||||
self.as_guest(host, conn, |g| g.stats.custom("player_kills", 1));
|
||||
}
|
||||
None => self.stats.custom("player_kills", 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
if hurt && (s.crit || s.magic) {
|
||||
let target = match victim {
|
||||
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
|
||||
@@ -307,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();
|
||||
|
||||
@@ -386,6 +386,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color,
|
||||
light: packed,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Level events whose client handler (`LevelEventHandler`) spawns particles
|
||||
//! on a block: bone meal's growth (1505), dragon egg teleportation (2015), a
|
||||
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
|
||||
//! off (3004) and scraping (3005), plus the smoke, drips, egg and dragon
|
||||
//! fireball particles of 1501, 1502, 1504, 2006, 2009, 2012 and 3009.
|
||||
//! consumable's teleport (2017), an enderman's (2018), electric sparks (3002),
|
||||
//! waxing (3003), wax off (3004) and scraping (3005), plus the smoke, drips,
|
||||
//! egg and dragon fireball particles of 1501, 1502, 1504, 2006, 2009, 2012
|
||||
//! and 3009.
|
||||
use super::Game;
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::particles::{self, ParticleOptions, ParticleType};
|
||||
@@ -12,19 +13,22 @@ use horizon_world::registry::BlockStateId;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.block_teleportation(pos, data, [16, 8, 16], self.settings.particles);
|
||||
self.particles.block_teleportation(pos, data, [16, 8, 16]);
|
||||
}
|
||||
/// Event 2016: `Shulker.teleportSomewhere`'s particles between the
|
||||
/// shell's old and new cells.
|
||||
pub(super) fn shulker_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.block_teleportation(pos, data, [8, 8, 8], self.settings.particles);
|
||||
self.particles.block_teleportation(pos, data, [8, 8, 8]);
|
||||
}
|
||||
/// Event 2017: `TeleportRandomlyConsumeEffect`'s directional particles.
|
||||
pub(super) fn consume_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.block_teleportation(pos, data, [127, 127, 127], self.settings.particles);
|
||||
.block_teleportation(pos, data, [127, 127, 127]);
|
||||
}
|
||||
/// Event 2018: an enderman's portal trail.
|
||||
pub(super) fn enderman_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
|
||||
self.particles
|
||||
.enderman_teleportation(pos, data, self.settings.particles);
|
||||
}
|
||||
/// `LevelEventHandler.levelEvent` 3002 to 3005, drawing from the client
|
||||
/// level's random.
|
||||
@@ -42,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))),
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ pub(super) struct Parked {
|
||||
tnts: Vec<tnt::PrimedTnt>,
|
||||
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
|
||||
mobs: Vec<Mob>,
|
||||
/// The entity ids of the level's fence knots (`knot_ids`).
|
||||
knot_ids: HashMap<[i32; 3], u32>,
|
||||
projectiles: Vec<horizon_client::projectiles::Projectile>,
|
||||
dropped: Vec<ItemEntity>,
|
||||
orbs: Vec<ExperienceOrb>,
|
||||
@@ -170,6 +172,7 @@ impl Game<'_> {
|
||||
tnts: Vec::new(),
|
||||
hoppers: HashMap::new(),
|
||||
mobs: Vec::new(),
|
||||
knot_ids: HashMap::new(),
|
||||
projectiles: Vec::new(),
|
||||
dropped: Vec::new(),
|
||||
orbs: Vec::new(),
|
||||
@@ -223,6 +226,7 @@ impl Game<'_> {
|
||||
swap(&mut self.tnts, &mut p.tnts);
|
||||
swap(&mut self.hoppers, &mut p.hoppers);
|
||||
swap(&mut self.mobs, &mut p.mobs);
|
||||
swap(&mut self.knot_ids, &mut p.knot_ids);
|
||||
swap(&mut self.creatures.projectiles, &mut p.projectiles);
|
||||
swap(&mut self.dropped, &mut p.dropped);
|
||||
swap(&mut self.orbs, &mut p.orbs);
|
||||
|
||||
@@ -16,7 +16,7 @@ pub(super) struct Bolt {
|
||||
flashes: i32,
|
||||
seed: i64,
|
||||
visual_only: bool,
|
||||
/// Mooshrooms switch colour once per bolt.
|
||||
/// Mooshrooms and copper golems change once per bolt.
|
||||
converted: Vec<u32>,
|
||||
}
|
||||
impl Game<'_> {
|
||||
@@ -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()
|
||||
@@ -278,6 +282,13 @@ impl Game<'_> {
|
||||
}
|
||||
self.strike_rod(b.at);
|
||||
}
|
||||
// LightningBolt.tick runs clearCopperOnLightningStrike for
|
||||
// every server bolt, a skeleton trap's visual one included;
|
||||
// a client's copy of a server bolt (spawn_remote_bolt) only
|
||||
// flashes.
|
||||
if self.remote.is_none() {
|
||||
self.bolt_clear_copper(b.at);
|
||||
}
|
||||
}
|
||||
b.life -= 1;
|
||||
if b.life < 0 {
|
||||
@@ -334,6 +345,16 @@ impl Game<'_> {
|
||||
self.level_event_at(3002, p, axis);
|
||||
}
|
||||
}
|
||||
/// `LightningBolt.clearCopperOnLightningStrike` at the block the bolt struck.
|
||||
fn bolt_clear_copper(&mut self, at: [f64; 3]) {
|
||||
let p = BlockPos::new(
|
||||
at[0].floor() as i32,
|
||||
(at[1] - 1.0e-6).floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
);
|
||||
self.with_level(|b, level, ticks| b.lightning_clear_copper(level, ticks, p));
|
||||
let _ = self.process_level_events();
|
||||
}
|
||||
/// `thunderHit` for entities within 3 blocks (up to 9 above).
|
||||
fn thunder_hit(&mut self, b: &mut Bolt) {
|
||||
let at = b.at;
|
||||
@@ -393,6 +414,17 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
Species::Creeper => m.extra.charged = true,
|
||||
// CopperGolem.thunderHit (after Entity.thunderHit's fire and
|
||||
// damage): once per bolt, an oxidized golem goes back one step
|
||||
// and its weathering timer (or wax, -2) is reset to -1.
|
||||
Species::CopperGolem if !b.converted.contains(&m.id) => {
|
||||
b.converted.push(m.id);
|
||||
if m.extra.variant > 0 {
|
||||
m.extra.vector[0] = -1.0;
|
||||
m.extra.variant =
|
||||
horizon_client::mobs::copper_golem::weather_previous(m.extra.variant);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Entity.thunderHit: burning (8 seconds) and 5 lightning damage.
|
||||
|
||||
@@ -25,11 +25,13 @@ impl Game<'_> {
|
||||
};
|
||||
let m = &mut self.mobs[index];
|
||||
let mut used = false;
|
||||
let mut loved = false;
|
||||
if item == "minecraft:hay_block" && m.extra.tame && m.extra.age == 0 && m.extra.in_love <= 0
|
||||
{
|
||||
m.extra.in_love = 600;
|
||||
m.extra.love_player = true;
|
||||
used = true;
|
||||
loved = true;
|
||||
}
|
||||
if m.health < m.attrs.max_health {
|
||||
m.health = (m.health + heal).min(m.attrs.max_health);
|
||||
@@ -57,6 +59,10 @@ impl Game<'_> {
|
||||
if !used {
|
||||
return false;
|
||||
}
|
||||
// Animal.setInLove: entity event 18.
|
||||
if loved {
|
||||
self.mob_event_particles(self.mobs[index].id, 18);
|
||||
}
|
||||
self.llama_sound(index, "entity.llama.eat", 1.0);
|
||||
self.consume_held();
|
||||
true
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
enchant::EffectContext,
|
||||
items::ItemStack,
|
||||
mobs::{Damage, Mob, Species},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
@@ -37,6 +38,41 @@ pub(super) fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool {
|
||||
|| m.species() == Species::Allay && m.extra.tame
|
||||
}
|
||||
|
||||
/// A smash's knockback on the players, put off while a guest's turn holds
|
||||
/// the other players.
|
||||
pub(super) struct SmashPush {
|
||||
center: Vec3,
|
||||
heavy: f64,
|
||||
attacker: Option<u32>,
|
||||
team: Option<horizon_client::scoreboard::Team>,
|
||||
}
|
||||
/// `MaceItem.getKnockbackPower` and the push of one entity at `at`, `heavy`
|
||||
/// being 2 after a fall of more than five blocks.
|
||||
fn smash_push(center: Vec3, heavy: f64, at: Vec3, velocity: &mut Vec3, resistance: f64) {
|
||||
let d = [at.x - center.x, at.y - center.y, at.z - center.z];
|
||||
let distance_sqr = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
if distance_sqr > KNOCKBACK_RADIUS.powi(2) {
|
||||
return;
|
||||
}
|
||||
let length = distance_sqr.sqrt();
|
||||
let power =
|
||||
(KNOCKBACK_RADIUS - length) * f64::from(KNOCKBACK_POWER) * heavy * (1.0 - resistance);
|
||||
if power <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// Vec3.normalize: a vector shorter than 1e-5 has no direction.
|
||||
let (x, z) = if length < f64::from(1.0e-5f32) {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
(d[0] / length * power, d[2] / length * power)
|
||||
};
|
||||
*velocity = Vec3::new(
|
||||
velocity.x + x,
|
||||
velocity.y + f64::from(KNOCKBACK_POWER),
|
||||
velocity.z + z,
|
||||
);
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn holds_mace(&self) -> bool {
|
||||
self.inventory[self.selected]
|
||||
@@ -100,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),
|
||||
@@ -109,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()),
|
||||
@@ -138,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
|
||||
@@ -158,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,44 +267,75 @@ impl Game<'_> {
|
||||
];
|
||||
self.level_event_at(2013, on_pos, 750);
|
||||
let heavy = if fall > HEAVY_THRESHOLD { 2.0 } else { 1.0 };
|
||||
// getKnockbackPower and the push of one entity at `at`.
|
||||
let push = |at: Vec3, velocity: &mut Vec3, resistance: f64| {
|
||||
let d = [at.x - center.x, at.y - center.y, at.z - center.z];
|
||||
let distance_sqr = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
if distance_sqr > KNOCKBACK_RADIUS.powi(2) {
|
||||
return;
|
||||
}
|
||||
let length = distance_sqr.sqrt();
|
||||
let power = (KNOCKBACK_RADIUS - length)
|
||||
* f64::from(KNOCKBACK_POWER)
|
||||
* heavy
|
||||
* (1.0 - resistance);
|
||||
if power <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// Vec3.normalize: a vector shorter than 1e-5 has no direction.
|
||||
let (x, z) = if length < f64::from(1.0e-5f32) {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
(d[0] / length * power, d[2] / length * power)
|
||||
};
|
||||
*velocity = Vec3::new(
|
||||
velocity.x + x,
|
||||
velocity.y + f64::from(KNOCKBACK_POWER),
|
||||
velocity.z + z,
|
||||
);
|
||||
};
|
||||
let player = self.level.player_uuid.or(Some([0, 0, 0, 1]));
|
||||
for m in self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.filter(|m| m.id != id && m.alive() && !m.removed && !ally_of_player(m, player))
|
||||
{
|
||||
push(m.position, &mut m.velocity, m.attrs.knockback_resistance);
|
||||
smash_push(
|
||||
center,
|
||||
heavy,
|
||||
m.position,
|
||||
&mut m.velocity,
|
||||
m.attrs.knockback_resistance,
|
||||
);
|
||||
}
|
||||
for s in self.armor_stands.iter_mut().filter(|s| !s.marker) {
|
||||
push(s.position, &mut s.velocity, 0.0);
|
||||
smash_push(center, heavy, s.position, &mut s.velocity, 0.0);
|
||||
}
|
||||
// The host and the LAN guests are living entities too. A guest's turn
|
||||
// holds the other players (`as_guest`): the push waits for it to end.
|
||||
let push = SmashPush {
|
||||
center,
|
||||
heavy,
|
||||
attacker: self.lan_acting,
|
||||
team: self.player_team().cloned(),
|
||||
};
|
||||
if self.lan_acting.is_some() {
|
||||
self.creatures.smash_pushes.push(push);
|
||||
} else {
|
||||
self.smash_push_players(&push);
|
||||
}
|
||||
}
|
||||
/// `MaceItem.knockback` on the players but the attacker: the host unless
|
||||
/// it attacked, and every guest but the attacker.
|
||||
fn smash_push_players(&mut self, push: &SmashPush) {
|
||||
if push.attacker.is_some() {
|
||||
self.smash_push_player(push);
|
||||
}
|
||||
self.each_guest_hit_except(push.attacker, |g| g.smash_push_player(push));
|
||||
}
|
||||
/// The pushes guests' smashes put off.
|
||||
pub(super) fn flush_smash_pushes(&mut self) {
|
||||
for push in std::mem::take(&mut self.creatures.smash_pushes) {
|
||||
self.smash_push_players(&push);
|
||||
}
|
||||
}
|
||||
/// `MaceItem.knockback` on the player here (the host, or the guest swapped
|
||||
/// in): not a spectator, an ally of the attacker (`isAlliedTo`: the same
|
||||
/// team) or a creative player flying.
|
||||
fn smash_push_player(&mut self, push: &SmashPush) {
|
||||
if self.spectator()
|
||||
|| horizon_client::scoreboard::teams_allied(push.team.as_ref(), self.player_team())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let creative = self.creative;
|
||||
let Some(p) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if creative && p.flying {
|
||||
return;
|
||||
}
|
||||
let resistance = p.attribute("minecraft:knockback_resistance").unwrap_or(0.0);
|
||||
smash_push(
|
||||
push.center,
|
||||
push.heavy,
|
||||
p.position,
|
||||
&mut p.velocity,
|
||||
resistance,
|
||||
);
|
||||
}
|
||||
/// `ServerPlayer.checkFallDamage` after a smash on a grounded target:
|
||||
/// the landing block's particles, 50 for every block fallen (200 at most).
|
||||
|
||||
@@ -41,6 +41,8 @@ enum Kind {
|
||||
Crafter,
|
||||
/// `BrewingStandBlockEntity` (a `WorldlyContainer`).
|
||||
Brewing,
|
||||
/// `ChiseledBookShelfBlockEntity`: books only, one to a slot.
|
||||
Bookshelf,
|
||||
/// `ComposterBlock.getContainer` at this level (a `WorldlyContainerHolder`
|
||||
/// without a block entity): input below 7, empty at 7, bone meal at 8.
|
||||
Composter(i32),
|
||||
@@ -82,7 +84,7 @@ impl View {
|
||||
}
|
||||
}
|
||||
|
||||
fn dir_from_name(name: &str) -> Option<Direction> {
|
||||
pub(super) fn dir_from_name(name: &str) -> Option<Direction> {
|
||||
Some(match name {
|
||||
"down" => Direction::Down,
|
||||
"up" => Direction::Up,
|
||||
@@ -169,6 +171,7 @@ impl Containers<'_> {
|
||||
(BlockEntity::Furnace(_), _) => Kind::Furnace,
|
||||
(BlockEntity::Crafter(_), _) => Kind::Crafter,
|
||||
(BlockEntity::Brewing(_), _) => Kind::Brewing,
|
||||
(BlockEntity::ChiseledBookshelf(_), _) => Kind::Bookshelf,
|
||||
(_, "minecraft:hopper") => Kind::Hopper,
|
||||
_ => Kind::Generic,
|
||||
};
|
||||
@@ -250,6 +253,16 @@ impl Containers<'_> {
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
if view.kind == Kind::Bookshelf {
|
||||
// ListBackedContainer.canPlaceItem: `acceptsItemType` (the books)
|
||||
// and a free slot, as `getMaxStackSize` is 1.
|
||||
return self
|
||||
.items
|
||||
.item_tags
|
||||
.get("minecraft:bookshelf_books")
|
||||
.is_some_and(|m| m.contains(&*stack.item))
|
||||
&& self.get(view, slot).is_none();
|
||||
}
|
||||
if view.kind == Kind::Brewing {
|
||||
// BrewingStandBlockEntity.canPlaceItem
|
||||
return match slot {
|
||||
@@ -399,6 +412,45 @@ pub(super) fn container_signal(
|
||||
.map_or(0, |v| c.signal(&v))
|
||||
}
|
||||
|
||||
/// `ChiseledBookShelfBlock.getAnalogOutputSignal` and
|
||||
/// `ShelfBlock.getAnalogOutputSignal`, from the block entity Horizon keeps as
|
||||
/// it was loaded; `None` for any other block. `direction` points from the
|
||||
/// block to the comparator.
|
||||
pub(super) fn shelf_signal(
|
||||
world: &World,
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
state: BlockStateId,
|
||||
pos: BlockPos,
|
||||
direction: Direction,
|
||||
) -> Option<i32> {
|
||||
let info = registry.state(state)?;
|
||||
let chiseled = info.name == "minecraft:chiseled_bookshelf";
|
||||
if !chiseled && !info.name.ends_with("_shelf") {
|
||||
return None;
|
||||
}
|
||||
if chiseled {
|
||||
// The last slot touched, plus one (`lastInteractedSlot` starts at -1).
|
||||
return Some(match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
|
||||
Some(BlockEntity::ChiseledBookshelf(b)) => b.last_slot + 1,
|
||||
_ => 0,
|
||||
});
|
||||
}
|
||||
let tag = super::spill::saved_entity(world, [pos.x, pos.y, pos.z]);
|
||||
// Only read from the back: a bit for each of the three slots in use.
|
||||
let facing = info.properties.get("facing").and_then(|f| dir_from_name(f));
|
||||
if facing.map(Direction::opposite) != Some(direction) {
|
||||
return Some(0);
|
||||
}
|
||||
let items = horizon_client::save::decode_items(tag.as_ref().and_then(|t| t.get("Items")), 3);
|
||||
Some(
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(slot, item)| i32::from(item.is_some()) << slot)
|
||||
.sum(),
|
||||
)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn containers(&self) -> Containers<'_> {
|
||||
Containers {
|
||||
@@ -433,6 +485,7 @@ impl Game<'_> {
|
||||
if pos.1 == super::minecarts::CART_KEY_Y {
|
||||
return;
|
||||
}
|
||||
self.chiseled_bookshelf_synced(pos);
|
||||
// A double chest's other half changes with it.
|
||||
let halves: Vec<Pos> = std::iter::once(pos)
|
||||
.chain(self.chest_other_half(pos))
|
||||
@@ -595,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),
|
||||
@@ -626,46 +693,13 @@ impl Game<'_> {
|
||||
/// `HopperBlockEntity.suckInItems`: from the container above, or item entities.
|
||||
fn hopper_suck(&mut self, pos: Pos, hopper: &View) -> bool {
|
||||
let above = (pos.0, pos.1 + 1, pos.2);
|
||||
let c = self.containers();
|
||||
if let Some(source) = c.view(above, false) {
|
||||
if let Kind::Composter(level) = source.kind {
|
||||
if level != 8 {
|
||||
return false;
|
||||
}
|
||||
// tryTakeInItemFromSlot on the OutputContainer: removeItem calls
|
||||
// its setChanged, which empties the composter, and so does the
|
||||
// second setChanged after the move or, when the hopper has no
|
||||
// room, the setItem putting the bone meal back into the detached
|
||||
// container (whose bone meal is lost with it).
|
||||
let bone_meal = c.bone_meal.clone();
|
||||
let at = BlockPos::new(above.0, above.1, above.2);
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
self.world.modified.insert((above.0 >> 4, above.2 >> 4));
|
||||
return moved;
|
||||
}
|
||||
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
|
||||
for slot in source.slots_for_face(Some(Direction::Down)) {
|
||||
let c = self.containers();
|
||||
let Some(stack) = c.get(&source, slot) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(&source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
let Some(one) = self.take_one(&source, slot) else {
|
||||
continue;
|
||||
};
|
||||
match self.add_item(source_hopper, hopper, one, None) {
|
||||
None => {
|
||||
self.view_changed(&source);
|
||||
return true;
|
||||
}
|
||||
Some(rest) => self.put_back(&source, slot, rest),
|
||||
}
|
||||
}
|
||||
return false;
|
||||
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);
|
||||
let blocked = self.res.facts().get(above_state).full_collision
|
||||
@@ -695,6 +729,168 @@ impl Game<'_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.suck_item_entities(hopper, min, max)
|
||||
}
|
||||
/// The container half of `HopperBlockEntity.suckInItems`: one item from the
|
||||
/// container at `above`, through its bottom face; 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 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);
|
||||
}
|
||||
// tryTakeInItemFromSlot on the OutputContainer: removeItem calls
|
||||
// its setChanged, which empties the composter, and so does the
|
||||
// second setChanged after the move or, when the hopper has no
|
||||
// room, the setItem putting the bone meal back into the detached
|
||||
// container (whose bone meal is lost with it).
|
||||
let bone_meal = c.bone_meal.clone();
|
||||
let at = BlockPos::new(above.0, above.1, above.2);
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
self.world.modified.insert((above.0 >> 4, above.2 >> 4));
|
||||
return Some(moved);
|
||||
}
|
||||
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
|
||||
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 {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
let Some(one) = self.take_one(source, slot) else {
|
||||
continue;
|
||||
};
|
||||
match self.add_item(source_hopper, hopper, one, None) {
|
||||
None => {
|
||||
self.view_changed(source);
|
||||
return true;
|
||||
}
|
||||
Some(rest) => self.put_back(source, slot, rest),
|
||||
}
|
||||
}
|
||||
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.
|
||||
fn suck_item_entities(&mut self, hopper: &View, min: [f64; 3], max: [f64; 3]) -> bool {
|
||||
let mut i = 0;
|
||||
while i < self.dropped.len() {
|
||||
let b = self.dropped[i].bounds();
|
||||
@@ -719,6 +915,44 @@ impl Game<'_> {
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `MinecartHopper.tick`'s `tryConsumeItems` (`suckInItems`) for an enabled
|
||||
/// hopper minecart: one item from the container above, else the item
|
||||
/// entities over it or touching it, each tick without a cooldown.
|
||||
pub(super) fn hopper_cart_suck(&mut self, cart: &mut super::minecarts::Minecart) {
|
||||
// The cart's slots are edited as a container under the placeholder key
|
||||
// its menu uses; when that menu is open the placeholder is the live copy.
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, cart.id as i32);
|
||||
let (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
|
||||
// above the cart.
|
||||
let above = (
|
||||
p.x.floor() as i32,
|
||||
(p.y + 1.5).floor() as i32,
|
||||
p.z.floor() as i32,
|
||||
);
|
||||
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,
|
||||
[p.x - 0.5, p.y + 11.0 / 16.0, p.z - 0.5],
|
||||
[p.x + 0.5, p.y + 2.0, p.z + 0.5],
|
||||
),
|
||||
};
|
||||
if !took {
|
||||
// The cart's own box, inflated by 0.25 sideways.
|
||||
let b = cart.bounds();
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
self.suck_item_entities(
|
||||
&view,
|
||||
[lo.x - 0.25, lo.y, lo.z - 0.25],
|
||||
[hi.x + 0.25, hi.y, hi.z + 0.25],
|
||||
);
|
||||
}
|
||||
self.reclaim_items(key, open, &mut cart.items);
|
||||
}
|
||||
|
||||
// ---- dispensers and droppers ----
|
||||
|
||||
@@ -955,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 {
|
||||
@@ -1143,6 +1229,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1194,6 +1282,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1266,6 +1356,8 @@ impl Game<'_> {
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 312 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user